Skip to content

Geometry types #88

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 34 commits into from
Nov 21, 2019
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d0771b8
wip
wojtekmach Nov 15, 2019
293b8b4
Encode some geometry types
wojtekmach Nov 16, 2019
753ce82
Use geo library
wojtekmach Nov 16, 2019
e3a698f
Improve test
wojtekmach Nov 16, 2019
55e0902
chore: add elixir_ls to gitignore
jeroenbourgois Nov 19, 2019
615ec79
chore: add more Geo types
jeroenbourgois Nov 19, 2019
7057c76
chore: add Polygon to the tests
jeroenbourgois Nov 19, 2019
cdface2
chore: update readme with a Geometry section
jeroenbourgois Nov 19, 2019
2afd2a5
chore: add some comments for the Geo section in the code
jeroenbourgois Nov 19, 2019
9827b7f
chore: update readme
jeroenbourgois Nov 19, 2019
9b237c1
Update README.md
wojtekmach Nov 20, 2019
48d55a6
Update values.ex
wojtekmach Nov 20, 2019
c1da9ad
chore: remove editor related ignore entry in the .gitignore file
jeroenbourgois Nov 20, 2019
e1dcaab
chore: check for db server geo support to run appropriate tests
jeroenbourgois Nov 20, 2019
db7e56e
chore: add more geom tests
jeroenbourgois Nov 20, 2019
68ad9a9
Merge branch 'wm-geometry' of github.com:jackjoe/myxql into wm-geometry
jeroenbourgois Nov 20, 2019
611773e
chore: fix some geo tests
jeroenbourgois Nov 20, 2019
fa32849
chore: for geometrycollection only test text for now
jeroenbourgois Nov 20, 2019
cee851c
Update README.md
wojtekmach Nov 20, 2019
f7b0245
chore: remove unsupported geo types
jeroenbourgois Nov 20, 2019
33c72c9
Merge branch 'wm-geometry' of github.com:jackjoe/myxql into wm-geometry
jeroenbourgois Nov 20, 2019
08fbf54
chore: make the geom encoding more obvious by adding the srid sequence
jeroenbourgois Nov 20, 2019
520569b
chore: add missing encoding for geometrycollection
jeroenbourgois Nov 20, 2019
db78cd6
chore: remove tests for unsupported geo types
jeroenbourgois Nov 20, 2019
45e551a
chore: fix geometrycollection test
jeroenbourgois Nov 20, 2019
f0d591b
Update values.ex
wojtekmach Nov 20, 2019
2c80785
chore: update mariaex compatibility text, removing the unsupported geo
jeroenbourgois Nov 20, 2019
953df7a
chore: try to exclude geom on mariadb
jeroenbourgois Nov 20, 2019
f9ed8dd
chore: remove debug statements
jeroenbourgois Nov 20, 2019
6a9a113
chore: testing
jeroenbourgois Nov 20, 2019
a71d2e2
chore: revert logging
jeroenbourgois Nov 20, 2019
dd9ede5
Decode SRID
wojtekmach Nov 21, 2019
659513d
Simply test setup
wojtekmach Nov 21, 2019
4d2e8a4
Update supports_geometry? check
wojtekmach Nov 21, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ myxql-*.tar
/errmsg-utf8.txt
/xref_graph.*
/config
.elixir_ls
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,57 @@ You can customize it to use another library via the `:json_library` configuratio
config :myxql, :json_library, SomeJSONModule
```

## Geometry support

MyXQL comes with support for PostGIS data type support out of the box via the [Geo](https://github.com/bryanjos/geo/) library, which itself has zero dependencies.

Bear in mind: inserting is best done via the `ST_GeomFromText` functions provided by MySQL. The `Geo` library can encode the `%Geo.DATA_TYPE{}` structs to the WKT string that functions expects.

So you can do the following:

```elixir

iex> {:ok, pid} = MyXQL.start_link(username: "root")
iex> MyXQL.query!(pid, "CREATE DATABASE IF NOT EXISTS geo_db")

# setup some geo fields
iex> {:ok, pid} = MyXQL.start_link(username: "root", database: "geo_db")
iex> MyXQL.query!(pid, "CREATE TABLE IF NOT EXISTS geo (id serial primary key, point POINT, polygon POLYGON)")

iex> point = %Geo.Point{coordinates: {1.0, 2.2}}
iex> MyXQL.query!(pid, "INSERT INTO geo (`point`) VALUES (ST_GeomFromText('#{Geo.WKT.encode!(point)}'))")
%MyXQL.Result{columns: nil, connection_id: 11204,, last_insert_id: 1, num_rows: 1, num_warnings: 0, rows: nil}

iex> polygon = %Geo.Polygon{coordinates: [[{30, 10}, {40, 40}, {20, 40}, {10, 20}, {30, 10}]]}
iex> MyXQL.query!(pid, "INSERT INTO geo (`polygon`) VALUES (ST_GeomFromText('#{Geo.WKT.encode!(polygon)}'))")

iex> MyXQL.query!(pid, "SELECT * FROM geo")
%MyXQL.Result{
columns: ["id", "point", "polygon"],
connection_id: 2078,
last_insert_id: nil,
num_rows: 2,
num_warnings: 0,
rows: [
[1, %Geo.Point{coordinates: {1.0, 2.2}, properties: %{}, srid: nil}, nil],
[
2,
nil,
%Geo.Polygon{
coordinates: [
[{30.0, 10.0}, {40.0, 40.0}, {20.0, 40.0}, {10.0, 20.0}, {30.0, 10.0}]
],
properties: %{},
srid: nil
}
]
]
}
```

If you plan on using MyXQL with Ecto, and need to handle user input, you might need to add a [custom Ecto type](https://hexdocs.pm/ecto/Ecto.Type.html
) in order to construct a valid WKT string.

## Contributing

Run tests:
Expand Down
84 changes: 75 additions & 9 deletions lib/myxql/protocol/values.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ defmodule MyXQL.Protocol.Values do
defp column_def_to_type(column_def(type: :mysql_type_string)), do: :binary
defp column_def_to_type(column_def(type: :mysql_type_bit, length: length)), do: {:bit, length}
defp column_def_to_type(column_def(type: :mysql_type_null)), do: :null
defp column_def_to_type(column_def(type: :mysql_type_geometry)), do: :geometry

# Text values

Expand Down Expand Up @@ -171,6 +172,10 @@ defmodule MyXQL.Protocol.Values do
decode_bit(value, size)
end

def decode_text_value(value, :geometry) do
decode_geometry(value)
end

# Binary values

def encode_binary_value(value)
Expand Down Expand Up @@ -216,6 +221,20 @@ defmodule MyXQL.Protocol.Values do
{:mysql_type_tiny, <<0>>}
end

def encode_binary_value(%Geo.Point{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.PointM{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.PointZ{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.PointZM{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.MultiPoint{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.LineString{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.LineStringZ{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.MultiLineString{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.MultiLineStringZ{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.Polygon{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.PolygonZ{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.MultiPolygon{} = geo), do: encode_geometry(geo)
def encode_binary_value(%Geo.MultiPolygonZ{} = geo), do: encode_geometry(geo)

def encode_binary_value(term) when is_list(term) or is_map(term) do
string = json_library().encode!(term)
{:mysql_type_var_string, encode_string_lenenc(string)}
Expand All @@ -225,6 +244,11 @@ defmodule MyXQL.Protocol.Values do
raise ArgumentError, "query has invalid parameter #{inspect(other)}"
end

defp encode_geometry(geo) do
binary = geo |> Geo.WKB.encode!(:ndr) |> Base.decode16!()
{:mysql_type_geometry, encode_string_lenenc(<<0::uint4, binary::binary>>)}
end

## Time/DateTime

# MySQL supports negative time and days, we don't.
Expand Down Expand Up @@ -308,7 +332,7 @@ defmodule MyXQL.Protocol.Values do
end

defp decode_binary_row(<<r::bits>>, null_bitmap, [:binary | t], acc),
do: decode_string_lenenc(r, null_bitmap, t, acc)
do: decode_string_lenenc(r, null_bitmap, t, acc, & &1)

defp decode_binary_row(<<r::bits>>, null_bitmap, [:int1 | t], acc),
do: decode_int1(r, null_bitmap, t, acc)
Expand Down Expand Up @@ -361,10 +385,34 @@ defmodule MyXQL.Protocol.Values do
defp decode_binary_row(<<r::bits>>, null_bitmap, [{:bit, size} | t], acc),
do: decode_bit(r, size, null_bitmap, t, acc)

defp decode_binary_row(<<r::bits>>, null_bitmap, [:geometry | t], acc),
do: decode_string_lenenc(r, null_bitmap, t, acc, &decode_geometry/1)

defp decode_binary_row(<<>>, _null_bitmap, [], acc) do
Enum.reverse(acc)
end

# Geometry
# =======
#
# Here, we use the awesome Geo [1] package to help encode and decode the WKT and WKB data in and out of the db.
# There still is some mistery about the first 32-bit integer, which most likely is the srid, judging.
#
# In the future, it would be nice to completely parse this ourselves, @jeroenbourgois got quite for, but not quite there.
# @wojtekmach then came to the rescue and proposed to use the Geo package, with is a no-dependency drop in.
#
# For future reference, here are some interesting reads about the Well-Known Text (WKT) and Well-Known Binary (WKB) format used to store the geo data, and references to how MySQL stores these internally.
#
# https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry#Well-known_binary
# https://dev.mysql.com/doc/refman/8.0/en/populating-spatial-columns.html
# https://dev.mysql.com/doc/refman/8.0/en/gis-data-formats.html#gis-wkb-format
# https://www.ibm.com/support/knowledgecenter/en/SSEPGG_11.1.0/com.ibm.db2.luw.spatial.topics.doc/doc/rsbp4121.html
#
# Still, all considered, this is a very elegant and readable solution!
defp decode_geometry(<<0::uint4, r::bits>>) do
r |> Base.encode16() |> Geo.WKB.decode!()
end

defp decode_int1(<<v::int1, r::bits>>, null_bitmap, t, acc),
do: decode_binary_row(r, null_bitmap >>> 1, t, [v | acc])

Expand Down Expand Up @@ -510,18 +558,36 @@ defmodule MyXQL.Protocol.Values do
}
end

defp decode_string_lenenc(<<n::uint1, v::string(n), r::bits>>, null_bitmap, t, acc)
defp decode_string_lenenc(<<n::uint1, v::string(n), r::bits>>, null_bitmap, t, acc, decoder)
when n < 251,
do: decode_binary_row(r, null_bitmap >>> 1, t, [v | acc])
do: decode_binary_row(r, null_bitmap >>> 1, t, [decoder.(v) | acc])

defp decode_string_lenenc(<<0xFC, n::uint2, v::string(n), r::bits>>, null_bitmap, t, acc),
do: decode_binary_row(r, null_bitmap >>> 1, t, [v | acc])
defp decode_string_lenenc(
<<0xFC, n::uint2, v::string(n), r::bits>>,
null_bitmap,
t,
acc,
decoder
),
do: decode_binary_row(r, null_bitmap >>> 1, t, [decoder.(v) | acc])

defp decode_string_lenenc(<<0xFD, n::uint3, v::string(n), r::bits>>, null_bitmap, t, acc),
do: decode_binary_row(r, null_bitmap >>> 1, t, [v | acc])
defp decode_string_lenenc(
<<0xFD, n::uint3, v::string(n), r::bits>>,
null_bitmap,
t,
acc,
decoder
),
do: decode_binary_row(r, null_bitmap >>> 1, t, [decoder.(v) | acc])

defp decode_string_lenenc(<<0xFE, n::uint8, v::string(n), r::bits>>, null_bitmap, t, acc),
do: decode_binary_row(r, null_bitmap >>> 1, t, [v | acc])
defp decode_string_lenenc(
<<0xFE, n::uint8, v::string(n), r::bits>>,
null_bitmap,
t,
acc,
decoder
),
do: decode_binary_row(r, null_bitmap >>> 1, t, [decoder.(v) | acc])

defp decode_json(<<n::uint1, v::string(n), r::bits>>, null_bitmap, t, acc) when n < 251,
do: decode_binary_row(r, null_bitmap >>> 1, t, [decode_json(v) | acc])
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ defmodule MyXQL.MixProject do
{:db_connection, "~> 2.0", db_connection_opts()},
{:decimal, "~> 1.6"},
{:jason, "~> 1.0", optional: true},
{:geo, "~> 3.3"},
{:binpp, ">= 0.0.0", only: [:dev, :test]},
{:dialyxir, "~> 1.0-rc", only: :dev, runtime: false},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm"},
"erlex": {:hex, :erlex, "0.2.1", "cee02918660807cbba9a7229cae9b42d1c6143b768c781fa6cee1eaf03ad860b", [:mix], [], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"geo": {:hex, :geo, "3.3.2", "30c7b458bcb0ab1ca73a997b26d22c68643d9ffe1726e475e0759499b6024cff", [:mix], [], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
Expand Down
74 changes: 39 additions & 35 deletions test/myxql/protocol/values_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ defmodule MyXQL.Protocol.ValueTest do
test "CHAR", c do
assert_roundtrip(c, "my_char", "é")
end

test "POINT", c do
assert_roundtrip(c, "my_point", %Geo.Point{coordinates: {1.0, 2.2}})
end

test "MULTIPOINT", c do
assert_roundtrip(c, "my_multipoint", %Geo.MultiPoint{
coordinates: [{1.0, 1.0}, {2.0, 2.0}]
})
end

test "POLYGON", c do
assert_roundtrip(c, "my_polygon", %Geo.Polygon{
coordinates: [[{30, 10}, {40, 40}, {20, 40}, {10, 20}, {30, 10}]]
})
end
end
end

Expand Down Expand Up @@ -352,41 +368,7 @@ defmodule MyXQL.Protocol.ValueTest do

defp insert(%{protocol: :text} = c, fields, values) when is_list(fields) and is_list(values) do
fields = Enum.map_join(fields, ", ", &"`#{&1}`")

values =
Enum.map_join(values, ", ", fn
nil ->
"NULL"

true ->
"TRUE"

false ->
"FALSE"

%DateTime{} = datetime ->
"'#{NaiveDateTime.to_iso8601(datetime)}'"

list when is_list(list) ->
"'#{Jason.encode!(list)}'"

%_{} = struct ->
"'#{struct}'"

map when is_map(map) ->
"'#{Jason.encode!(map)}'"

value when is_binary(value) ->
"'#{value}'"

value when is_bitstring(value) ->
size = bit_size(value)
<<value::size(size)>> = value
"B'#{Integer.to_string(value, 2)}'"

value ->
"'#{value}'"
end)
values = Enum.map_join(values, ", ", &encode_text/1)

%MyXQL.Result{last_insert_id: id} =
query!(c, "INSERT INTO test_types (#{fields}) VALUES (#{values})")
Expand All @@ -407,6 +389,28 @@ defmodule MyXQL.Protocol.ValueTest do
insert(c, [field], [value])
end

defp encode_text(nil), do: "NULL"
defp encode_text(true), do: "TRUE"
defp encode_text(false), do: "FALSE"
defp encode_text(%DateTime{} = datetime), do: "'#{NaiveDateTime.to_iso8601(datetime)}'"
defp encode_text(%Geo.Point{} = geo), do: encode_text_geo(geo)
defp encode_text(%Geo.MultiPoint{} = geo), do: encode_text_geo(geo)
defp encode_text(%Geo.Polygon{} = geo), do: encode_text_geo(geo)
defp encode_text(%_{} = struct), do: "'#{struct}'"
defp encode_text(map) when is_map(map), do: "'#{Jason.encode!(map)}'"
defp encode_text(list) when is_list(list), do: "'#{Jason.encode!(list)}'"
defp encode_text(value) when is_binary(value), do: "'#{value}'"

defp encode_text(value) when is_bitstring(value) do
size = bit_size(value)
<<value::size(size)>> = value
"B'#{Integer.to_string(value, 2)}'"
end

defp encode_text(value), do: "'#{value}'"

defp encode_text_geo(value), do: "ST_GeomFromText('#{Geo.WKT.encode!(value)}')"

defp get(c, fields, id) when is_list(fields) do
fields = Enum.map_join(fields, ", ", &"`#{&1}`")
statement = "SELECT #{fields} FROM test_types WHERE id = '#{id}'"
Expand Down
5 changes: 4 additions & 1 deletion test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ defmodule TestHelper do
my_mediumblob MEDIUMBLOB,
my_longblob LONGBLOB,
#{if supports_json?(), do: "my_json JSON,", else: ""}
my_char CHAR
my_char CHAR,
my_point POINT,
my_multipoint MULTIPOINT,
my_polygon POLYGON
);

DROP PROCEDURE IF EXISTS single_procedure;
Expand Down