libs.domain.placeholder

libs/domain/placeholder.py

  1"""
  2libs/domain/placeholder.py
  3"""
  4
  5import logging
  6import re
  7import textwrap
  8from dataclasses import dataclass, field, fields
  9from math import ceil
 10from typing import TYPE_CHECKING, Any, Literal, Optional, Union
 11
 12import pandas as pd
 13
 14from libs.domain.datamodels import ParameterData
 15from libs.functions import lookup
 16from libs.types import ServiceType
 17from libs.utils import dbutil
 18from libs.utils.timekit import ExtendedDatetime as ExtDt
 19
 20if TYPE_CHECKING:
 21    from pathlib import Path
 22
 23
 24@dataclass
 25class PlaceholderBuilder(ParameterData):
 26    """プレースホルダ構築クラス"""
 27
 28    service_type: ServiceType = field(default=ServiceType.UNKNOWN)
 29    """連携先サービス"""
 30    command: str = field(default="")
 31    """コマンド名"""
 32    channel_config: Optional["Path"] = field(default=None)
 33    """チャンネル個別設定状況
 34    - *Path*: 追加設定ファイルパス
 35    - *None*: 個別設定を利用していない
 36    """
 37
 38    # ルール情報
 39    target_mode: int = field(default=0)
 40    """集計対象モードの指定
 41    - *0*: settingのデフォルトに従う
 42    - *not 0*: 指定値でmodeを上書き
 43    """
 44    mode: int = field(default=4)
 45    """集計モード"""
 46    default_rule: str = field(default="")
 47    """ルール識別子(設定値)"""
 48    rule_version: str = field(default="")
 49    """ルール識別子(指定値)"""
 50    rule_list: list[str] = field(default_factory=list)
 51    """集計対象ルール識別子"""
 52    mixed: bool = field(default=False)
 53    """ルール識別子の扱い
 54    - *True*: 定義済みすべてのルール識別子を含める
 55    - *False*: ルール識別子を個別指定
 56    """
 57    # ルールセット登録用
 58    origin_point: int = field(default=250)
 59    """配給原点"""
 60    return_point: int = field(default=300)
 61    """返し点"""
 62    rank_point: str = field(default="")
 63    """順位点(空白区切りの文字列)"""
 64    ignore_flying: bool = field(default=False)
 65    """トビカウントの無効化"""
 66    draw_split: bool = field(default=False)
 67    """同点時の順位点の取り扱い
 68    - *True*: 山分け
 69    - *False*: 席順
 70    """
 71    undefined_word: int = field(default=1)
 72    """未登録ワードの扱い
 73    - *0*: 役満扱い
 74    - *1*: カウントのみ
 75    - *2*: 卓外清算(個人清算)
 76    - *3*: 卓外清算(チーム清算)
 77    """
 78
 79    # 集計対象情報
 80    player_name: str = field(default="")
 81    """集計対象プレイヤー"""
 82    guest_name: str = field(default="")
 83    """ゲストの名前"""
 84    target_player: list[str] = field(default_factory=list)
 85    """引数で受け付けたプレイヤーのリスト"""
 86    player_list: list[str] = field(default_factory=list)
 87    """集計対象プレイヤーリスト"""
 88    competition_list: list[str] = field(default_factory=list)
 89    """比較対象プレイヤーリスト"""
 90    all_player: bool = field(default=False)
 91    """検索対象に登録済みメンバー全員を加える"""
 92    source: str = field(default="")
 93    """スコア入力元識別子"""
 94    separate: bool = field(default=False)
 95    """スコア入力元識別子別集計フラグ
 96    - *True*: 識別子別に集計
 97    - *False*: すべて集計
 98    """
 99    collection: str = field(default="")
100    """集約集計
101    - *daily*: 日次集約
102    - *weekly*: 週次集約
103    - *monthly*: 月次集約
104    - *yearly*: 年次集約
105    - *all*: 全体集約
106    """
107    aggregate_unit: Literal["A", "M", "Y", None] = field(default=None)
108    """レポート生成用日付範囲デフォルト値
109    - *A*: 全期間
110    - *M*: 月別
111    - *Y*: 年別
112    - *None*: 未定義
113    """
114    target_count: int = field(default=0)
115    """直近ゲーム数指定"""
116
117    starttime: Union[str, ExtDt, None] = field(default=None)
118    """集計開始日時"""
119    endtime: Union[str, ExtDt, None] = field(default=None)
120    """集計終了日時"""
121    onday: Union[str, ExtDt, None] = field(default=None)
122    """time_adjust修正を含まない日時"""
123
124    # 動作/表示変更フラグ
125    score_comparisons: bool = field(default=False)
126    """スコア比較表示"""
127    verbose: bool = field(default=False)
128    """詳細情報表示"""
129    game_results: bool = field(default=False)
130    """ゲーム結果表示"""
131    versus_matrix: bool = field(default=False)
132    """対戦マトリックス表示"""
133    order: bool = field(default=False)
134    """順位推移グラフ表示"""
135    rating: bool = field(default=False)
136    """レーティング推移グラフ表示"""
137    anonymous: bool = field(default=False)
138    """匿名化フラグ"""
139    fourfold: bool = field(default=True)
140    """縦持ち/横持ちデータ判定"""
141
142    # 出力関連
143    guest_mark: str = field(default="※")
144    """ゲスト無効時に未登録メンバーに付与する印"""
145    format: Literal["default", "csv", "txt"] = field(default="default")
146    """出力フォーマット指定"""
147    filename: str = field(default="")
148    """出力ファイル名"""
149
150    # その他
151    database_file: Union[str, "Path"] = field(default="")
152    """成績管理データベースファイル名"""
153    logging_verbose: int = field(default=0)
154    """デバッグ情報出力レベル"""
155
156    def update_from_dict(self, input_dict: dict[str, Any]) -> None:
157        """
158        辞書の内容で値を更新する
159
160        Args:
161            input_dict (dict[str,Any]): 更新内容
162
163        """
164        field_list: list[str] = [x.name for x in fields(self)]
165        for k, v in input_dict.items():
166            if k in field_list:
167                setattr(self, k, v)
168
169    def update_setting(self, main_config: "Path", key_name: str, val_type: type, fallback: Any = None) -> None:
170        """
171        優先度順に key_name を探索して値を更新する
172
173        Args:
174            main_config (Path): メイン設定ファイルパス
175            key_name (str): 探索するキー名
176            val_type (type): 取り込む値の型 (bool, str)
177            fallback (Any): 見つからなかった場合にセットする値
178            - *None* が指定されているときは値を更新しない
179
180        Note:
181            探索優先順序
182            1. 個別設定ファイル内settingセクション
183            2. メイン設定ファイル内チャンネル個別セクション
184            3. メイン設定ファイル内サービス別セクション
185            4. メイン設定ファイル内settingセクション
186
187        """
188        value: Optional[Any] = None
189        # 個別設定ファイル内探索
190        if self.channel_config:
191            value = lookup.get_config_value(
192                config_file=self.channel_config,
193                section="setting",
194                name=key_name,
195                val_type=val_type,
196                fallback=None,
197            )
198            if value is not None:
199                setattr(self, key_name, value)
200                return
201
202        # メイン設定ファイル内探索
203        for section_name in [self.source, self.service_type, "setting"]:
204            value = lookup.get_config_value(
205                config_file=main_config,
206                section=section_name,
207                name=key_name,
208                val_type=val_type,
209                fallback=None,
210            )
211            if value is not None:
212                setattr(self, key_name, value)
213                return
214
215        if fallback is not None:
216            setattr(self, key_name, fallback)
217
218    def query_modification(self, query: str) -> str:
219        """
220        クエリをオプションの内容で修正する
221
222        Args:
223            query (str): 修正するクエリ
224
225        Returns:
226            str: 修正後のクエリ
227
228        """
229        if self.individual:  # 個人集計
230            query = query.replace("--[individual] ", "")
231            # ゲスト関連フラグ
232            if self.unregistered_replace:
233                query = query.replace("--[unregistered_replace] ", "")
234                if self.guest_skip:
235                    query = query.replace("--[guest_not_skip] ", "")
236                else:
237                    query = query.replace("--[guest_skip] ", "")
238            else:
239                query = query.replace("--[unregistered_not_replace] ", "")
240        else:  # チーム集計
241            self.unregistered_replace = False
242            self.guest_skip = True
243            query = query.replace("--[team] ", "")
244            if not self.friendly_fire:
245                query = query.replace("--[friendly_fire] ", "")
246
247        # 集約集計
248        match self.collection:
249            case "daily":
250                query = query.replace("--[collection_daily] ", "")
251                query = query.replace("--[collection] ", "")
252            case "weekly":
253                query = query.replace("--[collection_weekly] ", "")
254                query = query.replace("--[collection] ", "")
255            case "monthly":
256                query = query.replace("--[collection_monthly] ", "")
257                query = query.replace("--[collection] ", "")
258            case "yearly":
259                query = query.replace("--[collection_yearly] ", "")
260                query = query.replace("--[collection] ", "")
261            case "all":
262                query = query.replace("--[collection_all] ", "")
263                query = query.replace("--[collection] ", "")
264            case _:
265                query = query.replace("--[not_collection] ", "")
266
267        # 集計対象ルール
268        if self.rule_list:
269            query = query.replace("<<rule_list>>", ",".join([f":rule_{idx}" for idx, _ in enumerate(self.rule_list)]))
270        else:
271            query = query.replace("and rule_version in (<<rule_list>>)", "")
272            query = query.replace("and results.rule_version in (<<rule_list>>)", "")
273            query = query.replace("and game_info.rule_version in (<<rule_list>>)", "")
274
275        # 集計モード
276        match self.mode:
277            case 3:
278                query = query.replace("--[mode3] ", "")
279            case 4:
280                query = query.replace("--[mode4] ", "")
281
282        # スコア入力元識別子別集計
283        if self.separate:
284            query = query.replace("--[separate] ", "")
285
286        # コメント検索
287        if self.search_word or self.group_length:
288            query = query.replace("--[group_by] ", "")
289        else:
290            query = query.replace("--[not_group_by] ", "")
291
292        if self.search_word:
293            query = query.replace("--[search_word] ", "")
294        else:
295            query = query.replace("--[not_search_word] ", "")
296
297        if self.group_length:
298            query = query.replace("--[group_length] ", "")
299        else:
300            query = query.replace("--[not_group_length] ", "")
301            if self.search_word:
302                query = query.replace("--[comment] ", "")
303            else:
304                query = query.replace("--[not_comment] ", "")
305
306        # 直近N検索用(全範囲取得してから絞る)
307        if self.target_count:
308            query = query.replace("and my.playtime between", "-- and my.playtime between")
309
310        # プレイヤーリスト
311        if self.player_name and self.player_list:
312            query = query.replace("--[player_name] ", "")
313            query = query.replace(
314                "<<player_list>>",
315                ", ".join([f":player_{idx}" for idx, _ in enumerate(self.player_list)]),
316            )
317        query = query.replace("<<guest_mark>>", self.guest_mark)
318
319        # フラグの処理
320        match self.aggregate_unit:
321            case "M":
322                query = query.replace("<<collection>>", "substr(collection_daily, 1, 7) as 集計")
323                query = query.replace("<<group by>>", "group by 集計")
324            case "Y":
325                query = query.replace("<<collection>>", "substr(collection_daily, 1, 4) as 集計")
326                query = query.replace("<<group by>>", "group by 集計")
327            case "A":
328                query = query.replace("<<collection>>", "'合計' as 集計")
329                query = query.replace("<<group by>>", "")
330            case _:
331                query = query.replace("<<collection>>,", "-- <<collection>>")
332                query = query.replace("<<group by>>", "-- <<group by>>")
333
334        if self.interval:
335            query = query.replace("<<Calculation Formula>>", "(row_number() over (order by total_count desc) - 1) / :interval")
336        else:
337            query = query.replace("<<Calculation Formula>>", ":interval")
338
339        if self.undefined_word is not None:
340            match self.undefined_word:
341                case 0:
342                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 0)")
343                case 1:
344                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 1)")
345                case 2:
346                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 2)")
347                case _:
348                    query = query.replace("<<where_string>>", "and (words.type = 1 or words.type = 2)")
349        else:
350            query = query.replace(":undefined_word", "1")
351
352        # queryコメント削除
353        query = re.sub(r"^ *--\[.*$", "", query, flags=re.MULTILINE)
354        query = re.sub(r"\n+", "\n", query, flags=re.MULTILINE)
355
356        return query
357
358    def named_query(self, query: str) -> str:
359        """
360        クエリにパラメータをバインドして返す
361
362        Args:
363            query (str): SQL
364
365        Returns:
366            str: バインド済みSQL
367
368        """
369        return textwrap.dedent(
370            re.sub(
371                r":(\w+)",
372                lambda m: repr(self.placeholder().get(m.group(1), m.group(0))),
373                query,
374            ),
375        ).strip()
376
377    def placeholder(self, game_count: Optional[int] = None) -> dict[str, Any]:
378        """
379        プレースホルダ用辞書出力
380
381        Args:
382            game_count (Optional[int]): 規定打数調整用ゲーム数. Defaults to None.
383
384        Returns:
385            dict[str, Any]: プレースホルダ
386
387        """
388        ret_dict: dict[str, Any] = {f.name: getattr(self, f.name) for f in fields(self)}
389
390        # 規定打数更新
391        if not ret_dict.get("stipulated") or game_count is not None:
392            if game_count is None:
393                ret_dict.update({"stipulated": 1})
394            else:
395                ret_dict.update({"stipulated": int(ceil(game_count * self.stipulated_rate) + 1)})
396
397        if self.player_list:
398            ret_dict.update({f"player_{idx}": x for idx, x in enumerate(self.player_list)})
399
400        if self.target_player:
401            ret_dict.update({f"target_{idx}": x for idx, x in enumerate(self.target_player)})
402
403        if self.competition_list:
404            ret_dict.update({f"competition_{idx}": x for idx, x in enumerate(self.competition_list)})
405
406        if self.rule_list:
407            ret_dict.update({f"rule_{idx}": x for idx, x in enumerate(self.rule_list)})
408        else:
409            ret_dict.update({"rule_0": self.rule_version})
410
411        # 日付型変換
412        for date_attr in ["starttime", "endtime", "onday"]:
413            if (val := ret_dict.get(date_attr)) and isinstance(val, ExtDt):
414                ret_dict.update({date_attr: val.format(ExtDt.FMT.SQL)})
415
416        return ret_dict
417
418    def read_data(self, keyword: str) -> pd.DataFrame:
419        """
420        データベースからデータを取得する
421
422        Args:
423            keyword (str): SQL選択キーワード
424
425        Returns:
426            pd.DataFrame: 集計結果
427
428        """
429        query = self.query_modification(dbutil.query(keyword))
430
431        if self.logging_verbose & 0x01:
432            print(f">>> params={self.placeholder()}")
433            print(f">>> SQL: {keyword} -> {self.database_file}\n{self.named_query(query)}")
434
435        try:
436            query_start_time = ExtDt().dt.timestamp()
437            df = pd.read_sql(
438                sql=query,
439                con=dbutil.connection(self.database_file),
440                params=self.placeholder(),
441            )
442            query_end_time = ExtDt().dt.timestamp()
443        except pd.errors.DatabaseError as err:
444            logging.error("DatabaseError: %s", err)
445            logging.error("SQL: %s, DATABASE: %s", keyword, self.database_file)
446            logging.error("params=%s", self.placeholder())
447            logging.error("query: %s", self.named_query(query))
448
449        if self.logging_verbose & 0x02:
450            print("=" * 80)
451            print(df.to_string())
452
453        logging.debug("SQL: %s, time: %s", keyword, query_end_time - query_start_time)
454        return df
@dataclass
class PlaceholderBuilder(libs.domain.datamodels.ParameterData):
 25@dataclass
 26class PlaceholderBuilder(ParameterData):
 27    """プレースホルダ構築クラス"""
 28
 29    service_type: ServiceType = field(default=ServiceType.UNKNOWN)
 30    """連携先サービス"""
 31    command: str = field(default="")
 32    """コマンド名"""
 33    channel_config: Optional["Path"] = field(default=None)
 34    """チャンネル個別設定状況
 35    - *Path*: 追加設定ファイルパス
 36    - *None*: 個別設定を利用していない
 37    """
 38
 39    # ルール情報
 40    target_mode: int = field(default=0)
 41    """集計対象モードの指定
 42    - *0*: settingのデフォルトに従う
 43    - *not 0*: 指定値でmodeを上書き
 44    """
 45    mode: int = field(default=4)
 46    """集計モード"""
 47    default_rule: str = field(default="")
 48    """ルール識別子(設定値)"""
 49    rule_version: str = field(default="")
 50    """ルール識別子(指定値)"""
 51    rule_list: list[str] = field(default_factory=list)
 52    """集計対象ルール識別子"""
 53    mixed: bool = field(default=False)
 54    """ルール識別子の扱い
 55    - *True*: 定義済みすべてのルール識別子を含める
 56    - *False*: ルール識別子を個別指定
 57    """
 58    # ルールセット登録用
 59    origin_point: int = field(default=250)
 60    """配給原点"""
 61    return_point: int = field(default=300)
 62    """返し点"""
 63    rank_point: str = field(default="")
 64    """順位点(空白区切りの文字列)"""
 65    ignore_flying: bool = field(default=False)
 66    """トビカウントの無効化"""
 67    draw_split: bool = field(default=False)
 68    """同点時の順位点の取り扱い
 69    - *True*: 山分け
 70    - *False*: 席順
 71    """
 72    undefined_word: int = field(default=1)
 73    """未登録ワードの扱い
 74    - *0*: 役満扱い
 75    - *1*: カウントのみ
 76    - *2*: 卓外清算(個人清算)
 77    - *3*: 卓外清算(チーム清算)
 78    """
 79
 80    # 集計対象情報
 81    player_name: str = field(default="")
 82    """集計対象プレイヤー"""
 83    guest_name: str = field(default="")
 84    """ゲストの名前"""
 85    target_player: list[str] = field(default_factory=list)
 86    """引数で受け付けたプレイヤーのリスト"""
 87    player_list: list[str] = field(default_factory=list)
 88    """集計対象プレイヤーリスト"""
 89    competition_list: list[str] = field(default_factory=list)
 90    """比較対象プレイヤーリスト"""
 91    all_player: bool = field(default=False)
 92    """検索対象に登録済みメンバー全員を加える"""
 93    source: str = field(default="")
 94    """スコア入力元識別子"""
 95    separate: bool = field(default=False)
 96    """スコア入力元識別子別集計フラグ
 97    - *True*: 識別子別に集計
 98    - *False*: すべて集計
 99    """
100    collection: str = field(default="")
101    """集約集計
102    - *daily*: 日次集約
103    - *weekly*: 週次集約
104    - *monthly*: 月次集約
105    - *yearly*: 年次集約
106    - *all*: 全体集約
107    """
108    aggregate_unit: Literal["A", "M", "Y", None] = field(default=None)
109    """レポート生成用日付範囲デフォルト値
110    - *A*: 全期間
111    - *M*: 月別
112    - *Y*: 年別
113    - *None*: 未定義
114    """
115    target_count: int = field(default=0)
116    """直近ゲーム数指定"""
117
118    starttime: Union[str, ExtDt, None] = field(default=None)
119    """集計開始日時"""
120    endtime: Union[str, ExtDt, None] = field(default=None)
121    """集計終了日時"""
122    onday: Union[str, ExtDt, None] = field(default=None)
123    """time_adjust修正を含まない日時"""
124
125    # 動作/表示変更フラグ
126    score_comparisons: bool = field(default=False)
127    """スコア比較表示"""
128    verbose: bool = field(default=False)
129    """詳細情報表示"""
130    game_results: bool = field(default=False)
131    """ゲーム結果表示"""
132    versus_matrix: bool = field(default=False)
133    """対戦マトリックス表示"""
134    order: bool = field(default=False)
135    """順位推移グラフ表示"""
136    rating: bool = field(default=False)
137    """レーティング推移グラフ表示"""
138    anonymous: bool = field(default=False)
139    """匿名化フラグ"""
140    fourfold: bool = field(default=True)
141    """縦持ち/横持ちデータ判定"""
142
143    # 出力関連
144    guest_mark: str = field(default="※")
145    """ゲスト無効時に未登録メンバーに付与する印"""
146    format: Literal["default", "csv", "txt"] = field(default="default")
147    """出力フォーマット指定"""
148    filename: str = field(default="")
149    """出力ファイル名"""
150
151    # その他
152    database_file: Union[str, "Path"] = field(default="")
153    """成績管理データベースファイル名"""
154    logging_verbose: int = field(default=0)
155    """デバッグ情報出力レベル"""
156
157    def update_from_dict(self, input_dict: dict[str, Any]) -> None:
158        """
159        辞書の内容で値を更新する
160
161        Args:
162            input_dict (dict[str,Any]): 更新内容
163
164        """
165        field_list: list[str] = [x.name for x in fields(self)]
166        for k, v in input_dict.items():
167            if k in field_list:
168                setattr(self, k, v)
169
170    def update_setting(self, main_config: "Path", key_name: str, val_type: type, fallback: Any = None) -> None:
171        """
172        優先度順に key_name を探索して値を更新する
173
174        Args:
175            main_config (Path): メイン設定ファイルパス
176            key_name (str): 探索するキー名
177            val_type (type): 取り込む値の型 (bool, str)
178            fallback (Any): 見つからなかった場合にセットする値
179            - *None* が指定されているときは値を更新しない
180
181        Note:
182            探索優先順序
183            1. 個別設定ファイル内settingセクション
184            2. メイン設定ファイル内チャンネル個別セクション
185            3. メイン設定ファイル内サービス別セクション
186            4. メイン設定ファイル内settingセクション
187
188        """
189        value: Optional[Any] = None
190        # 個別設定ファイル内探索
191        if self.channel_config:
192            value = lookup.get_config_value(
193                config_file=self.channel_config,
194                section="setting",
195                name=key_name,
196                val_type=val_type,
197                fallback=None,
198            )
199            if value is not None:
200                setattr(self, key_name, value)
201                return
202
203        # メイン設定ファイル内探索
204        for section_name in [self.source, self.service_type, "setting"]:
205            value = lookup.get_config_value(
206                config_file=main_config,
207                section=section_name,
208                name=key_name,
209                val_type=val_type,
210                fallback=None,
211            )
212            if value is not None:
213                setattr(self, key_name, value)
214                return
215
216        if fallback is not None:
217            setattr(self, key_name, fallback)
218
219    def query_modification(self, query: str) -> str:
220        """
221        クエリをオプションの内容で修正する
222
223        Args:
224            query (str): 修正するクエリ
225
226        Returns:
227            str: 修正後のクエリ
228
229        """
230        if self.individual:  # 個人集計
231            query = query.replace("--[individual] ", "")
232            # ゲスト関連フラグ
233            if self.unregistered_replace:
234                query = query.replace("--[unregistered_replace] ", "")
235                if self.guest_skip:
236                    query = query.replace("--[guest_not_skip] ", "")
237                else:
238                    query = query.replace("--[guest_skip] ", "")
239            else:
240                query = query.replace("--[unregistered_not_replace] ", "")
241        else:  # チーム集計
242            self.unregistered_replace = False
243            self.guest_skip = True
244            query = query.replace("--[team] ", "")
245            if not self.friendly_fire:
246                query = query.replace("--[friendly_fire] ", "")
247
248        # 集約集計
249        match self.collection:
250            case "daily":
251                query = query.replace("--[collection_daily] ", "")
252                query = query.replace("--[collection] ", "")
253            case "weekly":
254                query = query.replace("--[collection_weekly] ", "")
255                query = query.replace("--[collection] ", "")
256            case "monthly":
257                query = query.replace("--[collection_monthly] ", "")
258                query = query.replace("--[collection] ", "")
259            case "yearly":
260                query = query.replace("--[collection_yearly] ", "")
261                query = query.replace("--[collection] ", "")
262            case "all":
263                query = query.replace("--[collection_all] ", "")
264                query = query.replace("--[collection] ", "")
265            case _:
266                query = query.replace("--[not_collection] ", "")
267
268        # 集計対象ルール
269        if self.rule_list:
270            query = query.replace("<<rule_list>>", ",".join([f":rule_{idx}" for idx, _ in enumerate(self.rule_list)]))
271        else:
272            query = query.replace("and rule_version in (<<rule_list>>)", "")
273            query = query.replace("and results.rule_version in (<<rule_list>>)", "")
274            query = query.replace("and game_info.rule_version in (<<rule_list>>)", "")
275
276        # 集計モード
277        match self.mode:
278            case 3:
279                query = query.replace("--[mode3] ", "")
280            case 4:
281                query = query.replace("--[mode4] ", "")
282
283        # スコア入力元識別子別集計
284        if self.separate:
285            query = query.replace("--[separate] ", "")
286
287        # コメント検索
288        if self.search_word or self.group_length:
289            query = query.replace("--[group_by] ", "")
290        else:
291            query = query.replace("--[not_group_by] ", "")
292
293        if self.search_word:
294            query = query.replace("--[search_word] ", "")
295        else:
296            query = query.replace("--[not_search_word] ", "")
297
298        if self.group_length:
299            query = query.replace("--[group_length] ", "")
300        else:
301            query = query.replace("--[not_group_length] ", "")
302            if self.search_word:
303                query = query.replace("--[comment] ", "")
304            else:
305                query = query.replace("--[not_comment] ", "")
306
307        # 直近N検索用(全範囲取得してから絞る)
308        if self.target_count:
309            query = query.replace("and my.playtime between", "-- and my.playtime between")
310
311        # プレイヤーリスト
312        if self.player_name and self.player_list:
313            query = query.replace("--[player_name] ", "")
314            query = query.replace(
315                "<<player_list>>",
316                ", ".join([f":player_{idx}" for idx, _ in enumerate(self.player_list)]),
317            )
318        query = query.replace("<<guest_mark>>", self.guest_mark)
319
320        # フラグの処理
321        match self.aggregate_unit:
322            case "M":
323                query = query.replace("<<collection>>", "substr(collection_daily, 1, 7) as 集計")
324                query = query.replace("<<group by>>", "group by 集計")
325            case "Y":
326                query = query.replace("<<collection>>", "substr(collection_daily, 1, 4) as 集計")
327                query = query.replace("<<group by>>", "group by 集計")
328            case "A":
329                query = query.replace("<<collection>>", "'合計' as 集計")
330                query = query.replace("<<group by>>", "")
331            case _:
332                query = query.replace("<<collection>>,", "-- <<collection>>")
333                query = query.replace("<<group by>>", "-- <<group by>>")
334
335        if self.interval:
336            query = query.replace("<<Calculation Formula>>", "(row_number() over (order by total_count desc) - 1) / :interval")
337        else:
338            query = query.replace("<<Calculation Formula>>", ":interval")
339
340        if self.undefined_word is not None:
341            match self.undefined_word:
342                case 0:
343                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 0)")
344                case 1:
345                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 1)")
346                case 2:
347                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 2)")
348                case _:
349                    query = query.replace("<<where_string>>", "and (words.type = 1 or words.type = 2)")
350        else:
351            query = query.replace(":undefined_word", "1")
352
353        # queryコメント削除
354        query = re.sub(r"^ *--\[.*$", "", query, flags=re.MULTILINE)
355        query = re.sub(r"\n+", "\n", query, flags=re.MULTILINE)
356
357        return query
358
359    def named_query(self, query: str) -> str:
360        """
361        クエリにパラメータをバインドして返す
362
363        Args:
364            query (str): SQL
365
366        Returns:
367            str: バインド済みSQL
368
369        """
370        return textwrap.dedent(
371            re.sub(
372                r":(\w+)",
373                lambda m: repr(self.placeholder().get(m.group(1), m.group(0))),
374                query,
375            ),
376        ).strip()
377
378    def placeholder(self, game_count: Optional[int] = None) -> dict[str, Any]:
379        """
380        プレースホルダ用辞書出力
381
382        Args:
383            game_count (Optional[int]): 規定打数調整用ゲーム数. Defaults to None.
384
385        Returns:
386            dict[str, Any]: プレースホルダ
387
388        """
389        ret_dict: dict[str, Any] = {f.name: getattr(self, f.name) for f in fields(self)}
390
391        # 規定打数更新
392        if not ret_dict.get("stipulated") or game_count is not None:
393            if game_count is None:
394                ret_dict.update({"stipulated": 1})
395            else:
396                ret_dict.update({"stipulated": int(ceil(game_count * self.stipulated_rate) + 1)})
397
398        if self.player_list:
399            ret_dict.update({f"player_{idx}": x for idx, x in enumerate(self.player_list)})
400
401        if self.target_player:
402            ret_dict.update({f"target_{idx}": x for idx, x in enumerate(self.target_player)})
403
404        if self.competition_list:
405            ret_dict.update({f"competition_{idx}": x for idx, x in enumerate(self.competition_list)})
406
407        if self.rule_list:
408            ret_dict.update({f"rule_{idx}": x for idx, x in enumerate(self.rule_list)})
409        else:
410            ret_dict.update({"rule_0": self.rule_version})
411
412        # 日付型変換
413        for date_attr in ["starttime", "endtime", "onday"]:
414            if (val := ret_dict.get(date_attr)) and isinstance(val, ExtDt):
415                ret_dict.update({date_attr: val.format(ExtDt.FMT.SQL)})
416
417        return ret_dict
418
419    def read_data(self, keyword: str) -> pd.DataFrame:
420        """
421        データベースからデータを取得する
422
423        Args:
424            keyword (str): SQL選択キーワード
425
426        Returns:
427            pd.DataFrame: 集計結果
428
429        """
430        query = self.query_modification(dbutil.query(keyword))
431
432        if self.logging_verbose & 0x01:
433            print(f">>> params={self.placeholder()}")
434            print(f">>> SQL: {keyword} -> {self.database_file}\n{self.named_query(query)}")
435
436        try:
437            query_start_time = ExtDt().dt.timestamp()
438            df = pd.read_sql(
439                sql=query,
440                con=dbutil.connection(self.database_file),
441                params=self.placeholder(),
442            )
443            query_end_time = ExtDt().dt.timestamp()
444        except pd.errors.DatabaseError as err:
445            logging.error("DatabaseError: %s", err)
446            logging.error("SQL: %s, DATABASE: %s", keyword, self.database_file)
447            logging.error("params=%s", self.placeholder())
448            logging.error("query: %s", self.named_query(query))
449
450        if self.logging_verbose & 0x02:
451            print("=" * 80)
452            print(df.to_string())
453
454        logging.debug("SQL: %s, time: %s", keyword, query_end_time - query_start_time)
455        return df

プレースホルダ構築クラス

PlaceholderBuilder( individual: bool = True, guest_skip: bool = True, guest_skip2: bool = True, unregistered_replace: bool = True, friendly_fire: bool = False, statistics: bool = False, ranked: int = 3, stipulated: int = 0, stipulated_rate: float = 0.05, interval: int = 80, search_word: str = '', group_length: int = 0, service_type: libs.types.ServiceType = <ServiceType.UNKNOWN: 'unknown'>, command: str = '', channel_config: pathlib.Path | None = None, target_mode: int = 0, mode: int = 4, default_rule: str = '', rule_version: str = '', rule_list: list[str] = <factory>, mixed: bool = False, origin_point: int = 250, return_point: int = 300, rank_point: str = '', ignore_flying: bool = False, draw_split: bool = False, undefined_word: int = 1, player_name: str = '', guest_name: str = '', target_player: list[str] = <factory>, player_list: list[str] = <factory>, competition_list: list[str] = <factory>, all_player: bool = False, source: str = '', separate: bool = False, collection: str = '', aggregate_unit: Literal['A', 'M', 'Y', None] = None, target_count: int = 0, starttime: str | libs.utils.timekit.ExtendedDatetime | None = None, endtime: str | libs.utils.timekit.ExtendedDatetime | None = None, onday: str | libs.utils.timekit.ExtendedDatetime | None = None, score_comparisons: bool = False, verbose: bool = False, game_results: bool = False, versus_matrix: bool = False, order: bool = False, rating: bool = False, anonymous: bool = False, fourfold: bool = True, guest_mark: str = '※', format: Literal['default', 'csv', 'txt'] = 'default', filename: str = '', database_file: str | pathlib.Path = '', logging_verbose: int = 0)
service_type: libs.types.ServiceType = <ServiceType.UNKNOWN: 'unknown'>

連携先サービス

command: str = ''

コマンド名

channel_config: pathlib.Path | None = None

チャンネル個別設定状況

  • Path: 追加設定ファイルパス
  • None: 個別設定を利用していない
target_mode: int = 0

集計対象モードの指定

  • 0: settingのデフォルトに従う
  • not 0: 指定値でmodeを上書き
mode: int = 4

集計モード

default_rule: str = ''

ルール識別子(設定値)

rule_version: str = ''

ルール識別子(指定値)

rule_list: list[str]

集計対象ルール識別子

mixed: bool = False

ルール識別子の扱い

  • True: 定義済みすべてのルール識別子を含める
  • False: ルール識別子を個別指定
origin_point: int = 250

配給原点

return_point: int = 300

返し点

rank_point: str = ''

順位点(空白区切りの文字列)

ignore_flying: bool = False

トビカウントの無効化

draw_split: bool = False

同点時の順位点の取り扱い

  • True: 山分け
  • False: 席順
undefined_word: int = 1

未登録ワードの扱い

  • 0: 役満扱い
  • 1: カウントのみ
  • 2: 卓外清算(個人清算)
  • 3: 卓外清算(チーム清算)
player_name: str = ''

集計対象プレイヤー

guest_name: str = ''

ゲストの名前

target_player: list[str]

引数で受け付けたプレイヤーのリスト

player_list: list[str]

集計対象プレイヤーリスト

competition_list: list[str]

比較対象プレイヤーリスト

all_player: bool = False

検索対象に登録済みメンバー全員を加える

source: str = ''

スコア入力元識別子

separate: bool = False

スコア入力元識別子別集計フラグ

  • True: 識別子別に集計
  • False: すべて集計
collection: str = ''

集約集計

  • daily: 日次集約
  • weekly: 週次集約
  • monthly: 月次集約
  • yearly: 年次集約
  • all: 全体集約
aggregate_unit: Literal['A', 'M', 'Y', None] = None

レポート生成用日付範囲デフォルト値

  • A: 全期間
  • M: 月別
  • Y: 年別
  • None: 未定義
target_count: int = 0

直近ゲーム数指定

starttime: str | libs.utils.timekit.ExtendedDatetime | None = None

集計開始日時

endtime: str | libs.utils.timekit.ExtendedDatetime | None = None

集計終了日時

onday: str | libs.utils.timekit.ExtendedDatetime | None = None

time_adjust修正を含まない日時

score_comparisons: bool = False

スコア比較表示

verbose: bool = False

詳細情報表示

game_results: bool = False

ゲーム結果表示

versus_matrix: bool = False

対戦マトリックス表示

order: bool = False

順位推移グラフ表示

rating: bool = False

レーティング推移グラフ表示

anonymous: bool = False

匿名化フラグ

fourfold: bool = True

縦持ち/横持ちデータ判定

guest_mark: str = '※'

ゲスト無効時に未登録メンバーに付与する印

format: Literal['default', 'csv', 'txt'] = 'default'

出力フォーマット指定

filename: str = ''

出力ファイル名

database_file: str | pathlib.Path = ''

成績管理データベースファイル名

logging_verbose: int = 0

デバッグ情報出力レベル

def update_from_dict(self, input_dict: dict[str, typing.Any]) -> None:
157    def update_from_dict(self, input_dict: dict[str, Any]) -> None:
158        """
159        辞書の内容で値を更新する
160
161        Args:
162            input_dict (dict[str,Any]): 更新内容
163
164        """
165        field_list: list[str] = [x.name for x in fields(self)]
166        for k, v in input_dict.items():
167            if k in field_list:
168                setattr(self, k, v)

辞書の内容で値を更新する

Arguments:
  • input_dict (dict[str,Any]): 更新内容
def update_setting( self, main_config: pathlib.Path, key_name: str, val_type: type, fallback: Any = None) -> None:
170    def update_setting(self, main_config: "Path", key_name: str, val_type: type, fallback: Any = None) -> None:
171        """
172        優先度順に key_name を探索して値を更新する
173
174        Args:
175            main_config (Path): メイン設定ファイルパス
176            key_name (str): 探索するキー名
177            val_type (type): 取り込む値の型 (bool, str)
178            fallback (Any): 見つからなかった場合にセットする値
179            - *None* が指定されているときは値を更新しない
180
181        Note:
182            探索優先順序
183            1. 個別設定ファイル内settingセクション
184            2. メイン設定ファイル内チャンネル個別セクション
185            3. メイン設定ファイル内サービス別セクション
186            4. メイン設定ファイル内settingセクション
187
188        """
189        value: Optional[Any] = None
190        # 個別設定ファイル内探索
191        if self.channel_config:
192            value = lookup.get_config_value(
193                config_file=self.channel_config,
194                section="setting",
195                name=key_name,
196                val_type=val_type,
197                fallback=None,
198            )
199            if value is not None:
200                setattr(self, key_name, value)
201                return
202
203        # メイン設定ファイル内探索
204        for section_name in [self.source, self.service_type, "setting"]:
205            value = lookup.get_config_value(
206                config_file=main_config,
207                section=section_name,
208                name=key_name,
209                val_type=val_type,
210                fallback=None,
211            )
212            if value is not None:
213                setattr(self, key_name, value)
214                return
215
216        if fallback is not None:
217            setattr(self, key_name, fallback)

優先度順に key_name を探索して値を更新する

Arguments:
  • main_config (Path): メイン設定ファイルパス
  • key_name (str): 探索するキー名
  • val_type (type): 取り込む値の型 (bool, str)
  • fallback (Any): 見つからなかった場合にセットする値
    • None が指定されているときは値を更新しない
Note:

探索優先順序

  1. 個別設定ファイル内settingセクション
  2. メイン設定ファイル内チャンネル個別セクション
  3. メイン設定ファイル内サービス別セクション
  4. メイン設定ファイル内settingセクション

def query_modification(self, query: str) -> str:
219    def query_modification(self, query: str) -> str:
220        """
221        クエリをオプションの内容で修正する
222
223        Args:
224            query (str): 修正するクエリ
225
226        Returns:
227            str: 修正後のクエリ
228
229        """
230        if self.individual:  # 個人集計
231            query = query.replace("--[individual] ", "")
232            # ゲスト関連フラグ
233            if self.unregistered_replace:
234                query = query.replace("--[unregistered_replace] ", "")
235                if self.guest_skip:
236                    query = query.replace("--[guest_not_skip] ", "")
237                else:
238                    query = query.replace("--[guest_skip] ", "")
239            else:
240                query = query.replace("--[unregistered_not_replace] ", "")
241        else:  # チーム集計
242            self.unregistered_replace = False
243            self.guest_skip = True
244            query = query.replace("--[team] ", "")
245            if not self.friendly_fire:
246                query = query.replace("--[friendly_fire] ", "")
247
248        # 集約集計
249        match self.collection:
250            case "daily":
251                query = query.replace("--[collection_daily] ", "")
252                query = query.replace("--[collection] ", "")
253            case "weekly":
254                query = query.replace("--[collection_weekly] ", "")
255                query = query.replace("--[collection] ", "")
256            case "monthly":
257                query = query.replace("--[collection_monthly] ", "")
258                query = query.replace("--[collection] ", "")
259            case "yearly":
260                query = query.replace("--[collection_yearly] ", "")
261                query = query.replace("--[collection] ", "")
262            case "all":
263                query = query.replace("--[collection_all] ", "")
264                query = query.replace("--[collection] ", "")
265            case _:
266                query = query.replace("--[not_collection] ", "")
267
268        # 集計対象ルール
269        if self.rule_list:
270            query = query.replace("<<rule_list>>", ",".join([f":rule_{idx}" for idx, _ in enumerate(self.rule_list)]))
271        else:
272            query = query.replace("and rule_version in (<<rule_list>>)", "")
273            query = query.replace("and results.rule_version in (<<rule_list>>)", "")
274            query = query.replace("and game_info.rule_version in (<<rule_list>>)", "")
275
276        # 集計モード
277        match self.mode:
278            case 3:
279                query = query.replace("--[mode3] ", "")
280            case 4:
281                query = query.replace("--[mode4] ", "")
282
283        # スコア入力元識別子別集計
284        if self.separate:
285            query = query.replace("--[separate] ", "")
286
287        # コメント検索
288        if self.search_word or self.group_length:
289            query = query.replace("--[group_by] ", "")
290        else:
291            query = query.replace("--[not_group_by] ", "")
292
293        if self.search_word:
294            query = query.replace("--[search_word] ", "")
295        else:
296            query = query.replace("--[not_search_word] ", "")
297
298        if self.group_length:
299            query = query.replace("--[group_length] ", "")
300        else:
301            query = query.replace("--[not_group_length] ", "")
302            if self.search_word:
303                query = query.replace("--[comment] ", "")
304            else:
305                query = query.replace("--[not_comment] ", "")
306
307        # 直近N検索用(全範囲取得してから絞る)
308        if self.target_count:
309            query = query.replace("and my.playtime between", "-- and my.playtime between")
310
311        # プレイヤーリスト
312        if self.player_name and self.player_list:
313            query = query.replace("--[player_name] ", "")
314            query = query.replace(
315                "<<player_list>>",
316                ", ".join([f":player_{idx}" for idx, _ in enumerate(self.player_list)]),
317            )
318        query = query.replace("<<guest_mark>>", self.guest_mark)
319
320        # フラグの処理
321        match self.aggregate_unit:
322            case "M":
323                query = query.replace("<<collection>>", "substr(collection_daily, 1, 7) as 集計")
324                query = query.replace("<<group by>>", "group by 集計")
325            case "Y":
326                query = query.replace("<<collection>>", "substr(collection_daily, 1, 4) as 集計")
327                query = query.replace("<<group by>>", "group by 集計")
328            case "A":
329                query = query.replace("<<collection>>", "'合計' as 集計")
330                query = query.replace("<<group by>>", "")
331            case _:
332                query = query.replace("<<collection>>,", "-- <<collection>>")
333                query = query.replace("<<group by>>", "-- <<group by>>")
334
335        if self.interval:
336            query = query.replace("<<Calculation Formula>>", "(row_number() over (order by total_count desc) - 1) / :interval")
337        else:
338            query = query.replace("<<Calculation Formula>>", ":interval")
339
340        if self.undefined_word is not None:
341            match self.undefined_word:
342                case 0:
343                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 0)")
344                case 1:
345                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 1)")
346                case 2:
347                    query = query.replace("<<where_string>>", "and (words.type is null or words.type = 2)")
348                case _:
349                    query = query.replace("<<where_string>>", "and (words.type = 1 or words.type = 2)")
350        else:
351            query = query.replace(":undefined_word", "1")
352
353        # queryコメント削除
354        query = re.sub(r"^ *--\[.*$", "", query, flags=re.MULTILINE)
355        query = re.sub(r"\n+", "\n", query, flags=re.MULTILINE)
356
357        return query

クエリをオプションの内容で修正する

Arguments:
  • query (str): 修正するクエリ
Returns:

str: 修正後のクエリ

def named_query(self, query: str) -> str:
359    def named_query(self, query: str) -> str:
360        """
361        クエリにパラメータをバインドして返す
362
363        Args:
364            query (str): SQL
365
366        Returns:
367            str: バインド済みSQL
368
369        """
370        return textwrap.dedent(
371            re.sub(
372                r":(\w+)",
373                lambda m: repr(self.placeholder().get(m.group(1), m.group(0))),
374                query,
375            ),
376        ).strip()

クエリにパラメータをバインドして返す

Arguments:
  • query (str): SQL
Returns:

str: バインド済みSQL

def placeholder(self, game_count: int | None = None) -> dict[str, typing.Any]:
378    def placeholder(self, game_count: Optional[int] = None) -> dict[str, Any]:
379        """
380        プレースホルダ用辞書出力
381
382        Args:
383            game_count (Optional[int]): 規定打数調整用ゲーム数. Defaults to None.
384
385        Returns:
386            dict[str, Any]: プレースホルダ
387
388        """
389        ret_dict: dict[str, Any] = {f.name: getattr(self, f.name) for f in fields(self)}
390
391        # 規定打数更新
392        if not ret_dict.get("stipulated") or game_count is not None:
393            if game_count is None:
394                ret_dict.update({"stipulated": 1})
395            else:
396                ret_dict.update({"stipulated": int(ceil(game_count * self.stipulated_rate) + 1)})
397
398        if self.player_list:
399            ret_dict.update({f"player_{idx}": x for idx, x in enumerate(self.player_list)})
400
401        if self.target_player:
402            ret_dict.update({f"target_{idx}": x for idx, x in enumerate(self.target_player)})
403
404        if self.competition_list:
405            ret_dict.update({f"competition_{idx}": x for idx, x in enumerate(self.competition_list)})
406
407        if self.rule_list:
408            ret_dict.update({f"rule_{idx}": x for idx, x in enumerate(self.rule_list)})
409        else:
410            ret_dict.update({"rule_0": self.rule_version})
411
412        # 日付型変換
413        for date_attr in ["starttime", "endtime", "onday"]:
414            if (val := ret_dict.get(date_attr)) and isinstance(val, ExtDt):
415                ret_dict.update({date_attr: val.format(ExtDt.FMT.SQL)})
416
417        return ret_dict

プレースホルダ用辞書出力

Arguments:
  • game_count (Optional[int]): 規定打数調整用ゲーム数. Defaults to None.
Returns:

dict[str, Any]: プレースホルダ

def read_data(self, keyword: str) -> pandas.DataFrame:
419    def read_data(self, keyword: str) -> pd.DataFrame:
420        """
421        データベースからデータを取得する
422
423        Args:
424            keyword (str): SQL選択キーワード
425
426        Returns:
427            pd.DataFrame: 集計結果
428
429        """
430        query = self.query_modification(dbutil.query(keyword))
431
432        if self.logging_verbose & 0x01:
433            print(f">>> params={self.placeholder()}")
434            print(f">>> SQL: {keyword} -> {self.database_file}\n{self.named_query(query)}")
435
436        try:
437            query_start_time = ExtDt().dt.timestamp()
438            df = pd.read_sql(
439                sql=query,
440                con=dbutil.connection(self.database_file),
441                params=self.placeholder(),
442            )
443            query_end_time = ExtDt().dt.timestamp()
444        except pd.errors.DatabaseError as err:
445            logging.error("DatabaseError: %s", err)
446            logging.error("SQL: %s, DATABASE: %s", keyword, self.database_file)
447            logging.error("params=%s", self.placeholder())
448            logging.error("query: %s", self.named_query(query))
449
450        if self.logging_verbose & 0x02:
451            print("=" * 80)
452            print(df.to_string())
453
454        logging.debug("SQL: %s, time: %s", keyword, query_end_time - query_start_time)
455        return df

データベースからデータを取得する

Arguments:
  • keyword (str): SQL選択キーワード
Returns:

pd.DataFrame: 集計結果