Generic (phantom) strongly-typed ID class

140 views
Skip to first unread message

Ivan Montilla

unread,
Aug 29, 2019, 2:03:05 AM8/29/19
to RavenDB - 2nd generation document database
Hi group, this is my first publication here.

I started with RavenDB a month ago, and I really love it, for me is (probably) the best database software.

When I started reading about RavenDB, I noticed that all Id must be a string. In the past, using NHibernate I was modeling my entities using a phantom type for Ids and I would use this approach with RavenDB.


Let's start with this ID class:
public class Id<TEntity> where TEntity : class
{
    public string Value { get; }

    public Id(string value)
    {
        Value = value;
    }
}

And this is my example entity:
public class Person
{
    public Id<Person> Id { get; set; }

    public string Name { get; set; }
    public int Age { get; set; }

    public bool IsInLegalAge => Age > 18;
    
    public Id<Person> FatherId { get; set; }
    public Id<Person> MotherId { get; set; }
}

An this is the entity saved into DB:
ID: people/737-A 
{
    "Name": "Ivan",
    "Age": 23,
    "FatherId": "people/705-A",
    "MotherId": "people/673-A",
    "Id": "people/737-A",
    "@metadata": {
        "@collection": "People",
        "Raven-Clr-Type": "Tryouts.Raven.Entities.Person, Tryout.Raven"
    }
}
Note that FatherId and MotherId are one-to-many relationships, in this case, to the same entity, but can be a relationship to other entity without any problem.

First of all, we need to tell the JsonSerializer that any Id<> type must be serialized as string, so I wrote this converter class and added to the serializer by RavenDB conventions:
public class IdConverter : JsonConverter
{
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var idString = (string) value.GetType().GetProperty("Value").GetValue(value);
        
        writer.WriteValue(idString);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var idString = (string) reader.Value;

        var ctor = objectType.GetConstructor(new[] { typeof(string) });
        return ctor?.Invoke(new object[] {idString});
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Id<>);
    }
}

Then, we need to modify the conventions to tell the document store use default ID generator and find ID in the default way in all entities that don't have Id property typed with Id<>:
var defaultDocumentIdGenerator = documentStore.Conventions.AsyncDocumentIdGenerator;
documentStore.Conventions.AsyncDocumentIdGenerator = async (dbName, entity) =>
{
    var entityIdentifierProperty = entity.GetType().GetProperty("Id");
    var entityIdentifierPropertyType = entityIdentifierProperty?.PropertyType;

    if (entityIdentifierProperty is null || entityIdentifierPropertyType != typeof(Id<>).MakeGenericType(entity.GetType()))
        return await defaultDocumentIdGenerator(dbName, entity);

    // Notice here that we are creating a new HiLo generator per entity, so we are receiving a new rank per entity.
    // The ideal is get the HiLo generator from the document store, but is private ;(
    var hiLoGenerator = new AsyncMultiDatabaseHiLoIdGenerator(documentStore, documentStore.Conventions);
    var generatedId = await hiLoGenerator.GenerateDocumentIdAsync(dbName, entity);
    
    entityIdentifierProperty.SetValue(entity,
        entityIdentifierPropertyType.GetConstructor(new []{typeof(string)})?.Invoke(new object[]{generatedId}));

    return generatedId;
};

var defaultIdentityProperty = documentStore.Conventions.FindIdentityProperty;
documentStore.Conventions.FindIdentityProperty = info =>
{
    var entityType = info.DeclaringType;
    var entityIdentifierProperty = entityType?.GetProperty("Id");
    var entityIdentifierPropertyType = entityIdentifierProperty?.PropertyType;
    
    if (entityIdentifierPropertyType is null || entityIdentifierPropertyType == typeof(Id<>).MakeGenericType(entityType))
        return defaultIdentityProperty(info);
    
    return false;
};

Thats all, there are a problem with the HiLo generator because we instance a new generator per entity, so we get a new rank per entity. The ideal solution is use the same generator that holds the document store, but it's private and I cannot access to it. I'm not sure how I can solve it. If someone have idea, please reply ;)

Let's try it:
var sophia = new Person
{
    Age = 50,
    Name = "SOPHIA",
};

var john = new Person
{
    Age = 53,
    Name = "JOHN"
};

// We store here the parents because we need that
// the sophia and john instances have Id assigned.
await session.StoreAsync(sophia);
await session.StoreAsync(john);

var ivan = new Person
{
    Age = 23,
    Name = "IVAN",
    FatherId = john.Id,
    MotherId = sophia.Id
};

await session.StoreAsync(ivan);
await session.SaveChangesAsync();

And this is the data inserted into DB:

RavenPeople.PNG


We can query to try it:

var ivan = await session.Query<Person>()
    .Where(x => x.Id == new Id<Person>("people/193-A"), true)
    .Include(x => x.FatherId)
    .Include(x => x.MotherId)
    .FirstAsync();

var sophia = await session.LoadAsync<Person>(ivan.MotherId.Value);
var john = await session.LoadAsync<Person>(ivan.FatherId.Value);

// Output: IVAN, Mother: SOPHIA, Father: JOHN
Console.WriteLine("{0}, Mother: {1}, Father: {2}", ivan.Name, sophia.Name, john.Name);


Best to the RavenDB community ;)

Oren Eini (Ayende Rahien)

unread,
Sep 1, 2019, 8:36:28 AM9/1/19
to ravendb
I'm torn here.
On the one hand, I'm really happy and impressed that you were able to achieve your goal.
On the other hand, as I mentioned, I think that this adds quite a significant overhead to your code and I don't know that I see the benefits here.

--
You received this message because you are subscribed to the Google Groups "RavenDB - 2nd generation document database" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ravendb+u...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/ravendb/6e70204d-8c09-4272-82e8-9b191e9bdd22%40googlegroups.com.


--
Oren Eini
CEO   /   Hibernating Rhinos LTD
Skype:  ayenderahien
Support:  sup...@ravendb.net

Ivan Montilla

unread,
Sep 3, 2019, 7:20:13 AM9/3/19
to RavenDB - 2nd generation document database
Hi Oren, thank you for impressions ;)

For me, the main benefit is avoid the primitive obsession problem. However, creating multiple classes like UserId, OrderId, etc. is boring. That is why I propose create a simple Id<TEntity> generic type, that solves the primitive obsession problem, so we only need one Id type and not one per entity.
To unsubscribe from this group and stop receiving emails from it, send an email to rav...@googlegroups.com.

Ivan Montilla

unread,
Sep 10, 2019, 2:16:14 PM9/10/19
to RavenDB - 2nd generation document database
@Oren, What about the idea of adding a feature in RavenDB client that allow to use any type as ID that can be converted from/to string? Maybe using a TypeConverter.

Oren Eini (Ayende Rahien)

unread,
Sep 11, 2019, 6:07:44 AM9/11/19
to ravendb
We have had a lot of issues with that in the past, we intentionally removed this feature in 4.0

To unsubscribe from this group and stop receiving emails from it, send an email to ravendb+u...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/ravendb/9269c68b-0b5f-48bd-a08f-99437074256c%40googlegroups.com.

Ivan Montilla

unread,
Sep 13, 2019, 6:59:28 AM9/13/19
to RavenDB - 2nd generation document database
Thanks for response. Can I ask about the issues? Think in mind that I started with RavenDB 4.2 and never used other version.

Oren Eini (Ayende Rahien)

unread,
Sep 27, 2019, 5:27:04 AM9/27/19
to ravendb
The primary issue was that there was a very distinct difference between the ID in the client side and the server side.
For example, if you had a "int Id" property, it would be translated automatically to a string.
But when doing a projection, you'll get a string back and fail to deserialize on the client.

Things like that.

To unsubscribe from this group and stop receiving emails from it, send an email to ravendb+u...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/ravendb/f79b740e-d4e9-4f01-9745-75eef5962889%40googlegroups.com.

Ivan Montilla

unread,
Sep 27, 2019, 5:48:33 AM9/27/19
to RavenDB - 2nd generation document database
I can imagine a issue with integers types when the server has some document with ID "00025" and other document with ID "25". The both strings will be converted into 25 int. Other problem that I can imagine is when `int Id` property is null and the Id is generated with HiLo algorithm that includes the collection and the node letters into the ID. You cannot deserialize "people/25-A" into a int (even removing the collection part, "25-A" cannot be deserialize into int).

But I'm not sure if required ID to be string is the best form to solve the problem, think in mind the flexibility. I propose that by default the client require the type Id, but allow to disable this restriction with a custom convention that include logic to serialize/desealize to/from string the Id type.

Thanks for your response :P

Off-Topic: Today is my birthday :P
Reply all
Reply to author
Forward
0 new messages