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 *   直近の親タグ、及びフルの親タグ名(親タグの階層を">[タグA]>[タグB]>[タグC]>"で表現)を
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                     名前空間URI。要素が名前空間 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        }
461}