From 0a62d37f4e19c1ca3c0e578ab72bde8ac1dc97ff Mon Sep 17 00:00:00 2001 From: Avior Date: Thu, 26 Sep 2024 00:11:48 +0200 Subject: [PATCH] feat: Add advanced filtering capabilities (#522) --- .bruno/cards/advanced-query.bru | 25 ++ .bruno/environments/Developpement.bru | 2 +- .bruno/sets/Advanced Query.bru | 21 + .github/workflows/test.yml | 9 + server/bun.lockb | Bin 88363 -> 94331 bytes server/src/V2/Components/Card.ts | 70 ++-- server/src/V2/Components/Serie.ts | 70 ++-- server/src/V2/Components/Set.ts | 66 ++-- server/src/V2/endpoints/jsonEndpoints.ts | 87 ++--- server/src/V2/graphql/index.ts | 4 +- server/src/V2/graphql/resolver.ts | 54 +-- server/src/libs/QueryEngine/filter.ts | 463 +++++++++++++++++++++++ server/src/libs/QueryEngine/parsers.ts | 195 ++++++++++ server/src/status.ts | 4 +- server/tsconfig.json | 3 +- 15 files changed, 887 insertions(+), 186 deletions(-) create mode 100644 .bruno/cards/advanced-query.bru create mode 100644 .bruno/sets/Advanced Query.bru create mode 100644 server/src/libs/QueryEngine/filter.ts create mode 100644 server/src/libs/QueryEngine/parsers.ts diff --git a/.bruno/cards/advanced-query.bru b/.bruno/cards/advanced-query.bru new file mode 100644 index 000000000..be0306014 --- /dev/null +++ b/.bruno/cards/advanced-query.bru @@ -0,0 +1,25 @@ +meta { + name: Advanced Query + type: http + seq: 1 +} + +get { + url: {{BASE_URL}}/v2/en/cards?name=eq:Pikachu&hp=gte:60&hp=lt:70&localId=5&localId=not:tg&id=neq:cel25-5 + body: none + auth: none +} + +params:query { + name: eq:Pikachu + hp: gte:60 + hp: lt:70 + localId: 5 + localId: not:tg + id: neq:cel25-5 +} + +assert { + res.status: eq 200 + res.body: length 14 +} diff --git a/.bruno/environments/Developpement.bru b/.bruno/environments/Developpement.bru index fa6654ac8..c0a0a0e4e 100644 --- a/.bruno/environments/Developpement.bru +++ b/.bruno/environments/Developpement.bru @@ -1,3 +1,3 @@ vars { - BASE_URL: http://localhost:3000 + BASE_URL: http://127.0.0.1:3000 } diff --git a/.bruno/sets/Advanced Query.bru b/.bruno/sets/Advanced Query.bru new file mode 100644 index 000000000..b07788021 --- /dev/null +++ b/.bruno/sets/Advanced Query.bru @@ -0,0 +1,21 @@ +meta { + name: Advanced Query + type: http + seq: 4 +} + +get { + url: {{BASE_URL}}/v2/en/sets?cardCount.official=gt:64&id=swsh + body: none + auth: none +} + +params:query { + cardCount.official:gt: 64 + id: swsh +} + +assert { + res.status: eq 200 + res.body: length 17 +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ce0d19f1..1344c0c35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: - name: Install deps run: | + bun install -g @usebruno/cli bun install --frozen-lockfile cd server bun install --frozen-lockfile @@ -31,3 +32,11 @@ jobs: bun run validate cd server bun run validate + + - name: Validate some requests + run: | + cd server + bun run start & + sleep 10 + cd ../.bruno + bru run --env Developpement diff --git a/server/bun.lockb b/server/bun.lockb index e2df89fa4a295992eb0f05f2e3bfa73626470abc..a9597b94a37f0f2e9c4b0bcd58642562b33ee006 100755 GIT binary patch delta 14977 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 PFl9?38eK7l?R5Vi0c$h_ delta 28657 zcmeFZWmHvN81K7jHiC3*LPAPP>5xvP8$nRIJ0t~^l+-0iDlYK zJQN575~RBUh08YrC4$RSp0*nj>h0z1U4HD6nAwFB=zM8&3p+g7&ZNZ{h6h;C0CxVQpjS zV+R^>f!zRtmA!?t6Ho>e0&yNxw72l$wRN_zgBr1zpkXW~S_gUr0(c|z1Ssqp1%|&G z10k_FLTo`*IQKo{U!I%`fAwGg2&TWbrxhp&LI)E9K@Q5hIJi2vICyyjfs+ySARQjp zCa4eR+qrq4rsJ~y9iIVEc)Y(@{$g$c1>+0}odXfrLD0p&84+iyjQO|0f9e6!!DNIw zcv*OQTKFSufDJeJ{;Yl%P_;`rNMDM*JMo&l5; z5rPPfKdTrH6!yF~PIF0!9pXlzHiI!*&kdygu#- z#04;=aC=r~>fpueteOEU(cM(wB+Q!z_AE*~74)+fhrj?B~0^w%q$P4liZeXkUg8WVXzs;F}a#WyP z^<@MCEQL^sD+mNwtf7@a;qm4Gg(rd!C?QZ!8!tC!UmL`#02m7b!5TUXB5+6Vfx>(c zXb7%g=LutgfeAs6aM?qKg#Y%_1{7}h0f2@57;>g}>>aFt8xRg&Hhy595Tv4in^^=3 zPmH^Tw>__ujej+$57+y*1K<_sZsFzi0;I!!^$`DCuLmfM^XzYWNa!tzza8rWg&X|0 zr~lt)!@o1*-`Svg^>0J}_CDZ6(1Q97Qhz7<15g-f3%b9ds;MPj#LN zea)@uOwyQ@q>u!&G?E_p3rt5SF4RFMh7B9g z35g^!8^jDjG8QB!D2m(z7I1AS{;4GZTzwr_m~eB*AYdt64FzyYV_bv|IAkzxLE@Y;$TXV2Tv(^$nFE#(XbKO)pcF+ifOQEoGfbbm`=KynvEF$L+N0B&hyAUH$LIwcpysDci-Wias= zAPF96qz2d#@Z{l~(q#Zk9avbf50T5j0_R330_va=#i3?IAjBala!Cwr$e&jRBN=Mq zl_A0e$Bhyw1K0Alc{F`%%k zV)($&{8s~$g&7Lqm&P$+Mj-g06Glm7G>E}djW~5_53u0EU}^D*A}PQj&j&0VI1h&$ z+`QoFl|)*A7z_a>52WV+OA$Jul0<$5F?d2S;UQyQxCk|0k;cinc$SUx^)wbtA&KMy zCqCRM&go42kCq+C(>`qkUTqDKxS$NqCP1bKKfLi3RLRT(4&xJAN_151=&6sZj?c&nW{11B6NG*hO(_lad z26iTyD5jPGbSN!@4bnuRwo8&Y_@E2hQzyHE7)%A$GB`_$fdy|0aJ;A?Q$68d(Vv#80~mlMuyOJIz@1Y6voMZ3_0!~6|QKa*K7JLq81MBaAU~30h@EZGj zMhk+Y1wLI)Pccj|q#Gy+_l1C+f~*1-TowP6(Gc`-W`WTp0Sk5}I6r~)=F9@O7jR5~ zba*lU-I9jDf}MaoWqS-Pc$UG^21n)OSspHgK_QAk1^KJVU<5%;YBHE#q@Y7J`26-) zm%%Xw^_AcqiSY~)*O0+LLH^)V6Kc|s!RZ3e1S(K4za$0$hyN3W>qmnN)>v@C?ZlmF z{OJP=FhJxqz={Q#>2fe5K`aeiaPjmr%>W9AQ-#`esiA3I8t7P;6XMaM0XqPJcn*Jn zfUcOpE;vp7tFT=JE=(w1k2(a-D1|>jKvx`KSDmK*RT#e(T-e}hJWGca=4%2MoZbd5 zIQ*{^?!6scB;a}n{xCUhU>F#1_@5~}FB7M^e--X<^349~1FUeZV{pOgzrh6uSm8Os1etI;_L<_s zg+V}9I3FLR!*hP_EFWFrB*L?FpdqIXlYj)cA{kIPkNiw2U;_kL;k|}BvtfnH)1TR} z!W~`!3MVn0rNatGna^x=h3m6ooUUOw0<6=7zZEW?Rb)F$hZU~Cac08`1MvcdJH8AQ z&bk7BoGF|xbY}n06mC!CHYflW6bA|?NS;+dSJ?G3XX&uQw!)bWD_lJIqa{}g^`lv zzZBx$E??YE?V!ijWBAV8pz!vsw#0iI-{-G>-e=pMthcjU&!!#qpxdN()Qj=3%y@3Z z_ohC0Jfe6X3bvpQ!Nz`{VcanD{wuLVqqUim?T8Ft^PXbAn1Ak`$;ot4*R~@@{k1;+ zreLk;A*`BI%o@uAQbc%>GPQQaooKu76UC>e=WpWQK;bjuU%-Ceqt_ZkK0XUkICV)B zGl=(lyvKR|2)|k56KWUH8oU#VS$a|^h#IxIE#T6E#~6n7M^e3B?T=w3xz)uS-4Nk$ zh}RO}X)0c?n4-`k#oO+^)p#*+)3}C%=eWz1@Twf{n z8zFve@Wo47g&fL7!VKZLi-re3;`|HP)*{l*2TemluMZx`m*PlemQjR;rb^rjOroPZo#`AL;hFK9>qo&u} z-PvLur(YYRez<+P=9wGx(#f+b*!G1K6mJFaRIp_CpB_n9*RIu{W4)rDoOAa{+k9u{ z#tSl>u$pr&qUVSU&pRfHXyj)H?GLvyJQd?ZBrK7U-O=5cT~cRX^g+Wr_m2%IHuUX?biCsHn#Pq8iuGdYE9P^frI+iU zYKD`%qDUZn{E`}jcbX{q^KQ9|1gcysaEVuliwcXn@D&@Sg{zFo1N#)s+Cu|#XcNX` zY~<-(<1UVSmTj-(To6TGDDpG+F2iLK^EgH7BBJKUn&}oX%5gP8F4OMkJ{K`Lb+-Q) zq!7n33ZWJEcf_i^j)q5!j+b-8>k(165WYO*Jkwr{?3f+amKOX-uhW@EPn*b9$#xSN zsZkP#qRBD{W|oRZ{g1|jAE}qZ7fsvt?y6Blz44k z@Ib7qWU;o_1uD&0>pg}vYyn=clHKlr>e0EFv2TyQA*UShugcK)3B8}%cpfccL3N3> z`wLk@f$4lgNc}Zt;NL{fO(wI3d5dGpqp8=R z6BsWqgY0U~#rxZRvLB-se7t`zm+&=v87y1tUf`vf`s5#Qt6+nbYxV2(xRA-M4oyS- zT9ua9l8glEz`5}fY2eho9 z0u7HE9q&z6f!uxJ-6|Z3WpksjM-f+|6>3f2oL?ELlVaWPC~#dY#uDg6qN*Rip}s(M zEnUgp^4h2UnHL5=Mp7ThWhU}=ATxV_XMgX2nsLbq$7EIm_2jl4i6)E4p>UDn4qr{* z?|5ng{n0%>E9Y4O)d|jo*~eqmqx>*MaL7lSe~DKKHy7b zMFks8RCUGy4>N?!8OIf8N;}ps)S~7NP~IwTL^0^mUK1OzjL%Ioyo@Y!?5X)3GuRc^ z!g-Ph5jg-n^&91A9 z{w`k+sVcf3gTZJZcpKAF(HpC36b+9K9q&`8GMmT~rpSjA=ULcT?L}4Xt|av2zgymx zzNE_HzC#dkGuD`^s!*;akn?+pPFU%w%K*{>Ut8 z)T6#^V*G<1fiSCc6J`-F{JC^#YR?6KwD&MVyM(h~i0f3sOOorAgfekR%Gnuq2IZ96ms zv$y7_2$tmt^M$zJ1PM^|{I_I_4qyIQ>r46fs%7VWJ2GOeA$g={?X zYm~8|$3QLcvvfq)`oEF?{Fqu{A4>aD*L{!1L|M^g1{k~x|JPvfcjjaf0&YV(4L)t3i zfL`8C>j8&vB@!%x8B&&>4JjO(D6H!KyKAT%vH=mTkJI@bE^oSdhwl84wJ7(uRMv>S zR0_?&cq#fkmunvrDU2>e&y;Dsoo&v~=9sS){B1|Dl=g&EwKVq9(T$$W{*PCzN%!$97mfdT+ zE+Q@?@;OfHLn`_s0eDgIU%+0xV%_{8zuh!dw$yVsCIV+(ORm?RTyvn^=|VtdXDLBl zF8SkM-KhIDh(6b#JGZeUMsm+*MK;YMEX-W6od-C|ASpM17t=4xu;3`-+WN-S&Q9g! zSX;Grl-rw6sK$ITV-iuroj3TXH|=#pqox8Wr_j8| z`yU*59Chbxz8~_j!njzTuliaGqoBc7Ze-i`%;j0*?>Y}Aq=Ku8mX;_iQ2irg;w>-n zKHFGqVXibuGTO1@mxSEj>2jEWHetM_uhTk4o5@%>S-0DYw>DlXRZLsYO#U1TulRYj ziAD&og|azDVCG=r;iqoRY{i$qXs5Uz@bD$DSNc5j!F`C2{=5fXr~DVN3$E6?MC(>B zZ=2{=O1;aU=?Vl-MUTtN-zhU(RLGYV`MM?{W2uLlpSL}B#JQ-?k3n4*B(m|j@4KV2 z8sankCKr_D4)A0V-;;BucR47x6SiD(jW`Gx_Iy$c2k*T&@o>8TRAf_cBiTdt!C2_m zGKNuw+F#Y37N#-0zJ?OK%%1CrdhM59(;7~z(^&+h!7&JI{P8>bY@3LS^y#q~Evc4<(kuJ(T^oY3D za)1|mOp!QO;QP1h<^|qKtRn(aRlWBOCd}5SzK)<&zkTu)y^YmvN8;?!()Q*dqXxYj zL8g4;<9f)<3*a439BJ{4_C9auC)0GEqulx&P|?dhwUD=sX!4!Cta{QROTc*RDbcf_ zdwN~RZybosxUDoen?nSfsjtiV^w9nNw)*e62i|)97qG9g$;!W>3UF%HnRX`BG5WJY z>HpvXX4D;GAIgjws|$pM_ZBKKf1rq?E^vtGN&ouv(7pU#FG}Cx*HU=Y^=QA0Vu;8a z;7Nt@Mu^mB*j(>fA0c*scW$TpK&w=YNDXuGIQ`ng=3{9g#3P5V-3kjT9y)=g3pQ(o zsMls=O{261 z)PBEOs`Yl~l9%d(&oSXUI7@Pq_^-ppru~v){w!Yh36!P(dUA)thx!I)T|6PS&2hUz zO9Kw%;sXY`IaV`7A^nS`P$+S+^u?r-c|T{~*v$^+byH4z&u2dzKakPCHX*g^K30D6 zt<7CZv!$$a!b|@u*Anxw}`b7*6f{CJf;$}XpD?$9O5Q(Lw9%J&*7KmBCcUp9PRZi9FbgD=1vdGdN_ zg^|co%kwNB%ubpAzqAAPP`1DktZ{vGmm{ zes3~m{Oma&goY=Fj`ylGNr}LULk+*MEWC`}4Au9B%?VYQPAbBw68SB1BU$s$WFKBW zN+4kOcU@pYanszc%yjqj5B)Zm+b*s@VE6hB4Z(O;A(T?g)5UU51P|>LroLVCFK%8x z$nkpc+zwjJDX9n$rF|!xY}kB}dQ5e$(2X?G`d0hM1N@ERcTI{lbt?LcXm|?fc*XPD z=K3SrmOpqxbaeu*8l9t=A3$Z(aIVzoX1v87tGr1*C@QIr;_|)d)qlb3B;}fb9##9@ zHL<0W{1KZgas-r6@Ew5nj;mmK{beOLnJLpr(TD7Kvo5M%@?8DbeR(GBllkb0MHL&T zzIm6U?wH|wRE*F{b0u_KMmpb^Z`4_lAT@s?kA|m&j(3G~a_Duu7!l(ujMaAvSMtN$ zI?q=p@@mipxdc3@Sx=xONPI7iM6FNkL^iBserC}dBF1jOTrbQc?NofKr;*J0#iw!;IqnhDS{*AZiQ}KjWI1x+sz^rHszt<(!bOqs zw_egqJ_WbpLnzI9M*Q>BB(B_LW#W}_59fk29 zWq3Rc7WeJS>z$q3LJ1gV%Y~>sRT~V?c_3aA=CS!fN&*|#9KjgmpGxa`2ae@^OEhO(O0K1Qc(+s7Lo5rKc8Z8^E`D3M?f zHre6&4NBz-wMyM|o7;PYdrhCPyZwcstU!R*VHNQ49yw{=3)S+VIb|cZ!rn2A7n0ou zq1SFb`Z}e)_U=|xU(iN)zFTKCtJ(Gw<5Gh2ZH4dEzi_VxG%~dbRPrK2m*M!=b~_uDld6hSiE1K;^tGb;MT&o7qvOQDgQpD*1f>) zvz7fqlY(E}bHXb50UZ-z?$9mUTeSsvyLsGrJqHzk-!K2YbL*jdZ+YY+{ZC~ZZsk3Z@#x$u4vWuC=en)H1^w8=pIeoz{_%l6KgP=Tv3 z4qq>H0^@!A?qQbID27knE%3yW|5`mxqQHVFcH7=^{e@YZD@TXpGKt}SzXVh{q=h~% zkBmNy9jJU^@>#TJ#v~`&gNMi*&3gvuc#TwHkun{TT*}J>hx1MfZrh69cYWx?&xQUr zO>>@JdR9i!_i1Ms_5ApLOuEKl;x*IQF}zm^rLN;dsI@2F&qC{=Z8qTv~#5N+sDw7_`Fx&QSOdC-{@k+YA02hxW$2YpQ78>!;N~>9)SPPS= zXdi3^%-#o3xa~$X8lD+C-YlMS%m>dBsAKdkHIHJGCiF;=Xgl7WjoFI1+2y&?;GYHk z!jG${r1#UAa;|(fo}UvjEAHp;v^Ci1w@2Jo;oX2-LIIu>Wd!5m4=Bw9p-O3Qd|v5#D-;hIb1c zuhN0I_p6n_$IzA6KfZ;EQIIO~Xs6kQH<}klin~{=59%F?{Cuf}df%U$BBLZKDmYo=RZ+`Jno4 zZ&s(y%irD~zz@4ihaYeK<|n-QbMw*^CTh1mboS+}(6Qk1r}PBePP6lum~L7J|61Qx zfrel_axSFSp715151yYKLPME?S!-+aU2RmqgbwU~yACy5v8~{3P0N!df0cZZESTf| zS{-AQkdH_rtlf@Ml5s%}z5NlXXJ~(Oxy+G2vaMz+uPUe~CFD5OVx_4-JcIbFCtVzBH zEw?Cr)FQ<29XEV+h-TXwzs|MFOnpU;H#)koVYJvrrX$OuUIsTY@y*fpyP2*9A-bycy&9qC?e^o$#+7$#8s`H zcEIBvYB^h;P^*-%1DtpmD4|UvV+8BR@s*ev6ARmDMK3|7rlgcDmmCO+4+nVEp91?s z3s*a-_OAK7<;7zyS<9d2#~t|T!~IY)|64)IeoOkqbJit+-H|@o*eLe(AKZ*yT{%xw zwE`3{r2N3>tiOqm%BCc7;i~Y712Z?8gemw*2%^1 zxZH+0T0@_ZEkyC9PijG%Fy3Sm$E*xOrFen6yI}Xh`*W%xF8v|rbC2rCXK1tW$2e!G zzo0HRrFN6&LAA*zcy)iy8_CJ zq=s6f)HOWNn?0Ffe7hb<;@Hi#e3Q7do~)tdpeK`DiMqzT|ryFK*fQ?rYfU zd=&}6`1lx0fC-F;$wY!pf+rT&WSt%|-_&9c=Xj7rf^&G!zp_-tch*Dg6WA9!!{7Y~J8%54U7s4N=bRh6U@ z{ds3Ie!H}G=(e{@zHeO&%>$3ZjOgdW*iRhf4Wd42R|HZp+{$*&-0|eD6)aeGbY>BH z;c-j61n&;30xcTe9dtaKM$)eZiax(7xRT|h?lCzc<xQTw4B0 zVkU3=$MX7N?CutKL&9We#9ZFi-|3#laq;gc>AH(oMSw@|se04)xUwIY82*Lhzr)@i z9d8~HlvO4fyi94R5WPw#7Fc^Iw73}ike$DF%y)5PwWWdd<4Au$YEwU(JFuV0nz*ue zgwHQNb;5Agm5}%C7a=Z)=mEfU6CE6npsoBZe9x)9SfU$Jl!&#jI(co5N!96^bJ7wt z**sR~-Y7Iua>YpAi;>JriA%;V%p}n%_mkrq8eSke9^0+mW*pxZ;il+r-IyeS z^J5H$K`y#}pZR7`?i21m`w943zA4>Ag?(3ZR#XVz;_flT&5y`_DC}N9;!nXzRuu@f z!FW7hc9@nDE#7}+qm!50?lZ`^)phh#@Lh?abmKl5-cWygt22p`hs(;s8k0e3hE2CX zjVjsr!}VC2u_^*$r&s7-EC-|G&DBUy6EouAxnGIGR*QqOgQ23xQkTF=Vuo)Si=;KR zO<(f}@X1hNJ>MsuSX=5|*=~F^+8?!ZJ?*I~{&4lNKa z<2N;}bqgEM@7M%gWEr@=zmIa>8GoTNli0vRu=~n=1z}dH7GP(a&EzmZQ-2udXP$-r zwfxynY)>JvakRyf8MRn`RZo8B@tg3CuhKreO4fmq#kMm0b3mY>f^dUJEu zCwbm_*wB!u-hD=edBSlNwxNQIAhwG^!@G};N7`t=UCzo}^{OYZDz;f{a_wX2t&i#x zJhsw>ilvX!Rn@2}FzI7a4kyRW5xB0lR)3xbrR{6$yJic%J9x57n<@hZ#{)c)?Sm52 zCw(`sJ-;-V$swHSzxEj`eMLkr=M{c*JmkO>x~UyAF0XK<2=h%0wa9ClASpBZia)$p zNWRlVyH;&_q2WcK<2~KW$=;q{(V22F+Ir9|Z4y0CE&02qi}=*o#A#|$~;!OZ}v@?&b$rK*?8*kY5zdpkLez0{( z30qI6VQ0uW`g4*@L7S9}da8R6D!|!;D~|0F&UQ_7`7Ga^W8(Vg-I}oGLo~c7biDt) zc2WP|4MQ_9-sjlI5|5ed38uu$0y)~tyBAlPMX2npj*ju|RYF3KPRxJwa}TX5$Nn^W zqb$(Y>gp^PU=mzi&`W-aC5Gb0z9Sl53_6~EBQvv=`ilJU+l23(c1*(`AtSa2mXdvb z??eYAygNun)2^)xmZOe?ai=*^5>Ic(keKnj4Qv0yefvNl-|3w!FBG2$@N5unvD(wm zvvJQC6ecXwPWt_PrO=e0H1d7e&{c)IZQ8mrox4M}%jU(CA4W=#o|<$y*kTLJB`tMh zg?vSHTPHfLbx0}+;0-&>uSiq4*G{!hh;NAQ5ZXB{7|e#L zuHDFJ{Asu69#%3@+NGx%X8h+pLhWsHLZ^ake4KCW&K~U8&d6-*Q!;C( z(-IY)XybeDIKC$;*e?GHy zIp1Y`F+J$J$PRbR!8Iwn;>2e^B%y*hjtDgGC8FbPzE=@(%%)`K{g~uT&{$OZVtFR( zMdI?zk89DZ1XNv#e+b)i-DOd?yp$x1l5(qyM=PH%bVUtO^S&@0ExtZ{JP&0(1bFYH zB-94I^CYltcD!tAs9zVor#?Q-dB5K}wKW{|dY(g`&q)m5O}n;y4cE*xmrs-UibKL1 zxkyv%_24y@k0C>s#%~Zns}TO!i8m)^&V52zez>c*3f@Anv9O8q&((V%AeuW^U)BMPNp_N zYF&@NB_puMSb=#|T4Ee~nV7eO*#lL4j>hBR(4Oez8h5Zyn1S$Ybt@U4{~zIN%1{{u z@I2c`lI0ke1tT*g7xwD}&fOb-sbGK+*ht51#52nMA#L}dyh-4(6jSDFdTF=VDo=LY zU&S*m2a64wiyTE49>V_=1%KY`M|1;tfT$W%HF(r zxgje1Do*KM@ZB$oah&ndJg-A>UPM=NMzC*W-lfIj7FDIW8(Hqe9(x7-E8!e;yc5qm z>%9Wl*|TqIF7uS^2gVL|uY8;Al4XgZuV~7?mUq*juI88(HErsWDWl2WMIK%@ES`b? zYUh0ANw2H|rQIep1mh8r(6_bDeB@E)ZoMjjd!e;BhsqdQQh73R<58N??Q4A(K3rp4 z`Y@GO^C_bw^%;9z3Ty6zM^$nUTZ`w)XdOb(-xBAc<9Rt0+-0&Al71w9(<>KQm-C@k zEK9{k_u`+lA1j}Oy@kGfSD^4OL9Iq}InV8c`YP>Ty+M#Gzv}UOwT!dV-*bZ<3Qh%h z#1=ZEK9&_#sx1jdIPtYP!zxXnZID)~Y2O`u$u#*{*8o@S zb-d&Gm)yb=?iQ3rBWRPGkB&zd;Si!5o#s$*RVS;?np{u*PecHnfZVH#!?cbuILQ8|iKb9^q~gPU{p@QaTV@Tt_v3W7|=59KKGx zHZr}#bb(ckg+{xmXEN>2F*F0?N!Qk|#BFodRW>O)M862?ppBU<)Hx~rWLVveW6`dT zxvk|w^Q~ypGRx7uvllDAc8x^ED&3+zNUT8gMCj<35gOhLbiC@_Zaw}hs+=ztBMPgi zYPDCSrv&#*`hFD~E@;{u(mXC`&BH@nM!C|j*7-u#Ci#g>PRZ9x#~zw>mUD}=y+)+Yrb(!*~w`G5nt!7$_IQ^TAj!z>Si_IH<6ke zwAA*f?fyu_fC^i*QIx$-_(8z4I;r@+du@9tMpBhz{ZkJLNGb#15i<04aRlv;t}h0J zOA}2pUft@vutZQ)bMEHH-Q%LWh0+?>o>#?37x1ags(Yy8PHq!!cL>byLTcfTnJ%1j zNoaUw=y=Z?q*8h|Oe79fzHeo!53#)1(W*G@jilu|N=Umrg2wg62e?My3Af#%$ zRZ>vXE;0Dwf;k;7f2|c zR_8KF?l48-GDy#@M7Vpeic8!1i#xM!{F7dBRJ)iSF0R728hWYjwz0v^^*7fei&Af< z4D9tmS(yOO<-CBCk1=tMEY_{kH4A13MI8pN%7t|T2M-B7wvXfPlU_-`y_)QEHJZGQ zJbf)^CVF4!tT4~cGI@&=>O7L_LGxY}I^H$kIKa(iV*`oD#;!Z)|E< zi2f$x7n8`86^iVEV=dlmcDb#@%(R5asl@ArkR0Gw)ylW4r4>(uzSVzD*ZVf8P9Y{ zE#C44*o&g!)u7}3Ns1Ho-cyMfO$?o;Ev(90Te0ELrF7dvEI^8D3%!>kCkQ^=R72sq zkY&FVSLI()AdjVeoPIzeO=GHFoG$;;4?2PI&Oa*nsM_BB`40o;z4{c79LlhWr#D8f zmknUF-sB`w=&?B;?exXIo1my1qt-4oy@nE3%KIWW zCc?aI*!$!97pN>7;FY&08?bFElQ)X_PUte>Y*9AgCMq<42{@P9dswkEk@_RSui6tc z1F2QounP~+qMK^LamQt*XG@uXrc2gi;&qYZ~@-L>C zaK3i`k!LwC)QSE;)r5|hiE?dTE`+e7+~ z!p6kX=?kA$yYu`R#<$B_N`~b=)OOD>IBW#&y)*R0c*nD9y0tAJaN~FD*ehOiycTpk z9sw^Y9c+@lyMu{>Ki!2$X9UdJxe)Y`CW(t*BN;3SjHgb*E7wsME7RA$=|%OlmgukU z2V7}>MiAsk_m&xPTm!YicuF)oDZ5=sQpOyJsq#>jEA5%hY9&d+@qIyo!~sNjnZac* z?Zf(cF7vt5vuAk(aIQB}R{Uw8x%nt6_|Ae@a|PPuwxQ!?kYQo8t5v(+e>NZ39ny4g zS75A8X3no^xLtm!ljl`g|B(F1msZrpG1CQa8h#5#x*=)aCPkKW=1Syt2MpC)R**{$ zz)M?evlXFy-uf{rfHgu)lBp-_2SHB_V~LJ@6kpzQc-?n)MeSDkA9{h>`FtTaRyQpq zRT^l?r`2WB+PsN#0)C_6wWH&Ge{Q)xYud^DQJ;SF&E)aD0Y^-dL{A#YQNeNJ=sP9C z=dn!muk@gPY+0~R*DP*h2&M7+<4wo0(>YQPzP^au^M)950iKq^^`DDv+kr7ANkzr|)eWRPjgqCX}^34Vg;gdU=m=dkY2&F>^H>`I8x z@H)`(L_bfu%;a74(f(z6N8#zcPgtUl2%kNiNJ_n)A?m#URdIZfnTHh<^=e$VVQ*7` zUf#m26}+NQD|XGrAh)2vx+x0{!FXc^i+dVT8H<88=fD5zMMiu65_VCVRDaVl>iC2A zJ4Q}%O#RarahL|0DgC$k`muH1ta{{bU9sVL!lMxMtmwx)8eSJV-Ui*VqS?iDnw8DZ zh~r~p-a5?Smzf79W>W`YQx_GIHze6!T9Epo?h#LB1by2s&|;bIt>Aw4`YFG~hBBRC z$Oa=6oCol{vA2**8h_r9R617%O4+^%)O&nVf1xWp=y3dUjnl%6}%YHk-t?OSqq(U(fzLqJ@U{8XZr)^S-VpZ(zZ!TiS>Ax)6_C;q}p|d&jJ9 zb$%u)kK1ZVd?gObEK$Yu#t&|^&$@45{Nm}*TFWqzT=*sIuH4VX0`Wcvcy3qI#<#9^ zjn+kvm|pVAZwp^H-u}9fSLf`4HRH+AzU7j1xKldIs-rkhrM>vbGUZ;d`U*!Mmf1+7 zV}jF25&G8XMaPRFd3Q0NSYJV5^Zh%?fko%lcZE#5VXt1-*{}Lr$=~Ovl5w2yqC^eu zNM2N0WLsJ5m~{QEo&M?GkmJNdSD&{%>(C60hg~hIX{KlDwe_TpZT3=wRXNK+DOX28 zDGe3Xqw8_?z7zLqu#Py7$|-9feZlG#FtPB+BXzY8E50t&K_JvOkN%tLesny=Aa%Z{ zHkmm+4779m7#51Gbhq&+3+}d6TSt19&CZrug&7!X(x847=T50NIc{>ZZeMuHOr+Sz zjLYh9z2+h#D-@p(@REX@rbHYpCu~b+9M4Ov-}>QG#$ZkNYUdEi#jVrm?_Xj5hTUDm zLgTifh5FaBg3A-fX@v!RLYi8|qIQAejOB;{X-9F-WQa;N^;E zi(bz<;$^M5!kd$DwL;-@$rJa>*YF>+mam?_wQ%Fsqc_xzCC246oSKi8`=anNTqz<; z?|zjin2Z^D)u@R6tH2?2JhO75F7wxF!(6hu!OfQQYT%DDL_S3h%FKyo+K5C^p>mk{*e5={Vd7|8;~hF) z(L#-y5r&2>&K&7+uu&o*dBTrNE&?8pNi&JOeiE8^kALz}v#Ued3tJE~D$@@o3FUu6 zY<>&~tWsNlYIkd_-U-So1bCP7a}JD{L#PRbNstLrW!c>^-XD6aj5(5M^hD}fFoo?` z)(0EtuMX{s-Fd=B?qfgPR|4vweg@681KlyI(~Rhmh4^5`w}*l zV#lgueN2AYxjr}iVDSHNQsXQQspphGhvfXOlv^AtZ~D@Jc+-HJ`3htHv+o=`T3P7- zesdfhPqJ@1zF@-9D1mNxcy#XnDek=EqB^=hzO!Nk1nFg$UQ_~7qzR%322l_cI~I%x zNL3IKD+)Z6(sZ|#4f)15?|4_MeW;!` zts`~z?CRS&wMP@QWd{$yykkyR!#9+LT{-pisIK8ZMJJMeNsBtT+o)W$O`})gf+pKEBC0iS$e^}KU6uIkdOul?}tb@wV@+bDU3jbq& z?qu{_Swu0++aVkDm|sbbE?s)wT7QZ`sc5*Zvv&DXgP)(S4)4605}akWrm)aA!pzPl z*<@&Aq$Fb9!s@E`_2nuX-P#Qw&FK0i#;vaAnWxsPnZ3%Z88rKurc<*`?2gAZ8C#b{ zsXPl$>{YRCSMd6T9enmfY2}XV&3pc+KhT)_XrF4^$~VS)Zaf#A(Y{=;U#3|C^PIo! z7PrkPD9H0e-hp$q>&iCQj*d$(^bfc<_;}URqS?Wvv2i0`MJ0_H_3@F`nCa>R$^t{P zCNI3@wR&Ir?17(~u5=l_-@BS;)1Vhwu>J3ao6YpSJ}>Y-Sx3I<+Cr%IjU-v_we=G#Oe^$g?*COt#tBs zd+QS1bh?Xqt+Jq_lVl4^HSh;jHGSS&^j-BJC2n_%mgZakAeSR?r-#+M&MdiNxFK;{ z?e^~fgo!rDC5xS?L_9J>C-ey3}ve~#36>N={nV*AVb z^zIpwW3ne{b=qUvMm~9yGVp0r?F6gomSex}lvXWqsr$prc|}`X z=ji3u_!;&f=H%1%teEI^TJdk@S6^(CR+iNyrv&_5J~HRST%U-kY1W$ie!3JEans6d zlFD~EyX&sHSMB?4M_bFsH`z1S1Y1vT==%2^?On~&^2>3lOPznrZ?Q)G<%KWT?5^AN z?opfVt=f%aXRUbF5*?{|Yu(Zu=?1mzJAQ?xXJSk2RmXpL;GB2KX5XjC4jtV+vR}!( z#onXcG8UDHpT?_&X?iuJU+NxIzvaj$(SV}fQx;qLy8EbwpBbFBAadF#wY>X!2`Q1E zE`C1xP@`Mg!H89t`j?w^nUnin%{w*ss$IZoO>wU3$%XePyz0E&f8fD!6C7&?f74sH zSY^f3hdJi!YZghTU-iH@TBP-J%1qQ7ud0Ab&U|!Fg1^xQPX1#p3{kL74 zcQ@Bb67tV>G4Ekl^Hvr+Y~J*sPw7>u>Agh!*nahTNR`Gv?Vb^7ecYC{*0x!VZ#We{ zS-NEBw2%oS3WjFvS7V_ckFALL{hC|wu)rU$ZIL-1hIvcPZk=g8G`FODt)`lbRhZt1 z&*`6gRgC)8CUtA{TGg?^oBfO60J6#lE9YJvQ{!LpkmYB$jYHH*KYb)iO%=Ao#MD<#9;&Xvh=O- zdsUr1|7nPPd-Cvb-Zk{wju|e?6~P*?(NW+1G3IsMig#7c=kYo_{_=#mc~~dV6Jcfu!ic z3F}`&d>X2p>&Con$em~SZrO^HWjVJ-9N(-qcDgJ@+X(x+s&rozIXg*I)><)=mARan z)=e%;ou4{iRF+qHhslmqrjSTe*bMwPW9+Cb%c?0UJ5o7=$y`pUh&3$BoT>+QXZf;> znhQF#TUcyT@=WUUw;tF`56a`@O;`?4Y#wcol%&)|C0!wnnvrSf*vbX}k1xZSx~g)r z(w@umJ`Ryx{@8Eua7-l~bX%uvHz4evc3UZEfv^_-l*=;ZTl0%np+J@j+X}+RMA?8WeGRCx1dxo3qU{DrCIwE&kO6-y zWhO{-0U01{M`((?@L}X%A;yc^=pR@&V2F60j6x0GewIv}ie?4;VD1 zUjv#(nl_pyTVM+4nPd*gr^tuMXK2dEO)w7TazCJXr+KDFemOV}PJsRVC}S3pS;(#c z{m~BXx?u?{01YyIJfv?=v;b}U_!%(dKO&_8e-FrAUV*#d9H;`-;5@hhE^>*OIb_n; zR>hzMOaNoSIIs@v1pfi#0NQ9?70~-zdT5A2H_(oA_dpwvtIP0|70w3X}`McCZD|3Rn%u z!(DNn(v`pwdGc`bX!1~!DX>x~QsFvy0Gh!`8aW)d-zdD*n+i0G)YZwL70@WsXr_X> zAO*yOIM5Bmf|!bj-Pksk8HN0KKtnqki~_zO2#f&3K_Ku4eqaa~1_A)}{2M^$C?5<) zb}5G-9|~yd#(*#|155|X>tu?iqA&%J@!^2xdLo#lWE#>4@GYRmBf(4%4Q7EvkN}dD z>1?E*aU>(11JaablF=~_MDCB-NLPR?kO`Ipno7OVj@{W)MIAa|g;RF9T+KKM>q-inmmhWxt-kQ-6D z1MH;c4? z0m1!^n1uD^I>yY9&oyFZ%#G(mB>qE(r+m$f#UFHdk{vTgHleH(~0t0XX7sLS3=QIARum4qllIe0YJ0>v^BHGNHYdhGlrA(a96Y{#JCXxQ{W6*rnYYw@KI7`%qODXyvP7AyG)s$+5jgHCwJTj zjre*)oV7M&Qn8;A_eDV*YQ)tInLXE*GF5T25ifOsP)PM9JXpd)OodP!qnyw0l3cl< zFSkYB8#5_CCSk68xsdx+lSS<9_VF=acsk16WBSzjNQ zUq(KVLeLqTb7!iK&S_Emawg51-$q>%AxzDUQGxrO=eN6m(a7Uk=1k(D2y7!-p8vMd zrSV}^mnMYJH)Ge{IAk6ktd6QQvoP!wk3dy+fp=OUY2@Ei=`PQ-WQOMOS7orCqlo0pFQM`ZA$1RhtDbL`VF}B>v zf`y3Z+VZ6q%$4Qv!*p>OueHDkD@{?@z#T2w023jC&p&fEt;1f{7sth4tk}-WELo`7!hwsen5(G}g2!WG&6B{BW?^zg`7}p4@GvVjOB~UU zpM<7(UO)ZKw_#i1A$x7m z4H>^>!-o0p!cb|$uY}k;+y2lixo~@nCN8XW4QfLJ8jJlL?zuUR@2Nb4dIS6OX||Yn zAwEx!AxXKXEq{uJ20Bl^d4%WN!W)%}Oi{!vwnAzmMD58w64PMsJ^GZquP#mlaQEUm zy)h_J?tDgXtUYvfac{Pj&+NmbtcW}JVUp28tQ`ySz3F2*I#N*C8~w$a7NYYAL1N_1 z_V15w2yuTTy1Gf@ZM3fAjGMyFUwH-^H5CE@g)K{}ynVE+6bj1QR<0-av&X3G4B%<@ zEW}s{38emXlG%`=_3!0PVV&?9eVD#)v6uWqz<&b-32|!36mkp~kq{zi^0|)g+uL5+ z;SAk@G1N8QoBA?i4o4Gpg4K*0+Nu6RQ+P%&fs7kx^0D>Cs{A!kGXTtt0& zXkU!0*hiu8_WhE$^Whu!irI5iF~St;@+>HX^@Tz&6r(3!IA5q$)LqPUAu)jDf4Oga zDMly=8k*4Pum0zA`#^maG4p^#9}?Wri|{}&5rP(VCb1(a)&VWB*hA4J#bxPH(E&vd zA)3+T8qcxMg5SM>qGC`)LcF6F(NiY04yp>k8E*_1I<07eyErnbsSx!@2<{_h_KJs` z=;C0m?SNUjF_t?@2vLrNfInX<{OQZ@;_hyu2#|yTN{3fjG>%&n@kJw^ z>-A%9p8vOk5K1Yr<@L>Vow4~}stQ(w@xy3>+c_|)yaVFj{dkBYb5o;FV>nce_!>v% zr$Tvt*^#+Ojc{k9CAZSc;}^ED*C#Lf%!Wf2UZLy640I|Qhp_Kd_#+Qy!fS>zXVcV} zx%1-@7RxauIW=uoa#B+A0>{*tXo#ZHc%(No<;f#h5O*HRdT{p<%!i*G!G`j?K}?Si z9Kt4=#m`BLNli(ON%KovloB(~S@E@svcL}xVYb{N33bN}XW{%oAWP=o2eNSr13ZT@ z3mzH3nkrI;F%!l=j9~9MZZOsrXy3ORPYPi+YyfX`WmXmDq0HCNF(EQ4ab8MHWTHG& zTVz^PoIJADyr}u9sf%VMr^dvlCeNP}?HCmopA^k^MB|-^?g(^s`c$ULOQV?@-yY3; z`RaIfQ|#ni@ofT|!uaVZHkpr%M&I & {set: () => Set} +type LocalCard = Omit & {set: () => TCGSet} interface variants { normal?: boolean; @@ -93,8 +92,8 @@ export default class Card implements LocalCard { }) } - public set(): Set { - return Set.findOne(this.lang, {filters: { id: this.card.set.id }}) as Set + public set(): TCGSet { + return TCGSet.findOne(this.lang, { id: this.card.set.id }) as TCGSet } public static getAll(lang: SupportedLanguages): Array { @@ -102,16 +101,15 @@ export default class Card implements LocalCard { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Card(lang, it)) + return executeQuery(Card.getAll(lang), query).data.map((it) => new Card(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleSort(handleValidation(this.getAll(lang), query), query) + const res = Card.find(lang, query) if (res.length === 0) { return undefined } - return new Card(lang, res[0]) + return res[0] } public resume(): CardResume { diff --git a/server/src/V2/Components/Serie.ts b/server/src/V2/Components/Serie.ts index edead5415..cb8d713f5 100644 --- a/server/src/V2/Components/Serie.ts +++ b/server/src/V2/Components/Serie.ts @@ -1,49 +1,48 @@ import { objectLoop } from '@dzeio/object-util' -import { Serie as SDKSerie, SerieResume, SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' -import { handlePagination, handleSort, handleValidation } from '../../util' -import Set from './Set' +import type { Serie as SDKSerie, SerieResume, SupportedLanguages } from '@tcgdex/sdk' +import { executeQuery, type Query } from '../../libs/QueryEngine/filter' +import TCGSet from './Set' -import en from '../../../generated/en/series.json' -import fr from '../../../generated/fr/series.json' -import es from '../../../generated/es/series.json' -import it from '../../../generated/it/series.json' -import pt from '../../../generated/pt/series.json' -import ptbr from '../../../generated/pt-br/series.json' -import ptpt from '../../../generated/pt-pt/series.json' import de from '../../../generated/de/series.json' -import nl from '../../../generated/nl/series.json' -import pl from '../../../generated/pl/series.json' -import ru from '../../../generated/ru/series.json' +import en from '../../../generated/en/series.json' +import es from '../../../generated/es/series.json' +import fr from '../../../generated/fr/series.json' +import id from '../../../generated/id/series.json' +import it from '../../../generated/it/series.json' import ja from '../../../generated/ja/series.json' import ko from '../../../generated/ko/series.json' -import zhtw from '../../../generated/zh-tw/series.json' -import id from '../../../generated/id/series.json' +import nl from '../../../generated/nl/series.json' +import pl from '../../../generated/pl/series.json' +import ptbr from '../../../generated/pt-br/series.json' +import ptpt from '../../../generated/pt-pt/series.json' +import pt from '../../../generated/pt/series.json' +import ru from '../../../generated/ru/series.json' import th from '../../../generated/th/series.json' import zhcn from '../../../generated/zh-cn/series.json' +import zhtw from '../../../generated/zh-tw/series.json' const series = { - 'en': en, - 'fr': fr, - 'es': es, - 'it': it, - 'pt': pt, + en: en, + fr: fr, + es: es, + it: it, + pt: pt, 'pt-br': ptbr, 'pt-pt': ptpt, - 'de': de, - 'nl': nl, - 'pl': pl, - 'ru': ru, - 'ja': ja, - 'ko': ko, + de: de, + nl: nl, + pl: pl, + ru: ru, + ja: ja, + ko: ko, 'zh-tw': zhtw, - 'id': id, - 'th': th, + id: id, + th: th, 'zh-cn': zhcn, } as const -type LocalSerie = Omit & {sets: () => Array} +type LocalSerie = Omit & {sets: () => Array} export default class Serie implements LocalSerie { @@ -63,8 +62,8 @@ export default class Serie implements LocalSerie { }) } - public sets(): Array { - return this.serie.sets.map((s) => Set.findOne(this.lang, {filters: { id: s.id }}) as Set) + public sets(): Array { + return this.serie.sets.map((s) => TCGSet.findOne(this.lang, { id: s.id }) as TCGSet) } public static getAll(lang: SupportedLanguages): Array { @@ -72,16 +71,15 @@ export default class Serie implements LocalSerie { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Serie(lang, it)) + return executeQuery(Serie.getAll(lang), query).data.map((it) => new Serie(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleValidation(this.getAll(lang), query) + const res = Serie.find(lang, query) if (res.length === 0) { return undefined } - return new Serie(lang, res[0]) + return res[0] } public resume(): SerieResume { diff --git a/server/src/V2/Components/Set.ts b/server/src/V2/Components/Set.ts index 5f7de8745..9efb3f9e9 100644 --- a/server/src/V2/Components/Set.ts +++ b/server/src/V2/Components/Set.ts @@ -1,45 +1,44 @@ import { objectLoop } from '@dzeio/object-util' -import { Set as SDKSet, SetResume, SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' -import { handlePagination, handleSort, handleValidation } from '../../util' +import type { Set as SDKSet, SetResume, SupportedLanguages } from '@tcgdex/sdk' +import { executeQuery, type Query } from '../../libs/QueryEngine/filter' import Card from './Card' import Serie from './Serie' -import en from '../../../generated/en/sets.json' -import fr from '../../../generated/fr/sets.json' -import es from '../../../generated/es/sets.json' -import it from '../../../generated/it/sets.json' -import pt from '../../../generated/pt/sets.json' -import ptbr from '../../../generated/pt-br/sets.json' -import ptpt from '../../../generated/pt-pt/sets.json' import de from '../../../generated/de/sets.json' -import nl from '../../../generated/nl/sets.json' -import pl from '../../../generated/pl/sets.json' -import ru from '../../../generated/ru/sets.json' +import en from '../../../generated/en/sets.json' +import es from '../../../generated/es/sets.json' +import fr from '../../../generated/fr/sets.json' +import id from '../../../generated/id/sets.json' +import it from '../../../generated/it/sets.json' import ja from '../../../generated/ja/sets.json' import ko from '../../../generated/ko/sets.json' -import zhtw from '../../../generated/zh-tw/sets.json' -import id from '../../../generated/id/sets.json' +import nl from '../../../generated/nl/sets.json' +import pl from '../../../generated/pl/sets.json' +import ptbr from '../../../generated/pt-br/sets.json' +import ptpt from '../../../generated/pt-pt/sets.json' +import pt from '../../../generated/pt/sets.json' +import ru from '../../../generated/ru/sets.json' import th from '../../../generated/th/sets.json' import zhcn from '../../../generated/zh-cn/sets.json' +import zhtw from '../../../generated/zh-tw/sets.json' const sets = { - 'en': en, - 'fr': fr, - 'es': es, - 'it': it, - 'pt': pt, + en: en, + fr: fr, + es: es, + it: it, + pt: pt, 'pt-br': ptbr, 'pt-pt': ptpt, - 'de': de, - 'nl': nl, - 'pl': pl, - 'ru': ru, - 'ja': ja, - 'ko': ko, + de: de, + nl: nl, + pl: pl, + ru: ru, + ja: ja, + ko: ko, 'zh-tw': zhtw, - 'id': id, - 'th': th, + id: id, + th: th, 'zh-cn': zhcn, } as const @@ -77,11 +76,11 @@ export default class Set implements LocalSet { symbol?: string | undefined public serie(): Serie { - return Serie.findOne(this.lang, {filters: { id: this.set.serie.id }}) as Serie + return Serie.findOne(this.lang, { id: this.set.serie.id }) as Serie } public cards(): Array { - return this.set.cards.map((s) => Card.findOne(this.lang, { filters: { id: s.id }}) as Card) + return this.set.cards.map((s) => Card.findOne(this.lang, { id: s.id }) as Card) } public static getAll(lang: SupportedLanguages): Array { @@ -89,16 +88,15 @@ export default class Set implements LocalSet { } public static find(lang: SupportedLanguages, query: Query) { - return handlePagination(handleSort(handleValidation(this.getAll(lang), query), query), query) - .map((it) => new Set(lang, it)) + return executeQuery(Set.getAll(lang), query).data.map((it) => new Set(lang, it)) } public static findOne(lang: SupportedLanguages, query: Query) { - const res = handleValidation(this.getAll(lang), query) + const res = Set.find(lang, query) if (res.length === 0) { return undefined } - return new Set(lang, res[0]) + return res[0] } public resume(): SetResume { diff --git a/server/src/V2/endpoints/jsonEndpoints.ts b/server/src/V2/endpoints/jsonEndpoints.ts index 7adb3998b..a0534e60a 100644 --- a/server/src/V2/endpoints/jsonEndpoints.ts +++ b/server/src/V2/endpoints/jsonEndpoints.ts @@ -1,41 +1,42 @@ -import { objectKeys, objectLoop } from '@dzeio/object-util' -import { Card as SDKCard } from '@tcgdex/sdk' +import { objectKeys } from '@dzeio/object-util' +import type { Card as SDKCard } from '@tcgdex/sdk' import apicache from 'apicache' -import express, { Request } from 'express' -import { Query } from '../../interfaces' +import express, { type Request } from 'express' import { Errors, sendError } from '../../libs/Errors' +import type { Query } from '../../libs/QueryEngine/filter' +import { recordToQuery } from '../../libs/QueryEngine/parsers' import { betterSorter, checkLanguage, unique } from '../../util' import Card from '../Components/Card' import Serie from '../Components/Serie' -import Set from '../Components/Set' +import TCGSet from '../Components/Set' type CustomRequest = Request & { /** * disable caching */ DO_NOT_CACHE?: boolean - advQuery?: Query + advQuery?: Query } const server = express.Router() const endpointToField: Record = { - "categories": 'category', + categories: 'category', 'energy-types': 'energyType', - "hp": 'hp', - 'illustrators': 'illustrator', - "rarities": 'rarity', + hp: 'hp', + illustrators: 'illustrator', + rarities: 'rarity', 'regulation-marks': 'regulationMark', - "retreats": 'retreat', - "stages": "stage", - "suffixes": "suffix", + retreats: 'retreat', + stages: "stage", + suffixes: "suffix", "trainer-types": "trainerType", // fields that need special care 'dex-ids': 'dexId', - "sets": "set", - "types": "types", - "variants": "variants", + sets: "set", + types: "types", + variants: "variants", } server @@ -66,27 +67,7 @@ server return } - const items: Query = { - filters: undefined, - sort: undefined, - pagination: undefined - } - - objectLoop(req.query as Record>, (value: string | Array, key: string) => { - if (!key.includes(':')) { - key = 'filters:' + key - } - const [cat, item] = key.split(':', 2) as ['filters', string] - if (!items[cat]) { - items[cat] = {} - } - const finalValue = Array.isArray(value) ? value.map((it) => isNaN(parseInt(it)) ? it : parseInt(it)) : isNaN(parseInt(value)) ? value : parseInt(value) - // @ts-expect-error normal behavior - items[cat][item] = finalValue - - }) - - req.advQuery = items + req.advQuery = recordToQuery(req.query as Record>) next() }) @@ -102,15 +83,16 @@ server return } + // biome-ignore lint/style/noNonNullAssertion: const query: Query = req.advQuery! - let data: Array = [] + let data: Array = [] switch (what.toLowerCase()) { case 'card': data = Card.find(lang, query) break case 'set': - data = Set.find(lang, query) + data = TCGSet.find(lang, query) break case 'serie': data = Serie.find(lang, query) @@ -132,7 +114,7 @@ server .get('/:lang/:endpoint', (req: CustomRequest, res): void => { let { lang, endpoint } = req.params - const query: Query = req.advQuery! + const query: Query = req.advQuery ?? {} if (endpoint.endsWith('.json')) { endpoint = endpoint.replace('.json', '') @@ -143,7 +125,7 @@ server return } - let result: any + let result: unknown switch (endpoint) { case 'cards': @@ -153,7 +135,7 @@ server break case 'sets': - result = Set + result = TCGSet .find(lang, query) .map((c) => c.resume()) break @@ -169,7 +151,6 @@ server case "rarities": case "regulation-marks": case "retreats": - case "series": case "stages": case "suffixes": case "trainer-types": @@ -224,26 +205,26 @@ server return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } - let result: any | undefined + let result: unknown switch (endpoint) { case 'cards': - result = Card.findOne(lang, { filters: { id }, strict: true })?.full() + result = Card.findOne(lang, { id })?.full() if (!result) { - result = Card.findOne(lang, { filters: { name: id }, strict: true })?.full() + result = Card.findOne(lang, { name: id })?.full() } break case 'sets': - result = Set.findOne(lang, { filters: { id }, strict: true })?.full() + result = TCGSet.findOne(lang, { id })?.full() if (!result) { - result = Set.findOne(lang, {filters: { name: id }, strict: true })?.full() + result = TCGSet.findOne(lang, { name: id })?.full() } break case 'series': - result = Serie.findOne(lang, { filters: { id }, strict: true })?.full() + result = Serie.findOne(lang, { id })?.full() if (!result) { - result = Serie.findOne(lang, { filters: { name: id }, strict: true })?.full() + result = Serie.findOne(lang, { name: id })?.full() } break default: @@ -282,12 +263,14 @@ server return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } - let result: any | undefined + let result: unknown switch (endpoint) { case 'sets': + // allow the dev to use a non prefixed value like `10` instead of `010` for newer sets result = Card - .findOne(lang, { filters: { localId: subid, set: id }, strict: true})?.full() + // @ts-expect-error normal behavior until the filtering is more fiable + .findOne(lang, { localId: { $or: [subid.padStart(3, '0'), subid]}, 'set.id': id })?.full() break } if (!result) { diff --git a/server/src/V2/graphql/index.ts b/server/src/V2/graphql/index.ts index 384a16b78..e54a177c5 100644 --- a/server/src/V2/graphql/index.ts +++ b/server/src/V2/graphql/index.ts @@ -1,6 +1,6 @@ import express from 'express' -import fs from 'fs' -import { buildSchema, GraphQLError } from 'graphql' +import fs from 'node:fs' +import { buildSchema, type GraphQLError } from 'graphql' import { createHandler } from 'graphql-http/lib/use/express' import { type ruruHTML as RuruHTML } from 'ruru/dist/server' /** @ts-expect-error typing is not correctly mapped (real type at ruru/dist/server.d.ts) */ diff --git a/server/src/V2/graphql/resolver.ts b/server/src/V2/graphql/resolver.ts index 2c05f2a91..6c095b9a4 100644 --- a/server/src/V2/graphql/resolver.ts +++ b/server/src/V2/graphql/resolver.ts @@ -1,44 +1,54 @@ -import { SupportedLanguages } from '@tcgdex/sdk' -import { Query } from '../../interfaces' +import type { SupportedLanguages } from '@tcgdex/sdk' +import { type Query, Sort } from '../../libs/QueryEngine/filter' +import { recordToQuery } from '../../libs/QueryEngine/parsers' import { checkLanguage } from '../../util' import Card from '../Components/Card' import Serie from '../Components/Serie' import Set from '../Components/Set' -const middleware = (fn: (lang: SupportedLanguages, query: Query) => any) => ( - data: Query, +// TODO: make a better way to find the language +function getLang(e: any): SupportedLanguages { + // get the locale directive + const langArgument = e?.fieldNodes?.[0]?.directives?.[0]?.arguments?.[0]?.value + + if (!langArgument) { + return 'en' + } + + if (langArgument.kind === 'Variable') { + return e.variableValues[langArgument.name.value] + } + return langArgument.value +} + +const middleware = (fn: (lang: SupportedLanguages, query: Query) => any) => ( + data: Record, _: any, e: any ) => { // get the locale directive - const langArgument = e?.fieldNodes?.[0]?.directives?.[0]?.arguments?.[0]?.value + const lang = getLang(e) + + const query = recordToQuery(data.filters ?? {}) // Deprecated code handling - // @ts-expect-error count is deprectaed in the frontend - if (data.pagination?.count) { - // @ts-expect-error count is deprectaed in the frontend - data.pagination.itemsPerPage = data.pagination.count + if (data.pagination) { + query.$page = data.pagination.page ?? 1 + query.$limit = data.pagination.itemsPerPage ?? 100 } - // if there is no locale directive - if (!langArgument) { - return fn('en', data) - } - // set default locale directive value - let lang = 'en' - - // handle variable for directive value - if (langArgument.kind === 'Variable') { - lang = e.variableValues[langArgument.name.value] - } else { - lang = langArgument.value + if (data.sort) { + query.$sort = { + [data.sort.field]: data.sort.order === 'DESC' ? Sort.DESC : Sort.ASC + } } if (!checkLanguage(lang)) { return undefined } - return fn(lang, data) + + return fn(lang, query) } export default { diff --git a/server/src/libs/QueryEngine/filter.ts b/server/src/libs/QueryEngine/filter.ts new file mode 100644 index 000000000..9ead9e643 --- /dev/null +++ b/server/src/libs/QueryEngine/filter.ts @@ -0,0 +1,463 @@ +import { objectGet, objectKeys, objectLoop, objectSize } from '@dzeio/object-util' +import { isNull } from '../../util' + +interface QueryRootFilters { + /** + * one of the results should be true to be true + */ + $or?: Array> + /** + * every results should be false to be true + */ + $nor?: Array> + /** + * (default) make sure every sub queries return true + */ + $and?: Array> + /** + * at least one result must be false + */ + $nand?: Array> + /** + * invert the result from the following query + */ + $not?: QueryList + + /************** + * PAGINATION * + **************/ + + /** + * define a precise offset of the data you fetched + */ + $offset?: number + /** + * limit the number of elements returned from the dataset + */ + $limit?: number + + /** + * instead of using a precise offset, use a page system + */ + $page?: number + + /********** + * Sorting * + **********/ + + /** + * sort the data the way you want with each keys being priorized + * + * ex: + * {a: Sort.DESC, b: Sort.ASC} + * + * will sort first by a and if equal will sort by b + */ + $sort?: SortInterface +} + +/** + * Logical operators that can be used to filter data + */ +export type QueryLogicalOperator = { + /** + * one of the results should be true to be true + */ + $or: Array> +} | { + /** + * every results should be false to be true + */ + $nor: Array> +} | { + /** + * at least one result must be false + */ + $nand: Array> +} | { + /** + * (default) make sure every sub queries return true + */ + $and: Array> +} | { + /** + * invert the result from the following query + */ + $not: QueryValues +} + +/** + * differents comparisons operators that can be used to filter data + */ +export type QueryComparisonOperator = { + /** + * the remote source value must be absolutelly equal to the proposed value + */ + $eq: Value | null +} | { + /** + * the remote source value must be greater than the proposed value + */ + $gt: number | Date +} | { + /** + * the remote source value must be lesser than the proposed value + */ + $lt: number | Date +} | { + /** + * the remote source value must be greater or equal than the proposed value + */ + $gte: number | Date +} | { + /** + * the remote source value must be lesser or equal than the proposed value + */ + $lte: number | Date +} | { + /** + * the remote source value must be one of the proposed values + */ + $in: Array +} | { + /** + * laxist validation of the remote value + * + * for strings: remote contains value while not following casing like ($lax) `pou` === `Pouet` (remote) + * for numbers: it does a string conversion first + */ + $lax: Value | null +} | { + /** + * (for arrays only) specify a needed length of the array + */ + $len: number | { $gt: number } +} + +export type QueryList = { + [Key in keyof Obj]?: QueryValues +} + +/** + * Differents values the element can take + * if null it will check if it is NULL on the remote + * if array it will check oneOf + * if RegExp it will check if regexp match + */ +export type QueryValues = Value | + null | + Array | + RegExp | + QueryComparisonOperator | + QueryLogicalOperator + +/** + * The query element that allows you to query different elements + */ +export type Query = QueryList & QueryRootFilters + + +// biome-ignore lint/style/useEnumInitializers: +export enum Sort { + /** + * Sort the values from the lowest to the largest + */ + ASC, + /** + * Sort the values form the largest to the lowest + */ + DESC +} + +/** + * sorting interface with priority + */ +export type SortInterface = { + [Key in keyof Obj]?: Sort +} + + + +export declare type AllowedValues = string | number | bigint | boolean | null | undefined + +interface FilterResult { + data: Array + rows: number + pagination?: { + page: number + pageCount: number + totalRows: number + } | undefined +} + +/** + * + * @param data the original data + * @param query the query to filter against + * @param options additionnal execution options + * @returns the filtered/ordered/paginated {@link data} + */ +export function executeQuery>(data: Array, query: Query, options?: { debug?: boolean }): FilterResult { + if (options?.debug) { + console.log('Query', query) + } + // filter + let filtered = data.filter((it) => { + const res = objectLoop(query, (value, key) => { + if (key === '$or') { + for (const sub of value as Array>) { + const final = filterEntry(sub, it) + // eslint-disable-next-line max-depth + if (final) { + return true + } + } + return false + } + if ((key as string).startsWith('$')) { + return true + } + return filterEntry(query, it) + }) + + return res + }) + + if (options?.debug) { + console.log('postFilters', filtered) + } + + // sort + if (query.$sort && objectSize(query.$sort) >= 1) { + // temp until better solution is found + // get the first key + const firstKey = objectKeys(query.$sort)[0] + // biome-ignore lint/style/noNonNullAssertion: item is not null + const first = query.$sort[firstKey]! + + // forst by the first key + filtered = filtered.sort((objA, objB) => { + const a = objA[firstKey] + const b = objB[firstKey] + const ascend = first !== Sort.DESC // it is Ascend by default, so compare against it + if (typeof a === 'number' && typeof b === 'number') { + if (ascend) { + return b - a + } + return a - b + } + if (a instanceof Date && b instanceof Date) { + if (ascend) { + return a.getTime() - b.getTime() + } + return b.getTime() - a.getTime() + } + if (typeof a === 'string' && typeof b === 'string') { + if (ascend) { + return a.localeCompare(b) + } + return b.localeCompare(a) + + } + if (ascend) { + return a > b ? 1 : -1 + } + return a > b ? -1 : 1 + }) + } + if (options?.debug) { + console.log('postSort', filtered) + } + + // length of the query assuming a single page + const unpaginatedLength = filtered.length + let page: number | null = null + let pageCount: number | null = null + // limit + if (!isNull(query.$offset) || !isNull(query.$limit) || !isNull(query.$page)) { + let limit = query.$limit ?? -1 + if (!isNull(query.$page) && isNull(query.$offset) && isNull(query.$limit)) { + console.warn('using $page NEED a $limit too, setting limit to `10`') + limit = 10 + } + // when using $page, they start at 1 and not 0 + const offset = query.$offset ?? (query.$page ? (query.$page - 1) * limit : 0) + filtered = filtered.slice(offset, limit >= 0 ? offset + limit : undefined) + page = Math.trunc(offset / limit) + pageCount = Math.ceil(unpaginatedLength / limit) + } + if (options?.debug) { + console.log('postLimit', filtered) + } + + return { + data: filtered, + rows: filtered.length, + pagination: (!isNull(page) && !isNull(pageCount)) ? { + page: page, + pageCount: pageCount, + totalRows: unpaginatedLength + } : undefined + } +} + +/** + * + * @param query the query of the entry + * @param item the implementation of the item + * @returns if it should be kept or not + */ +export function filterEntry(query: QueryList, item: T): boolean { + // eslint-disable-next-line complexity + const res = objectLoop(query as any, (queryValue, key: keyof typeof query) => { + /** + * TODO: handle $keys + */ + if ((key as string).startsWith('$')) { + return true + } + + let value: unknown = undefined + + // handle deeply nested items + if ((key as string).includes('.')) { + value = objectGet(item, key as string) + } + + // handle if nested item does not exists + if (typeof value === 'undefined') { + value = item[key] + } + + return filterValue(value, queryValue) + }) + + return res +} + +/** + * indicate if a value should be kept by an ENTIRE query + * + * @param value the value to filter + * @param query the full query + * @returns if the query should keep the value or not + */ +function filterValue(value: unknown, query: QueryValues) { + if (typeof query !== 'object' || query === null || query instanceof RegExp || Array.isArray(query)) { + return filterItem(value, query) + } + + // loop through each keys of the query + // eslint-disable-next-line arrow-body-style + return objectLoop(query as any, (querySubValue: unknown, queryKey: string) => { + return filterItem(value, {[queryKey]: querySubValue } as QueryValues) + }) +} + +/** + * + * @param value the value to check + * @param query a SINGLE query to check against + * @returns if the value should be kept or not + */ +// eslint-disable-next-line complexity +function filterItem(value: any, query: QueryValues): boolean { + /** + * check if the value is null + */ + if (query === null) { + return typeof value === 'undefined' || value === null + } + + if (query instanceof RegExp) { + return query.test(typeof value === 'string' ? value : value.toString()) + } + + /** + * ?!? + */ + if (value === null || typeof value === 'undefined') { + return false + } + + /** + * strict value check by default + */ + if (!(typeof query === 'object')) { + return query === value + } + + /** + * Array checking and $in + */ + if (Array.isArray(query) || '$in' in query) { + const arr = Array.isArray(query) ? query : query.$in as Array + return arr.includes(value) + } + + if ('$inc' in query) { + if (typeof value === 'number' && typeof query.$inc === 'number') { + return value === query.$inc + } + return (value.toString() as string).toLowerCase().includes(query.$inc!.toString()!.toLowerCase()) + } + + if ('$eq' in query) { + return query.$eq === value + } + + /** + * numbers specific cases for numbers + */ + if ('$gt' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$gt instanceof Date ? query.$gt.getTime() : query.$gt + return typeof value === 'number' && typeof comparedValue === 'number' && value > comparedValue + } + + if ('$lt' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$lt instanceof Date ? query.$lt.getTime() : query.$lt + return typeof value === 'number' && typeof comparedValue === 'number' && value < comparedValue + } + + if ('$gte' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$gte instanceof Date ? query.$gte.getTime() : query.$gte + return typeof value === 'number' && typeof comparedValue === 'number' && value >= comparedValue + } + + if ('$lte' in query) { + value = value instanceof Date ? value.getTime() : value + const comparedValue = query.$lte instanceof Date ? query.$lte.getTime() : query.$lte + return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue + } + + if ('$len' in query && Array.isArray(value)) { + return value.length === query.$len + } + + /** + * Logical Operators + */ + if ('$or' in query && Array.isArray(query.$or)) { + return !!query.$or.find((it) => filterValue(value, it as QueryValues)) + } + if ('$and' in query && Array.isArray(query.$and)) { + return !query.$and.find((it) => !filterValue(value, it as QueryValues)) + } + + if ('$not' in query) { + return !filterValue(value, query.$not as QueryValues) + } + + if ('$nor' in query && Array.isArray(query.$nor)) { + return !query.$nor.find((it) => filterValue(value, it as QueryValues)) + } + + if ('$nand' in query && Array.isArray(query.$nand)) { + return !!query.$nand.find((it) => !filterValue(value, it as QueryValues)) + } + + return false +} diff --git a/server/src/libs/QueryEngine/parsers.ts b/server/src/libs/QueryEngine/parsers.ts new file mode 100644 index 000000000..bf0046b01 --- /dev/null +++ b/server/src/libs/QueryEngine/parsers.ts @@ -0,0 +1,195 @@ +import { isObject, objectLoop } from '@dzeio/object-util' +import { Sort, type Query, type QueryValues } from './filter' + +/** + * List of allowed prefixes + */ +const prefixes = [ + 'like', + 'not', + 'notlike', + 'eq', + 'neq', + 'gte', + 'gt', + 'lt', + 'lte', + 'null', + 'notnull' +] as const + +type Prefix = typeof prefixes[number] + +/** + * indicate if the string is a prefix or not + * + * @param str the string to check + * @returns {boolean} true if it's a prefix, else false + */ +function isPrefix(str: string): str is Prefix { + return prefixes.includes(str as Prefix) +} + +/** + * Parse a {@link URL.searchParams} object into a {@link Query} + * + * @param searchParams the searchparams object to parse + * @param skip keys that are skipped by the transformer + * + * @returns the searchParams into a Query object + */ +export function parseSearchParams(searchParams: URLSearchParams, skip: Array = []): Query { + const query: Query> = {} + skip.push('sort:field', 'sort:order') + + const sortField = searchParams.get('sort:field') + if (sortField) { + const order = searchParams.get('sort:order') ?? 'ASC' + + query.$sort = { [sortField]: order === 'ASC' ? Sort.ASC : Sort.DESC } + } + for (const [key, value] of searchParams) { + + if (key === 'pagination:page') { + query.$page = Number.parseInt(value) + continue + } + + if (key === 'pagination:itemsPerPage') { + query.$limit = Number.parseInt(value) + continue + } + + if (skip.includes(key)) { + continue + } + + const params = parseParam(key, value) + if (!query[key]) { + query[key] = params + } else { + if (isObject(params)) { + objectLoop(params, (v, k) => { + (query[key] as any)[k] = v + return + }) + } else { + query[key] = params + } + } + + } + + console.log(query) + return query as Query +} + +/** + * parse a simple {@link Record} object into a {@link Query} + * + * @param searchParams the searchparams object to parse + * @param skip keys that are skipped by the transformer + * + * @returns the searchParams into a Query object + */ +export function recordToQuery(input: Record>, skip: Array = []): Query { + const query: Query> = {} + skip.push('sort:field', 'sort:order') + + const sortField = input['sort:field'] as string + if (sortField) { + const order = input['sort:order'] ?? 'ASC' + + query.$sort = { [sortField]: order === 'ASC' ? Sort.ASC : Sort.DESC } + } + + objectLoop(input, (value: string | Array, key) => { + + if (key === 'pagination:page') { + query.$page = Number.parseInt(value as string) + return + } + + if (key === 'pagination:itemsPerPage') { + query.$limit = Number.parseInt(value as string) + return + } + + if (skip.includes(key)) { + return + } + + if (!Array.isArray(value)) { + value = [value] + } + + for (const it of value) { + const params = parseParam(key, it) + if (!query[key]) { + query[key] = params + } else { + if (isObject(params)) { + objectLoop(params, (v, k) => { + (query[key] as any)[k] = v + return + }) + } else { + query[key] = params + } + } + } + + }) + + console.log(query) + return query as Query +} + +/** + * Parse a single element + * + * @param _key currently unused, kept for future compatibility + * @param value the value to parse + * + * @returns the parsed {@link Query} element to be added + */ +function parseParam(_key: string, value: string): QueryValues { + const colonLocation = value.indexOf(':') + let filter: Prefix = 'like' + let compared: string | number = value + if (colonLocation >= 2) { // 2 because the smallest prefix is two characters long + const prefix = value.slice(0, colonLocation) + if (isPrefix(prefix)) { + filter = prefix + compared = value.slice(colonLocation + 1) + } + } + + if (/^\d+\.?\d*$/g.test(compared)) { + compared = Number.parseFloat(compared) + } + + switch (filter) { + case 'not': + case 'notlike': + return { $not: { $inc: compared }} + case 'eq': + return compared + case 'neq': + return { $not: compared } + case 'gte': + return { $gte: compared } + case 'gt': + return { $gt: compared } + case 'lt': + return { $lt: compared } + case 'lte': + return { $lte: compared } + case 'null': + return null + case 'notnull': + return { $not: null } + default: + return { $inc: compared } + } +} diff --git a/server/src/status.ts b/server/src/status.ts index e4e97e235..8554ee57f 100644 --- a/server/src/status.ts +++ b/server/src/status.ts @@ -346,7 +346,7 @@ export default express.Router() ${objectMap(setsData, (serie, serieId) => { // Loop through every series and name them - const name = Serie.findOne('en', { filters: { id: serieId }})?.name ?? Serie.findOne('ja' as any, { filters: { id: serieId }})?.name + const name = Serie.findOne('en', { id: serieId })?.name ?? Serie.findOne('ja' as any, { id: serieId })?.name return ` @@ -364,7 +364,7 @@ export default express.Router() // loop through every sets // find the set in the first available language (Should be English globally) - const setTotal = Set.findOne(data[0] as 'en', { filters: { id: setId }}) + const setTotal = Set.findOne(data[0] as 'en', { id: setId }) let str = '' + `` // let str = '' + `` diff --git a/server/tsconfig.json b/server/tsconfig.json index a3165dfaa..33878bf06 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "./node_modules/@dzeio/config/tsconfig.base.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "esModuleInterop": true }, "include": ["src"] }

${name} (${serieId})

${setTotal?.name} (${setId})
${setTotal?.cardCount.total ?? 1} cards
${setId})