blob: fc6e4b546b21e40e56ac743d7677184318e702ce [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright ownership.
// The ASF licenses this file to You under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance with
// the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
= .NET LINQ Queries
Ignite .NET client provides LINQ support that is integrated with Ignite SQL APIs. You can avoid working with SQL syntax directly and write queries in C# with LINQ. C# LINQ expressions are then translated into Ignite-specific SQL. For example, the following two snippets achieve the same result:
[tabs]
--
tab:LINQ[]
[source, csharp]
----
var table = await Client.Tables.GetTableAsync("TBL1");
IQueryable<Poco> query = table!.GetRecordView<Poco>().AsQueryable()
.Where(x => x.Key > 3)
.OrderBy(x => x.Key);
List<Poco> queryResults = await query.ToListAsync();
----
tab:SQL[]
[source, csharp]
----
var query = "select KEY, VAL from PUBLIC.TBL1 where (KEY > ?) order by KEY asc";
await using IResultSet<IIgniteTuple> resultSet = await Client.Sql.
ExecuteAsync(transaction: null, query, 3);
var queryResults = new List<Poco>();
await foreach (IIgniteTuple row in resultSet)
{
queryResults.Add(new Poco { Key = (long)row[0]!, Val = (string?)row[1] });
}
----
--
LINQ has the following advantages over SQL:
* Queries are strongly typed and checked at compilation;
* It is easier to write and maintain with IDE support (auto-completion, navigation, find usages);
* LINQ is refactoring-friendly: rename a column and all queries are updated at once;
* Ignite-specific SQL knowledge is not required, and most C# developers are already familiar with LINQ;
* LINQ is safe against SQL injections;
* Results are mapped to types naturally.
In real-world scenarios the performance of Ignite LINQ queries is on par with equivalent SQL queries.
However, a small overhead still exists (due to query translation), and your mileage may vary depending on the query complexity, so it is recommended to measure the performance of your queries.
== Getting Started With LINQ
Here is how you can create a simple table in Ignite:
1. Create a table:
+
[source, csharp]
----
await Client.Sql.ExecuteAsync(
null, @"CREATE TABLE PUBLIC.PERSON (NAME VARCHAR PRIMARY KEY, AGE INT)");
----
+
2. Define the classes (or records) that represent tables:
* Member names should match column names (case-insensitive).
* If a column name is not a valid C# identifier, use `[Column("name")]` attribute to specify the name.
+
[source, csharp]
----
public record Person(string Name, int Age, string Address, string Status);
----
+
3. Obtain a table reference:
+
[source, csharp]
----
ITable table = await Client.Tables.GetTableAsync("PERSON");
----
+
4. Use the `GetRecordView<T>()` method to get a typed view of the table:
+
[source, csharp]
----
IRecordView<Person> view = table.GetRecordView<Person>();
----
+
5. Use `AsQueryable()` to perform LINQ queries on `IRecordView<T>`.
+
[source, csharp]
----
List<string> names = await view.AsQueryable()
.Where(x => x.Age > 30)
.Select(x => x.Name)
.ToListAsync();
----
== Using LINQ
=== Inspecting Generated SQL
Viewing generated SQL is useful for debugging and performance tuning. There are two ways to do it:
* `IgniteQueryableExtensions.ToQueryString()` extension method:
+
[source, csharp]
----
IQueryable<Person> query = table.GetRecordView<Person>()
.AsQueryable()
.Where(x => x.Age > 30);
string sql = query.ToQueryString();
----
+
* Debug logging:
+
[source, csharp]
----
var cfg = new IgniteClientConfiguration
{
Logger = new ConsoleLogger { MinLevel = LogLevel.Debug },
...
};
using var client = IgniteClient.StartAsync(cfg);
...
----
All generated SQL will be logged with `Debug` level to the specified logger.
=== Transactions
Transaction can be passed to the LINQ provider by using the `AsQueryeable` parameter:
[source, csharp]
----
await using var tx = await client.Transactions.BeginAsync();
var view = (await client.Tables.GetTableAsync("person"))!.GetRecordView<Person>();
pocoView.AsQueryable(tx)...;
----
=== Custom Query Options
Custom query options (timeout, page size) can be specified by using the second `AsQueryable` parameter with `QueryableOptions`:
[source, csharp]
----
var options = new QueryableOptions
{
PageSize = 512,
Timeout = TimeSpan.FromSeconds(30)
};
table.GetRecordView<Person>().AsQueryable(options: options)...;
----
=== Result Materialization
Materialization is the process of converting query results (`IQueryable<T>`) into an object or a collection of objects.
LINQ is lazy. Nothing happens (no network calls, no SQL translation) until the query is materialized.
For example, the following code only constructs an expression, but does not execute anything:
[source, csharp]
----
IQueryable<Person> query = table!.GetRecordView<Person>().AsQueryable()
.Where(x => x.Key > 3)
.OrderBy(x => x.Key);
----
Query execution and materialization can be triggered in multiple ways:
==== Iteration
You can iterate through query results by using `foreach` statement, or asynchronously by using the `AsAsyncEnumerable` method:
[source, csharp]
----
foreach (var person in query) { ... }
await foreach (var person in query.AsAsyncEnumerable()) { ... }
----
==== Converting to Collections
You can convert queries to collections by using the `ToList` and `ToDictionary` methods, or `ToListAsync` and `ToDictionaryAsync` methods to do it asynchronously:
[tabs]
--
tab:Synchronous[]
[source, csharp]
----
List<Person> list = query.ToList();
Dictionary<string, int> dict = query.ToDictionary(x => x.Name, x => x.Age);
----
tab:Asynchronous[]
[source, csharp]
----
List<Person> list = await query.ToListAsync();
Dictionary<string, int> dict = await query.
ToDictionaryAsync(x => x.Name, x => x.Age);
----
--
==== Ignite-specific IResultSet
Underlying `IResultSet` can be obtained by using the `IgniteQueryableExtensions.ToResultSetAsync()` extension method:
[source, csharp]
----
await using IResultSet<Person> resultSet = await query.ToResultSetAsync();
Console.WriteLine(resultSet.Metadata);
var rows = resultSet.CollectAsync(...);
----
Obtaining `IResultSet` can be useful for access to metadata and `CollectAsync` method, which provides more control over result materialization.
== Supported LINQ Features
=== Projection
Projection is the process of converting query results into a different type.
Among other things, projections are used to select a subset of columns.
For example, `Person` table may have many columns, but we only need `Name` and `Age`.
* First, create a projection class:
+
[source, csharp]
----
public record PersonInfo(string Name, int Age);
----
+
* Then, use `Select` to project query results:
+
[source, csharp]
----
List<PersonInfo> result = query
.Select(x => new PersonInfo(x.Name, x.Age))
.ToList();
----
Resulting SQL will select only those two columns, avoiding overfetching
(a common issue that happens when ORM-generated query includes all table columns, but only a few of them are needed by the business logic).
Ignite also supports anonymous type projections:
[source, csharp]
----
var result = query.Select(x => new { x.Name, x.Age }).ToList();
----
=== Inner Joins
Use the standard `Join` method to perform joins on other tables:
[source, csharp]
----
var customerQuery = customerTable.GetRecordView<Customer>().AsQueryable();
var orderQuery = orderTable.GetRecordView<Order>().AsQueryable();
var ordersByCustomer = customerQuery
.Join(orderQuery,
cust => cust.Id,
order => order.CustId,
(cust, order) => new { cust.Name, order.Amount })
.ToList();
----
=== Outer Joins
Outer joins are supported through the `DefaultIfEmpty` method.
For example, not every book in a library is borrowed by a student, so a left outer join is used to retrieve all books and their current borrowers (if any):
[source, csharp]
----
var bookQuery = bookTable.GetRecordView<Book>().AsQueryable();
var studentQuery = studentTable.GetRecordView<Student>().AsQueryable();
var booksWithStudents = bookQuery
.Join(studentQuery.DefaultIfEmpty(),
book => book.StudentId,
student => student.Id,
(book, student) => new { book.Title, student.Name })
.ToList();
----
=== Grouping
Grouping is supported through `GroupBy` method. This is equivalent to SQL GROUP BY operator. You can get both single and multiple columns in your queries. When working with multiple columns, use anonymous type:
[tabs]
--
tab:Single Column[]
[source, csharp]
----
var bookCountByAuthor = bookTable.GetRecordView<Book>().AsQueryable()
.GroupBy(book => book.Author)
.Select(grp => new { Author = grp.Key, Count = x.Count() })
.ToList();
----
tab:Multiple Columns[]
[source, csharp]
----
var bookCountByAuthorAndYear = bookTable.GetRecordView<Book>().AsQueryable()
.GroupBy(book => new { book.Author, book.Year })
.Select(grp => new { Author = grp.Key.Author,
Year = grp.Key.Year,
Count = x.Count() })
.ToList();
----
--
Aggregate functions `Count`, `Sum`, `Min`, `Max`, `Average` can be used with groupings.
=== Ordering
`OrderBy`, `OrderByDescending`, `ThenBy`, `ThenByDescending` are supported. You can combine them to order by multiple columns:
[source, csharp]
----
var booksOrderedByAuthorAndYear = bookTable.GetRecordView<Book>().AsQueryable()
.OrderBy(book => book.Author)
.ThenByDescending(book => book.Year)
.ToList();
----
=== Union, Intersect, Except
Multiple result sets can be combined by using the `Union`, `Intersect`, `Except` methods. For example:
[source, csharp]
----
IQueryable<string> employeeEmails = employeeTable
.GetRecordView<Employee>().AsQueryable()
.Select(x => x.Email);
IQueryable<string> customerEmails = customerTable
.GetRecordView<Customer>().AsQueryable()
.Select(x => x.Email);
List<string> allEmails = employeeEmails.Union(customerEmails)
.OrderBy(x => x)
.ToList();
List<string> employeesThatAreCustomers = employeeEmails
.Intersect(customerEmails).ToList();
----
=== Aggregate Functions
Below is a list of .NET aggregate functions and their SQL equivalents that are supported in Ignite:
[cols="30%,30%,30%", width="70%"]
|===
|LINQ synchronous method |LINQ asynchronous method | SQL Operator
|First|FirstAsync|FIRST
|FirstOrDefault|FirstOrDefaultAsync|FIRST ... LIMIT 1
|Single|SingleAsync|FIRST
|SingleOrDefault|SingleOrDefaultAsync|FIRST ... LIMIT 2
|Max|MaxAsync|MAX
|Min|MinAsync|MIN
|Average|AverageAsync|AVG
|Sum|SumAsync|SUM
|Count|CountAsync|COUNT
|LongCount|LongCountAsync|COUNT
|Any|AnyAsync|ANY
|All|AllAsync|ALL
|===
Here are examples of how you can use these methods:
[tabs]
--
tab:Synchronous[]
[source, csharp]
----
Person first = query.First();
Person? firstOrDefault = query.FirstOrDefault();
Person single = query.Single();
Person? singleOrDefault = query.SingleOrDefault();
int maxAge = query.Max(x => x.Age);
int minAge = query.Min(x => x.Age);
int avgAge = query.Average(x => x.Age);
int sumAge = query.Sum(x => x.Age);
int count = query.Count();
long longCount = query.LongCount();
bool any = query.Any(x => x.Age > 30);
bool all = query.All(x => x.Age > 30);
----
tab:Asynchronous[]
[source, csharp]
----
Person first = await query.FirstAsync();
Person? firstOrDefault = await query.FirstOrDefaultAsync();
Person single = await query.SingleAsync();
Person? singleOrDefault = await query.SingleOrDefaultAsync();
int maxAge = await query.MaxAsync(x => x.Age);
int minAge = await query.MinAsync(x => x.Age);
int avgAge = await query.AverageAsync(x => x.Age);
int sumAge = await query.SumAsync(x => x.Age);
int count = await query.CountAsync();
long longCount = await query.LongCountAsync();
bool any = await query.AnyAsync(x => x.Age > 30);
bool all = await query.AllAsync(x => x.Age > 30);
----
--
=== Math Functions
The following `Math` functions are supported (will be translated to SQL equivalents):
`Abs`, `Cos`, `Cosh`, `Acos`, `Sin`, `Sinh`, `Asin`, `Tan`, `Tanh`, `Atan`, `Ceiling`, `Floor`,
`Exp`, `Log`, `Log10`, `Pow`, `Round`, `Sign`, `Sqrt`, `Truncate`.
The following `Math` functions are NOT supported (no equivalent in Ignite SQL engine):
`Acosh`, `Asinh`, `Atanh`, `Atan2`, `Log2`, `Log(x, y)`.
Here is the example of how you can use math functions:
[source, csharp]
----
var triangles = table.GetRecordView<Triangle>().AsQueryable()
.Select(t => new {
Hypotenuse,
Opposite = t.Hypotenuse * Math.Sin(t.Angle),
Adjacent = t.Hypotenuse * Math.Cos(t.Angle)
})
.ToList();
----
=== String Functions
The following string functions are supported: `string.Compare(string)`, `string.Compare(string, bool ignoreCase)`, concatenation `s1 + s2 + s3`, `ToUpper`, `ToLower`,
`Substring(start)`, `Substring(start, len)`,
`Trim`, `Trim(char)`, `TrimStart`, `TrimStart(char)`, `TrimEnd`, `TrimEnd(char)`,
`Contains`, `StartsWith`, `EndsWith`, `IndexOf`, `Length`, `Replace`.
Here is the example of how you can use string functions:
[source, csharp]
----
List<string> fullNames = table.GetRecordView<Person>().AsQueryable()
.Where(p => p.FirstName.StartsWith("Jo"))
.Select(p => new {
FullName = p.FirstName.ToUpper() +
" " +
p.LastName.ToLower() })
.ToList();
----
=== Regular Expressions
`Regex.Replace` is translated to `regexp_replace` function. Here is how you can use regular expressions in your code:
[source, csharp]
----
List<string> addresses = table.GetRecordView<Person>().AsQueryable()
.Select(p => new { Address = Regex.Replace(p.Address, @"(\d+)", "[$1]")
.ToList();
----
NOTE: Regular expression engine within SQL may behave differently from .NET engine.
=== DML (Bulk Update and Delete)
Bulk update and delete with optional conditions are supported through `ExecuteUpdateAsync` and `ExecuteDeleteAsync` extensions methods on `IQueryable<T>`:
[source, csharp]
----
var orders = orderTable.GetRecordView<Order>().AsQueryable();
await orders.Where(x => x.Amount == 0).ExecuteDeleteAsync();
----
Update statement can set properties to constant values or to an expression based on other properties of the same row:
[source, csharp]
----
var orders = orderTable.GetRecordView<Order>().AsQueryable();
await orders
.Where(x => x.CustomerId == customerId)
.ExecuteUpdateAsync(
order => order.SetProperty(x => x.Discount, 0.1m)
.SetProperty(x => x.Note, x => x.Note +
" Happy birthday, " +
x.CustomerName));
----
Resulting SQL:
[source, csharp]
----
update PUBLIC.tbl1 as _T0
set NOTE = concat(concat(_T0.NOTE, ?), _T0.CUSTOMERNAME), DISCOUNT = ?
where (_T0.CUSTOMERID IS NOT DISTINCT FROM ?)
----
=== Composing Queries
`IQueryable<T>` expressions can be composed dynamically. A common use case is to compose a query based on user input.
For example, optional filters on different columns can be applied to a query:
[source, csharp]
----
public List<Book> GetBooks(string? author, int? year)
{
IQueryable<Book> query = bookTable.GetRecordView<Book>().AsQueryable();
if (!string.IsNullOrEmpty(author))
query = query.Where(x => x.Author == author);
if (year != null)
query = query.Where(x => x.Year == year);
return query.ToList();
}
----
=== Column Name Mapping
Unless custom mapping is provided with `[Column]`, LINQ provider will use property or field names as column names,
using unquoted identifiers, which are case-insensitive.
[tabs]
--
tab:C#[]
[source, csharp]
----
bookTable.GetRecordView<Book>().AsQueryable().Select(x => x.Author).ToList();
----
tab:Resulting SQL[]
[source, csharp]
----
select _T0.AUTHOR from PUBLIC.books as _T0
----
--
To use quoted identifiers, or to map column names to different property names, use `[Column]` attribute:
[tabs]
--
tab:C#[]
[source, csharp]
----
public class Book
{
[Column("book_author")]
public string Author { get; set; }
}
// Or a record:
public record Book([property: Column("book_author")] string Author);
----
tab:Resulting SQL[]
[source, sql]
----
SELECT _T0."book_author" FROM PUBLIC.books AS _T0
----
--
=== KeyValueView
All examples above use `IRecordView<T>` to perform queries; LINQ provider supports `IKeyValueView<TK, TV>` equally well:
[source, csharp]
----
IQueryable<KeyValuePair<int, Book>> query =
bookTable.GetKeyValueView<int, Book>().AsQueryable();
List<Book> books = query
.Where(x => x.Key > 10)
.Select(x => x.Value)
.ToList();
----