Pull to refresh

Типизированные запросы OData в TypeScript

Reading time 8 min
Views 4.3K


Традиционно запросы OData к данным выражаются в виде простых строк без проверки типов при компиляции или без поддержки IntelliSense, кроме того, разработчику приходится изучать синтаксис языка запросов. Данная статья описывает библиотеку TsToOdata, которая превращает запросы в удобную языковую конструкцию и применяется аналогично классам и методам. Вы создаете типизированные запросы с помощью ключевых слов языка TypeScript и знакомых операторов.


TsToOdata — библиотека для TypeScript. Она явлется подобием LINQ для C#, но, в отличие от последнего, предназначена только для запросов OData. Для разработчика, который создает запросы, наиболее очевидной частью TsToOdata является выражение запроса. Выражения запроса используют декларативный синтаксис, таким образом разработчик пишет, что нужно сделать, без указания как это делается. С помощью синтаксиса запроса можно выполнять фильтрацию, упорядочение и группирование данных из источника данных, обходясь минимальным объемом программного кода.


Установка TsToOdata


npm install ts2odata

Создание модели данных


Первым делом нам надо получить отображение схемы 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.
В результате у меня получилась схема данных которую я буду использовать в дальнейших примерах.


Создание контекста доступа к данным


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/thenFilter, выборки — select/thenSelect, групировки — 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 будет Вам полезен и избавит Вас от рутины написания однообразного кода.

Tags:
Hubs:
+7
Comments 2
Comments Comments 2

Articles