Supporting arbitrary types in Azure Mobile Services managed client – complex types
[The object model shown in this post for the client-side is mostly out-of-date; check the updated post at https://blogs.msdn.com/b/carlosfigueira/archive/2013/08/23/complex-types-and-azure-mobile-services.aspx for the more up-to-date version]
In my last post I showed how to use the IDataMemberJsonConverter to enable data types which aren’t natively supported by the managed client for Mobile Services. But I only touched on simple types, leaving more complex types for this post. Let’s get back to the example we had.
Now we also want to reviews for our movies. We can do that by adding a new property in our class:
- public class Movie
- {
- public int Id { get; set; }
- public string Title { get; set; }
- public int ReleaseYear { get; set; }
- [DataMemberJsonConverter(ConverterType = typeof(TimeSpanConverter))]
- public TimeSpan Duration { get; set; }
- public MovieReview[] Reviews { get; set; }
- }
- publicclassMovieReview
- {
- publicint Stars { get; set; }
- publicstring Comment { get; set; }
- }
And if we try to insert one instance of the movie in a Mobile Services table
- var table = mobileService.GetTable<Movie>();
- var item = new Movie
- {
- Title = "Pulp Fiction",
- ReleaseYear = 1994,
- Duration = TimeSpan.FromMinutes(154),
- Reviews = new MovieReview[]
- {
- new MovieReview { Stars = 5, Comment = "Awesome movie!" },
- new MovieReview { Stars = 4, Comment = "One of the best movies from Tarantino." }
- }
- };
- await table.InsertAsync(item);
- AddToDebug("Movie details: {0}", item);
We’ll get the error that we saw last time.
Error: System.ArgumentException: Cannot serialize member 'Reviews' of type 'MovieReview[]' declared on type 'Movie'.
Parameter name: instance
at Microsoft.WindowsAzure.MobileServices.MobileServiceTableSerializer.Serialize(Object instance, Boolean ignoreCustomSerialization)
at Microsoft.WindowsAzure.MobileServices.MobileServiceTable`1.<InsertAsync>d__21.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
So we try to do what we did last time, adding a converter for arrays of movie reviews, where we send them over an array of JSON objects… Notice that now we start getting into the bad (or very restricted) API for the Windows.Data.Json classes, as we need to perform lots of checks for their items which we get for free (based on defaults) on other JSON libraries such as System.Json or JSON.NET. As I mentioned before, I normally use some extension methods, but I’ll keep the code here in full for completeness sake.
- public class Movie
- {
- public int Id { get; set; }
- public string Title { get; set; }
- public int ReleaseYear { get; set; }
- [DataMemberJsonConverter(ConverterType = typeof(TimeSpanConverter))]
- public TimeSpan Duration { get; set; }
- [DataMemberJsonConverter(ConverterType = typeof(MovieReviewsConverter))]
- public MovieReview[] Reviews { get; set; }
- publicoverridestring ToString()
- {
- return string.Format("Movie[Id={0},Title={1},ReleaseYear={2},Duration={3},Reviews={4}]",
- Id, Title, ReleaseYear, Duration,
- Reviews == null ?
- "<<NULL>>" :
- "[" + string.Join(", ", Reviews.Select(r => string.Format("{0} - {1}", new string('*', r.Stars), r.Comment))) + "]");
- }
- }
- public class MovieReview
- {
- public int Stars { get; set; }
- public string Comment { get; set; }
- }
- public class MovieReviewsConverter : IDataMemberJsonConverter
- {
- private static readonly IJsonValue NullJson = JsonValue.Parse("null");
- public object ConvertFromJson(IJsonValue value)
- {
- MovieReview[] result = null;
- if (value != null && value.ValueType == JsonValueType.Array)
- {
- JsonArray jsonArray = value.GetArray();
- result = new MovieReview[jsonArray.Count];
- for (int i = 0; i < jsonArray.Count; i++)
- {
- IJsonValue item = jsonArray[i];
- if (item != null && item.ValueType == JsonValueType.Object)
- {
- JsonObject review = item.GetObject();
- IJsonValue stars = review["stars"];
- IJsonValue comment = review["comment"];
- result[i] = new MovieReview();
- if (stars != null && stars.ValueType == JsonValueType.Number)
- {
- result[i].Stars = (int)stars.GetNumber();
- }
- if (comment != null && comment.ValueType == JsonValueType.String)
- {
- result[i].Comment = comment.GetString();
- }
- }
- }
- }
- return result;
- }
- public IJsonValue ConvertToJson(object instance)
- {
- MovieReview[] reviews = instance asMovieReview[];
- if (reviews != null)
- {
- JsonArray jsonArray = new JsonArray();
- foreach (var review in reviews)
- {
- JsonObject jsonReview = new JsonObject();
- jsonReview.Add("stars", JsonValue.CreateNumberValue(review.Stars));
- jsonReview.Add("comment", JsonValue.CreateStringValue(review.Comment));
- jsonArray.Add(jsonReview);
- }
- return jsonArray;
- }
- else
- {
- return NullJson;
- }
- }
- }
Based on the previous post, this should work, but when we try to run the code again, we get another error:
Error: Microsoft.WindowsAzure.MobileServices.MobileServiceInvalidOperationException: The value of property 'Reviews' is of an unsupported type. (400 BadRequest - Details: {"code":400,"error":"The value of property 'Reviews' is of an unsupported type."})
at Microsoft.WindowsAzure.MobileServices.MobileServiceClient.CreateMobileServiceException(String errorMessage, IServiceFilterRequest request, IServiceFilterResponse response)
at Microsoft.WindowsAzure.MobileServices.MobileServiceClient.ThrowInvalidResponse(IServiceFilterRequest request, IServiceFilterResponse response, IJsonValue body)
at Microsoft.WindowsAzure.MobileServices.MobileServiceClient.<RequestAsync>d__5.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at Microsoft.WindowsAzure.MobileServices.MobileServiceTable.<SendInsertAsync>d__8.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at Microsoft.WindowsAzure.MobileServices.MobileServiceTable`1.<InsertAsync>d__21.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
What happened here? This time, the client library didn’t complain, but when the request reached the server, that’s when the server complained. On the server there’s no easy way to determine what it should do with that kind of data. In this example, should the comments be stored in the same Movie table (denormalized), or should we have a separate table for the comments (normalized), so that the same information doesn’t get spread over multiple tables? So the Mobile Services runtime will simply error out, forcing the developer to do that. So let’s see how we could implement both alternatives.
First, the easy way: denormalizing the data. In this example, since the cardinality of the comments is greater than that of the movie, we could choose to store comments, and have the movie information on them, but that just doesn’t make sense. We can also store the comments in the same row as the movies, but we’ll need to convert them into a simple type, which is what we’ll do here. We could actually have done that at the client (instead of sending the comments as an array of objects, we could have sent them as a string), but I’ll do it on the server to keep the client programming model clean. So let’s go to the portal, select our Mobile Service and the Data tab, and select the script for the insert operation. The code below is simple: if the Reviews property is an array, then we’ll join the elements using a simple format. Notice that this is definitely not production-ready code, since the review comment cannot have either the ‘-‘ or the ‘|’ characters, but I’ll keep this way for simplicity.
- function insert(item, user, request) {
- if (Array.isArray(item.Reviews)) {
- var reviews = item.Reviews.map(function (review) {
- return review.stars + "-" + review.comment;
- }).join("|");
- item.Reviews = reviews;
- }
- request.execute();
- }
One parenthesis before we move on: I used Array.isArray to check that the Reviews property is an array. I started off by using the instanceof operator (“item.Reviews instanceof Array”) but that didn’t work. Searching around I found many people complaining about that operator (including Crockford himself), and I got help from Mathew, a developer in our team, to figure out the issue. It turns out that the Array object in the context of the script runs in a different environment (the scripts sandbox) than the Array object from the environment where the deserialization took place (the Node process), when using the instanceof operator it was comparing it with a different Array class. In short, don’t use instanceof in your scripts, and for arrays you can use Node’s Array.isArray function instead.
And if we run that code again, we’ll finally get our Movie object properly inserted.
But there’s still a problem with this code. After the InsertAsync operation returns (and is awaited), the value of the Reviews property is set to null (the Id is properly set to the value on the server). The problem is that, on the movie reviews converter, on the call to ConvertFromJson, the value of the IJsonValue parameter is not an array, but a string value instead. When we, in the server script, replaced the value of item.Reviews with a string, that value was returned to the client as the inserted object. So what we need to do is to, after executing the request, restore the original array value to the item, prior to returning it to the client:
- function insert(item, user, request) {
- var originalReviews = null;
- if (Array.isArray(item.Reviews)) {
- var reviews = item.Reviews.map(function (review) {
- return review.stars + "-" + review.comment;
- }).join("|");
- originalReviews = item.Reviews;
- item.Reviews = reviews;
- }
- request.execute({
- success: function () {
- if (originalReviews) {
- item.Reviews = originalReviews;
- request.respond();
- }
- }
- });
- }
Now we’re good. The insertion worked, and we can verify that by browsing the data in the portal, and we the data is properly returned by the client.
Next up, reading data. Just like when we were inserting the data we had to convert between the array and the string which will go into the table, when reading the data. In the portal, select the Mobile Service, then the Data tab, then select the Movie table and the script for the Read operation:
- function read(query, user, request) {
- request.execute({
- success: function (results) {
- results.forEach(function (review) {
- review.Reviews = review.Reviews.split('|')
- .map(function (item) {
- var parts = item.split('-');
- var stars = parseInt(parts[0]);
- var comment = parts[1];
- return { stars: stars, comment: comment };
- });
- });
- request.respond();
- }
- });
- }
At this point we can also query data:
- var table = mobileService.GetTable<Movie>();
- var moviesFrom1994 = await table.Where(m => m.ReleaseYear == 1994).ToListAsync();
- foreach (var movie in moviesFrom1994)
- {
- AddToDebug("Movie: {0}", movie);
- }
The last thing we need to do, if we need to support updates as well, is to update the Update function to do the same as we did for insertions. For delete operations we don’t need to change any scripts, since all that is passed to the script is the id of the item to be removed.
Coming up
In the last post we saw how we could convert between arbitrary types in the client (although I only showed simple types) to one of the supported types (such as strings). In this post we saw how to maintain the complexity of the types as they’re leaving the client, into the wire, but at the server we did the same trick – converting them into strings. In the next post, I’ll show how to normalize the data in the server, by splitting the data entered into multiple tables.
Comments
- Anonymous
January 16, 2014
Just ran into this issue posting a backbone model that included a collection. Thank you so much for the post! It was a real time saver.Instead of creating an arbitrary string from the array, you can also use JSON.stringify on the property, and JSON.parse before sending back to the client.