mcpField
H
how-to-build-a-graph-database-with-flutter

how-to-build-a-graph-database-with-flutter

github.com/rodydavisデータ・データベース6050 回インストール2 週間前に更新
リポジトリ

how-to-build-a-graph-database-with-flutter について

Flutter 開発者向けに、SQLite と drift を使ったグラフデータベースの構築方法を解説するチュートリアルです。ノードとエッジの保存、全文検索、走査クエリ、グラフの可視化を扱います。

主な機能

  1. 01SQLite でグラフのノードとエッジを保存
  2. 02drift によるデータベース定義
  3. 03FTS5 によるノード本文の検索
  4. 04入方向・出方向のエッジ検索
  5. 05再帰クエリによる関係の走査
  6. 06graphview によるグラフ表示

活用シーン

  1. 01ソーシャルネットワークアプリの関係モデル化
  2. 02ゲームのプレイヤーとアイテムの関連付け
  3. 03ブログ記事とコメントの接続
  4. 04Flutter アプリでのグラフデータ検索・走査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rodydavis/skills --skill how-to-build-a-graph-database-with-flutter

インストール後、Claude Code で/how-to-build-a-graph-database-with-flutterを入力して読み込まれたか確認します。

詳細

Flutter でグラフデータベースを構築する方法

この記事では、Flutter を使ってグラフデータベースを作成し、使用する方法を説明します。

要約 最終的なソースコードはこちら、オンラインのデモはこちらです。

前提条件

Flutter がインストールされ、セットアップされていること(必要であれば、こちらの記事を参照してください)。

SQLite の基本的な知識。

グラフデータベースの基本的な知識(詳しく知りたい場合は、こちらの動画を参照してください)。

概要

まず、ほかのストレージの選択肢がある中で、なぜグラフデータベースが必要なのでしょうか?

キーと値のストア、ドキュメントストア、リレーショナルデータベースではだめなのでしょうか?

その答えは、解決しようとしている問題によって異なります。

グラフデータベースは、データ間の関係をモデル化するのに適しています。

例をいくつか挙げます。

  • ソーシャルネットワークアプリでは、ユーザーと投稿の関係をモデル化できます
  • ゲームでは、プレイヤーとアイテムの関係をモデル化できます
  • ブログでは、投稿とコメントの関係をモデル化できます

可能性は無限です。

コレクションごとにテーブルにデータを格納するのではなく、SQLite の拡張機能をいくつか追加して扱いやすくしたうえで、ノードテーブルとエッジテーブルを使ってデータをグラフとして格納します。

こちらのページでは、詳細を説明し、構築を目指すものを紹介しています。

はじめに

まず、新しい Flutter プロジェクトを作成します。

mkdir flutter_graph_database
cd flutter_graph_database
flutter create .

プロジェクトを作成したら、お好みのコードエディターで開きます。

code .

データベースの作成

データベースの作成には、drift パッケージを使用します。

pubspec.yaml ファイルを次のように更新します。

name: flutter_graph_database
description: A new Flutter package project.
version: 0.0.1
publish_to: none

environment:
  sdk: ">=2.19.0-238.0.dev <3.0.0"
  flutter: ">=1.17.0"

dependencies:
  flutter:
    sdk: flutter
  drift: ^2.1.0
  sqlite3_flutter_libs: ^0.5.5
  http: ^0.13.5
  path_provider: ^2.0.0
  path: ^1.8.2
  sqlite3: ^1.7.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.2.0
  drift_dev: ^2.1.0

flutter:
データベース接続

次に、データベースを作成します。

lib/database/connection/unsupported.dart に新しいファイルを作成し、次のように更新します。

import 'package:drift/drift.dart';
import 'package:drift/native.dart';

DatabaseConnection connect(
  String dbName, {
  bool useWebWorker = false,
  bool logStatements = false,
}) {
  return DatabaseConnection(NativeDatabase.memory(
    logStatements: logStatements,
  ));
}

lib/database/connection/native.dart に新しいファイルを作成し、次のように更新します。

import 'dart:io';
import 'dart:isolate';

import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

DatabaseConnection connect(
  String dbName, {
  bool useWebWorker = false,
  bool logStatements = false,
}) {
  return DatabaseConnection.delayed(Future.sync(() async {
    final appDir = await getApplicationDocumentsDirectory();
    final dbPath = p.join(appDir.path, dbName);

    final receiveDriftIsolate = ReceivePort();
    await Isolate.spawn(_entrypointForDriftIsolate,
        _IsolateStartRequest(receiveDriftIsolate.sendPort, dbPath));

    final driftIsolate = await receiveDriftIsolate.first as DriftIsolate;
    return driftIsolate.connect();
  }));
}

class _IsolateStartRequest {
  final SendPort talkToMain;
  final String databasePath;

  _IsolateStartRequest(this.talkToMain, this.databasePath);
}

void _entrypointForDriftIsolate(_IsolateStartRequest request) {
  final databaseImpl = NativeDatabase(
    File(request.databasePath),
    logStatements: false,
  );

  final driftServer = DriftIsolate.inCurrent(
    () => DatabaseConnection(databaseImpl),
  );

  request.talkToMain.send(driftServer);
}

lib/database/connection/web.dart に新しいファイルを作成し、次のように更新します。

import 'dart:async';

// ignore: avoid_web_libraries_in_flutter
import 'dart:html';

import 'package:drift/drift.dart';
import 'package:drift/remote.dart';
import 'package:drift/web.dart';
import 'package:drift/wasm.dart';
import 'package:http/http.dart' as http;
import 'package:sqlite3/wasm.dart';

DatabaseConnection connect(
  String dbName, {
  bool useWebWorker = false,
  bool logStatements = false,
}) {
  if (useWebWorker) {
    final worker = SharedWorker('shared_worker.dart.js');
    return remote(worker.port!.channel());
  } else {
    return DatabaseConnection.delayed(Future.sync(() async {
      final response = await http.get(Uri.parse('sqlite3.wasm'));
      final fs = await IndexedDbFileSystem.open(dbName: '/db/');
      final path = '/drift/db/$dbName';
      final sqlite3 = await WasmSqlite3.load(
        response.bodyBytes,
        SqliteEnvironment(fileSystem: fs),
      );
      final databaseImpl = WasmDatabase(
        sqlite3: sqlite3,
        path: path,
        fileSystem: fs, // <- this is required but not documented
        logStatements: logStatements,
      );
      return DatabaseConnection(databaseImpl);
    }));
  }
}

lib/database/connection/connection.dart に新しいファイルを作成し、次のように更新します。

export 'unsupported.dart'
    if (dart.library.js) 'web.dart'
    if (dart.library.ffi) 'native.dart';
データベースの SQL ファイル
スキーマ

lib/database/sql/schema.drift に新しいファイルを作成し、次のように更新します。

CREATE TABLE IF NOT EXISTS nodes (
    body TEXT,
    id   TEXT GENERATED ALWAYS AS (json_extract(body, '$.id')) VIRTUAL NOT NULL UNIQUE
);

CREATE INDEX IF NOT EXISTS id_idx ON nodes(id);

CREATE TABLE IF NOT EXISTS edges (
    source     TEXT,
    target     TEXT,
    properties TEXT,
    UNIQUE(source, target, properties) ON CONFLICT REPLACE,
    FOREIGN KEY(source) REFERENCES nodes(id),
    FOREIGN KEY(target) REFERENCES nodes(id)
);

CREATE INDEX IF NOT EXISTS source_idx ON edges(source);
CREATE INDEX IF NOT EXISTS target_idx ON edges(target);

ID 列は、body 列から生成される仮想列です。これは、JSON の body 列を解析せずに、ID でデータベースを検索できるようにするためです。

クエリ

lib/database/sql/queries.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

getAllNodes: 
    SELECT * FROM nodes;
getAllEdges: 
    SELECT * FROM edges;
エッジの削除

lib/database/sql/delete-edge.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

deleteEdge:
    DELETE FROM edges 
    WHERE source = ? OR target = ?;
ノードの削除

lib/database/sql/delete-node.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

deleteNode: 
    DELETE FROM nodes 
    WHERE id = ?;
エッジの挿入

lib/database/sql/insert-edge.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

insertEdge(:source as TEXT, :target as TEXT, :body as TEXT): 
    INSERT INTO edges VALUES(:source, :target, json(:body));
インバウンドエッジの検索

lib/database/sql/search-edges-inbound.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

searchEdgesInbound: 
    SELECT * FROM edges 
    WHERE source = ?;
アウトバウンドエッジの検索

lib/database/sql/search-edges-outbound.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

searchEdgesOutbound: 
    SELECT * FROM edges 
    WHERE target = ?;
エッジの検索

lib/database/sql/search-edges.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

searchEdges: 
    SELECT * FROM edges WHERE source = ? 
    UNION 
    SELECT * FROM edges WHERE target = ?;
ID によるノードの検索

lib/database/sql/search-node-by-id.drift に新しいファイルを作成し、次のように更新します。

import 'schema.drift';

searchNodeById: 
    SELECT body FROM nodes 
    WHERE id = ?;
ノードの検索

lib/database/sql/search-node.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

-- Create a text index of entries, see https://www.sqlite.org/fts5.html#external_content_tables
CREATE VIRTUAL TABLE node_entries USING fts5 (
    body,
    content=nodes,
    content_rowid=id
);

-- Triggers to keep entries and fts5 index in sync.
CREATE TRIGGER nodes_insert AFTER INSERT ON nodes BEGIN
  INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;

CREATE TRIGGER nodes_delete AFTER DELETE ON nodes BEGIN
  INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', old.id, old.body);
END;

CREATE TRIGGER nodes_update AFTER UPDATE ON nodes BEGIN
  INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', new.id, new.body);
  INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body);
END;

-- Full text search query.
searchNode: SELECT r.** FROM node_entries
    INNER JOIN nodes r ON r.id = node_entries.rowid
    WHERE node_entries MATCH :query
    ORDER BY rank;

ここでは、fts5 拡張機能を使用して、全文検索インデックスを作成しています。これは、ノードの本文テキストを検索できる非常に強力な機能です。

インバウンドのトラバース

lib/database/sql/traverse-inbound.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverseInbound(:source AS TEXT): 
  WITH RECURSIVE traverse(id) AS (
  SELECT :source
  UNION
  SELECT source FROM edges JOIN traverse ON target = id
) SELECT id FROM traverse;
アウトバウンドのトラバース

lib/database/sql/traverse-outbound.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverseOutbound(:source AS TEXT): 
  WITH RECURSIVE traverse(id) AS (
  SELECT :source
  UNION
  SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
本文を含むインバウンドのトラバース

lib/database/sql/traverse-with-bodies-inbound.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverseWithBodiesInbound(:source AS TEXT): 
  WITH RECURSIVE traverse(x, y, obj) AS (
  SELECT :source, '()', '{}'
  UNION
  SELECT id, '()', body FROM nodes JOIN traverse ON id = x
  UNION
  SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
) SELECT x, y, obj FROM traverse;
本文を含むアウトバウンドのトラバース

lib/database/sql/traverse-with-bodies-outbound.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverseWithBodiesOutbound(:source AS TEXT): 
  WITH RECURSIVE traverse(x, y, obj) AS (
  SELECT :source, '()', '{}'
  UNION
  SELECT id, '()', body FROM nodes JOIN traverse ON id = x
  UNION
  SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
本文のトラバース

lib/database/sql/traverse-bodies.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverseWithBodies(:source AS TEXT): 
  WITH RECURSIVE traverse(x, y, obj) AS (
  SELECT :source, '()', '{}'
  UNION
  SELECT id, '()', body FROM nodes JOIN traverse ON id = x
  UNION
  SELECT source, '<-', properties FROM edges JOIN traverse ON target = x
  UNION
  SELECT target, '->', properties FROM edges JOIN traverse ON source = x
) SELECT x, y, obj FROM traverse;
トラバース

lib/database/sql/traverse.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

traverse(:source AS TEXT): 
  WITH RECURSIVE traverse(id) AS (
  SELECT :source
  UNION
  SELECT source FROM edges JOIN traverse ON target = id
  UNION
  SELECT target FROM edges JOIN traverse ON source = id
) SELECT id FROM traverse;
ノードの更新

lib/database/sql/update-node.drift に新しいファイルを作成し、以下の内容に更新します。

import 'schema.drift';

updateNode: 
    UPDATE nodes SET body = json(?) 
    WHERE id = ?;
データベースのセットアップ

lib/database/database.dart に新しいファイルを作成し、以下の内容に更新します。

import 'dart:convert';

import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';

import 'connection/connection.dart' as impl;

part 'database.g.dart';

@DriftDatabase(include: {
  'sql/schema.drift',
  'sql/queries.drift',
  'sql/delete-edge.drift',
  'sql/delete-node.drift',
  'sql/insert-edge.drift',
  'sql/insert-node.drift',
  'sql/search-edges-inbound.drift',
  'sql/search-edges-outbound.drift',
  'sql/search-edges.drift',
  'sql/search-node-by-id.drift',
  'sql/search-node.drift',
  'sql/traverse-inbound.drift',
  'sql/traverse-outbound.drift',
  'sql/traverse-with-bodies-inbound.drift',
  'sql/traverse-with-bodies-outbound.drift',
  'sql/traverse-with-bodies.drift',
  'sql/traverse.drift',
  'sql/update-node.drift',
})
class GraphDatabase extends _$GraphDatabase {
  GraphDatabase({
    String dbName = 'graph_db.db',
    DatabaseConnection? connection,
    bool useWebWorker = false,
    bool logStatements = false,
  }) : super.connect(
          connection ??
              impl.connect(
                dbName,
                useWebWorker: useWebWorker,
                logStatements: logStatements,
              ),
        );

  @override
  int get schemaVersion => 1;

  /// Helper method to add graph data from json
  Future<void> addGraphData(
    Map<String, dynamic> data, {
    bool shouldBatch = false,
  }) {
    return transaction(() async {
      try {
        final localNodes = data['nodes'] as List<dynamic>;
        final localEdges = data['edges'] as List<dynamic>;
        // Update nodes
        for (final node in localNodes) {
          final id = node['id'] as String?;
          if (id != null) {
            final current = await searchNodeById(id).getSingleOrNull();
            final body = jsonEncode(node);
            if (current != null) {
              await updateNode(id, body);
            } else {
              await insertNode(body);
            }
          }
        }
        // Update edges
        for (final edge in localEdges) {
          final source = edge['from'] ?? edge['source'] as String?;
          final target = edge['to'] ?? edge['target'] as String?;
          if (source != null && target != null) {
            final body = jsonEncode(edge);
            await insertEdge(source, target, body);
          }
        }
      } catch (e) {
        debugPrint('Error adding graph data: $e');
      }
    });
  }

  Future<void> deleteAll() {
    return transaction(() async {
      try {
        await deleteAllEdges();
        await deleteAllNodes();
      } catch (e) {
        debugPrint('Error clearing graph data: $e');
      }
    });
  }

  Future<void> deleteAllEdges() {
    return transaction(() async {
      final edges = await getAllEdges().get();
      for (final edge in edges) {
        await deleteEdge(edge.source, edge.target);
      }
    });
  }

  Future<void> deleteAllNodes() {
    return transaction(() async {
      final nodes = await getAllNodes().get();
      for (final node in nodes) {
        await deleteNode(node.id);
      }
    });
  }
}

build.yaml に新しいファイルを作成し、以下の内容に更新します。

targets:
  $default:
    sources:
      - lib/**
      - web/**
      - "tool/**"
      - pubspec.yaml
      - lib/$lib$
      - $package$
    builders:
      drift_dev:
        options:
          sql:
            dialect: sqlite
            options:
              version: "3.38"
              modules:
                - json1
                - fts5
          generate_connect_constructor: true
          apply_converters_on_variables: true
          generate_values_in_copy_with: true
          scoped_dart_components: true

次に、以下のコマンドを実行してデータベースファイルを生成します。

flutter pub run build_runner build --delete-conflicting-outputs

データベースへの接続

pubspec.yaml ファイルに新しい依存関係を追加します。

flutter pub add graphview

これはグラフの可視化に使用します。

lib/main.dart に新しいファイルを作成し、以下の内容に更新します。

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_graph_database/flutter_graph_database.dart' as db;
import 'package:graphview/GraphView.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Graph Database',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: const Example(),
    );
  }
}

class Example extends StatefulWidget {
  const Example({Key? key}) : super(key: key);

  @override
  State<Example> createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  final database = db.GraphDatabase();
  Graph graph = Graph();
  Algorithm builder = FruchtermanReingoldAlgorithm();

  final nodes = <String, db.Node>{};
  bool loaded = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => loadData());
  }

  @override
  void reassemble() {
    super.reassemble();
    // Needed to reset graph on hot reload
    loadData();
  }

  void setLoadedState(bool value) {
    if (mounted) {
      setState(() {
        loaded = value;
      });
    }
  }

  Future<void> addDummyData() async {
    // Load example data
    try {
      // Optionally reset data
      await database.deleteAll();
      // Add example data to database
      await database.addGraphData({
        "nodes": [
          {"id": '1', "label": 'circle'},
          {"id": '2', "label": 'ellipse'},
          {"id": '3', "label": 'database'},
          {"id": '4', "label": 'box'},
          {"id": '5', "label": 'diamond'},
          {"id": '6', "label": 'dot'},
          {"id": '7', "label": 'square'},
          {"id": '8', "label": 'triangle'},
          {"id": '9', "label": "star"},
        ],
        "edges": [
          {"from": '1', "to": '2'},
          {"from": '2', "to": '3'},
          {"from": '2', "to": '4'},
          {"from": '2', "to": '5'},
          {"from": '5', "to": '6'},
          {"from": '5', "to": '7'},
          {"from": '6', "to": '8'},
          {"from": '2', "to": '8'},
          {"from": '1', "to": '8'},
          {"from": '1', "to": '7'},
          {"from": '1', "to": '6'},
          {"from": '1', "to": '5'},
          {"from": '1', "to": '4'},
          {"from": '1', "to": '3'},
          {"from": '1', "to": '9'},
          {"from": '9', "to": '8'},
          {"from": '9', "to": '5'},
          {"from": '9', "to": '3'},
        ]
      });
      loadData();
    } catch (e) {
      debugPrint('Error loading example data: $e');
    }
  }

  Future<void> loadData() async {
    setLoadedState(false);

    final nodeMap = <String, Node>{};
    this.nodes.clear();
    graph = Graph();
    builder = FruchtermanReingoldAlgorithm();

    // Load graph data
    final nodes = await database.getAllNodes().get();
    final edges = await database.getAllEdges().get();

    for (final node in nodes) {
      final newNode = Node.Id(node.id);
      nodeMap[node.id] = newNode;
      this.nodes[node.id] = node;
      graph.addNode(newNode);
    }
    for (final edge in edges) {
      final source = nodeMap[edge.source];
      final target = nodeMap[edge.target];
      if (source != null && target != null) {
        graph.addEdge(source, target);
      }
    }

    setLoadedState(true);
  }

  Widget buildNode(Node node) {
    final dbNode = nodes[node.key!.value];
    final data = jsonDecode(dbNode?.body ?? '{}') as Map<String, dynamic>;
    final label = data['label'] ?? '';
    return SizedBox(
      width: 80,
      height: 80,
      child: Center(
        child: Text(
          label,
          textAlign: TextAlign.center,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Graph Database'),
        actions: [
          IconButton(
            icon: const Icon(Icons.restore),
            onPressed: addDummyData,
          ),
        ],
      ),
      body: !loaded
          ? const Center(child: CircularProgressIndicator())
          : nodes.isEmpty
              ? const Center(child: Text('No Data Loaded'))
              : LayoutBuilder(builder: (context, dimens) {
                  return SizedBox.expand(
                    child: InteractiveViewer(
                      constrained: false,
                      boundaryMargin: EdgeInsets.symmetric(
                        horizontal: dimens.maxWidth * 0.75,
                        vertical: dimens.maxHeight * 0.75,
                      ),
                      minScale: 0.01,
                      maxScale: 5.6,
                      child: GraphView(
                        key: UniqueKey(),
                        graph: graph,
                        algorithm: builder,
                        paint: Paint()
                          ..color = Colors.green
                          ..strokeWidth = 1
                          ..style = PaintingStyle.stroke,
                        builder: buildNode,
                      ),
                    ),
                  );
                }),
    );
  }
}

Flutter アプリを実行すると、以下のように表示されます。

README の画像

まとめ

Flutter でグラフデータベースを構築する方法について詳しく知りたい場合は、ソースコードをご覧ください。

コメント