Skip to content
Back to Blog
1 min read

Dataverse Virtual Tables: Integrating External Data

I wrote “Dataverse Virtual Tables: Integrating External Data” to share practical, production-minded guidance on this topic.

Virtual Table Concepts

Virtual tables provide:

  • Real-time access to external data
  • Native Dataverse experience
  • Security role support
  • Power Platform integration

Creating a Custom Virtual Table Provider

Implement a custom data provider:

// Plugin/VirtualEntityDataProvider.cs
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Extensions;
using System.Net.Http.Json;

public class ExternalProductDataProvider : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var context = serviceProvider.Get<IPluginExecutionContext>();
        var tracingService = serviceProvider.Get<ITracingService>();

        tracingService.Trace("Virtual Entity Plugin executing...");

        try
        {
            switch (context.MessageName.ToLower())
            {
                case "retrieve":
                    HandleRetrieve(context, tracingService);
                    break;
                case "retrievemultiple":
                    HandleRetrieveMultiple(context, tracingService);
                    break;
            }
        }
        catch (Exception ex)
        {
            tracingService.Trace($"Error: {ex.Message}");
            throw new InvalidPluginExecutionException($"Virtual entity error: {ex.Message}");
        }
    }

    private void HandleRetrieve(IPluginExecutionContext context, ITracingService trace)
    {
        var target = (EntityReference)context.InputParameters["Target"];
        var productId = target.Id.ToString();

        trace.Trace($"Retrieving product: {productId}");

        using var client = new HttpClient();
        var response = client.GetAsync($"https://api.example.com/products/{productId}").Result;

        if (response.IsSuccessStatusCode)
        {
            var product = response.Content.ReadFromJsonAsync<ExternalProduct>().Result;
            var entity = MapToEntity(product, target.LogicalName);
            context.OutputParameters["BusinessEntity"] = entity;
        }
    }

    private void HandleRetrieveMultiple(IPluginExecutionContext context, ITracingService trace)
    {
        var query = (QueryExpression)context.InputParameters["Query"];

        trace.Trace($"Retrieving multiple products");

        // Build API query from QueryExpression
        var apiQuery = BuildApiQuery(query);

        using var client = new HttpClient();
        var response = client.GetAsync($"https://api.example.com/products?{apiQuery}").Result;

        if (response.IsSuccessStatusCode)
        {
            var products = response.Content.ReadFromJsonAsync<List<ExternalProduct>>().Result;
            var entityCollection = new EntityCollection();

            foreach (var product in products)
            {
                entityCollection.Entities.Add(MapToEntity(product, query.EntityName));
            }

            context.OutputParameters["BusinessEntityCollection"] = entityCollection;
        }
    }

    private string BuildApiQuery(QueryExpression query)
    {
        var queryParams = new List<string>();

        // Handle pagination
        if (query.PageInfo != null)
        {
            queryParams.Add($"page={query.PageInfo.PageNumber}");
            queryParams.Add($"pageSize={query.PageInfo.Count}");
        }

        // Handle filters
        foreach (var condition in query.Criteria.Conditions)
        {
            switch (condition.Operator)
            {
                case ConditionOperator.Equal:
                    queryParams.Add($"{condition.AttributeName}={condition.Values[0]}");
                    break;
                case ConditionOperator.Contains:
                    queryParams.Add($"search={condition.Values[0]}");
                    break;
            }
        }

        return string.Join("&", queryParams);
    }

    private Entity MapToEntity(ExternalProduct product, string entityName)
    {
        var entity = new Entity(entityName);
        entity.Id = Guid.Parse(product.Id);
        entity["cr_productid"] = product.Id;
        entity["cr_name"] = product.Name;
        entity["cr_description"] = product.Description;
        entity["cr_price"] = new Money(product.Price);
        entity["cr_category"] = product.Category;
        entity["cr_stocklevel"] = product.StockLevel;
        entity["cr_lastmodified"] = product.LastModified;
        return entity;
    }
}

public class ExternalProduct
{
    public string Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
    public int StockLevel { get; set; }
    public DateTime LastModified { get; set; }
}

SQL Server Virtual Table

Connect to external SQL Server:

<!-- Virtual Table Definition -->
<entity Name="cr_externalorder">
  <DisplayName>External Order</DisplayName>
  <DisplayCollectionName>External Orders</DisplayCollectionName>
  <OwnershipType>None</OwnershipType>
  <IsVirtual>true</IsVirtual>
  <DataProvider>Microsoft.Dynamics.CRM.SqlDataProvider</DataProvider>
  <DataSourceId>datasource_externaldb</DataSourceId>

  <attributes>
    <attribute Name="cr_orderid" Type="Guid" IsPrimaryKey="true">
      <DisplayName>Order ID</DisplayName>
      <ExternalName>OrderId</ExternalName>
    </attribute>
    <attribute Name="cr_customername" Type="String" MaxLength="200">
      <DisplayName>Customer Name</DisplayName>
      <ExternalName>CustomerName</ExternalName>
    </attribute>
    <attribute Name="cr_orderdate" Type="DateTime">
      <DisplayName>Order Date</DisplayName>
      <ExternalName>OrderDate</ExternalName>
    </attribute>
    <attribute Name="cr_totalamount" Type="Money">
      <DisplayName>Total Amount</DisplayName>
      <ExternalName>TotalAmount</ExternalName>
    </attribute>
    <attribute Name="cr_status" Type="Picklist">
      <DisplayName>Status</DisplayName>
      <ExternalName>Status</ExternalName>
    </attribute>
  </attributes>
</entity>

SharePoint Virtual Table

Create virtual table for SharePoint documents:

public class SharePointDocumentProvider : IPlugin
{
    private readonly string _siteUrl;
    private readonly string _libraryName;

    public void Execute(IServiceProvider serviceProvider)
    {
        var context = serviceProvider.Get<IPluginExecutionContext>();

        if (context.MessageName == "RetrieveMultiple")
        {
            var query = (QueryExpression)context.InputParameters["Query"];
            var documents = GetSharePointDocuments(query);

            var collection = new EntityCollection();
            foreach (var doc in documents)
            {
                var entity = new Entity("cr_spdocument");
                entity.Id = Guid.Parse(doc.UniqueId);
                entity["cr_name"] = doc.Name;
                entity["cr_fileurl"] = doc.ServerRelativeUrl;
                entity["cr_filesize"] = doc.Length;
                entity["cr_created"] = doc.TimeCreated;
                entity["cr_modified"] = doc.TimeLastModified;
                entity["cr_author"] = doc.Author;
                collection.Entities.Add(entity);
            }

            context.OutputParameters["BusinessEntityCollection"] = collection;
        }
    }

    private List<SharePointDocument> GetSharePointDocuments(QueryExpression query)
    {
        // Use Microsoft Graph or SharePoint REST API
        using var client = GetAuthenticatedClient();

        var graphQuery = $"https://graph.microsoft.com/v1.0/sites/{_siteUrl}/lists/{_libraryName}/items?$expand=fields";

        var response = client.GetAsync(graphQuery).Result;
        var result = response.Content.ReadFromJsonAsync<GraphListResponse>().Result;

        return result.Value.Select(item => new SharePointDocument
        {
            UniqueId = item.Id,
            Name = item.Fields.Name,
            ServerRelativeUrl = item.WebUrl,
            Length = item.Fields.Size,
            TimeCreated = item.CreatedDateTime,
            TimeLastModified = item.LastModifiedDateTime,
            Author = item.CreatedBy?.User?.DisplayName
        }).ToList();
    }

    private HttpClient GetAuthenticatedClient()
    {
        // Implement authentication
        var client = new HttpClient();
        // Add bearer token
        return client;
    }
}

Using Virtual Tables in Power Apps

Access virtual tables like native tables:

// Power Apps formula for virtual table

// Load external products
ClearCollect(
    colProducts,
    Filter(
        'External Products',
        cr_category = ddCategory.Selected.Value
    )
);

// Search with delegation
Search(
    'External Products',
    txtSearch.Text,
    "cr_name",
    "cr_description"
);

// Display in gallery with sorting
SortByColumns(
    'External Products',
    "cr_price",
    If(SortAscending, SortOrder.Ascending, SortOrder.Descending)
);

// Navigate to detail view
Navigate(
    ProductDetail,
    ScreenTransition.Fade,
    {SelectedProduct: Gallery1.Selected}
);

Power Automate with Virtual Tables

Use virtual tables in flows:

{
  "triggers": {
    "manual": {
      "type": "Request",
      "kind": "Button"
    }
  },
  "actions": {
    "List_External_Products": {
      "type": "ApiConnection",
      "inputs": {
        "host": {
          "connection": {
            "name": "@parameters('$connections')['commondataservice']['connectionId']"
          }
        },
        "method": "get",
        "path": "/v2/datasets/@{encodeURIComponent(variables('environmentUrl'))}/tables/@{encodeURIComponent('cr_externalproducts')}/items",
        "queries": {
          "$filter": "cr_stocklevel lt 10"
        }
      }
    },
    "For_Each_Low_Stock_Product": {
      "type": "Foreach",
      "foreach": "@body('List_External_Products')?['value']",
      "actions": {
        "Send_Alert_Email": {
          "type": "ApiConnection",
          "inputs": {
            "method": "post",
            "path": "/v2/Mail",
            "body": {
              "To": "inventory@company.com",
              "Subject": "Low Stock Alert: @{items('For_Each_Low_Stock_Product')?['cr_name']}",
              "Body": "Product @{items('For_Each_Low_Stock_Product')?['cr_name']} has only @{items('For_Each_Low_Stock_Product')?['cr_stocklevel']} items in stock."
            }
          }
        }
      }
    }
  }
}

Performance Considerations

Optimize virtual table performance:

public class OptimizedVirtualProvider : IPlugin
{
    private static readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions
    {
        SizeLimit = 1000
    });

    public void Execute(IServiceProvider serviceProvider)
    {
        var context = serviceProvider.Get<IPluginExecutionContext>();

        if (context.MessageName == "RetrieveMultiple")
        {
            var query = (QueryExpression)context.InputParameters["Query"];
            var cacheKey = GenerateCacheKey(query);

            // Try cache first
            if (_cache.TryGetValue(cacheKey, out EntityCollection cached))
            {
                context.OutputParameters["BusinessEntityCollection"] = cached;
                return;
            }

            // Fetch from external source
            var results = FetchFromExternalSource(query);

            // Cache for 5 minutes
            var cacheOptions = new MemoryCacheEntryOptions()
                .SetSize(1)
                .SetSlidingExpiration(TimeSpan.FromMinutes(5));

            _cache.Set(cacheKey, results, cacheOptions);

            context.OutputParameters["BusinessEntityCollection"] = results;
        }
    }

    private string GenerateCacheKey(QueryExpression query)
    {
        // Generate unique key based on query parameters
        var builder = new StringBuilder(query.EntityName);

        foreach (var condition in query.Criteria.Conditions)
        {
            builder.Append($"|{condition.AttributeName}:{condition.Operator}:{string.Join(",", condition.Values)}");
        }

        return builder.ToString();
    }

    private EntityCollection FetchFromExternalSource(QueryExpression query)
    {
        // Implementation
        return new EntityCollection();
    }
}

Summary

Dataverse virtual tables enable:

  • Real-time external data access
  • No data duplication
  • Native Dataverse security
  • Power Platform integration
  • Custom provider development

Extend Dataverse reach without moving data.

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.