Skip to content

Commit 4ab67e7

Browse files
authored
Fix encoding for decimals in scientific notation (#165)
* Add support for decimals using scientific notation * Use actual sign value to be more explicit * Extract into function * Create schema before using it * Raise error with invalid sign value * Add test cases * Fix typo
1 parent 336aec0 commit 4ab67e7

File tree

3 files changed

+261
-27
lines changed

3 files changed

+261
-27
lines changed

lib/tds/types.ex

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -687,16 +687,15 @@ defmodule Tds.Types do
687687

688688
# Decimal
689689
def decode_decimal(precision, scale, <<sign::int8(), value::binary>>) do
690+
set_decimal_precision(precision)
691+
690692
size = byte_size(value)
691693
<<value::little-size(size)-unit(8)>> = value
692694

693-
Decimal.Context.get()
694-
|> Map.put(:precision, precision)
695-
|> Decimal.Context.set()
696-
697695
case sign do
698696
0 -> Decimal.new(-1, value, -scale)
699-
_ -> Decimal.new(1, value, -scale)
697+
1 -> Decimal.new(1, value, -scale)
698+
_ -> raise ArgumentError, "Sign value out of range. Expected 0 or 1, got #{inspect(sign)}"
700699
end
701700
end
702701

@@ -878,9 +877,7 @@ defmodule Tds.Types do
878877
end
879878

880879
def encode_decimal_type(%Parameter{value: value}) do
881-
d_ctx = Decimal.Context.get()
882-
d_ctx = %{d_ctx | precision: 38}
883-
Decimal.Context.set(d_ctx)
880+
set_decimal_precision(38)
884881

885882
value_list =
886883
value
@@ -933,9 +930,7 @@ defmodule Tds.Types do
933930
end
934931

935932
def encode_float_type(%Parameter{value: %Decimal{} = value}) do
936-
d_ctx = Decimal.Context.get()
937-
d_ctx = %{d_ctx | precision: 38}
938-
Decimal.Context.set(d_ctx)
933+
set_decimal_precision(38)
939934

940935
value_list =
941936
value
@@ -1124,9 +1119,7 @@ defmodule Tds.Types do
11241119
end
11251120

11261121
def encode_decimal_descriptor(%Parameter{value: %Decimal{} = dec}) do
1127-
d_ctx = Decimal.Context.get()
1128-
d_ctx = %{d_ctx | precision: 38}
1129-
Decimal.Context.set(d_ctx)
1122+
set_decimal_precision(38)
11301123

11311124
value_list =
11321125
dec
@@ -1284,9 +1277,7 @@ defmodule Tds.Types do
12841277

12851278
# decimal
12861279
def encode_data(@tds_data_type_decimaln, %Decimal{} = value, attr) do
1287-
d_ctx = Decimal.Context.get()
1288-
d_ctx = %{d_ctx | precision: 38}
1289-
Decimal.Context.set(d_ctx)
1280+
set_decimal_precision(38)
12901281
precision = attr[:precision]
12911282

12921283
d =
@@ -1300,12 +1291,12 @@ defmodule Tds.Types do
13001291
-1 -> 0
13011292
end
13021293

1303-
d_abs = Decimal.abs(d)
1304-
1305-
value = d_abs.coef
1306-
13071294
value_binary =
13081295
value
1296+
|> Decimal.abs()
1297+
|> Decimal.to_string(:normal)
1298+
|> String.replace(".", "")
1299+
|> String.to_integer()
13091300
|> :binary.encode_unsigned(:little)
13101301

13111302
value_size = byte_size(value_binary)
@@ -1318,8 +1309,8 @@ defmodule Tds.Types do
13181309
precision <= 38 -> 16
13191310
end
13201311

1321-
{byte_len, padding} = {len, len - value_size}
1322-
byte_len = byte_len + 1
1312+
padding = len - value_size
1313+
byte_len = len + 1
13231314
value_binary = value_binary <> <<0::size(padding)-unit(8)>>
13241315
<<byte_len>> <> <<sign>> <> value_binary
13251316
end
@@ -1883,4 +1874,10 @@ defmodule Tds.Types do
18831874
data = <<type, 0x07>>
18841875
{type, data, scale: 7}
18851876
end
1877+
1878+
defp set_decimal_precision(precision) do
1879+
Decimal.Context.get()
1880+
|> Map.put(:precision, precision)
1881+
|> Decimal.Context.set()
1882+
end
18861883
end

test/test_helper.exs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,14 @@ end
131131
CREATE TABLE [uniques] ([id] int NOT NULL, CONSTRAINT UIX_uniques_id UNIQUE([id]))
132132
""")
133133

134-
{"Changed database context to 'test'." <> _, 0} =
134+
{"", 0} =
135135
Tds.TestHelper.sqlcmd(opts, """
136-
USE test
137-
GO
138-
CREATE SCHEMA test;
136+
IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'test')
137+
EXEC('CREATE SCHEMA [test]')
139138
""")
140139

140+
{"Changed database context to 'test'." <> _, 0} = Tds.TestHelper.sqlcmd(opts, "USE test;")
141+
141142
# :dbg.start()
142143
# :dbg.tracer()
143144
# :dbg.p(:all,:c)

test/types/types_test.exs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
defmodule Tds.TypesTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Tds.Parameter
5+
6+
import Tds.TestHelper
7+
8+
require Logger
9+
10+
@tds_data_type_decimaln 0x6A
11+
12+
setup do
13+
{:ok, pid} = Tds.start_link(opts())
14+
15+
{:ok, [pid: pid]}
16+
end
17+
18+
defp create_table(context) do
19+
precision = context[:precision] || 10
20+
scale = context[:scale] || 4
21+
22+
if not is_integer(precision) do
23+
raise ArgumentError, "precision must be an integer"
24+
end
25+
26+
if not is_integer(scale) do
27+
raise ArgumentError, "scale must be an integer"
28+
end
29+
30+
query("DROP TABLE IF EXISTS foo", [])
31+
query("CREATE TABLE foo (col DECIMAL(#{precision}, #{scale}) NULL)", [])
32+
end
33+
34+
@spec insert_decimal(Decimal.t() | nil, map) :: Decimal.t()
35+
defp insert_decimal(value, context) do
36+
query("TRUNCATE TABLE foo", [])
37+
38+
:ok =
39+
query("INSERT INTO foo (col) VALUES (@1)", [
40+
%Parameter{name: "@1", value: value, type: :decimal}
41+
])
42+
43+
{:ok, result} = Tds.query(context[:pid], "SELECT col FROM foo", [])
44+
%Tds.Result{rows: [[value]]} = result
45+
value
46+
end
47+
48+
describe "encode_data/3" do
49+
test "encodes decimal type", _context do
50+
value = Decimal.new("1000")
51+
attr = [precision: 8, scale: 4]
52+
53+
# assert <<5, 1, 128, 150, 152, 0>> =
54+
assert <<byte_len>> <> <<sign>> <> value_binary =
55+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
56+
57+
assert byte_len == 5
58+
assert sign == 1
59+
assert <<232, 3, 0, 0>> = value_binary
60+
assert :binary.decode_unsigned(value_binary, :little) == 1000
61+
end
62+
63+
test "encodes decimal type with scientific notation", _context do
64+
value = Decimal.new("1E+3")
65+
attr = [precision: 8, scale: 4]
66+
67+
assert <<byte_len>> <> <<sign>> <> value_binary =
68+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
69+
70+
assert byte_len == 5
71+
assert sign == 1
72+
assert <<232, 3, 0, 0>> = value_binary
73+
assert :binary.decode_unsigned(value_binary, :little) == 1000
74+
end
75+
76+
# Decimal.new("-1E+3")
77+
test "encodes negative decimal with scientific notation", _context do
78+
value = Decimal.new("-1E+3")
79+
attr = [precision: 8, scale: 4]
80+
81+
assert <<byte_len>> <> <<sign>> <> value_binary =
82+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
83+
84+
assert byte_len == 5
85+
assert sign == 0
86+
assert <<232, 3, 0, 0>> = value_binary
87+
assert :binary.decode_unsigned(value_binary, :little) == 1000
88+
end
89+
90+
test "encodes decimal type with precision", _context do
91+
value = Decimal.new("1000.0000")
92+
attr = [precision: 8, scale: 4]
93+
94+
assert <<byte_len>> <> <<sign>> <> value_binary =
95+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
96+
97+
assert byte_len == 5
98+
assert sign == 1
99+
assert <<128, 150, 152, 0>> = value_binary
100+
assert :binary.decode_unsigned(value_binary, :little) == 10_000_000
101+
end
102+
103+
test "encodes negative decimal", _context do
104+
value = Decimal.new("-1000.0000")
105+
attr = [precision: 8, scale: 4]
106+
107+
assert <<byte_len>> <> <<sign>> <> value_binary =
108+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
109+
110+
assert byte_len == 5
111+
assert sign == 0
112+
assert <<128, 150, 152, 0>> = value_binary
113+
assert :binary.decode_unsigned(value_binary, :little) == 10_000_000
114+
end
115+
116+
test "encodes decimal type for 1000.1234", _context do
117+
value = Decimal.new("1000.1234")
118+
attr = [precision: 8, scale: 4]
119+
120+
assert <<byte_len>> <> <<sign>> <> value_binary =
121+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
122+
123+
assert byte_len == 5
124+
assert sign == 1
125+
assert <<82, 155, 152, 0>> = value_binary
126+
assert :binary.decode_unsigned(value_binary, :little) == 10_001_234
127+
end
128+
129+
test "encodes very large decimal", _context do
130+
value = Decimal.new("9999999999.9999")
131+
attr = [precision: 14, scale: 4]
132+
133+
assert <<byte_len>> <> <<sign>> <> value_binary =
134+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
135+
136+
assert byte_len == 9
137+
assert sign == 1
138+
assert :binary.decode_unsigned(value_binary, :little) == 99_999_999_999_999
139+
end
140+
141+
test "encodes very small decimal", _context do
142+
value = Decimal.new("0.0001")
143+
attr = [precision: 5, scale: 4]
144+
145+
assert <<byte_len>> <> <<sign>> <> value_binary =
146+
Tds.Types.encode_data(@tds_data_type_decimaln, value, attr)
147+
148+
assert byte_len == 5
149+
assert sign == 1
150+
assert :binary.decode_unsigned(value_binary, :little) == 1
151+
end
152+
end
153+
154+
describe "inserting decimal values into the database" do
155+
setup :create_table
156+
157+
@tag precision: 10, scale: 4
158+
test "inserts various decimal values", context do
159+
assert insert_decimal(Decimal.new("1000"), context) == Decimal.new("1000.0000")
160+
assert insert_decimal(Decimal.new("1000.0000"), context) == Decimal.new("1000.0000")
161+
assert insert_decimal(Decimal.new("1000.1234"), context) == Decimal.new("1000.1234")
162+
assert insert_decimal(Decimal.new("-1000.0000"), context) == Decimal.new("-1000.0000")
163+
164+
# Decimals can be scientific notation when converted from float:
165+
# iex> Decimal.from_float(1000.0)
166+
# Decimal.new("1E+3")
167+
assert insert_decimal(Decimal.new("1E+3"), context) == Decimal.new("1000.0000")
168+
assert insert_decimal(Decimal.new("-1E+3"), context) == Decimal.new("-1000.0000")
169+
end
170+
171+
@tag precision: 5, scale: 2
172+
test "decodes decimal type with precision 5 and scale 2", context do
173+
assert insert_decimal(Decimal.new("123.45"), context) == Decimal.new("123.45")
174+
assert insert_decimal(Decimal.new("-123.45"), context) == Decimal.new("-123.45")
175+
end
176+
177+
@tag precision: 10, scale: 5
178+
test "decodes decimal type to 99999.99999", context do
179+
assert insert_decimal(Decimal.new("99999.99999"), context) == Decimal.new("99999.99999")
180+
end
181+
182+
@tag precision: 2, scale: 1
183+
test "decodes decimal type to 9.9", context do
184+
assert insert_decimal(Decimal.new("9.9"), context) == Decimal.new("9.9")
185+
end
186+
187+
@tag precision: 38, scale: 0
188+
test "decodes to exact value with 0 scale", context do
189+
value = Decimal.new("99999999999999999999999999999999999999")
190+
assert insert_decimal(value, context) == value
191+
end
192+
193+
@tag precision: 5, scale: 2
194+
test "decodes to NULL", context do
195+
assert insert_decimal(nil, context) == nil
196+
end
197+
198+
@tag precision: 5, scale: 2
199+
test "rounds up fractional parts", context do
200+
assert insert_decimal(Decimal.new("123.456"), context) == Decimal.new("123.46")
201+
assert insert_decimal(Decimal.new("123.454"), context) == Decimal.new("123.45")
202+
assert insert_decimal(Decimal.new("-0.00001"), context) == Decimal.new("0.00")
203+
end
204+
205+
@tag precision: 2, scale: 1, capture_log: true
206+
test "raises an error with truncated value (cannot round non-fractional parts)", context do
207+
value = Decimal.new("9.99")
208+
# %Tds.Error{message: nil, mssql: %{state: 8, number: 8115, line_number:
209+
# 1, msg_text: \"Arithmetic overflow error converting numeric to data type numeric.\",
210+
# server_name: \"04e0392f6c76\", class: 16, proc_name: \"\"}}
211+
message = ~r/Arithmetic overflow error converting numeric to data type numeric/
212+
assert_raise MatchError, message, fn -> insert_decimal(value, context) end
213+
end
214+
215+
# Maximum precision and scale for SQL Server
216+
# https://learn.microsoft.com/en-us/sql/t-sql/data-types/precision-scale-and-length-transact-sql?view=sql-server-ver16#remarks
217+
@tag precision: 38, scale: 18
218+
test "inserts very large decimal", context do
219+
# 38 digits
220+
value = Decimal.new("99999999999999999999.999999999999999999")
221+
assert insert_decimal(value, context) == value
222+
end
223+
224+
@tag precision: 38, scale: 18, capture_log: true
225+
test "raises an error with value larger than SQL Server maximum", context do
226+
# 39 digits
227+
value = Decimal.new("999999999999999999999.9999999999999999999")
228+
message = ~r/size \(39\) given to the type 'decimal' exceeds the maximum allowed \(38\)/
229+
assert_raise(MatchError, message, fn -> insert_decimal(value, context) end)
230+
end
231+
232+
test "inserts very small decimal", context do
233+
assert insert_decimal(Decimal.new("0.0001"), context) == Decimal.new("0.0001")
234+
end
235+
end
236+
end

0 commit comments

Comments
 (0)