I'm writing an Application that contains a nested structure like this one:
public class Country
{
public string Name { get; set; }
public List<State> States { get; set; } = new();
}
public class State
{
public string Name { get; set; }
public List<City> Cities { get; set; }
}
public class City
{
public string Name { get; set; }
public List<Shop> Shops { get; set; }
}
public class Shop
{
public string Name { get; set; }
public int Area { get; set; }
}
with the mock data:
private static List<Country> GenerateData()
{
List<Country> countries = new();
Country country = new()
{
Name = "USA",
States = new List<State>()
{
new State()
{
Name = "Texas",
Cities = new List<City>()
{
new City()
{
Name = "Dallas",
Shops = new List<Shop>()
{
new Shop()
{
Name = "Walmart",
Area = 30000
},
new Shop()
{
Name = "Walmart",
Area = 40000
}
}
},
new City()
{
Name = "Austin",
Shops = new List<Shop>()
{
new Shop()
{
Name = "Walmart",
Area = 20000
}
}
}
}
},
new State()
{
Name = "Alabama",
Cities = new List<City>()
{
new City()
{
Name = "Auburn",
Shops = new List<Shop>()
{
new Shop()
{
Name = "MyShop",
Area = 500
}
}
},
new City()
{
Name = "Dothan",
Shops = new List<Shop>()
{
new Shop()
{
Name = "MyShop2",
Area = 6000
}
}
}
}
}
}
};
countries.Add(country);
return countries;
}
My goal is to filter this nested structure like this:
Search for City name containing "Dal". Result shall be the full hierarchy from the root down to the shops. In that case:
- USA
- Texas
- Dallas
- Walmart (Area: 30000)
- Walmart (Area: 40000)
Another filter might be filtering the shop name, e.g. search for "MyShop2" would result in:
- USA
- Alabama
- Dothan
- MyShop2 (Area: 6000)
I'm somewhat familiar with linq, so filtering for the city's name can look like:
var result =
from country in countries
from state in country.States
from city in state.Cities
where city.Name.Contains("Dal", StringComparison.OrdinalIgnoreCase)
select city;
But in that case, I'll get the city and shops only. How to the get hierarchy (country and state) at the top in the result?
Same is for the second search:
var result =
from country in countries
from state in country.States
from city in state.Cities
from shop in city.Shops
where shop.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)
select shop;
Here I'll get only the shop without the hierarchy above...
CodePudding user response:
Since you need to select the whole hierarchy, you need to group the results by your topmost node, i.e., country, and rebuild from there
var result =
from country in countries
from state in country.States
from city in state.Cities
from shop in city.Shops
where shop.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)
select new { country, state, city, shop } into p
group p by p.country into g
let shops = g.Select(x => x.shop)
let cities = g.Select(x => x.city).Distinct()
let states = g.Select(x => x.state).Distinct()
select new Country()
{
Name = g.Key.Name,
States = states.Select(s => new State()
{
Name = s.Name,
Cities = s.Cities.Intersect(cities).Select(c => new City()
{
Name = c.Name,
Shops = c.Shops.Intersect(shops).ToList()
}).ToList()
}).ToList()
};
CodePudding user response:
You have the relevant path in a sense in the queries you've shown
var result =
from country in countries
from state in country.States
from city in state.Cities
where city.Name.Contains("Dal", StringComparison.OrdinalIgnoreCase)
select (country, state, city);
This will select triples that specify a path to a given city. Now you want to take such triples and turn them into your class structure, so, well, we do just that. Define a Path class:
class FilteredPath
{
public Country? Country { get; init; }
public State? State { get; init; }
public City? City { get; init; }
public Shop? Shop { get; init; }
}
And then we write lots of code:
class FilteredBuilder
{
private List<Country> _countries = new();
public FilteredBuilder AddPath(FilteredPath path)
{
if (path.Country is null)
{
return this;
}
if (path.State is null)
{
AddFullCountry(path.Country);
}
else
{
Country country = GetOrCreateBy(_countries, c => c.Name == path.Country.Name);
country.Name = path.Country.Name;
AddPathTo(country, path);
}
return this;
}
private void AddFullCountry(Country country)
{
_countries.RemoveAll(c => c.Name == country.Name);
_countries.Add(country);
}
private void AddFullState(Country country, State state)
{
country.States.RemoveAll(s => s.Name == state.Name);
country.States.Add(state);
}
private void AddFullCity(State state, City city)
{
state.Cities.RemoveAll(c => c.Name == city.Name);
state.Cities.Add(city);
}
private void AddFullShop(City city, Shop shop)
{
city.Shops.RemoveAll(s => s.Name == shop.Name && s.Area == shop.Area);
city.Shops.Add(shop);
}
private void AddPathTo(Country country, FilteredPath path)
{
Debug.Assert(path.State is not null);
if (path.City is null)
{
AddFullState(country, path.State);
}
else
{
State state = GetOrCreateBy(country.States, s => s.Name == path.State.Name);
state.Name = path.State.Name;
AddPathTo(state, path);
}
}
private void AddPathTo(State state, FilteredPath path)
{
Debug.Assert(path.City is not null);
if (path.Shop is null)
{
AddFullCity(state, path.City);
}
else
{
City city = GetOrCreateBy(state.Cities, s => s.Name == path.City.Name);
city.Name = path.City.Name;
AddPathTo(city, path);
}
}
private void AddPathTo(City city, FilteredPath path)
{
Debug.Assert(path.Shop is not null);
AddFullShop(city, path.Shop);
}
private static T GetOrCreateBy<T>(ICollection<T> source, Func<T, bool> predicate) where T : new()
{
T? result = source.FirstOrDefault(predicate);
if (result is null)
{
result = new T();
source.Add(result);
}
return result;
}
public List<Country> Build()
{
var result = _countries;
_countries = new();
return result;
}
}
Fire it up like this:
var result =
from country in countries
from state in country.States
from city in state.Cities
where city.Name.Contains("Dal", StringComparison.OrdinalIgnoreCase)
select (country, state, city);
var paths = result.Select(r => new FilteredPath
{
Country = r.country,
State = r.state,
City = r.city
});
var builder = new FilteredBuilder();
foreach (var path in paths)
{
builder.AddPath(path);
}
var filtered = builder.Build();
There a few considerations to take if you want this in production:
- Looking up
Lists is inefficient. You probably want a helper type for each entity that will have aDictionaryto lookup their children by name, and then convert to your original model at the end. - There surely is a way to make this more generic and avoid code repetition, but, to quote a classic, "I didn't have time to make it short".
However it does generalize to different types of queries. You're filtering states? Just pass null to both City and Shop of the FilteredPath.
Working demo: https://dotnetfiddle.net/IHIOZk
