Skip to content

Commit

Permalink
render error hints
Browse files Browse the repository at this point in the history
  • Loading branch information
nsidnev committed Sep 26, 2023
1 parent 9e98c37 commit 002ca28
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 1 deletion.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

[Compare with 0.6.1](https://github.com/edgedb/edgedb-elixir/compare/v0.6.1...HEAD)

### Added

- rendering hints for query errors from EdgeDB.

## [0.6.1] - 2023-07-07

[Compare with 0.6.0](https://github.com/edgedb/edgedb-elixir/compare/v0.6.0...v0.6.1)
Expand Down
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Config

config :edgedb,
rended_colored_errors: false,
file_module: Tests.Support.Mocks.FileMock,
path_module: Tests.Support.Mocks.PathMock,
system_module: Tests.Support.Mocks.SystemMock
4 changes: 4 additions & 0 deletions lib/edgedb.ex
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ defmodule EdgeDB do
iex(2)> {:error, %EdgeDB.Error{} = error} = EdgeDB.query(client, "select UndefinedType")
iex(3)> raise error
** (EdgeDB.Error) InvalidReferenceError: object type or alias 'default::UndefinedType' does not exist
┌─ query:1:8
1 │ select UndefinedType
│ ^^^^^^^^^^^^^ error
```
If a query has arguments, they can be passed as a list for a query with positional arguments
Expand Down
216 changes: 215 additions & 1 deletion lib/edgedb/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ defmodule EdgeDB.Error do
* `EdgeDB.Error.retry?/1`
* `EdgeDB.Error.inheritor?/2`
By default the client generates exception messages in full format, attempting to output all useful
information about the error location if it is possible.
This behavior can be disabled by using the `:render_error_hints` configuration of the `:edgedb` application.
The renderer also tries to colorize the output message. This behavior defaults to `IO.ANSI.enabled?/0`,
but can also be configured with the `:rended_colored_errors` setting for the `:edgedb` application.
"""

alias EdgeDB.Error.Parser
Expand Down Expand Up @@ -68,7 +76,11 @@ defmodule EdgeDB.Error do

@impl Exception
def message(%__MODULE__{} = exception) do
"#{exception.name}: #{exception.message}"
render_hints? = Application.get_env(:edgedb, :render_error_hints, true)
color_errors? = Application.get_env(:edgedb, :rended_colored_errors, IO.ANSI.enabled?())

config = generate_render_config(exception, render_hints?, color_errors?)
generate_message(exception, config)
end

@doc """
Expand Down Expand Up @@ -184,4 +196,206 @@ defmodule EdgeDB.Error do
def inheritor?(_exception, _error_type) do
false
end

defp generate_message(
%__MODULE__{
query: %EdgeDB.Query{statement: query}
} = exception,
%{start: start} = config
)
when start >= 0 do
lines =
query
|> String.split("\n")
|> Enum.map(&"#{&1}\n")

padding =
lines
|> length()
|> Integer.digits()
|> length()

config = Map.put(config, :padding, padding)

{_config, lines} =
lines
|> Enum.with_index(1)
|> Enum.reduce_while({config, []}, fn {line, idx}, {config, lines} ->
line_size = string_length(line)
line = String.trim_trailing(line)

case render_line(line, line_size, to_string(idx), config, lines) do
{:rendered, {config, lines}} ->
{:cont, {config, lines}}

{:finished, {config, lines}} ->
{:halt, {config, lines}}
end
end)

[
[:reset, "#{exception.name}: "],
[:bright, "#{exception.message}", "\n"],
[:blue, "#{String.pad_leading("", padding)} ┌─ "],
[:reset, "query:#{config.line}:#{config.col}", "\n"],
[:blue, "#{String.pad_leading("", padding)} │", "\n"]
| Enum.reverse(lines)
]
|> IO.ANSI.format(config.use_color)
|> IO.iodata_to_binary()
end

defp generate_message(%__MODULE__{} = exception, _config) do
"#{exception.name}: #{exception.message}"
end

defp generate_render_config(%__MODULE__{} = exception, true, color_errors?) do
position_start =
case Integer.parse(exception.attributes[:character_start] || "") do
{position_start, ""} ->
position_start

:error ->
-1
end

position_end =
case Integer.parse(exception.attributes[:character_end] || "") do
{position_end, ""} ->
position_end

:error ->
-1
end

%{
start: position_start,
offset: max(1, position_end - position_start),
line: exception.attributes[:line_start] || "?",
col: exception.attributes[:column_start] || "?",
hint: exception.attributes[:hint] || "error",
use_color: color_errors?
}
end

defp generate_render_config(%__MODULE__{}, _render_hints?, _color_errors?) do
%{}
end

defp render_line(_line, line_size, _line_num, %{start: start} = config, lines)
when start >= line_size do
{:rendered, {%{config | start: start - line_size}, lines}}
end

defp render_line(line, line_size, line_num, config, lines) do
{line, line_size, config, lines} =
render_border(line, line_size, line_num, config, lines)

render_error(line, line_size, config, lines)
end

defp render_border(line, line_size, line_num, %{start: start} = config, lines)
when start >= 0 do
{first_half, line} = split_string_at(line, config.start)
line_size = line_size - config.start

lines = [
[
[:blue, "#{String.pad_leading(line_num, config.padding)} │ "],
[:reset, first_half]
]
| lines
]

config = %{config | start: unicode_width(first_half)}

{line, line_size, config, lines}
end

defp render_border(line, line_size, line_num, config, lines) do
lines = [
[
[:blue, "#{String.pad_leading(line_num, config.padding)} │ "],
[:red, "│ "]
]
| lines
]

{line, line_size, config, lines}
end

defp render_error(line, line_size, %{offset: offset, start: start} = config, lines)
when offset > line_size and start >= 0 do
lines = [
[
[:red, line, "\n"],
[:blue, "#{String.pad_leading("", config.padding)} │ "],
[:red, "╭─#{String.duplicate("─", config.start)}^", "\n"]
]
| lines
]

config = %{config | offset: config.offset - line_size, start: -1}
{:rendered, {config, lines}}
end

defp render_error(line, line_size, %{offset: offset} = config, lines) when offset > line_size do
lines = [[:red, line, "\n"] | lines]

config = %{config | offset: config.offset - line_size, start: -1}
{:rendered, {config, lines}}
end

defp render_error(line, _line_size, %{start: start} = config, lines) when start >= 0 do
{first_half, line} = split_string_at(line, config.offset)
error_width = unicode_width(first_half)
padding_string = String.pad_leading("", config.padding)

lines =
[
[
[:red, first_half],
[:reset, line, "\n"],
[:blue, "#{padding_string}#{String.duplicate(" ", config.start)}"],
[:red, "#{String.duplicate("^", error_width)} #{config.hint}"]
]
| lines
]

{:finished, {config, lines}}
end

defp render_error(line, _line_size, config, lines) do
{first_half, line} = split_string_at(line, config.offset)
error_width = unicode_width(first_half)

lines =
[
[
[:red, first_half],
[:reset, line, "\n"],
[:blue, "#{String.duplicate(" ", config.padding)} │ "],
[:red, "╰─#{String.duplicate("─", error_width - 1)}^ #{config.hint}"]
]
| lines
]

{:finished, {config, lines}}
end

defp string_length(text) do
text
|> String.codepoints()
|> length
end

defp unicode_width(text) do
Ucwidth.width(text)
end

defp split_string_at(text, position) do
codes = String.codepoints(text)
{list1, list2} = Enum.split(codes, position)
{Enum.join(list1), Enum.join(list2)}
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ defmodule EdgeDB.MixProject do
{:jose, "~> 1.11"},
{:crc, "~> 0.10.4"},
{:castore, "~> 0.1.0 or ~> 1.0"},
{:ucwidth, "~> 0.2.0"},
{:jason, "~> 1.2", optional: true},
{:timex, "~> 3.7", optional: true},
# test
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"ucwidth": {:hex, :ucwidth, "0.2.0", "1f0a440f541d895dff142275b96355f7e91e15bca525d4a0cc788ea51f0e3441", [:mix], [], "hexpm", "c1efd1798b8eeb11fb2bec3cafa3dd9c0c3647bee020543f0340b996177355bf"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}
4 changes: 4 additions & 0 deletions pages/rst/api/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ If an error occurs, it will be returned as a ``{:error, exception}`` tuple where
iex(2)> {:error, %EdgeDB.Error{} = error} = EdgeDB.query(client, "select UndefinedType")
iex(3)> raise error
** (EdgeDB.Error) InvalidReferenceError: object type or alias 'default::UndefinedType' does not exist
┌─ query:1:8
1 │ select UndefinedType
│ ^^^^^^^^^^^^^ error
If a query has arguments, they can be passed as a list for a query with positional arguments or as a list of keywords for a query with named
arguments.
Expand Down
8 changes: 8 additions & 0 deletions pages/rst/api/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ The useful ones are:
- ``EdgeDB.Error.retry?/1``
- ``EdgeDB.Error.inheritor?/2``

By default the client generates exception messages in full format, attempting to output all useful information about the error location if it is
possible.

This behavior can be disabled by using the ``:render_error_hints`` configuration of the ``:edgedb`` application.

The renderer also tries to colorize the output message. This behavior defaults to ``IO.ANSI.enabled?/0``, but can also be configured with the
``:rended_colored_errors`` setting for the ``:edgedb`` application.

Types
~~~~~

Expand Down
54 changes: 54 additions & 0 deletions test/edgedb/error_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule Tests.ErrorTest do
use Tests.Support.EdgeDBCase

setup :edgedb_client

@queries %{
"select $0" => """
QueryError: missing a type cast before the parameter
┌─ query:1:8
1 │ select $0
│ ^^ error
""",
"select ('something', 42, 1 < 'kek😁lol/не англ');" => """
InvalidTypeError: operator '<' cannot be applied to operands of type 'std::int64' and 'std::str'
┌─ query:1:26
1 │ select ('something', 42, 1 < 'kek😁lol/не англ');
│ ^^^^^^^^^^^^^^^^^^^^^^ Consider using an explicit type cast or a conversion function.
""",
"""
select (
'something', 'not valid operand' < (
2, 3, 4,
), 345
);
""" => """
InvalidTypeError: operator '<' cannot be applied to operands of type 'std::str' and 'tuple<std::int64, std::int64, std::int64>'
┌─ query:2:18
2 │ 'something', 'not valid operand' < (
│ ╭──────────────────^
3 │ │ 2, 3, 4,
4 │ │ ), 345
│ ╰─────^ Consider using an explicit type cast or a conversion function.
""",
"select { x := 1 } { x := 'f̷͈͎͒̕ǫ̴̏͌ö̶̱̘' };" => """
SchemaError: cannot redefine property 'x' of object type 'std::FreeObject' as scalar type 'std::str'
┌─ query:1:26
1 │ select { x := 1 } { x := 'f̷͈͎͒̕ǫ̴̏͌ö̶̱̘' };
│ ^^^^^ error
"""
}

for {query, message} <- @queries do
message = String.trim(message)

test "rendering error for #{inspect(query)} query", %{client: client} do
assert {:error, %EdgeDB.Error{} = exc} = EdgeDB.query(client, unquote(query))
assert Exception.message(exc) == unquote(message)
end
end
end

0 comments on commit 002ca28

Please sign in to comment.