diff --git a/CHANGELOG.md b/CHANGELOG.md
index aeaeda6..d6791d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,78 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## v3.0.0 (2026-03-05)
+
+### Breaking Changes
+
+* **Money columns return `%Decimal{}`** instead of `float`. Use `Decimal`
+ arithmetic for money values. Previously `SELECT CAST(10.50 AS money)`
+ returned `10.5` (float); now returns `Decimal.new("10.5000")`.
+
+* **Date/time columns always return Elixir calendar structs.** No more tuple
+ format. `smalldatetime`, `datetime`, `datetime2` return `%NaiveDateTime{}`;
+ `datetimeoffset` returns `%DateTime{}`; `date` returns `%Date{}`;
+ `time` returns `%Time{}`. The `use_elixir_calendar_types` config option
+ is ignored — struct output is always on.
+
+* **`Tds.Types.UUID` deprecated.** Use `Ecto.UUID` instead. The new
+ `Tds.Type.UUID` wire handler performs MSSQL mixed-endian byte reordering
+ at the protocol level, so `Ecto.UUID` works directly.
+
+* **`Tds.Types` module removed.** The 1815-line monolithic type module has been
+ replaced by 12 focused handler modules under `Tds.Type.*`.
+
+### New Features
+
+* **`Tds.Type` behaviour** — Pluggable type system with 7 callbacks:
+ `type_codes/0`, `type_names/0`, `decode_metadata/1`, `decode/2`,
+ `encode/2`, `param_descriptor/2`, `infer/1`.
+
+* **12 handler modules** — `Tds.Type.{Boolean, Integer, Float, Decimal, Money,
+ String, Binary, DateTime, UUID, Xml, Variant, Udt}`.
+
+* **`Tds.Type.DataReader`** — Shared framing reader with 6 strategies
+ (`:fixed`, `:bytelen`, `:shortlen`, `:longlen`, `:plp`, `:variant`).
+ PLP uses iolist accumulation (8x faster than binary concat).
+ All strategies sever sub-binary references via `:binary.copy/1` to
+ prevent memory leaks.
+
+* **`Tds.Type.Registry`** — Per-connection type registry mapping TDS type
+ codes and atom names to handlers. Supports user-provided `extra_types`
+ that override built-in handlers.
+
+* **`extra_types` connection option** — Register custom type handlers at
+ connect time: `Tds.start_link(extra_types: [MyApp.GeographyType])`.
+
+### Performance
+
+Benchmarked on Apple M4 Pro, 48 GB, Elixir 1.18.1, Erlang/OTP 27.2:
+
+* Integer decode: **54% faster** (7.12M → 10.96M ips)
+* Decimal encode (1000 params): **8.5x faster** (0.63K → 5.39K ips)
+* Decimal encode memory: **8.8x less** (1.99 MB → 226 KB per 1000 params)
+* PLP reassembly: **8x faster** at 1 MB, **7x faster** at 10 MB
+* PLP memory: **~2x less** (iolist vs binary concat)
+
+### Improvements
+
+* Decimal encoding no longer mutates `Decimal.Context` in the process
+ dictionary. Precision and scale are passed via metadata.
+* All decoded values sever sub-binary references to the TCP packet buffer,
+ preventing memory retention when values are stored in ETS or GenServer state.
+
+### Migration Guide
+
+1. **Money values**: Replace float arithmetic with `Decimal` operations.
+ `Decimal.to_float/1` is available if float is needed temporarily.
+2. **Date/time tuples**: Replace `{{y,m,d},{h,min,s}}` with
+ `~N[2013-10-12 00:37:14]` or `NaiveDateTime.new!/3`. For encoding,
+ convert tuples to calendar structs before passing as parameters.
+3. **Remove `use_elixir_calendar_types`**: Delete from your config —
+ calendar structs are now the only output format.
+4. **`Tds.Types.UUID`**: Replace with `Ecto.UUID`. If you called
+ `Tds.generate_uuid/0`, use `Ecto.UUID.bingenerate/0` instead.
+
## v2.3.5 (2024-01-23)
### Improvements
* Removed unnecessary append of possibly large binaries for floats
@@ -324,11 +396,7 @@ could not determine if binary is of uuid type, it interpreted such values as raw
### Enhancements
* Added API for ATTN call
-<<<<<<< HEAD
-## v0.1.5
-=======
## v0.1.5 - 2015-02-19
->>>>>>> 1007dc1 (Misc doc changes)
### Bug Fixes
* Fixed issue where driver would not call Connection.next when setting the state to :ready
* Fixed UCS2 Encoding
diff --git a/README.md b/README.md
index 465d5c9..a9fe420 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,6 @@ MSSQL / TDS Database driver for Elixir.
### NOTE:
Since TDS version 2.0, `tds_ecto` package is deprecated, this version supports `ecto_sql` since version 3.3.4.
-Please check out the issues for a more complete overview. This branch should not be considered stable or ready for production yet.
-
For stable versions always use [hex.pm](https://hex.pm/packages/tds) as source for your mix.exs.
## Usage
@@ -19,7 +17,7 @@ Add `:tds` as a dependency in your `mix.exs` file.
```elixir
def deps do
[
- {:tds, "~> 2.3"}
+ {:tds, "~> 3.0"}
]
end
```
@@ -151,52 +149,77 @@ This functionality requires specific environment to be developed.
## Data representation
-| TDS | Elixir |
-| ----------------- | ------------------------------------------------------------------------------------------ |
-| NULL | nil |
-| bool | true / false |
-| char | "é" |
-| int | 42 |
-| float | 42.0 |
-| text | "text" |
-| binary | <<42>> |
-| numeric | #Decimal<42.0> |
-| date | {2013, 10, 12} or %Date{} |
-| time | {0, 37, 14} or {0, 37, 14, 123456} or %Time{} |
-| smalldatetime | {{2013, 10, 12}, {0, 37, 14}} or {{2013, 10, 12}, {0, 37, 14, 123456}} |
-| datetime | {{2013, 10, 12}, {0, 37, 14}} or {{2013, 10, 12}, {0, 37, 14, 123456}} or %NaiveDateTime{} |
-| datetime2 | {{2013, 10, 12}, {0, 37, 14}} or {{2013, 10, 12}, {0, 37, 14, 123456}} or %NaiveDateTime{} |
-| datetimeoffset(n) | {{2013, 10, 12}, {0, 37, 14}} or {{2013, 10, 12}, {0, 37, 14, 123456}} or %DateTime{} |
-| uuid | <<160,238,188,153,156,11,78,248,187,109,107,185,189,56,10,17>> |
-
-Currently unsupported: [User-Defined Types](https://docs.microsoft.com/en-us/sql/relational-databases/clr-integration-database-objects-user-defined-types/working-with-user-defined-types-in-sql-server), XML
+| TDS | Elixir |
+| ----------------- | ---------------------- |
+| NULL | `nil` |
+| bool | `true` / `false` |
+| char / varchar | `"text"` |
+| nchar / nvarchar | `"text"` |
+| int / bigint | `42` |
+| float / real | `42.0` |
+| text / ntext | `"text"` |
+| binary / varbinary | `<<42>>` |
+| numeric / decimal | `#Decimal<42.0>` |
+| money / smallmoney | `#Decimal<10.5000>` |
+| date | `%Date{}` |
+| time | `%Time{}` |
+| smalldatetime | `%NaiveDateTime{}` |
+| datetime | `%NaiveDateTime{}` |
+| datetime2 | `%NaiveDateTime{}` |
+| datetimeoffset(n) | `%DateTime{}` |
+| uniqueidentifier | `<<_::128>>` |
+| xml | `"..."` |
+| sql_variant | varies by inner type |
+
+User-Defined Types (UDT) are returned as raw binary by default. Register custom
+handlers via `extra_types` to decode specific UDTs (see below).
### Dates and Times
-Tds can work with dates and times in either a tuple format or as Elixir calendar types. Calendar types can be enabled in the config with `config :tds, opts: [use_elixir_calendar_types: true]`.
-
-**Tuple forms:**
+As of v3.0, all date/time columns are decoded as Elixir calendar structs:
-- Date: `{yr, mth, day}`
-- Time: `{hr, min, sec}` or `{hr, min, sec, fractional_seconds}`
-- DateTime: `{date, time}`
-- DateTimeOffset: `{utc_date, utc_time, offset_mins}`
+| SQL Type | Elixir Type |
+| ------------------- | -------------------- |
+| `date` | `%Date{}` |
+| `time(n)` | `%Time{}` |
+| `smalldatetime` | `%NaiveDateTime{}` |
+| `datetime` | `%NaiveDateTime{}` |
+| `datetime2(n)` | `%NaiveDateTime{}` |
+| `datetimeoffset(n)` | `%DateTime{}` |
-In SQL Server, the `fractional_seconds` of a `time`, `datetime2` or `datetimeoffset(n)` column can have a precision of 0-7, where the `microsecond` field of a `%Time{}` or `%DateTime{}` struct can have a precision of 0-6.
+SQL Server `time`, `datetime2`, and `datetimeoffset` support precision 0-7.
+Elixir's `microsecond` field supports precision 0-6, so fractional seconds
+are truncated to microsecond precision when the SQL scale exceeds 6.
-Note that the DateTimeOffset tuple expects the date and time in UTC and the offset in minutes. For example, `{{2020, 4, 5}, {5, 30, 59}, 600}` is equal to `'2020-04-05 15:30:59+10:00'`.
+The `use_elixir_calendar_types` config option from v2.x is no longer needed
+and is ignored in v3.0.
### UUIDs
[MSSQL stores UUIDs in mixed-endian
-format](https://dba.stackexchange.com/a/121878), and these mixed-endian UUIDs
-are returned in [Tds.Result](https://hexdocs.pm/tds/Tds.Result.html).
+format](https://dba.stackexchange.com/a/121878) where the first three groups
+are byte-reversed (little-endian) and the last two are big-endian.
+
+As of v3.0, the `Tds.Type.UUID` wire handler performs this byte reordering
+automatically at the protocol level, so `Ecto.UUID` works directly without
+any wrapper module.
+
+`Tds.Types.UUID` is deprecated. Use `Ecto.UUID` for all UUID operations.
+
+### Custom Type Handlers
-To convert a mixed-endian UUID binary to a big-endian string, use
-[Tds.Types.UUID.load/1](https://hexdocs.pm/tds/Tds.Types.UUID.html#load/1)
+Register custom type handlers via the `extra_types` connection option:
+
+```elixir
+Tds.start_link(
+ hostname: "localhost",
+ extra_types: [MyApp.GeographyType]
+)
+```
-To convert a big-endian UUID string to a mixed-endian binary, use
-[Tds.Types.UUID.dump/1](https://hexdocs.pm/tds/Tds.Types.UUID.html#dump/1)
+Custom handlers implement the `Tds.Type` behaviour and can override built-in
+handlers for the same type codes or names. See `Tds.Type` docs for the
+callback specification.
## Contributing
@@ -216,7 +239,7 @@ use it for the first time.
The tests require an SQL Server database to be available on localhost.
If you are not using Windows OS you can start sql server instance using Docker.
-Official SQL Server Docker image can be found [here](https://hub.docker.com/r/microsoft/mssql-server-linux).
+Official SQL Server Docker image can be found [here](https://hub.docker.com/r/microsoft/mssql-server).
If you do not have specific requirements on how you would like to start sql server
in docker, you can use script for this repo.
diff --git a/bench/plp_bench.exs b/bench/plp_bench.exs
new file mode 100644
index 0000000..5106ac0
--- /dev/null
+++ b/bench/plp_bench.exs
@@ -0,0 +1,83 @@
+# PLP chunk reassembly: current binary concat vs iolist accumulation.
+# Run: mix run bench/plp_bench.exs
+#
+# Baseline results (2026-03-05)
+# Machine: Apple M4 Pro, 48 GB, macOS
+# Elixir 1.18.1, Erlang/OTP 27.2, JIT enabled
+#
+# Name ips average deviation median 99th %
+# 1MB new (iolist) 43.15 K 23.17 us +-10.68% 23.38 us 29.21 us
+# 1MB current (concat) 5.38 K 186.02 us +-21.73% 183.63 us 291.98 us
+# 10MB new (iolist) 3.44 K 290.93 us +-21.21% 280.71 us 730.41 us
+# 10MB current (concat) 0.49 K 2026.15 us +-6.40% 2018.46 us 2400.42 us
+#
+# Summary: iolist is 8x faster at 1MB and 7x faster at 10MB
+#
+# Memory usage:
+# 1MB new (iolist) 18.13 KB
+# 1MB current (concat) 35.44 KB - 1.96x
+# 10MB new (iolist) 170.70 KB
+# 10MB current (concat) 357.38 KB - 2.10x
+
+defmodule PLPBench do
+ # Current approach: buf <> :binary.copy(chunk)
+ def decode_plp_current(<<0::little-unsigned-32, _rest::binary>>, buf),
+ do: buf
+
+ def decode_plp_current(
+ <>,
+ buf
+ ) do
+ decode_plp_current(rest, buf <> :binary.copy(chunk))
+ end
+
+ # New approach: iolist accumulation
+ def decode_plp_iolist(<<0::little-unsigned-32, _rest::binary>>, acc),
+ do: :lists.reverse(acc) |> IO.iodata_to_binary()
+
+ def decode_plp_iolist(
+ <>,
+ acc
+ ) do
+ decode_plp_iolist(rest, [chunk | acc])
+ end
+
+ def build_plp_payload(total_size, chunk_size) do
+ chunk = :crypto.strong_rand_bytes(chunk_size)
+ num_chunks = div(total_size, chunk_size)
+
+ chunks =
+ for _ <- 1..num_chunks, into: <<>> do
+ <> <> chunk
+ end
+
+ chunks <> <<0::little-unsigned-32>>
+ end
+end
+
+payload_1mb = PLPBench.build_plp_payload(1_048_576, 4096)
+payload_10mb = PLPBench.build_plp_payload(10_485_760, 4096)
+
+Benchee.run(
+ %{
+ "1MB current (concat)" => fn ->
+ PLPBench.decode_plp_current(payload_1mb, <<>>)
+ end,
+ "1MB new (iolist)" => fn ->
+ PLPBench.decode_plp_iolist(payload_1mb, [])
+ end,
+ "10MB current (concat)" => fn ->
+ PLPBench.decode_plp_current(payload_10mb, <<>>)
+ end,
+ "10MB new (iolist)" => fn ->
+ PLPBench.decode_plp_iolist(payload_10mb, [])
+ end
+ },
+ warmup: 1,
+ time: 5,
+ memory_time: 2
+)
diff --git a/bench/type_system_bench.exs b/bench/type_system_bench.exs
new file mode 100644
index 0000000..61a3be9
--- /dev/null
+++ b/bench/type_system_bench.exs
@@ -0,0 +1,83 @@
+# Benchmarks for type system decode/encode throughput.
+# Run: mix run bench/type_system_bench.exs
+#
+# Requires benchee: add {:benchee, "~> 1.3", only: :dev, runtime: false}
+# to mix.exs deps if not present.
+
+alias Tds.Parameter
+alias Tds.Type.{DataReader, Registry}
+
+defmodule TypeBench.Fixtures do
+ import Tds.Protocol.Constants
+
+ @registry Tds.Type.Registry.new()
+
+ def registry, do: @registry
+
+ def integer_decode_input do
+ {:ok, handler} =
+ Registry.handler_for_code(@registry, tds_type(:int))
+
+ meta = %{data_reader: {:fixed, 4}, handler: handler}
+ data = <<42, 0, 0, 0, 0xFF>>
+ {meta, data}
+ end
+
+ def string_decode_input do
+ value = String.duplicate("hello", 100)
+ ucs2 = Tds.Encoding.UCS2.from_string(value)
+ size = byte_size(ucs2)
+
+ {:ok, handler} =
+ Registry.handler_for_code(@registry, tds_type(:nvarchar))
+
+ meta = %{
+ data_reader: :shortlen,
+ collation: %Tds.Protocol.Collation{codepage: :RAW},
+ encoding: :ucs2,
+ length: size,
+ handler: handler
+ }
+
+ data = <> <> ucs2 <> <<0xFF>>
+ {meta, data}
+ end
+
+ def decimal_encode_params do
+ for _ <- 1..1000 do
+ %Parameter{
+ name: "@1",
+ value: Decimal.new("12345.6789"),
+ type: :decimal
+ }
+ end
+ end
+end
+
+{int_meta, int_data} = TypeBench.Fixtures.integer_decode_input()
+{str_meta, str_data} = TypeBench.Fixtures.string_decode_input()
+params = TypeBench.Fixtures.decimal_encode_params()
+registry = TypeBench.Fixtures.registry()
+
+Benchee.run(
+ %{
+ "decode integer" => fn ->
+ {raw, _rest} = DataReader.read(int_meta.data_reader, int_data)
+ int_meta.handler.decode(raw, int_meta)
+ end,
+ "decode string" => fn ->
+ {raw, _rest} = DataReader.read(str_meta.data_reader, str_data)
+ str_meta.handler.decode(raw, str_meta)
+ end,
+ "encode 1000 decimal params" => fn ->
+ Enum.each(params, fn p ->
+ {:ok, handler} = Registry.handler_for_name(registry, p.type)
+ meta = %{type: p.type}
+ handler.encode(p.value, meta)
+ end)
+ end
+ },
+ warmup: 2,
+ time: 5,
+ memory_time: 2
+)
diff --git a/lib/tds.ex b/lib/tds.ex
index caa0cc1..8db9768 100644
--- a/lib/tds.ex
+++ b/lib/tds.ex
@@ -16,7 +16,6 @@ defmodule Tds do
Please consult with [configuration](readme.html#configuration) how to do this.
"""
alias Tds.Query
- alias Tds.Types.UUID
@timeout 5000
@execution_mode :prepare_execute
@@ -202,22 +201,28 @@ defmodule Tds do
Application.fetch_env!(:tds, :json_library)
end
+ @deprecated "Use Ecto.UUID instead"
@doc """
Generates a version 4 (random) UUID in the MS uniqueidentifier binary format.
"""
@spec generate_uuid :: <<_::128>>
- def generate_uuid, do: UUID.bingenerate()
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ def generate_uuid, do: apply(Tds.Types.UUID, :bingenerate, [])
+ @deprecated "Use Ecto.UUID instead"
@doc """
Decodes MS uniqueidentifier binary to its string representation.
"""
- def decode_uuid(uuid), do: UUID.load(uuid)
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ def decode_uuid(uuid), do: apply(Tds.Types.UUID, :load, [uuid])
+ @deprecated "Use Ecto.UUID instead"
@doc """
Same as `decode_uuid/1` but raises `ArgumentError` if value is invalid.
"""
def decode_uuid!(uuid) do
- case UUID.load(uuid) do
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ case apply(Tds.Types.UUID, :load, [uuid]) do
{:ok, value} ->
value
@@ -226,15 +231,19 @@ defmodule Tds do
end
end
+ @deprecated "Use Ecto.UUID instead"
@doc """
Encodes UUID string into MS uniqueidentifier binary.
"""
@spec encode_uuid(any) :: :error | {:ok, <<_::128>>}
- def encode_uuid(value), do: UUID.dump(value)
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ def encode_uuid(value), do: apply(Tds.Types.UUID, :dump, [value])
+ @deprecated "Use Ecto.UUID instead"
@doc """
Same as `encode_uuid/1` but raises `ArgumentError` if value is invalid.
"""
@spec encode_uuid!(any) :: <<_::128>>
- def encode_uuid!(value), do: UUID.dump!(value)
+ # credo:disable-for-next-line Credo.Check.Refactor.Apply
+ def encode_uuid!(value), do: apply(Tds.Types.UUID, :dump!, [value])
end
diff --git a/lib/tds/messages.ex b/lib/tds/messages.ex
index 5294e80..a591f84 100644
--- a/lib/tds/messages.ex
+++ b/lib/tds/messages.ex
@@ -3,11 +3,12 @@ defmodule Tds.Messages do
import Record, only: [defrecord: 2]
import Tds.Tokens, only: [decode_tokens: 1]
+ import Tds.Protocol.Constants
alias Tds.Encoding.UCS2
alias Tds.Parameter
- alias Tds.Protocol.{Login7, Prelogin}
- alias Tds.Types
+ alias Tds.Protocol.{Login7, Packet, Prelogin}
+ alias Tds.Type.Registry
require Bitwise
require Logger
@@ -47,21 +48,7 @@ defmodule Tds.Messages do
# @tds_sp_prepexecrpc 14
@tds_sp_unprepare 15
- ## Packet Size
- @tds_pack_data_size 4088
- # @tds_pack_header_size 8
- # @tds_pack_size @tds_pack_header_size + @tds_pack_data_size
-
- ## Packet Types
- # @tds_pack_sqlbatch 1
- # @tds_pack_rpcRequest 3
- @tds_pack_cancel 6
- # @tds_pack_bulkloadbcp 7
- # @tds_pack_transmgrreq 14
- # @tds_pack_normal 15
- # @tds_pack_login7 16
- # @tds_pack_sspimessage 17
- # @tds_pack_prelogin 18
+ # Packet sizes and types are sourced from Tds.Protocol.Constants via import.
## Parsers
def parse(:prelogin, packet_data, s) do
@@ -254,7 +241,7 @@ defmodule Tds.Messages do
end
defp encode(msg_attn(), _s) do
- encode_packets(@tds_pack_cancel, <<>>)
+ Packet.encode(packet_type(:attention), <<>>)
end
defp encode(msg_sql(query: q), %{trans: trans}) do
@@ -277,7 +264,7 @@ defmodule Tds.Messages do
total_length = byte_size(headers) + 4
all_headers = <> <> headers
data = all_headers <> q_ucs
- encode_packets(0x01, data)
+ Packet.encode(packet_type(:sql_batch), data)
end
defp encode(msg_rpc(proc: proc, params: params), %{trans: trans}) do
@@ -299,7 +286,7 @@ defmodule Tds.Messages do
data = all_headers <> encode_rpc(proc, params)
# layout Data
- encode_packets(0x03, data)
+ Packet.encode(packet_type(:rpc), data)
end
defp encode(msg_transmgr(command: "TM_BEGIN_XACT", isolation_level: isolation_level), %{
@@ -356,7 +343,7 @@ defmodule Tds.Messages do
data = all_headers <> <>
- encode_packets(0x0E, data)
+ Packet.encode(packet_type(:transaction_manager), data)
end
defp encode_rpc(:sp_executesql, params) do
@@ -412,38 +399,57 @@ defmodule Tds.Messages do
defp encode_rpc_param(%Tds.Parameter{name: name} = param) do
p_name = UCS2.from_string(name)
p_flags = param |> Parameter.option_flags()
- {type_code, type_data, type_attr} = Types.encode_data_type(param)
- p_meta_data = <> <> p_name <> p_flags <> type_data
+ {_type_code, meta_bin, value_bin} =
+ encode_via_handler(param)
- p_meta_data <> Types.encode_data(type_code, param.value, type_attr)
+ IO.iodata_to_binary([
+ <>,
+ p_name,
+ p_flags,
+ meta_bin,
+ value_bin
+ ])
end
- def encode_header(type, data, id, status) do
- length = byte_size(data) + 8
- # id::unsigned-size(8) below basicaly deals overflow e.g. rem(id, 255)
- <>
+ @default_registry Registry.new()
+
+ defp encode_via_handler(%Tds.Parameter{
+ type: type,
+ value: value
+ })
+ when not is_nil(type) do
+ handler = resolve_handler(@default_registry, type)
+ meta = handler_metadata(handler, value, type)
+ handler.encode(value, meta)
end
- @spec encode_packets(integer, binary, non_neg_integer) :: [binary, ...]
- def encode_packets(type, binary, id \\ 1)
+ defp encode_via_handler(%Tds.Parameter{value: value}) do
+ {:ok, handler, meta} =
+ Registry.infer(@default_registry, value)
- def encode_packets(_type, <<>>, _) do
- []
+ handler.encode(value, meta)
end
- def encode_packets(
- type,
- <>,
- id
- ) do
- status = if byte_size(tail) > 0, do: 0, else: 1
- packet = [encode_header(type, data, id, status), data]
- [packet | encode_packets(type, tail, id + 1)]
+ defp resolve_handler(registry, type) do
+ case Registry.handler_for_name(registry, type) do
+ {:ok, handler} ->
+ handler
+
+ :error ->
+ # Unknown types (e.g. {:array, :string}) default
+ # to string encoding, matching legacy behavior.
+ {:ok, handler} =
+ Registry.handler_for_name(registry, :string)
+
+ handler
+ end
end
- def encode_packets(type, data, id) do
- header = encode_header(type, data, id, 1)
- [header <> data]
+ defp handler_metadata(handler, value, type) do
+ case handler.infer(value) do
+ {:ok, meta} -> Map.put(meta, :type, type)
+ :skip -> %{type: type}
+ end
end
end
diff --git a/lib/tds/parameter.ex b/lib/tds/parameter.ex
index 63ce1b1..c72f97f 100644
--- a/lib/tds/parameter.ex
+++ b/lib/tds/parameter.ex
@@ -1,7 +1,7 @@
defmodule Tds.Parameter do
@moduledoc false
- alias Tds.Types
+ alias Tds.Type.Registry
@type t :: %__MODULE__{
name: String.t() | nil,
@@ -33,20 +33,20 @@ defmodule Tds.Parameter do
<<0::size(6), fDefaultValue::size(1), fByRefValue::size(1)>>
end
- def prepared_params(params) do
+ def prepared_params(params, registry \\ nil) do
+ reg = registry || default_registry()
+
params
|> List.wrap()
|> name(0)
|> Enum.map_join(", ", fn param ->
- param
- |> fix_data_type()
- |> Types.encode_param_descriptor()
+ param_descriptor(param, reg)
end)
end
@doc """
- Prepares parameters by giving them names, define missing type, encoding value
- if necessary.
+ Prepares parameters by giving them names, define missing type,
+ encoding value if necessary.
"""
def prepare_params(params) do
params
@@ -64,9 +64,14 @@ defmodule Tds.Parameter do
param =
case param do
- %__MODULE__{name: nil} -> fix_data_type(%{param | name: "@#{name}"})
- %__MODULE__{} -> fix_data_type(param)
- raw_param -> fix_data_type(raw_param, name)
+ %__MODULE__{name: nil} ->
+ fix_data_type(%{param | name: "@#{name}"})
+
+ %__MODULE__{} ->
+ fix_data_type(param)
+
+ raw_param ->
+ fix_data_type(raw_param, name)
end
do_name(tail, name, [param | acc])
@@ -82,8 +87,6 @@ defmodule Tds.Parameter do
end
def fix_data_type(%__MODULE__{type: nil, value: nil} = param) do
- # should fix Ecto has_one, on_change :nullify issue where type is not known when Ecto
- # builds query/statement for on_change callback
%{param | type: :binary}
end
@@ -108,11 +111,6 @@ defmodule Tds.Parameter do
def fix_data_type(%__MODULE__{value: value} = param)
when is_integer(value) do
- # if -2_147_483_648 >= value and value <= 2_147_483_647 do
- # %{param | type: :integer}
- # else
- # %{param | type: :bigint}
- # end
%{param | type: :integer}
end
@@ -145,14 +143,15 @@ defmodule Tds.Parameter do
%{param | type: :datetime}
end
- def fix_data_type(%__MODULE__{value: %NaiveDateTime{microsecond: {_, s}}} = param) do
+ def fix_data_type(
+ %__MODULE__{value: %NaiveDateTime{microsecond: {_, s}}} =
+ param
+ ) do
type = if s > 3, do: :datetime2, else: :datetime
%{param | type: type}
end
def fix_data_type(%__MODULE__{value: {{_, _, _}, {_, _, _, fsec}}} = param) do
- # todo: enable warning and introduce Tds.Types.DateTime2 and Tds.Types.DateTime
- # Logger.warn(fn -> "Datetime as tuple is obsolete, please use NaiveDateTime." end)
type = if rem(fsec, 1000) > 0, do: :datetime2, else: :datetime
%{param | type: type}
end
@@ -179,4 +178,54 @@ defmodule Tds.Parameter do
def fix_data_type(raw_param, acc) do
fix_data_type(%__MODULE__{name: "@#{acc}", value: raw_param})
end
+
+ @doc """
+ Generates a SQL parameter descriptor for a single parameter.
+
+ Returns a string like `"@name int"` or `"@name nvarchar(2000)"`.
+ """
+ def encode_param_descriptor(%__MODULE__{} = param) do
+ param_descriptor(param, default_registry())
+ end
+
+ defp param_descriptor(
+ %__MODULE__{name: name, type: type, value: value},
+ registry
+ )
+ when not is_nil(type) do
+ handler = resolve_handler(registry, type)
+ meta = infer_metadata(handler, value, type)
+ desc = handler.param_descriptor(value, meta)
+ "#{name} #{desc}"
+ end
+
+ defp param_descriptor(%__MODULE__{} = param, registry) do
+ param
+ |> fix_data_type()
+ |> param_descriptor(registry)
+ end
+
+ defp resolve_handler(registry, type) do
+ case Registry.handler_for_name(registry, type) do
+ {:ok, handler} ->
+ handler
+
+ :error ->
+ {:ok, handler} =
+ Registry.handler_for_name(registry, :string)
+
+ handler
+ end
+ end
+
+ defp infer_metadata(handler, value, type) do
+ case handler.infer(value) do
+ {:ok, meta} -> Map.put(meta, :type, type)
+ :skip -> %{type: type}
+ end
+ end
+
+ defp default_registry do
+ Registry.new()
+ end
end
diff --git a/lib/tds/protocol.ex b/lib/tds/protocol.ex
index 2fc918e..2d0e56b 100644
--- a/lib/tds/protocol.ex
+++ b/lib/tds/protocol.ex
@@ -3,7 +3,9 @@ defmodule Tds.Protocol do
Implements DBConnection behaviour for TDS protocol.
"""
alias Tds.{Parameter, Query}
- import Tds.{BinaryUtils, Messages, Utils}
+ alias Tds.Protocol.Packet
+ alias Tds.Type.Registry
+ import Tds.{Messages, Utils}
require Logger
use DBConnection
@@ -42,7 +44,8 @@ defmodule Tds.Protocol do
result: nil | list(),
query: nil | String.t(),
transaction: transaction,
- env: env
+ env: env,
+ registry: Registry.t()
}
defstruct sock: nil,
@@ -59,7 +62,8 @@ defmodule Tds.Protocol do
savepoint: 0,
collation: %Tds.Protocol.Collation{},
packetsize: 4096
- }
+ },
+ registry: nil
@spec connect(opts :: Keyword.t()) :: {:ok, state :: t()} | {:error, Exception.t()}
def connect(opts) do
@@ -71,7 +75,8 @@ defmodule Tds.Protocol do
|> Keyword.put_new(:hostname, System.get_env("MSSQLHOST") || "localhost")
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
- s = %__MODULE__{}
+ registry = Registry.new(opts[:extra_types] || [])
+ s = %__MODULE__{registry: registry}
case opts[:instance] do
nil ->
@@ -201,7 +206,7 @@ defmodule Tds.Protocol do
:prepare_execute ->
params =
opts[:parameters]
- |> Parameter.prepared_params()
+ |> Parameter.prepared_params(s.registry)
send_prepare(statement, params, %{s | state: :prepare})
@@ -609,7 +614,7 @@ defmodule Tds.Protocol do
defp send_param_query(
%Query{handle: handle, statement: statement} = _,
params,
- %{transaction: :started} = s
+ %{transaction: :started, registry: registry} = s
) do
msg =
case handle do
@@ -625,7 +630,7 @@ defmodule Tds.Protocol do
name: "@params",
type: :string,
direction: :input,
- value: Parameter.prepared_params(params)
+ value: Parameter.prepared_params(params, registry)
}
| Parameter.prepare_params(params)
]
@@ -661,7 +666,7 @@ defmodule Tds.Protocol do
defp send_param_query(
%Query{handle: handle, statement: statement} = _,
params,
- s
+ %{registry: registry} = s
) do
msg =
case handle do
@@ -677,7 +682,7 @@ defmodule Tds.Protocol do
name: "@params",
type: :string,
direction: :input,
- value: Parameter.prepared_params(params)
+ value: Parameter.prepared_params(params, registry)
}
| Parameter.prepare_params(params)
]
@@ -818,14 +823,15 @@ defmodule Tds.Protocol do
mod.send(sock, pak)
end)
- case msg_recv(s) do
- {:disconnect, ex, s} ->
- {:disconnect, ex, %{s | opts: clean_opts(opts)}}
+ case Packet.reassemble(s.sock) do
+ {:ok, _type, payload} ->
+ decode(payload, %{s | state: :login})
- buffer ->
- buffer
- |> IO.iodata_to_binary()
- |> decode(%{s | state: :login})
+ {:error, reason} ->
+ {:disconnect,
+ %Tds.Error{
+ message: "Login failed: #{inspect(reason)}"
+ }, %{s | opts: clean_opts(opts)}}
end
end
@@ -850,95 +856,15 @@ defmodule Tds.Protocol do
end)
with :ok <- send_result,
- buffer when is_list(buffer) <- msg_recv(s) do
- buffer
- |> IO.iodata_to_binary()
- |> decode(s)
- end
- end
-
- defp msg_recv(%{sock: {mod, pid}} = s) do
- case mod.recv(pid, 0) do
- {:ok, pkg} ->
- pkg
- |> next_tds_pkg([])
- |> msg_recv(s)
-
- {:error, error} ->
+ {:ok, _type, payload} <- Packet.reassemble(s.sock) do
+ decode(payload, s)
+ else
+ {:error, reason} ->
{:disconnect,
%Tds.Error{
- message: "Connection failed to receive packet due #{inspect(error)}"
+ message: "Failed to receive packet: #{inspect(reason)}"
}, s}
end
- catch
- {:error, error} -> {:disconnect, error, s}
- end
-
- defp msg_recv({:done, buffer, _}, _s) do
- Enum.reverse(buffer)
- end
-
- defp msg_recv({:more, buffer, more, last?}, %{sock: {mod, pid}} = s) do
- take = if last?, do: more, else: 0
-
- case mod.recv(pid, take) do
- {:ok, pkg} ->
- next_tds_pkg(pkg, buffer, more, last?)
- |> msg_recv(s)
-
- {:error, error} ->
- throw({:error, error})
- end
- end
-
- defp msg_recv({:more, buffer, unknown_pkg}, %{sock: {mod, pid}} = s) do
- case mod.recv(pid, 0) do
- {:ok, pkg} ->
- unknown_pkg
- |> Kernel.<>(pkg)
- |> next_tds_pkg(buffer)
- |> msg_recv(s)
-
- {:error, error} ->
- throw({:error, error})
- end
- end
-
- defp next_tds_pkg(pkg, buffer) do
- case pkg do
- <<0x04, 0x01, size::int16(), _::int32(), chunk::binary>> ->
- more = size - 8
- next_tds_pkg(chunk, buffer, more, true)
-
- <<0x04, 0x00, size::int16(), _::int32(), chunk::binary>> ->
- more = size - 8
- next_tds_pkg(chunk, buffer, more, false)
-
- unknown_pkg ->
- {:more, buffer, unknown_pkg}
- end
- end
-
- defp next_tds_pkg(pkg, buffer, more, true) do
- case pkg do
- <> ->
- {:done, [chunk | buffer], tail}
-
- <> ->
- more = more - byte_size(chunk)
- {:more, [chunk | buffer], more, true}
- end
- end
-
- defp next_tds_pkg(pkg, buffer, more, false) do
- case pkg do
- <> ->
- next_tds_pkg(tail, [chunk | buffer])
-
- <> ->
- more = more - byte_size(chunk)
- {:more, [chunk | buffer], more, false}
- end
end
defp clean_opts(opts) do
diff --git a/lib/tds/protocol/binary.ex b/lib/tds/protocol/binary.ex
new file mode 100644
index 0000000..99b20d2
--- /dev/null
+++ b/lib/tds/protocol/binary.ex
@@ -0,0 +1,239 @@
+defmodule Tds.Protocol.Binary do
+ @moduledoc """
+ Unified binary macros for TDS protocol encoding and decoding.
+
+ Consolidates macros from `Tds.BinaryUtils` (little-endian, used by most
+ modules) and `Tds.Protocol.Grammar` (big-endian + parameterized, used by
+ prelogin and collation).
+
+ ## Byte Order Convention
+
+ Multi-byte integer macros default to **little-endian** (the standard for
+ TDS data fields) and accept an optional `:big` or `:little` atom argument:
+
+ <> # little-endian (default)
+ <> # explicit little-endian
+ <> # big-endian (for prelogin headers)
+
+ ## Parameterized Macros
+
+ `bit/1`, `byte/1`, `uchar/1`, `unicodechar/1`, `bigbinary/1` accept
+ a size parameter and are used for structures like collation bitfields.
+ """
+
+ # ===========================================================================
+ # Unsigned integers — with optional endianness argument
+ # ===========================================================================
+
+ @doc "An unsigned single byte (8-bit) value. Range: 0..255."
+ defmacro byte, do: quote(do: unsigned - 8)
+
+ @doc """
+ An unsigned 2-byte (16-bit) value. Range: 0..65535.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro ushort(endian \\ :little)
+ defmacro ushort(:little), do: quote(do: little - unsigned - 16)
+ defmacro ushort(:big), do: quote(do: unsigned - 16)
+
+ @doc """
+ An unsigned 4-byte (32-bit) value. Range: 0..(2^32)-1.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro ulong(endian \\ :little)
+ defmacro ulong(:little), do: quote(do: little - unsigned - 32)
+ defmacro ulong(:big), do: quote(do: unsigned - 32)
+
+ @doc """
+ An unsigned 4-byte (32-bit) value. Alias for `ulong`.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro dword(endian \\ :little)
+ defmacro dword(:little), do: quote(do: little - unsigned - 32)
+ defmacro dword(:big), do: quote(do: unsigned - 32)
+
+ @doc """
+ An unsigned 8-byte (64-bit) value. Range: 0..(2^64)-1.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro ulonglong(endian \\ :little)
+ defmacro ulonglong(:little), do: quote(do: little - unsigned - 64)
+ defmacro ulonglong(:big), do: quote(do: unsigned - 64)
+
+ @doc "An unsigned single byte (8-bit) value representing a character."
+ defmacro uchar, do: quote(do: unsigned - 8)
+
+ # ===========================================================================
+ # Signed integers — with optional endianness argument
+ # ===========================================================================
+
+ @doc """
+ A signed 4-byte (32-bit) value.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro long(endian \\ :little)
+ defmacro long(:little), do: quote(do: little - signed - 32)
+ defmacro long(:big), do: quote(do: signed - 32)
+
+ @doc """
+ A signed 8-byte (64-bit) value.
+
+ Defaults to little-endian. Pass `:big` for big-endian.
+ """
+ defmacro longlong(endian \\ :little)
+ defmacro longlong(:little), do: quote(do: little - signed - 64)
+ defmacro longlong(:big), do: quote(do: signed - 64)
+
+ @doc "A signed 8-bit integer."
+ defmacro int8, do: quote(do: signed - 8)
+
+ @doc "A signed 16-bit little-endian integer."
+ defmacro int16, do: quote(do: little - signed - 16)
+
+ @doc "A signed 32-bit little-endian integer."
+ defmacro int32, do: quote(do: little - signed - 32)
+
+ @doc "A signed 64-bit little-endian integer."
+ defmacro int64, do: quote(do: little - signed - 64)
+
+ # ===========================================================================
+ # Unsigned integer aliases (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "An unsigned 8-bit integer."
+ defmacro uint8, do: quote(do: unsigned - 8)
+
+ @doc "An unsigned 16-bit little-endian integer."
+ defmacro uint16, do: quote(do: little - unsigned - 16)
+
+ @doc "An unsigned 32-bit little-endian integer."
+ defmacro uint32, do: quote(do: little - unsigned - 32)
+
+ @doc "An unsigned 64-bit little-endian integer."
+ defmacro uint64, do: quote(do: little - unsigned - 64)
+
+ # ===========================================================================
+ # Floats — little-endian (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "A 32-bit little-endian float."
+ defmacro float32, do: quote(do: little - signed - float - 32)
+
+ @doc "A 64-bit little-endian float."
+ defmacro float64, do: quote(do: little - signed - float - 64)
+
+ # ===========================================================================
+ # Length prefixes (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "Unsigned 8-bit length prefix."
+ defmacro bytelen, do: quote(do: unsigned - 8)
+
+ @doc "Unsigned 16-bit little-endian length prefix."
+ defmacro ushortlen, do: quote(do: little - unsigned - 16)
+
+ @doc "Unsigned 16-bit little-endian char/binary length prefix."
+ defmacro ushortcharbinlen, do: quote(do: little - unsigned - 16)
+
+ @doc "Signed 32-bit little-endian length prefix."
+ defmacro longlen, do: quote(do: little - signed - 32)
+
+ @doc "Unsigned 64-bit little-endian length prefix."
+ defmacro ulonglonglen, do: quote(do: little - unsigned - 64)
+
+ # ===========================================================================
+ # Type metadata (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "Unsigned 8-bit precision value."
+ defmacro precision, do: quote(do: unsigned - 8)
+
+ @doc "Unsigned 8-bit scale value."
+ defmacro scale, do: quote(do: unsigned - 8)
+
+ # ===========================================================================
+ # Null markers (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "A single byte (8-bit) NULL value."
+ defmacro gen_null, do: quote(do: size(8))
+
+ @doc "A 2-byte (16-bit) NULL value for char/binary data."
+ defmacro charbin_null16, do: quote(do: size(16))
+
+ @doc "A 4-byte (32-bit) NULL value for char/binary data."
+ defmacro charbin_null32, do: quote(do: size(32))
+
+ # ===========================================================================
+ # Reserved fields (from BinaryUtils — include literal zero values)
+ # ===========================================================================
+
+ @doc "A single reserved bit, set to 0."
+ defmacro freservedbit, do: quote(do: 0x0 :: size(1))
+
+ @doc "A single reserved byte, set to 0x00."
+ defmacro freservedbyte, do: quote(do: 0x00 :: size(8))
+
+ # ===========================================================================
+ # Fixed-width special (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "An unsigned 6-byte (48-bit) value."
+ defmacro sixbyte, do: quote(do: unsigned - 48)
+
+ @doc "A single bit value of either 0 or 1."
+ defmacro bit, do: quote(do: size(1))
+
+ # ===========================================================================
+ # Parameterized binary/unicode (from BinaryUtils)
+ # ===========================================================================
+
+ @doc "A binary of `size` bytes."
+ defmacro binary(size), do: quote(do: binary - size(unquote(size)))
+
+ @doc "A binary of `size * unit` bits."
+ defmacro binary(size, unit),
+ do: quote(do: binary - size(unquote(size)) - unit(unquote(unit)))
+
+ @doc "A little-endian UCS-2 binary of `size` 16-bit code units."
+ defmacro unicode(size),
+ do: quote(do: binary - little - size(unquote(size)) - unit(16))
+
+ # ===========================================================================
+ # Parameterized macros (from Grammar, for collation and structured fields)
+ # ===========================================================================
+
+ @doc "A field of `n` consecutive 1-bit units."
+ defmacro bit(n), do: quote(do: size(1) - unit(unquote(n)))
+
+ @doc "An unsigned field of `n` bytes."
+ defmacro byte(n), do: quote(do: unsigned - size(unquote(n)) - unit(8))
+
+ @doc "An unsigned field of `n` bytes (character variant)."
+ defmacro uchar(n), do: quote(do: unsigned - size(unquote(n)) - unit(8))
+
+ @doc "A field of `n` UCS-2 (16-bit) character units."
+ defmacro unicodechar(n), do: quote(do: size(unquote(n)) - unit(16))
+
+ @doc "A binary field of `n` bytes."
+ defmacro bigbinary(n), do: quote(do: binary - size(unquote(n)) - unit(8))
+
+ @doc "A reserved bit field of `n` 1-bit units for padding."
+ defmacro freservedbit(n), do: quote(do: size(1) - unit(unquote(n)))
+
+ @doc "A reserved byte field of `n` bytes for padding."
+ defmacro freservedbyte(n), do: quote(do: size(unquote(n)) - unit(8))
+
+ @doc """
+ A 2-byte or 4-byte NULL marker for char/binary data.
+
+ `n` must be 2 or 4.
+ """
+ defmacro charbin_null(n) when n in [2, 4],
+ do: quote(do: size(unquote(n)) - unit(8))
+end
diff --git a/lib/tds/protocol/constants.ex b/lib/tds/protocol/constants.ex
new file mode 100644
index 0000000..4ab7c3f
--- /dev/null
+++ b/lib/tds/protocol/constants.ex
@@ -0,0 +1,350 @@
+defmodule Tds.Protocol.Constants do
+ @moduledoc """
+ All TDS protocol constants (or type tokens if you like).
+
+ Provides macros that expand to integer literals at compile time,
+ making them usable in binary pattern matching and guard clauses.
+
+ ## Usage
+
+ require Tds.Protocol.Constants
+ alias Tds.Protocol.Constants
+
+ # In a function head / binary match:
+ def decode(<>), do: ...
+
+ # As a plain value:
+ type = Constants.packet_type(:login7)
+ """
+
+ # ---------------------------------------------------------------------------
+ # Packet Types
+ # ---------------------------------------------------------------------------
+
+ @packet_types %{
+ sql_batch: 0x01,
+ rpc: 0x03,
+ tabular_result: 0x04,
+ attention: 0x06,
+ bulk: 0x07,
+ fedauth_token: 0x08,
+ transaction_manager: 0x0E,
+ login7: 0x10,
+ sspi: 0x11,
+ prelogin: 0x12
+ }
+
+ @doc "Returns the numeric packet type code for the given atom."
+ defmacro packet_type(name) do
+ Map.fetch!(@packet_types, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Packet Sizes
+ # ---------------------------------------------------------------------------
+
+ @packet_sizes %{
+ header_size: 8,
+ max_data_size: 4088,
+ max_packet_size: 4096
+ }
+
+ @doc "Returns the packet size constant for the given atom."
+ defmacro packet_size(name) do
+ Map.fetch!(@packet_sizes, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # TDS Data Type Codes
+ # ---------------------------------------------------------------------------
+
+ # Fixed-length data types (zero-length null is included here)
+ @fixed_types %{
+ null: 0x1F,
+ tinyint: 0x30,
+ bit: 0x32,
+ smallint: 0x34,
+ int: 0x38,
+ smalldatetime: 0x3A,
+ real: 0x3B,
+ money: 0x3C,
+ datetime: 0x3D,
+ float: 0x3E,
+ smallmoney: 0x7A,
+ bigint: 0x7F
+ }
+
+ # Variable-length data types
+ @variable_types %{
+ uniqueidentifier: 0x24,
+ intn: 0x26,
+ # Legacy types
+ decimal: 0x37,
+ numeric: 0x3F,
+ bitn: 0x68,
+ decimaln: 0x6A,
+ numericn: 0x6C,
+ floatn: 0x6D,
+ moneyn: 0x6E,
+ datetimen: 0x6F,
+ daten: 0x28,
+ timen: 0x29,
+ datetime2n: 0x2A,
+ datetimeoffsetn: 0x2B,
+ # Legacy short types
+ char: 0x2F,
+ varchar: 0x27,
+ binary: 0x2D,
+ varbinary: 0x25,
+ # Big types (used for actual protocol encoding)
+ bigvarbinary: 0xA5,
+ bigvarchar: 0xA7,
+ bigbinary: 0xAD,
+ bigchar: 0xAF,
+ nvarchar: 0xE7,
+ nchar: 0xEF,
+ xml: 0xF1,
+ udt: 0xF0,
+ json: 0xF4,
+ vector: 0xF5,
+ text: 0x23,
+ image: 0x22,
+ ntext: 0x63,
+ variant: 0x62
+ }
+
+ @all_types Map.merge(@fixed_types, @variable_types)
+
+ @doc "Returns the numeric TDS data type code for the given atom."
+ defmacro tds_type(name) do
+ Map.fetch!(@all_types, name)
+ end
+
+ # Fixed data types mapped by code -> byte length
+ @fixed_data_types_map %{
+ 0x1F => 0,
+ 0x30 => 1,
+ 0x32 => 1,
+ 0x34 => 2,
+ 0x38 => 4,
+ 0x3A => 4,
+ 0x3B => 4,
+ 0x3C => 8,
+ 0x3D => 8,
+ 0x3E => 8,
+ 0x7A => 4,
+ 0x7F => 8
+ }
+
+ @doc "Returns a map of fixed type code => byte length."
+ @spec fixed_data_types() :: %{non_neg_integer() => non_neg_integer()}
+ def fixed_data_types, do: @fixed_data_types_map
+
+ @doc "Returns true if the given type code is a fixed-length data type."
+ @spec is_fixed_type?(non_neg_integer()) :: boolean()
+ def is_fixed_type?(code), do: Map.has_key?(@fixed_data_types_map, code)
+
+ @doc "Returns the byte length for a fixed type code, or nil if not a fixed type."
+ @spec fixed_type_length(non_neg_integer()) :: non_neg_integer() | nil
+ def fixed_type_length(code), do: Map.get(@fixed_data_types_map, code)
+
+ # ---------------------------------------------------------------------------
+ # Token Codes
+ # ---------------------------------------------------------------------------
+
+ @tokens %{
+ offset: 0x78,
+ returnstatus: 0x79,
+ colmetadata: 0x81,
+ altmetadata: 0x88,
+ dataclassification: 0xA3,
+ tabname: 0xA4,
+ colinfo: 0xA5,
+ order: 0xA9,
+ error: 0xAA,
+ info: 0xAB,
+ returnvalue: 0xAC,
+ loginack: 0xAD,
+ featureextack: 0xAE,
+ row: 0xD1,
+ nbcrow: 0xD2,
+ altrow: 0xD3,
+ envchange: 0xE3,
+ sessionstate: 0xE4,
+ sspi: 0xED,
+ fedauthinfo: 0xEE,
+ done: 0xFD,
+ doneproc: 0xFE,
+ doneinproc: 0xFF
+ }
+
+ @doc "Returns the numeric token code for the given atom."
+ defmacro token(name) do
+ Map.fetch!(@tokens, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Encryption Flags
+ # ---------------------------------------------------------------------------
+
+ @encryption_flags %{
+ off: 0x00,
+ on: 0x01,
+ not_supported: 0x02,
+ required: 0x03
+ }
+
+ @doc "Returns the numeric encryption flag for the given atom."
+ defmacro encryption(name) do
+ Map.fetch!(@encryption_flags, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Prelogin Token Types
+ # ---------------------------------------------------------------------------
+
+ @prelogin_token_types %{
+ version: 0x00,
+ encryption: 0x01,
+ instopt: 0x02,
+ thread_id: 0x03,
+ mars: 0x04,
+ trace_id: 0x05,
+ fed_auth_required: 0x06,
+ nonce_opt: 0x07,
+ terminator: 0xFF
+ }
+
+ @doc "Returns the numeric prelogin token type for the given atom."
+ defmacro prelogin_token_type(name) do
+ Map.fetch!(@prelogin_token_types, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Time Scale to Byte Length
+ # ---------------------------------------------------------------------------
+
+ @time_scale_lengths %{
+ 0 => 3,
+ 1 => 3,
+ 2 => 3,
+ 3 => 4,
+ 4 => 4,
+ 5 => 5,
+ 6 => 5,
+ 7 => 5
+ }
+
+ @doc "Returns the byte length needed to store a time value at the given scale (0..7)."
+ @spec time_byte_length(0..7) :: 3 | 4 | 5
+ def time_byte_length(scale) when scale in 0..7 do
+ Map.fetch!(@time_scale_lengths, scale)
+ end
+
+ # ---------------------------------------------------------------------------
+ # PLP (Partially Length-Prefixed) Constants
+ # ---------------------------------------------------------------------------
+
+ @plp_constants %{
+ null: 0xFFFFFFFFFFFFFFFF,
+ unknown_length: 0xFFFFFFFFFFFFFFFE,
+ marker_length: 0xFFFF,
+ max_short_data_size: 8000
+ }
+
+ @doc "Returns the PLP constant for the given atom."
+ defmacro plp(name) do
+ Map.fetch!(@plp_constants, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Environment Change Types
+ # ---------------------------------------------------------------------------
+
+ @envchange_types %{
+ database: 0x01,
+ language: 0x02,
+ charset: 0x03,
+ packet_size: 0x04,
+ unicode_data_sorting_local_id: 0x05,
+ unicode_data_sorting_comparison_flags: 0x06,
+ sql_collation: 0x07,
+ begin_transaction: 0x08,
+ commit_transaction: 0x09,
+ rollback_transaction: 0x0A,
+ enlist_dtc_transaction: 0x0B,
+ defect_transaction: 0x0C,
+ real_time_log_shipping: 0x0D,
+ promote_transaction: 0x0F,
+ transaction_manager_address: 0x10,
+ transaction_ended: 0x11,
+ reset_completion_acknowledgement: 0x12,
+ user_instance_started: 0x13,
+ routing_info: 0x14
+ }
+
+ @doc "Returns the numeric environment change type for the given atom."
+ defmacro envchange_type(name) do
+ Map.fetch!(@envchange_types, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Isolation Levels
+ # ---------------------------------------------------------------------------
+
+ @isolation_levels %{
+ read_uncommitted: 0x01,
+ read_committed: 0x02,
+ repeatable_read: 0x03,
+ snapshot: 0x04,
+ serializable: 0x05
+ }
+
+ @doc "Returns the numeric isolation level for the given atom."
+ defmacro isolation_level(name) do
+ Map.fetch!(@isolation_levels, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # TDS Protocol Versions
+ # ---------------------------------------------------------------------------
+
+ @tds_versions %{
+ tds_7_0: 0x70000000,
+ tds_7_1: 0x71000001,
+ tds_7_2: 0x72090002,
+ tds_7_3a: 0x730A0003,
+ tds_7_3b: 0x730B0003,
+ tds_7_4: 0x74000004
+ }
+
+ @doc "Returns the 4-byte TDS version code for the given atom."
+ defmacro tds_version(name) do
+ Map.fetch!(@tds_versions, name)
+ end
+
+ # ---------------------------------------------------------------------------
+ # Login7 Feature Extension IDs
+ # ---------------------------------------------------------------------------
+
+ @feature_ids %{
+ sessionrecovery: 0x01,
+ fedauth: 0x02,
+ columnencryption: 0x04,
+ globaltransactions: 0x05,
+ azuresqlsupport: 0x08,
+ dataclassification: 0x09,
+ utf8_support: 0x0A,
+ azuresqldnscaching: 0x0B,
+ jsonsupport: 0x0D,
+ vectorsupport: 0x0E,
+ enhancedroutingsupport: 0x0F,
+ useragent: 0x10,
+ terminator: 0xFF
+ }
+
+ @doc "Returns the numeric feature extension ID for the given atom."
+ defmacro feature_id(name) do
+ Map.fetch!(@feature_ids, name)
+ end
+end
diff --git a/lib/tds/protocol/login7.ex b/lib/tds/protocol/login7.ex
index c5dd5f5..a940d11 100644
--- a/lib/tds/protocol/login7.ex
+++ b/lib/tds/protocol/login7.ex
@@ -5,13 +5,10 @@ defmodule Tds.Protocol.Login7 do
See: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac
"""
alias Tds.Encoding.UCS2
- import Tds.BinaryUtils
+ alias Tds.Protocol.Packet
+ import Tds.Protocol.Binary
+ import Tds.Protocol.Constants
- @packet_header 0x10
- ## Packet Size
- @tds_pack_header_size 8
- @tds_pack_data_size 4088
- @tds_pack_size @tds_pack_header_size + @tds_pack_data_size
@max_supported_tds_version <<0x04, 0x00, 0x00, 0x74>>
@default_client_version <<0x04, 0x00, 0x00, 0x07>>
@client_pid <<0x00, 0x10, 0x00, 0x00>>
@@ -67,7 +64,7 @@ defmodule Tds.Protocol.Login7 do
%__MODULE__{
tds_version: @max_supported_tds_version,
- packet_size: <<@tds_pack_size::little-size(4)-unit(8)>>,
+ packet_size: <>,
hostname: to_string(hostname),
app_name: Keyword.get(opts, :app_name, @default_app_name),
client_version: @default_client_version,
@@ -95,7 +92,7 @@ defmodule Tds.Protocol.Login7 do
login7_len = byte_size(login7) + 4
data = <> <> login7
- Tds.Messages.encode_packets(@packet_header, data)
+ Packet.encode(packet_type(:login7), data)
end
defp fixed_login(login) do
diff --git a/lib/tds/protocol/packet.ex b/lib/tds/protocol/packet.ex
new file mode 100644
index 0000000..6efa2c7
--- /dev/null
+++ b/lib/tds/protocol/packet.ex
@@ -0,0 +1,312 @@
+defmodule Tds.Protocol.Packet do
+ @moduledoc """
+ TDS packet framing: encode payloads into TDS packets and
+ decode packet headers.
+
+ TDS packets are at most 4096 bytes: an 8-byte header followed
+ by up to 4088 bytes of data. Messages larger than 4088 bytes
+ are split across multiple packets with incrementing packet IDs.
+ """
+
+ import Tds.Protocol.Constants
+
+ @status_more 0x00
+ @status_eom 0x01
+
+ @type header :: %{
+ type: byte(),
+ status: byte(),
+ length: pos_integer(),
+ spid: non_neg_integer(),
+ packet_id: byte(),
+ window: byte()
+ }
+
+ @type sock ::
+ {module(), :gen_tcp.socket() | :ssl.sslsocket()}
+
+ @default_max_payload_size 200 * 1024 * 1024
+
+ @doc """
+ Encode a payload into one or more TDS packets.
+
+ Returns a list of iodata, one entry per packet. Each entry
+ contains an 8-byte TDS header followed by up to 4088 bytes
+ of payload data.
+
+ Packet IDs start at 1 and wrap at 256 per the TDS spec.
+ """
+ @spec encode(byte(), binary()) :: [iodata()]
+ def encode(type, payload) when is_binary(payload) do
+ do_encode(type, payload, 1)
+ end
+
+ defp do_encode(_type, <<>>, _id), do: []
+
+ defp do_encode(
+ type,
+ <>,
+ id
+ ) do
+ status =
+ if byte_size(rest) > 0, do: @status_more, else: @status_eom
+
+ [
+ build_packet(type, chunk, id, status)
+ | do_encode(type, rest, rem(id + 1, 256))
+ ]
+ end
+
+ defp do_encode(type, chunk, id) when is_binary(chunk) do
+ [build_packet(type, chunk, id, @status_eom)]
+ end
+
+ defp build_packet(type, data, id, status) do
+ length = byte_size(data) + packet_size(:header_size)
+ [<>, data]
+ end
+
+ @doc """
+ Parse an 8-byte TDS packet header from a binary.
+
+ Returns `{:ok, header, rest}` where `rest` is the remaining
+ bytes after the header, or `{:error, :incomplete_header}` if
+ the binary is shorter than 8 bytes.
+ """
+ @spec decode_header(binary()) ::
+ {:ok, header(), binary()} | {:error, :incomplete_header}
+ def decode_header(<<
+ type,
+ status,
+ length::16-big,
+ spid::16-little,
+ packet_id,
+ window,
+ rest::binary
+ >>) do
+ header = %{
+ type: type,
+ status: status,
+ length: length,
+ spid: spid,
+ packet_id: packet_id,
+ window: window
+ }
+
+ {:ok, header, rest}
+ end
+
+ def decode_header(_), do: {:error, :incomplete_header}
+
+ # ---------------------------------------------------------------------------
+ # Reassembly
+ # ---------------------------------------------------------------------------
+
+ @doc """
+ Read and reassemble a complete TDS message from the socket.
+
+ Reads one or more TDS packets, validates packet ID ordering,
+ strips headers, and concatenates the data payloads.
+
+ Returns `{:ok, type, payload}` on success.
+
+ ## Options
+
+ * `:max_payload_size` - maximum allowed payload in bytes
+ (default: 200 MB)
+ """
+ @spec reassemble(sock(), keyword()) ::
+ {:ok, byte(), binary()} | {:error, term()}
+ def reassemble(sock, opts \\ []) do
+ max =
+ Keyword.get(
+ opts,
+ :max_payload_size,
+ @default_max_payload_size
+ )
+
+ do_reassemble(sock, <<>>, nil, [], 0, nil, max)
+ end
+
+ defp do_reassemble(
+ {mod, port} = sock,
+ pending,
+ pkt_type,
+ buf,
+ total,
+ expected_id,
+ max
+ ) do
+ case mod.recv(port, 0) do
+ {:ok, data} ->
+ process_packets(
+ sock,
+ pending <> data,
+ pkt_type,
+ buf,
+ total,
+ expected_id,
+ max
+ )
+
+ {:error, reason} ->
+ {:error, {:recv_failed, reason}}
+ end
+ end
+
+ defp process_packets(
+ sock,
+ data,
+ pkt_type,
+ buf,
+ total,
+ expected_id,
+ max
+ ) do
+ case decode_header(data) do
+ {:error, :incomplete_header} ->
+ do_reassemble(
+ sock,
+ data,
+ pkt_type,
+ buf,
+ total,
+ expected_id,
+ max
+ )
+
+ {:ok, header, rest} ->
+ case validate_packet_id(expected_id, header.packet_id) do
+ :ok ->
+ type = pkt_type || header.type
+ data_len = header.length - packet_size(:header_size)
+
+ extract_and_continue(
+ sock,
+ rest,
+ type,
+ buf,
+ total,
+ header,
+ data_len,
+ max
+ )
+
+ {:error, _} = err ->
+ err
+ end
+ end
+ end
+
+ defp extract_and_continue(
+ sock,
+ rest,
+ pkt_type,
+ buf,
+ total,
+ header,
+ data_len,
+ max
+ ) do
+ case collect_chunk(sock, rest, data_len) do
+ {:ok, chunk, tail} ->
+ new_total = total + byte_size(chunk)
+
+ if new_total > max do
+ {:error, {:payload_too_large, new_total, max}}
+ else
+ next_id = rem(header.packet_id + 1, 256)
+
+ finish_or_continue(
+ sock,
+ tail,
+ pkt_type,
+ [chunk | buf],
+ new_total,
+ header.status,
+ next_id,
+ max
+ )
+ end
+
+ {:error, _} = err ->
+ err
+ end
+ end
+
+ defp collect_chunk(sock, available, needed) do
+ available_len = byte_size(available)
+
+ if available_len >= needed do
+ <> = available
+ {:ok, chunk, tail}
+ else
+ {mod, port} = sock
+ remaining = needed - available_len
+
+ case mod.recv(port, remaining) do
+ {:ok, more} ->
+ combined = available <> more
+ <> = combined
+ {:ok, chunk, tail}
+
+ {:error, reason} ->
+ {:error, {:recv_failed, reason}}
+ end
+ end
+ end
+
+ defp finish_or_continue(
+ _sock,
+ _tail,
+ pkt_type,
+ buf,
+ _total,
+ @status_eom,
+ _next_id,
+ _max
+ ) do
+ payload = buf |> Enum.reverse() |> IO.iodata_to_binary()
+ {:ok, pkt_type, payload}
+ end
+
+ defp finish_or_continue(
+ sock,
+ tail,
+ pkt_type,
+ buf,
+ total,
+ @status_more,
+ next_id,
+ max
+ ) do
+ if byte_size(tail) > 0 do
+ process_packets(
+ sock,
+ tail,
+ pkt_type,
+ buf,
+ total,
+ next_id,
+ max
+ )
+ else
+ do_reassemble(
+ sock,
+ <<>>,
+ pkt_type,
+ buf,
+ total,
+ next_id,
+ max
+ )
+ end
+ end
+
+ defp validate_packet_id(nil, _actual), do: :ok
+ defp validate_packet_id(expected, expected), do: :ok
+
+ defp validate_packet_id(expected, actual) do
+ {:error, {:out_of_order, expected: expected, got: actual}}
+ end
+end
diff --git a/lib/tds/protocol/prelogin.ex b/lib/tds/protocol/prelogin.ex
index b9ea692..a09748d 100644
--- a/lib/tds/protocol/prelogin.ex
+++ b/lib/tds/protocol/prelogin.ex
@@ -4,7 +4,9 @@ defmodule Tds.Protocol.Prelogin do
See: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/60f56408-0188-4cd5-8b90-25c6f2423868
"""
- import Tds.Protocol.Grammar
+ alias Tds.Protocol.Packet
+ import Tds.Protocol.Binary
+ import Tds.Protocol.Constants
require Logger
@type state :: Tds.Protocol.t()
@@ -30,24 +32,8 @@ defmodule Tds.Protocol.Prelogin do
mars: boolean()
}
- @packet_header 0x12
-
- # PL Options Tokens
- @version_token 0x00
- @encryption_token 0x01
- @instopt_token 0x02
- @thread_id_token 0x03
- @mars_token 0x04
- # @trace_id_token 0x05
- @fed_auth_required_token 0x06
- @nonce_opt_token 0x07
- @terminator_token 0xFF
-
- # Encryption flags
- @encryption_off 0x00
- @encryption_on 0x01
- @encryption_not_supported 0x02
- @encryption_required 0x03
+ # Packet type, prelogin token types, and encryption flags are now
+ # sourced from Tds.Protocol.Constants via import above.
@version Mix.Project.config()[:version]
|> String.split(".")
@@ -56,7 +42,7 @@ defmodule Tds.Protocol.Prelogin do
@spec encode(maybe_improper_list()) :: [binary(), ...]
def encode(opts) do
stream = [
- {@version_token, get_version()},
+ {prelogin_token_type(:version), get_version()},
encode_encryption(opts),
# when instance id check is sent, encryption is not negotiated
# encode_instance(opts),
@@ -69,13 +55,13 @@ defmodule Tds.Protocol.Prelogin do
{iodata, _} =
stream
- |> Enum.reduce({[[], @terminator_token, []], start_offset}, fn
+ |> Enum.reduce({[[], prelogin_token_type(:terminator), []], start_offset}, fn
{token, option_data}, {[options, term, data], offset} ->
data_length = byte_size(option_data)
options = [
options,
- <>
+ <>
]
data = [data, option_data]
@@ -83,14 +69,14 @@ defmodule Tds.Protocol.Prelogin do
end)
data = IO.iodata_to_binary(iodata)
- Tds.Messages.encode_packets(@packet_header, data)
+ Packet.encode(packet_type(:prelogin), data)
end
defp get_version do
@version
|> case do
[major, minor, build] ->
- <>
+ <>
[major, minor] ->
<<0x00, 0x00, minor, major, 0x00, 0x00>>
@@ -106,13 +92,13 @@ defmodule Tds.Protocol.Prelogin do
data =
case ssl?(opts) do
:on ->
- <<@encryption_on::byte()>>
+ <>
:not_supported ->
- <<@encryption_not_supported::byte()>>
+ <>
:required ->
- <<@encryption_required::byte()>>
+ <>
:off ->
# TODO: Support ssl: :off
@@ -120,13 +106,13 @@ defmodule Tds.Protocol.Prelogin do
# the other packages are send unencrypted over the wire.
raise ArgumentError, ~s("ssl: :off" is currently not supported)
- # <<@encryption_off::byte>>
+ # <>
value ->
raise ArgumentError, "invalid value for :ssl: #{inspect(value)}"
end
- {@encryption_token, data}
+ {prelogin_token_type(:encryption), data}
end
# defp encode_instance(opts) do
@@ -149,15 +135,15 @@ defmodule Tds.Protocol.Prelogin do
|> Integer.parse()
|> elem(0)
- {@thread_id_token, <>}
+ {prelogin_token_type(:thread_id), <>}
end
defp encode_mars(_opts) do
- {@mars_token, <<0x00>>}
+ {prelogin_token_type(:mars), <<0x00>>}
end
defp encode_fed_auth_required(_opts) do
- {@fed_auth_required_token, <<0x01>>}
+ {prelogin_token_type(:fed_auth_required), <<0x01>>}
end
# DECODE
@@ -177,23 +163,23 @@ defmodule Tds.Protocol.Prelogin do
disconnect(msg, s)
# Encryption is off. Allowed server response is :off or :not_supported
- {:off, enc, _} when enc in [<<@encryption_off>>, <<@encryption_not_supported>>] ->
+ {:off, enc, _} when enc in [<>, <>] ->
{:login, s}
# TODO: Encryption is off but server has encryption on. Should upgrade.
- {:off, <<@encryption_required>>, _} ->
+ {:off, <>, _} ->
disconnect("Server does not allow the requested encryption level.", s)
# Encryption is not supported. The server needs to respond with :not_supported
- {:not_supported, <<@encryption_not_supported>>, _} ->
+ {:not_supported, <>, _} ->
{:login, s}
# Encryption is on. The server needs to respond with :on
- {:on, <<@encryption_on>>, _} ->
+ {:on, <>, _} ->
{:encrypt, s}
# Encryption is required. The server needs to respond with :on
- {:required, <<@encryption_on>>, _} ->
+ {:required, <>, _} ->
{:encrypt, s}
{_, _, _} ->
@@ -202,7 +188,8 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@version_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -211,7 +198,8 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@encryption_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -220,16 +208,18 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@instopt_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
- tokens = [{:encryption, offset, length} | tokens]
+ tokens = [{:instance, offset, length} | tokens]
decode_tokens(tail, tokens, s)
end
defp decode_tokens(
- <<@thread_id_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -238,7 +228,7 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@mars_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -247,7 +237,8 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@fed_auth_required_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -256,7 +247,8 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@nonce_opt_token, offset::ushort(), length::ushort(), tail::binary>>,
+ <>,
tokens,
s
) do
@@ -265,7 +257,7 @@ defmodule Tds.Protocol.Prelogin do
end
defp decode_tokens(
- <<@terminator_token, tail::binary>>,
+ <>,
tokens,
_s
) do
diff --git a/lib/tds/query.ex b/lib/tds/query.ex
index 76d3362..02a4293 100644
--- a/lib/tds/query.ex
+++ b/lib/tds/query.ex
@@ -13,7 +13,6 @@ end
defimpl DBConnection.Query, for: Tds.Query do
alias Tds.Parameter
- alias Tds.Types
alias Tds.Query
alias Tds.Result
@@ -26,7 +25,7 @@ defimpl DBConnection.Query, for: Tds.Query do
end
def encode(%Query{statement: statement}, params, _) do
- param_desc = Enum.map_join(params, ", ", &Types.encode_param_descriptor/1)
+ param_desc = Enum.map_join(params, ", ", &Parameter.encode_param_descriptor/1)
[
%Parameter{value: statement, type: :string},
diff --git a/lib/tds/tokens.ex b/lib/tds/tokens.ex
index 31e26e2..285819d 100644
--- a/lib/tds/tokens.ex
+++ b/lib/tds/tokens.ex
@@ -1,13 +1,16 @@
defmodule Tds.Tokens do
@moduledoc false
- import Tds.BinaryUtils
+ import Tds.Protocol.Binary
+ import Tds.Protocol.Constants
import Bitwise
require Logger
alias Tds.Encoding.UCS2
- alias Tds.Types
+ alias Tds.Type.{DataReader, Registry}
+
+ @registry Registry.new()
def retval_typ_size(38) do
# 0x26 - SYBINTN - 1
@@ -43,30 +46,53 @@ defmodule Tds.Tokens do
[]
end
- def decode_tokens(<>, collmetadata) do
+ def decode_tokens(
+ <>,
+ collmetadata
+ ) do
{token_data, tail, collmetadata} =
case token do
- 0x81 -> decode_colmetadata(tail, collmetadata)
- # 0xA5 -> decode_colinfo(tail, collmetadata)
- 0xFD -> decode_done(tail, collmetadata)
- 0xFE -> decode_doneproc(tail, collmetadata)
- 0xFF -> decode_doneinproc(tail, collmetadata)
- 0xE3 -> decode_envchange(tail, collmetadata)
- 0xAA -> decode_error(tail, collmetadata)
- # 0xAE -> decode_featureextack(tail, collmetadata)
- # 0xEE -> decode_fedauthinfo(tail, collmetadata)
- 0xAB -> decode_info(tail, collmetadata)
- 0xAD -> decode_loginack(tail, collmetadata)
- 0xD2 -> decode_nbcrow(tail, collmetadata)
- # 0x78 -> decode_offset(tail, collmetadata)
- 0xA9 -> decode_order(tail, collmetadata)
- 0x79 -> decode_returnstatus(tail, collmetadata)
- 0xAC -> decode_returnvalue(tail, collmetadata)
- 0xD1 -> decode_row(tail, collmetadata)
- # 0xE4 -> decode_sessionstate(tail, collmetadata)
- # 0xED -> decode_sspi(tail, collmetadata)
- # 0xA4 -> decode_tablename(tail, collmetadata)
- t -> raise_unsupported_token(t, collmetadata)
+ token(:colmetadata) ->
+ decode_colmetadata(tail, collmetadata)
+
+ token(:done) ->
+ decode_done(tail, collmetadata)
+
+ token(:doneproc) ->
+ decode_doneproc(tail, collmetadata)
+
+ token(:doneinproc) ->
+ decode_doneinproc(tail, collmetadata)
+
+ token(:envchange) ->
+ decode_envchange(tail, collmetadata)
+
+ token(:error) ->
+ decode_error(tail, collmetadata)
+
+ token(:info) ->
+ decode_info(tail, collmetadata)
+
+ token(:loginack) ->
+ decode_loginack(tail, collmetadata)
+
+ token(:nbcrow) ->
+ decode_nbcrow(tail, collmetadata)
+
+ token(:order) ->
+ decode_order(tail, collmetadata)
+
+ token(:returnstatus) ->
+ decode_returnstatus(tail, collmetadata)
+
+ token(:returnvalue) ->
+ decode_returnvalue(tail, collmetadata)
+
+ token(:row) ->
+ decode_row(tail, collmetadata)
+
+ t ->
+ raise_unsupported_token(t, collmetadata)
end
[token_data | decode_tokens(tail, collmetadata)]
@@ -74,7 +100,8 @@ defmodule Tds.Tokens do
defp raise_unsupported_token(token, _) do
raise RuntimeError,
- "Unsupported Token code #{inspect(token, base: :hex)} in Token Stream"
+ "Unsupported Token code " <>
+ "#{inspect(token, base: :hex)} in Token Stream"
end
defp decode_returnvalue(bin, collmetadata) do
@@ -89,8 +116,8 @@ defmodule Tds.Tokens do
>> = bin
name = UCS2.to_string(name)
- {type_info, tail} = Tds.Types.decode_info(data)
- {value, tail} = Tds.Types.decode_data(type_info, tail)
+ {meta, tail} = decode_type_metadata(data)
+ {value, tail} = decode_type_value(meta, tail)
param = %Tds.Parameter{name: name, value: value, direction: :output}
{{:returnvalue, param}, tail, collmetadata}
end
@@ -112,7 +139,10 @@ defmodule Tds.Tokens do
end
# ORDER
- defp decode_order(<>, collmetadata) do
+ defp decode_order(
+ <>,
+ collmetadata
+ ) do
length = trunc(length / 2)
{columns, tail} = decode_column_order(tail, length)
{{:order, columns}, tail, collmetadata}
@@ -146,8 +176,6 @@ defmodule Tds.Tokens do
line_number: line_number
}
- # TODO Need to concat errors for delivery
- # Logger.debug "SQL Error: #{inspect e}"
{{:error, e}, tail, collmetadata}
end
@@ -190,7 +218,6 @@ defmodule Tds.Tokens do
|> IO.iodata_to_binary()
end)
- # tokens = Keyword.update(tokens, :info, [i], &[i | &1])
{{:info, info}, tail, collmetadata}
end
@@ -290,12 +317,6 @@ defmodule Tds.Tokens do
{{:packetsize, new_packetsize, old_packetsize}, rest}
- # 0x05
- # @tds_envtype_unicode_data_storing_local_id ->
-
- # 0x06
- # @tds_envtype_uncode_data_string_comparison_flag ->
-
0x07 ->
<<
new_value_size::unsigned-8,
@@ -341,9 +362,6 @@ defmodule Tds.Tokens do
trans = :binary.copy(old_value)
{{:transaction_rollback, <<0x00>>, trans}, rest}
- # 0x0B
- # @tds_envtype_enlist_dtc_transaction ->
-
0x0C ->
<<
value_size::unsigned-8,
@@ -382,7 +400,7 @@ defmodule Tds.Tokens do
0x13 ->
<<
- size::little-uint16(),
+ size::uint16(),
value::binary(size, 16),
0x00,
rest::binary
@@ -392,11 +410,10 @@ defmodule Tds.Tokens do
0x14 ->
<<
- _routing_data_len::little-uint16(),
- # Protocol MUST be 0, specifying TCP-IP protocol
+ _routing_data_len::uint16(),
0x00,
- port::little-uint16(),
- alt_host_len::little-uint16(),
+ port::uint16(),
+ alt_host_len::uint16(),
alt_host::binary(alt_host_len, 16),
0x00,
0x00,
@@ -407,7 +424,7 @@ defmodule Tds.Tokens do
UCS2.to_string(alt_host)
|> String.split("\\")
|> case do
- [host, instance] -> {host, instance}
+ [host, inst] -> {host, inst}
[host] -> {host, nil}
end
@@ -425,8 +442,12 @@ defmodule Tds.Tokens do
## DONE
defp decode_done(
- <>,
+ <<
+ status::little-unsigned-size(2)-unit(8),
+ cur_cmd::little-unsigned-size(2)-unit(8),
+ row_count::little-size(8)-unit(8),
+ tail::binary
+ >>,
collmetadata
) do
status = %{
@@ -463,7 +484,7 @@ defmodule Tds.Tokens do
defp decode_loginack(
<<
- _length::little-uint16(),
+ _length::uint16(),
interface::size(8),
tds_version::unsigned-32,
prog_name_len::size(8),
@@ -492,7 +513,11 @@ defmodule Tds.Tokens do
{Enum.reverse(acc), tail}
end
- defp decode_column_order(<>, n, acc) do
+ defp decode_column_order(
+ <>,
+ n,
+ acc
+ ) do
decode_column_order(tail, n - 1, [col_id | acc])
end
@@ -522,13 +547,10 @@ defmodule Tds.Tokens do
end
defp decode_column(<<_usertype::int32(), _flags::int16(), tail::binary>>) do
- {info, tail} = Types.decode_info(tail)
+ {info, tail} = decode_type_metadata(tail)
{name, tail} = decode_column_name(tail)
- info =
- info
- |> Map.put(:name, name)
-
+ info = Map.put(info, :name, name)
{info, tail}
end
@@ -543,31 +565,64 @@ defmodule Tds.Tokens do
{Enum.reverse(acc), tail}
end
- defp decode_row_columns(<>, [column_meta | colmetadata], acc) do
- {column, tail} = decode_row_column(data, column_meta)
+ defp decode_row_columns(
+ <>,
+ [column_meta | colmetadata],
+ acc
+ ) do
+ {column, tail} = decode_type_value(column_meta, data)
decode_row_columns(tail, colmetadata, [column | acc])
end
- defp decode_nbcrow_columns(binary, colmetadata, bitmap, acc \\ [])
+ defp decode_nbcrow_columns(
+ binary,
+ colmetadata,
+ bitmap,
+ acc \\ []
+ )
defp decode_nbcrow_columns(<>, [], _bitmap, acc) do
{Enum.reverse(acc), tail}
end
- defp decode_nbcrow_columns(<>, colmetadata, bitmap, acc) do
+ defp decode_nbcrow_columns(
+ <>,
+ colmetadata,
+ bitmap,
+ acc
+ ) do
[column_meta | colmetadata] = colmetadata
[bit | bitmap] = bitmap
{column, tail} =
case bit do
- 0 -> decode_row_column(tail, column_meta)
+ 0 -> decode_type_value(column_meta, tail)
_ -> {nil, tail}
end
- decode_nbcrow_columns(tail, colmetadata, bitmap, [column | acc])
+ decode_nbcrow_columns(
+ tail,
+ colmetadata,
+ bitmap,
+ [column | acc]
+ )
+ end
+
+ # -- New type system pipeline ----------------------------------------
+
+ # Decodes type metadata from binary using Registry + handler.
+ defp decode_type_metadata(<> = bin) do
+ {:ok, handler} =
+ Registry.handler_for_code(@registry, type_code)
+
+ {:ok, meta, rest} = handler.decode_metadata(bin)
+ {Map.put(meta, :handler, handler), rest}
end
- defp decode_row_column(<>, column_meta) do
- Types.decode_data(column_meta, tail)
+ # Decodes a column value using DataReader + handler.decode.
+ defp decode_type_value(%{handler: handler} = meta, bin) do
+ {raw, rest} = DataReader.read(meta.data_reader, bin)
+ value = handler.decode(raw, meta)
+ {value, rest}
end
end
diff --git a/lib/tds/type.ex b/lib/tds/type.ex
new file mode 100644
index 0000000..acd42a7
--- /dev/null
+++ b/lib/tds/type.ex
@@ -0,0 +1,48 @@
+defmodule Tds.Type do
+ @moduledoc """
+ Behaviour for TDS type handlers.
+
+ Each handler serves one or more TDS type codes and provides
+ encode/decode between TDS wire format and Elixir values.
+ """
+
+ @type metadata :: map()
+
+ @doc "TDS type codes this handler serves (decode path)."
+ @callback type_codes() :: [byte()]
+
+ @doc "Atom type names this handler serves (encode path)."
+ @callback type_names() :: [atom()]
+
+ @doc "Parse type-specific metadata from token stream binary."
+ @callback decode_metadata(binary()) ::
+ {:ok, metadata(), rest :: binary()}
+
+ @doc """
+ Decode raw value bytes into Elixir value.
+
+ Receives `nil` for SQL NULL (DataReader detected null marker).
+ Receives raw bytes with length prefix already stripped
+ by DataReader.
+ """
+ @callback decode(nil | binary(), metadata()) :: term()
+
+ @doc """
+ Encode Elixir value to TDS binary for RPC parameter.
+
+ Returns `{type_code, colmetadata_binary, value_binary}`.
+ """
+ @callback encode(term(), metadata()) ::
+ {type_code :: byte(), meta_bin :: iodata(), value_bin :: iodata()}
+
+ @doc "Generate sp_executesql parameter descriptor string."
+ @callback param_descriptor(term(), metadata()) :: String.t()
+
+ @doc """
+ Type inference: can this handler encode this value?
+
+ Returns `{:ok, metadata}` if yes, `:skip` if not this
+ handler's type.
+ """
+ @callback infer(term()) :: {:ok, metadata()} | :skip
+end
diff --git a/lib/tds/type/binary.ex b/lib/tds/type/binary.ex
new file mode 100644
index 0000000..e427b87
--- /dev/null
+++ b/lib/tds/type/binary.ex
@@ -0,0 +1,181 @@
+defmodule Tds.Type.Binary do
+ @moduledoc """
+ TDS type handler for binary values.
+
+ Handles 5 type codes on decode:
+ - bigbinary (0xAD), bigvarbinary (0xA5) — 2-byte max_length
+ - legacy binary (0x2D), legacy varbinary (0x25) — 1-byte length
+ - image (0x22) — longlen with table name parts
+
+ Always encodes as bigvarbinary (0xA5) for parameters.
+ No character encoding — raw binary passthrough.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ # -- type_codes / type_names ----------------------------------------
+
+ @impl true
+ def type_codes do
+ [
+ tds_type(:bigbinary),
+ tds_type(:bigvarbinary),
+ tds_type(:image),
+ tds_type(:binary),
+ tds_type(:varbinary)
+ ]
+ end
+
+ @impl true
+ def type_names, do: [:binary, :image]
+
+ # -- decode_metadata ------------------------------------------------
+
+ # Big types: bigbinary (0xAD), bigvarbinary (0xA5)
+ # 2-byte LE max_length, shortlen or plp (0xFFFF)
+ @impl true
+ def decode_metadata(<>)
+ when type_code in [tds_type(:bigbinary), tds_type(:bigvarbinary)] do
+ data_reader = if length == 0xFFFF, do: :plp, else: :shortlen
+
+ meta = %{
+ data_reader: data_reader,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # Legacy short types: binary (0x2D), varbinary (0x25)
+ # 1-byte length, bytelen reader
+ def decode_metadata(<>)
+ when type_code in [tds_type(:binary), tds_type(:varbinary)] do
+ meta = %{
+ data_reader: :bytelen,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # image (0x22): 4-byte length + numparts table names
+ def decode_metadata(
+ <>
+ ) do
+ rest = skip_table_parts(numparts, rest)
+
+ meta = %{
+ data_reader: :longlen,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # -- decode ---------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<>>, _metadata), do: <<>>
+
+ def decode(data, _metadata) do
+ :binary.copy(data)
+ end
+
+ # -- encode ---------------------------------------------------------
+
+ @impl true
+ def encode(value, metadata) when is_integer(value) do
+ encode(<>, metadata)
+ end
+
+ def encode(nil, _metadata) do
+ type = tds_type(:bigvarbinary)
+ meta_bin = <>
+ value_bin = <>
+ {type, meta_bin, value_bin}
+ end
+
+ def encode(value, _metadata) when is_binary(value) do
+ type = tds_type(:bigvarbinary)
+ size = byte_size(value)
+
+ cond do
+ size == 0 ->
+ meta_bin = <>
+ value_bin = <<0::unsigned-64, 0::unsigned-32>>
+ {type, meta_bin, value_bin}
+
+ size > 8000 ->
+ meta_bin = <>
+ value_bin = encode_plp(value)
+ {type, meta_bin, value_bin}
+
+ true ->
+ meta_bin = <>
+ value_bin = <> <> value
+ {type, meta_bin, value_bin}
+ end
+ end
+
+ # -- param_descriptor -----------------------------------------------
+
+ @impl true
+ def param_descriptor(value, metadata) when is_integer(value) do
+ param_descriptor(<>, metadata)
+ end
+
+ def param_descriptor(nil, _metadata), do: "varbinary(1)"
+
+ def param_descriptor(value, _metadata) when is_binary(value) do
+ if byte_size(value) <= 0 do
+ "varbinary(1)"
+ else
+ "varbinary(max)"
+ end
+ end
+
+ # -- infer ----------------------------------------------------------
+
+ @impl true
+ def infer(value) when is_binary(value) do
+ if String.valid?(value) do
+ :skip
+ else
+ {:ok, %{}}
+ end
+ end
+
+ def infer(_value), do: :skip
+
+ # -- private helpers ------------------------------------------------
+
+ defp skip_table_parts(0, rest), do: rest
+
+ defp skip_table_parts(n, rest) when n > 0 do
+ <> = rest
+
+ skip_table_parts(n - 1, next)
+ end
+
+ defp encode_plp(data) do
+ size = byte_size(data)
+
+ <> <>
+ encode_plp_chunks(size, data, <<>>) <>
+ <<0::little-unsigned-32>>
+ end
+
+ defp encode_plp_chunks(0, _data, buf), do: buf
+
+ defp encode_plp_chunks(size, data, buf) do
+ <<_hi::unsigned-32, chunk_size::unsigned-32>> =
+ <>
+
+ <> = data
+ plp = <> <> chunk
+ encode_plp_chunks(size - chunk_size, rest, buf <> plp)
+ end
+end
diff --git a/lib/tds/type/boolean.ex b/lib/tds/type/boolean.ex
new file mode 100644
index 0000000..8df8f0f
--- /dev/null
+++ b/lib/tds/type/boolean.ex
@@ -0,0 +1,51 @@
+defmodule Tds.Type.Boolean do
+ @moduledoc """
+ TDS type handler for boolean values.
+
+ Handles fixed bit (0x32) and variable bitn (0x68) on decode.
+ Always encodes as bitn (0x68) to support NULL.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @impl true
+ def type_codes, do: [tds_type(:bit), tds_type(:bitn)]
+
+ @impl true
+ def type_names, do: [:boolean]
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 1}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen}, rest}
+ end
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<0x00>>, _metadata), do: false
+ def decode(_data, _metadata), do: true
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:bitn)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(value, _metadata) when is_boolean(value) do
+ type = tds_type(:bitn)
+ byte = if value, do: 0x01, else: 0x00
+ {type, <>, <<0x01, byte>>}
+ end
+
+ @impl true
+ def param_descriptor(_value, _metadata), do: "bit"
+
+ @impl true
+ def infer(value) when is_boolean(value), do: {:ok, %{}}
+ def infer(_value), do: :skip
+end
diff --git a/lib/tds/type/data_reader.ex b/lib/tds/type/data_reader.ex
new file mode 100644
index 0000000..c34633c
--- /dev/null
+++ b/lib/tds/type/data_reader.ex
@@ -0,0 +1,85 @@
+defmodule Tds.Type.DataReader do
+ @moduledoc """
+ Reads type-specific value bytes from the TDS token stream.
+
+ Handles six framing strategies. All strategies sever sub-binary
+ references via `:binary.copy/1` or `IO.iodata_to_binary/1` to
+ prevent packet buffer memory leaks.
+ """
+
+ @spec read(strategy :: term(), binary()) ::
+ {nil | binary(), rest :: binary()}
+
+ # Fixed-length: size known from type metadata
+ def read({:fixed, length}, binary) do
+ <> = binary
+ {:binary.copy(value), rest}
+ end
+
+ # Bytelen: 1-byte length prefix, 0x00 = NULL
+ def read(:bytelen, <<0x00, rest::binary>>), do: {nil, rest}
+
+ def read(:bytelen, <>),
+ do: {:binary.copy(data), rest}
+
+ # Shortlen: 2-byte LE length prefix, 0xFFFF = NULL
+ def read(:shortlen, <<0xFF, 0xFF, rest::binary>>), do: {nil, rest}
+
+ def read(:shortlen, <>),
+ do: {:binary.copy(data), rest}
+
+ # Longlen: text_ptr + timestamp + 4-byte length, 0x00 = NULL
+ def read(:longlen, <<0x00, rest::binary>>), do: {nil, rest}
+
+ def read(
+ :longlen,
+ <<
+ ptr_size::unsigned-8,
+ _ptr::binary-size(ptr_size),
+ _timestamp::unsigned-64,
+ size::little-signed-32,
+ data::binary-size(size),
+ rest::binary
+ >>
+ ),
+ do: {:binary.copy(data), rest}
+
+ # Variant: 4-byte LE length prefix, 0x00000000 = NULL
+ def read(:variant, <<0::little-unsigned-32, rest::binary>>),
+ do: {nil, rest}
+
+ def read(:variant, <>),
+ do: {:binary.copy(data), rest}
+
+ # PLP: 8-byte NULL marker or chunked data
+ def read(
+ :plp,
+ <<
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ 0xFF,
+ rest::binary
+ >>
+ ),
+ do: {nil, rest}
+
+ def read(:plp, <<_total::little-unsigned-64, rest::binary>>) do
+ {chunks, rest} = read_plp_chunks(rest, [])
+ data = :lists.reverse(chunks) |> IO.iodata_to_binary()
+ {data, rest}
+ end
+
+ defp read_plp_chunks(<<0::little-unsigned-32, rest::binary>>, acc),
+ do: {acc, rest}
+
+ defp read_plp_chunks(
+ <>,
+ acc
+ ),
+ do: read_plp_chunks(rest, [:binary.copy(chunk) | acc])
+end
diff --git a/lib/tds/type/datetime.ex b/lib/tds/type/datetime.ex
new file mode 100644
index 0000000..ff0ead2
--- /dev/null
+++ b/lib/tds/type/datetime.ex
@@ -0,0 +1,539 @@
+defmodule Tds.Type.DateTime do
+ @moduledoc """
+ TDS type handler for date and time values.
+
+ Handles seven type codes on decode:
+ - daten (0x28) — Date
+ - timen (0x29) — Time with scale
+ - datetime2n (0x2A) — NaiveDateTime with scale
+ - datetimeoffsetn (0x2B) — DateTime with timezone offset
+ - smalldatetime (0x3A) — 4-byte NaiveDateTime (minute precision)
+ - datetime (0x3D) — 8-byte NaiveDateTime (1/300s precision)
+ - datetimen (0x6F) — nullable smalldatetime/datetime
+
+ Always returns Elixir calendar structs: Date, Time,
+ NaiveDateTime, or DateTime. No tuple format.
+
+ Encodes Date as daten, Time as timen, NaiveDateTime as
+ datetime2n, and DateTime as datetimeoffsetn.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @year_1900_days :calendar.date_to_gregorian_days({1900, 1, 1})
+ @secs_in_min 60
+ @secs_in_hour 60 * @secs_in_min
+ @max_time_scale 7
+
+ @daten_code tds_type(:daten)
+ @timen_code tds_type(:timen)
+ @datetime2n_code tds_type(:datetime2n)
+ @datetimeoffsetn_code tds_type(:datetimeoffsetn)
+ @smalldatetime_code tds_type(:smalldatetime)
+ @datetime_code tds_type(:datetime)
+ @datetimen_code tds_type(:datetimen)
+
+ # -- type_codes / type_names -----------------------------------------
+
+ @impl true
+ def type_codes do
+ [
+ @daten_code,
+ @timen_code,
+ @datetime2n_code,
+ @datetimeoffsetn_code,
+ @smalldatetime_code,
+ @datetime_code,
+ @datetimen_code
+ ]
+ end
+
+ @impl true
+ def type_names do
+ [:date, :time, :datetime, :datetime2, :smalldatetime, :datetimeoffset]
+ end
+
+ # -- decode_metadata -------------------------------------------------
+
+ @impl true
+ def decode_metadata(<<@daten_code, rest::binary>>) do
+ {:ok, %{data_reader: :bytelen, type_code: @daten_code}, rest}
+ end
+
+ def decode_metadata(<<@timen_code, scale::unsigned-8, rest::binary>>) do
+ meta = %{
+ data_reader: :bytelen,
+ scale: scale,
+ type_code: @timen_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ def decode_metadata(<<@datetime2n_code, scale::unsigned-8, rest::binary>>) do
+ meta = %{
+ data_reader: :bytelen,
+ scale: scale,
+ type_code: @datetime2n_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ def decode_metadata(<<@datetimeoffsetn_code, scale::unsigned-8, rest::binary>>) do
+ meta = %{
+ data_reader: :bytelen,
+ scale: scale,
+ type_code: @datetimeoffsetn_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ def decode_metadata(<<@smalldatetime_code, rest::binary>>) do
+ meta = %{
+ data_reader: {:fixed, 4},
+ type_code: @smalldatetime_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ def decode_metadata(<<@datetime_code, rest::binary>>) do
+ meta = %{
+ data_reader: {:fixed, 8},
+ type_code: @datetime_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ def decode_metadata(<<@datetimen_code, length::unsigned-8, rest::binary>>) do
+ meta = %{
+ data_reader: :bytelen,
+ length: length,
+ type_code: @datetimen_code
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # -- decode ----------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+
+ def decode(data, %{type_code: @daten_code}),
+ do: decode_date(data)
+
+ def decode(data, %{type_code: @timen_code} = m),
+ do: decode_time(m.scale, data)
+
+ def decode(data, %{type_code: @smalldatetime_code}),
+ do: decode_smalldatetime(data)
+
+ def decode(data, %{type_code: @datetime_code}),
+ do: decode_datetime(data)
+
+ def decode(data, %{type_code: @datetimen_code, length: 4}),
+ do: decode_smalldatetime(data)
+
+ def decode(data, %{type_code: @datetimen_code, length: 8}),
+ do: decode_datetime(data)
+
+ def decode(data, %{type_code: @datetime2n_code} = m),
+ do: decode_datetime2(m.scale, data)
+
+ def decode(data, %{type_code: @datetimeoffsetn_code} = m),
+ do: decode_datetimeoffset(m.scale, data)
+
+ # -- encode ----------------------------------------------------------
+
+ @impl true
+ def encode(nil, %{type: :date}) do
+ {tds_type(:daten), <>, <<0x00>>}
+ end
+
+ def encode(%Date{} = date, %{type: :date}) do
+ data = encode_date(date)
+ {tds_type(:daten), <>, [<<0x03>>, data]}
+ end
+
+ def encode(nil, %{type: :time}) do
+ type = tds_type(:timen)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(%Time{} = time, %{type: :time}) do
+ type = tds_type(:timen)
+ {data, scale} = encode_time(time)
+ len = time_byte_length(scale)
+ {type, <>, [<>, data]}
+ end
+
+ def encode(nil, %{type: :datetime2}) do
+ type = tds_type(:datetime2n)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(%NaiveDateTime{} = ndt, %{type: :datetime2}) do
+ type = tds_type(:datetime2n)
+ {data, scale} = encode_datetime2(ndt)
+ len = time_byte_length(scale) + 3
+ {type, <>, [<>, data]}
+ end
+
+ def encode(nil, %{type: :datetimeoffset}) do
+ type = tds_type(:datetimeoffsetn)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(%DateTime{} = dt, %{type: :datetimeoffset}) do
+ type = tds_type(:datetimeoffsetn)
+ {_, scale} = dt.microsecond
+ data = encode_datetimeoffset(dt, scale)
+ len = time_byte_length(scale) + 3 + 2
+ {type, <>, [<>, data]}
+ end
+
+ # Legacy datetime (8-byte datetimen)
+ def encode(nil, %{type: :datetime}) do
+ type = tds_type(:datetimen)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(value, %{type: :datetime}) do
+ ndt = to_naive_datetime(value)
+ type = tds_type(:datetimen)
+ data = encode_legacy_datetime(ndt)
+ {type, <>, [<<0x08>>, data]}
+ end
+
+ # Legacy smalldatetime (4-byte datetimen)
+ def encode(nil, %{type: :smalldatetime}) do
+ type = tds_type(:datetimen)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(value, %{type: :smalldatetime}) do
+ ndt = to_naive_datetime(value)
+ type = tds_type(:datetimen)
+ data = encode_smalldatetime(ndt)
+ {type, <>, [<<0x04>>, data]}
+ end
+
+ # Tuple format: {y,m,d}
+ def encode({_, _, _} = date_tuple, %{type: :date} = meta) do
+ encode(Date.from_erl!(date_tuple), meta)
+ end
+
+ # Tuple format: {h,m,s} -> default scale 7
+ def encode({h, m, s}, %{type: :time}) do
+ encode_time_tuple({h, m, s, 0}, @max_time_scale)
+ end
+
+ # Tuple format: {h,m,s,fsec} -> scale 7 (100ns units)
+ def encode({h, m, s, fsec}, %{type: :time}) do
+ encode_time_tuple({h, m, s, fsec}, @max_time_scale)
+ end
+
+ # Tuple format: {{y,m,d},{h,m,s}} or {{y,m,d},{h,m,s,fsec}}
+ def encode({date, time}, %{type: :datetime2})
+ when is_tuple(date) do
+ encode_dt2_tuple(date, time, @max_time_scale)
+ end
+
+ # Tuple format: {{y,m,d},{h,m,s|{h,m,s,us}},offset_min}
+ # Replicates old Tds.Types behavior: sends time/date as-is
+ # with the offset appended, no UTC conversion.
+ def encode(
+ {{_, _, _} = d, time, offset_min},
+ %{type: :datetimeoffset}
+ ) do
+ type = tds_type(:datetimeoffsetn)
+ time_tuple = normalize_time_tuple(time)
+ {time_bin, scale} = encode_time_raw(time_tuple, @max_time_scale)
+ date_bin = encode_date(Date.from_erl!(d))
+ data = time_bin <> date_bin <> <>
+ len = time_byte_length(scale) + 3 + 2
+ {type, <>, [<>, data]}
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(_value, %{type: :date}), do: "date"
+
+ def param_descriptor(%Time{microsecond: {_, s}}, %{type: :time}),
+ do: "time(#{s})"
+
+ def param_descriptor(_value, %{type: :time}), do: "time"
+
+ def param_descriptor(_value, %{type: :datetime}), do: "datetime"
+
+ def param_descriptor(_value, %{type: :smalldatetime}),
+ do: "smalldatetime"
+
+ def param_descriptor(
+ %NaiveDateTime{microsecond: {_, s}},
+ %{type: :datetime2}
+ ),
+ do: "datetime2(#{s})"
+
+ def param_descriptor(_value, %{type: :datetime2}), do: "datetime2"
+
+ def param_descriptor(
+ %DateTime{microsecond: {_, s}},
+ %{type: :datetimeoffset}
+ ),
+ do: "datetimeoffset(#{s})"
+
+ def param_descriptor(_value, %{type: :datetimeoffset}),
+ do: "datetimeoffset"
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(%Date{}), do: {:ok, %{type: :date}}
+ def infer(%Time{}), do: {:ok, %{type: :time}}
+ def infer(%NaiveDateTime{}), do: {:ok, %{type: :datetime2}}
+ def infer(%DateTime{}), do: {:ok, %{type: :datetimeoffset}}
+ def infer(_value), do: :skip
+
+ # -- private: tuple encoding -----------------------------------------
+
+ defp encode_time_tuple(time_tuple, scale) do
+ type = tds_type(:timen)
+ {data, scale} = encode_time_raw(time_tuple, scale)
+ len = time_byte_length(scale)
+ {type, <>, [<>, data]}
+ end
+
+ defp encode_dt2_tuple(date, time, scale) do
+ type = tds_type(:datetime2n)
+ time_tuple = normalize_time_tuple(time)
+ {time_bin, scale} = encode_time_raw(time_tuple, scale)
+ date_bin = encode_date(Date.from_erl!(date))
+ len = time_byte_length(scale) + 3
+ {type, <>, [<>, time_bin <> date_bin]}
+ end
+
+ defp normalize_time_tuple({h, m, s}), do: {h, m, s, 0}
+ defp normalize_time_tuple({_, _, _, _} = t), do: t
+
+ # Convert NaiveDateTime to tuple format for legacy datetime
+ defp to_naive_datetime(%NaiveDateTime{} = ndt), do: ndt
+
+ defp to_naive_datetime({{y, m, d}, {h, mi, s}}) do
+ NaiveDateTime.from_erl!({{y, m, d}, {h, mi, s}})
+ end
+
+ defp to_naive_datetime({{y, m, d}, {h, mi, s, us}})
+ when us < 1_000_000 do
+ NaiveDateTime.from_erl!(
+ {{y, m, d}, {h, mi, s}},
+ {us, 6}
+ )
+ end
+
+ # fsec >= 1_000_000 means 100ns units (scale 7), convert
+ defp to_naive_datetime({{y, m, d}, {h, mi, s, fsec}}) do
+ us = div(fsec, 10)
+
+ NaiveDateTime.from_erl!(
+ {{y, m, d}, {h, mi, s}},
+ {us, 6}
+ )
+ end
+
+ # -- private: date ---------------------------------------------------
+
+ defp decode_date(<>) do
+ date = :calendar.gregorian_days_to_date(days + 366)
+ Date.from_erl!(date, Calendar.ISO)
+ end
+
+ defp encode_date(%Date{} = date) do
+ days =
+ date
+ |> Date.to_erl()
+ |> :calendar.date_to_gregorian_days()
+ |> Kernel.-(366)
+
+ <>
+ end
+
+ # -- private: smalldatetime ------------------------------------------
+
+ defp decode_smalldatetime(<>) do
+ date = :calendar.gregorian_days_to_date(@year_1900_days + days)
+ hour = div(mins, 60)
+ min = mins - hour * 60
+ NaiveDateTime.from_erl!({date, {hour, min, 0}})
+ end
+
+ defp encode_smalldatetime(%NaiveDateTime{} = ndt) do
+ {date, {hour, min, _}} = NaiveDateTime.to_erl(ndt)
+ days = :calendar.date_to_gregorian_days(date) - @year_1900_days
+ mins = hour * 60 + min
+ <>
+ end
+
+ # -- private: datetime -----------------------------------------------
+
+ defp decode_datetime(<>) do
+ date = :calendar.gregorian_days_to_date(@year_1900_days + days)
+ milliseconds = round(secs300 * 10 / 3)
+ usec = rem(milliseconds, 1_000)
+ seconds = div(milliseconds, 1_000)
+ {_, {h, m, s}} = :calendar.seconds_to_daystime(seconds)
+
+ NaiveDateTime.from_erl!(
+ {date, {h, m, s}},
+ {usec * 1_000, 3},
+ Calendar.ISO
+ )
+ end
+
+ defp encode_legacy_datetime(%NaiveDateTime{} = ndt) do
+ {date, {h, m, s}} = NaiveDateTime.to_erl(ndt)
+ {us, _} = ndt.microsecond
+ days = :calendar.date_to_gregorian_days(date) - @year_1900_days
+ milliseconds = ((h * 60 + m) * 60 + s) * 1_000 + us / 1_000
+ secs_300 = round(milliseconds / (10 / 3))
+
+ {days, secs_300} =
+ if secs_300 == 25_920_000 do
+ {days + 1, 0}
+ else
+ {days, secs_300}
+ end
+
+ <>
+ end
+
+ # -- private: time ---------------------------------------------------
+
+ defp decode_time(scale, fsec_bin) do
+ parsed_fsec = parse_time_fsec(scale, fsec_bin)
+ fs_per_sec = trunc(:math.pow(10, scale))
+
+ hour = trunc(parsed_fsec / fs_per_sec / @secs_in_hour)
+ rem1 = parsed_fsec - hour * @secs_in_hour * fs_per_sec
+
+ min = trunc(rem1 / fs_per_sec / @secs_in_min)
+ rem2 = rem1 - min * @secs_in_min * fs_per_sec
+
+ sec = trunc(rem2 / fs_per_sec)
+ frac = trunc(rem2 - sec * fs_per_sec)
+
+ {usec, out_scale} = fsec_to_microsecond(frac, scale)
+ Time.from_erl!({hour, min, sec}, {usec, out_scale})
+ end
+
+ defp parse_time_fsec(scale, bin) when scale in [0, 1, 2] do
+ <> = bin
+ val
+ end
+
+ defp parse_time_fsec(scale, bin) when scale in [3, 4] do
+ <> = bin
+ val
+ end
+
+ defp parse_time_fsec(scale, bin) when scale in [5, 6, 7] do
+ <> = bin
+ val
+ end
+
+ defp fsec_to_microsecond(frac, scale) when scale > 6 do
+ {trunc(frac / 10), 6}
+ end
+
+ defp fsec_to_microsecond(frac, scale) do
+ {trunc(frac * :math.pow(10, 6 - scale)), scale}
+ end
+
+ defp encode_time(%Time{} = t) do
+ {h, m, s} = Time.to_erl(t)
+ {_, scale} = t.microsecond
+ fsec = microsecond_to_fsec(t.microsecond)
+ encode_time_raw({h, m, s, fsec}, scale)
+ end
+
+ defp encode_time_raw({hour, min, sec, fsec}, scale) do
+ fs_per_sec = trunc(:math.pow(10, scale))
+
+ total =
+ hour * 3600 * fs_per_sec +
+ min * 60 * fs_per_sec +
+ sec * fs_per_sec +
+ fsec
+
+ bin =
+ cond do
+ scale < 3 -> <>
+ scale < 5 -> <>
+ true -> <>
+ end
+
+ {bin, scale}
+ end
+
+ defp microsecond_to_fsec({us, 6}), do: us
+
+ defp microsecond_to_fsec({us, scale}),
+ do: trunc(us / :math.pow(10, 6 - scale))
+
+ # -- private: datetime2 ----------------------------------------------
+
+ defp decode_datetime2(scale, data) do
+ tlen = time_byte_length(scale)
+ <> = data
+ date = decode_date(date_bin)
+ time = decode_time(scale, time_bin)
+ NaiveDateTime.new!(date, time)
+ end
+
+ defp encode_datetime2(%NaiveDateTime{} = value) do
+ t = NaiveDateTime.to_time(value)
+ {time_bin, scale} = encode_time(t)
+ date_bin = encode_date(NaiveDateTime.to_date(value))
+ {time_bin <> date_bin, scale}
+ end
+
+ # -- private: datetimeoffset -----------------------------------------
+
+ defp decode_datetimeoffset(scale, data) do
+ tlen = time_byte_length(scale)
+ dt2_len = tlen + 3
+
+ <> = data
+
+ # Wire stores UTC time + offset. Return UTC DateTime
+ # (same as old Tds.Types behavior) so roundtrip is stable.
+ naive_utc = decode_datetime2(scale, dt2_bin)
+ DateTime.from_naive!(naive_utc, "Etc/UTC")
+ end
+
+ defp encode_datetimeoffset(%DateTime{utc_offset: offset} = dt, scale) do
+ {dt2_bin, _} =
+ dt
+ |> DateTime.add(-offset)
+ |> DateTime.to_naive()
+ |> encode_ndt_with_scale(scale)
+
+ offset_min = div(offset, 60)
+ dt2_bin <> <>
+ end
+
+ defp encode_ndt_with_scale(%NaiveDateTime{} = ndt, scale) do
+ {h, m, s} = NaiveDateTime.to_erl(ndt) |> elem(1)
+ fsec = microsecond_to_fsec(ndt.microsecond)
+ {time_bin, scale} = encode_time_raw({h, m, s, fsec}, scale)
+ date_bin = encode_date(NaiveDateTime.to_date(ndt))
+ {time_bin <> date_bin, scale}
+ end
+end
diff --git a/lib/tds/type/decimal.ex b/lib/tds/type/decimal.ex
new file mode 100644
index 0000000..33078ef
--- /dev/null
+++ b/lib/tds/type/decimal.ex
@@ -0,0 +1,148 @@
+defmodule Tds.Type.Decimal do
+ @moduledoc """
+ TDS type handler for decimal/numeric values.
+
+ Handles legacy decimal (0x37), numeric (0x3F) and modern
+ decimaln (0x6A), numericn (0x6C) on decode.
+ Always encodes as decimaln (0x6A) to support NULL.
+
+ Precision and scale come from metadata, never from
+ the process dictionary.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @impl true
+ def type_codes do
+ [
+ tds_type(:decimal),
+ tds_type(:numeric),
+ tds_type(:decimaln),
+ tds_type(:numericn)
+ ]
+ end
+
+ @impl true
+ def type_names, do: [:decimal, :numeric]
+
+ # -- decode_metadata -----------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, precision: p, scale: s}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, precision: p, scale: s}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, length: len}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, length: len}, rest}
+ end
+
+ # -- decode --------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+
+ def decode(<>, metadata) do
+ size = byte_size(value)
+ <> = value
+ scale = Map.get(metadata, :scale, 0)
+
+ case sign do
+ 1 -> Decimal.new(1, coef, -scale)
+ 0 -> Decimal.new(-1, coef, -scale)
+ end
+ end
+
+ # -- encode --------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:decimaln)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(%Decimal{} = value, _metadata) do
+ {precision, scale} = compute_precision_scale(value)
+ type = tds_type(:decimaln)
+
+ sign = if value.sign == 1, do: 1, else: 0
+ coef_int = wire_coefficient(value, scale)
+
+ coef_bytes = :binary.encode_unsigned(coef_int, :little)
+ coef_size = byte_size(coef_bytes)
+ data_len = data_length(precision)
+ padding = data_len - coef_size
+ value_size = data_len + 1
+ padded = coef_bytes <> <<0::size(padding)-unit(8)>>
+
+ meta = <>
+ val = <> <> padded
+ {type, meta, val}
+ end
+
+ # -- param_descriptor ----------------------------------------------
+
+ @impl true
+ def param_descriptor(nil, _metadata), do: "decimal(1, 0)"
+
+ def param_descriptor(%Decimal{} = value, _metadata) do
+ {precision, scale} = compute_precision_scale(value)
+ "decimal(#{precision}, #{scale})"
+ end
+
+ # -- infer ---------------------------------------------------------
+
+ @impl true
+ def infer(%Decimal{}), do: {:ok, %{}}
+ def infer(_value), do: :skip
+
+ # -- private -------------------------------------------------------
+
+ defp compute_precision_scale(%Decimal{coef: coef, exp: exp}) do
+ coef_digits = digit_count(coef)
+
+ if exp >= 0 do
+ {coef_digits + exp, 0}
+ else
+ scale = -exp
+ int_digits = max(coef_digits + exp, 1)
+ {int_digits + scale, scale}
+ end
+ end
+
+ defp wire_coefficient(%Decimal{coef: coef, exp: exp}, scale) do
+ # The wire integer is: abs(value) * 10^scale
+ # which equals coef * 10^(exp + scale)
+ shift = exp + scale
+ coef * pow10(shift)
+ end
+
+ defp digit_count(0), do: 1
+
+ defp digit_count(n) when is_integer(n) and n > 0 do
+ n |> Integer.to_string() |> byte_size()
+ end
+
+ defp pow10(0), do: 1
+ defp pow10(n) when n > 0, do: 10 ** n
+
+ defp data_length(precision) when precision <= 9, do: 4
+ defp data_length(precision) when precision <= 19, do: 8
+ defp data_length(precision) when precision <= 28, do: 12
+ defp data_length(precision) when precision <= 38, do: 16
+
+ defp data_length(precision) do
+ raise ArgumentError,
+ "size (#{precision}) given to the type " <>
+ "'decimal' exceeds the maximum allowed (38)"
+ end
+end
diff --git a/lib/tds/type/float.ex b/lib/tds/type/float.ex
new file mode 100644
index 0000000..041e36b
--- /dev/null
+++ b/lib/tds/type/float.ex
@@ -0,0 +1,71 @@
+defmodule Tds.Type.Float do
+ @moduledoc """
+ TDS type handler for floating-point values.
+
+ Handles fixed real (0x3B, 4-byte float-32), fixed float (0x3E,
+ 8-byte float-64) and variable floatn (0x6D) on decode.
+ Always encodes as floatn (0x6D) with 8-byte float-64 to
+ support NULL.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @impl true
+ def type_codes do
+ [tds_type(:real), tds_type(:float), tds_type(:floatn)]
+ end
+
+ @impl true
+ def type_names, do: [:float]
+
+ # -- decode_metadata -----------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 4}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 8}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, length: length}, rest}
+ end
+
+ # -- decode --------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+
+ def decode(<>, _metadata), do: val
+
+ def decode(<>, _metadata), do: val
+
+ # -- encode --------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:floatn)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(value, _metadata) when is_float(value) do
+ type = tds_type(:floatn)
+ {type, <>, <<0x08, value::little-float-64>>}
+ end
+
+ # -- param_descriptor ----------------------------------------------
+
+ @impl true
+ def param_descriptor(nil, _metadata), do: "decimal(1,0)"
+ def param_descriptor(_value, _metadata), do: "float(53)"
+
+ # -- infer ---------------------------------------------------------
+
+ @impl true
+ def infer(value) when is_float(value), do: {:ok, %{}}
+ def infer(_value), do: :skip
+end
diff --git a/lib/tds/type/integer.ex b/lib/tds/type/integer.ex
new file mode 100644
index 0000000..293f1ed
--- /dev/null
+++ b/lib/tds/type/integer.ex
@@ -0,0 +1,132 @@
+defmodule Tds.Type.Integer do
+ @moduledoc """
+ TDS type handler for integer values.
+
+ Handles fixed tinyint (0x30), smallint (0x34), int (0x38),
+ bigint (0x7F) and variable intn (0x26) on decode.
+ Always encodes as intn (0x26) to support NULL.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @impl true
+ def type_codes do
+ [
+ tds_type(:null),
+ tds_type(:tinyint),
+ tds_type(:smallint),
+ tds_type(:int),
+ tds_type(:bigint),
+ tds_type(:intn)
+ ]
+ end
+
+ @impl true
+ def type_names, do: [:integer]
+
+ # -- decode_metadata -----------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 0}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 1}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 2}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 4}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 8}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, length: length}, rest}
+ end
+
+ # -- decode --------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<>>, _metadata), do: nil
+
+ def decode(<>, _metadata), do: val
+
+ def decode(<>, _metadata), do: val
+
+ def decode(<>, _metadata), do: val
+
+ def decode(<>, _metadata), do: val
+
+ # -- encode --------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:intn)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(value, _metadata) when is_integer(value) do
+ type = tds_type(:intn)
+ size = wire_size(value)
+ {type, <>, [<>, encode_value(value, size)]}
+ end
+
+ # -- param_descriptor ----------------------------------------------
+
+ @impl true
+ def param_descriptor(nil, _metadata), do: "int"
+
+ def param_descriptor(0, _metadata), do: "int"
+
+ def param_descriptor(value, _metadata) when value >= 1, do: "bigint"
+
+ def param_descriptor(value, _metadata) when value < 0 do
+ precision =
+ value
+ |> Integer.to_string()
+ |> String.length()
+ |> Kernel.-(1)
+
+ "decimal(#{precision}, 0)"
+ end
+
+ # -- infer ---------------------------------------------------------
+
+ @impl true
+ def infer(value) when is_integer(value), do: {:ok, %{}}
+ def infer(_value), do: :skip
+
+ # -- private -------------------------------------------------------
+
+ defp wire_size(value)
+ when value in -2_147_483_648..2_147_483_647,
+ do: 4
+
+ defp wire_size(value)
+ when value in -9_223_372_036_854_775_808..9_223_372_036_854_775_807,
+ do: 8
+
+ defp wire_size(value) do
+ raise ArgumentError,
+ "integer #{value} exceeds 64-bit range; " <>
+ "use Decimal.new/1 instead"
+ end
+
+ defp encode_value(value, 4) do
+ <>
+ end
+
+ defp encode_value(value, 8) do
+ <>
+ end
+end
diff --git a/lib/tds/type/money.ex b/lib/tds/type/money.ex
new file mode 100644
index 0000000..bf01006
--- /dev/null
+++ b/lib/tds/type/money.ex
@@ -0,0 +1,102 @@
+defmodule Tds.Type.Money do
+ @moduledoc """
+ TDS type handler for money values.
+
+ Handles fixed money (0x3C, 8 bytes), fixed smallmoney (0x7A, 4 bytes),
+ and variable moneyn (0x6E) on decode.
+
+ Returns `%Decimal{}` instead of float for exact representation
+ of monetary values. This is a breaking change from the old
+ Tds.Types module which returned floats.
+
+ Wire format:
+ - smallmoney: 4-byte little-endian signed integer (1/10000 units)
+ - money: 8 bytes, high 4 bytes then low 4 bytes (both LE),
+ reinterpreted as a signed-64 value (1/10000 units)
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ @max_smallmoney_units 2_147_483_647
+ @min_smallmoney_units -2_147_483_648
+
+ @impl true
+ def type_codes do
+ [tds_type(:money), tds_type(:smallmoney), tds_type(:moneyn)]
+ end
+
+ @impl true
+ def type_names, do: [:money, :smallmoney]
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 8}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: {:fixed, 4}}, rest}
+ end
+
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen, length: length}, rest}
+ end
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+
+ def decode(<>, _metadata) do
+ sign = if val < 0, do: -1, else: 1
+ Decimal.new(sign, abs(val), -4)
+ end
+
+ def decode(
+ <>,
+ _metadata
+ ) do
+ <> = <>
+ sign = if combined < 0, do: -1, else: 1
+ Decimal.new(sign, abs(combined), -4)
+ end
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:moneyn)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(%Decimal{} = dec, _metadata) do
+ type = tds_type(:moneyn)
+ units = decimal_to_units(dec)
+ <> = <>
+
+ {type, <>, <<0x08, high::little-unsigned-32, low::little-unsigned-32>>}
+ end
+
+ @impl true
+ def param_descriptor(nil, _metadata), do: "money"
+
+ def param_descriptor(%Decimal{} = dec, _metadata) do
+ units = decimal_to_units(dec)
+
+ if units >= @min_smallmoney_units and
+ units <= @max_smallmoney_units do
+ "smallmoney"
+ else
+ "money"
+ end
+ end
+
+ @impl true
+ def infer(_value), do: :skip
+
+ defp decimal_to_units(%Decimal{sign: sign, coef: coef, exp: exp}) do
+ scale_shift = exp + 4
+ raw = if scale_shift >= 0, do: coef * pow10(scale_shift), else: div(coef, pow10(-scale_shift))
+ if sign == -1, do: -raw, else: raw
+ end
+
+ defp pow10(0), do: 1
+ defp pow10(n) when n > 0, do: 10 * pow10(n - 1)
+end
diff --git a/lib/tds/type/registry.ex b/lib/tds/type/registry.ex
new file mode 100644
index 0000000..ede48ce
--- /dev/null
+++ b/lib/tds/type/registry.ex
@@ -0,0 +1,140 @@
+defmodule Tds.Type.Registry do
+ @moduledoc """
+ Maps TDS type codes and Elixir type names to handler modules.
+
+ Stored in connection state, built once at connection init.
+ User-provided handler modules override built-in handlers
+ for the same type codes or names.
+ """
+
+ @type t :: %__MODULE__{
+ by_code: %{byte() => module()},
+ by_name: %{atom() => module()},
+ user_types: [module()]
+ }
+
+ @enforce_keys [:by_code, :by_name, :user_types]
+ defstruct [:by_code, :by_name, :user_types]
+
+ @doc """
+ Build registry from user-provided and built-in handler lists.
+
+ User handlers override built-ins for the same type code or name
+ because `builtin_types ++ extra_types` means later entries win
+ in the map comprehension.
+ """
+ @spec new(
+ extra_types :: [module()],
+ builtin_types :: [module()]
+ ) :: t()
+ def new(extra_types \\ [], builtin_types \\ default_builtins()) do
+ all = builtin_types ++ extra_types
+
+ by_code =
+ for handler <- all,
+ code <- handler.type_codes(),
+ into: %{},
+ do: {code, handler}
+
+ by_name =
+ for handler <- all,
+ name <- handler.type_names(),
+ into: %{},
+ do: {name, handler}
+
+ %__MODULE__{
+ by_code: by_code,
+ by_name: by_name,
+ user_types: extra_types
+ }
+ end
+
+ @doc "Decode path: type code -> handler module."
+ @spec handler_for_code(t(), byte()) :: {:ok, module()} | :error
+ def handler_for_code(%__MODULE__{by_code: by_code}, code) do
+ Map.fetch(by_code, code)
+ end
+
+ @doc "Encode path: atom type name -> handler module."
+ @spec handler_for_name(t(), atom()) :: {:ok, module()} | :error
+ def handler_for_name(%__MODULE__{by_name: by_name}, name) do
+ Map.fetch(by_name, name)
+ end
+
+ @doc """
+ Encode path: infer handler from Elixir value.
+
+ Tries user types first (linear scan), then falls back
+ to guard-based type name lookup in the by_name map.
+ """
+ @spec infer(t(), term()) ::
+ {:ok, module(), Tds.Type.metadata()} | :error
+ def infer(%__MODULE__{} = reg, value) do
+ case try_handlers(reg.user_types, value) do
+ {:ok, _mod, _meta} = hit ->
+ hit
+
+ :skip ->
+ infer_from_name(reg.by_name, value)
+ end
+ end
+
+ defp infer_from_name(by_name, value) do
+ name = value_to_type_name(value)
+
+ case Map.fetch(by_name, name) do
+ {:ok, handler} -> call_infer(handler, value)
+ :error -> :error
+ end
+ end
+
+ # Boolean MUST come before integer (booleans are integers)
+ defp value_to_type_name(v) when is_boolean(v), do: :boolean
+ defp value_to_type_name(v) when is_integer(v), do: :integer
+ defp value_to_type_name(v) when is_float(v), do: :float
+
+ defp value_to_type_name(v) when is_binary(v) do
+ if String.valid?(v), do: :string, else: :binary
+ end
+
+ defp value_to_type_name(%Decimal{}), do: :decimal
+ defp value_to_type_name(%Date{}), do: :date
+ defp value_to_type_name(%Time{}), do: :time
+ defp value_to_type_name(%NaiveDateTime{}), do: :datetime2
+ defp value_to_type_name(%DateTime{}), do: :datetimeoffset
+ defp value_to_type_name(nil), do: :binary
+ defp value_to_type_name(_), do: nil
+
+ defp call_infer(handler, value) do
+ case handler.infer(value) do
+ {:ok, meta} -> {:ok, handler, meta}
+ :skip -> :error
+ end
+ end
+
+ defp try_handlers([], _value), do: :skip
+
+ defp try_handlers([handler | rest], value) do
+ case handler.infer(value) do
+ {:ok, meta} -> {:ok, handler, meta}
+ :skip -> try_handlers(rest, value)
+ end
+ end
+
+ defp default_builtins do
+ [
+ Tds.Type.Boolean,
+ Tds.Type.Integer,
+ Tds.Type.Float,
+ Tds.Type.Decimal,
+ Tds.Type.Money,
+ Tds.Type.String,
+ Tds.Type.Binary,
+ Tds.Type.DateTime,
+ Tds.Type.UUID,
+ Tds.Type.Xml,
+ Tds.Type.Variant,
+ Tds.Type.Udt
+ ]
+ end
+end
diff --git a/lib/tds/type/string.ex b/lib/tds/type/string.ex
new file mode 100644
index 0000000..a7b488c
--- /dev/null
+++ b/lib/tds/type/string.ex
@@ -0,0 +1,231 @@
+defmodule Tds.Type.String do
+ @moduledoc """
+ TDS type handler for string values.
+
+ Handles 8 type codes on decode:
+ - bigchar (0xAF), bigvarchar (0xA7) — single-byte with collation
+ - nvarchar (0xE7), nchar (0xEF) — UCS-2 (UTF-16LE)
+ - legacy varchar (0x27), legacy char (0x2F) — single-byte short
+ - text (0x23), ntext (0x63) — longlen with table name parts
+
+ Always encodes as nvarchar for parameters (UCS-2).
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ alias Tds.Encoding.UCS2
+ alias Tds.Protocol.Collation
+
+ @null_collation <<0x00, 0x00, 0x00, 0x00, 0x00>>
+
+ # UCS-2 type codes (decode as UTF-16LE)
+ @ucs2_types [tds_type(:nvarchar), tds_type(:nchar), tds_type(:ntext)]
+
+ @impl true
+ def type_codes do
+ [
+ tds_type(:bigchar),
+ tds_type(:bigvarchar),
+ tds_type(:nvarchar),
+ tds_type(:nchar),
+ tds_type(:text),
+ tds_type(:varchar),
+ tds_type(:char),
+ tds_type(:ntext)
+ ]
+ end
+
+ @impl true
+ def type_names, do: [:string]
+
+ # -- decode_metadata -------------------------------------------------
+
+ # Big types: bigvarchar (0xA7), bigchar (0xAF), nvarchar (0xE7),
+ # nchar (0xEF) — 2-byte LE max_length + 5-byte collation
+ @impl true
+ def decode_metadata(
+ <>
+ )
+ when type_code in [
+ tds_type(:bigvarchar),
+ tds_type(:bigchar),
+ tds_type(:nvarchar),
+ tds_type(:nchar)
+ ] do
+ {:ok, collation} = Collation.decode(collation_bin)
+ data_reader = if length == 0xFFFF, do: :plp, else: :shortlen
+ encoding = encoding_for(type_code)
+
+ meta = %{
+ data_reader: data_reader,
+ collation: collation,
+ encoding: encoding,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # Legacy short types: varchar (0x27), char (0x2F) — 1-byte length
+ # + 5-byte collation
+ def decode_metadata(<>)
+ when type_code in [tds_type(:varchar), tds_type(:char)] do
+ {:ok, collation} = Collation.decode(collation_bin)
+
+ meta = %{
+ data_reader: :bytelen,
+ collation: collation,
+ encoding: :single_byte,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # text (0x23) — 4-byte length + collation + numparts table names
+ def decode_metadata(
+ <>
+ ) do
+ {:ok, collation} = Collation.decode(collation_bin)
+ rest = skip_table_parts(numparts, rest)
+
+ meta = %{
+ data_reader: :longlen,
+ collation: collation,
+ encoding: :single_byte,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # ntext (0x63) — 4-byte length + collation + numparts table names
+ def decode_metadata(
+ <>
+ ) do
+ {:ok, collation} = Collation.decode(collation_bin)
+ rest = skip_table_parts(numparts, rest)
+
+ meta = %{
+ data_reader: :longlen,
+ collation: collation,
+ encoding: :ucs2,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # -- decode ----------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+
+ def decode(<<>>, _metadata), do: ""
+
+ def decode(data, %{encoding: :ucs2}) do
+ UCS2.to_string(data)
+ end
+
+ def decode(data, %{encoding: :single_byte, collation: col}) do
+ Tds.Utils.decode_chars(data, col.codepage)
+ end
+
+ # -- encode ----------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:nvarchar)
+ meta_bin = <> <> @null_collation
+ value_bin = <>
+ {type, meta_bin, value_bin}
+ end
+
+ def encode(value, _metadata) when is_binary(value) do
+ type = tds_type(:nvarchar)
+ ucs2 = UCS2.from_string(value)
+ ucs2_size = byte_size(ucs2)
+
+ cond do
+ ucs2_size == 0 ->
+ meta_bin = <> <> @null_collation
+ value_bin = <<0::unsigned-64, 0::unsigned-32>>
+ {type, meta_bin, value_bin}
+
+ ucs2_size > 8000 ->
+ meta_bin = <> <> @null_collation
+ value_bin = encode_plp(ucs2)
+ {type, meta_bin, value_bin}
+
+ true ->
+ meta_bin =
+ <> <>
+ @null_collation
+
+ value_bin =
+ <> <> ucs2
+
+ {type, meta_bin, value_bin}
+ end
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(nil, _metadata), do: "nvarchar(1)"
+
+ def param_descriptor(value, _metadata) when is_binary(value) do
+ len = String.length(value)
+
+ cond do
+ len <= 0 -> "nvarchar(1)"
+ len <= 2_000 -> "nvarchar(2000)"
+ true -> "nvarchar(max)"
+ end
+ end
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(value) when is_binary(value), do: {:ok, %{}}
+ def infer(_value), do: :skip
+
+ # -- private helpers -------------------------------------------------
+
+ defp encoding_for(type_code) when type_code in @ucs2_types,
+ do: :ucs2
+
+ defp encoding_for(_type_code), do: :single_byte
+
+ defp skip_table_parts(0, rest), do: rest
+
+ defp skip_table_parts(n, rest) when n > 0 do
+ <> = rest
+
+ skip_table_parts(n - 1, next)
+ end
+
+ defp encode_plp(data) do
+ size = byte_size(data)
+
+ <> <>
+ encode_plp_chunks(size, data, <<>>) <>
+ <<0::little-unsigned-32>>
+ end
+
+ defp encode_plp_chunks(0, _data, buf), do: buf
+
+ defp encode_plp_chunks(size, data, buf) do
+ # Use lower 32 bits of size as chunk size (matches Tds.Types)
+ <<_hi::unsigned-32, chunk_size::unsigned-32>> =
+ <>
+
+ <> = data
+ plp = <> <> chunk
+ encode_plp_chunks(size - chunk_size, rest, buf <> plp)
+ end
+end
diff --git a/lib/tds/type/udt.ex b/lib/tds/type/udt.ex
new file mode 100644
index 0000000..9aec491
--- /dev/null
+++ b/lib/tds/type/udt.ex
@@ -0,0 +1,111 @@
+defmodule Tds.Type.Udt do
+ @moduledoc """
+ TDS type handler for CLR User-Defined Type values.
+
+ Handles 1 type code on decode:
+ - udt (0xF0) -- 2-byte LE max_length, shortlen or PLP
+
+ UDT values are passed through as raw binary. Application code
+ (e.g. Ecto custom types) is responsible for interpreting the
+ binary payload. Built-in UDT types like HierarchyId are also
+ returned as raw bytes.
+
+ Always encodes as bigvarbinary (0xA5) for parameters.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ # -- type_codes / type_names ----------------------------------------
+
+ @impl true
+ def type_codes, do: [tds_type(:udt)]
+
+ @impl true
+ def type_names, do: [:udt]
+
+ # -- decode_metadata ------------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ data_reader = if length == 0xFFFF, do: :plp, else: :shortlen
+
+ meta = %{
+ data_reader: data_reader,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # -- decode ---------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<>>, _metadata), do: <<>>
+ def decode(data, _metadata), do: :binary.copy(data)
+
+ # -- encode ---------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:bigvarbinary)
+ meta_bin = <>
+ value_bin = <>
+ {type, meta_bin, value_bin}
+ end
+
+ def encode(value, _metadata) when is_binary(value) do
+ type = tds_type(:bigvarbinary)
+ size = byte_size(value)
+
+ cond do
+ size == 0 ->
+ meta_bin = <>
+ value_bin = <<0::unsigned-64, 0::unsigned-32>>
+ {type, meta_bin, value_bin}
+
+ size > 8000 ->
+ meta_bin = <>
+ value_bin = encode_plp(value)
+ {type, meta_bin, value_bin}
+
+ true ->
+ meta_bin = <>
+ value_bin = <> <> value
+ {type, meta_bin, value_bin}
+ end
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(_value, _metadata), do: "varbinary(max)"
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(_value), do: :skip
+
+ # -- private helpers -------------------------------------------------
+
+ defp encode_plp(data) do
+ size = byte_size(data)
+
+ <> <>
+ encode_plp_chunks(size, data, <<>>) <>
+ <<0::little-unsigned-32>>
+ end
+
+ defp encode_plp_chunks(0, _data, buf), do: buf
+
+ defp encode_plp_chunks(size, data, buf) do
+ <<_hi::unsigned-32, chunk_size::unsigned-32>> =
+ <>
+
+ <> = data
+ plp = <> <> chunk
+ encode_plp_chunks(size - chunk_size, rest, buf <> plp)
+ end
+end
diff --git a/lib/tds/type/uuid.ex b/lib/tds/type/uuid.ex
new file mode 100644
index 0000000..2ae62a9
--- /dev/null
+++ b/lib/tds/type/uuid.ex
@@ -0,0 +1,135 @@
+defmodule Tds.Type.UUID do
+ @moduledoc """
+ TDS type handler for UUID (uniqueidentifier) values.
+
+ Tds.Types.UUID generates and works with mixed-endian bytes.
+ This handler sends and receives bytes as-is to preserve
+ roundtrip compatibility with that module.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ # -- type_codes / type_names -----------------------------------------
+
+ @impl true
+ def type_codes, do: [tds_type(:uniqueidentifier)]
+
+ @impl true
+ def type_names, do: [:uuid]
+
+ # -- decode_metadata -------------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :bytelen}, rest}
+ end
+
+ # -- decode ----------------------------------------------------------
+
+ # Tds.Types.UUID works in mixed-endian format. Bytes are stored
+ # and returned without reordering to preserve existing roundtrip.
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(data, _metadata), do: :binary.copy(data)
+
+ # -- encode ----------------------------------------------------------
+
+ # Bytes are sent as-is (no reorder) to match Tds.Types.UUID
+ # which generates mixed-endian bytes.
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:uniqueidentifier)
+ {type, <>, <<0x00>>}
+ end
+
+ def encode(<<_::128>> = bin, _metadata) do
+ type = tds_type(:uniqueidentifier)
+ {type, <>, <<0x10>> <> bin}
+ end
+
+ def encode(
+ <<_::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96>> = str,
+ metadata
+ ) do
+ encode(parse_uuid_string(str), metadata)
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(_value, _metadata), do: "uniqueidentifier"
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(<<_::128>>), do: {:ok, %{}}
+ def infer(_value), do: :skip
+
+ # -- private helpers -------------------------------------------------
+
+ defp parse_uuid_string(
+ <>
+ ) do
+ <<
+ hex(a1)::4,
+ hex(a2)::4,
+ hex(a3)::4,
+ hex(a4)::4,
+ hex(a5)::4,
+ hex(a6)::4,
+ hex(a7)::4,
+ hex(a8)::4,
+ hex(b1)::4,
+ hex(b2)::4,
+ hex(b3)::4,
+ hex(b4)::4,
+ hex(c1)::4,
+ hex(c2)::4,
+ hex(c3)::4,
+ hex(c4)::4,
+ hex(d1)::4,
+ hex(d2)::4,
+ hex(d3)::4,
+ hex(d4)::4,
+ hex(e1)::4,
+ hex(e2)::4,
+ hex(e3)::4,
+ hex(e4)::4,
+ hex(e5)::4,
+ hex(e6)::4,
+ hex(e7)::4,
+ hex(e8)::4,
+ hex(e9)::4,
+ hex(e10)::4,
+ hex(e11)::4,
+ hex(e12)::4
+ >>
+ end
+
+ @compile {:inline, hex: 1}
+ defp hex(?0), do: 0
+ defp hex(?1), do: 1
+ defp hex(?2), do: 2
+ defp hex(?3), do: 3
+ defp hex(?4), do: 4
+ defp hex(?5), do: 5
+ defp hex(?6), do: 6
+ defp hex(?7), do: 7
+ defp hex(?8), do: 8
+ defp hex(?9), do: 9
+ defp hex(?a), do: 10
+ defp hex(?b), do: 11
+ defp hex(?c), do: 12
+ defp hex(?d), do: 13
+ defp hex(?e), do: 14
+ defp hex(?f), do: 15
+ defp hex(?A), do: 10
+ defp hex(?B), do: 11
+ defp hex(?C), do: 12
+ defp hex(?D), do: 13
+ defp hex(?E), do: 14
+ defp hex(?F), do: 15
+end
diff --git a/lib/tds/type/variant.ex b/lib/tds/type/variant.ex
new file mode 100644
index 0000000..4a2ac17
--- /dev/null
+++ b/lib/tds/type/variant.ex
@@ -0,0 +1,65 @@
+defmodule Tds.Type.Variant do
+ @moduledoc """
+ TDS type handler for sql_variant values (stub).
+
+ Handles 1 type code on decode:
+ - variant (0x62) -- 4-byte LE max_length, variant data reader
+
+ This is a stub handler. Decode returns raw binary without
+ inner-type dispatch. Full variant decoding (reading the inner
+ type code and delegating to the appropriate handler) is deferred.
+
+ Encoding sql_variant parameters is not supported by TDS RPC,
+ so encode raises at runtime.
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ # -- type_codes / type_names ----------------------------------------
+
+ @impl true
+ def type_codes, do: [tds_type(:variant)]
+
+ @impl true
+ def type_names, do: [:variant]
+
+ # -- decode_metadata ------------------------------------------------
+
+ @impl true
+ def decode_metadata(<>) do
+ meta = %{
+ data_reader: :variant,
+ length: length
+ }
+
+ {:ok, meta, rest}
+ end
+
+ # -- decode ----------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<>>, _metadata), do: <<>>
+ def decode(data, _metadata), do: :binary.copy(data)
+
+ # -- encode ----------------------------------------------------------
+
+ @impl true
+ def encode(_value, _metadata) do
+ raise RuntimeError,
+ "sql_variant encoding is not supported. " <>
+ "TDS does not allow sql_variant as an RPC parameter type."
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(_value, _metadata), do: "sql_variant"
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(_value), do: :skip
+end
diff --git a/lib/tds/type/xml.ex b/lib/tds/type/xml.ex
new file mode 100644
index 0000000..cedd4c7
--- /dev/null
+++ b/lib/tds/type/xml.ex
@@ -0,0 +1,128 @@
+defmodule Tds.Type.Xml do
+ @moduledoc """
+ TDS type handler for XML values.
+
+ Handles 1 type code on decode:
+ - xml (0xF1) — PLP with optional schema info
+
+ Metadata includes a schema presence byte. If schema is present,
+ db_name, owner_name, and collection_name are read and discarded
+ (not needed for decode/encode).
+
+ Always encodes as nvarchar for parameters (UCS-2 PLP).
+ """
+
+ @behaviour Tds.Type
+
+ import Tds.Protocol.Constants
+
+ alias Tds.Encoding.UCS2
+
+ @null_collation <<0x00, 0x00, 0x00, 0x00, 0x00>>
+
+ # -- type_codes / type_names ----------------------------------------
+
+ @impl true
+ def type_codes, do: [tds_type(:xml)]
+
+ @impl true
+ def type_names, do: [:xml]
+
+ # -- decode_metadata ------------------------------------------------
+
+ # No schema (0x00): just the presence byte
+ @impl true
+ def decode_metadata(<>) do
+ {:ok, %{data_reader: :plp}, rest}
+ end
+
+ # With schema (0x01): read and discard db, owner, collection
+ def decode_metadata(<>) do
+ rest = skip_schema_info(rest)
+ {:ok, %{data_reader: :plp}, rest}
+ end
+
+ # -- decode ---------------------------------------------------------
+
+ @impl true
+ def decode(nil, _metadata), do: nil
+ def decode(<<>>, _metadata), do: ""
+ def decode(data, _metadata), do: UCS2.to_string(data)
+
+ # -- encode ---------------------------------------------------------
+
+ @impl true
+ def encode(nil, _metadata) do
+ type = tds_type(:nvarchar)
+ meta_bin = <> <> @null_collation
+ value_bin = <>
+ {type, meta_bin, value_bin}
+ end
+
+ def encode(value, _metadata) when is_binary(value) do
+ type = tds_type(:nvarchar)
+ ucs2 = UCS2.from_string(value)
+ ucs2_size = byte_size(ucs2)
+
+ cond do
+ ucs2_size == 0 ->
+ meta_bin = <> <> @null_collation
+ value_bin = <<0::unsigned-64, 0::unsigned-32>>
+ {type, meta_bin, value_bin}
+
+ ucs2_size > plp(:max_short_data_size) ->
+ meta_bin = <> <> @null_collation
+ value_bin = encode_plp(ucs2)
+ {type, meta_bin, value_bin}
+
+ true ->
+ meta_bin =
+ <> <>
+ @null_collation
+
+ value_bin =
+ <> <> ucs2
+
+ {type, meta_bin, value_bin}
+ end
+ end
+
+ # -- param_descriptor ------------------------------------------------
+
+ @impl true
+ def param_descriptor(_value, _metadata), do: "xml"
+
+ # -- infer -----------------------------------------------------------
+
+ @impl true
+ def infer(_value), do: :skip
+
+ # -- private helpers -------------------------------------------------
+
+ defp skip_schema_info(binary) do
+ <> = binary
+
+ rest
+ end
+
+ defp encode_plp(data) do
+ size = byte_size(data)
+
+ <> <>
+ encode_plp_chunks(size, data, <<>>) <>
+ <<0::little-unsigned-32>>
+ end
+
+ defp encode_plp_chunks(0, _data, buf), do: buf
+
+ defp encode_plp_chunks(size, data, buf) do
+ <<_hi::unsigned-32, chunk_size::unsigned-32>> =
+ <>
+
+ <> = data
+ plp = <> <> chunk
+ encode_plp_chunks(size - chunk_size, rest, buf <> plp)
+ end
+end
diff --git a/lib/tds/types.ex b/lib/tds/types.ex
deleted file mode 100644
index 8fa72c6..0000000
--- a/lib/tds/types.ex
+++ /dev/null
@@ -1,1883 +0,0 @@
-defmodule Tds.Types do
- @moduledoc false
-
- import Tds.BinaryUtils
- import Tds.Utils
-
- alias Tds.Encoding.UCS2
- alias Tds.Parameter
-
- @year_1900_days :calendar.date_to_gregorian_days({1900, 1, 1})
- @secs_in_min 60
- @secs_in_hour 60 * @secs_in_min
- @max_time_scale 7
-
- # Zero Length Data Types
- @tds_data_type_null 0x1F
-
- # Fixed Length Data Types
- # See: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/859eb3d2-80d3-40f6-a637-414552c9c552
- @tds_data_type_tinyint 0x30
- @tds_data_type_bit 0x32
- @tds_data_type_smallint 0x34
- @tds_data_type_int 0x38
- @tds_data_type_smalldatetime 0x3A
- @tds_data_type_real 0x3B
- @tds_data_type_money 0x3C
- @tds_data_type_datetime 0x3D
- @tds_data_type_float 0x3E
- @tds_data_type_smallmoney 0x7A
- @tds_data_type_bigint 0x7F
-
- # Fixed Data Types with their length
- @fixed_data_types %{
- @tds_data_type_null => 0,
- @tds_data_type_tinyint => 1,
- @tds_data_type_bit => 1,
- @tds_data_type_smallint => 2,
- @tds_data_type_int => 4,
- @tds_data_type_smalldatetime => 4,
- @tds_data_type_real => 4,
- @tds_data_type_money => 8,
- @tds_data_type_datetime => 8,
- @tds_data_type_float => 8,
- @tds_data_type_smallmoney => 4,
- @tds_data_type_bigint => 8
- }
-
- # Variable-Length Data Types
- # See: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/ce3183a6-9d89-47e8-a02f-de5a1a1303de
- @tds_data_type_uniqueidentifier 0x24
- @tds_data_type_intn 0x26
- # legacy
- @tds_data_type_decimal 0x37
- # legacy
- @tds_data_type_numeric 0x3F
- @tds_data_type_bitn 0x68
- @tds_data_type_decimaln 0x6A
- @tds_data_type_numericn 0x6C
- @tds_data_type_floatn 0x6D
- @tds_data_type_moneyn 0x6E
- @tds_data_type_datetimen 0x6F
- @tds_data_type_daten 0x28
- @tds_data_type_timen 0x29
- @tds_data_type_datetime2n 0x2A
- @tds_data_type_datetimeoffsetn 0x2B
- @tds_data_type_char 0x2F
- @tds_data_type_varchar 0x27
- @tds_data_type_binary 0x2D
- @tds_data_type_varbinary 0x25
- @tds_data_type_bigvarbinary 0xA5
- @tds_data_type_bigvarchar 0xA7
- @tds_data_type_bigbinary 0xAD
- @tds_data_type_bigchar 0xAF
- @tds_data_type_nvarchar 0xE7
- @tds_data_type_nchar 0xEF
- @tds_data_type_xml 0xF1
- @tds_data_type_udt 0xF0
- @tds_data_type_text 0x23
- @tds_data_type_image 0x22
- @tds_data_type_ntext 0x63
- @tds_data_type_variant 0x62
-
- @variable_data_types [
- @tds_data_type_uniqueidentifier,
- @tds_data_type_intn,
- @tds_data_type_decimal,
- @tds_data_type_numeric,
- @tds_data_type_bitn,
- @tds_data_type_decimaln,
- @tds_data_type_numericn,
- @tds_data_type_floatn,
- @tds_data_type_moneyn,
- @tds_data_type_datetimen,
- @tds_data_type_daten,
- @tds_data_type_timen,
- @tds_data_type_datetime2n,
- @tds_data_type_datetimeoffsetn,
- @tds_data_type_char,
- @tds_data_type_varchar,
- @tds_data_type_binary,
- @tds_data_type_varbinary,
- @tds_data_type_bigvarbinary,
- @tds_data_type_bigvarchar,
- @tds_data_type_bigbinary,
- @tds_data_type_bigchar,
- @tds_data_type_nvarchar,
- @tds_data_type_nchar,
- @tds_data_type_xml,
- @tds_data_type_udt,
- @tds_data_type_text,
- @tds_data_type_image,
- @tds_data_type_ntext,
- @tds_data_type_variant
- ]
-
- # @tds_plp_marker 0xffff
- @tds_plp_null 0xFFFFFFFFFFFFFFFF
- # @tds_plp_unknown 0xfffffffffffffffe
-
- #
- # Data Type Decoders
- #
-
- def to_atom(token) do
- case token do
- @tds_data_type_null -> :null
- @tds_data_type_tinyint -> :tinyint
- @tds_data_type_bit -> :bit
- @tds_data_type_smallint -> :smallint
- @tds_data_type_int -> :int
- @tds_data_type_smalldatetime -> :smalldatetime
- @tds_data_type_real -> :real
- @tds_data_type_money -> :money
- @tds_data_type_datetime -> :datetime
- @tds_data_type_float -> :float
- @tds_data_type_smallmoney -> :smallmoney
- @tds_data_type_bigint -> :bigint
- @tds_data_type_uniqueidentifier -> :uniqueidentifier
- @tds_data_type_intn -> :intn
- @tds_data_type_decimal -> :decimal
- @tds_data_type_numeric -> :numeric
- @tds_data_type_bitn -> :bitn
- @tds_data_type_decimaln -> :decimaln
- @tds_data_type_numericn -> :numericn
- @tds_data_type_floatn -> :floatn
- @tds_data_type_moneyn -> :moneyn
- @tds_data_type_datetimen -> :datetimen
- @tds_data_type_daten -> :daten
- @tds_data_type_timen -> :timen
- @tds_data_type_datetime2n -> :datetime2n
- @tds_data_type_datetimeoffsetn -> :datetimeoffsetn
- @tds_data_type_char -> :char
- @tds_data_type_varchar -> :varchar
- @tds_data_type_binary -> :binary
- @tds_data_type_varbinary -> :varbinary
- @tds_data_type_bigvarbinary -> :bigvarbinary
- @tds_data_type_bigvarchar -> :bigvarchar
- @tds_data_type_bigbinary -> :bigbinary
- @tds_data_type_bigchar -> :bigchar
- @tds_data_type_nvarchar -> :nvarchar
- @tds_data_type_nchar -> :nchar
- @tds_data_type_xml -> :xml
- @tds_data_type_udt -> :udt
- @tds_data_type_text -> :text
- @tds_data_type_image -> :image
- @tds_data_type_ntext -> :ntext
- @tds_data_type_variant -> :variant
- end
- end
-
- def decode_info(<>)
- when is_map_key(@fixed_data_types, data_type_code) do
- {%{
- data_type: :fixed,
- data_type_code: data_type_code,
- length: @fixed_data_types[data_type_code],
- data_type_name: to_atom(data_type_code)
- }, tail}
- end
-
- def decode_info(<>)
- when user_type in @variable_data_types do
- def_type_info = %{
- data_type: :variable,
- data_type_code: user_type,
- sql_type: to_atom(user_type)
- }
-
- cond do
- user_type == @tds_data_type_daten ->
- length = 3
-
- type_info =
- def_type_info
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :bytelen)
-
- {type_info, tail}
-
- user_type in [
- @tds_data_type_timen,
- @tds_data_type_datetime2n,
- @tds_data_type_datetimeoffsetn
- ] ->
- <> = tail
-
- length =
- cond do
- scale in [0, 1, 2] -> 3
- scale in [3, 4] -> 4
- scale in [5, 6, 7] -> 5
- true -> nil
- end
-
- length =
- case user_type do
- @tds_data_type_datetime2n -> length + 3
- @tds_data_type_datetimeoffsetn -> length + 5
- _ -> length
- end
-
- type_info =
- def_type_info
- |> Map.put(:scale, scale)
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :bytelen)
-
- {type_info, rest}
-
- user_type in [
- @tds_data_type_numericn,
- @tds_data_type_decimaln
- ] ->
- <<
- length::little-unsigned-8,
- precision::unsigned-8,
- scale::unsigned-8,
- rest::binary
- >> = tail
-
- type_info =
- def_type_info
- |> Map.put(:precision, precision)
- |> Map.put(:scale, scale)
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :bytelen)
-
- {type_info, rest}
-
- user_type in [
- @tds_data_type_uniqueidentifier,
- @tds_data_type_intn,
- @tds_data_type_decimal,
- @tds_data_type_numeric,
- @tds_data_type_bitn,
- @tds_data_type_floatn,
- @tds_data_type_moneyn,
- @tds_data_type_datetimen,
- @tds_data_type_binary,
- @tds_data_type_varbinary
- ] ->
- <> = tail
-
- type_info =
- def_type_info
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :bytelen)
-
- {type_info, rest}
-
- user_type in [
- @tds_data_type_char,
- @tds_data_type_varchar
- ] ->
- <> = tail
- {:ok, collation} = decode_collation(collation)
-
- type_info =
- def_type_info
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :bytelen)
- |> Map.put(:collation, collation)
-
- {type_info, rest}
-
- user_type == @tds_data_type_xml ->
- {_schema_info, rest} = decode_schema_info(tail)
-
- type_info =
- def_type_info
- |> Map.put(:data_reader, :plp)
-
- {type_info, rest}
-
- user_type in [
- @tds_data_type_bigvarchar,
- @tds_data_type_bigchar,
- @tds_data_type_nvarchar,
- @tds_data_type_nchar
- ] ->
- <> = tail
- {:ok, collation} = decode_collation(collation)
-
- type_info =
- def_type_info
- |> Map.put(:collation, collation)
- |> Map.put(
- :data_reader,
- if(length == 0xFFFF, do: :plp, else: :shortlen)
- )
- |> Map.put(:length, length)
-
- {type_info, rest}
-
- user_type in [
- @tds_data_type_bigvarbinary,
- @tds_data_type_bigbinary,
- @tds_data_type_udt
- ] ->
- <> = tail
-
- type_info =
- def_type_info
- |> Map.put(
- :data_reader,
- if(length == 0xFFFF, do: :plp, else: :shortlen)
- )
- |> Map.put(:length, length)
-
- {type_info, rest}
-
- user_type in [@tds_data_type_text, @tds_data_type_ntext] ->
- <<
- length::little-unsigned-32,
- collation::binary-5,
- numparts::signed-8,
- rest::binary
- >> = tail
-
- {:ok, collation} = decode_collation(collation)
-
- type_info =
- def_type_info
- |> Map.put(:collation, collation)
- |> Map.put(:data_reader, :longlen)
- |> Map.put(:length, length)
-
- rest =
- Enum.reduce(
- 1..numparts,
- rest,
- fn _,
- <> ->
- next_rest
- end
- )
-
- {type_info, rest}
-
- user_type == @tds_data_type_image ->
- # TODO NumParts Reader
- <> = tail
-
- rest =
- Enum.reduce(
- 1..numparts,
- rest,
- fn _,
- <> ->
- next
- end
- )
-
- type_info =
- def_type_info
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :longlen)
-
- {type_info, rest}
-
- user_type == @tds_data_type_variant ->
- <> = tail
-
- type_info =
- def_type_info
- |> Map.put(:length, length)
- |> Map.put(:data_reader, :variant)
-
- {type_info, rest}
- end
- end
-
- @spec decode_collation(binpart :: <<_::40>>) ::
- {:ok, Tds.Protocol.Collation.t()}
- | {:error, :more}
- | {:error, any}
- defdelegate decode_collation(binpart),
- to: Tds.Protocol.Collation,
- as: :decode
-
- #
- # Data Decoders
- #
- def decode_data(
- %{data_type: :fixed, data_type_code: data_type_code, length: length},
- <>
- ) do
- <> = tail
-
- value =
- case data_type_code do
- @tds_data_type_null ->
- nil
-
- @tds_data_type_bit ->
- value_binary != <<0x00>>
-
- @tds_data_type_smalldatetime ->
- decode_smalldatetime(value_binary)
-
- @tds_data_type_smallmoney ->
- decode_smallmoney(value_binary)
-
- @tds_data_type_real ->
- <> = value_binary
- Float.round(val, 4)
-
- @tds_data_type_datetime ->
- decode_datetime(value_binary)
-
- @tds_data_type_float ->
- <> = value_binary
- Float.round(val, 8)
-
- @tds_data_type_money ->
- decode_money(value_binary)
-
- _ ->
- <> = value_binary
- val
- end
-
- {value, tail}
- end
-
- # ByteLength Types
- def decode_data(%{data_reader: :bytelen}, <<0x00, tail::binary>>),
- do: {nil, tail}
-
- def decode_data(
- %{
- data_type_code: data_type_code,
- data_reader: :bytelen,
- length: length
- } = data_info,
- <>
- ) do
- value =
- cond do
- data_type_code == @tds_data_type_daten ->
- decode_date(data)
-
- data_type_code == @tds_data_type_timen ->
- decode_time(data_info[:scale], data)
-
- data_type_code == @tds_data_type_datetime2n ->
- decode_datetime2(data_info[:scale], data)
-
- data_type_code == @tds_data_type_datetimeoffsetn ->
- decode_datetimeoffset(data_info[:scale], data)
-
- data_type_code == @tds_data_type_uniqueidentifier ->
- decode_uuid(:binary.copy(data))
-
- data_type_code == @tds_data_type_intn ->
- case length do
- 1 ->
- <> = data
- val
-
- 2 ->
- <> = data
- val
-
- 4 ->
- <> = data
- val
-
- 8 ->
- <> = data
- val
- end
-
- data_type_code in [
- @tds_data_type_decimal,
- @tds_data_type_numeric,
- @tds_data_type_decimaln,
- @tds_data_type_numericn
- ] ->
- decode_decimal(data_info[:precision], data_info[:scale], data)
-
- data_type_code == @tds_data_type_bitn ->
- data != <<0x00>>
-
- data_type_code == @tds_data_type_floatn ->
- len = length * 8
- <> = data
- val
-
- data_type_code == @tds_data_type_moneyn ->
- case length do
- 4 -> decode_smallmoney(data)
- 8 -> decode_money(data)
- end
-
- data_type_code == @tds_data_type_datetimen ->
- case length do
- 4 -> decode_smalldatetime(data)
- 8 -> decode_datetime(data)
- end
-
- data_type_code in [
- @tds_data_type_char,
- @tds_data_type_varchar
- ] ->
- decode_char(data_info, data)
-
- data_type_code in [
- @tds_data_type_binary,
- @tds_data_type_varbinary
- ] ->
- :binary.copy(data)
- end
-
- {value, tail}
- end
-
- # ShortLength Types
- def decode_data(%{data_reader: :shortlen}, <<0xFF, 0xFF, tail::binary>>),
- do: {nil, tail}
-
- def decode_data(
- %{data_type_code: data_type_code, data_reader: :shortlen} = data_info,
- <>
- ) do
- value =
- cond do
- data_type_code in [
- @tds_data_type_bigvarchar,
- @tds_data_type_bigchar
- ] ->
- decode_char(data_info, data)
-
- data_type_code in [
- @tds_data_type_bigvarbinary,
- @tds_data_type_bigbinary
- ] ->
- :binary.copy(data)
-
- data_type_code in [
- @tds_data_type_nvarchar,
- @tds_data_type_nchar
- ] ->
- decode_nchar(data_info, data)
-
- data_type_code == @tds_data_type_udt ->
- decode_udt(data_info, :binary.copy(data))
- end
-
- {value, tail}
- end
-
- def decode_data(%{data_reader: :longlen}, <<0x00, tail::binary>>),
- do: {nil, tail}
-
- def decode_data(
- %{data_type_code: data_type_code, data_reader: :longlen} = data_info,
- <<
- text_ptr_size::unsigned-8,
- _text_ptr::size(text_ptr_size)-unit(8),
- _timestamp::unsigned-64,
- size::little-signed-32,
- data::binary-size(size)-unit(8),
- tail::binary
- >>
- ) do
- value =
- case data_type_code do
- @tds_data_type_text -> decode_char(data_info, data)
- @tds_data_type_ntext -> decode_nchar(data_info, data)
- @tds_data_type_image -> :binary.copy(data)
- _ -> nil
- end
-
- {value, tail}
- end
-
- # TODO Variant Types
-
- def decode_data(%{data_reader: :plp}, <<
- @tds_plp_null::little-unsigned-64,
- tail::binary
- >>),
- do: {nil, tail}
-
- def decode_data(
- %{data_type_code: data_type_code, data_reader: :plp} = data_info,
- <<_size::little-unsigned-64, tail::binary>>
- ) do
- {data, tail} = decode_plp_chunk(tail, <<>>)
-
- value =
- cond do
- data_type_code == @tds_data_type_xml ->
- decode_xml(data_info, data)
-
- data_type_code in [
- @tds_data_type_bigvarchar,
- @tds_data_type_bigchar,
- @tds_data_type_text
- ] ->
- decode_char(data_info, data)
-
- data_type_code in [
- @tds_data_type_bigvarbinary,
- @tds_data_type_bigbinary,
- @tds_data_type_image
- ] ->
- data
-
- data_type_code in [
- @tds_data_type_nvarchar,
- @tds_data_type_nchar,
- @tds_data_type_ntext
- ] ->
- decode_nchar(data_info, data)
-
- data_type_code == @tds_data_type_udt ->
- decode_udt(data_info, data)
- end
-
- {value, tail}
- end
-
- def decode_plp_chunk(<>, buf)
- when chunksize == 0,
- do: {buf, tail}
-
- def decode_plp_chunk(
- <<
- chunksize::little-unsigned-32,
- chunk::binary-size(chunksize)-unit(8),
- tail::binary
- >>,
- buf
- ) do
- decode_plp_chunk(tail, buf <> :binary.copy(chunk))
- end
-
- def decode_smallmoney(<>) do
- Float.round(money * 0.0001, 4)
- end
-
- def decode_money(<<
- money_m::little-unsigned-32,
- money_l::little-unsigned-32
- >>) do
- <> = <>
- Float.round(money * 0.0001, 4)
- end
-
- # UUID
- def decode_uuid(<<_::128>> = bin), do: bin
-
- def encode_uuid(<<_::64, ?-, _::32, ?-, _::32, ?-, _::32, ?-, _::96>> = string) do
- raise ArgumentError,
- "trying to load string UUID as Tds.Types.UUID: #{inspect(string)}. " <>
- "Maybe you wanted to declare :uuid as your database field?"
- end
-
- def encode_uuid(<<_::128>> = bin), do: bin
-
- def encode_uuid(any),
- do: raise(ArgumentError, "Invalid uuid value #{inspect(any)}")
-
- # Decimal
- def decode_decimal(precision, scale, <>) do
- set_decimal_precision(precision)
-
- size = byte_size(value)
- <> = value
-
- case sign do
- 0 -> Decimal.new(-1, value, -scale)
- 1 -> Decimal.new(1, value, -scale)
- _ -> raise ArgumentError, "Sign value out of range. Expected 0 or 1, got #{inspect(sign)}"
- end
- end
-
- def decode_char(data_info, <>) do
- Tds.Utils.decode_chars(data, data_info.collation.codepage)
- end
-
- def decode_nchar(_data_info, <>) do
- UCS2.to_string(data)
- end
-
- def decode_xml(_data_info, <>) do
- UCS2.to_string(data)
- end
-
- def decode_udt(%{}, <>) do
- # UDT, if used, should be decoded by app that uses it,
- # tho we could've registered UDT types on connection
- # Example could be ecto, where custom type is created
- # special case are built in udt types such as HierarchyId
- data
- end
-
- @doc """
- Data Type Encoders
- Encodes the COLMETADATA for the data type
- """
- def encode_data_type(%Parameter{type: type} = param) when type != nil do
- case type do
- :boolean -> encode_binary_type(param)
- :binary -> encode_binary_type(param)
- :string -> encode_string_type(param)
- :integer -> encode_integer_type(param)
- :decimal -> encode_decimal_type(param)
- :numeric -> encode_decimal_type(param)
- :float -> encode_float_type(param)
- :smalldatetime -> encode_smalldatetime_type(param)
- :datetime -> encode_datetime_type(param)
- :datetime2 -> encode_datetime2_type(param)
- :datetimeoffset -> encode_datetimeoffset_type(param)
- :date -> encode_date_type(param)
- :time -> encode_time_type(param)
- :uuid -> encode_uuid_type(param)
- :image -> encode_image_type(param)
- _ -> encode_string_type(param)
- end
- end
-
- def encode_data_type(param),
- do: param |> Parameter.fix_data_type() |> encode_data_type()
-
- def encode_binary_type(%Parameter{value: value} = param)
- when value == "" do
- encode_string_type(param)
- end
-
- def encode_binary_type(%Parameter{value: value} = param)
- when is_integer(value) do
- %{param | value: <>} |> encode_binary_type
- end
-
- def encode_binary_type(%Parameter{value: value}) do
- length = length_for_binary(value)
- type = @tds_data_type_bigvarbinary
- data = <> <> length
- {type, data, []}
- end
-
- defp length_for_binary(nil), do: <<0xFF, 0xFF>>
-
- defp length_for_binary(value) do
- case byte_size(value) do
- # varbinary(max)
- value_size when value_size > 8000 -> <<0xFF, 0xFF>>
- value_size -> <>
- end
- end
-
- def encode_bit_type(%Parameter{}) do
- type = @tds_data_type_bigvarbinary
- data = <>
- {type, data, []}
- end
-
- def encode_uuid_type(%Parameter{value: value}) do
- length =
- if value == nil do
- 0x00
- else
- 0x10
- end
-
- type = @tds_data_type_uniqueidentifier
- data = <>
- {type, data, []}
- end
-
- def encode_image_type(%Parameter{value: value}) do
- length =
- if value == nil do
- 0x00
- else
- byte_size(value)
- end
-
- type = @tds_data_type_image
- data = <>
- {type, data, []}
- end
-
- def encode_string_type(%Parameter{value: value}) do
- collation = <<0x00, 0x00, 0x00, 0x00, 0x00>>
-
- length =
- if value != nil do
- value = value |> UCS2.from_string()
- value_size = byte_size(value)
-
- if value_size == 0 or value_size > 8000 do
- <<0xFF, 0xFF>>
- else
- <>
- end
- else
- <<0xFF, 0xFF>>
- end
-
- type = @tds_data_type_nvarchar
- data = <> <> length <> collation
- {type, data, [collation: collation]}
- end
-
- # def encode_integer_type(%Parameter{value: value} = param)
- # when value < 0 do
- # encode_decimal_type(Decima.new(param))
- # end
-
- def encode_integer_type(%Parameter{value: value}) do
- attributes = []
- type = @tds_data_type_intn
-
- {attributes, length} =
- if value == nil do
- attributes =
- attributes
- |> Keyword.put(:length, 4)
-
- value_size = int_type_size(value)
- {attributes, <>}
- else
- value_size = int_type_size(value)
- # cond do
- # value_size == 1 ->
- # data_type_code = @tds_data_type_tinyint
- # Enum.find(data_types, fn(x) -> x[:name] == :tinyint end)
- # value_size == 2 ->
- # data_type_code = @tds_data_type_smallint
- # Enum.find(data_types, fn(x) -> x[:name] == :smallint end)
- # value_size > 2 and value_size <= 4 ->
- # data_type_code = @tds_data_type_int
- # Enum.find(data_types, fn(x) -> x[:name] == :int end)
- # value_size > 4 and value_size <= 8 ->
- # data_type_code = @tds_data_type_bigint
- # Enum.find(data_types, fn(x) -> x[:name] == :bigint end)
- # end
- attributes =
- attributes
- |> Keyword.put(:length, value_size)
-
- {attributes, <>}
- end
-
- data = <> <> length
- {type, data, attributes}
- end
-
- def encode_decimal_type(%Parameter{value: nil} = param) do
- encode_binary_type(param)
- end
-
- def encode_decimal_type(%Parameter{value: value}) do
- set_decimal_precision(38)
-
- value_list =
- value
- |> Decimal.abs()
- |> Decimal.to_string(:normal)
- |> String.split(".")
-
- {precision, scale} =
- case value_list do
- [p, s] ->
- {String.length(p) + String.length(s), String.length(s)}
-
- [p] ->
- {String.length(p), 0}
- end
-
- dec_abs =
- value
- |> Decimal.abs()
-
- value =
- dec_abs.coef
- |> :binary.encode_unsigned(:little)
-
- value_size = byte_size(value)
-
- len =
- cond do
- precision <= 9 -> 4
- precision <= 19 -> 8
- precision <= 28 -> 12
- precision <= 38 -> 16
- end
-
- padding = len - value_size
- value_size = value_size + padding + 1
-
- type = @tds_data_type_decimaln
- data = <