    001/*
    002 * Copyright (c) 2009 The openGion Project.
    003 *
    004 * Licensed under the Apache License, Version 2.0 (the "License");
    005 * you may not use this file except in compliance with the License.
    006 * You may obtain a copy of the License at
    007 *
    008 *     http://www.apache.org/licenses/LICENSE-2.0
    009 *
    010 * Unless required by applicable law or agreed to in writing, software
    011 * distributed under the License is distributed on an "AS IS" BASIS,
    012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
    013 * either express or implied. See the License for the specific language
    014 * governing permissions and limitations under the License.
    015 */
    016package org.opengion.fukurou.xml;
    017
    018import org.opengion.fukurou.system.OgRuntimeException ;         // 6.4.2.0 (2016/01/29)
    019import java.io.ByteArrayInputStream;
    020import java.io.IOException;
    021import java.io.InputStream;
    022import java.io.UnsupportedEncodingException;
    023import java.util.ArrayList;
    024import java.util.concurrent.ConcurrentMap;                                                      // 6.4.3.3 (2016/03/04)
    025import java.util.concurrent.ConcurrentHashMap;                                          // 6.4.3.1 (2016/02/12) refactoring
    026import java.util.List;
    027import java.util.Locale;
    028
    029import javax.xml.parsers.ParserConfigurationException;
    030import javax.xml.parsers.SAXParser;
    031import javax.xml.parsers.SAXParserFactory;
    032
    033import org.opengion.fukurou.util.StringUtil;
    034import org.xml.sax.Attributes;
    035import org.xml.sax.SAXException;
    036import org.xml.sax.helpers.DefaultHandler;
    037
    038/**
    039 * XML2TableParser は、XMLを表形式に変換するためのXMLパーサーです。
    040 * XMLのパースには、SAXを採用しています。
    041 *
    042 * このクラスでは、XMLデータを分解し、2次元配列の表データ、及び、指定されたキーに対応する
    043 * 属性データのマップを生成します。
    044 *
    045 * これらの配列を生成するためには、以下のパラメータを指定する必要があります。
    046 *
    047 * ①2次元配列データ(表データ)の取り出し
    048 *   行のキー(タグ名)と、項目のキー一覧(タグ名)を指定することで、表データを取り出します。
    049 *   具体的には、行キーのタグセットを"行"とみなし、その中に含まれる項目キーをその列の"値"と
    050 *   して分解されます。(行キーがN回出現すれば、N行が生成されます。)
    051 *   もし、行キーの外で、項目キーのタグが出現した場合、その項目キーのタグは無視されます。
    052 *
    053 *   また、colKeysにPARENT_TAG、PARENT_FULL_TAGを指定することで、rowKeyで指定されたタグの
    054 *   直近の親タグ、及びフルの親タグ名(親タグの階層を"&gt;[タグA]&gt;[タグB]&gt;[タグC]&gt;"で表現)を
    055 *   取得することができます。
    056 *
    057 *   行キー及び項目キーは、{@link #setTableCols(String, String[])}で指定します。
    058 *
    059 * ②属性データのマップの取り出し
    060 *   属性キー(タグ名)を指定することで、そのタグ名に対応した値をマップとして生成します。
    061 *   同じタグ名が複数回にわたって出現した場合、値はアペンドされます。
    062 *
    063 *   属性キーは、{@link #setReturnCols(String[])}で指定します。
    064 *
    065 * ※それぞれのキー指定は、大文字、小文字を区別した形で指定することができます。
    066 *   但し、XMLのタグ名とマッチングする際は、大文字、小文字は区別せずにマッチングされます。
    067 *
    068 * @og.rev 6.3.9.1 (2015/11/27) 修飾子を、なし → private に変更(フィールド)
    069 *
    070 * @version  4.0
    071 * @author   Hiroki Nakamura
    072 * @since    JDK5.0,
    073 */
    074public class XML2TableParser extends DefaultHandler {
    075
    076        // 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
    077        private static final String PARENT_FULL_TAG_KEY = "PARENT_FULL_TAG";            // 6.3.9.1 (2015/11/27)
    078        private static final String PARENT_TAG_KEY              = "PARENT_TAG";                         // 6.3.9.1 (2015/11/27)
    079
    080        // 6.4.3.3 (2016/03/04) getColIdx( String ) で、存在しない場合に返す、-1 の Integer オブジェクト定義
    081        private static final int NO_IDX = -1;
    082
    083        /*-----------------------------------------------------------
    084         *  表形式パース
    085         *-----------------------------------------------------------*/
    086        // 表形式パースの変数
    087        private String rowCpKey  = "";                                                                          // 6.3.9.1 (2015/11/27)
    088        private String colCpKeys = "";                                                                          // 6.3.9.1 (2015/11/27)
    089        /** 6.4.3.1 (2016/02/12) PMD refactoring. HashMap → ConcurrentHashMap に置き換え。 */
    090        private final ConcurrentMap<String,Integer> colIdxMap = new ConcurrentHashMap<>();              // 6.3.9.1 (2015/11/27)
    091
    092        // 表形式出力データ
    093        private final List<String[]> rows = new ArrayList<>();                          // 6.3.9.1 (2015/11/27)
    094        private String[] data;                                                                                          // 6.3.9.1 (2015/11/27)
    095        private String[] cols;                                                                                          // 6.3.9.1 (2015/11/27)
    096
    097        /*-----------------------------------------------------------
    098         *  Map型パース
    099         *-----------------------------------------------------------*/
    100        // Map型パースの変数
    101        private String rtnCpKeys = "";                                                                          // 6.3.9.1 (2015/11/27)
    102
    103        // Map型出力データ
    104        /** 6.4.3.1 (2016/02/12) PMD refactoring. HashMap → ConcurrentHashMap に置き換え。 */
    105        private final ConcurrentMap<String,String> rtnKeyMap    = new ConcurrentHashMap<>();            // 6.3.9.1 (2015/11/27)
    106        /** 6.4.3.1 (2016/02/12) PMD refactoring. HashMap → ConcurrentHashMap に置き換え。 */
    107        private final ConcurrentMap<String,String> rtnMap               = new ConcurrentHashMap<>();            // 6.3.9.1 (2015/11/27)
    108
    109        /*-----------------------------------------------------------
    110         *  パース中のタグの状態定義
    111         *-----------------------------------------------------------*/
    112        private boolean isInRow         ;               // rowKey中に入る間のみtrue                                                            // 6.3.9.1 (2015/11/27)
    113        private String curQName         = "";   // パース中のタグ名    ( [タグC]             )                    // 6.3.9.1 (2015/11/27)
    114        private String curFQName        = "";   // パース中のフルタグ名( [タグA]>[タグB]>[タグC] )              // 6.3.9.1 (2015/11/27)
    115
    116        private int pFullTagIdx = -1;                                                                   // 6.3.9.1 (2015/11/27)
    117        private int pTagIdx             = -1;                                                                   // 6.3.9.1 (2015/11/27)
    118
    119        /*-----------------------------------------------------------
    120         *  href、IDによるデータリンク対応
    121         *-----------------------------------------------------------*/
    122        private String curId = "";                                                                                                                                                              // 6.3.9.1 (2015/11/27)
    123        private final List<RowColId>     idList = new ArrayList<>();    // row,colとそのIDを記録                              // 6.3.9.1 (2015/11/27)
    124        /** 6.4.3.1 (2016/02/12) PMD refactoring. HashMap → ConcurrentHashMap に置き換え。 */
    125        private final ConcurrentMap<String,String> idMap        = new ConcurrentHashMap<>();    // col__idをキーに値のマップを保持          // 6.3.9.1 (2015/11/27)
    126
    127        private final InputStream input;                                                                // 6.3.9.1 (2015/11/27)
    128
    129        /**
    130         * XMLの文字列を指定してパーサーを形成します。
    131         *
    132         * @og.rev 6.4.1.1 (2016/01/16) PMD refactoring. It is a good practice to call super() in a constructor
    133         *
    134         * @param st XMLデータ(文字列)
    135         */
    136        public XML2TableParser( final String st ) {
    137                super();
    138                byte[] bts = null;
    139                try {
    140                        bts = st.getBytes( "UTF-8" );
    141                }
    142                catch( final UnsupportedEncodingException ex ) {
    143                        final String errMsg = "不正なエンコードが指定されました。エンコード=[UTF-8]"  ;
    144                        throw new OgRuntimeException( errMsg , ex );
    145                }
    146                // XML宣言の前に不要なデータがあれば、取り除きます。
    147                final int offset = st.indexOf( '<' );
    148                input = new ByteArrayInputStream( bts, offset, bts.length - offset  );
    149        }
    150
    151        /**
    152         * ストリームを指定してパーサーを形成します。
    153         *
    154         * @og.rev 6.4.1.1 (2016/01/16) PMD refactoring. It is a good practice to call super() in a constructor
    155         *
    156         * @param is XMLデータ(ストリーム)
    157         */
    158        public XML2TableParser( final InputStream is ) {
    159                super();
    160                input = is;
    161        }
    162
    163        /**
    164         * 2次元配列データ(表データ)の取り出しを行うための行キーと項目キーを指定します。
    165         *
    166         * @og.rev 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
    167         * @og.rev 5.1.9.0 (2010/08/01) 可変オブジェクトへの参照の直接セットをコピーに変更
    168         *
    169         * @param rKey 行キー
    170         * @param cKeys 項目キー配列(可変長引数)
    171         */
    172        public void setTableCols( final String rKey, final String... cKeys ) {
    173                // 6.1.1.0 (2015/01/17) 可変長引数でもnullは来る。
    174                if( rKey == null || rKey.isEmpty() || cKeys == null || cKeys.length == 0 ) {
    175                        return;
    176                }
    177                cols = cKeys.clone();           // 5.1.9.0 (2010/08/01)
    178                rowCpKey = rKey.toUpperCase( Locale.JAPAN );
    179                colCpKeys = "," + StringUtil.array2csv( cKeys ).toUpperCase( Locale.JAPAN ) + ",";
    180
    181                for( int i=0; i<cols.length; i++ ) {
    182                        final String tmpKey = cols[i].toUpperCase( Locale.JAPAN );
    183                        // 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
    184                        if( PARENT_TAG_KEY.equals( tmpKey ) ) {
    185                                pTagIdx = Integer.valueOf( i );
    186                        }
    187                        else if( PARENT_FULL_TAG_KEY.equals( tmpKey ) ) {
    188                                pFullTagIdx = Integer.valueOf( i );
    189                        }
    190                        colIdxMap.put( tmpKey, Integer.valueOf( i ) );
    191                }
    192        }
    193
    194        /**
    195         * 属性データのマップの取り出しを行うための属性キーを指定します。
    196         *
    197         * @og.rev 6.4.3.3 (2016/03/04) 可変長引数でもnullは来る。
    198         *
    199         * @param rKeys 属性キー配列(可変長引数)
    200         */
    201        public void setReturnCols( final String... rKeys ) {
    202                // 6.1.1.0 (2015/01/17) 可変長引数は、nullは来ないので、ロジックを組みなおします。
    203                // 6.4.3.3 (2016/03/04) 可変長引数でもnullは来る。
    204                if( rKeys != null && rKeys.length > 0 ) {
    205                        rtnCpKeys = "," + StringUtil.array2csv( rKeys ).toUpperCase( Locale.JAPAN ) + ",";
    206                        for( int i=0; i<rKeys.length; i++ ) {
    207                                rtnKeyMap.put( rKeys[i].toUpperCase( Locale.JAPAN ), rKeys[i] );
    208                        }
    209                }
    210        }
    211
    212        /**
    213         * 表データのヘッダーの項目名を配列で返します。
    214         *
    215         * @og.rev 5.1.9.0 (2010/08/01) 可変オブジェクトの参照返しをコピー返しに変更
    216         *
    217         * @return 表データのヘッダーの項目名の配列
    218         */
    219        public String[] getCols() {
    220                return (cols == null) ? null : cols.clone();    // 5.1.9.0 (2010/08/01)
    221        }
    222
    223        /**
    224         * 表データを2次元配列で返します。
    225         *
    226         * @return 表データの2次元配列
    227         * @og.rtnNotNull
    228         */
    229        public String[][] getData() {
    230                return rows.toArray( new String[rows.size()][0] );
    231        }
    232
    233        /**
    234         * 属性データをマップ形式で返します。
    235         *
    236         * ※ 6.4.3.1 (2016/02/12) で、セットするMapを、ConcurrentHashMap に置き換えているため、
    237         *    key,value ともに、not null制限が入っています。
    238         *
    239         * @og.rev 6.4.3.3 (2016/03/04) 戻すMapが、not null制限つきであることを示すため、ConcurrentMap に置き換えます。
    240         *
    241         * @return 属性データのマップ(not null制限)
    242         */
    243        public ConcurrentMap<String,String> getRtn() {
    244                return rtnMap;
    245        }
    246
    247        /**
    248         * XMLのパースを実行します。
    249         */
    250        public void parse() {
    251                final SAXParserFactory spfactory = SAXParserFactory.newInstance();
    252                try {
    253                        final SAXParser parser = spfactory.newSAXParser();
    254                        parser.parse( input, this );
    255                }
    256                catch( final ParserConfigurationException ex ) {
    257                        throw new OgRuntimeException( "パーサーの設定に問題があります。", ex );
    258                }
    259                catch( final SAXException ex ) {
    260                        throw new OgRuntimeException( "パースに失敗しました。", ex );
    261                }
    262                catch( final IOException ex ) {
    263                        throw new OgRuntimeException( "データの読み取りに失敗しました。", ex );
    264                }
    265        }
    266
    267        /**
    268         * 要素の開始タグ読み込み時に行う処理を定義します。
    269         *
    270         * @og.rev 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
    271         * @og.rev 6.3.9.0 (2015/11/06) コンストラクタで初期化されていないフィールドを null チェックなしで利用している(findbugs)
    272         *
    273         * @param       uri                     名前空間ＵＲＩ。要素が名前空間 ＵＲＩを持たない場合、または名前空間処理が行われない場合は空文字列
    274         * @param       localName       接頭辞を含まないローカル名。名前空間処理が行われない場合は空文字列
    275         * @param       qName           接頭辞を持つ修飾名。修飾名を使用できない場合は空文字列
    276         * @param       attributes      要素に付加された属性。属性が存在しない場合、空の Attributesオブジェクト
    277         */
    278        @Override
    279        public void startElement( final String uri, final String localName, final String qName, final Attributes attributes ) {
    280
    281                // 処理中のタグ名を設定します。
    282                curQName = getCpTagName( qName );
    283
    284                if( rowCpKey.equals( curQName ) ) {
    285                        // 6.3.9.0 (2015/11/06) コンストラクタで初期化されていないフィールドを null チェックなしで利用している(findbugs)
    286                        if( cols == null ) {
    287                                final String errMsg = "#setTableCols(String,String...)を先に実行しておいてください。" ;
    288                                throw new OgRuntimeException( errMsg );
    289                        }
    290
    291                        isInRow = true;
    292                        data = new String[cols.length];
    293                        // 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
    294                        if( pTagIdx >= 0 ) { data[pTagIdx] = getCpParentTagName( curFQName ); }
    295                        if( pFullTagIdx >= 0 ) { data[pFullTagIdx] = curFQName; }
    296                }
    297
    298                curFQName += ">" + curQName + ">";
    299
    300                // href属性で、ID指定(初めが"#")の場合は、その列番号、行番号、IDを記憶しておきます。(後で置き換え)
    301                final String href = attributes.getValue( "href" );
    302                if( href != null && href.length() > 0 && href.charAt(0) == '#' ) {
    303                        // 6.0.2.5 (2014/10/31) refactoring
    304                        final int colIdx = getColIdx( curQName );
    305                        if( isInRow && colIdx >= 0 ) {
    306                                idList.add( new RowColId( rows.size(), colIdx, href.substring( 1 ) ) );
    307                        }
    308                }
    309
    310                // id属性を記憶します。
    311                curId = attributes.getValue( "id" );
    312        }
    313
    314        /**
    315         * href属性を記憶するための簡易ポイントクラスです。
    316         */
    317        private static final class RowColId {
    318                private final int row;
    319                private final int col;
    320                private final String id;
    321
    322                /**
    323                 * 行、列、idキーを引数に取るコンストラクター
    324                 *
    325                 * @param       rw      行
    326                 * @param       cl      列
    327                 * @param       st      idキー
    328                 */
    329                RowColId( final int rw, final int cl, final String st ) {
    330                        row = rw; col = cl; id = st;
    331                }
    332        }
    333
    334        /**
    335         * テキストデータ読み込み時に行う処理を定義します。
    336         *
    337         * @og.rev 6.3.9.0 (2015/11/06) コンストラクタで初期化されていないフィールドを null チェックなしで利用している(findbugs)
    338         * @og.rev 6.4.3.3 (2016/03/04) ConcurrentHashMap の not null制限のチェック追加
    339         *
    340         * @param       ch              文字データ配列
    341         * @param       offset  文字配列内の開始位置
    342         * @param       length  文字配列から使用される文字数
    343         */
    344        @Override
    345        public void characters( final char[] ch, final int offset, final int length ) {
    346                final String val = new String( ch, offset, length );
    347                // 6.0.2.5 (2014/10/31) refactoring
    348                final int colIdx = getColIdx( curQName );
    349
    350                // 表形式データの値をセットします。
    351                // 6.3.9.0 (2015/11/06) コンストラクタで初期化されていないフィールドを null チェックなしで利用している(findbugs)
    352                if( isInRow && colIdx >= 0 && data != null && data.length > colIdx ) {
    353                        data[colIdx] = ( data[colIdx] == null ? "" : data[colIdx] ) + val;
    354                }
    355
    356                // 属性マップの値を設定します。
    357                // 5.1.6.0 (2010/05/01)
    358                if( curQName != null && curQName.length() > 0 && rtnCpKeys.indexOf( curQName ) >= 0 ) {
    359                        final String key = rtnKeyMap.get( curQName );
    360                        // 6.4.3.3 (2016/03/04) ConcurrentHashMap の not null制限のチェック追加。ついでに、Map#merge を使ってみる。
    361                        if( key != null ) {
    362                                rtnMap.merge( key , val , String::concat );                             // 既存の値が無ければ、val を、すでにあれば、val を 連結していきます。
    363                        }
    364                }
    365
    366                // ID属性が付加された要素の値を取り出し、保存します。
    367                if( curId != null && curId.length() > 0  && colIdx >= 0 ) {
    368                        final String curVal = rtnMap.get( colIdx + "__" + curId );
    369                        idMap.put( colIdx + "__" + curId, ( curVal == null ? "" : curVal ) + val );
    370                }
    371        }
    372
    373        /**
    374         * 要素の終了タグ読み込み時に行う処理を定義します。
    375         *
    376         * @param       uri                     名前空間 URI。要素が名前空間 URI を持たない場合、または名前空間処理が行われない場合は空文字列
    377         * @param       localName       接頭辞を含まないローカル名。名前空間処理が行われない場合は空文字列
    378         * @param       qName           接頭辞を持つ修飾名。修飾名を使用できない場合は空文字列
    379         */
    380        @Override
    381        public void endElement( final String uri, final String localName, final String qName ) {
    382                curQName = "";
    383                curId = "";
    384
    385                // 表形式の行データを書き出します。
    386                final String tmpCpQName = getCpTagName( qName );
    387                if( rowCpKey.equals( tmpCpQName ) ) {
    388                        rows.add( data );
    389                        isInRow = false;
    390                }
    391
    392                curFQName = curFQName.replace( ">" + tmpCpQName + ">", "" );
    393        }
    394
    395        /**
    396         * ドキュメント終了時に行う処理を定義します。
    397         *
    398         */
    399        @Override
    400        public void endDocument() {
    401                // hrefのIDに対応する値を置き換えます。
    402                for( final RowColId rci : idList ) {
    403                        rows.get( rci.row )[rci.col] = idMap.get( rci.col + "__" + rci.id );
    404                }
    405        }
    406
    407        /**
    408         * PREFIXを取り除き、さらに大文字かしたタグ名を返します。
    409         *
    410         * @param qName PREFIX付きタグ名
    411         *
    412         * @return PREFIXを取り除いた大文字のタグ名
    413         */
    414        private String getCpTagName( final String qName ) {
    415                String tmpCpName = qName.toUpperCase( Locale.JAPAN );
    416                // 6.0.2.5 (2014/10/31) refactoring
    417                final int preIdx = tmpCpName.indexOf( ':' );
    418                if( preIdx >= 0 ) {
    419                        tmpCpName = tmpCpName.substring( preIdx + 1 );
    420                }
    421                return tmpCpName;
    422        }
    423
    424        /**
    425         * >[タグC]>[タグB]>[タグA]>と言う形式のフルタグ名から[タグA](直近の親タグ名)を
    426         * 取り出します。
    427         *
    428         * @og.rev 5.1.9.0 (2010/08/01) 引数がメソッド内部で使用されていなかったため、修正します。
    429         *
    430         * @param fQName フルタグ名
    431         *
    432         * @return 親タグ名
    433         */
    434        private String getCpParentTagName( final String fQName ) {
    435                String tmpPQName = "";
    436
    437                final int curNStrIdx = fQName.lastIndexOf( '>', fQName.length() - 2 ) + 1;      // 6.0.2.5 (2014/10/31) refactoring
    438                final int curNEndIdx = fQName.length() - 1;
    439                if( curNStrIdx >= 0 && curNEndIdx >= 0 && curNStrIdx < curNEndIdx ) {
    440                        tmpPQName = fQName.substring( curNStrIdx, curNEndIdx );
    441                }
    442                return tmpPQName;
    443        }
    444
    445        /**
    446         * タグ名に相当するカラムの配列番号を返します。
    447         *
    448         * @og.rev 5.1.6.0 (2010/05/01) colKeysで指定できない項目が存在しない場合にエラーとなるバグを修正
    449         * @og.rev 6.4.3.3 (2016/03/04) Map#getOrDefault を使用します。
    450         *
    451         * @param       tagName タグ名
    452         *
    453         * @return 配列番号(存在しない場合は、-1)
    454         */
    455        private int getColIdx( final String tagName ) {
    456                return tagName == null || tagName.isEmpty() || colCpKeys.indexOf( tagName ) < 0
    457                                ? NO_IDX
    458                                : colIdxMap.getOrDefault( tagName , NO_IDX ) ;          // int → Integer → Integer → int で、効率悪そうだが、ソースは判りやすい。
    459        }
    460}