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