Обычно для решения рабочих задач хватает примера кода из своего или соседнего проекта. Дополнительные сведения можно найти на страницах официальной документации, в профильных блогах или на StackOverflow. Но вот про реализацию работы Azure Functions c базой данных Dataverse, которая относится к облачной Power Apps (часть Power Platform), вы найдете крупицы информации.
Автор этой статьи – Борис Шимберев, .NET-разработчик в EPAM. Борис на практике разобрался со всеми мыслимыми и немыслимыми проблемами, с которыми можно столкнуться при работе с Microsoft Dataverse. В статье он рассказывает об особенностях использования этой СУБД и делится полезными находками.
Disclaimer! Не могу считать себя экспертом по Miscrosoft Dataverse, но связан с ним узами интеграции. Далее приводятся реальные впечатления от продукта, так что не обессудьте.
Знакомство с Dataverse
Если Microsoft Power Platform – это облачное коробочное решение для закрытия широкого круга задач бизнеса, то Dataverse – это табличная база данных с собственной инфраструктурой для хранения бизнес-сущностей такой платформы. База состоит из стандартных (для порталов общего назначения) таблиц-сущностей, таких как контакты, календарь, роли, но позволяет, естественно, создавать и собственные таблицы. Система хранения имеет под собой Microsoft Azure SQL, но, учитывая слой логики над ней, лучше говорить о Dataverse как о СУБД или отдельной платформе, интегрированной в облачную систему Power Platform.
Изначально база появилась в обличии Microsoft Dynamics CRM (а далее XRM, Dynamics 365 и до ноября 2020 – Common Data Service или CDS). Много ребрендинга – легко ошибиться при работе со старыми проектами, поэтому также важно не путать Microsoft Dataverse с платформой для хранения исследовательских данных The Dataverse Project, у них тоже есть своё API.
Разработчики, которые часто взаимодействуют с Microsoft Dataverse, обычно пользуются бесплатным приложением XrmToolBox, в нём есть множество удобных инструментов, упрощающих работу с базой.
Для интеграции с базой из инфраструктуры Microsoft (Azure, PowerBi) обычно используются встроенные коннекторы, но, конечно же, нет такого коннектора, который бы устраивал именно вашего заказчика. Например, я интегрировал Dataverse c Azure Functions. Для такой относительно низкоуровневой интеграции существует два варианта: Organization Service и Web API.
Organization Service – это SOAP сервис в логическом слое базы. Этот способ считается устаревшим по мнению Microsoft, поэтому переходим ко второму.
Web API – это интерфейс Dataverse, построенный на протоколе OData 4.0. Запросы к нему можно отправлять как в общепринятом виде OData (есть некоторые ограничения, например, отсутствует возможность для постраничных запросов, так как не используется терм skip), так и с помощью синтаксиса запросов FetchXML, который немного отличается от первого (например, поддерживает постраничные запросы с помощью page и count параметров). С помощью FetchXML удобно представлять сложные запросы, к примеру, с вложенными JOIN, фильтрами и сортировкой.
Для взаимодействия с Dataverse я использовал и OData запросы, и FetchXML. Далее запросы к базе я буду указывать в вариантах: curl запрос (OData и/или FetchXML) и/или представление в виде С#-класса.
Подготовка к работе
Аутентификация в Dataverse обеспечивается стандартным механизмом в облачном мире Azure через создание App registration/Enterprise application в Active Directory. После создания интеграции (App registration) ей присваивается Application (client) ID (далее 8b441c8c-2cc1-405c-bf35-6cbdb1204c6d),
а в меню Certificates & secrets можно создать ключ доступа (далее 4.T5a~RD7t2WpJDklI6vk003F9~FW4m3odpf). Здесь же указан и идентификатор клиента Directory (tenant) ID (далее 835d8b21-b31c-45e8-b0a6-cea337ec6d37).
В самом Power portals интеграция регистрируется по пути Environments > [ENV] > Settings > Application users, таким образом обеспечивается авторизация.
Адрес Web API вашего портала можно посмотреть в Power Apps (справа сверху): Settings > Power Apps > Developer resources > Web API endpoint (далее your-project-development.api.crm.dynamics.com/api/data/v9.2).
Архитектура
Архитектура инфраструктурного слоя представляет собой провайдер/обработчик (читай репозиторий) с интерфейсом для использования в логическом слое, провайдер доступа, провайдер соединения, а также набор классов-запросов и классов-ответов. Код доступен на GitHub.
Вызов от логического слоя попадает в инфраструктурный провайдер, затем на основании входящих данных формируется объект запроса (можно формировать объект запроса сразу в логическом слое). Объект запроса обрабатывается провайдером соединения, который формирует валидный HTTP вызов к Dataverse Web API и обрабатывает ответ от него. Перед отправкой данных в Dataverse должен быть указан актуальный аутентификационный заголовок, за этим следит отдельный провайдер, который предоставляет всегда актуальное значение заголовка.
В дальнейших примерах используется упрощённая схема запросов и рассмотрен только инфраструктурный слой, так как следить за актуальностью аутентификационного заголовка не нужно.
Изначально мы делаем один аутентификационный запрос и предоставляем провайдеру соединения заголовок аутентификации. После этого передаём провайдер соединения в обработчик того или иного запроса, где формируется запрос и обрабатывается ответ от провайдера соединения.
Аутентификация
Здесь ничего сложного, обычный OAuth 2.0. Для чистого HTTP запроса используется общий endpoint авторизации Azure.
curl https://login.microsoftonline.com/835d8b21-b31c-45e8-b0a6-cea337ec6d37/oauth2/token -d "grant_type=client_credentials&client_id=8b441c8c-2cc1-405c-bf35-6cbdb1204c6d&client_secret=4.T5a~RD7t2WpJDklI6vk003F9~FW4m3odpf&resource=https://your-project-development.api.crm.dynamics.com"
В ответе придёт информация в формате JSON:
{
"token_type":"Bearer",
"expires_in":"3599",
"ext_expires_in":"3599",
"expires_on":"1641554934",
"not_before":"1641551034",
"resource":"https://your-project-development.api.crm.dynamics.com",
"access_token":"eyJ0eXAiOiJKV1Q…"
}
Сам токен доступа можно изучить на сайте jwt.ms. Окно доступа ограничено одним часом и указано в UNIX-time формате в секундах.
Доступ из кода возможен разными путями, вплоть до ручного создания HTTP-запроса. Наиболее удобный, как мне кажется, способ – это использование абстрактного класса Azure.Core.TokenCredential и его имплементации в виде Azure.Identity.ClientSecretCredential.
Пример кода провайдера доступа:
using Azure.Core;
using Azure.Identity;
using System.Net.Http.Headers;
namespace Dataverse.Habr.Intro;
public static class AuthProvider
{
public static AuthenticationHeaderValue GetAuthHeader(
string tenantId,
string clientId,
string clientSecret,
string scope)
{
var clientSecretCredential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var tokenRequestContext = new TokenRequestContext(new[] { scope + ".default" });
var accessToken = clientSecretCredential.GetToken(tokenRequestContext, CancellationToken.None);
return new AuthenticationHeaderValue("Bearer", accessToken.Token);
}
}
Описание интерфейса метода запроса заголовка:
Параметр |
Описание |
Значение |
tenantId |
Идентификатор клиента AD (глобальный для организации) |
835d8b21-b31c-45e8-b0a6-cea337ec6d37 |
clientId |
Идентификатор приложения, зарегистрированного в Dataverse |
8b441c8c-2cc1-405c-bf35-6cbdb1204c6d |
clientSecret |
Секретный ключ приложения |
4.T5a~RD7t2WpJDklI6vk003F9~FW4m3odpf |
scope |
Ресурс, к которому предоставляется доступ. В рамках этой статьи мы не разбираем разные области доступа и используем общую (/.default) |
https://your-project-development.api.crm.dynamics.com/ |
В ответе мы получаем данные структуры AccessToken с полями DateTimeOffset ExpiresOn и string Token.
Теперь, получив доступ к Dataverse, мы можем попробовать свои силы в доступе к данным базы. Нет. Не можем. Есть ещё один важный момент при взаимодействии с Dataverse. Так как эта база была создана для оптимального хранения пользовательских данных в первую очередь для code-free приложений, то именования таблиц подчиняются особым правилам.
Названия сущностей (EntitySetName)
При создании новой таблицы задаётся несколько названий таблицы для использования в различных местах.
Далее для уже созданной таблицы можно посмотреть (и поменять) некоторые из них. Мы видим 4 различных наименования таблицы (Display name, Plural name, Schema name, Logical name).
На самом деле их ещё больше. Вот описание некоторых из них из документации:
Имя |
Описание |
Пример |
Display name |
Отображение в интерфейсе |
My Table |
Logical name |
На основании Display name генерируются все остальные значения. Значение Logical name будет фигурировать в базе и должно предваряться префиксом схемы. Оно уникально внутри проекта |
mysch_mytable |
Schema name |
Обычно совпадает с Logical name и записывается в Pascal case |
mysch_MyTable |
Entity set name (см. Logical collection name в документации) |
Обычно совпадает с Logical name во множественном числе. Именно его мы будем использовать при обращении к Web API Dataverse |
mysch_mytables |
Я специально останавливаюсь на этом моменте, так как он неочевиден. Нужное нам значение имени таблицы имени сущности – это Entity set name, и его нет в интерфейсе Power Apps. Нужные названия можно получить из запроса:
curl https://your-project-development.api.crm.dynamics.com/api/data/v9.2 -H "Authorization: Bearer eyJ0eXAiOiJ…"
Формат ответа:
{
"@odata.context": "https://your-project-development.api.crm.dynamics.com/api/data/v9.0/$metadata",
"value": [
...
, {
"name": "mysch_mytables",
"kind": "EntitySet",
"url": "mysch_mytables"
},
...
]
}
Если таблицы создавали не вы, то лучше посмотреть нужное именование в запросе выше, иначе можно встретить ошибку в виде опечатки или неправильного образования множественного числа.
Запрос на получение одного поля
Простейший запрос на получение одного поля таблицы выглядит так:
curl "https://your-project-development.api.crm.dynamics.com/api/data/v9.2/mysch_mytables?$select=mysch_myid&$filter=mysch_mytype ne null&$top=3" -H "Authorization: Bearer eyJ0eXAiOiJ…"
Аналогичный запрос с использованием FetchXML:
curl 'https://your-project-development.api.crm.dynamics.com/api/data/v9.2/mysch_mytables?fetchXml=<fetch top="3"><entity name="mysch_mytable"><attribute name="mysch_myid" /><filter><condition attribute="mysch_mytype" operator="ne" value="null" /></filter></entity></fetch>' -H "Authorization: Bearer eyJ0eXAiOiJ…"
Для реализации тех же запросов в коде введём несколько вспомогательных классов: BaseRequest<TOut> для организации формата запросов,
namespace Dataverse.Habr.Intro.DataverseBase;
public abstract class BaseRequest<TOut>
{
public abstract HttpMethod HttpMethod { get; }
public abstract string GetRequest();
public abstract string? GetBody();
}
OdataResponse<T> для организации формата ответов,
using System.Text.Json.Serialization;
namespace Dataverse.Habr.Intro.DataverseBase;
public class OdataResponse<T>
{
[JsonPropertyName("@odata.context")]
public string OdataContext { get; set; }
[JsonPropertyName("@Microsoft.Dynamics.CRM.fetchxmlpagingcookie")]
public string FetchXmlPagingCookie { get; set; }
[JsonPropertyName("@odata.count")]
public int Count { get; set; }
[JsonPropertyName("value")]
public T Value { get; set; }
}
ConnectionProvider для формирования конечных запросов и обработки ответов от Dataverse Web API,
using Dataverse.Habr.Intro.DataverseBase;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Dataverse.Habr.Intro;
public class ConnectionProvider
{
private readonly HttpClient _httpClient;
public ConnectionProvider(HttpClient httpClient) => _httpClient = httpClient;
public OdataResponse<T> ProcessRequest<T>(BaseRequest<T> baseRequest) where T : class
{
var requestUri = baseRequest.GetRequest();
var content = baseRequest.GetBody();
var request = new HttpRequestMessage(baseRequest.HttpMethod, requestUri);
if (content != null)
{
request.Content = new StringContent(content);
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
}
var response = _httpClient.Send(request);
var data = response.Content.ReadAsStream();
#if DEBUG
var debugStringData = ReadResponseAsString(response);
data.Position = 0;
#endif
if (response.IsSuccessStatusCode)
{
return (JsonSerializer.Deserialize<OdataResponse<T>>(data))!;
}
var responseContent = ReadResponseAsString(response);
var errorMessage = string.IsNullOrWhiteSpace(responseContent)
? $"Failed with a status of '{response.ReasonPhrase}'"
: $"Failed with content: {responseContent.Replace("\"", string.Empty)}";
throw new Exception(errorMessage);
}
private static string ReadResponseAsString(HttpResponseMessage message)
=> new StreamReader(message.Content.ReadAsStream()).ReadToEnd();
}
Program.cs – одно кольцо, чтоб править всеми класс для объединения логики,
using Dataverse.Habr.Intro;
using Dataverse.Habr.Intro.Handlers;
const string tenantId = "835d8b21-b31c-45e8-b0a6-cea337ec6d37";
const string clientId = "8b441c8c-2cc1-405c-bf35-6cbdb1204c6d";
const string clientSecret = "4.T5a~RD7t2WpJDklI6vk003F9~FW4m3odpf";
const string scope = "https://your-project-development.api.crm.dynamics.com/";
var authenticationHeader = AuthProvider.GetAuthHeader(tenantId, clientId, clientSecret, scope);
Console.WriteLine($"authenticationHeader: {authenticationHeader}");
var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri($"{scope}api/data/v9.2/");
httpClient.DefaultRequestHeaders.Authorization = authenticationHeader;
var connectionProvider = new ConnectionProvider(httpClient);
var handlers = new IHandler[] { new GetOneField() };
foreach(var handler in handlers)
{
Console.WriteLine();
Console.WriteLine($"{handler.Text}: {handler.Handle(connectionProvider)}");
}
тогда класс обработчик вместе с запросом и ответом может выглядеть вот так:
using Dataverse.Habr.Intro.DataverseBase;
using System.Text.Json.Serialization;
namespace Dataverse.Habr.Intro.Handlers;
public class GetOneField : IHandler
{
public string Text => "Three rows field value";
public string Handle(ConnectionProvider connectionProvider)
{
try
{
var result = connectionProvider.ProcessRequest(new Request());
return string.Join(", ", result.Value.Select(i => i.Field));
}
catch (Exception ex)
{
return ex.Message;
}
}
private class Request : BaseRequest<Response[]>
{
public override HttpMethod HttpMethod => HttpMethod.Get;
public override string? GetBody() => null;
public override string GetRequest() => "mysch_mytables?$select=mysch_myid&$filter=mysch_mytype ne null&$top=3";
}
private class Response
{
[JsonPropertyName("mysch_myid")]
public string Field { get; set; }
}
}
Для составления OData запроса можно было бы использовать какую-нибудь библиотеку-конструктор, но у нас довольно простые примеры, и строковый вид запроса представляется нагляднее. Далее нас будут интересовать только классы-запросы, поэтому остальной код я буду опускать.
Запрос на получение одного поля (FetchXML через код)
Для формирования FetchXML запроса нам необходимо добавить дополнительный код. В первую очередь это класс, построенный на основе схемы FetchXML, для его создания используется XML Schema Definition tool (Xsd.exe). Синтаксис элементарный:
xsd Fetch.xsd /classes /namespace:Dataverse.Habr.Intro.DataverseBase
После получения представления схемы в виде классов у нас появляется необходимость в сериализации объектов-запросов, а базовый запрос BaseRequest<TOut> принимает вид:
using System.Text;
using System.Xml;
using System.Xml.Serialization;
namespace Dataverse.Habr.Intro.DataverseBase;
public abstract class BaseRequest<TOut>
{
private static readonly XmlSerializerNamespaces EmptyNamespaces = GetEmptyXmlSerializerNamespaces();
private static readonly XmlWriterSettings XmlWriterSettings = new() { OmitXmlDeclaration = true };
private static readonly XmlSerializer XmlSerializer = new(typeof(FetchType));
public abstract HttpMethod HttpMethod { get; }
public abstract string GetRequest();
public abstract string? GetBody();
protected static string Serialize(FetchType fetchType)
{
var stringBuilder = new StringBuilder();
using var writer = XmlWriter.Create(stringBuilder, XmlWriterSettings);
XmlSerializer.Serialize(writer, fetchType, EmptyNamespaces);
return stringBuilder.ToString();
}
private static XmlSerializerNamespaces GetEmptyXmlSerializerNamespaces()
{
var namespaces = new XmlSerializerNamespaces();
namespaces.Add(string.Empty, string.Empty);
return namespaces;
}
}
Теперь для класса запроса через FetchXML можно использовать такой код:
private class Request : BaseRequest<Response[]>
{
public override HttpMethod HttpMethod => HttpMethod.Get;
public override string? GetBody() => null;
public override string GetRequest() => "mysch_mytables?fetchXml=" + GetFetchXml();
private static string GetFetchXml()
{
var fetch = new FetchType
{
top = "3",
Items = new object[]
{
new FetchEntityType
{
name = "mysch_mytable",
Items = new object[]
{
new FetchAttributeType
{
name = "mysch_myid"
},
new filter
{
Items = new[]
{
new condition
{
attribute = "mysch_mytype",
@operator = @operator.ne,
value = "null"
}
}.ToArray<object>()
}
}
}
}
};
var fetchXml = Serialize(fetch);
return fetchXml;
}
}
Код получился сложнее из-за объектного представления, но при этом появилась возможность удобной параметризации и комбинирования. Внезапно у сгенерированных классов есть две особенности, которые могут вызывать ошибки:
при использовании значения по умолчанию для поля класса необходимо явно добавлять поле с тем же именем, но с постфиксом Specified (принудительная сериализация). Например, distinct = true требует указания distinctSpecified = true.
поскольку массивы в сгенерированных классах имеют тип object[], необходимо следить за преобразованием в .ToArray<object>() из списков через IEnumerable. Так как типизация пропадает, легко спутать объект и массив объектов в поле Items.
Сложный постраничный запрос
Рассмотрим кое-что посложнее. Добавим пейджинг, вложенные JOIN, агрегацию и сортировку. Возьмём запрос для поиска, аналогичный этому SQL:
SELECT
mysch_mytable.mysch_mytype
,COUNT(mysch_mytable.mysch_myid) AS 'mysch_myid_Count'
,MAX(mysch_mytable.createdon) AS 'createdon_Max'
,MIN(mysch_relatedtable.mysch_filesize) AS 'mysch_filesize_Min'
FROM [dbo].[mysch_mytable] AS mysch_mytable
JOIN [dbo].[mysch_relatedtable_mysch_mytable] AS relation
ON mysch_mytable.mysch_myid = relation.mysch_mytableid
JOIN [dbo].[mysch_relatedtable] AS mysch_relatedtable
ON mysch_relatedtable.mysch_relatedtableid = relation.mysch_relatedtableid
WHERE mysch_mytable.createdon >= '2021-03-06'
AND mysch_mytable.createdon < '2022-03-07'
GROUP BY mysch_mytype
ORDER BY MAX(mysch_mytable.createdon) DESC
OFFSET 3 ROWS
FETCH NEXT 3 ROWS ONLY
Тогда класс запроса может выглядеть вот так:
private class Request : BaseRequest<Response[]>
{
public override HttpMethod HttpMethod => HttpMethod.Get;
public override string? GetBody() => null;
public override string GetRequest() => "mysch_mytables?fetchXml=" + GetFetchXml();
private static string GetFetchXml()
{
var innerJoin = new FetchLinkEntityType
{
name = "mysch_relatedtable_mysch_mytable",
from = "mysch_myid",
to = "mysch_mytableid",
Items = new object[]
{
new FetchLinkEntityType
{
name = "mysch_relatedtable",
from = "mysch_relatedtableid",
to = "mysch_relatedtableid",
Items = new object[]
{
new FetchAttributeType
{
name = "mysch_filesize",
aggregate = AggregateType.min,
aggregateSpecified = true,
alias = "mysch_filesize_Min"
}
}
}
}
};
var items = new object[]
{
new FetchAttributeType
{
name = "mysch_mytype",
groupby = FetchBoolType.@true,
groupbySpecified = true,
alias = "mysch_mytype"
},
new FetchAttributeType
{
name = "mysch_myid",
aggregate = AggregateType.count,
aggregateSpecified = true,
alias = "mysch_myid_Count"
},
new FetchAttributeType
{
name = "createdon",
aggregate = AggregateType.max,
aggregateSpecified = true,
alias = "createdon_Max"
},
new filter
{
Items = new object[]
{
new condition
{
attribute = "createdon",
@operator = @operator.ge,
value = "2021-03-06"
},
new condition
{
attribute = "createdon",
@operator = @operator.lt,
value = "2022-03-07"
}
}
},
innerJoin,
new FetchOrderType
{
alias = "createdon_Max",
descending = true
}
};
var fetch = new FetchType
{
page = "2",
count = "3",
aggregate = true,
aggregateSpecified = true,
Items = new object[]
{
new FetchEntityType
{
name = "mysch_mytable",
Items = items
}
}
};
var fetchXml = Serialize(fetch);
return fetchXml;
}
}
А соответствующий ему FetchXML – вот так:
<fetch count="3" page="2" aggregate="true">
<entity name="mysch_mytable">
<attribute name="mysch_mytype" alias="mysch_mytype" groupby="true"/>
<attribute name="mysch_myid" alias="mysch_myid_Count" aggregate="count"/>
<attribute name="createdon" alias="createdon_Max" aggregate="max"/>
<filter>
<condition attribute="createdon" operator="ge" value="2021-03-06"/>
<condition attribute="createdon" operator="lt" value="2022-03-07"/>
</filter>
<link-entity name="mysch_relatedtable_mysch_mytable" to="mysch_mytableid" from="mysch_myid">
<link-entity name="mysch_relatedtable" to="mysch_relatedtableid" from="mysch_relatedtableid">
<attribute name="mysch_filesize" alias="mysch_filesize_Min" aggregate="min"/>
</link-entity>
</link-entity>
<order alias="createdon_Max" descending="true"/>
</entity>
</fetch>
Сложность при работе с Dataverse в том, что, ожидая SQL-поведения от базы, ты забываешь, что некоторые стандартные функции SQL ограничены или отключены.
Запрос более 5000 строчек и paging cookie
При запросе большого (хе-хе) количества данных мы встречаемся с ограничением базы в 5000 записей. Для получения бóльшего количества на помощь приходит механизм paging cookie – использование ссылки на первую запись в новой пачке. Полученное в ответе значение из @Microsoft.Dynamics.CRM.fetchxmlpagingcookie (FetchXmlPagingCookie) необходимо передать в следующий запрос.
Пример FetchXmlPagingCookie в ответе:
<cookie pagenumber=\"2\" pagingcookie=\"%253ccookie%2520page%253d%25222%2522%253e%253cmysch_myid%2520last%253d%2522%257b2AE7C651-F554-EB11-A812-000D3A10255B%257d%2522%2520first%253d%2522%257bC296A62D-F554-EB11-A812-000D3A10255B%257d%2522%2520%252f%253e%253c%252fcookie%253e\" istracking=\"False\" />
Значение для передачи дважды Url-закодировано и находится в атрибуте pagingcookie:
<cookie page="2"><mysch_myid last="{2AE7C651-F554-EB11-A812-000D3A10255B}" first="{C296A62D-F554-EB11-A812-000D3A10255B}" /></cookie>
Для дальнейшего использования в запросе его нужно экранировать для XML и снова Url-кодировать. Код для этих преобразований добавляем в OdataResponse<T>:
public string? GetPagingCookie()
{
if (FetchXmlPagingCookie == null)
{
return null;
}
var match = CookieRegex.Match(FetchXmlPagingCookie);
if (match.Groups.Count != 2)
{
return null;
}
var pagingCookieTwiceEncoded = match.Groups[1].Value;
var pagingCookie = HttpUtility.UrlDecode(HttpUtility.UrlDecode(pagingCookieTwiceEncoded));
return HttpUtility.UrlEncode(HttpUtility.HtmlEncode(pagingCookie));
}
Пример значения, готового для передачи в следующий запрос:
%26lt%3bcookie+page%3d%26quot%3b2%26quot%3b%26gt%3b%26lt%3bmysch_myid+last%3d%26quot%3b%7b2AE7C651-F554-EB11-A812-000D3A10255B%7d%26quot%3b+first%3d%26quot%3b%7bC296A62D-F554-EB11-A812-000D3A10255B%7d%26quot%3b+%2f%26gt%3b%26lt%3b%2fcookie%26gt%3b
Тогда класс для последовательного доступа к базе через пачки по 5000 записей может выглядеть вот так:
using Dataverse.Habr.Intro.DataverseBase;
using System.Text.Json.Serialization;
namespace Dataverse.Habr.Intro.Handlers;
public class LinesCount : IHandler
{
public const int MaxCount = 10_000;
public string Text => $"Slow sequential count (max {MaxCount})";
public string Handle(ConnectionProvider connectionProvider)
{
try
{
var count = 0;
var page = 0;
string? pagingCookie = null;
do
{
var result = connectionProvider.ProcessRequest(new Request((++page, pagingCookie)));
pagingCookie = result.GetPagingCookie();
count += result.Value.Length;
} while (pagingCookie != null && count < MaxCount);
return count.ToString();
}
catch (Exception ex)
{
return ex.Message;
}
}
private class Request : BaseRequest<Response[]>
{
private readonly (int, string?) _pageAndPagingCookie;
public Request((int, string?) pageAndPagingCookie)
=> _pageAndPagingCookie = pageAndPagingCookie;
public override HttpMethod HttpMethod => HttpMethod.Get;
public override string? GetBody() => null;
public override string GetRequest()
=> "mysch_mytables?fetchXml=" + GetFetchXml(_pageAndPagingCookie);
private static string GetFetchXml((int, string?) pageAndPagingCookie)
{
var fetch = new FetchType
{
page = pageAndPagingCookie.Item1.ToString(),
pagingcookie = pageAndPagingCookie.Item2,
Items = new object[]
{
new FetchEntityType
{
name = "mysch_mytable",
Items = new object[]
{
new FetchAttributeType
{
name = "mysch_myid"
}
}.ToArray()
}
}
};
var fetchXml = Serialize(fetch);
return fetchXml;
}
}
private class Response
{
[JsonPropertyName("mysch_myid")]
public string Field { get; set; }
}
}
Форматированные данные
При создании отдельных колонок в Dataverse есть возможность задавать специфические типы данных, такие как Choice, Currency, Yes/No и другие. Такое представление хранится в виде кода и для получения форматированного значения требуется добавить специальный заголовок Prefer со значением “odata.include-annotations=OData.Community.Display.V1.FormattedValue” в запрос. Для этого ещё немного расширим класс BaseRequest<TOut>, добавив в него флаг возврата форматированных значений:
public virtual bool ReturnFormattedValues => false;
Тогда в ConnectionProvider перед передачей запроса добавляется проверка флага форматирования:
if (baseRequest.ReturnFormattedValues)
{
_httpClient.DefaultRequestHeaders.Add(
"Prefer",
"odata.include-annotations=OData.Community.Display.V1.FormattedValue");
}
В запросе мы указываем колонку, которая соответствует основному значению (коду), а в ответе нас интересует именно форматированное значение. Объекты запроса и ответа будут выглядеть следующим образом:
private class Request : BaseRequest<Response[]>
{
public override HttpMethod HttpMethod => HttpMethod.Get;
public override string? GetBody() => null;
public override bool ReturnFormattedValues => true;
public override string GetRequest() => "mysch_mytables?$select=mypaymenttype,mystatuscode&$top=1";
}
private class Response
{
[JsonPropertyName("mypaymenttype@OData.Community.Display.V1.FormattedValue")]
public string PaymentTypeText { get; set; }
[JsonPropertyName("mystatuscode@OData.Community.Display.V1.FormattedValue")]
public string StatusCodeText { get; set; }
}
Количество строчек в таблице
Ещё одна проблемная операция – это получение общего числа строк в таблице. Просто запросить COUNT(*) не получится. Либо придётся последовательно запрашивать все строки пачками по 5000 записей (как мы сделали это выше), либо использовать встроенную функцию RetrieveTotalRecordCount, которая возвращает не совсем актуальное значение из снимка базы (snapshot от последних 24-ёх часов).
curl https://your-project-development.api.crm.dynamics.com/api/data/v9.2/RetrieveTotalRecordCount(EntityNames=["mysch_mytable"]) -H "Authorization: Bearer eyJ0eXAiOiJ…"
Ограничения базы данных (429 Too Many Requests)
Напоследок хотел бы добавить, что при работе с Microsoft Dataverse необходимо следить за ограничениями API базы данных. Следует не выходить за заданные рамки по количеству одновременных запросов, запросов внутри интервала времени и длительности запроса. Так как рамки довольно узкие, стоит рассмотреть применение политик (паттерна) Retry для сглаживания пиков использования.
Заключение
После некоторого привыкания к Microsoft Dataverse перестаёшь плеваться, но порог этого привыкания довольно высок. Надеюсь, моя статья поможет тем, кто только начинает работать с этим чудесным инструментом. Конечно, в статье рассмотрены не все аспекты Dataverse Web API – это только введение. Есть еще много важных тем, которые требуют отдельного изучения: изменение данных, транзакции, LateMaterialize, SQL Hints и другие. Успехов вам!
Ещё раз ссылка на код.