Традиционно запросы OData к данным выражаются в виде простых строк без проверки типов при компиляции или без поддержки IntelliSense, кроме того, разработчику приходится изучать синтаксис языка запросов. Данная статья описывает библиотеку TsToOdata, которая превращает запросы в удобную языковую конструкцию и применяется аналогично классам и методам. Вы создаете запросы к строго типизированным коллекциям объектов с помощью ключевых слов языка TypeScript и знакомых операторов.
TsToOdata — библиотека для TypeScript. Она явлется подобием LINQ для C#, но, в отличие от последнего, предназначена только для запросов OData. Для разработчика, который создает запросы, наиболее очевидной частью TsToOdata является выражение запроса. Выражения запроса используют декларативный синтаксис, таким образом разработчик пишет, что нужно сделать, без указания как это делается. С помощью синтаксиса запроса можно выполнять фильтрацию, упорядочение и группирование данных из источника данных, обходясь минимальным объемом программного кода.
Создание модели данных
Первым делом нам надо получить отображение схемы OData на классы TypeScript.
Первым шагом потребуется сначала получить из EDMX Json схему. Для этого можно воспользоваться библиотекой OdataToEntity.
IEdmModel edmModel;
using (var reader = XmlReader.Create("edmx_schema.xml"))
edmModel = CsdlReader.Parse(reader);
var generator = new OeJsonSchemaGenerator(edmModel);
using (var utf8Json = new MemoryStream())
{
generator.Generate(utf8Json);
utf8Json.Position = 0;
File.WriteAllBytes("json_schema.json", utf8Json.ToArray());
}
Вторым ашгом из Json схемы мы можем уже получить модель данных на TypeScript. Для этого можно воспользоваться библиотекой quicktype.
В результате у меня получилась схема данных которую я буду использовать в дальнейших примерах.
Установка TsToOdata
npm install ts2odata
Создание контекста доступа к данным
import { EntitySet, OdataContext } from 'ts2odata';
import * as oe from './order';
export class OrderContext extends OdataContext<OrderContext> {
Categories = EntitySet.default<oe.Category>();
Customers = EntitySet.default<oe.Customer>();
OrderItems = EntitySet.default<oe.OrderItem>();
OrderItemsView = EntitySet.default<oe.OrderItemsView>();
Orders = EntitySet.default<oe.Order>();
}
let context: OrderContext = OdataContext.create(OrderContext, 'http://localhost:5000/api');
Примеры запросов
Получить все записи в таблицы
context.Orders;
//http://localhost:5000/api/Orders
Получить выбранные столбцы
context.Orders.select(o => { return { p: o.Name } });
//http://localhost:5000/api/Orders?$select=Name
Сортировка по возрастанию
context.Orders.orderby(i => i.Id);
//http://localhost:5000/api/Orders?$orderby=Id
Сортировка по убыванию
context.Orders.orderbyDescending(i => i.Id);
//http://localhost:5000/api/Orders?$orderby=Id desc
Фильтрация
context.Orders.filter(o => o.Date.getFullYear() == 2016);
//http://localhost:5000/api/Orders?$filter=year(Date) eq 2016
Получить связанные данные
context.Orders.expand(o => o.Items);
//http://localhost:5000/api/Orders?$expand=Items
Получить связанные данные на несколько вложенных уровней
context.Customers.expand(c => c.Orders).thenExpand(o => o.Items);
//http://localhost:5000/api/Customers?$expand=Orders($expand=Items)
Пропустить несколько записей
context.Orders.orderby(i => i.Id).skip(2);
//http://localhost:5000/api/Orders?$orderby=Id&$skip=2
Взять несколько записей
context.Orders.orderby(i => i.Id).top(3);
//http://localhost:5000/api/Orders?$orderby=Id&$top=3
Группировка
context.OrderItems.groupby(i => { return { Product: i.Product } });
//localhost:5000/api/OrderItems?$apply=groupby((Product))
Агрегация
context.OrderItems.groupby(i => { return { OrderId: i.OrderId, Status: i.Order.Status } })
.select(g => {
return {
orderId: g.key.OrderId,
avg: g.average(i => i.Price),
dcnt: g.countdistinct(i => i.Product),
max: g.max(i => i.Price),
max_status: g.max(_ => g.key.Status),
min: g.min(i => i.Price),
sum: g.sum(i => i.Price),
cnt: g.count()
}});
//http://localhost:5000/api/OrderItems?$apply=groupby((OrderId,Order/Status),aggregate(Price with average as avg,Product with countdistinct as dcnt,Price with max as max,Order/Status with max as max_status,Price with min as min,Price with sum as sum,$count as cnt))
Выборка по ключу
context.Customers.key({ Country: 'RU', Id: 1 });
//http://localhost:5000/api/Customers(Country='RU',Id=1)
Выборка по ключу и навигационному свойству
context.OrderItems.key(1, i => i.Order.Customer);
//http://localhost:5000/api/OrderItems(1)/Order/Customer
Вычесляемые столбцы
context.OrderItems
.select(i => {
return {
product: i.Product,
Total: i.Count * i.Price,
SumId: i.Id + i.OrderId
}
});
//http://localhost:5000/api/OrderItems?$select=Product&$compute=Count mul Price as Total,Id add OrderId as SumId
Лямбда операторы
context.Orders.filter(o => o.Items.every(i => i.Price >= 2.1));
//http://localhost:5000/api/Orders?$filter=Items/all(d:d/Price ge 2.1)
context.Orders.filter(o => o.Items.some(i => i.Count > 2));
//http://localhost:5000/api/Orders?$filter=Items/any(d:d/Count gt 2)
IN оператор
let items = [1.1, 1.2, 1.3];
context.OrderItems.filter(i => items.includes(i.Price), { items: items });
//http://localhost:5000/api/OrderItems?$filter=Price in (1.1,1.2,1.3)
Количество записей
context.Orders.count();
//http://localhost:5000/api/Orders/$count
Вернуть контекст источника данных
Метод asEntitySet необходим когда надо выполнить сортировку по столбцам отсутствующим в выборке
context.Orders(o => o.AltCustomer).thenSelect(o => {{
p1: o.Address,
: o.Country,
: o.Id,
: o.Name,
: o.Sex
}}).asEntitySet().orderby(o => o.Id)
//http://localhost:5000/api/Orders?$expand=AltCustomer($select=Address,Country,Id,Name,Sex)&$orderby=Id
Остальные примеры можно посмотреть на GitHub.
Следует заметить, что методы select, expand, groupby изменяют контекст — их результатом становиться новый тип — и, чтобы продолжить выполнение в этом новом контексте, нужно использовать методы с приставкой then: thenFilter, thenExpand, thenOrderby, thenOrderbyDescending, thenSkip, thenTop. Методы select и thenSelect необратимо меняют контекст, и, чтобы вернуться обратно к родительскому контексту, надо применить метод asEntitySet.
Параметризация запросов
Запросы фильтрации — filter, выборки — select, групировки — groupby можно параметризировать, имена свойств объекта параметризации должны совпадать с именами переменных в коде запроса.
let count: number | null = null;
context.OrderItems.filter(i => i.Count == count, { count: count }); //http://localhost:5000/api/OrderItems?$filter=Count eq null
let s = {
altCustomerId: 3,
customerId: 4,
dateYear: 2016,
dateMonth: 11,
dateDay: 20,
date: null,
name: 'unknown',
status: "OdataToEntity.Test.Model.OrderStatus'Unknown'",
count1: 0,
count2: null,
price1: 0,
price2: null,
product1: 'unknown',
product2: 'null',
orderId: -1,
id: 1
};
context.Orders.filter(o => o.AltCustomerId == s.altCustomerId && o.CustomerId == s.customerId && (o.Date.getFullYear() == s.dateYear && o.Date.getMonth() > s.dateMonth && o.Date.getDay() < s.dateDay || o.Date == s.date) && o.Name.includes(s.name) && o.Status == s.status, s).expand(o => o.Items).thenFilter(i => (i.Count == s.count1 || i.Count == s.count2) && (i.Price == s.price1 || i.Price == s.price2) && (i.Product.includes(s.product1) || i.Product.includes(s.product2)) && i.OrderId > s.orderId && i.Id != s.id, s);
//http://localhost:5000/api/Orders?$filter=AltCustomerId eq 3 and CustomerId eq 4 and (year(Date) eq 2016 and month(Date) gt 11 and day(Date) lt 20 or Date eq null) and contains(Name,'unknown') and Status eq OdataToEntity.Test.Model.OrderStatus'Unknown'&$expand=Items($filter=(Count eq 0 or Count eq null) and (Price eq 0 or Price eq null) and (contains(Product,'unknown') or contains(Product,'null')) and OrderId gt -1 and Id ne 1)
Отображение функций
JavaScript | OData |
---|---|
Math.ceil | ceiling |
concat | concat |
includes | contains |
getDay | day |
endsWith | endswith |
Math.floor | floor |
getHours | hour |
indexOf | indexof |
stringLength | length |
getMinutes | minute |
getMonth | month |
Math.round | round |
getSeconds | second |
startsWith | startswith |
substring | substring |
toLowerCase | tolower |
toUpperCase | toupper |
trim | trim |
getFullYear | year |
Для длины строки нужно использовать OdataFunctions.stringLength
context.Customers.filter(c => OdataFunctions.stringLength(c.Name) == 5); //http://localhost:5000/api/Customers?$filter=length(Name) eq 5
Для длины массива нужно использовать OdataFunctions.arrayLength
context.Orders.filter(o => OdataFunctions.arrayLength(o.Items) > 2); //http://localhost:5000/api/Customers?$filter=Items/$count gt 2
Получение результатов
Методы описывающие запрос такие как select, filter и другие должны закачиваться методом getQueryUrl или toArrayAsync.
getQueryUrl возвращает URL запроса. Результатом этого кода на TypeScript:
let url: URL = context.Customers
.expand(c => c.AltOrders).thenExpand(o => o.Items).thenOrderby(i => i.Price)
.expand(c => c.AltOrders).thenExpand(o => o.ShippingAddresses).thenOrderby(s => s.Id)
.expand(c => c.Orders).thenExpand(o => o.Items).thenOrderby(i => i.Price)
.expand(c => c.Orders).thenExpand(o => o.ShippingAddresses).thenOrderby(s => s.Id)
.orderby(c => c.Country).orderby(c => c.Id).getQueryUrl();
будет OData запрос:
http://localhost:5000/api/Customers?$expand=
AltOrders($expand=Items($orderby=Price),ShippingAddresses($orderby=Id)),
Orders($expand=Items($orderby=Price),ShippingAddresses($orderby=Id))
&$orderby=Country,Id
toArrayAsync возвращает результат запроса в виде Json. Результатом этого кода на TypeScript:
context.Customers
.expand(c => c.Orders).thenSelect(o => { return { Date: o.Date } }).orderby(o => o.Date)
.asEntitySet().select(c => { return { Name: c.Name } }).orderby(c => c.Name).toArrayAsync();
будет Json:
[{
"Name": "Ivan",
"Orders": [{
"Date": "2016-07-04T19:10:10.8237573+03:00"
}, {
"Date": "2020-02-20T20:20:20.000002+03:00"
}
]
}, {
"Name": "Natasha",
"Orders": [{
"Date": "2016-07-04T19:10:11+03:00"
}
]
}, {
"Name": "Sasha",
"Orders": []
}, {
"Name": "Unknown",
"Orders": [{
"Date": null
}
]
}
]
Если нужно получить свойство как дату, а не строку, можно вызвать toArrayAsync с необязательным параметром OdataParser:
import { OdataParser } from 'ts2odata';
import schema from './schema.json';
let odataParser = new OdataParser(schema);
context.Orders.toArrayAsync(odataParser);
Типы перечислений (enum)
Если ваш OData сервис не поддерживает перечисления без пространства имен (Namespace), для правильной трансляции необходимо передать его значение в метод создания контекста данных:
let context: OrderContext = OdataContext.create(OrderContext, 'http://localhost:5000/api', 'OdataToEntity.Test.Model');
В некоторых случаях для правильной трансляции перечислений может потребоваться создание объекта OdataParser.
import { OdataParser } from 'ts2odata';
import schema from './schema.json';
let odataParser = new OdataParser(schema);
let context: OrderContext = OdataContext.create(OrderContext, 'http://localhost:5000/api', 'OdataToEntity.Test.Model', odataParser);
Исходный код
Исходный код лежит на GitHub.
В папке source — код Node пакета, в папке test — тесты.
Я надеюсь, мой проект TsToOdata будет Вам полезен и избавит Вас от рутины написания однообразного кода.