From 1c56857f0cb3e6a2caaa7d5031c05370e75cc033 Mon Sep 17 00:00:00 2001 From: Denodo Research Labs <65558872+denodo-research-labs@users.noreply.github.com> Date: Fri, 25 Oct 2024 01:24:28 +0200 Subject: [PATCH] feat(db_engine_specs): added support for Denodo Virtual DataPort (#29927) --- README.md | 1 + docs/docs/configuration/databases.mdx | 11 ++ docs/src/resources/data.js | 5 + docs/static/img/databases/denodo.png | Bin 0 -> 17428 bytes pyproject.toml | 1 + superset/db_engine_specs/denodo.py | 158 ++++++++++++++++++ .../unit_tests/db_engine_specs/test_denodo.py | 146 ++++++++++++++++ 7 files changed, 322 insertions(+) create mode 100644 docs/static/img/databases/denodo.png create mode 100644 superset/db_engine_specs/denodo.py create mode 100644 tests/unit_tests/db_engine_specs/test_denodo.py diff --git a/README.md b/README.md index 51540104efdf1..62d5e11ee76a8 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Here are some of the major database solutions that are supported: +
**A more comprehensive list of supported databases** along with the configuration instructions can be found [here](https://superset.apache.org/docs/configuration/databases). diff --git a/docs/docs/configuration/databases.mdx b/docs/docs/configuration/databases.mdx index b02a1a83887a5..8f69cc8d6f670 100644 --- a/docs/docs/configuration/databases.mdx +++ b/docs/docs/configuration/databases.mdx @@ -55,6 +55,7 @@ are compatible with Superset. | [ClickHouse](/docs/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` | | [CockroachDB](/docs/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` | | [Couchbase](/docs/configuration/databases#couchbase) | `pip install couchbase-sqlalchemy` | `couchbase://{username}:{password}@{hostname}:{port}?truststorepath={ssl certificate path}` | +| [Denodo](/docs/configuration/databases#denodo) | `pip install denodo-sqlalchemy` | `denodo://{username}:{password}@{hostname}:{port}/{database}` | | [Dremio](/docs/configuration/databases#dremio) | `pip install sqlalchemy_dremio` |`dremio+flight://{username}:{password}@{host}:32010`, often useful: `?UseEncryption=true/false`. For Legacy ODBC: `dremio+pyodbc://{username}:{password}@{host}:31010` | | [Elasticsearch](/docs/configuration/databases#elasticsearch) | `pip install elasticsearch-dbapi` | `elasticsearch+http://{user}:{password}@{host}:9200/` | | [Exasol](/docs/configuration/databases#exasol) | `pip install sqlalchemy-exasol` | `exa+pyodbc://{username}:{password}@{hostname}:{port}/my_schema?CONNECTIONLCALL=en_US.UTF-8&driver=EXAODBC` | @@ -512,6 +513,16 @@ For a connection to a SQL endpoint you need to use the HTTP path from the endpoi ``` +#### Denodo + +The recommended connector library for Denodo is +[denodo-sqlalchemy](https://pypi.org/project/denodo-sqlalchemy/). + +The expected connection string is formatted as follows (default port is 9996): + +``` +denodo://{username}:{password}@{hostname}:{port}/{database} +``` #### Dremio diff --git a/docs/src/resources/data.js b/docs/src/resources/data.js index ec19e92400256..baeed74eb9543 100644 --- a/docs/src/resources/data.js +++ b/docs/src/resources/data.js @@ -132,4 +132,9 @@ export const Databases = [ href: 'https://www.couchbase.com/', imgName: 'couchbase.svg', }, + { + title: 'Denodo', + href: 'https://www.denodo.com/', + imgName: 'denodo.png', + }, ]; diff --git a/docs/static/img/databases/denodo.png b/docs/static/img/databases/denodo.png new file mode 100644 index 0000000000000000000000000000000000000000..3561c387c4bf2587ca3a9249ddac06535bffe56f GIT binary patch literal 17428 zcmeHubySqy+Bb-bf}{#4J*YH8Ntbk|bjN^n4PAnifFJ^bfTVPZ2uMpef`F8CcMVAIZnMpMuh@_qm53=Zv~s zDK-0e!_WJq`tj>pW-qU@3wB)!1w1Z}Vvl5?p<9O?b8%+4Tlq&SsAOYZ>J2X~ye#Qa{98^wJy#V!w084p$J^A6+fl7^nBd>>N9~zV8Qa9q Zk2~AMeh0#%>G7GL0Ya3hpiWZj4t=9y^xX zj2jedxK6jxYZUydIv-mOya`_!xLxP7lsc()iic&Yd|i$5*+z-(0sL@|?eaqgUqU zt|STax^5Yu2q|t(Ed44VE~=UHSg_;jb=@FA`5KR|RT zTji;Ft-4noiI@Xns{pgrCmC&xc*ww*6-Q!rPW}0rzQO8y;c@|bO77cAjcLqwQ$mR4 ze)N!p8VU<-k>z}=-Cu={t89&s1{Mp%i_`Pepx%S&pzV_&G9xoNVYk!Gi*s9z0=0o; zo`8O=tYR0JWqu!lr~K<`A`b_uI6KFS)|`2nbiTc8aa>eXRSoEj^|7n+mXT9<)ok>v z&if1(Omg2_Y3|wL9~c^%9mr8RT*(m|wv)Aiw7ebuW}gC&_37$FK~W_qocREhmfxW<`qKR91!Yh7 z%x!FJIHW1^dAvyyCMM^c1IFSjF!frOSd4M;@qcC}lW%QN2^Tfi?x1B_JW9lxpYCY1 zL66>7YoQVk6Vh E@pA0SE{N}V1n zl2g*l4JVh7lfzPD@{sA^=0ZnJPfPnG7 zkJX`DlRZDFKND8wdkz(l+TOXla 9C+G=wD5CBxIi#B{=iqH*nGE-A0`Eu7u; zieGp3W|F!RYIK!w-Zw^;2y1+M{?1g$&Ea0PCbAu+X}kOD14dh7*7DO>{1xE3@1eS; z-x{i2kA7BR6qX0MYf|ZLxSPo_85tX6-Pa2vT7ktje-5^*u5out$ZCA>v@4;&(^WZ0 z+O%~b#%^;xwRWeMl0m<$>+?tb^q2(D)-4OTuL&~VKvih1sM!t40_`-izw(j}NA`5> zdil#vgtZy-g}PO^{1#!99*ZNpghp-YK0-=LN(pKM9;~XW#Xge)vI_DXx(28(hfK{3 zR_5BX3kyxLoFY!NpFe+8!|-cx0K&9SyEa
HEG#wbuCp@Ysz|xwF+F#)#x&*pCqvMPU_jv;gI4xcfIsU z$Qd&`c@ruUdwS4kKlAA3@z@~^29JEAKhx1>qpeP2;ep!fDigy%UTz2{L9H)XT3p%` zPh$;dZc&lM0{w%y7}ox;F-0ZMKAx_LTK)c9pGVyPeq$4?2 )}wUzEAh7 z%br;4LugNqxob-6E312()1V W-bawk$zdyw(~~r-2%28^ zDDJZsnq8$mTwuBv2i?uegk~K{-s!n?(lRnwq~b`cYHAW~47{trNPckKL-YYJYm6u+ zf0>gb0c&% PM_Nsw)oVBCr@dTw;w(r9+0~TkR+G(<7M7e(1kwqoqvJ6F@Og z>yXXYEA6 Uhs8Y}c%E8YOU9<6%*WA-cH#Ok{{T^s?#_1$zy;+3jXqi!z=;Su zqSA?$bU*QNW9X{YVuG^DW)ia|>Fv_Pm5;tj_o4LqRI1-62-doteatet*azUad4ntW z=h0Aheie!Ub$uSO3dBK}tjVkEtf}br@AGEitc*~qiC^G>W^VNC02Fztjy3|o06vYB zy`Dgp`=r$J$ihB0Qw(HE;qU#&-Tg!BZ@%0?EaZ6B6!<0rz2Ecs(oes?K2c{&ZVKKy z?)Jm`2xdjk^uCril=FQ=O_j{{xXWU3amshQO?-nC&e{% *z7yZ>ZxP!Pxw9){iiwG3-a<&Z5i6$Y3DHFS_W8z9$lcm(U#u zf{Q`3^%h4nF y`xS-$FZ?n~ew{?>8-(R&ZR{nz_&tKlf59L-cH-Cve!Ow0+N4qaTYNGx?#PJvO6R zmy%Mo;2_9s^@wM2A-(_WBDbv`WU`O|9>32AMrJ`@>f lqn8Rvj4#qnW%?{?cIUA%apd(N&eY4EAcr5aQl0-*P|x3n+WP5STz89NRfCTGXl znQy*-&j{k?>Li7g$>YZ}1VROX&-wHl+U578-BydnZCZC=ovvtMzqj>@-@z8h5L#99 zV}pL6+vHsA7CgTW{rc=E2uB#`hEref@zU)O)709pUkB*RJlWU)?ZLqTqg4s=ZDFDT zi2$RO`|_k# M|YC`d(JECO@sB}Vzk$ b3PsKl4F z uc?IDC7m(?a)U3CyQ zG;V>(l~g1>t+(8ElE9-u<~@@y&}RT9q21ar3wY1c(_Qwwyu1K48$-NlSC2gle}DgP zh=coL^}|aq!ic!>d7amAYlqxHg(Xsh3RDSdXY=zw6;)NN65{6;?hkL`kcIiI^r3+z zU36cZY@E7z`YhvPtCMkv#{JyQc|T|oa~>W5@-|;=v_GXtV2VPrz=5YoHdqDcq~|ND zARLcwY<_WCwjSq; %o6WB9V)kJ=eU%2b%9WYzNQx%0`FG}4bc1}*ts^eH# zou5FR0jU2-MmE%0^K$8|*$=( 19C~A9OoONDYcOe(s`U#}3 znaZ1-R!pOQhK)g%#~;3|evaENmX!0jotm0z&BW &W+AD>nPzj@=7^D&1HeycN Qan@=C3ZGDFd)PoKtnCw1|3anlqF2;xLWMy}Iw5`@mHot3&CDVs0W zd5v*&3xX&lg^9S`E;X#Cv$ive7251~c<2gVDcW_Y5y2S5I(FtYkB&=DzUjcrW_4%> z;=j0sdwldHC9pS}Hlda~d(;X^(LcwW)T*`k0@d%`045SS%DkfGo39OBOOlOemn#23 z&f&=g3`uv`=>(y&xw_pN79nQgK3d=tMkL*nG+4$Gts2DL*bcZXUo8SD9GQBIuXPAW zRr$Z*rU4jRaN>ajzr*+J%1SM*4 >cyz?PU5;j%>W_U%x`Z7_J#!{SpRvtag&${4GoP22%SN*@;U$r zCCTfDR@TGR@>Hcs%K(Y)#@B4ai-cD9&QJ1n_TQVLW~?0A)53&TorKEKPu|?PL8jsZ zswdjeuakN;TCuEXD9a^gGCBQR%~5ymufX^qUS@KIg?TN&S;z9!OLnY-{O2C&9$?q? z2S-u`kjktcjeV&pc|tfA5{id30BQy yP zJrG=nHO@TxK}`Ee(ECE_d6s3z0IjeOR7}agyWk*teHnO%qW!0z1 D1fnWdFW% ze$kIamh}S)DmIl}{&D_{L=s--uJqN<-?+A6{o{&E?mW+dQ>H>uSx;V^-M-zANA5Fo z+pzVE21CPYNn^~NJ(HJ;(Bsf=z`YG?_O$Pxf3wf5Nlm*J8m8oKHcxM{0wSh&=fehr z`8jGV@s&=TXg4_N6RMPNcRN&7@7Hpkg%O(G;!jGWab;?+=FMN9ya?!>?bh9@e2Yb7 z^2-dQb756gjz^E6AotUGPRzcg6!F)O?(J3VpHNit6 bzRrGd4uXLaxFM+e8Zer*Mc(8`^d2l-SGh^z~n*p2s- zf{3?6_izQstjCQ-$k+ytehm)Z=1 tXtwp>0f8FzqfAgb#RUGn9jA8<~U=LX9;{h>mmxfd@{8^WEoXT*Nt7c7~5 zaZvUUUSpy+G&KC_t=>1i7Z!@y+1c4tXpgJuLzE+T*Gpgb@rsm{jHpc$%9c5^6!u;g zu{O_BGF{B ;CGK(@f7=kML>#g5;!#5I(i|lffTZb60P&= zJQNiCKIB<|539aihta!hnt2jFwzg))8vY8AGv0fj9@p)keqOp^%1W<4wO}m7w)Y9h zLP3x*az|V~3xGJd&iR`?$+74B>|X$$m9U^A#+d|CyYhk!@7~&o%F?!uIl1@YG 2CoUOa-cnwtLXzVrZRb{}v{1sn3NU8H8InN?ilrCFxZ{YYOi_+w}k0hAYF zY{x~?(C8~0l19M*(Rm?MJYuzuL6hbZx6&`+oh>gZ&nibsmrliCDNv)*_T5)FV#swP zF}fKsH=nA 6tcSVTa7Bvsa?>u8l)`9YJGW>CKj)sSC zH^#eFOsfm1?x1|Up#VK=7eNmP)pCh1yF>Ot%T1PS7u%ZzX=-hWdPaxh-6)m{qJq|Z z ^J(KUX3tCTw>JQo^RZZ#9!c| zUAuOOwe655g1#()*w7Uxy2pInFAkJ}a5dFJGrB%;_$Z)_UbtpSLiD#`^RRETb}%;S z9CsKq4MincP1{Gl;j#~jOSA!LSEX_atA;-8nGwhi~VT|A-h)z(Ru)#iq>-gOA%3UGy7?q{2StD!}nlsF3+o5G9K z`~dS%asK*et7W(kIrk^JgfVhgraD$%+eZ lWtX5S`RkgMrAplC2&rULwo3Eb*+##VsxW+>D7w$cW;HB2;*Le#otN~v`G+396 z_>)0RJ@aX6*#Sb>0;+-cWNB(T)vkT3*Q7nreJWix7n}~}cIG<+YpKguf9FK?I-VuV z#D4M#0`W^=Sm@Jz`8xA}#%Wyl>T|T?gqg}y_#RyyiRhE9@uOYg2x|AO)-X|Y^@*r` zm(yTAfq~GZKs%)^nhHGO8eUQPpd3M`kjFu?5-rcKjTAntMq@fo3f`ED(*;naKiOv2 z2R1#IH{7KkKKUO5&+yQl9|NY@dehNmOLface1a=R2%%D%GEsx&r4ZKJo|Buqvt+P= z4UFN8 8LR0u)}pL=l<1 zOBHrqzxMQ%c}LjN9y)~Z3vtG5Yd4XkbT(m)v5>A@0b+`#^4aN`2+nOw*>R`+HO=>A ztoQq1pY}xy5R;mK%zof^oAD;5XnyatZwL>FLpeF4U&Ye;J6Oc8Q15pTcY^CI1CtQ< zn6kAg8ux+kAG0Vy9uxktH(YGYz;8mACP69|RL+}|8v?Q=IF%B!MO``Z?krjf5pDsh zMH`>MrC4>{Z+6ctD)9JaUTV9(`>F_aSL&yIjmZkLGF#s0$Bq10xPt}*#CWv)6Zb!B z6(A$uD@b9WVBdl0f Zn8Cy zZ(-jocn!V|y4K@k_xBX6F #K8JT3Q2qT_CbEb;79l zmXFSJb$ajO56H@cd>;?1tQ3EI8QFTTAg)0k%;)o)V9A0aEOmDo71_FEW|!}8Fx?lZ z!AFr#;-f{lg4h}lk2Cptf@~i$#tCb}H}GuCq*z7xQNkWlIrnvC9&p~Sqe-KW2CaY` zK^Q0+r*~C2=WMrv{Zex2K!^Ae>>xX=k~?egDHs@}Y>o4o+>s>_U~pbB0vWN>($B=d z1(X-J>!>{r*6`e`K`R^Y^$V08{z)gGVIA4H`})=%$V*1_4dWTYjWM~o$cFNmS0MJ2 zE-oRzpcCke*J68xEZPCEy&H39I-*4io3S$i5B V#Q*x{(`6Kw_k+`}zcp_e*!XhF8$#dYX{-k*`ozQ?nZ}gX-r+YE< zO^)B|bAr%5-X zA_NJ}`G*{|R3s&Xx2Sa>LsqJwFS14ZYIX2h2YU~c=No=Q0ZE^-E5=yGmjO P3e&*miwJhnGabjxxlhyKk_{&>h3XMoPq)I%@t@v^V8`nu|pq_ukN$^O-&h0 z@2T0yhrjJ}&l{vWr8N+Cz-J%`%}l0sH{k}I5M}yaZZ72H4X5n*#@z~%6;x~T)zZP8 zj81sbOTpo #I6Jr>CWHAKn=q8rYU<6}u18H8l%7KGln20gMh;Row%y z%(8dl&!5WEla>XUJ;6zSDggAw;aKK M|cx_>G!wtxKRbG-h~ySh28RF O wQr+9fNuO>+SH^2q$DSlH zQAGgkwnX1s^Uz6FQvRnEI! l+ zhIWBkgvG(@-xzxlg4_Plcb#u~6g+GJ0OhB9Rw$rr2R&(cXlT@NGw@Vw5wbHhuu}gZ z4^>qqUEMcGNPJ1j_*)* =Lc^ABd9JPDr&@+y^3 zhk+sy6X8B|MkOAOh;tSMyFU?@m-fdh@v3AIDheQ48^$E3o`R&LIEHzbU?7RraGWm^ ztG0FO$d@CXcxA<6d_iJ#-TAq?y76ov77_c_g~3kA-GDlwur63rTVz8P9?z>Y#GHv7 z0qVwNISy4Ib`<{7UUs7i-!pp$0YkW1$a5#Br>y$zZwZzF6rOu@ZDfo#1X2S)+MJNA z@)Oa^Lv$P0b0{OupRGWVAUc~~obTtYtrIoV+}xSD_Nd`1vj~LSU~}@?K#d*o^!ZmV z<;bMKx4vjBVD}Kd`l~ym^*}A}K1ko eRgOHWP_I;a_<#xEDlOs? znB4E%Ch4E@V>y2L!hTYHcCv2>aPb@%B@o+a>D|EG-0Z9Ux<^j*YBej&n*EfU)lin% z7D{$@M6HS@u*?+;%Zy1Jlx!~LoD K6`vmY2Lz;g9Bcq&Z?S+GH1|w;)xn?&2K#nzC^Ms$HG{;UW4m $ zRFUHn6VsO}RjWVa{Bndzc#Buf%8CKNeGkto2L=aN^P}uQMGP803n6>&i|(&)wKv`B zRWjwDb}^}%# ;g_2v)1$Z!PuGlVs#5fEPQcw1Dz(s?+BA%OS-J@_l+V<-J?R9 zkY4%gkt1Pm!o`JI58l`~rYI=%do{6ub`#gquL}a+FjRqHETy+C((A^Y3} )Vv#xby7^rLN&R?(1W)+#s8$|E++!j;&ez3j2GAJyeVnawy{-aM4;_Ny8rQ${C zXC7Ild-+G5`mTMOcZu}dUByPXlapCqt)F`_=zS5@s#3VK$5f)h;7Ky1Rj*h3{CKv! zVt3)owI@ccLyrLtN(6u;UhSKhVGx4WFSGORRX>`GO*#~DetVL%VjS9l$|Me~53&1M zgJD=h@w E-2kBQ9YPrN z=X&Y*`h9Ss%Rpg3x;2(6!ez*i7!%{uFGB#F?9z6S`m(UF^f528aqz^ipOeB&0oD{~ z4tmxJ#;7N9MmMb9w!E!R%Ju>vt VD0rN_!HfC^<*vXXiu_m&Z&Bdovt4qJzxVK z@NJwr(7pUs4QEdPyG%U@X#%R0MxQwyQp`TWPKA6tsy5%K*b^ 20D(t{*88(Nbv)Gu|t&&grILa0-~GGr{i+m<+K%@8rY=5-U?p0I0`%-y}Z40p7 zo)|gy5ftUPcB_5<7-P3h&C5+1lpSE84`3MjjzGt+eM@6oZvDYqyp|3uyUm1h Y{2QXJ1+bN_lad*Wx!S-VTm-1_Xl9IVmauQmzz?wbGTt}*5n1sC33vDvsoP>r5 z?#qPqMu0EzyYm X+a53&+28~rljNNk3nDoxUPLH@lLJc zrWKLnMCV?1&6>(3Hl%zgqlqvo)?fx}DK0vF+3NgViw%HNG_+?VtGm13JW$8)Ll#Y7 zZoiX$gL-RN=WFxV=_YQXX(`e| jX0)xEzX6uh)84qC+}JY=itOqTKG z%P9koc|A5k^!yCi$-!^~;y~`9YugvASD$mUvf=}_6qS!FTIKj9CFO;IhKdWm5p8d8 zlYGc>4w5Vx)Rza|Qr?hG?X@W)EG)}SgvTbZuciTPe}CAa5tP~bm)Ss^3omaF gjzFxNna6?$3& z;5mSdb=pnR14`hL+f~>bI(4)vS?+MVYF&_#_BTe1hfi362#EMi!-fP%*=4H8A3p4G z+Xdc-0aC8hDVM<6*XzjbBK}xlFRt#wBxz}BhuH-;K=P-yu=wnALHKi1)iTCm;4{q~ z5-wb?V??AD%O zPqS#?kGGNwFizPxTxys>eKx3H+oP!M=e7(%fkQCn1yQ)Y$9Nx6S;-SkPSal#c?Y2Q z{)fo^c`DNTOe1B?t*9r7KlIOumz^)HAarB!2LauOJ>-9gJk zkdJ{+l83kC&J#xT)5=k;fq{Xk!{XV>a(b3oV-}097s$i|j=9{~S$}b0sW7mj=RAx| z7flV`06Fq>Ip?dbgD?MDGVmZTc=CuE zhbY&sT^ruznD#Q3q_ze7|5J8cC@6r`D8dCS?>j5xNN; =0.2.0", ] db2 = ["ibm-db-sa>0.3.8, <=0.4.0"] +denodo = ["denodo-sqlalchemy~=1.0.6"] dremio = ["sqlalchemy-dremio>=1.2.1, <4"] drill = ["sqlalchemy-drill>=1.1.4, <2"] druid = ["pydruid>=0.6.5,<0.7"] diff --git a/superset/db_engine_specs/denodo.py b/superset/db_engine_specs/denodo.py new file mode 100644 index 0000000000000..2de528260f864 --- /dev/null +++ b/superset/db_engine_specs/denodo.py @@ -0,0 +1,158 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import re +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy.types import Date, DateTime + +from superset.db_engine_specs.base import BaseEngineSpec, BasicParametersMixin +from superset.errors import SupersetErrorType + + +# Internal class for defining error message patterns (for translation) +class _ErrorPatterns: # pylint: disable=too-few-public-methods + CONN_INVALID_USER_PWD_REGEX = re.compile("The username or password is incorrect") + CONN_INVALID_PWD_NEEDED_REGEX = re.compile("no password supplied") + CONN_INVALID_HOSTNAME_REGEX = re.compile( + 'could not translate host name "(?P .*?)" to address: ' + ) + CONN_PORT_CLOSED_REGEX = re.compile( + "Is the server running on that host and accepting" + ) + CONN_UNKNOWN_DATABASE_REGEX = re.compile("Database '(?P .*?)' not found") + CONN_FORBIDDEN_DATABASE_REGEX = re.compile( + "Insufficient privileges to connect to the database '(?P .*?)'" + ) + QUERY_SYNTAX_ERROR_REGEX = re.compile("Exception parsing query near '(?P .*?)'") + QUERY_COLUMN_NOT_EXIST_REGEX = re.compile( + "Field not found '(?P .*?)' in view '(?P .*?)'" + ) + QUERY_GROUPBY_ERROR_REGEX = re.compile( + "Error computing capabilities of GROUP BY view" + ) + QUERY_GROUPBY_CANT_PROJ_REGEX = re.compile( + "Invalid GROUP BY expression. '(?P .*?)' cannot be projected" + ) + + +class DenodoEngineSpec(BaseEngineSpec, BasicParametersMixin): + engine = "denodo" + engine_name = "Denodo" + + default_driver = "psycopg2" + sqlalchemy_uri_placeholder = ( + "denodo://user:password@host:port/dbname[?key=value&key=value...]" + ) + encryption_parameters = {"sslmode": "require"} + + _time_grain_expressions = { + None: "{col}", + "PT1M": "TRUNC({col},'MI')", + "PT1H": "TRUNC({col},'HH')", + "P1D": "TRUNC({col},'DDD')", + "P1W": "TRUNC({col},'W')", + "P1M": "TRUNC({col},'MONTH')", + "P3M": "TRUNC({col},'Q')", + "P1Y": "TRUNC({col},'YEAR')", + } + + custom_errors: dict[ + re.Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]] + ] = { + _ErrorPatterns.CONN_INVALID_USER_PWD_REGEX: ( + "Incorrect username or password.", + SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR, + {"invalid": ["username", "password"]}, + ), + _ErrorPatterns.CONN_INVALID_PWD_NEEDED_REGEX: ( + "Please enter a password.", + SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR, + {"invalid": ["password"]}, + ), + _ErrorPatterns.CONN_INVALID_HOSTNAME_REGEX: ( + 'Hostname "%(hostname)s" cannot be resolved.', + SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR, + {"invalid": ["host"]}, + ), + _ErrorPatterns.CONN_PORT_CLOSED_REGEX: ( + "Server refused the connection: check hostname and port.", + SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR, + {"invalid": ["host", "port"]}, + ), + _ErrorPatterns.CONN_UNKNOWN_DATABASE_REGEX: ( + 'Unable to connect to database "%(database)s"', + SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR, + {"invalid": ["database"]}, + ), + _ErrorPatterns.CONN_FORBIDDEN_DATABASE_REGEX: ( + 'Unable to connect to database "%(database)s": database does not ' + "exist or insufficient permissions", + SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR, + {"invalid": ["database"]}, + ), + _ErrorPatterns.QUERY_SYNTAX_ERROR_REGEX: ( + "Please check your query for syntax errors at or " + 'near "%(err)s". Then, try running your query again.', + SupersetErrorType.SYNTAX_ERROR, + {}, + ), + _ErrorPatterns.QUERY_COLUMN_NOT_EXIST_REGEX: ( + 'Column "%(column)s" not found in "%(view)s".', + SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR, + {}, + ), + _ErrorPatterns.QUERY_GROUPBY_ERROR_REGEX: ( + "Invalid aggregation expression.", + SupersetErrorType.SYNTAX_ERROR, + {}, + ), + _ErrorPatterns.QUERY_GROUPBY_CANT_PROJ_REGEX: ( + '"%(exp)s" is neither an aggregation function nor ' + "appears in the GROUP BY clause.", + SupersetErrorType.SYNTAX_ERROR, + {}, + ), + } + + @classmethod + def epoch_to_dttm(cls) -> str: + return "GETTIMEFROMMILLIS({col})" + + @classmethod + def convert_dttm( + cls, target_type: str, dttm: datetime, db_extra: Optional[dict[str, Any]] = None + ) -> Optional[str]: + sqla_type = cls.get_sqla_column_type(target_type) + if isinstance(sqla_type, Date): + return f"TO_DATE('yyyy-MM-dd', '{dttm.date().isoformat()}')" + if isinstance(sqla_type, DateTime): + dttm_formatted = dttm.isoformat(sep=" ", timespec="milliseconds") + return f"TO_TIMESTAMP('yyyy-MM-dd HH:mm:ss.SSS', '{dttm_formatted}')" + return None + + @classmethod + def get_datatype(cls, type_code: Any) -> Optional[str]: + # pylint: disable=import-outside-toplevel + from psycopg2.extensions import binary_types, string_types + + # Obtain data type names from psycopg2 + types = binary_types.copy() + types.update(string_types) + if type_code in types: + return types[type_code].name + return None diff --git a/tests/unit_tests/db_engine_specs/test_denodo.py b/tests/unit_tests/db_engine_specs/test_denodo.py new file mode 100644 index 0000000000000..31e9c0dea0d96 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_denodo.py @@ -0,0 +1,146 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from datetime import datetime +from typing import Any, Optional + +import pytest +from sqlalchemy import column, types +from sqlalchemy.engine.url import make_url + +from superset.db_engine_specs.denodo import DenodoEngineSpec as spec +from superset.utils.core import GenericDataType +from tests.unit_tests.db_engine_specs.utils import ( + assert_column_spec, + assert_convert_dttm, +) +from tests.unit_tests.fixtures.common import dttm # noqa: F401 + + +@pytest.mark.parametrize( + "target_type,expected_result", + [ + ("Date", "TO_DATE('yyyy-MM-dd', '2019-01-02')"), + ( + "DateTime", + "TO_TIMESTAMP('yyyy-MM-dd HH:mm:ss.SSS', '2019-01-02 03:04:05.678')", + ), + ( + "TimeStamp", + "TO_TIMESTAMP('yyyy-MM-dd HH:mm:ss.SSS', '2019-01-02 03:04:05.678')", + ), + ("UnknownType", None), + ], +) +def test_convert_dttm( + target_type: str, + expected_result: Optional[str], + dttm: datetime, # noqa: F811 +) -> None: + assert_convert_dttm(spec, target_type, expected_result, dttm) + + +def test_epoch_to_dttm( + dttm: datetime, # noqa: F811 +) -> None: + assert isinstance(dttm, datetime) + assert ( + spec.epoch_to_dttm().format(col="epoch_dttm") == "GETTIMEFROMMILLIS(epoch_dttm)" + ) + + +@pytest.mark.parametrize( + "native_type,sqla_type,attrs,generic_type,is_dttm", + [ + ("SMALLINT", types.SmallInteger, None, GenericDataType.NUMERIC, False), + ("INTEGER", types.Integer, None, GenericDataType.NUMERIC, False), + ("BIGINT", types.BigInteger, None, GenericDataType.NUMERIC, False), + ("DECIMAL", types.Numeric, None, GenericDataType.NUMERIC, False), + ("NUMERIC", types.Numeric, None, GenericDataType.NUMERIC, False), + ("REAL", types.REAL, None, GenericDataType.NUMERIC, False), + ("MONEY", types.Numeric, None, GenericDataType.NUMERIC, False), + # String + ("CHAR", types.String, None, GenericDataType.STRING, False), + ("VARCHAR", types.String, None, GenericDataType.STRING, False), + ("TEXT", types.String, None, GenericDataType.STRING, False), + # Temporal + ("DATE", types.Date, None, GenericDataType.TEMPORAL, True), + ("TIMESTAMP", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True), + ("TIME", types.Time, None, GenericDataType.TEMPORAL, True), + # Boolean + ("BOOLEAN", types.Boolean, None, GenericDataType.BOOLEAN, False), + ], +) +def test_get_column_spec( + native_type: str, + sqla_type: type[types.TypeEngine], + attrs: Optional[dict[str, Any]], + generic_type: GenericDataType, + is_dttm: bool, +) -> None: + assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm) + + +def test_get_schema_from_engine_params() -> None: + """ + Test the ``get_schema_from_engine_params`` method. + Should return None. + """ + + assert ( + spec.get_schema_from_engine_params( + make_url("denodo://user:password@host/db"), {} + ) + is None + ) + + +def test_get_default_catalog() -> None: + """ + Test ``get_default_catalog``. + Should return None. + """ + from superset.models.core import Database + + database = Database( + database_name="denodo", + sqlalchemy_uri="denodo://user:password@host:9996/db", + ) + assert spec.get_default_catalog(database) is None + + +@pytest.mark.parametrize( + "time_grain,expected_result", + [ + (None, "col"), + ("PT1M", "TRUNC(col,'MI')"), + ("PT1H", "TRUNC(col,'HH')"), + ("P1D", "TRUNC(col,'DDD')"), + ("P1W", "TRUNC(col,'W')"), + ("P1M", "TRUNC(col,'MONTH')"), + ("P3M", "TRUNC(col,'Q')"), + ("P1Y", "TRUNC(col,'YEAR')"), + ], +) +def test_timegrain_expressions(time_grain: str, expected_result: str) -> None: + """ + DB Eng Specs (denodo): Test time grain expressions + """ + actual = str( + spec.get_timestamp_expr(col=column("col"), pdf=None, time_grain=time_grain) + ) + assert actual == expected_result