Search code examples
entity-frameworkasp.net-web-apikendo-gridodataasp.net-web-api-odata

Cannot serialize navigation properties correctly while performing Web API Patch operation from Kendo Grid


I'm currently using Web API v2 with OData v3 linked up to a Kendo Grid. I'm having problems getting the grid to serialize a model correctly to the PatchEntityAsync method on the AsyncEntitySetController<TEntity, TKey> class. The Delta<TEntity> that is passed to the PatchEntityAsync method is null which obviously is not correct.

First, the Entity Framework models. I have a GameSeries definition:

[Table("stats.GameSeries")]
public class GameSeries
{
    [Key]
    public int GameSeriesId { get; set; }

    [MaxLength(500)]
    [Required]
    public string Description { get; set; }

    public string Notes { get; set; }
}

And then there is the Game definition, each Game instance has a reference to a GameSeries instance:

[Table("stats.Game")]
public class Game
{
    [Key]
    public int GameId { get; set; }

    [MaxLength(500)]
    [Required]
    public string Description { get; set; }

    public int GameSeriesId { get; set; }

    [ForeignKey("GameSeriesId")]
    public virtual GameSeries GameSeries { get; set; }

    public int Revision { get; set; }

    [MaxLength(100)]
    public string Tag { get; set; }

    public string Notes { get; set; }
}

When querying for a Game using JSON, and issuing an $expand on the GameSeries property, I get the following, which is expected/correct:

{
    "odata.metadata":
        "http://localhost:7566/odata/$metadata#Games",
    "odata.count":"58",
    "value":[
    {
        "GameSeries": {
            "GameSeriesId": 1,
            "Description":"Street Fighter IV",
            "Notes":null
        },
        "GameId": 1,
        "Description": "Street Fighter IV",
        "GameSeriesId": 1,
        "Revision": 1,
        "Tag": null,
        "Notes": null
    }, {
        "GameSeries": {
            "GameSeriesId":1,
            "Description": "Street Fighter IV",
            "Notes": null
        },
        "GameId": 2,
        "Description": "Super Street Fighter IV",
        "GameSeriesId": 1,
        "Revision": 2,
        "Tag": null,
        "Notes": null
    },
    // And so on...
  ]
}

I'm exposing these through an OData Web API (Microsoft.AspNet.WebApi.OData 5.2.0) endpoint to a Kendo UI Grid. Here's the configuration for the grid:

function initializeGrid(selector, entitySet, key, modelFields, columns, expand) {
    // Edit and destroy commands.
    columns.push({ command: ["edit", "destroy"], title: "Operations" });

    // The main key is not editable.
    modelFields[key].editable = false;
    modelFields[key].defaultValue = 0;

    var baseODataUrl = "/odata/" + entitySet,
        options = {
            dataSource: {
                type: "odata",
                pageSize: 50,
                //autoSync: true,
                transport: {
                    read: {
                        url: baseODataUrl,
                        dataType: "json",
                        data: {
                            $expand: expand
                        }
                    },
                    update: {
                        url: function(data) {
                            return baseODataUrl + "(" + data[key] + ")";
                        },
                        type: "patch",
                        dataType: "json"
                    },
                    destroy: {
                        url: function (data) {
                            return baseODataUrl + "(" + data[key] + ")";
                        },
                        dataType: "json"
                    },
                    create: {
                        url: baseODataUrl,
                        dataType: "json",
                        contentType: "application/json;odata=verbose"
                    }
                },
                batch: false,
                serverPaging: true,
                serverSorting: true,
                serverFiltering: true,
                schema: {
                    data: function (data) {
                        return data.value;
                    },
                    total: function (data) {
                        return data["odata.count"];
                    },
                    model: {
                        id: key,
                        fields: modelFields
                    }
                }
            },
            height: 550,
            toolbar: ["create"],
            filterable: true,
            sortable: true,
            pageable: true,
            editable: "popup",
            navigatable: true,
            columns: columns
        };

    selector.kendoGrid(options);
}

$(function () {
    var baseODataUrl = "/odata/",
        gameSeriesIdDataSource = new kendo.data.DataSource({
            type: "odata",
            schema: {
                data: function (data) {
                    return data.value;
                },
                total: function (data) {
                    return data["odata.count"];
                }
            },
            transport: {
                read: {
                    url: baseODataUrl + "GameSeries",
                    dataType: "json"
                }
            }
        }),
        gameSeriesIdAutoCompleteEditor = function(container, options) {
            $('<input data-text-field="Description" data-value-field="GameSeriesId" data-bind="value:GameSeriesId"/>')
                .appendTo(container)
                .kendoDropDownList({
                    autoBind: false,
                    dataSource: gameSeriesIdDataSource,
                    dataTextField: "Description",
                    dataValueField: "GameSeriesId"
                });
        };

    initializeGrid($("#grid"), "Games", "GameId", {
        GameId: {
            title: "Game ID",
            editable: false
        },
        Description: { type: "string" },
        GameSeriesId: { type: "integer" },
        Revision: { type: "integer" },
        Tag: { type: "string" },
        Notes: { type: "string" }
    }, [
        { field: "GameId", title: "Game ID" },
        "Description",
        { field: "GameSeries.Description", title: "Game Series", editor: gameSeriesIdAutoCompleteEditor },
        "Revision",
        "Tag",
        "Notes"
    ], "GameSeries");
});
}(jQuery));

This will render the grid correctly, where I'll get GameSeries.Description displayed instead of the numeric ID of the GameSeries.

However, I believe part of the problem comes from how I define the custom editor, specifically the data attributes that Kendo requires:

$('<input data-text-field="Description" data-value-field="GameSeriesId" data-bind="value:GameSeriesId"/>')

I get the feeling I should use dot notation to reference the GameSeries property on the Game instance, but I'm not sure how.

Additionally, I believe the binding here is contributing to the create command failing. There should be some way to set the data binding attributes which will allow for new creations, as well as editing existing ones.

However, when I get the editor to pop up for an existing instance, it does so correctly with the drop down list populated with all of the GameSeries instances.

I can make changes, and when I do, I notice through Fiddler that the body is being passed through, although I notice some discrepancies:

{
    "GameSeries": {
        "GameSeriesId": 1,
        "Description": "Street Fighter IV",
        "Notes": null
    },
    "GameId": "1",
    "Description":
    "Street Fighter IV",
    "GameSeriesId": "4",
    "Revision": "1",
    "Tag": "Test",
    "Notes": null
}

In this case, the GameSeriesId property is populated correctly with the change (I want 4) but the expanded GameSeries property has a "GameSeriesId" of 1.

When this call is made, the Delta<Game> instance passed in is null.

What I've tried:

I've noticed that the GameSeriesId property on the expanded GameSeries property isn't stringified. I've changed the value to "1"" and the Delta<Game> instance is still null.

I've replicated the call to the OData point to not include the extended GameSeries property, so the payload looks like this:

{
    "GameId": "1",
    "Description":
    "Street Fighter IV",
    "GameSeriesId": "4",
    "Revision": "1",
    "Tag": "Test",
    "Notes": null
}

And the Delta<Game> is populated. I'm not sure if getting the expanded GameSeries property droped from the payload is the right approach, or whether or not it should be tackled on the server side, or in the Kendo grid.


Solution

  • Since the foreign key id is changed successfully you can just exclude the navigation property GameSeries when doing the update.

    EF works so well when updating the relationship just by using the foreign key id.

    So let the OData point to include the GameSeries, but exclude it when doing update. You can use parameterMap to intercept the update operation.

    parameterMap: function (data, type) {
        if (type === "update") {
            delete data.GameSeries;
            return JSON.stringify(data);
        }
    
        // Returns as it is.
        return data;
    }
    

    update

    To sync the editor with the grid, you need to bind the change event and change the property of the model in the grid manually.

    gameSeriesIdAutoCompleteEditor = function (container, options) {
        /* omitted code */
        .kendoDropDownList({
            /* omitted code */
            change: function (e) {
                options.model.GameSeries = this.dataItem();
            }