Code at the end...
My solution is to implement a value conversion like Microsoft documentation: https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions?tabs=data-annotations
Any solution related to Geometry of geography were rejected because I do not need to search for any element of the array independently. We do not want to add Database package in order to keep things simple. For our cases, where we only want to save a struct of Points, we prefer to keep a more easy solution to implement and maintain.
After pretty much a lot of work and tests, I got a pretty nice solution, I think. Efficient in speed and size. It is also pretty much flexible supporting any struct type arrays or simple type arrays. The only downside, like 'Panagiotis Kanavos' highlighted is saving data in binary format is less flexible than Json where Json seems to be the most popular choice theses days. But I choose binary for speed and space.
About the following SO question (related): How to store double[] array to database with Entity Framework Code-First approach: ... I found some negative aspect on the accepted answer (Jonas answer) and few others answers too:
The addition of a "fake" property (only for persistence to the database) seems to me as a last resort. Adding a property only for persistence to the DB add code which could make hard to understand why it is there and prone to error. My opinion is that is should be avoided when possible. Because EFCore does enable us to do conversion at its level, I prefer to do so. The code stay cleaner.
Also some peoples pointed out about conversion that should occur on each access to the property if it is not cached. See other comment on the same previous link. Very important to do caching when using an additional property (or field).
There is also a memory issue when using string split on very large array where it creates a string object for each and every values. When your array have many thousands of points, it is preferable to avoid "string.split". So split should be avoided if possible and use of Span is highly recommanded (at least I think). Span<T> creates lots less temporary objects.
Stats of my solution (binary) vs Json (Microsoft Json serializer) vs string (values concatenation/less flexible - the way proposed in the accepted answer of the previous link):
Result for saving array of struct Point in SqServer database
Release build,
Each result is the average of 4 test results,
SqlServer 2019
Binary
Time to save 1000000 points: 1727 ms
Time to load 1000000 points: 1611 ms
Time to save 1000000 points: 600 ms
Time to load 1000000 points: 320 ms
Time to save 1000000 points: 470 ms
Time to load 1000000 points: 261 ms
Time to save 1000000 points: 330 ms
Time to load 1000000 points: 245 ms
size = 16 000 000 in DB
Json
Time to save 1000000 points: 3883 ms
Time to load 1000000 points: 4149 ms
Time to save 1000000 points: 2702 ms
Time to load 1000000 points: 2294 ms
Time to save 1000000 points: 2716 ms
Time to load 1000000 points: 1877 ms
Time to save 1000000 points: 2320 ms
Time to load 1000000 points: 3357 ms
size = ~48 540 581
String concatenation
Time to save 1000000 points: 3295 ms
Time to load 1000000 points: 1713 ms
Time to save 1000000 points: 1801 ms
Time to load 1000000 points: 1981 ms
Time to save 1000000 points: 1711 ms
Time to load 1000000 points: 1445 ms
Time to save 1000000 points: 1765 ms
Time to load 1000000 points: 1329 ms
size = ~38 538 490

Code:
DbContext
public class DbContextAlgo : DbContext
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
configurationBuilder.Properties<double[]>()
.HaveConversion<DoubleArrayConverter, ArrayStructuralComparer<double>>();
base.ConfigureConventions(configurationBuilder);
configurationBuilder.Properties<SamplePoint[]>()
.HaveConversion<StructArrayToByteArrayConverter<SamplePoint>, ArrayStructuralComparer<SamplePoint>>();
//base.ConfigureConventions(configurationBuilder);
//configurationBuilder.Properties<SamplePoint[]>()
// .HaveConversion<StructArrayToJsonConverter<SamplePoint>, ArrayStructuralComparer<SamplePoint>>();
//base.ConfigureConventions(configurationBuilder);
//configurationBuilder.Properties<SamplePoint[]>()
// .HaveConversion<SamplePointArrayToStringConverter, ArrayStructuralComparer<SamplePoint>>();
}
...
StructArrayConverter
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Reflection.Metadata.Ecma335; // Required for MemoryMarshal and StructLayout
namespace General.Converter
{
public static class StructArrayConverter<T> where T : struct
{
public static byte[] StructArrayToByteArray_MemoryMarshal(T[] points)
{
if (points == null || points.Length == 0)
{
return Array.Empty<byte>();
}
// Get a read-only span of bytes representing the points array's memory.
// This operation itself is very fast as it doesn't copy memory.
ReadOnlySpan<byte> byteSpan = MemoryMarshal.AsBytes<T>(points.AsSpan());
// If you need a byte[], you then copy the span.
return byteSpan.ToArray();
}
public static T[] ByteArrayToStructArray_MemoryMarshal(byte[] byteArray)
{
if (byteArray == null || byteArray.Length == 0)
{
return Array.Empty<T>();
}
// Get a span of MyPoint reinterpreting the byte array's memory.
Span<T> pointSpan = MemoryMarshal.Cast<byte, T>(byteArray.AsSpan());
return pointSpan.ToArray();
}
public static string StructArrayToJson(T[] points)
{
return JsonSerializer.Serialize<T[]>(points);
}
public static T[] JsonToStructArray(string json)
{
return JsonSerializer.Deserialize<T[]>(json) ?? Array.Empty<T>();
}
}
}
StructArrayToByteArrayConverter.cs
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace GeneralDb.Converter
{
/// <summary>
/// Converter for converting a struct array to a byte array and vice versa. Used for SqlServer and others.
/// </summary>
/// <typeparam name="T">T is the struct type without array</typeparam>
public class StructArrayToByteArrayConverter<T> : ValueConverter<T[], byte[]> where T : struct
{
public StructArrayToByteArrayConverter() : base(v => ConvertStructArrayToByteArray(v), v => ConvertByteArrayToStructArray(v), true) { }
public static byte[] ConvertStructArrayToByteArray(T[] points)
{
return General.Converter.StructArrayConverter<T>.StructArrayToByteArray_MemoryMarshal(points);
}
public static T[] ConvertByteArrayToStructArray(byte[] byteArray)
{
return General.Converter.StructArrayConverter<T>.ByteArrayToStructArray_MemoryMarshal(byteArray);
}
}
}
StructArrayToJsonConverter.cs
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace GeneralDb.Converter
{
/// <summary>
/// This class is used to convert a struct array to a JSON string and vice versa. Used for SqlServer and others.
/// </summary>
/// <typeparam name="T">T is the struct type without array</typeparam>
public class StructArrayToJsonConverter<T> : ValueConverter<T[], string> where T : struct
{
public StructArrayToJsonConverter() : base(v => ConvertStructArrayToJson(v), v => ConvertJsonToStructArray(v), true) { }
public static string ConvertStructArrayToJson(T[] points)
{
return General.Converter.StructArrayConverter<T>.StructArrayToJson(points);
}
public static T[] ConvertJsonToStructArray(string json)
{
return General.Converter.StructArrayConverter<T>.JsonToStructArray(json);
}
}
}
SamplePointArrayToStringConverter.cs
using General.Collection;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Standard;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GeneralDb.Converter
{
/// <summary>
/// This class is used to convert a struct array to a JSON string and vice versa. Used for SqlServer and others.
/// </summary>
/// <typeparam name="T">T is the struct type without array</typeparam>
public class SamplePointArrayToStringConverter : ValueConverter<SamplePoint[], string>
{
public SamplePointArrayToStringConverter() : base(v => ConvertSamplePointArrayToString(v), v => ConvertStringToSamplePointArray(v), true) { }
public static string ConvertSamplePointArrayToString(SamplePoint[] points)
{
StringBuilder sb = new StringBuilder();
sb.Append(points.Length);
sb.Append(';');
foreach (SamplePoint point in points)
{
sb.Append(point.X);
sb.Append(';');
sb.Append(point.Y);
sb.Append(';');
}
return sb.ToString();
}
public static SamplePoint[] ConvertStringToSamplePointArray(string str)
{
try
{
ReadOnlySpan<char> span = str.AsSpan();
int index = span.IndexOf(';');
if (index == -1)
{
return default;
}
int length;
int indexStart = 0;
int indexEnd = span.IndexOf(';');
var part = span.Slice(indexStart, indexEnd - indexStart);
if (!int.TryParse(part, out length))
{
throw new InvalidDataException("Invalid string format");
}
int count = 0;
if (count == length)
{
return default;
}
SamplePoint[] samplePoints = new SamplePoint[length];
while(count < length)
{
indexStart = indexEnd + 1;
indexEnd = span.GetNextIndex(';', indexStart);
part = span.Slice(indexStart, indexEnd - indexStart);
double x = double.Parse(part);
indexStart = indexEnd + 1;
indexEnd = span.GetNextIndex(';', indexStart);
part = span.Slice(indexStart, indexEnd - indexStart);
double y = double.Parse(part);
samplePoints[count] = new SamplePoint(x, y);
count++;
}
return samplePoints;
}
catch (Exception ex)
{
Debug.WriteLine("Ooops");
throw new InvalidDataException("Invalid string format");
}
}
}
}
SpanExtension.cs
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace General.Collection
{
public static class SpanExtension
{
/// <summary>
/// Search for the first occurence of item and return it's index in the span
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="span">span source searched</param>
/// <param name="item">item searched in the span</param>
/// <param name="index">starting index where the search begin</param>
/// <returns>index of the first occurence found or -1 if not found</returns>
public static int GetNextIndex<T>(this ReadOnlySpan<T> span, T item, int index = 0)
{
if (index < 0)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index cannot be negative");
}
while(index < span.Length)
{
if (span[index].Equals(item))
{
return index;
}
index++;
}
return -1;
}
/// <summary>
/// Search for the first occurence of item and return it's index in the span
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="span">span source searched</param>
/// <param name="item">item searched in the span</param>
/// <param name="index">starting index where the search begin</param>
/// <returns>index of the first occurence found or -1 if not found</returns>
public static int GetNextIndex<T>(this Span<T> span, T item, int index = 0)
{
if (index < 0)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index cannot be negative");
}
while (index < span.Length)
{
if (span[index].Equals(item))
{
return index;
}
index++;
}
return -1;
}
}
}
My tests
private void OnCommandTest()
{
const int PointCount = 1000000;
Random rand = new Random();
TestSamplePointArray t = new TestSamplePointArray();
Guid id = default;
// EO: test Database SamplePoint[]
using (var ctx = new DbContextAlgo())
{
t.SamplePoints = new SamplePoint[PointCount];
foreach(var index in Enumerable.Range(0, PointCount))
{
t.SamplePoints[index] = new SamplePoint(rand.NextDouble(), rand.NextDouble());
}
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
ctx.Add(t);
ctx.SaveChanges();
stopwatch.Stop();
id = t.TestSamplePointArrayId;
Trace.WriteLine($"Time to save {PointCount} points: {stopwatch.ElapsedMilliseconds} ms");
}
using (var ctx = new DbContextAlgo())
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var t2 = ctx.TestSamplePointArrays.Find(id);
stopwatch.Stop();
Debug.Assert(t.SamplePoints != null);
Trace.WriteLine($"Time to load {t.SamplePoints.Length} points: {stopwatch.ElapsedMilliseconds} ms");
if (t2 != null)
{
for (int index = 0; index < t2.SamplePoints.Length; index++)
{
Debug.Assert(t2.SamplePoints[index].Equals(t.SamplePoints[index]));
}
}
}
}
Map_Point (MapId, X, Y, PRIMARY KEY (MapId, X, Y))Or have you considered using theGeographytype learn.microsoft.com/en-us/ef/core/modeling/spatial Do you have a polygon shape, or is it just a list of points?geometryorgeographytype.varbinary(max)column, using the WKBWriter and WKBReader classes. All spatial libraries can read that format. Although just using EF Core+NetTopologySuite would take care of everything automatically