Skip to content

Commit

Permalink
Encode spatial types for SearchFilter
Browse files Browse the repository at this point in the history
  • Loading branch information
heaths committed Sep 29, 2020
1 parent 16572ae commit 4ae710c
Show file tree
Hide file tree
Showing 9 changed files with 370 additions and 65 deletions.
70 changes: 9 additions & 61 deletions sdk/search/Azure.Search.Documents/src/SearchFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ public static string Create(FormattableString filter, IFormatProvider formatProv
char x => Quote(x.ToString(formatProvider)),
StringBuilder x => Quote(x.ToString()),

// Microsoft.Spatial types
object x when SpatialProxyFactory.TryCreate(x, out GeometryProxy proxy) => proxy.ToString(),

// Everything else
object x => throw new ArgumentException(
$"Unable to convert argument {i} from type {x.GetType()} to an OData literal.")
Expand Down Expand Up @@ -128,22 +131,8 @@ private static string Quote(string text)
/// </summary>
/// <param name="position">The position.</param>
/// <returns>The OData representation of the position.</returns>
private static string EncodeGeometry(GeoPosition position)
{
const int maxLength =
19 + // "geography'POINT( )'".Length
2 * // Lat and Long each have:
(15 + // Maximum precision for a double (without G17)
1 + // Optional decimal point
1); // Optional negative sign
StringBuilder odata = new StringBuilder(maxLength);
odata.Append("geography'POINT(");
odata.Append(JsonSerialization.Double(position.Longitude, CultureInfo.InvariantCulture));
odata.Append(" ");
odata.Append(JsonSerialization.Double(position.Latitude, CultureInfo.InvariantCulture));
odata.Append(")'");
return odata.ToString();
}
private static string EncodeGeometry(GeoPosition position) =>
SpatialFormatter.EncodePoint(position.Longitude, position.Latitude);

/// <summary>
/// Convert a <see cref="GeoPoint"/> to an OData value.
Expand All @@ -164,39 +153,8 @@ private static string EncodeGeometry(GeoPoint point)
/// </summary>
/// <param name="line">The line forming a polygon.</param>
/// <returns>The OData representation of the line.</returns>
private static string EncodeGeometry(GeoLine line)
{
Argument.AssertNotNull(line, nameof(line));
Argument.AssertNotNull(line.Positions, $"{nameof(line)}.{nameof(line.Positions)}");
if (line.Positions.Count < 4)
{
throw new ArgumentException(
$"A {nameof(GeoLine)} must have at least four {nameof(GeoLine.Positions)} to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Positions)}");
}
else if (line.Positions[0] != line.Positions[line.Positions.Count - 1])
{
throw new ArgumentException(
$"A {nameof(GeoLine)} must have matching first and last {nameof(GeoLine.Positions)} to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Positions)}");
}

Argument.AssertInRange(line.Positions?.Count ?? 0, 4, int.MaxValue, $"{nameof(line)}.{nameof(line.Positions)}");

StringBuilder odata = new StringBuilder();
odata.Append("geography'POLYGON((");
bool first = true;
foreach (GeoPosition position in line.Positions)
{
if (!first) { odata.Append(","); }
first = false;
odata.Append(JsonSerialization.Double(position.Longitude, CultureInfo.InvariantCulture));
odata.Append(" ");
odata.Append(JsonSerialization.Double(position.Latitude, CultureInfo.InvariantCulture));
}
odata.Append("))'");
return odata.ToString();
}
private static string EncodeGeometry(GeoLine line) =>
SpatialFormatter.EncodePolygon(line);

/// <summary>
/// Convert a <see cref="GeoPolygon"/> to an OData value. A
Expand All @@ -205,18 +163,8 @@ private static string EncodeGeometry(GeoLine line)
/// </summary>
/// <param name="polygon">The polygon.</param>
/// <returns>The OData representation of the polygon.</returns>
private static string EncodeGeometry(GeoPolygon polygon)
{
Argument.AssertNotNull(polygon, nameof(polygon));
Argument.AssertNotNull(polygon.Rings, $"{nameof(polygon)}.{nameof(polygon.Rings)}");
if (polygon.Rings.Count != 1)
{
throw new ArgumentException(
$"A {nameof(GeoPolygon)} must have exactly one {nameof(GeoPolygon.Rings)} to form a searchable polygon.",
$"{nameof(polygon)}.{nameof(polygon.Rings)}");
}
return EncodeGeometry(polygon.Rings[0]);
}
private static string EncodeGeometry(GeoPolygon polygon) =>
SpatialFormatter.EncodePolygon(polygon);
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ public GeometryLineStringProxy(object value) : base(value)
ref _points,
nameof(Points),
value => new GeometryPointProxy(value));

/// <inheritdoc/>
public override string ToString() => SpatialFormatter.EncodePolygon(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Azure.Search.Documents
/// <summary>
/// Proxy for a Microsoft.Spatial.GeometryPoint class.
/// </summary>
internal class GeometryPointProxy : GeometryProxy
internal class GeometryPointProxy : GeometryProxy, IEquatable<GeometryPointProxy>
{
private static PropertyInfo s_xProperty;
private static PropertyInfo s_yProperty;
Expand All @@ -32,5 +32,49 @@ public GeometryPointProxy(object value) : base(value)
/// Gets the longitude.
/// </summary>
public double Y => GetPropertyValue<double>(ref s_yProperty, nameof(Y));

/// <summary>
/// Determines whether the <paramref name="left"/> has the same values as the <paramref name="right"/> value.
/// </summary>
/// <param name="left">The first <see cref="GeometryPointProxy"/> to compare.</param>
/// <param name="right">The second <see cref="GeometryPointProxy"/> to compare.</param>
/// <returns><c>true</c> if the <paramref name="left"/> has the same values as the <paramref name="right"/> value; otherwise, <c>false</c>.</returns>
public static bool operator ==(GeometryPointProxy left, GeometryPointProxy right)
{
if (left is null)
{
return right is null;
}

return left.Equals(right);
}

/// <summary>
/// Determines whether the <paramref name="left"/> has the same values as the <paramref name="right"/> value.
/// </summary>
/// <param name="left">The first <see cref="GeometryPointProxy"/> to compare.</param>
/// <param name="right">The second <see cref="GeometryPointProxy"/> to compare.</param>
/// <returns><c>true</c> if the <paramref name="left"/> has the same values as the <paramref name="right"/> value; otherwise, <c>false</c>.</returns>
public static bool operator !=(GeometryPointProxy left, GeometryPointProxy right)
{
if (left is null)
{
return right is { };
}

return !left.Equals(right);
}

/// <inheritdoc/>
public bool Equals(GeometryPointProxy other) => Value.Equals(other?.Value);

/// <inheritdoc/>
public override bool Equals(object obj) => Equals(obj as GeometryPointProxy);

/// <inheritdoc/>
public override int GetHashCode() => Value?.GetHashCode() ?? 0;

/// <inheritdoc/>
public override string ToString() => SpatialFormatter.EncodePoint(Y, X);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ public GeometryPolygonProxy(object value) : base(value)
ref _rings,
nameof(Rings),
value => new GeometryLineStringProxy(value));

/// <inheritdoc/>
public override string ToString() => SpatialFormatter.EncodePolygon(this);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ public GeometryPositionProxy(object value) : base(value)
/// Gets the longitude.
/// </summary>
public double Y => GetPropertyValue<double>(ref s_yProperty, nameof(Y));

/// <inheritdoc/>
public override string ToString() => SpatialFormatter.EncodePoint(Y, X);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ public GeometryProxy(object value)
/// </summary>
public object Value { get; }

/// <summary>
/// Returns an OData filter representation of the underlying Microsoft.Spatial.Geometry object.
/// </summary>
/// <returns></returns>
public abstract override string ToString();

/// <summary>
/// Gets the value of the named property.
/// </summary>
Expand Down
189 changes: 189 additions & 0 deletions sdk/search/Azure.Search.Documents/src/Spatial/SpatialFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Globalization;
using System.Text;
using Azure.Core;
using Azure.Core.GeoJson;

namespace Azure.Search.Documents
{
/// <summary>
/// Encodes geometry from both Azure.Core and Microsoft.Spatial for use in OData filters.
/// </summary>
internal static class SpatialFormatter
{
/// <summary>
/// Encodes the longitude and latitude of points or positions for use in OData filters.
/// </summary>
/// <param name="longitude">The longitude to encode, which may also be known as Y.</param>
/// <param name="latitude">The latitude to encode, which may also be known as X.</param>
/// <returns>The OData filter-encoded POINT string.</returns>
public static string EncodePoint(double longitude, double latitude)
{
const int maxLength =
19 + // "geography'POINT( )'".Length
2 * // Lat and Long each have:
(15 + // Maximum precision for a double (without G17)
1 + // Optional decimal point
1); // Optional negative sign

return new StringBuilder(maxLength)
.Append("geography'POINT(")
.Append(JsonSerialization.Double(longitude, CultureInfo.InvariantCulture))
.Append(" ")
.Append(JsonSerialization.Double(latitude, CultureInfo.InvariantCulture))
.Append(")'")
.ToString();
}

// Support for both Azure.Core.GeoJson and Microsoft.Spatial encoding are duplicated
// below to avoid extraneous allocations for adapters and to consolidate them to a single
// source file for easier maintenance.

#if EXPERIMENTAL_SPATIAL
/// <summary>
/// Encodes a polygon for use in OData filters.
/// </summary>
/// <param name="line">The <see cref="GeoLine"/> to encode.</param>
/// <returns>The OData filter-encoded POLYGON string.</returns>
/// <exception cref="ArgumentException">The <paramref name="line"/> has fewer than 4 points, or the first and last points do not match.</exception>
/// <exception cref="ArgumentNullException"><paramref name="line"/> or <see cref="GeoLine.Positions"/> is null.</exception>
public static string EncodePolygon(GeoLine line)
{
Argument.AssertNotNull(line, nameof(line));
Argument.AssertNotNull(line.Positions, $"{nameof(line)}.{nameof(line.Positions)}");

if (line.Positions.Count < 4)
{
throw new ArgumentException(
$"A {nameof(GeoLine)} must have at least four {nameof(GeoLine.Positions)} to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Positions)}");
}
else if (line.Positions[0] != line.Positions[line.Positions.Count - 1])
{
throw new ArgumentException(
$"A {nameof(GeoLine)} must have matching first and last {nameof(GeoLine.Positions)} to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Positions)}");
}

StringBuilder odata = new StringBuilder("geography'POLYGON((");

bool first = true;
foreach (GeoPosition position in line.Positions)
{
if (!first)
{
odata.Append(",");
}
else
{
first = false;
}

odata.Append(JsonSerialization.Double(position.Longitude, CultureInfo.InvariantCulture))
.Append(" ")
.Append(JsonSerialization.Double(position.Latitude, CultureInfo.InvariantCulture));
}

return odata
.Append("))'")
.ToString();
}

/// <summary>
/// Encodes a polygon for use in OData filters.
/// <seealso cref="EncodePolygon(GeoLine)"/>
/// </summary>
/// <param name="polygon">The <see cref="GeoPolygon"/> to encode.</param>
/// <returns>The OData filter-encoded POLYGON string.</returns>
/// <exception cref="ArgumentNullException"><paramref name="polygon"/> or <see cref="GeoPolygon.Rings"/> is null.</exception>
public static string EncodePolygon(GeoPolygon polygon)
{
Argument.AssertNotNull(polygon, nameof(polygon));
Argument.AssertNotNull(polygon.Rings, $"{nameof(polygon)}.{nameof(polygon.Rings)}");

if (polygon.Rings.Count != 1)
{
throw new ArgumentException(
$"A {nameof(GeoPolygon)} must have exactly one {nameof(GeoPolygon.Rings)} to form a searchable polygon.",
$"{nameof(polygon)}.{nameof(polygon.Rings)}");
}

return EncodePolygon(polygon.Rings[0]);
}
#endif

/// <summary>
/// Encodes a polygon for use in OData filters.
/// </summary>
/// <param name="line">The <see cref="GeometryLineStringProxy"/> to encode.</param>
/// <returns>The OData filter-encoded POLYGON string.</returns>
/// <exception cref="ArgumentException">The <paramref name="line"/> has fewer than 4 points, or the first and last points do not match.</exception>
/// <exception cref="ArgumentNullException"><paramref name="line"/> or <see cref="GeometryLineStringProxy.Points"/> is null.</exception>
public static string EncodePolygon(GeometryLineStringProxy line)
{
Argument.AssertNotNull(line, nameof(line));
Argument.AssertNotNull(line.Points, $"{nameof(line)}.{nameof(line.Points)}");

if (line.Points.Count < 4)
{
throw new ArgumentException(
$"A GeometryLineString must have at least four Points to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Points)}");
}
else if (line.Points[0] != line.Points[line.Points.Count - 1])
{
throw new ArgumentException(
$"A GeometryLineString must have matching first and last Points to form a searchable polygon.",
$"{nameof(line)}.{nameof(line.Points)}");
}

StringBuilder odata = new StringBuilder("geography'POLYGON((");

bool first = true;
foreach (GeometryPointProxy point in line.Points)
{
if (!first)
{
odata.Append(",");
}
else
{
first = false;
}

odata.Append(JsonSerialization.Double(point.Y, CultureInfo.InvariantCulture))
.Append(" ")
.Append(JsonSerialization.Double(point.X, CultureInfo.InvariantCulture));
}

return odata
.Append("))'")
.ToString();
}

/// <summary>
/// Encodes a polygon for use in OData filters.
/// <seealso cref="EncodePolygon(GeometryLineStringProxy)"/>
/// </summary>
/// <param name="polygon">The <see cref="GeometryPolygonProxy"/> to encode.</param>
/// <returns>The OData filter-encoded POLYGON string.</returns>
/// <exception cref="ArgumentNullException"><paramref name="polygon"/> or <see cref="GeometryPolygonProxy.Rings"/> is null.</exception>
public static string EncodePolygon(GeometryPolygonProxy polygon)
{
Argument.AssertNotNull(polygon, nameof(polygon));
Argument.AssertNotNull(polygon.Rings, $"{nameof(polygon)}.{nameof(polygon.Rings)}");

if (polygon.Rings.Count != 1)
{
throw new ArgumentException(
$"A GeometryPolygon must have exactly one Rings to form a searchable polygon.",
$"{nameof(polygon)}.{nameof(polygon.Rings)}");
}

return EncodePolygon(polygon.Rings[0]);
}
}
}
Loading

0 comments on commit 4ae710c

Please sign in to comment.