Browse Source

Improve content id serializer (#1068)

* Improve content id serializer

* Fix serializer

* Fix serializer again.
pull/1069/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
105bcdbe4c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 159
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.IdInfo.cs
  2. 121
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs
  3. 58
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs

159
backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.IdInfo.cs

@ -0,0 +1,159 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.MongoDb;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
public partial class BsonUniqueContentIdSerializer
{
private readonly record struct IdInfo(int Length, bool IsGuid, Guid AsGuid, string Source)
{
public const byte GuidLength = 16;
public const byte GuidIndicator = byte.MaxValue;
public const byte LongIdIndicator = byte.MaxValue - 1;
public const byte SizeOfInt = 4;
public const byte SizeOfByte = 1;
public bool IsEmpty => IsGuid && AsGuid == default;
public static IdInfo Create(DomainId id)
{
var source = id.ToString();
if (Guid.TryParse(source, out var guid))
{
return new IdInfo(GuidLength, true, guid, source);
}
return new IdInfo(Encoding.UTF8.GetByteCount(source), false, default, source);
}
public int Size(bool writeEmpty)
{
if (IsEmpty && !writeEmpty)
{
return 0;
}
if (Length >= LongIdIndicator)
{
return SizeOfByte + SizeOfInt + Length;
}
return SizeOfByte + Length;
}
public int Write(Span<byte> buffer)
{
if (Length >= LongIdIndicator)
{
buffer[0] = LongIdIndicator;
Write(buffer[1..], WriteLengthAsInt);
}
else
{
Write(buffer, WriteLengthAsByte);
}
return Size(false);
}
private int Write(Span<byte> buffer, WriteLength writeLength)
{
int lengthSize;
if (IsGuid)
{
// Special length indicator for all guids.
lengthSize = writeLength(buffer, GuidIndicator);
AsGuid.TryWriteBytes(buffer[lengthSize..]);
}
else
{
// We assume that we use relatively small IDs, not longer than 253 bytes.
lengthSize = writeLength(buffer, Length);
Encoding.UTF8.GetBytes(Source, buffer[lengthSize..]);
}
return lengthSize + Length;
}
public static (DomainId Id, int Length) Read(ReadOnlySpan<byte> buffer)
{
if (buffer.Length == 0)
{
return default;
}
if (buffer[0] == LongIdIndicator)
{
var (id, read) = Read(buffer[1..], ReadLengthAsInt);
return (id, read + 1);
}
else
{
return Read(buffer, ReadLengthAsByte);
}
}
private static (DomainId Id, int Length) Read(ReadOnlySpan<byte> buffer, ReadLength readLength)
{
var (length, offset) = readLength(buffer);
if (length == GuidIndicator)
{
// For guids the size is just an indicator and we use a hardcoded size.
buffer = buffer.Slice(offset, GuidLength);
return (DomainId.Create(new Guid(buffer)), offset + GuidLength);
}
else
{
// For strings the size is correct.
buffer = buffer.Slice(offset, length);
return (DomainId.Create(Encoding.UTF8.GetString(buffer)), offset + length);
}
}
private static int WriteLengthAsByte(Span<byte> buffer, int length)
{
buffer[0] = (byte)length;
return SizeOfByte;
}
private static int WriteLengthAsInt(Span<byte> buffer, int length)
{
BitConverter.TryWriteBytes(buffer, length);
return SizeOfInt;
}
private static (int, int) ReadLengthAsByte(ReadOnlySpan<byte> buffer)
{
return (buffer[0], SizeOfByte);
}
private static (int, int) ReadLengthAsInt(ReadOnlySpan<byte> buffer)
{
return (BitConverter.ToInt32(buffer), SizeOfInt);
}
private delegate int WriteLength(Span<byte> buffer, int length);
private delegate (int, int) ReadLength(ReadOnlySpan<byte> buffer);
}
}

121
backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs

@ -13,9 +13,8 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb;
public sealed class BsonUniqueContentIdSerializer : SerializerBase<UniqueContentId> public partial class BsonUniqueContentIdSerializer : SerializerBase<UniqueContentId>
{ {
private const byte GuidLength = 16;
private static readonly BsonUniqueContentIdSerializer Instance = new BsonUniqueContentIdSerializer(); private static readonly BsonUniqueContentIdSerializer Instance = new BsonUniqueContentIdSerializer();
public static void Register() public static void Register()
@ -23,10 +22,6 @@ public sealed class BsonUniqueContentIdSerializer : SerializerBase<UniqueContent
BsonSerializer.TryRegisterSerializer(Instance); BsonSerializer.TryRegisterSerializer(Instance);
} }
private BsonUniqueContentIdSerializer()
{
}
public static UniqueContentId NextAppId(DomainId appId) public static UniqueContentId NextAppId(DomainId appId)
{ {
static void IncrementByteArray(byte[] bytes) static void IncrementByteArray(byte[] bytes)
@ -63,121 +58,37 @@ public sealed class BsonUniqueContentIdSerializer : SerializerBase<UniqueContent
public override UniqueContentId Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) public override UniqueContentId Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{ {
var buffer = context.Reader.ReadBytes()!; var buffer = context.Reader.ReadBytes()!.AsSpan();
var offset = 0;
static DomainId ReadId(byte[] buffer, ref int offset)
{
DomainId id;
// If we have reached the end of the buffer then
if (offset >= buffer.Length)
{
return default;
}
var length = buffer[offset++];
// Special length indicator for all guids.
if (length == 0xFF)
{
id = DomainId.Create(new Guid(buffer.AsSpan(offset, GuidLength)));
offset += GuidLength;
}
else
{
id = DomainId.Create(Encoding.UTF8.GetString(buffer.AsSpan(offset, length)));
offset += length;
}
return id; var (appId, read) = IdInfo.Read(buffer);
}
return new UniqueContentId(ReadId(buffer, ref offset), ReadId(buffer, ref offset)); return new UniqueContentId(appId, IdInfo.Read(buffer[read..]).Id);
} }
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, UniqueContentId value) public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, UniqueContentId value)
{ {
var appId = CheckId(value.AppId); var appId = IdInfo.Create(value.AppId);
var contentId = CheckId(value.ContentId);
var isEmptyContentId = if (appId.Length >= IdInfo.LongIdIndicator)
contentId.IsGuid &&
contentId.Guid == default;
// Do not write empty Ids to the buffer to allow prefix searches.
var contentLength = !isEmptyContentId ? contentId.Length + 1 : 0;
var bufferLength = appId.Length + 1 + contentLength;
var bufferArray = new byte[bufferLength];
var offset = Write(bufferArray, 0,
appId.IsGuid,
appId.Guid,
appId.Source,
appId.Length);
if (!isEmptyContentId)
{ {
// Do not write the empty content id, so we can search for app as well. ThrowHelper.InvalidOperationException("App ID cannot be longer than 253 bytes.");
Write(bufferArray, offset,
contentId.IsGuid,
contentId.Guid,
contentId.Source,
contentId.Length);
} }
static int Write(byte[] buffer, int offset, bool isGuid, Guid guid, string id, int idLength) var contentId = IdInfo.Create(value.ContentId);
{
if (isGuid)
{
// Special length indicator for all guids.
buffer[offset++] = 0xFF;
WriteGuid(buffer.AsSpan(offset), guid);
return offset + GuidLength;
}
else
{
// We assume that we use relatively small IDs, not longer than 254 bytes.
buffer[offset++] = (byte)idLength;
WriteString(buffer.AsSpan(offset), id);
return offset + idLength;
}
}
context.Writer.WriteBytes(bufferArray); var size = appId.Size(true) + contentId.Size(false);
}
private static (int Length, bool IsGuid, Guid Guid, string Source) CheckId(DomainId id) var bufferArray = new byte[size];
{ var bufferSpan = bufferArray.AsSpan();
var source = id.ToString();
var idIsGuid = Guid.TryParse(source, out var idGuid); var written = appId.Write(bufferSpan);
var idLength = GuidLength;
if (!idIsGuid) if (!contentId.IsEmpty)
{ {
idLength = (byte)Encoding.UTF8.GetByteCount(source); // Do not write empty Ids to the buffer to allow prefix searches.
contentId.Write(bufferSpan[written..]);
// We only use a single byte to write the length, therefore we do not allow large strings.
if (idLength > 254)
{
ThrowHelper.InvalidOperationException("Cannot write long IDs.");
}
} }
return (idLength, idIsGuid, idGuid, source); context.Writer.WriteBytes(bufferArray);
}
private static void WriteString(Span<byte> span, string id)
{
Encoding.UTF8.GetBytes(id, span);
}
private static void WriteGuid(Span<byte> span, Guid guid)
{
guid.TryWriteBytes(span);
} }
} }

58
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using MongoDB.Bson.Serialization.Attributes;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.MongoDb; using Squidex.Domain.Apps.Entities.MongoDb;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -18,6 +19,14 @@ public class BsonUniqueContentIdSerializerTests
BsonUniqueContentIdSerializer.Register(); BsonUniqueContentIdSerializer.Register();
} }
public static readonly TheoryData<string> CustomIds = new TheoryData<string>
{
"id",
"id-short",
"id-and-guid-size",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
};
[Fact] [Fact]
public void Should_serialize_and_deserialize_guid_guid() public void Should_serialize_and_deserialize_guid_guid()
{ {
@ -28,10 +37,11 @@ public class BsonUniqueContentIdSerializerTests
Assert.Equal(source, deserialized); Assert.Equal(source, deserialized);
} }
[Fact] [Theory]
public void Should_serialize_and_deserialize_guid_custom() [MemberData(nameof(CustomIds))]
public void Should_serialize_and_deserialize_guid_custom(string id)
{ {
var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create("id42")); var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create(id));
var deserialized = source.SerializeAndDeserializeBson(); var deserialized = source.SerializeAndDeserializeBson();
@ -48,10 +58,22 @@ public class BsonUniqueContentIdSerializerTests
Assert.Equal(source, deserialized); Assert.Equal(source, deserialized);
} }
[Fact] [Theory]
public void Should_serialize_and_deserialize_custom_custom() [MemberData(nameof(CustomIds))]
public void Should_serialize_and_deserialize_custom_custom(string id)
{
var source = new UniqueContentId(DomainId.Create(id), DomainId.Create(id));
var deserialized = source.SerializeAndDeserializeBson();
Assert.Equal(source, deserialized);
}
[Theory]
[MemberData(nameof(CustomIds))]
public void Should_serialize_and_deserialize_custom_guid(string id)
{ {
var source = new UniqueContentId(DomainId.Create("id41"), DomainId.Create("id42")); var source = new UniqueContentId(DomainId.Create(id), DomainId.NewGuid());
var deserialized = source.SerializeAndDeserializeBson(); var deserialized = source.SerializeAndDeserializeBson();
@ -59,15 +81,35 @@ public class BsonUniqueContentIdSerializerTests
} }
[Fact] [Fact]
public void Should_serialize_and_deserialize_custom_guid() public void Should_serialize_very_long_content_id()
{ {
var source = new UniqueContentId(DomainId.Create("id42"), DomainId.NewGuid()); var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create(new string('x', 512)));
var deserialized = source.SerializeAndDeserializeBson(); var deserialized = source.SerializeAndDeserializeBson();
Assert.Equal(source, deserialized); Assert.Equal(source, deserialized);
} }
[Fact]
public void Should_not_serialize_very_long_app_id()
{
var source = new UniqueContentId(DomainId.Create(new string('x', 512)), DomainId.NewGuid());
var exception = Assert.ThrowsAny<Exception>(() => source.SerializeAndDeserializeBson());
Assert.Contains("App ID cannot be longer than 253 bytes.", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Should_not_serialize_long_app_id()
{
var source = new UniqueContentId(DomainId.Create(new string('x', 512)), DomainId.NewGuid());
var exception = Assert.ThrowsAny<Exception>(() => source.SerializeAndDeserializeBson());
Assert.Contains("App ID cannot be longer than 253 bytes.", exception.Message, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Should_calculate_next_custom_id() public void Should_calculate_next_custom_id()
{ {

Loading…
Cancel
Save