Kako sestaviti “like a Google” iskalnik s pomočjo izraznega drevesa dinamičnih LINQ poizvedb

11.04.2022

Vsekakor ste kdaj v svojih razvojnih projektih prišli do situacije, ko bi si želeli izdelali univerzalen in napreden iskalnik, kot je npr. Google. Ta bi iskal po vsebini vaše aplikacije, v ozadju pa poizvedbe po bazi izvajate preko LINQ poizvedb.

Sama baza je z vašo aplikacijo povezana preko Entity Framework Object-Relational Mappinga (ORM). Za te namreč verjetno veste, da v osnovi niso ravno dinamične oz. prilagojene temu, da bi se preko njih lahko na enostaven način izdelalo dinamično poizvedbo, katera je osnova za prijazen iskalnik tako uporabniku kot programerju. 

Torej bolj “po domače” povedano, želite imeti za iskalnik nekaj takega, kot prikazuje spodnja slika:

  • V spustnem izbirnem meniju imate možnost izbrati, po katerem sklopu podatkov iščete željeni iskalni niz (1), 
  • samo vnosno polje za iskalni niz (2), katero sprejme tudi wildcard znak (*) kot tudi znak za negacijo (!)
    • moj* - išči vse besede, ki se začnejo na moj
    • *nes – išči vse besede, ki se končajo na nes
    • *obe* - išči vse besede, ki vsebujejo znake obe
    • !tvo* – išči vse besede, ki se ne začnejo na tvo
    • * - išči vse zapise, ki vsebujejo nek podatek (niso prazne)
    • in še vse ostale kombinacije, ki vam pridejo na pamet
  • pa seveda par dodatnih (že pred-definiranih) opcij, kot so iskanje po korenu ali po obrazilu (3)

Če si npr želite iskati po polju z imeno StudentName in sicer po besedi Billie, potem bi enostavna LINQ poizvedba izgledala nekako tako:
var podatki = _context.Table.Where(x => x.StudentName.Equals("Billie");

Iz tega ven lahko razberete, da bi morali za iskanje po kakršnemkoli drugem polju narediti ločen query stavek, saj ta podatek ne nastopa v LINQ poizvedbah kot dinamična/nastavljiva spremenljivka, ki se jo lahko spreminja v času izvajanja. Prav tako bi bil potreben ločen staven za kakršnokoli drugačno iskanje, katero ni tipa Equals (npr. StartsWith, EndsWith, Contains itd). Torej to pomeni ogromno neke podobne kode v vaši logiki iskalnika, ogromno nekih if/elseif/else ali switch stavkov, kar posledično pomeni veliko vrstic kode, katera bi lahko bila poenostavljena. Za to nastopijo tu izrazna drevesa dinamičnih LINQ poizvedb. Pa poglejmo primer moje rešitve takega iskalnika.

Izrazno drevo dinamične LINQ poizvedbe, ki je enakovredna statični LINQ poizvedbi, kot jo vidite zgoraj, si lahko predstavljate na tak način:

Torej kot lahko vidite, je potrebno sestavljati vsak posamezen del vaše LINQ poizvedbe od spodaj navzgor:

  • torej najprej morebitne konstante (constant) in polja (member) po katerih iščete
  • nato operacijo (npr Equal) katero izvedete nad elementi prve alineje
  • In seveda na koncu izdelate nek lambda izraz iz druge alineje zgoraj

Kot vidite, gre zgoraj za enostavno poizvedbo primerjanja enega polja z eno konstanto na način, ali je vrednost v polju enaka vrednosti konstante (equal oz ==). 
Predstavljajte si, da si želite imeti sestavljen lambda izraz iz več kot enega pogoja. Ali pa da želite sam podatek, ki ga preverjate (oz kot smo ga zgoraj poimenovali t.i. polje), predhodno / pred preverjanjem še normalizirati (ToUpperCase), pa mogoče odstraniti odvečne začetne in končne prazne prostore (Trim). V vseh teh primerih izrazno drevo hitro razraste oz. se poveča.

Pa začnimo s sestavljanjem naše kode oz logike sestavljanja izraznega drevesa za naš “like a Google” iskalnik.

1. Najprej je potrebno izdelati metodo, ki bo znala glede na iskalni niz, izbrati ustrezno operacijo (Equals, NotEmpty, StartsWith, EndsWith, Contains). S temi osnovnimi operacijami namreč lahko dosežemo že kar napreden iskalnik po naši vsebini oz pokrijemo skoraj vse vsakodnevne potrebe po iskanju. Metoda bi izgledala takole:

private static string Expression_PrepareData(string iskalniNiz, out string methodName, out bool isWildCardSearch, out bool isNot)
{
    char wildcard = '*';
    isNot = false;

    methodName = "Equals";
    isWildCardSearch = false;

    if (iskalniNiz.Equals(wildcard))
    {
        iskalniNiz = "";
        isWildCardSearch = true;
        methodName = "NotEmpty";
    }
    else 
    {
        if (iskalniNiz[0] == '!')
        {
            iskalniNiz = iskalniNiz.Remove(0, 1);
            isNot = true;
        }

        var txtLength = iskalniNiz.Length;
        iskalniNiz = iskalniNiz.TrimEnd(wildcard);
        if (txtLength > iskalniNiz.Length)
        {
            isWildCardSearch = true;
            methodName = "StartsWith";
            txtLength = iskalniNiz.Length;
        }

        iskalniNiz = iskalniNiz.TrimStart(wildcard);
        if (txtLength > iskalniNiz.Length)
        {
            isWildCardSearch = true;
            methodName = (methodName == "StartsWith") ? "Contains" : "EndsWith";
        }
    }

    return iskalniNiz;
}

Kot lahko iz zgornje kode opazite, najprej nastavimo osnoven način iskanja na “Equals”. Če je iskalni niz enak točno in samo “*”, potem bomo uporabili metodo “NotEmpty”. V vseh ostalih primerih pa:

  • Če je začetek iskalnega niza enak “!”, potem bomo imeli negacijo (nastavimo isNot out paremeter).
  • Če se iskalni niz konča z znakom “*”, potem bomo imeli operacijo “StartsWith”
  • Če se iskalni niz začne z znakom “*”, potem:
    • Če se je tudi s tem znakom končal bomo imeli operacijo “Contains”
    • Sicer bomo imeli operacijo “EndsWith”

2. Naslednja metoda bo tista, ki bo sestavljala drevesno strukturo dinamičnega LINQ querya:

public static Expression Expression_CreateSearchLINQQuery(Type pType, string pName, Type p2Type, string p2Name, string iskalniNiz, bool isDateTime, out ParameterExpression e, bool? iskanjePoKorenu = null, bool? iskanjePoObrazilu = null)
{
    bool isWildCardSearch;
    string methodName;
    bool isNot;
    iskalniNiz = Expression_PrepareData(iskalniNiz, out methodName, out isWildCardSearch, out isNot);

    var ucIskalniNiz = iskalniNiz.ToUpper();

    if (isDateTime)
    {
        ucIskalniNiz = ucIskalniNiz.Replace("  ", " ").Replace(". ", ".").Replace(": ", ":");

        if (ucIskalniNiz.StartsWith("0"))
            ucIskalniNiz = ucIskalniNiz.Substring(1, ucIskalniNiz.Length - 1);

        ucIskalniNiz = ucIskalniNiz.Replace(".0", ".").Replace(" 0", " ").Replace(":0", ":");
    }

    var c = Expression.Constant(ucIskalniNiz, typeof(string));

    e = Expression.Parameter(pType, "x");
    var piO = pType.GetProperty(pName);

    var m0 = Expression.MakeMemberAccess(e, piO);

    Expression mt = null;
    if (p2Type != null && !String.IsNullOrEmpty(p2Name))
    {
        var m1 = Expression.MakeMemberAccess(m0, p2Type.GetProperty(p2Name));
        mt = Expression_CheckNull(m0, Expression.Constant(""), m1);
    }
    else
        mt = m0;

    if ((Nullable.GetUnderlyingType(piO.PropertyType) != null) || (Type.GetTypeCode(piO.PropertyType) == TypeCode.String))
        mt = Expression_CheckNull(mt, Expression.Constant(""), mt);

    var m = Expression.Call(Expression.Convert(mt, typeof(object)), typeof(object).GetMethod("ToString"));

    var miToUpper = typeof(string).GetMethod("ToUpper", new Type[] { });
    var pi = Expression.Call(m, miToUpper);

    Expression c2 = null;
    if (isWildCardSearch)
    {
        if (methodName.Equals("NotEmpty"))
        {
            c2 = Expression.NotEqual(pi, Expression.Constant("", typeof(string)));
        }
        else
        {
            var mi = typeof(string).GetMethod(methodName, new Type[] { typeof(string) });
            c2 = Expression.Call(pi, mi, c);
        }
    }
    else if (iskanjePoKorenu.HasValue && iskanjePoKorenu.Value && iskanjePoObrazilu.HasValue && iskanjePoObrazilu.Value)
    {
        var mi = typeof(string).GetMethod("Contains", new Type[] { typeof(string) });
        c2 = Expression.Call(pi, mi, c);
    }
    else if (iskanjePoKorenu.HasValue && iskanjePoKorenu.Value)
    {
        var mi = typeof(string).GetMethod("StartsWith", new Type[] { typeof(string) });
        c2 = Expression.Call(pi, mi, c);
    }
    else if (iskanjePoObrazilu.HasValue && iskanjePoObrazilu.Value)
    {
        var mi = typeof(string).GetMethod("EndsWith", new Type[] { typeof(string) });
        c2 = Expression.Call(pi, mi, c);
    }
    else
    {
        var mi = typeof(string).GetMethod("Equals", new Type[] { typeof(string) });
        c2 = Expression.Call(pi, mi, c);
    }

    if (isNot == true)
    {
        c2 = Expression.Not(c2);
    }

    return c2;
}

private static Expression Expression_CheckNull(Expression m, Expression trueE, Expression falseE)
{
    return Expression.Condition(Expression.Equal(m, Expression.Constant(null)), trueE, falseE);
}

Kot lahko vidimo, je klic metode spisan do te mere, da lahko sprejme naše LINQ dinamične poizvedbe kot polje, po katerem išče, do globine 2 (to v preslikavi iz podatkovne baze preko Entity Framework-a in LINQ poizvedb pomeni, da lahko poizvedujemo do globine dveh povezanih tabel). Lahko bi bila metoda spisana bolj optimalno in bi tukaj sprejela dinamično globino – dinamično število povezanih tabel, preko katerih delaš query stavek. Recimo da to predstavlja ena izmed nadgradenj te naše metode.

Globino 2 predstavlja spodnja kombinacija vhodnih podatkov zgornje metode: 
Type pType, string pName, Type p2Type, string p2Name


Med ostalimi podatki so še informacija o temu, ali gre za datumsko polje. Datum namreč predstavlja par posebnosti v samih LINQ dinamičnih izrazih, predvsem zaradi formata pisanja datuma in ure.

Prav tako so tu še informacije o temu, ali gre za iskanje po korenu ali za iskanje po obrazilu (tista dodatna dva checkboxa pod točko 3. na prvi sliki zgoraj).

Kot lahko vidite, najprej pokličemo prejšnjo metodo, katera ugotovi način iskanja (equals, startswith itd).
Če iščemo po datumu, je potrebno narediti univerzalnost iskanja po različnih tipih zapisov (11.01.2022, 11.1.2022, 11. 1. 2022). Teh posebnosti je še več, če imamo zraven še uro.

Nato izberemo konstanto in lastnost (property), po kateremu iščemo. Seveda, če gre za povezani tabeli, vse skupaj povežemo še v globino na 2. nivoju. Ob tem je potrebno seveda paziti tudi na to, ali gre slučajno za null oz prazen podatek: Expression_CheckNull(…)

Zelo pomembno je, da preveriš ali gre za nullable podatkovni tip. Če je, ga moraš spremeniti v konstanto "".

Prav tako za vse ostale podatkovne tipe, ki niso tekstovni oz tipa string (npr int, double itd) izvedeš pretvorbo v string (ToString() metoda), saj lahko samo nad njimi delaš operacije, ki smo jih prej pripravljali v metodi Expression_PrepareData.

Ker želimo narediti iskalnik, ki ignorira velike/male črke, želimo vse skupaj normalizirati v ToUpper. Za  tem rabimo izdelati samo še sestavljanje klicev glede na način iskanja preko metode Expression.Call(…).

3. Kot zadnji korak je tukaj še naša glavna metoda, ki vse skupaj pokliče in dokonča – sestavi LINQ dinamičen izraz in ga izvede na naši podatkovni bazi preko Entity Frameworka ORMja:

public async Task<List<Iztocnica>> Iztocnica_GetCurrentIztocnicas(HttpContext context, string sender = null, List<string> tipiIskanja = null, List<string> iskalniNizi = null, List<string> vezajiIskanja = null, bool? iskanjePoKorenu = null, bool? iskanjePoObrazilu = null)

{
    List<Iztocnica> returnV = new List<Iztocnica>();
    bool filtriram = false;

    try
    {
        var sIDs = ...

        var startReturnV = _context.Iztocnicas.Where(x => sIDs.Contains(x.Slovar.ID));

        if (tipiIskanja != null && tipiIskanja.Count > 0)
        {
            var jePrvaIteracija = true;

            for (var indx = 0; indx <= iskalniNizi.Count - 1; indx++)
            {
                var iskalniNiz = iskalniNizi[indx];
                var tipIskanja = tipiIskanja[indx];
                var vezajIskanja = vezajiIskanja[indx];

                if (!String.IsNullOrEmpty(tipIskanja))
                {
                    filtriram = true;

                    List<Iztocnica> tempReturnV = new List<Iztocnica>();

                    string pName = null;
                    Type pType = null;
                    string p2Name = null;
                    Type p2Type = null;
                    bool isDateTime = false;
                    List<int> niIDs = new List<int>();

                    // glede na način izvor iskanja
                    if (sender == null || sender == "Iztocnice" || sender == "L")
                    {
                        switch (tipIskanja)
                        {
                            case IztocniceTipiIskanjaConst.BesednaVrsta:
                                pType = typeof(Iztocnica);
                                pName = "BesednaVrsta";

                                p2Type = typeof(BesednaVrsta);
                                p2Name = "Ime";
                                break;
                            case IztocniceTipiIskanjaConst.DatumPreveritve:
                                pType = typeof(Iztocnica);
                                pName = "CheckedStr";
                                break;
                            case IztocniceTipiIskanjaConst.IztocniceBrezUstreznika:
                                niIDs = await _context.RazlagaUstrs.Include(x => x.Razlaga).Select(x => x.Razlaga.IztocnicaID).Distinct().ToListAsync();
                                break;
                            // Zapišeš vse svoje načine iskanja (1. točka na prvi sliki zgoraj)
                        }
                    }
                    else if (sender == "Razlage")
                    {
                        switch (tipIskanja)
                        {
                            case RazlageTipiIskanjaConst.DatumPreveritve:
                                pType = typeof(Razlaga);
                                pName = "CheckedStr";
                                break;
                            // Zapišeš vse svoje načine iskanja (1. točka na prvi sliki zgoraj)
                        }
                    }

                    if (niIDs.Count() > 0)
                        tempReturnV.AddRange(await _context.Iztocnicas.Where(x => !niIDs.Contains(x.ID)).ToListAsync());

                    if (pType != null && !String.IsNullOrEmpty(pName))
                    {
                        ParameterExpression e = null;
                        var c2 = Helpers.Expression_CreateSearchLINQQuery(pType, pName, p2Type, p2Name, iskalniNiz, isDateTime, out e, iskanjePoKorenu, iskanjePoObrazilu);

                        if (pType == typeof(Iztocnica))
                        {
                            // če je iztočnica
                            var exp = Expression.Lambda<Func<Iztocnica, bool>>(c2, e);

                            if (p2Type != null && p2Type == typeof(BesednaVrsta))
                                tempReturnV = startReturnV.Include(x => x.BesednaVrsta).Where(exp.Compile()).ToList();
                            else if (p2Type != null && p2Type == typeof(BesednaVrstaOznaka))
                                tempReturnV = startReturnV.Include(x => x.BesednaVrstaOznaka).Where(exp.Compile()).ToList();
                            else if (p2Type != null && p2Type == typeof(User) && pName.Equals("CheckedBy"))
                                tempReturnV = startReturnV.Include(x => x.CheckedBy).Where(exp.Compile()).ToList();
                            // še vse ostale možne povezane tabele
                            else
                                tempReturnV = startReturnV.Where(exp.Compile()).ToList();
                        }
                        else if (pType == typeof(Kazalka))
                        {
                            // če je karkoli drugega - moraš dobit IDje iztočnic
                            var exp = Expression.Lambda<Func<Kazalka, bool>>(c2, e);
                            var iIDs = _context.Kazalkas.Include(x => x.PrednIztocnica).Where(exp.Compile()).Select(x => x.IztocnicaID).ToList();

                            tempReturnV = await startReturnV.Where(x => iIDs.Contains(x.ID)).ToListAsync();
                        }
                        // še vsi ostali izvori iskanja
                    }

                    // *** vezaji iskanja ***
                    if (tempReturnV.Count() > 0)
                    {
                        if (vezajIskanja == VezajIskanjaConst.In)
                        {
                            if (jePrvaIteracija && returnV.Count == 0)
                                returnV.AddUnique(tempReturnV);
                            else
                                returnV = returnV.Intersect(tempReturnV).ToList();
                        }
                        else
                            returnV.AddUnique(tempReturnV);

                        jePrvaIteracija = false;
                    }
                }
            }
        }

        // če nisi nič filtriral
        if (!filtriram && returnV.Count == 0)
            returnV = await startReturnV.ToListAsync();
    }
    catch (Exception ex)
    {

    }

    return returnV.OrderBy(x => x.Naziv).ToList();
}

V našem iskalniku je možnost imeti tudi sestavljene iskalne pogoje (zato imamo pri klicu metode možnost navesti seznam tipov iskanja, seznam iskalnik nizov in seznam vezajev (torej vezalni pogoji med iskalnimi nizi, kateri so IN oz ALI). 

List<string> tipiIskanja = null, List<string> iskalniNizi = null,

List<string> vezajiIskanja = null

Primer takega iskalnika si lahko ogledate na spodnji sliki.

To bo vse za tokrat.

Uspešno kodiranje in lep pozdrav

Gašper Rupnik
Vodja razvoja, predavatelj
MCT, MS, MCSD, MCPS
gasper.rupnik@kompas-xnet.si

Need assistance?
Need assistance?