Mantisで重複先課題総数を課題一覧とExcel エクスポートに表示する



# 概要

Mantisの検索画面およびそこからのExcel出力時に、課題の関連付けで、**重複先**に設定された課題数を整数値で表示します。

このカスタマイズは、通常用意されているカスタマイズ方法とは逸脱しており、プラグインも使用しません。
すなわち、coreのphpファイルを複数編集し、Mantis全体に影響を及ぼします。
ON・OFF機能も[^1]ありません。
今後のバージョンアップの追従に悪影響を及ぼすと考えてもらっていいと思います。

対象バージョンは`1.2.17`となります。
現在開発中の`1.3.x`系統とは細部が異なり、同様の変更ができるとは限りません。[^2]

再度触る場合のために調査過程から書いています。

# 背景(読み飛ばしOK)

各所から上がってくるバグ報告の、改修優先度を決める際の指標の一つとして、同様のバグ報告数を使うとする。
報告は同じバグでも環境ごとに細部がことなるため、先着の課題にコメントで追記していくのは不十分な可能性がある。(加えて重複数を手動でカウントしなければならない(コメント数≠重複数))
そのため、重複の課題登録を許可し、重複が発見された場合は、関連機能を使い、**重複元**,**重複先**関係を構築する。

次期改修課題の決定の際は、個々の課題を確認するのではなく、課題のExcel出力から、各課題の概要(タイトル)と優先度(標準機能：別ルールで決定)と報告数(発生数、重複数)を俯瞰し協議する。

**関係**は標準機能では表示項目に設定できないため、手動でカスタマイズを行い、**重複先**数のみを集計し、新規列に表示したい。


# 注意

いろいろな箇所をいじります。
数自体は多くはないのですが、その関連性が見えにくいというか、カスタマイズ中はあっちいったりこっちいったりで混乱します。多分数日経ったら全貌を忘れますし、カスタマイズ中も把握していたというと嘘になります。
プラグインで一から出力機能を作ったほうがまだいい感じに思えます。
できるだけメモをとりながら、なぜここに書いたか、こことこことは似ているのになぜ違うかを把握したほうがいいでしょう。

# :one: 課題一覧の出力用SQLの確認

課題一覧とは以下の画面のこと(DEMOから拝借)
![mantis serach.png](https://qiita-image-store.s3.amazonaws.com/0/62354/2058481b-67b2-6094-7267-347df7b8e658.png)

「要約」の隣に新たに列を追加します。

この一覧をどうやって作っているのかを追うと、URLの/[view_all_bug_page.php]から辿り、/[view_all_inc.php]でHTML出力している。しかし、ループする課題の変数`$rows`は戻って/[view_all_bug_page.php]から

```php
$rows = filter_get_bug_rows( $f_page_number, $t_per_page, $t_page_count, $t_bug_count, null, null, null, true );
```

で取得されている。
`$rows`をデバッグすると、課題情報のオブジェクトが配列で入っているので、DBの取得結果と推測する。

`filter_get_bug_rows()`は/core/[filter_api.phpの1037行目]から長々と書かれているが、SQLの組み立てとだけ認識して末尾から読む。

```php:filter_get_bug_rows()return前

	$t_result = db_query_bound( "$t_select_string $t_from_string $t_join_string $t_where_string $t_order_string", $t_query_clauses['where_values'], $p_per_page, $t_offset );
	$t_row_count = db_num_rows( $t_result );

	$t_id_array_lastmod = array();
	for( $i = 0;$i < $t_row_count;$i++ ) {
		$t_row = db_fetch_array( $t_result );
		$t_id_array_lastmod[] = (int) $t_row['id'];
		$t_rows[] = $t_row;
	}

	return filter_cache_result( $t_rows, $t_id_array_lastmod );
}

```

明らかに`db_query_bound()`がクエリ実行なので、引数をデバッグ

```php:クエリ変数デバッグ

var_dump($t_select_string); // "SELECT DISTINCT mantis_bug_table.*"
var_dump($t_from_string);   // " FROM mantis_bug_table"
var_dump($t_join_string);   // "JOIN mantis_project_table ON mantis_project_table.id = mantis_bug_table.project_id"
var_dump($t_where_string);  // "WHERE mantis_project_table.enabled = ? AND ( mantis_bug_table.project_id = 5 ) AND ( ( mantis_bug_table.status in (?, ?, ?, ?, ?) ) ) "
var_dump($t_order_string);  // " ORDER BY mantis_bug_table.sticky DESC, mantis_bug_table.last_updated DESC, mantis_bug_table.date_submitted DESC"
var_dump($t_query_clauses['where_values']); // array(1,10,50,60,70,80)

```

合わせると

```sql

SELECT DISTINCT mantis_bug_table.* FROM mantis_bug_table JOIN mantis_project_table ON mantis_project_table.id = mantis_bug_table.project_id WHERE mantis_project_table.enabled = 1 AND ( mantis_bug_table.project_id = 5 ) AND ( ( mantis_bug_table.status in (10, 50, 60, 70, 80) ) )  ORDER BY mantis_bug_table.sticky DESC, mantis_bug_table.last_updated DESC, mantis_bug_table.date_submitted DESC;

```

フィルターやプロジェクト選択で細部は変わると思われるが、そこは問題無いはず。
JOINやWHEREが綺麗に変数分けされているので、この変数群にうまく重複数を追加するようにSQLを足せばいいはず。

# :two: 重複数を計算するSQL

mantisの*関係*はDBの`mantis_bug_relationship_table`に入っている。構造はこんな感じ。

```

mysql> show columns from mantis_bug_relationship_table;
+--------------------+------------------+------+-----+---------+----------------+
| Field              | Type             | Null | Key | Default | Extra          |
+--------------------+------------------+------+-----+---------+----------------+
| id                 | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| source_bug_id      | int(10) unsigned | NO   | MUL | 0       |                |
| destination_bug_id | int(10) unsigned | NO   | MUL | 0       |                |
| relationship_type  | smallint(6)      | NO   |     | 0       |                |
+--------------------+------------------+------+-----+---------+----------------+
4 rows in set (0.10 sec)

```

幸いにも、**重複元** **重複先**関係は、`source_bug_id`と`destination_bug_id`で表されている(重複先が`source_bug_id`に現れることはない)。
よって、単純に、`relationship_type`が**重複元**の`source_bug_id`をグループ化してやれば良い。

以下で求められる(ちょっと`count(*)`とかあやしい)。

```sql:課題ごとの重複先数を集計するSQL

mysql> select source_bug_id, count(*) as count from  mantis_bug_relationship_table where relationship_type = 0 group by source_bug_id;
+---------------+-------+
| source_bug_id | count |
+---------------+-------+
|           169 |     2 |
|           324 |     1 |
+---------------+-------+
2 rows in set (0.00 sec)

```

これを、:one:の変数に組み込む。

# :three: 課題一覧の出力に重複数を追加する

SQLに疎いのでかなり悪戦苦闘しましたが、JOINで以下な感じになりました。

```php

	// カスタマイズ
	$t_select_string = $t_select_string . ', COALESCE(mantis_bug_relationship_table.count, "") as duplicate_count';
	$t_join_string = $t_join_string . ' LEFT JOIN (select source_bug_id, count(*) as count from  mantis_bug_relationship_table where relationship_type = 0 group by source_bug_id) as mantis_bug_relationship_table ON mantis_bug_table.id = mantis_bug_relationship_table.source_bug_id';

	$t_result = db_query_bound( "$t_select_string $t_from_string $t_join_string $t_where_string $t_order_string", $t_query_clauses['where_values'], $p_per_page, $t_offset );
```

単純に`LEFT JOIN`で副問い合わせを結合し、重複が無い行には`NULL`が入るので`COALESCE()`で空文字に初期化します。初期化は0でもよかったですが、コメント数などを参考に空に。ここで`COALESCE()`を使ったことで`as`で名前付け出来たのが、もしかしたらよかったかもしれません。


再度`$rows`を`var_dump`し、結果のオブジェクトに`duplicate_count`が増えていればOKです。(まだHTMLには反映されません。)

# :four: 課題一覧に表示させる

課題一覧に表示させるには、表示項目管理の表示項目の欄にカラム名を追加します。(管理でもアカウント毎でも)
詳しくは[Mantisの使い方(検索とCSV出力の表示項目) - Symfoware]
課題一覧なので、「表示項目」に`duplicate_count`を追加したいのですが、そのままだと「全項目」に存在しないと弾かれる。「全項目」は`readonly`で編集不可という状態です。

## 「全項目」に`duplicate_count`を追加する。

「表示項目管理」画面のURLから/[manage_config_columns_page.php]を調査し、何所から全項目を引っ張ってきているのかを調べます。
短いですね。共通部分と画面部分の読み込みのみですので、肝心の/[manage_columns_inc.php]を調べます。
怪しいのは[48行目の$t_columns = columns_get_all( $t_project_id );]ですね。その後のHTMLの作り方から見てもこれです。
`columns_get_all()`は/core/[columns_api.phpの175行目]です
戻り値は配列なので`return`前に追加しちゃいます。

```php:columns_get_all()

	$t_columns[] = 'duplicate_count'; // 無理矢理カラム追加
	return $t_columns;
}

```

ブラウザから「全項目」の最後に`, duplicate_count`が増えていればOKです。
「表示項目」にも`, duplicate_count`が追加できるようになったので、追加しましょう。

## `duplicate_count`をソート可能にする


課題一覧を見ると画像の様に列が増えているはずです。

![duplicate_count.png](https://qiita-image-store.s3.amazonaws.com/0/62354/045c7981-9ee8-3276-dae5-02c49c5efabe.png)

他のリンクになっている列名と同じく、リンクをクリックすると、ソートしようとします。
また、検索フィルターの並び順にも追加されています。

![filter.png](https://qiita-image-store.s3.amazonaws.com/0/62354/52f94235-18ba-8003-b9f0-09344a6839d0.png)

ですが、これらを使って`duplicate_count`でソートすると、SQLエラーになります。
原因は、カスタマイズ項目でもプラグイン設定されてもいない項目=デフォルト項目のソート時に、`mantis_bug_table`をつけようとするからです。
つまり`ORDER by mantis_bug_table.duplicate_count`でソートしようとするのですが、`duplicate_count`は副問い合わせ結果なので、エラーになります。
命名時に`as mantis_bug_table.duplicate_count`と横着しようとしましたが、これも怒られました。当然ですね。

なので、`duplicate_count`でのソート時は`mantis_bug_table`を付けないようにする必要があります。

`ORDER BY`はどこで作成されているか。
重複数の追加のときにすでに出ています。

```php

var_dump($t_order_string);  // " ORDER BY mantis_bug_table.sticky DESC, mantis_bug_table.last_updated DESC, mantis_bug_table.date_submitted DESC"

```

`$t_order_string`は/core/[filter_api.phpの2048行目]で`$t_query_clauses['order']`から組み立てられており、
`$t_query_clauses['order']`で既にテーブル名は付加済み。

`$t_query_clauses['order']`は/core/[filter_api.phpの2033行目]で、`filter_get_query_sort_data()`に`$t_query_clauses`を渡し、そこで`['order']`を追加した上で`$t_query_clauses`に上書きします。

`filter_get_query_sort_data()`は同じく、/core/[filter_api.phpの892行目]

で、よく見かける3種類の分類をしています。

1. カスタムフィールド用
2. プラグインフィールド用
3. その他デフォルト用

デフォルト処理が以下

```php

			# standard column
			} else {
				$t_sort_col = "$t_bug_table.$c_sort";

				# when sorting by due_date, always display undefined dates last
				if( 'due_date' == $c_sort && 'ASC' == $c_dir ) {
					$t_sort_due_date = "$t_sort_col = 1";
					$p_query_clauses['select'][] = $t_sort_due_date;
					$t_sort_col = "$t_sort_due_date, $t_sort_col";
				}

				$p_query_clauses['order'][] = "$t_sort_col $c_dir";
			}

```

`$t_sort_col = "$t_bug_table.$c_sort";`でテーブル名が付加されているので、`else`の前に`else if`を追加して、`duplicate_count`専用の処理を追加します。

```php

			} else if($c_sort == 'duplicate_count') {
				$p_query_clauses['order'][] = "$c_sort $c_dir";

			# standard column
			} else {

```

これでSQLエラーがでなくなり、ソートが可能になりました。

![duplicate_count_sort.png](https://qiita-image-store.s3.amazonaws.com/0/62354/ef5bbcfc-37f2-054d-ed7e-24b4ef0d3529.png)



# :five: Excel エクスポートに表示させる

Excel エクスポートに使用するカラムは、表示項目管理の「Excel 項目」です。課題一覧と同じく、ここにも`, duplicate_count`を追加し、更新します。

## まずは出力

個人的にはHTMLより単純なので、これでカラムが増え、あとは和訳だけだと思っていたのですが…ファイルが出力されなくなる。
Excel エクスポート機能は/[excel_xml_export.php]
よくわからないので/[excel_xml_export.php]に`ini_set( 'display_errors', 1 );`を書いて、再読み込み。今度はファイル保存のダイアログが出て、開いたxmlには

```
Fatal error: Call to undefined function excel_format_duplicate_count() in /mantisbt-1.2.17/excel_xml_export.php on line 84
```

[84行目を見ると]

```php
$t_function = 'excel_format_' . $t_column;
echo $t_function( $t_row );
```

と、どうやらカラム毎に値の出力用関数が必要なようなので、`excel_format_duplicate_count()`を作成します。
場所は/core/[excel_api.php]です。

`print_column_title_duplicate_count`のときの様に、他の関数をコピーして…

```php

function excel_format_duplicate_count( $p_bug ) {
	return excel_prepare_string( $p_bug->duplicate_count );
}

```

です。[excel_prepare_string()]は`& < >`のHTML(XML?) エンティティ化で、整数値と空文字の`duplicate_count`には不要なのですが…やっぱりお約束でつけておきます。

これでxlsの出力が可能になりました。値は正常に反映され、1行目は`dulicate_count`になります。

![xls_before.JPG](https://qiita-image-store.s3.amazonaws.com/0/62354/a7b50812-9a7d-201d-5c70-4715ffd86970.jpeg)


## 項目名を変更する

では最後に`dulicate_count`に項目名をつけ日本語にします。

あやしいのは[excel_xml_export.phpの62行目]
`echo excel_get_titles_row();`

定義は/core/[excel_api.phpの107行目]

カラムごとに`$t_ret .= excel_format_column_title( column_get_title( $t_column ) );`としています。

[excel_api.php]の[excel_format_column_title()]はカラム名をExcel用のXMLにくるんでいるだけなので、/core/[columns_api.php]の[column_get_title()]ですね。

これ、結構[custom_function_default_print_column_title()]と似ていて、の割にはオーバーライド不可だったりして、もやっとしてしまいました。
[column_get_title()]は同様にカスタム、プラグイン、その他で処理が分かれていますが、`if else`を使わず[ガード]というか**関数から早く返す**[^4]が実践できてますね。
(この[ガード]が使うべき[特殊な例外であるか]は別問題ですが、個人的には`else(ネスト)`は極力減らしたいです。)
閑話休題。

で、カスタムでもプラグインでむにゃむにゃでもないので、`switch文`に入ります。そこで`case 'bugnotes_count'`の様に

```php

case 'duplicate_count':
			return '重複している課題';

```

といれれば完成しそうなんですが…
現状入っている

```php

default:
	return lang_get_defaulted( $p_column );

```

で、恐らく言語ファイルから訳を取ってきているように見えます。
カスタマイズの気楽さが
**言語ファイルへの追加 >> coreのphpファイルの編集**
なので、`case`の追加はせず、言語ファイルに追加してみます。
(lang_*系の処理は読み飛ばしたんですが、まぁやりたいことはどこでも同じだろうということで…)

対象ファイルは/lang/[strings_japanese.txt]

見た感じルールが`$s_カラム名 = 訳`なので、`$s_duplicate_count = '重複している課題';`と追記して保存。

これでExcel エクスポートも日本語になりました！

![xls_after.JPG](https://qiita-image-store.s3.amazonaws.com/0/62354/8fa8c606-723a-a9be-742b-47cef99ed3e4.jpeg)

ついでにデフォルト処理に引っ張られ、課題一覧も日本語化されたので、これにて完成です。

# 欠番：項目名を変更する

欠番説明：このセクションは本来、**「全項目」に`duplicate_count`を追加する。**の次にありました。
差し替え項で説明しているかもしれませんが、以下の作業を行っても、フィルターから`duplicate_count`のソートが可能であり、同様のエラーが発生します。
そこで、差し替え項ではソートが可能になるようにしましたが、項目名の変更というカスタマイズの手法として、欠番扱いで残しておきます。

***

…の前に！この段階の課題一覧の重複数表示は恐らくこんな感じなはず。

![duplicate_count.png](https://qiita-image-store.s3.amazonaws.com/0/62354/045c7981-9ee8-3276-dae5-02c49c5efabe.png)

(全部重複無しなので空文字""が入っています(悪い例画像))

で、カラム名がリンクになっています。クリックすると、ソートしようとします。ですけど、`duplicate_count`は無理矢理つけたので、当然失敗し、エラーになります。そして
**`duplicate_count`をソート対象に選択したという記録が残り、以後検索画面を表示するたびにエラーになります。**
**別のカラムをソート対象に設定し直そうとしても、そもそもそのための検索画面がエラーで表示されません。**
**ログアウトしても無駄です。詰みです。**

というぐらい焦りましたが、「表示項目」から一旦`, duplicate_count`を削除すれば大丈夫です。

重複数のソートができればそれにこしたことはないですが、ちょっとキツイので、コメント数などと同様にソート不可にしたいです。

では、カラム名がどうやって出力されているか追います。
/[view_all_inc.phpの163行目]からがカラム名の出力ですね。
`helper_call_custom_function()`を使っているということは、関数をオーバーライドできるのですが…オーバーライドの仕組みを飛ばし、実際に呼ばれるデフォルトの[custom_function_default_print_column_title()]を見ます[^3]。
中身は大きく`if else`でカスタム項目とその他に分かれていて、今回はその他ですね。

```php:custom_function_default_print_column_title()のelse

	} else {
		$t_plugin_columns = columns_get_plugin_columns();
		$t_function = 'print_column_title_' . $p_column;
		if( function_exists( $t_function ) ) {
			$t_function( $t_sort, $t_dir, $p_columns_target );
		} else if ( isset( $t_plugin_columns[ $p_column ] ) ) {
			$t_column_object = $t_plugin_columns[ $p_column ];
			print_column_title_plugin( $p_column, $t_column_object, $t_sort, $t_dir, $p_columns_target );
		} else {
			echo '<td>';
			print_view_bug_sort_link( column_get_title( $p_column ), $p_column, $t_sort, $t_dir, $p_columns_target );
			print_sort_icon( $t_dir, $t_sort, $p_column );
			echo '</td>';
		}
	}

```

で、その中でも3種類に別れ、

1. `print_column_title_カラム名()`の関数があればそれを実行
2. プラグインでむにゃむにゃしているならそれ
3. それ以外はデフォルトでソートリンク付きHTML

のようです。
先の画像の例は3のデフォルトのようです。
プラグインはよくわからないし、せっかく仕組みを用意していくれているので、今回は1の`print_column_title_duplicate_count()`を作成します。
(`custom_function_default_print_column_title()`はせっかくカスタマイズできる仕様なのにしませんでした。`custom_function_override_print_column_title()`を作って、`duplicate_count`のときはこう！というのを追加するのもいいと思います。)

といっても、適当な`print_column_title_*()`を拾ってさらっと改変

```php

function print_column_title_duplicate_count( $p_sort, $p_dir, $p_columns_target = COLUMNS_TARGET_VIEW_PAGE ) {
	echo '<td> 重複先数 </td>';
}

```

場所は/core/[columns_api.php]です。
引数はなくてもいいと思いますが、お約束で。
文字の位置は他の雰囲気的に`<td class="left">`などで出来そうカモ。

これで課題一覧での出力はひとまず完成です。

![mantis serach2.png](https://qiita-image-store.s3.amazonaws.com/0/62354/558809d7-0e7c-1dd8-a0f7-cd45df4a2de5.png)

日本語でソート無しのカラムになりました。

[filter_api.phpの892行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/filter_api.php#L892
[filter_api.phpの1037行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/filter_api.php#L1037
[filter_api.phpの2048行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/filter_api.php#L2048
[filter_api.phpの2033行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/filter_api.php#L2033
[view_all_bug_page.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/view_all_bug_page.php
[view_all_inc.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/view_all_inc.php
[view_all_inc.phpの163行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/view_all_inc.php#L163
[manage_config_columns_page.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/manage_config_columns_page.php
[manage_columns_inc.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/manage_columns_inc.php
[48行目の$t_columns = columns_get_all( $t_project_id );]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/manage_columns_inc.php#L48
[columns_api.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/columns_api.php
[columns_api.phpの175行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/columns_api.php#L175
[custom_function_default_print_column_title()]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/custom_function_api.php#L238
[excel_xml_export.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/excel_xml_export.php
[84行目を見ると]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/excel_xml_export.php#L83-L84
[excel_xml_export.phpの62行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/excel_xml_export.php#L62
[excel_api.php]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/excel_api.php
[excel_api.phpの107行目]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/excel_api.php#L107
[excel_prepare_string()]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/excel_api.php#L142
[excel_format_column_title()]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/excel_api.php#L76
[column_get_title()]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/core/columns_api.php#L262
[strings_japanese.txt]:https://github.com/mantisbt/mantisbt/blob/release-1.2.17/lang/strings_japanese.txt


[Mantisの使い方(検索とCSV出力の表示項目) - Symfoware]:http://symfoware.blog68.fc2.com/blog-entry-1042.html
[ガード]:https://ja.wikipedia.org/wiki/%E3%82%AC%E3%83%BC%E3%83%89_%28%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%29
[特殊な例外であるか]:http://dqn.sakusakutto.jp/2012/08/if-else.html

[^1]: 今回の実装では(configで切り替えは出来そうだと思っています)
[^2]: **1.3.x**のコードをしっかり読んでいませんし、そもそも**1.2.17**も把握しきれていません…
[^3]: `custom_function_override_print_column_title()`があればそちらが実行されます。`helper_call_custom_function()`の仕組みです。
[^4]: リーダブルコードにあったはず