defmodule HL7 do
@moduledoc """
Functions to manipulate and query HL7 documents -- plus a way to handle HL7 file streams.
"""
defstruct segments: []
@buffer_size 32768
@type file_type_hl7 :: :mllp | :line | nil
@type hl7_map_data() :: %{optional(non_neg_integer) => hl7_map_data() | String.t()}
@type hl7_list_data() :: String.t() | [hl7_list_data]
@type segment() :: %{
0 => String.t(),
optional(pos_integer) => hl7_map_data() | String.t()
}
@type t() :: %__MODULE__{segments: [segment()]}
@type parsed_hl7_segments :: t() | [segment()]
@type parsed_hl7 :: t() | segment() | [segment()] | hl7_map_data()
alias HL7.Path
@doc ~S"""
The `~p` sigil encodes an HL7 path into a struct at compile-time to guarantee correctness and speed.
It is designed to work with data returned by `HL7.new!/1`.
The full path structure in HL7 is expressed as:
`~p"SEGMENT_NAME[SEGMENT_NUMBER]-FIELD[REPETITION].COMPONENT.SUBCOMPONENT`
`SEGMENT_NAME` -- 3 character string
`SEGMENT_NUMBER` -- Positive integer in brackets, defaulting to 1. All segments of the name can be accessed with `[*]`
`FIELD` -- Positive integer
`REPETITION` -- Positive integer in brackets, defaulting to 1. All repetitions of the field can be accessed with `[*]`
`COMPONENT` -- Positive integer
`SUBCOMPONENT` -- Positive integer
Example paths:
`~p"OBX"` refers to the 1st OBX segment in its entirety, the same as `~p"OBX[1]"`.
`~p"OBX-5"` means the 1st repetition of the 5th field of the 1st OBX segment.
`~p"OBX[1]-5[1]"` is equivalent, the numbers in brackets specifying the default repetition and segment number values.
`~p"OBX[2]-5"` thus means 1st repetition of the 5th field of the 2nd OBX segment.
`~p"OBX[*]-5"` would get the 1st repetition of the 5th field of every OBX segment, returning a list of results.
Note that repetitions are uncommon in HL7 and the default of a 1st repetition is often just assumed.
`~p"PID-3"` is equivalent to `~p"PID-3[1]"` and is the most standard representation.
All repetitions can be found using a repetition wildcard: `~p"PID-11[*]"`. A list of lists can
be produced by selecting multiple segments and multiple repetitions with `~p"PID[*]-11[*]"`.
Components and subcomponents can also be accessed with the path structures.
`h"OBX-2.3.1"` would return the 1st subcomponent of the 3rd component of the 2nd field of the 1st OBX.
Lastly, if a path might have additional data such that a string might be found at either
`~p"OBX-2"` or `~p"OBX-2.1"` or even `~p"OBX-2.1.1"`, there is truncation character (the bang symbol) that
will return the first element found in the HL7 text at the target specificity. Thus, `~p"OBX[*]-2!"`
would get the 1st piece of data in the 2nd field of every OBX whether it is a string or nested map.
## Examples
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX-5")
"1.80"
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX[*]-5")
["1.80", "79"]
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX[*]-2!")
["N", "NM"]
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID-11[*].5")
["35209", "35200"]
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID[*]-11[*].5")
[["35209", "35200"]]
iex> import HL7
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID-11[2].1")
"NICKELL’S PICKLES"
"""
defmacro sigil_p({:<<>>, _, [path]}, _modifiers) do
path
|> HL7.Path.new()
|> Macro.escape()
end
@doc ~S"""
Creates an HL7 struct from HL7 data (accepting text, lists or the deprecated `HL7.Message` struct).
"""
@spec new!(hl7_list_data() | String.t() | HL7.Message.t() | [segment()]) :: t()
def new!(segments) when is_list(segments) do
segments =
Enum.map(
segments,
fn
segment when is_list(segment) -> to_map(%{}, 0, segment)
%{0 => _} = segment -> segment
end
)
%__MODULE__{segments: segments}
end
def new!(text) when is_binary(text) do
text |> HL7.Message.to_list() |> new!()
end
def new!(%HL7.Message{} = message) do
message |> HL7.Message.to_list() |> new!()
end
@spec new(String.t()) :: {:ok, t()} | {:error, HL7.InvalidMessage.t()}
def new(text) when is_binary(text) do
case HL7.Message.new(text) do
%HL7.Message{} = message -> {:ok, message |> HL7.Message.to_list() |> new!()}
invalid_message -> {:error, invalid_message}
end
end
@spec put(parsed_hl7(), Path.t(), String.t() | nil | hl7_map_data()) :: parsed_hl7()
def put(%HL7{segments: segments} = hl7, %Path{} = path, value) do
%HL7{hl7 | segments: put(segments, path, value)}
end
def put(segment_data, %Path{} = path, value) do
segment_data |> do_put(path, value)
end
def update(%HL7{segments: segments} = hl7, %Path{} = path, default, fun) do
%HL7{hl7 | segments: update(segments, path, default, fun)}
end
def update(segment_data, path, default, fun) do
segment_data |> do_put(path, {default, fun})
end
def update!(%HL7{segments: segments} = hl7, %Path{} = path, fun) do
%HL7{hl7 | segments: update!(segments, path, fun)}
end
def update!(segment_data, path, fun) do
segment_data |> do_put(path, {fun})
end
def get_segments(%HL7{segments: segments}) do
segments
end
def set_segments(%HL7{} = hl7, segments) do
%HL7{hl7 | segments: segments}
end
@doc ~S"""
Labels source data (a segment map or list of segment maps) by using `HL7.Path` sigils in a labeled
output template.
One-arity functions placed as output template values will be called with the source data.
## Examples
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.label(%{mrn: ~p"PID-3!", name: ~p"PID-5.2"})
%{mrn: "56782445", name: "BARRY"}
"""
@spec label(t() | segment(), map()) :: map()
def label(segment_or_segments, template_map) do
for {key, output_param} <- template_map, into: Map.new() do
{key, do_label(segment_or_segments, output_param) |> nil_for_empty()}
end
end
@doc ~S"""
Finds data within an `HL7.t()` and from within the data that `get/2` returns (segments and repetitions)
using an `HL7.Path` sigil.
Selecting data across multiple segments or repetitions with the wildcard `[*]` pattern
will return a list of results.
Repetition data can be search using a partial path containing ony the component and/or
subcomponent with the preceding period, e.g. `~p".2.3"`.
## Examples
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX-5")
"1.80"
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX[*]-5")
["1.80", "79"]
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"OBX[*]-2!")
["N", "NM"]
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID-11[*].5")
["35209", "35200"]
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID-11[2].1")
"NICKELL’S PICKLES"
iex> import HL7, only: :sigils
iex> HL7.Examples.wikipedia_sample_hl7()
...> |> HL7.new!()
...> |> HL7.get(~p"PID-11[*]")
...> |> HL7.get(~p".1")
["260 GOODWIN CREST DRIVE", "NICKELL’S PICKLES"]
"""
@spec get(parsed_hl7(), %Path{}) ::
hl7_map_data() | String.t() | nil
def get(data, path) do
data
|> do_get(path)
|> maybe_truncate(path)
end
@doc """
Creates a list of lists in which the specified `segment_name` is used to get the first segment map
of each list. This function helps to do things like grouping `OBX` segments with their parent `OBR` segment.
Options:
`keep_prefix_segments: true` will leave the first set of non-matching segments in the return value.
"""
@spec chunk_by_lead_segment(t() | [segment()], String.t(), Keyword.t()) :: [[segment()]]
def chunk_by_lead_segment(segments, segment_name, options \\ []) do
do_chunk_by_segment(segments, segment_name)
|> maybe_keep_prefix_segments(!!options[:keep_prefix_segments])
end
@doc """
Converts a segment map (or lists of segments maps) or HL7 struct into a raw Elixir list.
"""
@spec to_list(t() | hl7_map_data()) :: hl7_list_data()
def to_list(%HL7{segments: segments}) do
to_list(segments)
end
def to_list(map_data) when is_list(map_data) do
Enum.map(map_data, fn segment_map -> do_to_list(segment_map) end)
end
def to_list(map_data) when is_map(map_data) do
do_to_list(map_data)
end
@doc """
Opens an HL7 file stream of either `:mllp` or `:line`. If the file_type is not specified
it will be inferred from the first three characters of the file contents.
"""
@spec open_hl7_file_stream(String.t(), file_type_hl7()) :: Enumerable.t()
def open_hl7_file_stream(file_path, file_type \\ nil) when is_atom(file_type) do
found_file_type =
file_type
|> case do
nil ->
infer_file_type(file_path)
_ ->
if File.exists?(file_path) do
{:ok, file_type}
else
{:error, :enoent}
end
end
found_file_type
|> case do
{:ok, :line} ->
file_path
|> File.stream!([], @buffer_size)
{:ok, :mllp} ->
file_path
|> File.stream!([], @buffer_size)
|> HL7.MLLPStream.raw_to_messages()
{:error, reason} ->
{:error, reason}
end
end
# internals
defp get_max_index(data) when is_map(data) do
data |> Map.keys() |> Enum.max() |> max(0)
end
defp to_map(value) when is_binary(value) do
value
end
defp to_map(value) when is_list(value) do
to_map(%{}, 1, value)
end
defp to_map(acc, index, [h]) do
Map.put(acc, index, to_map(h))
end
defp to_map(acc, index, [h | t]) do
case to_map(h) do
"" -> acc
v -> Map.put(acc, index, v)
end
|> to_map(index + 1, t)
end
def do_to_list(hl7_map_data) when is_binary(hl7_map_data) do
hl7_map_data
end
def do_to_list(hl7_map_data) do
do_to_list([], hl7_map_data, get_max_index(hl7_map_data))
end
defp do_to_list(acc, %{0 => _} = hl7_map_data, index) when index > -1 do
chunk = hl7_map_data[index] || ""
do_to_list([do_to_list(chunk) | acc], hl7_map_data, index - 1)
end
defp do_to_list(acc, hl7_map_data, index) when index > 0 do
chunk = hl7_map_data[index] || ""
do_to_list([do_to_list(chunk) | acc], hl7_map_data, index - 1)
end
defp do_to_list(acc, _hl7_map_data, _index) do
acc
end
defp do_chunk_by_segment(%HL7{segments: segment_list}, segment_name) do
do_chunk_by_segment([], [], segment_list, segment_name)
end
defp do_chunk_by_segment(segment_list, segment_name) when is_list(segment_list) do
do_chunk_by_segment([], [], segment_list, segment_name)
end
defp do_chunk_by_segment([], [], [%{0 => segment_name} = segment | rest], segment_name) do
do_chunk_by_segment([], [segment], rest, segment_name)
end
defp do_chunk_by_segment(
acc,
chunk_acc,
[%{0 => segment_name} = segment | rest],
segment_name
) do
do_chunk_by_segment([chunk_acc | acc], [segment], rest, segment_name)
end
defp do_chunk_by_segment(acc, chunk_acc, [segment | rest], segment_name) do
do_chunk_by_segment(acc, [segment | chunk_acc], rest, segment_name)
end
defp do_chunk_by_segment(acc, chunk_acc, [], _segment_name) do
[chunk_acc | acc]
|> Enum.map(&Enum.reverse/1)
|> Enum.reverse()
end
defp get_value_at_index(nil, _) do
nil
end
defp get_value_at_index(segment_data, 1) when is_binary(segment_data) do
segment_data
end
defp get_value_at_index(segment_data, nil) when is_binary(segment_data) do
segment_data
end
defp get_value_at_index(segment_data, _) when is_binary(segment_data) do
nil
end
defp get_value_at_index(segment_data, i) do
max_index = get_max_index(segment_data)
if i > max_index, do: nil, else: Map.get(segment_data, i, "")
end
defp ensure_map(data) when is_binary(data) or is_nil(data) do
%{1 => data}
end
defp ensure_map(data) when is_map(data) do
data
end
defp maybe_truncate(segment_data, %Path{truncate: true}) do
truncate(segment_data)
end
defp maybe_truncate(segment_data, %Path{truncate: false}) do
segment_data
end
defp truncate(segment_data) when is_binary(segment_data) or is_nil(segment_data) do
segment_data
end
defp truncate(segment_data) when is_map(segment_data) do
get_value_at_index(segment_data, 1)
|> truncate()
end
defp truncate(segment_data) when is_list(segment_data) do
Enum.map(segment_data, &truncate/1)
end
defp resolve_placement_value(_field_data = nil, {default, _fun}, _path) do
default
end
defp resolve_placement_value(field_data, {_default, fun}, _path) do
fun.(field_data)
end
defp resolve_placement_value(_field_data = nil, {_fun}, path) do
raise KeyError, message: "HL7.Path #{inspect(path)} could not be found"
end
defp resolve_placement_value(field_data, {fun}, _path) do
fun.(field_data)
end
defp resolve_placement_value(_field_data, value, _path) do
value
end
defp do_get(%HL7{} = hl7, %Path{} = path) do
do_get(hl7.segments, path)
end
defp do_get([], %Path{} = _path) do
[]
end
defp do_get([%{0 => _} | _] = _segments, %Path{segment: nil} = path) do
raise RuntimeError,
"`HL7.Path` to get data across segments must begin with a segment name, not #{inspect(path)}"
end
defp do_get([%{0 => _} | _] = segments, %Path{segment: name, segment_number: "*"} = path) do
segments
|> Stream.filter(&(&1[0] == name))
|> Enum.map(&do_get_in_segment(&1, path))
end
defp do_get([%{0 => _} | _] = segments, %Path{segment: name, segment_number: n} = path) do
segments
|> Stream.filter(&(&1[0] == name))
|> Stream.drop(n - 1)
|> Enum.at(0)
|> do_get_in_segment(path)
end
defp do_get(nil = _segment_data, _path) do
nil
end
defp do_get(%{0 => _} = segment_data, path) do
do_get_in_segment(segment_data, path)
end
defp do_get(repetition_list, %{field: nil, repetition: nil} = path)
when is_list(repetition_list) do
Enum.map(repetition_list, &do_get_in_repetition(&1, path))
end
defp do_get(repetition_data, %{field: nil, repetition: nil} = path) do
do_get_in_repetition(repetition_data, path)
end
defp do_get(_repetition_data, path) do
raise RuntimeError,
"HL7.Path to directly access repetitions should be begin with `.`, not #{inspect(path)}"
end
defp do_get_in_segment(segment_data, %{field: nil, component: nil} = _path) do
segment_data
end
defp do_get_in_segment(_segment_data, %{field: nil} = path) do
raise RuntimeError,
"HL7.Path to access components within segments requires field number, not #{inspect(path)}"
end
defp do_get_in_segment(segment_data, %{field: f} = path) do
segment_data
|> get_value_at_index(f)
|> do_get_in_field(path)
end
defp do_get_in_field(field_data, %{repetition: "*"} = path) when is_map(field_data) do
1..get_max_index(field_data)
|> Enum.map(fn r ->
field_data
|> get_value_at_index(r)
|> do_get_in_repetition(path)
end)
end
defp do_get_in_field(field_data, %{repetition: "*"}) do
[field_data]
end
defp do_get_in_field(field_data, %{repetition: r} = path) do
field_data
|> get_value_at_index(r)
|> do_get_in_repetition(path)
end
defp do_get_in_repetition(repetition_data, %{component: nil}) do
repetition_data
end
defp do_get_in_repetition(repetition_data, %{component: c} = path) do
repetition_data
|> get_value_at_index(c)
|> do_get_in_component(path)
end
defp do_get_in_component(component_data, %{subcomponent: nil}) do
component_data
end
defp do_get_in_component(component_data, %{subcomponent: s}) do
component_data
|> get_value_at_index(s)
end
defp do_put([%{0 => _} | _] = segments, %Path{segment: name, segment_number: "*"} = path, value) do
segments
|> Enum.map(fn segment ->
if segment[0] == name, do: do_put_in_segment(segment, value, path), else: segment
end)
end
defp do_put([%{0 => _} | _] = segments, %Path{segment: name, segment_number: n} = path, value) do
segments
|> Stream.with_index()
|> Stream.filter(&(elem(&1, 0)[0] == name))
|> Stream.drop(n - 1)
|> Enum.at(0)
|> case do
{segment_data, index} ->
List.replace_at(segments, index, do_put_in_segment(segment_data, value, path))
nil ->
raise RuntimeError, "HL7.Path #{inspect(path)} has no matching segment."
end
end
defp do_put(%{0 => _} = segment_data, path, value) do
do_put_in_segment(segment_data, value, path)
end
defp do_put(repetition_list, %{field: nil, repetition: nil} = path, value)
when is_list(repetition_list) do
Enum.map(repetition_list, &do_put_in_repetition(&1, value, path))
end
defp do_put(repetition_data, %{field: nil, repetition: nil} = path, value) do
do_put_in_repetition(repetition_data, value, path)
end
defp do_put(_repetition_data, path, _value) do
raise RuntimeError,
"HL7.Path to directly update repetitions should be begin with `.`, not #{inspect(path)}"
end
defp do_put_in_segment(segment_data, value, %{field: f} = path) do
Map.put(segment_data, f, do_put_in_field(segment_data[f], value, path))
end
# fields can contain either simple strings or maps of repetitions by ordinal index
# if a map of repetitions contains only one string, it is reduced here to the string value alone
defp simplify_string_fields(%{1 => value} = field_data)
when map_size(field_data) == 1 and is_binary(value) do
value
end
defp simplify_string_fields(field_data) do
field_data
end
defp do_put_in_field(field_data, value, %{repetition: "*", component: nil} = path) do
resolve_placement_value(field_data, value, path)
|> simplify_string_fields()
end
defp do_put_in_field(field_data, value, %{repetition: "*"} = path) do
1..get_max_index(field_data)
|> Map.new(fn i ->
{i, do_put_in_repetition(ensure_map(field_data[i]), value, path)}
end)
|> simplify_string_fields()
end
defp do_put_in_field(field_data, value, %{repetition: r} = path) do
field_map = ensure_map(field_data)
Map.put(field_map, r, do_put_in_repetition(field_map[r], value, path))
|> simplify_string_fields()
end
defp do_put_in_repetition(repetition_data, value, %{component: nil} = path) do
resolve_placement_value(repetition_data, value, path)
end
defp do_put_in_repetition(repetition_data, value, %{component: c} = path) do
repetition_map = ensure_map(repetition_data)
Map.put(repetition_map, c, do_put_in_component(repetition_map[c], value, path))
end
defp do_put_in_component(component_data, value, %{subcomponent: nil} = path) do
resolve_placement_value(component_data, value, path)
end
defp do_put_in_component(subcomponent_data, value, %{subcomponent: s} = path) do
subcomponent_map = ensure_map(subcomponent_data)
Map.put(subcomponent_map, s, resolve_placement_value(subcomponent_map[s], value, path))
end
defp do_label(segment_data, %Path{} = output_param) do
get(segment_data, output_param)
end
defp do_label(segment_data, output_param) when is_map(output_param) do
label(segment_data, output_param)
end
defp do_label(segment_data, list) when is_list(list) do
Enum.map(list, &do_label(segment_data, &1))
end
defp do_label(segment_data, function) when is_function(function, 1) do
function.(segment_data)
end
defp do_label(_segment_data, value) do
value |> nil_for_empty()
end
defp maybe_keep_prefix_segments(segment_chunks, true) do
segment_chunks
end
defp maybe_keep_prefix_segments([_prefix_segments | segment_chunks], false) do
segment_chunks
end
defp nil_for_empty(""), do: nil
defp nil_for_empty(value), do: value
@spec infer_file_type(String.t()) :: {:ok, :line} | {:ok, :mllp} | {:error, atom()}
defp infer_file_type(file_path) do
File.open(file_path, [:read])
|> case do
{:ok, file_ref} ->
first_three = IO.binread(file_ref, 3)
_ = File.close(file_ref)
case first_three do
<<"MSH">> ->
{:ok, :line}
<<0x0B, "M", "S">> ->
{:ok, :mllp}
_ ->
{:error, :unrecognized_file_type}
end
error ->
error
end
end
end
defimpl Inspect, for: HL7 do
def inspect(%HL7{segments: segments} = _hl7, _opts) do
count = Enum.count(segments)
names = segments |> Enum.take(5) |> Enum.map(&to_string(&1[0]))
label = Enum.join(names, ", ")
over = count - Enum.count(names)
if over > 0 do
"#HL7[" <> label <> ", +" <> to_string(over) <> "]"
else
"#HL7[" <> label <> "]"
end
end
end
defimpl String.Chars, for: HL7 do
@spec to_string(HL7.t()) :: String.t()
def to_string(%HL7{} = hl7) do
hl7 |> HL7.to_list() |> HL7.Message.raw() |> Map.get(:raw)
end
end