2025年5月11日 星期日

Outlook Mail 的製作紀錄 - MSO conditional comments 及 VML (Vector Markup Language)

在製作 Outlook HTML Mail 時,PC 版 Outlook 有著許多的限制,
像是有限的 CSS 支援 (例如不支援 border-radius,要以 inline-css 的方式)、
最好的排版方式是使用 <table> 等,
所以在設計版面上面跟普通的 html page 有著蠻大的不同。

今天想要紀錄的是如何製作一般 Web Mail (如 Gmail、Web 版 Outlook 等) 及 PC 版 Outlook 都可正常顯示的 "在圓角長方形上面的一個有帶連結的圓角長方形按鈕"。

成果圖會像這樣 (還是有些許差異,但大體上設計沒有跑掉):

如果是普通的 Web Mail,HTML 的部份可以這樣設計:

<div style="border-radius: 10px; background-color: #f4f4f4; padding-top: 20px; padding-bottom: 20px; padding-right: 20px; padding-left: 20px;">
	<a href="https://xxx.xxx.xxx" style="background-color: #000000; color: #ffffff; border-radius: 10px; padding-top:10px; padding-bottom:10px; padding-right:20px; padding-left:20px; font-size: 16px; text-decoration: none;">
		Click This Button
	</a>
 </div>

在 Gmail 的樣子如下(Web Outlook 也差不多)

但是 PC Outlook 沒辦法正常處理 border-raidus, padding 等問題,會長得像這樣:

所以需要對 PC Outlook 做特別處理。

這裡我們使用了兩個知識點來處理,分別是用來分辨裝置是否為 PC Outlook 的 MSO conditional comments 和用來繪置圖形的 VML (Vector Markup Language) 。

MSO conditional comments 可以寫在 HTML 裡,可以用來判斷裝置是否為 PC Outlook,
寫法如下,對一般網頁瀏覽器來說,專鬥給 PC Outlook 看的部份因為被 <!-- 和 --> 包圍所以會被視為注解,而給一般網頁瀏覽器看的部份則不會在 PC Outlook 上顯示。
這樣我們就可以區分 PC Outlook 和一般網頁瀏覽器了。

<!--[if mso]>
給 PC Outlook 看的 Content,PC Outlook 會顯示,而一般網頁瀏覽器會視其為注解,因為被 <!-- 和 --> 包圍
<![endif]-->

<!--[if !mso]><!-- -->
給一般網頁瀏覽器看的 Content,PC Outlook 不會顯示
<!--<![endif]-->

再來是 VML (Vector Markup Language) ,雖然 PC Outlook 可以顯示 HTML,但它使用的其實是 Word 的 Render Engine,而其可以使用 VML (Vector Markup Language) 來繪製圖形。
所以我們這邊可以使用 VML 取代 HTML 來繪製我們要的圓角連結按鈕,更詳細的 VML 圖形繪制語法可以參考 使用預先定義的圖形 - Win32 apps | Microsoft Learn

在這裡我使用了 <v:roundrect> 來繪製圓角長方形。
第一個 <v:roundrect> 設定如下:

  1. 用 strokecolor 和 fillcolor 設定邊框及底色為灰色 (#f4f4f4) 。
  2. 用 arcsize 設定圓角半徑。
  3. 用 style 設定 width 和 height。
  4. 用 style 設定 position: absoulte 用來等下將下一個 <v:roundrect> 疊在上面顯示。
第二個 <v:roundrect> 設定如下:

    1. 用 strokecolor 和 fillcolor 設定邊框及底色為黑色 (#000000) 。
    2. 用 arcsize 設定圓角半徑。
    3. 用 style 設定 width 和 height。
    4. 用 style 設定 position: absoulte, 配合 top 和 left 設定擺放位置,這樣就可以將這個 <v:roundrect> 放置在第一個 <v:roundrect> 中央。
    5. 用 href 設定連結。
    6. 裡面可以放置一個 <center> 來顯示按鈕文字。
    要注意的是 <v:roundrect> 內部不能再放一個 <v:roundrect> ,因為 <v:roundrect> 不支援嵌套。

    最後的語法結果如下:
    <!--[if mso]>
     <v:roundrect
    	xmlns:v="urn:schemas-microsoft-com:vml"
    	xmlns:w="urn:schemas-microsoft-com:office:word"
    	style="v-text-anchor:middle;position:absolute;height:64px;width:520px;"
    	arcsize="10%"
    	strokecolor="#f4f4f4"
    	fillcolor="#f4f4f4">
     </v:roundrect>
     <v:roundrect
    		xmlns:v="urn:schemas-microsoft-com:vml"
    		xmlns:w="urn:schemas-microsoft-com:office:word"
    		href="https://xxx.xxx.xxx"
    		style="v-text-anchor:middle;position:absolute;top:12;left:180;height:40px;width:160px;"
    		arcsize="10%"
    		strokecolor="#000000"
    		fillcolor="#000000">
    		<w:anchorlock/>
    		<center style="color:#ffffff;font-family:sans-serif;font-size:16px;">
    		  Click This Button
    		</center>
      </v:roundrect>
    <![endif]-->
    
    <!--[if !mso]><!-- -->
     <div style="border-radius: 10px; background-color: #f4f4f4; padding-top: 20px; padding-bottom: 20px; padding-right: 20px; padding-left: 20px;">
    	<a href="https://xxx.xxx.xxx" style="background-color: #000000; color: #ffffff; border-radius: 10px; border-top:10px solid #000000; border-bottom:10px solid #000000; border-right:20px solid #000000; border-left:20px solid #000000; font-size: 16px; text-decoration: none;">
    		Click This Button
    	</a>
     </div>
    <!--<![endif]-->
    

    參考資料:

    1. 向量標記語言 (VML) - Win32 apps | Microsoft Learn
    2. 使用預先定義的圖形 - Win32 apps | Microsoft Learn
    3. 從零開始建立一個 Email HTML 版型 | 電子豹部落格

    2025年4月15日 星期二

    Java Axis 2 client 端呼叫 SOAP API (需要 Basic Authentication 時的 Bug 應對方式)

    最近在工作上接到一個升級 client 端 Apache Axis 版本的任務,
    碰到了一些問題及解決方法,在這邊紀錄一下怕之後忘記可以拿來參考。

    Apache Axis 目前有兩個大版本,v1 含有一些漏洞與弱點 (CVE, Common Vulnerabilities and Exposures),所以建議升級改用 v2 版本的 Apache Axis 2

    因為工作關係只用到 client 端,所以在這篇文章裡只涉及呼叫 SOAP API 的 client  部份,沒有 server 端的部份。

    在升級時我碰到的最大問題是無法正常設定 Basic Authentication header,
    因為我要呼叫的 SOAP Server API 需要 Basic Authentication header 驗證,
    也就是要在 Http Header 上加入如下的 Header 做驗證,把 username 和 password 用冒號 (":") 接起來以後對其做 Base64 encode,再在前端加上 "Basic ",
    將其將做 Value 搭配 "Authorization" 做 Key 設定到 Header 上 :

    Authorization: Basic base64Encode(<username>:<password>)

    根據官網這裡 Basic, Digest and NTLM Authentication 有寫到正常設定 Baisc Authentication 的程式寫法,
    但也提到了目前版本有一個存在的問題導致正常寫法的方式不起作用,
    其中寫到:

    Note: Basic preemptive authentication requires a work around described in https://issues.apache.org/jira/browse/AXIS2-6055 until a proper fix is contributed by the community as we lack committers who use it.

    其中給了一個連結: https://issues.apache.org/jira/browse/AXIS2-6055,
    在連結中有提到問題及 work around 的暫時解法,
    在這篇文中我們會參考在上面提到的解法。

    下面直接上程式,相關說明也都寫在註解中:

    首先是使用的 Maven Dependency (${axis2.version} 我是設定 2.0.0 版)

    <!-- https://mvnrepository.com/artifact/org.apache.axis2/axis2-kernel -->
    	<dependency>
    	    <groupId>org.apache.axis2</groupId>
    	    <artifactId>axis2-kernel</artifactId>
    	    <version>${axis2.version}</version>
    	</dependency>
    	
    	<!-- https://mvnrepository.com/artifact/org.apache.axis2/axis2-adb -->
    	<dependency>
    	    <groupId>org.apache.axis2</groupId>
    	    <artifactId>axis2-adb</artifactId>
    	    <version>${axis2.version}</version>
    	</dependency>
    	
    	<!-- https://mvnrepository.com/artifact/org.apache.axis2/axis2-transport-http -->
    	<dependency>
    	    <groupId>org.apache.axis2</groupId>
    	    <artifactId>axis2-transport-http</artifactId>
    	    <version>${axis2.version}</version>
    	</dependency>
    	
    	<!-- https://mvnrepository.com/artifact/org.apache.axis2/axis2-transport-local -->
    	<dependency>
    	    <groupId>org.apache.axis2</groupId>
    	    <artifactId>axis2-transport-local</artifactId>
    	    <version>${axis2.version}</version>
    	</dependency>
    	
    	<!-- https://mvnrepository.com/artifact/org.apache.axis2/axis2-jaxws -->
    	<dependency>
    	    <groupId>org.apache.axis2</groupId>
    	    <artifactId>axis2-jaxws</artifactId>
    	    <version>${axis2.version}</version>
    	</dependency>

    再來是程式碼:

    package test.soap;
    
    import java.nio.charset.StandardCharsets;
    import java.rmi.RemoteException;
    import java.util.ArrayList;
    import java.util.Base64;
    import java.util.List;
    
    import org.apache.axiom.om.OMAbstractFactory;
    import org.apache.axiom.om.OMElement;
    import org.apache.axiom.om.OMFactory;
    import org.apache.axiom.om.OMNamespace;
    import org.apache.axis2.addressing.EndpointReference;
    import org.apache.axis2.client.Options;
    import org.apache.axis2.client.ServiceClient;
    import org.apache.axis2.context.NamedValue;
    import org.apache.axis2.kernel.http.HTTPConstants;
    
    public class SoapAxis2Test {
    	
    	public static void main(String[] args) throws RemoteException {
    		String username = "username";
    		String password = "password";
    		String namespace = "http://xxx.xxx/";
    		
    		// SOAP API 名稱 (operation)
    		String operationName = "operationName"; 
    		//依需要設定 SOAP Action,有些 Server 端需要有些不需要,
    	    //可自行參考 WSDL 文檔 API 的 soapAction 節點部份
    		String action = namespace + operationName;
    		
    		//對應的 WSDL 文檔位址會是 https://xxx.xxx.xxx?wsdl
    		EndpointReference targetEPR = new EndpointReference("https://xxx.xxx.xxx");
    	    Options options = new Options();
    	    options.setTo(targetEPR);
          
    	    //雖然官網說可以用以下寫法設置 Basic Authorization header,但目前實際上不行,
    	    //問題似乎是出在 preemptive authentication 無法正常設定
    //	    Authenticator auth = new Authenticator();
    //      auth.setUsername(username);
    //      auth.setPassword(password);
    //      auth.setPreemptiveAuthentication(true);
    //      auth.setAuthSchemes(List.of(Authenticator.BASIC));
    //      options.setProperty(HTTPConstants.AUTHENTICATE, auth);
    	    
    	    //根據官網這裡 (https://axis.apache.org/axis2/java/core/docs/http-transport.html#Basic.2C_Digest_and_NTLM_Authentication)
    	    //有寫到目前存在的問題
    	    //Note: Basic preemptive authentication requires a work around described in https://issues.apache.org/jira/browse/AXIS2-6055 until a proper fix is contributed by the community as we lack committers who use it.
    	    //其中給了一個連結: https://issues.apache.org/jira/browse/AXIS2-6055
    	    //在連結中有提到問題及 work around 的暫時解法,其中一個方法如下,
    	    //成功設置後,應可在 http request 上正確地設定 Basic Authorization header
    	    List<NamedValue> authHeaders = new ArrayList<>();
    	    String basicAuth = username + ":" + password;
    	    authHeaders.add(new NamedValue(HTTPConstants.HEADER_AUTHORIZATION, "Basic " + new String(Base64.getEncoder().encode(basicAuth.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)));
    	    options.setProperty(HTTPConstants.HTTP_HEADERS, authHeaders);
    	    //依需要設定 SOAP Action
    	    //options.setAction(action);
    	    
    	    ServiceClient client = new ServiceClient();
            client.setOptions(options);
            
    	    OMFactory factory = OMAbstractFactory.getOMFactory();
    	    
    	    //依要呼叫的 SOAP API 自行建構 OMElement,供產生 xml 放在 http request body 中
    	    //自行設定 SOAP API 的名稱 (operation)
            OMNamespace omNamespace = factory.createOMNamespace(namespace, "");
            OMElement operationElement = factory.createOMElement(operationName, omNamespace);
    
            //自行設定 SOAP API 所需的參數 (parameter)
            OMElement param1Element = factory.createOMElement("xxParameter1", omNamespace);
            param1Element.setText("xxx");
            operationElement.addChild(param1Element);
            
            OMElement param2Element = factory.createOMElement("xxParameter2", omNamespace);
            param2Element.setText("xxx");
            operationElement.addChild(param2Element);
            
            //Blocking invocation
            OMElement result = client.sendReceive(operationElement);
            //自行解析 SOAP API 回傳的結果
            String resultStr = result.getFirstElement().getText();
    	    
    	    System.out.println(resultStr);
    	}
    }
    

    參考資料:

    1. Migrating from Apache Axis 1.x to Axis2
    2. HTTP transports – Apache Axis2 - Basic, Digest and NTLM Authentication
    3. Basic Auth credentials are missing in request
    4. Axis2 服务器未能识别 HTTP 头 SOAPAction 的值 的解决办法
    5. axis2客户端调用的三种方式-CSDN博客

    2025年3月31日 星期一

    DAO Sql 得到 insert 主鍵的方法,包括一般 JDBC 和 Spring JDBC Template

    一般 JDBC:

    int id = 0;
    String sql = "INSERT INTO ...........";
    
    Connection con = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    
    try{
    	con = DriverManager.getConnection(Constant.DB_MAIN);
    	pstmt = con.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS);
    	pstmt.executeUpdate();
    
    	ResultSet generatedKeys = pstmt.getGeneratedKeys();
    	if (generatedKeys.next()){
    		id = generatedKeys.getInt(1);
    	}
    }catch (Exception e) {
    	e.printStackTrace();
    } finally {
    	ConControl.freeConnection(rs, pstmt, con);
    }
    

    Spring JDBC Template:

    public int insertPopularFaq(PopularFaqBean popularFaq) {
    		String sql = "DECLARE @popular_faq_rule_id INT "
    				   + "DECLARE @faq_id INT "
    				   + "SET @popular_faq_rule_id = ? "
    				   + "SET @faq_id = ? "
    				   + "INSERT INTO popular_faq(popular_faq_rule_id, faq_id) VALUES(@popular_faq_rule_id, @faq_id)";
    		
    		KeyHolder keyHolder = new GeneratedKeyHolder();
    		cs_JdbcTemplate.update((Connection con) -> {
    			PreparedStatement pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
    			int i = 1;
    			pstmt.setInt(i++, popularFaq.getPopularFaqRuleId());
    			pstmt.setInt(i++, popularFaq.getFaqId());
    			
    			return pstmt;
    		}, keyHolder);
            
            Number key = keyHolder.getKey();
    		
    		return key.intValue();
    	}
    

    2025年1月7日 星期二

    使用 MS SQL CTE (Common Table Expression) 的遞迴功能

    MS Sql Server 有提供 CTE (Common Table Expression),它可以讓我們用 WITH 語法來建立暫時的 Table。

    例如:

    WITH numRange(num) AS (
    	SELECT 1
    )
    SELECT *
    FROM numRange
    

    查詢結果是:

    1

    而因為 CTE 可以在 AS () 中參考自已本身,
    所以可以達到遞迴的效果,今天就是要來紀錄一下使用的方法。

    在 AS () 中,會由兩種成員組成,分別是放在前面的錨點成員 (anchor) 和放在後面的遞迴成員 (recursive),錨點成員是用來設定初始的 Table 內容,不可參考到 CTE 本身的 Table。

    而遞迴成員會參考到 CTE Table 本身 (準確來說是參考到上一次迭代得到的資料表)。

    錨點成員跟遞迴成員必須要用 UNION ALL 連接。

    在每一次的遞迴迭代中,遞迴成員會參考到上一次得到的資料表,而做完 SELECT 後會產生出一份屬於這次迭代的資料表給下一次迭代的遞迴成員使用,迭代會一直進行下去直到超過次數上限 (可以用 OPTION (MAXRECURSION X) 來設定上限,X是次數,設 0 代表無限) 或是產生不出資料表為止 (就是 SELECT 的結果為空集合)。

    當所有迭代執行結束後,它會把所有產生出的資料表結合起來得到最後的結果資料表。

    下面舉個例子:

    WITH numRange(num) AS (
    	SELECT 1
    
    	UNION ALL
    
    	SELECT num + 1
    	FROM numRange
    	WHERE (num + 1) <= 3
    )
    SELECT *
    FROM numRange
    

    查詢結果是:

    1
    2
    3

    一開始初始第1次迭代的資料表是:

    1

    第2次迭代產生的資料表是

    2

    第3次迭代產生的資料表是

    3

    而第4次因為 SQL 語句 (此時 num 參考上一次、也就是第三次迭代的 num 是 3)

    SELECT num + 1
    	FROM numRange
    	WHERE (num + 1) <= 3
    

    得到的結果集為空,所以迭代結束。

    接著下面來看另一個範例,
    我們有一張表叫做 family,欄位是 name, father, mother,分別代表家族成員的名字、父親名字、母親名字,假設名字都不會重覆,所以名字可以當做 id 主鍵來做 JOIN。

    表的內容如下:

    name mother father
    小孩1 媽媽 爸爸
    小孩2 媽媽 爸爸
    媽媽1 外婆 外公
    爸爸 祖父 祖母
    祖父 NULL NULL
    祖母 NULL NULL
    外公 NULL NULL
    外婆 NULL NULL

    現在我們想找出特定人的所有長輩及其的父母、還有長輩和特定人的輩份距離,就可以像下面這樣查詢,範例是要找出"小孩1"的長輩及其父母和長輩跟"小孩1"的輩份關係,相關的說明也都已寫在範例的注釋中:

    -- 建立資料表
    CREATE TABLE family(
       name NVARCHAR(100),
       mother NVARCHAR(100),
       father NVARCHAR(100)
    );
    
    -- 塞資料
    INSERT INTO family(name, mother, father)
    VALUES(N'小孩1', N'媽媽', N'爸爸'),
          (N'小孩2', N'媽媽', N'爸爸'),
    	  (N'媽媽', N'外婆', N'外公'),
    	  (N'爸爸', N'祖父', N'祖母'),
    	  (N'祖父', NULL, NULL),
    	  (N'祖母', NULL, NULL),
    	  (N'外公', NULL, NULL),
    	  (N'外婆', NULL, NULL);
    
    -- 進行查詢
    WITH elderRelation(elder, elder_mother, elder_father, level) AS (
       -- 這裡是錨點成員,建立一開始的初始資料集
       -- 例如此例是找出 "小孩1" 的父母及其父母和小孩與父母的輩份相差數
    
       -- 只要沒有參考到 elderRelation CTE 本身就是屬於錨點成員
       ---------- 錨點成員 - 開始 ----------
    
       -- 查詢"小孩1"的母親及其父母資料
       SELECT parent.name, parent.mother, parent.father, 1
       FROM family person INNER JOIN family parent ON person.mother = parent.name
       WHERE person.name = N'小孩1'
    
       UNION ALL
    
       -- 查詢"小孩1"的父親及其父母資料
       SELECT parent.name, parent.mother, parent.father, 1
       FROM family person INNER JOIN family parent ON person.father = parent.name
       WHERE person.name = N'小孩1'
    
       -- 上述只是展示錨點成員也可以自己視需要用 UNION 組合多個 Table,
       -- 上述範例也可以用一次查詢完成,例如:
       --SELECT parent.name, parent.mother, parent.father, 1
       --FROM family person INNER JOIN family parent ON person.mother = parent.name OR person.father = parent.name
       --WHERE person.name = N'小孩1'
    
       ---------- 錨點成員 - 結束 ----------
    
       -- 用 UNION ALL 與遞迴成員組合
       UNION ALL
    
       -- 下面因為開始參考到 elderRelation 這個 CTE 本身,所以是遞迴成員
       ---------- 遞迴成員 - 開始 ----------
       -- 找出 elderRelation 母親的父母人員資料 
       SELECT family.name, family.mother,  family.father, level + 1
       FROM elderRelation INNER JOIN family ON elderRelation.elder_mother = family.name
    
       UNION ALL
    
       -- 找出 elderRelation 父親的父母人員資料
       SELECT family.name, family.mother,  family.father, level + 1
       FROM elderRelation INNER JOIN family ON elderRelation.elder_father = family.name
    
       -- 上述只是展示遞迴成員也可以自己視需要用 UNION 組合多個 Table,
       -- 上述範例也可以用一次查詢完成,例如:
       --
       --SELECT family.name, family.mother,  family.father, level + 1
       --FROM elderRelation INNER JOIN family ON elderRelation.elder_mother = family.name OR elderRelation.elder_father = family.name
    
       ---------- 遞迴成員 - 結束 ----------
    )
    SELECT *
    FROM elderRelation
    OPTION (MAXRECURSION 0); -- 可以視需要設置遞迴的最大允許次數,0代表無限大
    

    查詢結果如下:

    elder elder_mother elder_father level
    媽媽 外婆 外公 1
    爸爸 祖父 祖母 1
    祖父 NULL NULL 2
    祖母 NULL NULL 2
    外婆 NULL NULL 2
    外公 NULL NULL 2

    參考資料:

    1. WITH common_table_expression (Transact-SQL)
    2. 利用 MAXRECURSION來突破CTE預設遞迴次數
    3. [SQL] 使用 CTE 遞迴查詢 (PostgreSQL / MSSQL)
    4. [SQL Server] CTE RECURSIVE (遞迴)製作月曆

    2025年1月2日 星期四

    Jaspersoft Report 要如何將 Java List 送進 Report 當 Datasource 和 Parameter

    在使用 Jaspersoft Report 時,除了直接在 jrxml 檔中設定 SQL 語法去 Database 查資料以外,
    也可以在 Java 程式中先去 Database 中查資料,查好後再把資料送去給 Jaspersoft 產生報表。

    在每個子報表 (Sub Report) 的資料來源需求都是同一個查詢,頂多 SQL Where 條件不一樣時,
    這種方式可以只要用 Java 端查詢一次資料送給各子報表,
    各個子報表再使用 Filter 功能去實現自己的 Where 條件篩選,
    就可以避免每個子報表都要進行一次 SQL 查詢而影響效能。

    以下紀錄一下使用範例。

    在 Java 中:

    //自己想辦法查出資料成一個 List 物件
    List dataset = getDataset();
    
    //把 List 放進 reportParams Map 中
    Map reportParams = new HashMap();
    reportParams.put("dataset", dataset);
    
    //把 reportParams Map 當參數送進 Jaspersoft Report 中
    //然後子報表 (Sub Report) 可以把 Dataset 包裝成 Datasource 使用
    JasperReport jasperReport = JasperCompileManager.compileReport("myReport.jrxml"));
    //Dataset 也可以作為 Data source 送給主報表 (Main Report)使用
    JRDataSource dataSource = new JRMapCollectionDataSource(dataset);
    JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, reportParams, dataSource);
    

    在 Main Report 的 jrxml 檔中:

    <!--設定名為 dataset 的 parameter 跟 Java 傳進來的 dataset 參數作對應  -->
    <parameter name="dataset" class="java.util.Collection"/>
    
    <!-- 用 JRMapCollectionDataSource 把 $P{dataset} 包裝成一個新的 DataSource 傳給 Sub Report  -->
    <!-- 不把 Main Report 的 DataSource 直接傳給 Sub Report 是因為 DataSource 被讀取 (Consume) 後就不能再讀了, -->
    <!-- 會造成 Sub Report 把 Main Report 的 DataSource 吃掉讓 Main Report 讀不到資料。  -->
    <subreport>
    
    ...........
    
     <dataSourceExpression><![CDATA[new net.sf.jasperreports.engine.data.JRMapCollectionDataSource($P{dataset})]]></dataSourceExpression>
    
    ............
    
    </subreport>
    

    在 Sub Report 的 jrxml 檔中:

    <!-- 在 Sub Report 中可以用 <filterExpression> 的功能過濾篩選資料,實現像 SQL Where 的效果 -->
    <!-- 例如下面範例: -->
    <!-- 從 Main Report 得到的 DataSource 取得的 Dataset 中, -->
    <!-- 有名為 groupName 的 String 欄位 (field), 設定 field 去接收它,例如值可能有 1, 2, 3, 或 4 -->
    <field name="groupName" class="java.lang.String"/>
    <!-- 設定 parameter 去接收 Main Report 傳給 Sub Report 的另一個名為 groupNameForFilter 的參數,例如值為 3 代表想篩選出只是 3 的 DataSet -->
    <parameter name="groupNameForFilter" class="java.lang.String"/>
    <!-- 用 filterExpression 功能去設定篩選條件為:$F{groupName} 要等於 $P{groupNameForFilter}  -->
    <filterExpression><![CDATA[$F{groupName}.equals($P{groupNameForFilter})]]></filterExpression>
    

    2024年12月31日 星期二

    Java Database Unit Test - Spring + Database Rider

    紀錄一下使用 Spring 搭配 Database Rider 的注意事項。

    下面直接看範例,相關的說明也寫在注解上了。

    最主要要注意的地方是,
    如果不使用 Dabase Rider 的 Annotation 的話,跟之前這篇
    使用 Dabase Rider 進行 Database 測試
    的寫法是差不多的,只差在取得 Connection 的地方可以直接使用 Spring 幫我們裝配好的

    JdbcTemplate 來取得 Datasource,再使用
    DataSourceUtils.getConnection(dataSource) 取得 Connection。

    如果想使用 Database Rider 的 Annotation 的話,需要使用
    @DBRider(dataSourceBeanName = "test_DataSource")
    這個 Annotaton 來設定 dataSourceBeanName,
    @DBRider 本身就是 @ExtendWith(DBUnitExtension.class) 和 @Test 的組合,
    但多提供了我們設定 dataSourceBeanName 的地方,
    我們只要把設定在 Spring 中需要的 dataSourceBeanName 設定給 @DBRider,
    之後 Database Rider 的 @DataSet, @ExpectedDataSet 這些 Annotation 就會直接用dataSourceBeanName 去找出 Spring 裝配好的 dataSource 進行 Database 連線,
    所以不用再像這篇
    使用 Dabase Rider 進行 Database 測試
    一樣特別去設定 ConnectionHolder。

    package test.dao;
    
    import java.io.File;
    import java.net.URISyntaxException;
    import java.net.URL;
    import java.nio.file.Paths;
    import java.sql.Connection;
    import java.sql.SQLException;
    
    import javax.sql.DataSource;
    
    import org.dbunit.DatabaseUnitException;
    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.TestInstance;
    import org.junit.jupiter.api.TestInstance.Lifecycle;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Qualifier;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.jdbc.datasource.DataSourceUtils;
    import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
    import com.github.database.rider.core.api.configuration.DBUnit;
    import com.github.database.rider.core.api.dataset.DataSet;
    import com.github.database.rider.core.api.dataset.DataSetFormat;
    import com.github.database.rider.core.api.dataset.ExpectedDataSet;
    import com.github.database.rider.core.api.exporter.DataSetExportConfig;
    import com.github.database.rider.core.configuration.DBUnitConfig;
    import com.github.database.rider.core.configuration.DataSetConfig;
    import com.github.database.rider.core.configuration.ExpectedDataSetConfig;
    import com.github.database.rider.core.dsl.RiderDSL;
    import com.github.database.rider.core.exporter.DataSetExporter;
    import com.github.database.rider.junit5.api.DBRider;
    
    //讀取配置檔讓 Spring 處理 Bean 的依賴裝配
    @SpringJUnitWebConfig(locations = {"file:WebContent/WEB-INF/mvc-config.xml"})
    //如果要使用 Database Rider 的 Annotation (@Dataset, @ExpectedDataSet 等)
    //才需要使用 @DBRider,
    //@DBRider 內包含 @ExtendWith(DBUnitExtension.class) 和 @Test,
    //並且多了一個 dataSourceBeanName 屬性,用來指定要使用的 Spring DataSourceBean 名稱
    @DBRider(dataSourceBeanName = "test_DataSource")
    @DBUnit(cacheConnection = false,
    		allowEmptyFields = true)
    @TestInstance(Lifecycle.PER_CLASS)
    class DBRiderTest {
    	
    	//如果不使用 Database Rider 的 Annotation,
    	//可以自行依需要取得 JdbcTemplate,進而取得 Datasource 和 Jdbc Connection
    	@Autowired
    	@Qualifier("test_JdbcTemplate")
    	JdbcTemplate test_JdbcTemplate;
    	
    	final String testResourceFolderPath = "";
    	final String backupDatasetResourcePath = "/backupDataset.xlsx";
    	final String testDatasetResourcePath = "/testDataset.xlsx";
    	
    	final String[] includeTableList = new String[] {"test_table"};
    	
    	//因為 @ExportDataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
    	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 export
    	@BeforeAll
    	void exportBackupDataset() throws SQLException, DatabaseUnitException, URISyntaxException {
    		URL backupDatasetResourceFolderUrl = DBRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
    		File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
    		String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
    		
    		DataSource dataSource = test_JdbcTemplate.getDataSource();
    		Connection conn = DataSourceUtils.getConnection(dataSource);
    		DataSetExporter.getInstance().export(conn,
    											 new DataSetExportConfig()
    											 .dataSetFormat(DataSetFormat.XLSX)
    											 .includeTables(includeTableList)
    											 .queryList(new String[] {})
    											 .outputFileName(backupDatasetResourceFilePath));
    		
    		DataSourceUtils.releaseConnection(conn, dataSource);
    	}
    	
    	//因為 @DataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
    	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 import
    	@AfterAll
    	void importBackupDataset() {
    		//直接利用 Spring Inject 裝配好的 JdbcTemplate,並取得 DataSource 和 Connection
    		DataSource dataSource = test_JdbcTemplate.getDataSource();
    		Connection conn = DataSourceUtils.getConnection(dataSource);
    		RiderDSL.withConnection(conn)
    				.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath)
    						           //依需求設定 import dataset 要執行的動作,
    						           //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
    						           .executeStatementsBefore("")) 
    				.withDBUnitConfig(new DBUnitConfig()
    						          .addDBUnitProperty("allowEmptyFields", true))
    				.createDataSet();
    	
    		DataSourceUtils.releaseConnection(conn, dataSource);
    		//do other things
    	}
    	
    	//使用 @DataSet 來 import test dataset
    	@BeforeEach
    	@DataSet(value = "testDataset.xlsx",
    			 //依需求設定 import dataset 要執行的動作,
    	         //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
    	         executeStatementsBefore = {""})
    	void importTestDataset() {
    	}
    	
    	@Test
    	void test() {
    		System.out.println("test");
    		Assertions.assertTrue(true);
    	}
    	
    	//使用 @ExpectedDataSet Annotation 比較 database table 的資料是否和 expected dataset 一致
    	@Test
    	@ExpectedDataSet(value = "expectedDataset.xlsx")
    	void testExpectedDatasetByAnnotation() throws DatabaseUnitException, SQLException {
    		//做你想做的 Test
    		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
    		//自己修改一下 database table 的資料
    		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
    		
    		//因為使用了 @ExpectedDataSet Annotation,所以這裡就不需要再多寫用 RiderDSL 等的程式碼了
    	}
    	
    	//也可以不使用用 @ExpectedDataSet Annotation,直接使用 RiderDSL 來比較 database table 的資料是否和 expected dataset 一致
    	@Test 
    	void testExpectedDataset() throws DatabaseUnitException, SQLException {
    		//做你想做的 Test		
    		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
    		//自己修改一下 database table 的資料
    		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
    		
    		//比較 database table 的資料是否和 expected dataset 一樣
    		DataSource dataSource = test_JdbcTemplate.getDataSource();
    		Connection conn = DataSourceUtils.getConnection(dataSource);
    		RiderDSL.withConnection(conn)
    			    .withDataSetConfig(new DataSetConfig("expectedDataset.xlsx"))
    			    .withDBUnitConfig(new DBUnitConfig()
    					              .addDBUnitProperty("escapePattern", "\"?\"")
    					              .addDBUnitProperty("caseSensitiveTableNames", true)
    					              .addDBUnitProperty("allowEmptyFields", true))
    		        .expectDataSet(new ExpectedDataSetConfig());
    		
    		DataSourceUtils.releaseConnection(conn, dataSource);
    	}
    }
    

    2024年12月4日 星期三

    Javascript - 使用 Array.splice() 交換 List 中的兩 Item 的位置

    紀錄一下使用 Javascript 的 Array.splice() 交換 List 中的兩個 Item 位置的方法,
    例如現在想要交換 list 中 index1 和 index2 位置的 item ,
    可以如下達成:

    var list = [1,2,3,4]; //list = [1,2,3,4]
    var index1 = 0;
    var index2 = 2;
    list[index1] = list.splice(index2, 1, list[index1])[0]; //list = [3,2,1,4]