【WPF】DataGridのデータをページネーションで制御する②

【WPF】DataGridのデータをページネーションで制御する②

はじめに

前回の記事ではページネーションのスタイルの編集、ViewModel で管理するところまで実装しました。

今回は SQLite に格納されたデータを制御してページネーションに反映できるようにしていきます。

ライブラリのインストール

必要なライブラリをインストールします。

[ツール] > [コマンドライン] > [開発用 PowerShell] より PowerShell を起動します。

install-for-nuget.png

以下のコマンドを実行します。

dotnet add package Dapper `
&& dotnet add package Microsoft.Data.SqlClient `
&& dotnet add package Microsoft.Data.Sqlite

IUsersRepository と Users を修正する

現在のIUsersRepository<T>には全てのデータを取得するメソッドしかなかったので、pageNumberpageSizeを指定できるメソッドを追加します。

"Repositories\IUsersRepository.cs"
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MVVM.DataGridPagination.Repositories
{
    public interface IUsersRepository<T>
    {
        Task<IEnumerable<T>> GetUsersAsync();

+       Task<IEnumerable<T>> GetPaginateAsync(int pageNumber, int pageSize); // 追加
    }
}

実装クラス側で SQL 文等を記述します。

"Infrastructure\Implements\Users.cs"
using Dapper;
using MVVM.DataGridPagination.Entities;
using MVVM.DataGridPagination.Infrastructure.Helpers;
using MVVM.DataGridPagination.Repositories;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MVVM.DataGridPagination.Infrastructure.Implements
{
    public sealed class Users : IUsersRepository<UserEntity>
    {
        /// <summary>
        /// ユーザーをページネーションして非同期的に取得する
        /// </summary>
        /// <param name="pageNumber">取得するページ番号(1から始まる)</param>
        /// <param name="pageSize">1ページあたりに取得するユーザー数</param>
        /// <returns>ユーザーエンティティ</returns>
        public async Task<IEnumerable<UserEntity>> GetPaginateAsync(int pageNumber, int pageSize)
        {
            var parameters = new DynamicParameters();

            // @PageSize や @Offset: パラメータ(プレースホルダー)
            // LIMIT @PageSize: 指定された pageSize 分の数のデータを返す
            // OFFSET @Offset: 指定された offset 分のデータを飛ばす
            // ※pageSize は常に500が渡ってくる
            var sql = @"SELECT * FROM Users ORDER BY Id LIMIT @PageSize OFFSET @Offset";

            // データを取得する位置を計算する
            // 例: (2 - 1) * 500 = 500;
            //  → 500行分のデータを飛ばして501行目からデータを取得する
            var offset = (pageNumber - 1) * pageSize;

            // パラメータにバインドする値を指定
            parameters.Add("@PageSize", pageSize);
            parameters.Add("@Offset", offset);

            // クエリ文を実行
            return await SqlHelper.Query<UserEntity>(sql, parameters);
        }

        /// <summary>
        /// 全てのユーザーを非同期的に取得する
        /// </summary>
        /// <returns>ユーザーエンティティ</returns>
        public async Task<IEnumerable<UserEntity>> GetUsersAsync()
        {
            var sql = @"SELECT * FROM Users";

            return await SqlHelper.Query<UserEntity>(sql);
        }
    }
}

データの取得範囲の指定はコメントに書いてあるとおりです。LIMITOFFSETを使用しています。

SQL ヘルパークラスの作成

次に、SQL 文を実行する処理をヘルパークラスに切り出します。

今回はデータを取得する処理のみ記述しています。

"Infrastructure\FileIO\DirectoryAndFileHelper.cs"
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Data.Sqlite;
using MVVM.DataGridPagination.Infrastructure.FileIO;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Threading.Tasks;

namespace MVVM.DataGridPagination.Infrastructure.Helpers
{
    /// <summary>
    /// データベース接続用のヘルパークラス
    /// </summary>
    internal static class SqlHelper
    {
        /// <summary>
        /// 接続文字列
        /// </summary>
        internal static string _connectionString;

        /// <summary>
        /// static なコンストラクター
        /// このクラスの _connectionString などのメンバー変数にアクセスしようとしたときに最初に実行される
        /// </summary>
        static SqlHelper()
        {

            var sourceDirectory = ConfigurationManager.AppSettings.Get("SQLiteFilePath");

            if (!DirectoryAndFileHelper.IsExistsDirectory(sourceDirectory))
            {
                throw new FileNotFoundException("SQLiteが格納されたディレクトリが存在しません");
            }

            var sourceDilePath = Path.Combine(sourceDirectory, "DataGridSearch.db");

            if (!DirectoryAndFileHelper.IsExistsFile(sourceDilePath))
            {
                throw new FileNotFoundException("SQLiteのファイルが存在しません");
            }

            var builder = new SqlConnectionStringBuilder();

            builder.DataSource = sourceDilePath;

            // 接続文字列を生成
            _connectionString = builder.ToString();

        }

        internal static async Task<IEnumerable<T>> Query<T>(string sql)
        {
            return await Query<T>(sql, null);
        }

        internal static async Task<IEnumerable<T>> ParamQuery<T>(string sql, object param)
        {
            return await Query<T>(sql, param);
        }

        internal static async Task<IEnumerable<T>> Query<T>(string sql, object param)
        {

            using (var connection = new SqliteConnection(_connectionString))
            {
                // IEnumerable を List に変換する
                // Dapperの機能でSQL文の実行結果を T に指定するエンティティにマッピングをする
                // SELECTのカラム数とエンティティの引数の値、型など揃っていないとエラーになるので注意
                // INFO: https://learn.microsoft.com/ja-jp/dotnet/framework/data/adonet/sql-server-data-type-mappings

                return await connection.QueryAsync<T>(sql, param);
            }
        }
    }
}

internal修飾子はそのプロジェクト内(同じアセンブリ内)からのみアクセスを許可するものです。

internalにしている理由ですが、このヘルパークラス内のメソッドは他のアセンブリ(ドメイン層やアプリケーション層)から直接使用することは想定していない為です。

今回のアプリケーションでは全て同じプロジェクト内MVVM.DataGridPagination.csprojで作成していますが、本来であればInfrastructure層のクラスライブラリを作成してデータベースへの札族や、ファイルの IO 処理などの処理を実装することでしょう。

その際にうまくinternalを使用することでカプセル化を実現し、余計な公開コードを作らない利点があります。

ViewModel の実装

先ほど作成したUsersクラスをMainWindowViewModelクラスに記述します。

"ViewModels\MainWindowViewModel.cs"
using MVVM.DataGridPagination.Entities;
using MVVM.DataGridPagination.Repositories;
using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;

namespace MVVM.DataGridPagination.ViewModels
{
    /// <summary>
    /// メインウィンドウのViewModelクラス
    /// </summary>
    public class MainWindowViewModel : BindableBase
    {
        private readonly IUsersRepository<UserEntity> _usersRepository;

        #region プロパティ

        /// <summary>
        /// PaginationViewModelを取得または設定する
        /// </summary>
        public PaginationViewModel PaginationVM
        {
            get => _paginationVM;
            set => SetProperty(ref _paginationVM, value);
        }
        private PaginationViewModel _paginationVM;

        /// <summary>
        /// 検索テキストが空かどうかを取得する
        /// </summary>
        public bool IsTextEmpty => string.IsNullOrWhiteSpace(SearchText);

        /// <summary>
        /// 検索テキストを取得または設定する
        /// </summary>
        public string SearchText
        {
            get => _searchText;
            set
            {
                if (SetProperty(ref _searchText, value))
                {
                    RaisePropertyChanged(nameof(IsTextEmpty));
                    FilterItems();
                }
            }
        }
        private string _searchText;

        private ObservableCollection<UserEntity> _originalMemberData;

        /// <summary>
        /// フィルタリングされたメンバーデータを取得または設定する
        /// </summary>
        public ObservableCollection<UserEntity> FilterMemberData
        {
            get => _filterMemberData;
            set => SetProperty(ref _filterMemberData, value);
        }
        private ObservableCollection<UserEntity> _filterMemberData;

        /// <summary>
        /// タイトルを取得または設定する
        /// </summary>
        public string Title
        {
            get => _title;
            set => SetProperty(ref _title, value);
        }
        private string _title = "Prism Application";

        #endregion

        #region コマンド

        /// <summary>
        /// 初期化を非同期で実行するコマンド
        /// </summary>
        public DelegateCommand InitializeAsyncCommand =>
           _initializeAsyncCommand ?? (new DelegateCommand(async () => await InitializeAsync()));
        private DelegateCommand _initializeAsyncCommand;

        /// <summary>
        /// アプリケーションを終了するコマンド
        /// </summary>
        public DelegateCommand AppCloseCommand =>
            _appCloseCommand ?? (new DelegateCommand(() => Application.Current.Shutdown()));
        private DelegateCommand _appCloseCommand;

        /// <summary>
        /// 検索を実行するコマンド
        /// </summary>
        public DelegateCommand SearchCommand =>
           _searchCommand ?? (new DelegateCommand(OnSearch));
        private DelegateCommand _searchCommand;

        #endregion

        /// <summary>
        /// 1ページで表示するデータの数
        /// </summary>
        private const int PageSize = 500;

        /// <summary>
        /// MainWindowViewModelクラスの新しいインスタンスを初期化する
        /// </summary>
        /// <param name="usersRepository">Userリポジトリインターフェース</param>
        public MainWindowViewModel(IUsersRepository<UserEntity> usersRepository)
        {
            _usersRepository = usersRepository;

            PaginationVM = new PaginationViewModel();
            // PaginationViewModel側の Action で通知されたときに実行されるメソッドを登録
            PaginationVM.PageChanged += OnPageChange;
        }

        /// <summary>
        /// ページが変更されたときに呼び出されるメソッド
        /// </summary>
        /// <param name="page">新しいページ番号</param>
        private async void OnPageChange(int page)
        {
            await LoadPage(page);
        }

        /// <summary>
        /// 指定されたページのデータを読み込む
        /// </summary>
        /// <param name="page">読み込むページ番号</param>
        private async Task LoadPage(int page)
        {
            // 総レコード数を取得する
            var totalCount = await _usersRepository.GetUsersAsync();

            // ページネーション用の設定を行う
            PaginationVM.TotalPages = (int)Math.Ceiling((double)totalCount.Count() / PageSize);
            PaginationVM.CurrentPage = page;

            // 指定されたページのデータを取得する
            var enumerableData = await _usersRepository.GetPaginateAsync(page, PageSize);

            FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
        }

        /// <summary>
        /// 初期化を非同期で実行する
        /// </summary>
        /// <param name="pageNumber">初期ページ番号 デフォルトは1</param>
        private async Task InitializeAsync(int pageNumber = 1)
        {
            try
            {
                // 総レコード数を取得する
                var totalCount = await _usersRepository.GetUsersAsync();

                // ページネーション用の設定を行う
                PaginationVM.TotalPages = (int)Math.Ceiling((double)totalCount.Count() / PageSize);
                PaginationVM.CurrentPage = 1;

                // 指定されたページのデータを取得する
                var enumerableData = await _usersRepository.GetPaginateAsync(pageNumber, PageSize);

                FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"初期化中にエラーが発生しました: {ex.Message}");
            }
        }

        /// <summary>
        /// アイテムをフィルタリングする
        /// </summary>
        public void FilterItems()
        {
            if (string.IsNullOrWhiteSpace(SearchText)) // 検索文字列が空の場合はTrueを返す
            {
                FilterMemberData = _originalMemberData;
            }
            else
            {
                // 検索結果をTextBoxの文字列でフィルタリングする
                var filterDate = _originalMemberData.Where(x => x.Name?.Contains(SearchText) == true);

                // フィルタリング結果をObservableCollectionに変換する
                FilterMemberData = filterDate == null ? new ObservableCollection<UserEntity>()
                                                      : new ObservableCollection<UserEntity>(filterDate);
            }
        }

        /// <summary>
        /// 検索を実行する
        /// </summary>
        private async void OnSearch()
        {
            try
            {
                var enumerableData = await _usersRepository.GetUsersAsync();

                FilterMemberData = new ObservableCollection<UserEntity>(enumerableData);

                // 絞り込み検索前のデータを保持する
                _originalMemberData = FilterMemberData;
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message);
            }
        }
    }
}

ページネーションの動作確認

アプリケーションを起動してページネーションが機能していることを確認します。

datagrid-pagination.gif

使用したサンプルアプリケーションのリポジトリはこちらです。