第9回: データソース管理とビュー作成 - 複数DB対応への第一歩
データソース機能の実装
実務では、複数のSQL Serverインスタンスや異なるデータベースに接続する必要がある。
例えば:
開発環境: localhost:1433
本番環境: prod-server:1433
テスト環境: test-server:1433
それぞれに異なる接続情報(サーバー、ポート、データベース名、認証情報)が必要だ。
データソース管理の設計
DataDrawersでは、データソースという概念を導入し、複数の接続先を管理できるようにした。
// prisma/schema.prisma
model DataSource {
id String @id @default(cuid())
name String // "開発環境"、"本番環境"など
type String // "mssql", "mysql", "postgres"
host String
port Int
database String
username String
password String // 実装では暗号化して保存
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
データソースの登録
ユーザーは、ダイアログから新しいデータソースを登録できる:
┌─────────────────────────────────────┐
│ データソースを追加 │
├─────────────────────────────────────┤
│ 名前: [開発環境___________________] │
│ 種類: [SQL Server ▼] │
│ ホスト: [localhost_______________] │
│ ポート: [1433____________________] │
│ DB名: [StudentDB________________] │
│ ユーザー: [sa___________________] │
│ パスワード: [••••••••••••••••••] │
│ │
│ [接続テスト] [キャンセル] [保存] │
└─────────────────────────────────────┘
接続テストボタンで、実際に接続できるか確認できる:
// app/api/datasources/test/route.ts
export async function POST(request: NextRequest) {
const { type, host, port, database, username, password } = await request.json();
try {
if (type === 'mssql') {
const pool = await sql.connect({
server: host,
port: port,
database: database,
user: username,
password: password,
options: { encrypt: true, trustServerCertificate: true }
});
await pool.request().query('SELECT 1');
await pool.close();
return NextResponse.json({ success: true, message: '接続成功' });
}
} catch (error) {
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : '接続失敗'
});
}
}
データソースの切り替え
登録したデータソースは、ヘッダーのドロップダウンで即座に切り替えられる:
┌──────────────────────────────────────────┐
│ DataDrawers [開発環境 ▼] [設定] [ヘルプ] │
│ ├ 開発環境 (現在) │
│ ├ 本番環境 │
│ └ テスト環境 │
└──────────────────────────────────────────┘
データソースを切り替えると、インポート済みのテーブル一覧が自動的に更新される。各テーブルはどのデータソースからインポートされたかを記録している:
// prisma/schema.prisma
model ImportedTable {
id String @id
sqlTableName String
displayName String
objectType String
columns String // JSON
importedAt DateTime
lastSyncAt DateTime?
customColumns String? // JSON
dataSourceId String? // データソースID
dataSource DataSource? @relation(fields: [dataSourceId], references: [id])
}
これにより、「このテーブルは開発環境からインポートした」という情報を保持できる。
マルチDB対応の意義
データソース機能により、以下が実現できる:
-
環境の切り替え
- 開発→本番へワンクリックで切り替え
- それぞれの環境で独立してデータ管理
-
複数プロジェクトの管理
- プロジェクトA、プロジェクトBのデータを同じツールで管理
- データソースを切り替えるだけで操作対象を変更
-
将来のMySQL/PostgreSQL対応
- データソースの
typeフィールドで種類を管理 - アダプターパターンで実装を切り替え
- データソースの
桐の「補集合」とは
桐(Kiri)には、独特の機能がいくつかある。その中でも特に便利なのが補集合だ。
例えば、こんなケース:
全生徒1,000人のうち、面談済みの生徒が800人いる。 未面談の200人のリストを作りたい。
普通のデータベースソフトなら:
- 全生徒のリストを開く
- フィルタで「面談済み = はい」を適用 → 800人が表示される
- ...ここで詰まる。「未面談の200人」をどうやって抽出する?
桐なら:
- 全生徒のリストを開く
- フィルタで「面談済み = はい」を適用 → 800人が表示される
- 「補集合」ボタンをクリック → 未面談の200人が表示される
これだけ。
補集合とは、「選択した行以外」を取得する機能。桐では当たり前のように使われていたが、他のデータベースソフトではあまり見かけない。
補集合の実装(SQLite版)
DataDrawersでも、この機能を実装した。ただし、IndexedDBからSQLiteに移行したため、実装方法が変わった。
IndexedDB版(旧)
IndexedDB版では、ブラウザに読み込まれたデータ内で補集合を計算していた:
// 旧実装(IndexedDB版)
const allData = await db.tableData.where('tableId').equals(tableId).toArray();
const currentIds = currentFilteredData.map(row => row.id);
const complementData = allData.filter(row => !currentIds.includes(row.id));
問題点: ブラウザに読み込んだデータが10万件だった場合、残りの114万件が計算対象外になる。
SQLite版(新)
SQLite版では、サーバー側のSQLiteデータベース全体から補集合を計算する:
// components/toolbar.tsx
const handleComplementSet = async () => {
if (filterSteps.length === 0 || !currentTable) return;
setIsCalculatingComplement(true);
try {
// 現在のフィルタ結果のIDを取得
const lastStep = filterSteps[filterSteps.length - 1];
const idField = lastStep.column;
const currentIds = currentFilteredData.map(row => row[idField]);
console.log('[補集合] 現在のフィルタ結果:', currentIds.length, '件');
// SQLite APIを呼び出して補集合を計算
const response = await fetch(`/api/tables/${encodeURIComponent(currentTable)}/complement`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
columnName: idField,
currentIds: currentIds,
previousFilterIds: null // 全データから計算
})
});
const result = await response.json();
if (result.success) {
console.log('[補集合] 補集合のID数:', result.complementIds.length);
// 最後のステップを削除してから補集合ステップを追加
removeFilterStepFromCurrentTable(filterSteps[filterSteps.length - 1].id);
addFilterStepToCurrentTable({
id: generateId(),
column: idField,
operator: 'IN',
value: result.complementIds,
resultCount: result.complementIds.length,
timestamp: new Date()
});
}
} catch (error) {
console.error('[補集合] エラー:', error);
} finally {
setIsCalculatingComplement(false);
}
};
APIルート側の処理
// app/api/tables/[tableId]/complement/route.ts
export async function POST(request: NextRequest, { params }: { params: { tableId: string } }) {
const { columnName, currentIds } = await request.json();
// 現在のIDをSetに変換(高速検索用)
const currentIdSet = new Set(currentIds.map(id => String(id)));
// SQLiteテーブルの全データから補集合を計算
const query = `SELECT "${columnName}" FROM "${tableId}"`;
const allRows: any[] = await prisma.$queryRawUnsafe(query);
console.log('[API complement] テーブル総件数:', allRows.length);
// 補集合を計算(現在のIDに含まれないもの)
const complementIds = allRows
.map(row => row[columnName])
.filter(value => value !== null && value !== undefined && !currentIdSet.has(String(value)));
// 重複を除去
const uniqueComplementIds = [...new Set(complementIds)];
console.log('[API complement] 補集合件数:', uniqueComplementIds.length);
return NextResponse.json({
success: true,
complementIds: uniqueComplementIds,
stats: {
baseDataCount: allRows.length,
currentCount: currentIds.length,
complementCount: uniqueComplementIds.length
}
});
}
実行例
124万件のテーブルで補集合を計算した例:
[補集合] 現在のフィルタ結果: 1,150,000 件
[API complement] テーブル総件数: 1,240,703
[API complement] 補集合件数: 90,703
[補集合] 補集合のID数: 90,703
所要時間: 約3秒
124万件のデータから、Set を使った高速検索で補集合を計算している。
補集合の実用例
ケース1: 未提出者リスト
1. 全生徒: 1,000人
2. 提出済みで絞り込み → 850人
3. 補集合 → 未提出: 150人 ✅
ケース2: 未登録データの抽出
1. 全レコード: 500,000件
2. カテゴリ = 'A' OR 'B' OR 'C' で絞り込み → 480,000件
3. 補集合 → カテゴリ未設定: 20,000件 ✅
ケース3: 複数条件での絞り込み後の補集合
1. 全データ: 1,240,703件
2. 学年 = 1年 → 300,000件
3. 在籍 = '在籍中' → 280,000件
4. 補集合 → 1年生の退学者・休学者: 20,000件 ✅
このように、「〇〇以外」を抽出したいケースは実務で頻繁にある。桐の補集合機能は、この操作を直感的にできるようにした画期的な機能だった。
併合機能の現状
もう一つの桐の核心機能が**併合(マージ)**だ。
併合とは
他のテーブルから、特定のカラムのデータを転記する機能。
例:
【生徒マスタ】
ID | 氏名 | 学年
---+--------+-----
1 | 田中太郎 | 1
2 | 佐藤花子 | 2
【出席データ】(併合前)
ID | student_id | student_name | 出席日
---+------------+--------------+--------
1 | 1 | null | 2026-01-10
2 | 2 | null | 2026-01-11
【出席データ】(併合後)
ID | student_id | student_name | 出席日
---+------------+--------------+--------
1 | 1 | 田中太郎 | 2026-01-10 ← 生徒マスタから転記
2 | 2 | 佐藤花子 | 2026-01-11 ← 生徒マスタから転記
SQLでいう JOIN + UPDATE の組み合わせだが、桐では専用のUIで簡単に設定できる。
現在の状態: ⚠️ 一時無効
併合機能は、当初 IndexedDB(Dexie.js)を前提に実装していた:
// components/merge-dialog.tsx (旧実装)
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db'; // Dexie
const importedTables = useLiveQuery(() => db.importedTables.toArray(), []);
しかし、SQLiteへの移行により、以下の理由で一時的に無効化している:
-
データ取得方法の変更
- IndexedDB:
useLiveQuery()でリアクティブに取得 - SQLite: API経由で fetch() が必要
- IndexedDB:
-
併合処理の実行方法
- IndexedDB: ブラウザ内で JOIN してデータを更新
- SQLite: サーバー側で SQL の JOIN + UPDATE を実行すべき
-
トランザクション管理
- IndexedDB: Dexie のトランザクション
- SQLite: Prisma のトランザクション
再実装の方針
SQLite版の併合機能は、以下のように実装予定:
// app/api/tables/[tableId]/merge/route.ts (予定)
export async function POST(request: NextRequest) {
const { sourceTableId, targetTableId, joinConditions, transferColumns, updateMode } = await request.json();
await prisma.$transaction(async (tx) => {
// 1. ソーステーブルとターゲットテーブルを JOIN
// 2. 転記カラムのデータを取得
// 3. ターゲットテーブルを UPDATE
const query = `
UPDATE "${targetTableId}"
SET ${transferColumns.map(col => `"${col.target}" = source."${col.source}"`).join(', ')}
FROM (
SELECT * FROM "${sourceTableId}"
) AS source
WHERE ${joinConditions.map(cond => `"${targetTableId}"."${cond.target}" = source."${cond.source}"`).join(' AND ')}
`;
await tx.$executeRawUnsafe(query);
});
}
現在は基本機能の完成を優先しているため、併合機能の再実装は後回しになっている。
ビュー作成機能
併合と並んで実装を完了したのが、ビュー作成機能だ。
ビュー作成とは
複数のテーブルを JOIN して、仮想的な統合テーブルを作成する機能。SQLの知識がなくても、直感的な操作で複雑な結合クエリを作成できる。
例:
【生徒テーブル】+ 【出席テーブル】+ 【成績テーブル】
↓ JOIN
【生徒の出席・成績統合ビュー】
ビジュアルエディタによる直感的な操作
DataDrawersでは、React Flowを使用したビジュアルエディタを実装した:
┌──────────────┐
│ 生徒 │──┐
│ • id │ │ JOIN (id = student_id)
│ • 氏名 │ │
└──────────────┘ │
├─→ 統合ビュー
┌──────────────┐ │
│ 出席 │──┘
│ • student_id│ │ JOIN (student_id = student_id)
│ • 出席日 │ │
└──────────────┘ │
├─→ さらに結合
┌──────────────┐ │
│ 成績 │──┘
│ • student_id│
│ • 科目 │
│ • 点数 │
└──────────────┘
3つ以上のテーブルも対応: 実務では生徒・出席・成績・クラブ活動など、4〜5つのテーブルを結合することも珍しくない。DataDrawersは無制限にテーブルを追加できる。
ビュー作成の主な機能
-
ドラッグ&ドロップでテーブル追加
- サイドバーからテーブルをキャンバスにドラッグ
- 自動的にカラム一覧が表示される
-
視覚的な結合設定
- テーブル間を線で結んで JOIN 条件を設定
- INNER / LEFT / RIGHT / FULL JOIN を選択可能
- 複数の結合条件(AND条件)にも対応
-
カラム選択
- チェックボックスで表示カラムを選択
- エイリアス(表示名)の設定
- カラムの並び順を自由に変更
-
WHERE条件ビルダー
- 絞り込み条件を GUI で設定
- AND / OR の複雑な条件にも対応
- リアルタイムでSQL生成
-
ORDER BY設定
- 並び順を指定
- 複数カラムでのソートに対応
-
リアルタイムプレビュー
- ビュー定義を変更すると即座に結果が表示される
- 最初の100件をプレビュー表示
実装例
// ビュー定義の構造
interface ViewDefinition {
id: string;
name: string;
tables: Array<{
tableId: string;
alias: string; // t1, t2, t3...
}>;
joins: Array<{
leftTable: string;
rightTable: string;
type: 'INNER' | 'LEFT' | 'RIGHT' | 'FULL';
conditions: Array<{
leftColumn: string;
operator: '=' | '!=' | '>' | '<' | '>=' | '<=';
rightColumn: string;
}>;
}>;
columns: Array<{
tableAlias: string;
columnName: string;
displayName?: string;
}>;
filters?: FilterStep[];
orderBy?: Array<{
tableAlias: string;
column: string;
direction: 'ASC' | 'DESC';
}>;
}
ビュー実行エンジン
ビュー定義から動的にSQLを生成し、SQLiteで実行:
// lib/view-executor.ts
export async function executeView(viewId: string, limit: number = 100000): Promise<any[]> {
// 1. ビュー定義を取得
const viewDef = await getViewDefinition(viewId);
// 2. SQLを動的生成
const sql = generateViewSQL(viewDef, limit);
// 3. SQLiteで実行
const result = await prisma.$queryRawUnsafe(sql);
return result;
}
生成されるSQLの例:
SELECT
t1.id AS "t1_id",
t1.氏名 AS "t1_氏名",
t2.出席日 AS "t2_出席日",
t3.点数 AS "t3_点数"
FROM table_1234 AS t1
INNER JOIN table_5678 AS t2 ON t1.id = t2.student_id
LEFT JOIN table_9012 AS t3 ON t1.id = t3.student_id
WHERE t1.学年 = 1 AND t2.出席日 >= '2026-01-01'
ORDER BY t1.id ASC, t2.出席日 DESC
LIMIT 100000;
ビューの保存と再利用
作成したビューは保存され、通常のテーブルと同じように操作できる:
- データ表示
- 絞り込み
- ソート
- エクスポート
ビューは仮想的なテーブルなので、元のテーブルが更新されると、ビューの結果も自動的に更新される。
補集合機能の価値
補集合は、一見地味な機能だが、実務では非常に重要だ。
従来のSQLでは:
-- 未面談者を抽出
SELECT * FROM 生徒
WHERE id NOT IN (
SELECT student_id FROM 面談記録
);
このクエリを書くには:
- SQLの知識が必要
- サブクエリの理解が必要
- パフォーマンスの考慮が必要
DataDrawersでは:
- 面談済みで絞り込み
- 補集合ボタンをクリック
これだけ。
SQLを知らない事務職員でも、直感的に操作できる。これが桐の設計思想であり、DataDrawersが目指すユーザー体験だ。
次回、DataDrawersの現状と今後の展望についてまとめる。