LINQ

LINQ (Language Integrated Query)

What is LINQ?

LINQ (Language Integrated Query) is uniform query syntax in C# to retrieve data from different sources and formats. It is integrated in C#, thereby eliminating the mismatch between programming languages and databases, as well as providing a single querying interface for different types of data sources. For example, SQL is a Structured Query Language used to save and retrieve data from a database. In the same way, LINQ is a structured query syntax built in C# to retrieve data from different types of data sources such as collections, ADO.Net DataSet, XML Docs, web service and MS SQL Server and other databases.

LINQ queries return results as objects. It enables you to uses object-oriented approach on the result set and not to worry about transforming different formats of results into objects.

The following example demonstrates a simple LINQ query that gets all strings from an array which contains a.

 1namespace ArgomentiAvanzati
 2{
 3  internal class Program
 4  {
 5      static void Main()
 6      {
 7          // Data source
 8          string[] names = { "Bill", "Steve", "James", "Johan" };
 9
10          // LINQ Query 
11          var myLinqQuery = from name in names
12                            where name.Contains('a')
13                            select name;
14          // Query execution
15          foreach (var name in myLinqQuery)
16              Console.Write(name + " ");
17      }
18  }
19}

In the above example, string array names is a data source. The following is a LINQ query which is assigned to a variable myLinqQuery.

1from name in names
2where name.Contains('a')
3select name;

The above query uses query syntax of LINQ. You will learn more about it in the Query Syntax chapter. You will not get the result of a LINQ query until you execute it. LINQ query can be execute in multiple ways, here we used foreach loop to execute our query stored in myLinqQuery. The foreach loop executes the query on the data source and get the result and then iterates over the result set. Thus, every LINQ query must query to some kind of data sources whether it can be array, collections, XML or other databases. After writing LINQ query, it must be executed to get the result.

Why LINQ?

To understand why we should use LINQ, let’s look at some examples. Suppose you want to find list of teenage students from an array of Student objects. Before C# 2.0, we had to use a ‘foreach’ or a ‘for’ loop to traverse the collection to find a particular object. For example, we had to write the following code to find all Student objects from an array of Students where the age is between 12 and 20 (for teenage 13 to 19):

Example 1 - Use for loop to find elements from the collection

  Ottieni il codice

 1namespace LINQDemo
 2{
 3    class Student
 4    {
 5        public int StudentID { get; set; }
 6        public string? StudentName { get; set; }
 7        public int Age { get; set; }
 8
 9        public override string ToString()
10        {
11            return string.Format($"[StudentID = {StudentID}, StudentName = {StudentName}, Age = {Age}]");
12        }
13    }
14    internal class Program
15    {
16        static void Main(string[] args)
17        {
18            Student[] studentArray =
19            {
20                new () { StudentID = 1, StudentName = "John", Age = 18},
21                new () { StudentID = 2, StudentName = "Steve",  Age = 21},
22                new () { StudentID = 3, StudentName = "Bill",  Age = 25},
23                new () { StudentID = 4, StudentName = "Ram" , Age = 20},
24                new () { StudentID = 5, StudentName = "Ron" , Age = 31},
25                new () { StudentID = 6, StudentName = "Chris",  Age = 17},
26                new () { StudentID = 7, StudentName = "Rob", Age = 19},
27            };
28            List<Student> students = [];
29            foreach (Student std in studentArray)
30            {
31                if (std.Age > 12 && std.Age < 20)
32                {
33                    students.Add(std);
34                }
35            }
36            //write result
37            foreach (var studente in students)
38            {
39                Console.WriteLine(studente);
40            }
41            Console.ReadLine();
42        }
43
44    }
45}

Use of for loop is cumbersome, not maintainable and readable. C# 2.0 introduced delegate, which can be used to handle this kind of a scenario, as shown below.

Example 2 - Use Delegates to Find Elements from the Collection

  Ottieni il codice

 1namespace LINQDemo2
 2{
 3    delegate bool FindStudent(Student std);
 4    class StudentExtension
 5    {
 6        public static List<Student> Where(Student[] stdArray, FindStudent del)
 7        {
 8            List<Student> resultList = [];
 9            foreach (Student std in stdArray)
10                if (del(std))
11                {
12                    resultList.Add(std);
13                }
14            return resultList;
15        }
16    }
17    class Student
18    {
19        public int StudentID { get; set; }
20        public string? StudentName { get; set; }
21        public int Age { get; set; }
22
23        public override string ToString()
24        {
25            return string.Format($"[StudentID = {StudentID}, StudentName = {StudentName}, Age = {Age}]");
26        }
27    }
28
29    internal class Program
30    {
31        static void Main(string[] args)
32        {
33            Student[] studentArray =
34            [
35                new () { StudentID = 1, StudentName = "John", Age = 18},
36                new () { StudentID = 2, StudentName = "Steve",  Age = 21},
37                new () { StudentID = 3, StudentName = "Bill",  Age = 25},
38                new () { StudentID = 4, StudentName = "Ram" , Age = 20},
39                new () { StudentID = 5, StudentName = "Ron" , Age = 31},
40                new () { StudentID = 6, StudentName = "Chris",  Age = 17},
41                new () { StudentID = 7, StudentName = "Rob", Age = 19},
42          ];
43
44            //List<Student> students = StudentExtension.Where(studentArray, delegate (Student std)
45            //{
46            //    return std.Age > 12 && std.Age < 20;
47            //});
48            //in alternativa al delegate si può usare una lambda
49            List<Student> students = StudentExtension.Where(studentArray,
50                        std => std.Age > 12 && std.Age < 20);
51            //write result
52            foreach (var studente in students)
53            {
54                Console.WriteLine(studente);
55            }
56            Console.ReadLine();
57        }
58
59    }
60}
61

So, with C# 2.0, you got the advantage of delegate in finding students with any criteria. You don’t have to use a for loop to find students using different criteria. For example, you can use the same delegate function to find a student whose StudentId is 5 or whose name is Bill, as below:

1List<Student> students = StudentExtension.where(studentArray, delegate(Student std) {
2        return std.StudentID == 5;
3    });
4
5//Also, use another criteria using same delegate
6List<Student> students = StudentExtension.where(studentArray, delegate(Student std) {
7        return std.StudentName == "Bill";
8    });

The C# team felt that they still needed to make the code even more compact and readable. So they introduced the extension method, lambda expression, expression tree, anonymous type and query expression in C# 3.0. You can use these features of C# 3.0, which are building blocks of LINQ to query to the different types of collection and get the resulted element(s) in a single statement.

Example 3 - Use LINQ to Find Elements from the Collection

  Ottieni il codice

 1
 2namespace LINQDemo3
 3{
 4    class Student
 5    {
 6        public int StudentID { get; set; }
 7        public string? StudentName { get; set; }
 8        public int Age { get; set; }
 9
10        public override string ToString()
11        {
12            return string.Format($"[StudentID = {StudentID}, StudentName = {StudentName}, Age = {Age}]");
13        }
14    }
15
16    class Program
17    {
18        static void Main(string[] args)
19        {
20            Student[] studentArray =
21            {
22                new () { StudentID = 1, StudentName = "John", Age = 18},
23                new () { StudentID = 2, StudentName = "Steve",  Age = 21},
24                new () { StudentID = 3, StudentName = "Bill",  Age = 25},
25                new () { StudentID = 4, StudentName = "Ram" , Age = 20},
26                new () { StudentID = 5, StudentName = "Ron" , Age = 31},
27                new () { StudentID = 6, StudentName = "Chris",  Age = 17},
28                new () { StudentID = 7, StudentName = "Rob", Age = 19},
29            };
30
31            // Use LINQ to find teenager students
32            Student[] teenAgerStudents = studentArray.Where(s => s.Age > 12 && s.Age < 20).ToArray();
33
34            // Use LINQ to find first student whose name is Bill 
35            Student? bill = studentArray.Where(s => s.StudentName == "Bill").FirstOrDefault();
36            //if (bill != null)
37            //{
38            //    Console.WriteLine(bill);
39            //}
40            // Use LINQ to find student whose StudentID is 5
41            Student? student5 = studentArray.Where(s => s.StudentID == 5).FirstOrDefault();
42
43            //write result
44            foreach (var studente in teenAgerStudents)
45            {
46                Console.WriteLine(studente);
47            }
48            Console.WriteLine("bill: " + bill);
49            Console.WriteLine("student5: " + student5);
50        }
51    }
52}

As you can see in the above example, we specify different criteria using LINQ operator and lambda expression in a single statement. Thus, LINQ makes code more compact and readable and it can also be used to query different data sources. For example, if you have a student table in a database instead of an array of student objects as above, you can still use the same query to find students using the Entity Framework.

Advantages of LINQ

• Familiar language: Developers don’t have to learn a new query language for each type of data source or data format.
• Less coding: It reduces the amount of code to be written as compared with a more traditional approach.
• Readable code: LINQ makes the code more readable so other developers can easily understand and maintain it.
• Standardized way of querying multiple data sources: The same LINQ syntax can be used to query multiple data sources.
• Compile time safety of queries: It provides type checking of objects at compile time.
• IntelliSense Support: LINQ provides IntelliSense for generic collections.
• Shaping data: You can retrieve data in different shapes.

LINQ API

We can write LINQ queries for the classes that implement IEnumerable<T> or IQueryable<T> interface.

System.Linq.Enumerable

The System.Linq.Enumerable class includes extension methods for the classes that implement IEnumerable<T> interface, for example all the built-in collection classes implement IEnumerable<T> interface and so we can write LINQ queries to retrieve data from the built-in collections.

System.Linq.Queryable

The System.Linq.Queryable class includes extension methods for classes that implement IQueryable<t> interface. The IQueryable<T> interface is used to provide querying capabilities against a specific data source where the type of the data is known. For example, Entity Framework api implements IQueryable<T> interface to support LINQ queries with underlying databases such as MS SQL Server. Also, there are APIs available to access third party data; for example, LINQ to Amazon provides the ability to use LINQ with Amazon web services to search for books and other items. This can be achieved by implementing the IQueryable interface for Amazon. The following figure shows the extension methods available in the Queryable class can be used with various native or third party data providers.

Come usare LINQ?

Ci sono due modi per usare LINQ: mediante l’uso delle “LINQ query” oppure mediante l’uso dei “LINQ methods”.

LINQ Query (Query Expression Syntax)

Query syntax is similar to SQL (Structured Query Language) for the database. It is defined within the C#.

LINQ Query Syntax:

1from <range variable> in <IEnumerable<T> or IQueryable<T> Collection>
2
3<Standard Query Operators> <lambda expression>
4
5<select or groupBy operator> <result formation>

The LINQ query syntax starts with from keyword and ends with select keyword. The following is a sample LINQ query that returns a collection of strings which contains a word “Tutorials”.

 1namespace ArgomentiAvanzati
 2{
 3    internal class Program
 4    {
 5        static void Main(string[] args)
 6        {
 7            // string collection
 8            IList<string> stringList = new List<string>() 
 9            {
10              "C# Tutorials",
11              "VB.NET Tutorials",
12              "Learn C++",
13              "MVC Tutorials" ,
14              "Java"
15            };
16            // LINQ Query Syntax
17            var result = from s in stringList
18                         where s.Contains("Tutorials")
19                         select s;
20
21            foreach (var str in result)
22            {
23                Console.WriteLine(str);
24            }
25        }
26    }
27}

Query syntax starts with a from clause followed by a Range variable. The from clause is structured like “From rangeVariableName in IEnumerableCollection”. In English, this means, from each object in the collection. It is similar to a foreach loop: foreach(Student s in studentList). After the From clause, you can use different Standard Query Operators to filter, group, join elements of the collection. There are around 50 Standard Query Operators available in LINQ. In the above figure, we have used where operator (aka clause) followed by a condition. This condition is generally expressed using lambda expression. LINQ query syntax always ends with a select or group clause. The select clause is used to shape the data. You can select the whole object as it is or only some properties of it. In the above example, we selected the each resulted string elements.
In the following example, we use LINQ query syntax to find out teenager students from the Student collection (sequence).

 1namespace ArgomentiAvanzati
 2{
 3  public class Student
 4  {
 5      public int StudentID { get; set; }
 6      public string? StudentName { get; set; }
 7      public int Age { get; set; }
 8  }
 9  internal class Program
10  {
11      static void Main(string[] args)
12      {
13          // Student collection
14          IList<Student> studentList = new List<Student>() 
15          {
16            new () { StudentID = 1, StudentName = "John", Age = 13},
17            new () { StudentID = 2, StudentName = "Mario",  Age = 21},
18            new () { StudentID = 3, StudentName = "Bill",  Age = 18},
19            new () { StudentID = 4, StudentName = "Ram" , Age = 20},
20            new () { StudentID = 5, StudentName = "Ron" , Age = 15}
21          };
22
23          // LINQ Query Syntax to find out teenager students
24          var teenAgerStudent = from s in studentList
25                                where s.Age > 12 && s.Age < 20
26                                select s;
27          Console.WriteLine("Teen age Students:");
28
29          foreach (Student std in teenAgerStudent)
30          {
31              Console.WriteLine(std.StudentName);
32          }
33      }
34  }
35}

Points to Remember on Query Syntax

  1. As name suggest, Query Syntax is same like SQL (Structure Query Language) syntax.
  2. Query Syntax starts with from clause and can be end with Select or GroupBy clause.
  3. Use various other operators like filtering, joining, grouping, sorting operators to construct the desired result.
  4. Implicitly typed variable - var can be used to hold the result of the LINQ query.

LINQ Methods (Fluent)

Method syntax (also known as fluent syntax) uses extension methods included in the Enumerable or Queryable static class, similar to how you would call the extension method of any class. The following is a sample LINQ method syntax query that returns a collection of strings which contains a word “Tutorials”.

 1// string collection
 2IList<string> stringList = new List<string>() 
 3{ 
 4    "C# Tutorials",
 5    "VB.NET Tutorials",
 6    "Learn C++",
 7    "MVC Tutorials" ,
 8    "Java" 
 9};
10
11// LINQ Method Syntax
12var result = stringList.Where(s => s.Contains("Tutorials"));

The extension method Where() is defined in the Enumerable class. If you check the signature of the Where extension method, you will find the Where method accepts a predicate delegate as Func<Student, bool>. This means you can pass any delegate function that accepts a Student object as an input parameter and returns a Boolean value as shown in the below figure. The lambda expression works as a delegate passed in the Where clause. The following example shows how to use LINQ method syntax query with the IEnumerable<T> collection.

 1// Student collection
 2IList<Student> studentList = new List<Student>() 
 3    { 
 4      new () { StudentID = 1, StudentName = "John", Age = 13},
 5      new () { StudentID = 2, StudentName = "Mario",  Age = 21},
 6      new () { StudentID = 3, StudentName = "Bill",  Age = 18},
 7      new () { StudentID = 4, StudentName = "Ram" , Age = 20},
 8      new () { StudentID = 5, StudentName = "Ron" , Age = 15} 
 9    };
10
11// LINQ Method Syntax to find out teenager students
12var teenAgerStudents = studentList.Where(s => s.Age > 12 && s.Age < 20)
13                                  .ToList<Student>();

Altro esempio:

 1namespace ArgomentiAvanzati
 2{
 3  public class Program
 4  {
 5      public static void Main()
 6      {
 7          // Student collection
 8          IList<Student> studentList = new List<Student>() 
 9          {
10              new () { StudentID = 1, StudentName = "John", Age = 13},
11              new () { StudentID = 2, StudentName = "Mario", Age = 21},
12              new () { StudentID = 3, StudentName = "Bill", Age = 18},
13              new () { StudentID = 4, StudentName = "Ram" , Age = 20},
14              new () { StudentID = 5, StudentName = "Ron" , Age = 15}
15          };
16
17          Func<Student, bool> isStudentTeenAger = s => s.Age > 12 && s.Age < 20;
18          var teenAgerStudent = studentList.Where(isStudentTeenAger);
19
20          Console.WriteLine("Teen age Students:");
21
22          foreach (Student std in teenAgerStudent)
23          {
24              Console.WriteLine(std.StudentName);
25          }
26
27          //oppure
28          var teenAgerStudents = from s in studentList
29                                  where isStudentTeenAger(s)
30                                  select s;
31
32          Console.WriteLine("Teen age Students:");
33          foreach (Student std in teenAgerStudents)
34          {
35              Console.WriteLine(std.StudentName);
36          }
37      }
38  }
39
40  public class Student
41  {
42      public int StudentID { get; set; }
43      public string? StudentName { get; set; }
44      public int Age { get; set; }
45
46  }
47}

Standard Query Operators

Standard Query Operators in LINQ are actually extension methods for the IEnumerable<T> and IQueryable<T> types. They are defined in the System.Linq.Enumerable and System.Linq.Queryable classes. There are over 50 standard query operators available in LINQ that provide different functionalities like filtering, sorting, grouping, aggregation, concatenation, etc.

Standard Query Operators in Query Syntax:

1var teenAgerStudents = from s in studentList
2                                  where s.Age > 12 && s.Age < 20
3                                  select s;

Standard Query Operators in Method Syntax (Fluent):

1var teenAgerStudents = studentList.Where(s => s.Age > 12 && s.Age < 20)
2                                  .ToList<Student>();

Standard Query Operators can be classified based on the functionality they provide. The following table lists all the classification of Standard Query Operators:

ClassificationStandard Query Operators
FilteringWhere, OfType
SortingOrderBy, OrderByDescending, ThenBy, ThenByDescending, Reverse
GroupingGroupBy, ToLookup
JoinGroupJoin, Join
ProjectionSelect, SelectMany
AggregationAggregate, Average, Count, LongCount, Max, Min, Sum
QuantifiersAll, Any, Contains
ElementsElementAt, ElementAtOrDefault, First, FirstOrDefault, Last, LastOrDefault, Single, SingleOrDefault
SetDistinct, Except, Intersect, Union
PartitioningSkip, SkipWhile, Take, TakeWhile
ConcatenationConcat
EqualitySequenceEqual
GenerationDefaultEmpty, Empty, Range, Repeat
ConversionAsEnumerable, AsQueryable, Cast, ToArray, ToDictionary, ToList

Gli esempi di tutte le funzioni presenti nella tabella precedente sono reperibili qui

Points to Remember on LINQ Method Syntax

  1. As name suggest, Method Syntax is like calling extension method.
  2. LINQ Method Syntax aka Fluent syntax because it allows series of extension methods call.
  3. Implicitly typed variable var can be used to hold the result of the LINQ query.

Esercitazione guidata 1 - LINQ Gym

  Ottieni il codice

  1using System.Collections;
  2
  3namespace LINQGym
  4{
  5    class Student
  6    {
  7        public int StudentID { get; set; }
  8        public string? StudentName { get; set; }
  9        public int Age { get; set; }
 10        public double MediaVoti { get; set; }
 11
 12        public override string ToString()
 13        {
 14            return string.Format($"[StudentID = {StudentID}, StudentName = {StudentName}, Age = {Age}, MediaVoti = {MediaVoti}]");
 15        }
 16    }
 17
 18    class Assenza
 19    {
 20        public int ID { get; set; }
 21        public DateTime Giorno { get; set; }
 22        public int StudentID { get; set; }
 23    }
 24
 25    class Persona
 26    {
 27        public string? Nome { get; set; }
 28        public int Eta { get; set; }
 29
 30        public override string ToString()
 31        {
 32            return string.Format($"[Nome = {Nome}, Età = {Eta}]");
 33        }
 34    }
 35    internal class Program
 36    {
 37        //stiamo definendo un tipo di puntatore a funzione
 38        delegate bool CondizioneRicerca(Student s);
 39
 40        public static void AzioneSuElemento(Student s)
 41        {
 42            Console.WriteLine(s);
 43        }
 44
 45        //metodo statico
 46        public static bool VerificaCondizione(Student s)
 47        {
 48            return s.Age >= 18 && s.Age <= 25;
 49        }
 50        static void Main(string[] args)
 51        {
 52            //condizione: non devono esistere due studenti con lo stesso StudentID
 53            //in questo caso si dice che StudentID è chiave primaria della collection
 54            Student[] studentArray1 = 
 55                {
 56                new () { StudentID = 1, StudentName = "John", Age = 18 , MediaVoti= 6.5},
 57                new () { StudentID = 2, StudentName = "Steve",  Age = 21 , MediaVoti= 8},
 58                new () { StudentID = 3, StudentName = "Bill",  Age = 25, MediaVoti= 7.4},
 59                new () { StudentID = 4, StudentName = "Ram" , Age = 20, MediaVoti = 10},
 60                new () { StudentID = 5, StudentName = "Ron" , Age = 31, MediaVoti = 9},
 61                new () { StudentID = 6, StudentName = "Chris",  Age = 17, MediaVoti = 8.4},
 62                new () { StudentID = 7, StudentName = "Rob",Age = 19  , MediaVoti = 7.7},
 63                new () { StudentID = 8, StudentName = "Robert",Age = 22, MediaVoti = 8.1},
 64                new () { StudentID = 11, StudentName = "John",  Age = 21 , MediaVoti = 8.5},
 65                new () { StudentID = 12, StudentName = "Bill",  Age = 25, MediaVoti = 7},
 66                new () { StudentID = 13, StudentName = "Ram" , Age = 20, MediaVoti = 9 },
 67                new () { StudentID = 14, StudentName = "Ron" , Age = 31, MediaVoti = 9.5},
 68                new () { StudentID = 15, StudentName = "Chris",  Age = 17, MediaVoti = 8},
 69                new () { StudentID = 16, StudentName = "Rob2",Age = 19  , MediaVoti = 7},
 70                new () { StudentID = 17, StudentName = "Robert2",Age = 22, MediaVoti = 8},
 71                new () { StudentID = 18, StudentName = "Alexander2",Age = 18, MediaVoti = 9},
 72                };
 73
 74            Student[] studentResultArray;
 75            List<Student> studentResultList;
 76
 77            //definiamo delle condizioni di ricerca
 78            //primo modo: uso di Func con lambda
 79            Func<Student, bool> condizioneDiRicerca = s => s.Age >= 18 && s.Age <= 25;
 80
 81            //secondo modo: uso di un delegato implementato attraverso lambda
 82            CondizioneRicerca condizioneDiRicerca2 = s => s.Age >= 18 && s.Age <= 25;
 83            //terzo modo: uso di un delegato che punta a un metodo precedentemente definito
 84            CondizioneRicerca condizioneDiRicerca3 = VerificaCondizione;
 85            //quarto modo: usiamo direttamente la lambda - il più comodo
 86
 87            //creiamo una lista con gli stessi oggetti presenti nell'array
 88            List<Student> studentList1 = [.. studentArray1];//equivalente a invocare studentArray1.ToList();
 89            //studiamo la clausola Where
 90            //trovare tutti gli studenti che hanno età compresa tra 18 e 25 anni, caso dell'array
 91            studentResultArray = studentArray1.Where(s => s.Age >= 18 && s.Age <= 25).ToArray();
 92            studentResultList = studentArray1.Where(s => s.Age >= 18 && s.Age <= 25).ToList();
 93            //verifichiamo che il risultato sia corretto con una stampa
 94            foreach (Student student in studentResultList)
 95            {
 96                Console.WriteLine(student);
 97            }
 98
 99            //processing sull'array risultato
100            Console.WriteLine("\nprocessing sull'array risultato");
101            foreach (Student student in studentResultArray)
102            {
103                Console.WriteLine(student);
104            }
105            //processing degli elementi di un array usando il LINQ
106            Console.WriteLine("\nEsempio di Action su array");
107            Array.ForEach(studentResultArray, AzioneSuElemento);
108            //Stampa media voti
109            Console.WriteLine("\nStampa età e  media voti");
110            Array.ForEach(studentResultArray, s =>
111            {
112                Console.Write(s.StudentName+ " ");
113                Console.Write(s.Age + " ");
114                Console.WriteLine(s.MediaVoti);
115            });
116
117            //processing degli elementi di una lista usando il LINQ
118            Console.WriteLine("\nProcessing degli elementi di una lista usando il LINQ");
119            studentResultList.ForEach(s =>
120            {
121                Console.Write(s.StudentName + " ");
122                Console.Write(s.Age + " ");
123                Console.WriteLine(s.MediaVoti);
124            });
125
126            //USO DELLA VERSIONE CON INDICE DELLA WHERE
127
128            //il metodo Where ha anche una versione con l'indice della collection
129            //in questo esempio prendiamo solo quelli che verificano la condizione sull'età e hanno indice pari
130            Console.WriteLine("selezioniamo solo quelli che verificano la condizione e hanno indice pari");
131
132            studentResultArray = studentArray1.Where(
133                (s, i) => (s.Age >= 18 && s.Age <= 25) && i % 2 == 0).ToArray();
134            Console.WriteLine("stampa su array");
135            Array.ForEach(studentResultArray, s => Console.WriteLine(s.StudentName + " age = " + s.Age));
136
137            studentResultList = studentList1.Where(
138                (s, i) => (s.Age >= 18 && s.Age <= 25) && i % 2 == 0).ToList();
139            Console.WriteLine("stampa su list");
140            studentResultList.
141                ForEach(s => Console.WriteLine(s.StudentName + " age = " + s.Age));
142
143            // E' possibile anche far applicare più volte la where per ottenere filtraggi multipli
144            Console.WriteLine("doppia where: quelli che verificano la condizione e che hanno ID>3");
145            studentResultList = studentList1.
146                Where(s => s.Age >= 18 && s.Age <= 25).
147                Where(s => s.StudentID > 3).ToList();
148            studentResultList.
149                ForEach(s => Console.WriteLine(s.StudentName + " age = " + s.Age + " ID = " + s.StudentID));
150
151            //Studiamo la clausola OfType
152            //nel caso di collection di tipo diverso è possibile trarre vantaggio dal metodo OfType
153            IList mixedList = new ArrayList
154            {
155                5,
156                "numero uno",
157                true,
158                "numero due",
159                new Student() { StudentID = 10, Age = 30, StudentName = "Roberto" }
160            };
161            List<string> mixedListResult = mixedList
162                .OfType<string>()
163                .ToList();
164            //IList mixedListResult2 =
165            //    (from s in mixedList.OfType<Student>()
166            //     where s.Age > 20
167            //     select s).
168            //    ToList();
169            List<Student> mixedListResult2 =mixedList
170                .OfType<Student>()
171                .Where(s => s.Age > 20)
172                .ToList();
173            Console.WriteLine("\nStampa del risultato con OfType method");
174            mixedListResult.ForEach(Console.WriteLine);
175            mixedListResult2.ForEach(Console.WriteLine);
176
177            //Studiamo la clausola OrderBy
178
179            //ordiniamo una lista di elementi
180            Console.WriteLine("\nOrdiniamo gli elementi di una lista con la clausola OrderBy");
181            Console.WriteLine("\nOrdiniamo in base all'età - LINQ method");
182            //per ordinare in ordine decrescente esiste OrderByDescending
183            //https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.orderbydescending
184            studentResultArray = [.. studentArray1.OrderBy(s => s.Age)];
185            Console.WriteLine("stampa su array");
186            Array.ForEach(studentResultArray, s => Console.WriteLine(s.StudentName + " age = " + s.Age));
187
188            studentResultList = [.. studentList1.OrderBy(s => s.Age)];
189            Console.WriteLine("\nstampa su list");
190            studentResultList.
191                ForEach(s => Console.WriteLine(s.StudentName + " age = " + s.Age));
192            //su una sola pipe
193            Console.WriteLine("Elaborazione di ordinamento e stampa su una sola pipe di codice");
194            studentList1
195                .OrderBy(s => s.Age)
196                .ToList()
197                .ForEach(s => Console.WriteLine(s.StudentName + " age = " + s.Age));
198
199            //ordinamenti multipli
200            Console.WriteLine("\nOrdinamenti multipli - LINQ method");
201            studentResultArray = [.. studentArray1
202                .OrderBy(s => s.Age)
203                .ThenBy(s => s.StudentName)];
204            Console.WriteLine("stampa su array");
205            Array.ForEach(studentResultArray, s => Console.WriteLine(s.StudentName + " age = " + s.Age));
206            //esiste anche la clausola ThenByDescending
207            //https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.thenbydescending
208            studentResultList = studentList1.OrderBy(s => s.Age).ThenBy(s => s.StudentName).ToList();
209            Console.WriteLine("\nstampa su list");
210            studentResultList.
211                ForEach(s => Console.WriteLine(s.StudentName + " age = " + s.Age));
212
213            //Studiamo la clausola select - proiettiamo gli elementi della sequenza in una nuova forma
214            Console.WriteLine("\nClausola select - LINQ method");
215            Console.WriteLine("\nUso di tipi anonimi");
216            //uso di tipi anonimi
217            //un tipo anonimo è un tipo che non ha una classe da cui discende, ma è creato direttamente a partire dalle properties
218            Array.ForEach(studentArray1
219                .Select(s => new { Nome = s.StudentName, Eta = s.Age })
220                .ToArray(),Console.WriteLine);
221            Console.WriteLine("\nUso di tipi anonimi - stampa solo il nome");
222            studentList1
223                .Select(s => new { Nome = s.StudentName, Eta = s.Age })
224                .ToList()
225                .ForEach(p => Console.WriteLine(p.Nome));
226
227            Console.WriteLine("\nUso di tipi non anonimi - stampa solo il nome");
228            studentList1
229                .Select(s => new Persona() { Nome = s.StudentName, Eta = s.Age })
230                .ToList()
231                .ForEach(p => Console.WriteLine(p.Nome));
232            Console.WriteLine("Primo esempio di raggruppamento - raggruppiamo in base all'età");
233            //Group By
234            //IEnumerable<IGrouping< int, Student>>?
235            var groupedResult = studentList1.GroupBy(s => s.Age);
236            foreach (var group in groupedResult)
237            {
238                Console.WriteLine("Group key(Age) = {0}", group.Key);
239                foreach (var student in group)
240                {
241                    Console.WriteLine("Student = {0}", student);
242                }
243                //calcoliamo una funzione di gruppo: min, max, avg, count
244                // funzione count: quanti studenti con la stessa età
245                Console.WriteLine("Numero studenti con la stessa età nel gruppo = {0}", group.Count());
246                Console.WriteLine("Valore medio dei voti = {0}", group.Average(s => s.MediaVoti));
247                Console.WriteLine("Voto massimo nel gruppo = {0}", group.Max(s => s.MediaVoti));
248                Console.WriteLine("Voto minimo nel gruppo = {0}", group.Min(s => s.MediaVoti));
249                
250                //C# implementa anche il metodo ToLookup che fa la stessa cosa di GroupBy ma la 
251                //differenza sta nel fatto che con grandi basi di dati ToLookup carica tutto il risultato in memoria
252                //GroupBy carica il risultato associato a una chiave quando serve
253                //https://stackoverflow.com/questions/10215428/why-are-tolookup-and-groupby-different
254                //https://stackoverflow.com/a/10215531
255            }
256            Console.WriteLine("STAMPA RAGGRUPPAMENTO PER NOME");
257            var groupedResult2 = studentList1.GroupBy(s => s.StudentName);
258            foreach (var group in groupedResult2)
259            {
260                Console.WriteLine("Chiave di raggruppamento (Nome) = " + group.Key);
261                foreach (var student in group)
262                {
263                    Console.WriteLine(student);
264                }
265                Console.WriteLine("Numero studenti omonimi: " + group.Count());
266                Console.WriteLine("Voto medio degli omonimi: " + group.Average(s => s.MediaVoti));
267            }
268            //intersezione tra due collection - Join
269            //creiamo un elenco di assenze di studenti
270            List<Assenza> assenzeList1 =
271            [
272                new (){ID = 1, Giorno = DateTime.Today, StudentID = 1 },
273                new (){ID = 2, Giorno = DateTime.Today.AddDays(-1) ,StudentID = 1 },
274                new (){ID = 3, Giorno = DateTime.Today.AddDays(-3), StudentID = 1 },
275                new (){ID = 4, Giorno = new DateTime(2020,11,30), StudentID = 2 },
276                new (){ID = 5, Giorno = new DateTime(2020,11,8), StudentID = 3 }
277            ];
278            //vogliamo riportare il nome dello studente e le date delle sue assenze 
279            //facciamo una join tra la lista degli studenti e la lista delle assenze degli studenti e poi facciamo la proiezione del risultato su un nuovo oggetto
280            var innerJoinStudentiAssenze = studentList1
281                .Join(assenzeList1,
282                    s => s.StudentID,
283                    a => a.StudentID,
284                    (s, a) => new { ID = s.StudentID, Nome = s.StudentName, GiornoAssenza = a.Giorno });
285            foreach (var obj in innerJoinStudentiAssenze)
286            {
287                Console.WriteLine($"ID = {obj.ID}, Nome = {obj.Nome}, GiornoAssenza = {obj.GiornoAssenza.ToShortDateString()}");
288            }
289        }
290    }
291}

Esercitazione guidata 2 – LINQ al Museo

  Ottieni il codice

Creare i seguenti POCO:

  • Artista (Id, Nome, Cognome, Nazionalità)
  • Opera (Id, Titolo, Quotazione, FkArtistaId)
  • Personaggio (Id, Nome, FkOperaId)

Creare per ciascun POCO una collection (lista) di tali oggetti:

  • artisti è una lista di oggetti di tipo Artista;
  • opere è una lista di oggetti di tipo Opera;
  • personaggi è una lista di oggetti di tipo Personaggio;
    Nota: le property che iniziano con Fk indicano una foreign key ossia una una property che “punta” ad una property di un altro oggetto. Ad esempio, i valori di FkOperaId devono corrispondere a valori dell’Id nella collection delle opere. In altri termini una foreign key è una property i cui valori devono corrispondere a valori già presenti nella collection a cui puntano.

Effettuare le seguenti query:

  1. Stampare le opere di un dato autore (ad esempio Picasso)
  2. Riportare per ogni nazionalità (raggruppare per nazionalità) gli artisti
  3. Contare quanti sono gli artisti per ogni nazionalità (raggruppare per nazionalità e contare)
  4. Trovare la quotazione media, minima e massima delle opere di Picasso
  5. Trovare la quotazione media, minima e massima di ogni artista
  6. Raggruppare le opere in base alla nazionalità e in base al cognome dell’artista (Raggruppamento in base a più proprietà)
  7. Trovare gli artisti di cui sono presenti almeno 2 opere
  8. Trovare le opere che hanno personaggi
  9. Trovare le opere che non hanno personaggi
  10. Trovare l’opera con il maggior numero di personaggi

Creazione del modello

  • Classe Artista

     1namespace LinqAlMuseo;
     2
     3public class Artista
     4{
     5    public int Id { get; set; }
     6    public string Nome { get; set; } = null!;
     7    public string Cognome { get; set; } = null!;
     8    public string? Nazionalita { get; set; }
     9
    10    public override string ToString()
    11    {
    12        return string.Format($"[ID = {Id}, Nome = {Nome},  Cognome = {Cognome}, Nazionalità = {Nazionalita}]"); ;
    13    }
    14
    15}
    

  • Classe Opera

     1namespace LinqAlMuseo;
     2
     3public class Opera
     4{
     5    public int Id { get; set; }
     6    public string Titolo { get; set; } = null!;
     7    public decimal Quotazione { get; set; }
     8    public int FkArtista { get; set; }
     9    public override string ToString()
    10    {
    11        return String.Format($"[ID = {Id}, Titolo = {Titolo}, Quotazione = {Quotazione},  FkArtista = {FkArtista}]"); ;
    12    }
    13}
    14
    

  • Classe Personaggio

     1namespace LinqAlMuseo;
     2
     3public class Personaggio
     4{
     5    public int Id { get; set; }
     6    public string Nome { get; set; } = null!;
     7    public int FkOperaId { get; set; }
     8    public override string ToString()
     9    {
    10        return string.Format($"[ID = {Id}, Nome = {Nome}, FkOperaId = {FkOperaId}]"); ;
    11    }
    12}
    13
    

Scrittura delle query in LINQ

  • Program.cs
      1//file Program.cs - utilizzo di Top Level Statements
      2//Esercizio: LINQ al museo
      3using LinqAlMuseo;
      4using System.Globalization;
      5using System.Text;
      6
      7//creazione delle collection
      8//si parte da quelle che non puntano a nulla, ossia quelle che non hanno chiavi esterne
      9IList<Artista> artisti =
     10    [
     11        new (){Id=1, Cognome="Picasso", Nome="Pablo", Nazionalita="Spagna"},
     12        new (){Id=2, Cognome="Dalì", Nome="Salvador", Nazionalita="Spagna"},
     13        new (){Id=3, Cognome="De Chirico", Nome="Giorgio", Nazionalita="Italia"},
     14        new (){Id=4, Cognome="Guttuso", Nome="Renato", Nazionalita="Italia"}
     15    ];
     16//poi le collection che hanno Fk
     17IList<Opera> opere =
     18    [
     19        new (){Id=1, Titolo="Guernica", Quotazione=50000000.00m , FkArtista=1},//opera di Picasso
     20        new (){Id=2, Titolo="I tre musici", Quotazione=15000000.00m, FkArtista=1},//opera di Picasso
     21        new (){Id=3, Titolo="Les demoiselles d’Avignon", Quotazione=12000000.00m,  FkArtista=1},//opera di Picasso
     22        new (){Id=4, Titolo="La persistenza della memoria", Quotazione=16000000.00m,  FkArtista=2},//opera di Dalì
     23        new (){Id=5, Titolo="Metamorfosi di Narciso", Quotazione=8000000.00m, FkArtista=2},//opera di Dalì
     24        new (){Id=6, Titolo="Le Muse inquietanti", Quotazione=22000000.00m,  FkArtista=3},//opera di De Chirico
     25    ];
     26IList<Personaggio> personaggi =
     27    [
     28        new (){Id=1, Nome="Uomo morente", FkOperaId=1},//un personaggio di Guernica 
     29        new (){Id=2, Nome="Un musicante", FkOperaId=2},
     30        new (){Id=3, Nome="una ragazza di Avignone", FkOperaId=3},
     31        new (){Id=4, Nome="una seconda ragazza di Avignone", FkOperaId=3},
     32        new (){Id=5, Nome="Narciso", FkOperaId=5},
     33        new (){Id=6, Nome="Una musa metafisica", FkOperaId=6},
     34    ];
     35
     36//impostiamo la console in modo che stampi correttamente il carattere dell'euro e che utilizzi le impostazioni di cultura italiana
     37Console.OutputEncoding = Encoding.UTF8;
     38Thread.CurrentThread.CurrentCulture = new CultureInfo("it-IT");
     39
     40//Le query da sviluppare sono:
     41//Effettuare le seguenti query:
     42//1)    Stampare le opere di un dato autore (ad esempio Picasso)
     43//2)    Riportare per ogni nazionalità (raggruppare per nazionalità) gli artisti
     44//3)    Contare quanti sono gli artisti per ogni nazionalità (raggruppare per nazionalità e contare)
     45//4)    Trovare la quotazione media, minima e massima delle opere di Picasso
     46//5)    Trovare la quotazione media, minima e massima di ogni artista
     47//6)    Raggruppare le opere in base alla nazionalità e in base al cognome dell’artista (Raggruppamento in base a più proprietà)
     48//7)    Trovare gli artisti di cui sono presenti almeno 2 opere
     49//8)    Trovare le opere che hanno personaggi
     50//9)    Trovare le opere che non hanno personaggi
     51//10)   Trovare l’opera con il maggior numero di personaggi
     52
     53
     54//svolgimento delle query richieste
     55//1) Stampare le opere di un dato autore (ad esempio Picasso)
     56Console.WriteLine("**** 1) Stampare le opere di un dato autore (ad esempio Picasso)\n");
     57//facciamo prima il filtraggio con la Where e poi la join
     58var opereDiArtista = artisti
     59    .Where(a => a.Cognome == "Picasso")
     60    .Join(opere,
     61        a => a.Id,
     62        o => o.FkArtista,
     63        (a, o) => o.Titolo);
     64opereDiArtista.ToList().ForEach(Console.WriteLine);
     65//altro metodo: facciamo prima la Join e poi il filtraggio con la Where sull'autore
     66Console.WriteLine();
     67var opereDiArtista2 = artisti
     68    .Join(opere,
     69        a => a.Id,
     70        o => o.FkArtista,
     71        (a, o) => new { a, o })
     72    .Where(t => t.a.Cognome == "Picasso");
     73opereDiArtista2.ToList().ForEach(t => Console.WriteLine(t.o.Titolo));
     74Console.WriteLine();
     75//altro modo:
     76//step n.1: calcoliamo l'id di Picasso
     77//step. n.2: calcoliamo le opere di quell'autore
     78var autore = artisti.Where(a => a.Cognome == "Picasso").FirstOrDefault();
     79if (autore != null)
     80{
     81    var opereDiArtista3 = opere.Where(o => o.FkArtista == autore.Id);
     82    opereDiArtista3.ToList().ForEach(t => Console.WriteLine(t.Titolo));
     83}
     84
     85//2) Riportare per ogni nazionalità (raggruppare per nazionalità) gli artisti
     86Console.WriteLine("\n**** 2) Riportare per ogni nazionalità (raggruppare per nazionalità) gli artisti\n");
     87//raggruppare gli artisti per nazionalità
     88var artistiPerNazionalità = artisti.GroupBy(a => a.Nazionalita);
     89foreach (var group in artistiPerNazionalità)
     90{
     91    Console.WriteLine($"Nazionalità: {group.Key}");
     92    foreach (var artista in group)
     93    {
     94        Console.WriteLine($"\t{artista.Nome} {artista.Cognome}");
     95    }
     96}
     97
     98//3) Contare quanti sono gli artisti per ogni nazionalità (raggruppare per nazionalità e contare)
     99Console.WriteLine("\n**** 3) Contare quanti sono gli artisti per ogni nazionalità (raggruppare per nazionalità e contare)\n");
    100
    101foreach (var group in artistiPerNazionalità)
    102{
    103    Console.WriteLine($"Nazionalità: {group.Key} Numero artisti: {group.Count()}");
    104}
    105
    106//4) Trovare la quotazione media, minima e massima delle opere di Picasso
    107Console.WriteLine("\n**** 4) Trovare la quotazione media, minima e massima delle opere di Picasso\n");
    108//troviamo le opere di Picasso
    109var opereDiPicasso = artisti
    110    .Where(a => a.Cognome == "Picasso")
    111    .Join(opere,
    112        a => a.Id,
    113        o => o.FkArtista,
    114        (a, o) => o)
    115    .ToList();
    116//troviamo le quotazioni
    117var quotazioneMinima = opereDiPicasso.Min(o => o.Quotazione);
    118var quotazioneMedia = opereDiPicasso.Average(o => o.Quotazione);
    119var quotazioneMassima = opereDiPicasso.Max(o => o.Quotazione);
    120//stampiamo il risultato
    121Console.WriteLine($"Quotazione minima = {quotazioneMinima}, " +
    122    $"quotazione media = {quotazioneMedia:F2}, quotazione massima = {quotazioneMassima}");
    123
    124//5) Trovare la quotazione media, minima e massima di ogni artista
    125Console.WriteLine("\n**** 5) Trovare la quotazione media, minima e massima di ogni artista\n");
    126//raggruppiamo per artista (usando FkArtista) e poi su ogni gruppo di opere calcoliamo le funzioni di gruppo
    127var operePerArtista = opere.GroupBy(o => o.FkArtista);
    128foreach (var group in operePerArtista)
    129{
    130    Console.Write($"Id artista = {group.Key} ");
    131    //vogliamo conoscere i dettagli dell'artista di cui conosciamo l'id
    132    var artista = artisti.Where(a => a.Id == group.Key).FirstOrDefault();
    133    if (artista != null)
    134    {
    135        Console.Write($"{artista.Nome} {artista.Cognome} ");
    136    }
    137    Console.WriteLine($"Quotazione minima = {group.Min(o => o.Quotazione):C2};" +
    138        $" media = {group.Average(o => o.Quotazione):C2};" +
    139        $" massima = {group.Max(o => o.Quotazione):C2}");
    140}
    141
    142//stessa query - versione con inner join
    143//effettuiamo prima la join tra opere e artisti e poi il raggruppamento
    144var opereDiArtistaGroupBy = artisti
    145    .Join(opere,
    146        a => a.Id,
    147        o => o.FkArtista,
    148        (a, o) => new { a, o })
    149    .GroupBy(t => t.a.Id);
    150
    151foreach (var group in opereDiArtistaGroupBy)
    152{
    153    Console.Write($"Id artista = {group.Key} |");
    154    var artistaOpera = group.FirstOrDefault();
    155    if (artistaOpera != null)
    156    {
    157        Console.Write($"Cognome = {artistaOpera.a.Cognome}");
    158    }
    159    Console.WriteLine($" | Quotazione media ={group.Average(t => t.o.Quotazione):C2} " +
    160    $" | Quotazione minima = {group.Min(t => t.o.Quotazione):C2} " +
    161    $" | Quotazione massima = {group.Max(t => t.o.Quotazione):C2} ");
    162}
    163
    164//6) Raggruppare le opere in base alla nazionalità e in base al cognome dell’artista (Raggruppamento in base a più proprietà)
    165Console.WriteLine("\n**** 6) Raggruppare le opere in base alla nazionalità e in base al cognome dell'artista (Raggruppamento in base a più proprietà)\n");
    166var opereDiArtistaGroupByMultiplo = artisti
    167    .Join(opere,
    168        a => a.Id,
    169        o => o.FkArtista,
    170        (a, o) => new { a, o })
    171    .GroupBy(t => new { t.a.Nazionalita, t.a.Cognome });
    172
    173foreach (var group in opereDiArtistaGroupByMultiplo)
    174{
    175    Console.WriteLine($"{group.Key.Nazionalita} {group.Key.Cognome} ");
    176    foreach (var item in group)
    177    {
    178        Console.WriteLine($"\tOpera = {item.o.Titolo}");
    179    }
    180}
    181
    182//7)Trovare gli artisti di cui sono presenti almeno 2 opere
    183Console.WriteLine("\n**** 7) Trovare gli artisti di cui sono presenti almeno 2 opere\n");
    184//calcoliamo gli artisti di cui abbiamo almeno un'opera
    185var artistiConAlmeno1Opera = artisti
    186    .Join(opere,
    187        a => a.Id,
    188        o => o.FkArtista,
    189        (a, o) => a);
    190//per calcolare gli artisti di cui sono presenti almeno due opere procediamo così:
    191//raggruppiamo gli artisti per FkArtista e successivamente filtriamo in base al conteggio degli 
    192//elementi in ogni gruppo
    193Console.WriteLine("Artisti con almeno due opere");
    194var artistiConAlmeno2Opere = opere
    195    .GroupBy(o => o.FkArtista)
    196    .Where(g => g.Count() >= 2)
    197    .Join(artisti,
    198        g => g.Key,
    199        a => a.Id,
    200        (g, a) => a);
    201foreach (var artista in artistiConAlmeno2Opere)
    202{
    203    Console.WriteLine(artista);
    204}
    205
    206//altra variante - riportiamo gli artisti con il relativo numero di opere
    207opere
    208    .GroupBy(o => o.FkArtista)
    209    .Select(group => new { group.Key, NumeroOpere = group.Count() })
    210    .Where(g => g.NumeroOpere >= 2)
    211    .Join(artisti,
    212        a2 => a2.Key,
    213        a => a.Id,
    214        (a2, a) => new { ID = a.Id, NomeArtista = a.Cognome, a2.NumeroOpere })
    215    .ToList()
    216    .ForEach(Console.WriteLine);
    217
    218//8)Trovare le opere che hanno personaggi
    219Console.WriteLine("\n**** 8) Trovare le opere che hanno personaggi\n");
    220//le opere con personaggi (è una semplice join)
    221var opereConPersonaggi = opere
    222    .Join(personaggi,
    223        o => o.Id,
    224        p => p.FkOperaId,
    225        (o, p) => o)
    226    //quando si effettua la Join tra due collection A e B, si ottiene il "Prodotto Cartesiano" AxB tra la prima collection A e
    227    //la seconda collection B, seguito dalla condizione di selezione della Join, ossia l'uguaglianza dei keySelctor della Join.
    228    //Nel caso in esame, la join produce le ennuple (o, p) tali che o.Id == p.FkOperaId, tuttavia prendendo solo le opere per
    229    //ogni coppia (opera, personaggio), ossia facendo la proiezione (o,p) => o, si possono ottenere opere ripetute nel risultato finale.
    230    //Ad esempio, un'opera con due personaggi comparirà due volte nel risultato finale e nella stampa.
    231    //
    232    //Per evitare inutili duplicazioni si può usare la clausola Distinct che restituisce una collection senza elementi ripetuti.
    233    //👇👇👇
    234    .Distinct();
    235Console.WriteLine("Opere con personaggi");
    236foreach (var opera in opereConPersonaggi)
    237{
    238    Console.WriteLine(opera);
    239}
    240
    241//9)Trovare le opere che non hanno personaggi
    242Console.WriteLine("\n**** 9) Trovare le opere che non hanno personaggi\n");
    243//opere senza personaggi: dall'insieme delle opere prendo solo quelle che non sono contenute in opereConPersonaggi
    244var opereSenzaPersonaggi = opere
    245    .Where(o => !opereConPersonaggi.Contains(o));
    246
    247Console.WriteLine("Opere senza personaggi");
    248foreach (var opera in opereSenzaPersonaggi)
    249{
    250    Console.WriteLine(opera);
    251}
    252
    253//10)Trovare le opere con il maggior numero di personaggi
    254Console.WriteLine("\n**** 10) Trovare le opere con il maggior numero di personaggi\n");
    255//primo step: calcolo il numero di personaggi per opera
    256var personaggiPerOpera = personaggi
    257    .GroupBy(p => p.FkOperaId)
    258    .Select(group => new { IdOpera = group.Key, NumeroPersonaggi = group.Count() });
    259//secondo step: dobbiamo filtrare gli oggetti in modo da prendere solo quelli con il numero massimo di personaggi
    260var numeroMassimoPersonaggi = personaggiPerOpera.Max(t => t.NumeroPersonaggi);
    261//terzo step: filtro i dati in modo da prendere solo gli oggetti con il numero massimo di personaggi
    262var opereConMaxNumeroPersonaggi = personaggiPerOpera
    263    .Where(t => t.NumeroPersonaggi == numeroMassimoPersonaggi)
    264    .Join(opere,
    265        t => t.IdOpera,
    266        o => o.Id,
    267        (t, o) => new { id = o.Id, o.Titolo, Personaggi = t.NumeroPersonaggi });
    268foreach (var item in opereConMaxNumeroPersonaggi)
    269{
    270    Console.WriteLine(item);
    271}