同事報案,使用 Dapper + ODP.NET 呼叫某 Procedure,以 Ref Cursor 取資料時出現型別轉換錯誤,一路深入追查,發現問題跟是否用了 Procedure、Ref Cursor、 Dapper 都沒有關係,錯誤發生在 ODP.NET 層。
有問題的查詢涉及幾個高精確度的欄位運動,經過一番簡化,我先找出用下列查詢可重現問題。
使用 PL/SQL Developer 查詢不會出錯,但使用 ODP.NET OracleDataReader dr["N"]、dr.GetDecimal(0)、dr.GetValue() 都會出現「指定的轉換無效」(Invalid Cast Exception)錯誤,dr.GetDouble(0) 則能讀出結果:
由於出錯案例的爆點在幾個高精確度數字(NUMBER(14)、NUMBER(10,7))的乘除結果,一度以為跟運算成員的欄位精確位數大高造成的。
經過一番調查,我才搞懂背後發生什麼事,並有了新結論:
- Oracle NUMBER的最高精確位數高達 38 位,C# decimal只有 28-29 位,當 Oracle 欄位值高於 28 位,ODP.NET 祭出 .NET 世界精準度最高的 decimal 也裝不下,引發轉型別轉換失敗錯誤。(延伸閱讀:ODP.NET 如何決定數值欄位的 .NET 對應型別?)
- 其實不必動用超大位數數字進行運算,只要一個簡單的除法搞出無窮小數就能重現 Oracle 位數超出 decimal 負荷的狀況。
- 當未指定型別,Oracle 會以 NUMBER(預設 38 位最高精確度)傳回運算結果,換言之,只要遇到無窮小數就會傳回滿滿 38 位數字讓 decimal 難看。
- PL/SQL Developer(或其他 Oracle 查詢軟體)顯示的結果小數位數是經過處理的,並非 Oracle 傳回的原始內容。
讓我用一個例子證明 1、2 兩點:
class Program
{
staticvoid TryGetValue(OracleDataReader dr, int n)
{
try
{
Console.WriteLine($"N{n}={dr.GetValue(n)}");
}
catch (Exception ex)
{
Console.WriteLine($"N{n} GetValue Error = {ex.Message}");
}
}
staticvoid DumpBinData(OracleDataReader dr, int n)
{
string binData = BitConverter.ToString(dr.GetOracleDecimal(n).BinData);
Console.WriteLine($"N{n} .BinData = {binData}");
}
staticvoid Main(string[] args)
{
using (var cn = new OracleConnection(Helper.GetCnnStr()))
{
cn.Open();
var cmd = cn.CreateCommand();
cmd.CommandText = @"
SELECT
100/17 AS N0,
CAST(100/17 AS NUMBER(*,28)) AS N1,
CAST(100/17 AS NUMBER(*,29)) AS N2,
CAST(100/17 AS NUMBER) AS N4
FROM DUAL";
var dr = cmd.ExecuteReader();
dr.Read();
for(var i = 0; i < 4; i++)
{
Console.WriteLine($"---N{i}---");
TryGetValue(dr, i);
DumpBinData(dr, i);
}
}
Console.Read();
}
}
我用 100/17 製造出包含無窮小數的數值,再分別 CAST 換成 NUMBER(*, 28)、NUMBER(*, 29) 及 NUMBER 三種型別。結果只有轉成 NUMBER(*, 28) 的欄位能被正確讀取,其餘的都發生轉型失敗。另外,由 OracleDataReader.GetOracleDecimal().BinData 可以檢視 Oracle 傳回的原始資料,N0(未指定型別)及 N4(轉型為 NUMBER)的 BinData 是相同的,足以證明當未指定型別時,Oracle 採用最大精確度的 NUMBER 型別。
所以,當查詢結果出現除不盡狀況的狀況,其真實數字位數會高達 38 位,由 Oracle 工具軟體看到的結果是經過四捨五入的結果,如第一張圖例的 PL/SQL Developer 取 16 位,不同的工具結果不同,例如改用 SQLPLUS,位數就只有 9 位。
所以,不要拿工具軟體查詢到的數字當成標準答案!
最後,好奇在 SQL Server 上會不會遇到同樣的狀況?答案是不會:
SQL 整數相除結果預設為整數,非整數相除預設精準度預設為 Single,除非刻意轉型讓位數破錶,否則不會出錯,而錯誤訊息為「轉換溢位」較明確。
結論:
當在 Oracle 查詢使用除法運算,請記得轉型讓精準度夠用就好,否則一旦出現除不盡的狀況,你就有得忙了。