├── .docker ├── setup │ ├── requirements.txt │ ├── Dockerfile │ └── setup.py ├── clickhouse │ ├── cluster │ │ ├── server1_macros.xml │ │ ├── server2_macros.xml │ │ ├── server1_config.xml │ │ └── server2_config.xml │ ├── single_node_tls │ │ ├── Dockerfile │ │ ├── certificates │ │ │ ├── ca.key │ │ │ ├── client.key │ │ │ ├── server.key │ │ │ ├── client.crt │ │ │ ├── ca.crt │ │ │ └── server.crt │ │ ├── users.xml │ │ └── config.xml │ ├── users.xml │ └── single_node │ │ ├── users.xml │ │ └── config.xml └── nginx │ └── local.conf ├── .github ├── deps.edn ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── question.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── check-jdbc-snapshot.yml │ └── check.yml ├── deps.edn ├── .build └── Dockerfile ├── .gitignore ├── AUTHORS.md ├── HISTORY.md ├── .static └── clickhouse.svg ├── resources └── metabase-plugin.yaml ├── src └── metabase │ └── driver │ ├── clickhouse_nippy.clj │ ├── clickhouse_version.clj │ ├── clickhouse_introspection.clj │ ├── clickhouse.clj │ └── clickhouse_qp.clj ├── README.md ├── CONTRIBUTING.md ├── docker-compose.yml ├── test └── metabase │ ├── driver │ ├── clickhouse_temporal_bucketing_test.clj │ ├── clickhouse_impersonation_test.clj │ ├── clickhouse_test.clj │ ├── clickhouse_substitution_test.clj │ └── clickhouse_introspection_test.clj │ └── test │ └── data │ ├── clickhouse_datasets.sql │ └── clickhouse.clj ├── LICENSE └── CHANGELOG.md /.docker/setup/requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.2 2 | -------------------------------------------------------------------------------- /.docker/setup/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.9-alpine 2 | COPY . /app/ 3 | RUN pip install -r /app/requirements.txt 4 | -------------------------------------------------------------------------------- /.github/deps.edn: -------------------------------------------------------------------------------- 1 | {:aliases 2 | {:user/clickhouse 3 | {:extra-paths ["PWD/modules/drivers/clickhouse/test"] 4 | :extra-deps {metabase/clickhouse {:local/root "PWD/modules/drivers/clickhouse"}}}}} 5 | -------------------------------------------------------------------------------- /.docker/clickhouse/cluster/server1_macros.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | test_cluster 4 | clickhouse1 5 | 1 6 | 7 | 8 | -------------------------------------------------------------------------------- /.docker/clickhouse/cluster/server2_macros.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | test_cluster 4 | clickhouse2 5 | 1 6 | 7 | 8 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps 3 | {com.widdindustries/cljc.java-time {:mvn/version "0.1.21"} 4 | com.clickhouse/clickhouse-jdbc {:mvn/version "0.8.4"} 5 | org.lz4/lz4-java {:mvn/version "1.8.0"}}} 6 | -------------------------------------------------------------------------------- /.build/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG METABASE_VERSION 2 | FROM metabase/metabase:$METABASE_VERSION 3 | COPY ./clickhouse.metabase-driver.jar /plugins/clickhouse.metabase-driver.jar 4 | RUN chmod 744 /plugins/clickhouse.metabase-driver.jar 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | \#*\# 2 | .\#* 3 | /target 4 | *.jar 5 | *.class 6 | /.lein-env 7 | /.lein-failures 8 | /.lein-plugins 9 | /.lein-repl-history 10 | /.clj-kondo 11 | .eastwood 12 | .calva 13 | .cpcache 14 | .joyride 15 | .nrepl-port 16 | .idea 17 | -------------------------------------------------------------------------------- /.docker/nginx/local.conf: -------------------------------------------------------------------------------- 1 | upstream clickhouse_cluster { 2 | server clickhouse1:8123; 3 | server clickhouse2:8123; 4 | } 5 | 6 | server { 7 | listen 8123; 8 | client_max_body_size 100M; 9 | location / { 10 | proxy_pass http://clickhouse_cluster; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Contributors, Authors 2 | 3 | The initial source base comprises major contributions from these authors (_the git log has suffered from frequent brutal rebases, please add yourself here, if I missed you!_): 4 | 5 | * Bogdan Mukvich (@Badya) 6 | * @tsl-karlp 7 | * Andrew Grigorev (@ei-grad) 8 | * Felix Mueller (@enqueue) 9 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM clickhouse/clickhouse-server:25.2-alpine 2 | COPY .docker/clickhouse/single_node_tls/certificates /etc/clickhouse-server/certs 3 | RUN chown clickhouse:clickhouse -R /etc/clickhouse-server/certs \ 4 | && chmod 600 /etc/clickhouse-server/certs/* \ 5 | && chmod 755 /etc/clickhouse-server/certs 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the driver 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | ### Use case 12 | 13 | ### Describe the solution you'd like 14 | 15 | ### Describe the alternatives you've considered 16 | 17 | ### Additional context 18 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/ca.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDA26CfROCd9PBA7jUKmImXeYSuExnZcdNeX6AR0Aaj1wgZ/iH3hoQYH 6 | bPi7JnRTo+6gBwYFK4EEACKhZANiAATTKvPxkWILniWZ9EmcftQRqhH7fpVhQm1h 7 | vtZW1cpTozV0z6tdopnS5p/Wl+Kti2k/kZx1rsN1ZrRYKJN8ANruJJ6vaDOjbf89 8 | cmViZ/dbOi49T8brTzdHeuGIE2TyP+U= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/client.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDCsmtNREbSJvGTTIQmx019frOllv9m2EqOWesvqu52rH6tIxdw5TE4A 6 | SZICvNKYzH2gBwYFK4EEACKhZANiAAQa0kr/vcR7KfAYHt8HaDvt1XQzOAw66HBN 7 | BpvhFuuTDev+qA5m8ijgdSW02NGqt5SDfT7VImwqxg6nFarUJ4oZ70yAy6itF5cb 8 | 7+iXNLeg712GioYOoyIprQezRL8rBlc= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDDyhVuBtGuyKEDr2HciKi4yS2T0WloMeUG2kgQRKim7Mih7977q7RbI 6 | t6sGwlsBxKGgBwYFK4EEACKhZANiAAQKy8dFjE+v6Qn8DsMNqMS7w+F075YbHMmz 7 | uT1J3Kk9ZLE5oIMwTa1arS3QaLLrclg2Gyf4ImCU0heG19LmcdLs1XlJ8EAFRW1l 8 | Lb8wWTwLAULP5Fxnh1vaC33fsCCN/EI= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about the driver 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | > Make sure to check the [documentation](https://clickhouse.com/docs/en/integrations/metabase) first. 11 | > If the question is concise and probably has a short answer, 12 | > asking it in the [community Slack](https://clickhouse.com/slack) is probably the fastest way to find the answer. 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | ## Checklist 5 | Delete items not relevant to your PR: 6 | - [ ] Unit and integration tests covering the common scenarios were added 7 | - [ ] A human-readable description of the changes was provided to include in CHANGELOG 8 | - [ ] For significant changes, documentation in https://github.com/ClickHouse/clickhouse-docs was updated with further explanations or tutorials 9 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | The request for a ClickHouse Metabase driver is formulated in [Metabase issue #3332](https://github.com/metabase/metabase/issues/3332). Some impatient ClickHouse users started development. The Metabase team is asking driver developers to publish plug-ins and collect some experiences before considering a PR, so here we are. 3 | 4 | This driver is based on the following PRs: 5 | * [metabase#8491](https://github.com/metabase/metabase/pull/8491) 6 | * [metabase#8722](https://github.com/metabase/metabase/pull/8722) 7 | * [metabase#9469](https://github.com/metabase/metabase/pull/9469) 8 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/client.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIB+TCCAX8CFEc86vC0vsMjLzQzxazHeHjQblL2MAoGCCqGSM49BAMEMF0xCzAJ 3 | BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEgMB4GA1UECgwXQ2xpY2tIb3VzZSBDb25u 4 | ZWN0IFRlc3QxHzAdBgNVBAMMFmNsaWNraG91c2Vjb25uZWN0LnRlc3QwHhcNMjIw 5 | NTE5MjEwNTA2WhcNNDIwNTEzMjEwNTA2WjBkMQswCQYDVQQGEwJVUzELMAkGA1UE 6 | CAwCQ0ExIDAeBgNVBAoMF0NsaWNrSG91c2UgQ29ubmVjdCBUZXN0MSYwJAYDVQQD 7 | DB1jbGllbnQuY2xpY2tob3VzZWNvbm5lY3QudGVzdDB2MBAGByqGSM49AgEGBSuB 8 | BAAiA2IABBrSSv+9xHsp8Bge3wdoO+3VdDM4DDrocE0Gm+EW65MN6/6oDmbyKOB1 9 | JbTY0aq3lIN9PtUibCrGDqcVqtQnihnvTIDLqK0Xlxvv6Jc0t6DvXYaKhg6jIimt 10 | B7NEvysGVzAKBggqhkjOPQQDBANoADBlAjBblevbpaRlekX7fH16KnYttGoIqDBI 11 | 45LlBJ2sEe5qSKCBoLdN89Tk8WD4lG7PhlkCMQDdFd8OKMPaZiUWIdHI6AeDWwXD 12 | bJi0LwDxXgyBVCGLZ2vTbOVxnr2Qp+9BjFURU8c= 13 | -----END CERTIFICATE----- 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | ### Describe the bug 12 | 13 | ### Steps to reproduce 14 | 1. 15 | 2. 16 | 3. 17 | 18 | ### Expected behaviour 19 | 20 | ### Error log 21 | 22 | ### Configuration 23 | #### Environment 24 | * metabase-clickhouse-driver version: 25 | * metabase-clickhouse-driver configuration: 26 | * Metabase version: 27 | * OS: 28 | 29 | #### ClickHouse server 30 | * ClickHouse Server version: 31 | * ClickHouse Server non-default settings, if any: 32 | * `CREATE TABLE` statements for tables involved: 33 | * Sample data for all these tables, use [clickhouse-obfuscator](https://github.com/ClickHouse/ClickHouse/blob/master/programs/obfuscator/Obfuscator.cpp#L42-L80) if necessary 34 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICTTCCAdKgAwIBAgIUaqbLNiwUtbV5VuolTMGXOO+21vEwCgYIKoZIzj0EAwQw 3 | XTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMSAwHgYDVQQKDBdDbGlja0hvdXNl 4 | IENvbm5lY3QgVGVzdDEfMB0GA1UEAwwWY2xpY2tob3VzZWNvbm5lY3QudGVzdDAe 5 | Fw0yMjA1MTkxODIxMzFaFw00MjA1MTQxODIxMzFaMF0xCzAJBgNVBAYTAlVTMQsw 6 | CQYDVQQIDAJDQTEgMB4GA1UECgwXQ2xpY2tIb3VzZSBDb25uZWN0IFRlc3QxHzAd 7 | BgNVBAMMFmNsaWNraG91c2Vjb25uZWN0LnRlc3QwdjAQBgcqhkjOPQIBBgUrgQQA 8 | IgNiAATTKvPxkWILniWZ9EmcftQRqhH7fpVhQm1hvtZW1cpTozV0z6tdopnS5p/W 9 | l+Kti2k/kZx1rsN1ZrRYKJN8ANruJJ6vaDOjbf89cmViZ/dbOi49T8brTzdHeuGI 10 | E2TyP+WjUzBRMB0GA1UdDgQWBBThZgdf9aToyK2TeSQ+suyjNUuifDAfBgNVHSME 11 | GDAWgBThZgdf9aToyK2TeSQ+suyjNUuifDAPBgNVHRMBAf8EBTADAQH/MAoGCCqG 12 | SM49BAMEA2kAMGYCMQDWQUTb39xLLds0WobJmNQbIkEwZyss0XNQkn6qI8rz73NL 13 | 6L5/6wNzetKhBf3WBCYCMQC+evVR3Td+WLfbKQDXrCbSkogW6++I/9l55wakMz9G 14 | P+0she/nvFuUKnB+VRcaBqM= 15 | -----END CERTIFICATE----- 16 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/users.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | random 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ::/0 15 | 16 | default 17 | default 18 | 1 19 | 20 | 21 | 22 | client.clickhouseconnect.test 23 | 24 | default 25 | 26 | 27 | 28 | 29 | 30 | 31 | 3600 32 | 0 33 | 0 34 | 0 35 | 0 36 | 0 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /.docker/clickhouse/users.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | random 7 | 1 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ::/0 16 | 17 | default 18 | default 19 | 1 20 | 21 | 22 | foo@bar! 23 | 24 | ::/0 25 | 26 | default 27 | default 28 | 1 29 | 30 | 31 | 32 | 33 | 34 | 35 | 3600 36 | 0 37 | 0 38 | 0 39 | 0 40 | 0 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node/users.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | random 7 | 1 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ::/0 16 | 17 | default 18 | default 19 | 1 20 | 21 | 22 | foo@bar! 23 | 24 | ::/0 25 | 26 | default 27 | default 28 | 1 29 | 30 | 31 | 32 | 33 | 34 | 35 | 3600 36 | 0 37 | 0 38 | 0 39 | 0 40 | 0 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/certificates/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDPTCCAsOgAwIBAgIURzzq8LS+wyMvNDPFrMd4eNBuUvUwCgYIKoZIzj0EAwQw 3 | XTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMSAwHgYDVQQKDBdDbGlja0hvdXNl 4 | IENvbm5lY3QgVGVzdDEfMB0GA1UEAwwWY2xpY2tob3VzZWNvbm5lY3QudGVzdDAe 5 | Fw0yMjA1MTkyMDU3MjRaFw00MjA1MTMyMDU3MjRaMGQxCzAJBgNVBAYTAlVTMQsw 6 | CQYDVQQIDAJDQTEgMB4GA1UECgwXQ2xpY2tIb3VzZSBDb25uZWN0IFRlc3QxJjAk 7 | BgNVBAMMHXNlcnZlci5jbGlja2hvdXNlY29ubmVjdC50ZXN0MHYwEAYHKoZIzj0C 8 | AQYFK4EEACIDYgAECsvHRYxPr+kJ/A7DDajEu8PhdO+WGxzJs7k9SdypPWSxOaCD 9 | ME2tWq0t0Giy63JYNhsn+CJglNIXhtfS5nHS7NV5SfBABUVtZS2/MFk8CwFCz+Rc 10 | Z4db2gt937AgjfxCo4IBOzCCATcwCQYDVR0TBAIwADARBglghkgBhvhCAQEEBAMC 11 | BkAwOQYJYIZIAYb4QgENBCwWKkNsaWNrSG91c2UgQ29ubmVjdCBUZXN0IFNlcnZl 12 | ciBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQUZDd2tpXw4FMDFcY38eXCb+tmukAwgZoG 13 | A1UdIwSBkjCBj4AU4WYHX/Wk6Mitk3kkPrLsozVLonyhYaRfMF0xCzAJBgNVBAYT 14 | AlVTMQswCQYDVQQIDAJDQTEgMB4GA1UECgwXQ2xpY2tIb3VzZSBDb25uZWN0IFRl 15 | c3QxHzAdBgNVBAMMFmNsaWNraG91c2Vjb25uZWN0LnRlc3SCFGqmyzYsFLW1eVbq 16 | JUzBlzjvttbxMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggrBgEFBQcDATAKBggq 17 | hkjOPQQDBANoADBlAjBc3W/8qr04xmUiDOHSEoug89cK8YxtRiKdCjiR3Lao1h5a 18 | J5Xc0JhVLaDUFb+blkoCMQCM7rKbO3itBKaweeJijX/veBcISYFulryWeANiltxo 19 | DFDHrC54rGXt4eOMouTlPbw= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8123 5 | 9000 6 | 7 | users.xml 8 | default 9 | default 10 | 11 | 5368709120 12 | 13 | /var/lib/clickhouse/ 14 | /var/lib/clickhouse/tmp/ 15 | /var/lib/clickhouse/user_files/ 16 | /var/lib/clickhouse/access/ 17 | UTC 18 | 19 | 20 | debug 21 | /var/log/clickhouse-server/clickhouse-server.log 22 | /var/log/clickhouse-server/clickhouse-server.err.log 23 | 1000M 24 | 10 25 | 1 26 | 27 | 28 | 29 | system 30 | query_log
31 | toYYYYMM(event_date) 32 | 1000 33 |
34 | 35 | 36 | /var/lib/clickhouse/format_schemas/ 37 | 38 | 39 | users.xml 40 | 41 | 42 |
43 | -------------------------------------------------------------------------------- /.docker/clickhouse/single_node_tls/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8443 5 | 9440 6 | 0.0.0.0 7 | 8 | users.xml 9 | default 10 | default 11 | 12 | 5368709120 13 | 14 | /var/lib/clickhouse/ 15 | /var/lib/clickhouse/tmp/ 16 | /var/lib/clickhouse/user_files/ 17 | /var/lib/clickhouse/access/ 18 | 19 | 20 | debug 21 | /var/log/clickhouse-server/clickhouse-server.log 22 | /var/log/clickhouse-server/clickhouse-server.err.log 23 | 1000M 24 | 10 25 | 1 26 | 27 | 28 | 29 | 30 | /etc/clickhouse-server/certs/server.crt 31 | /etc/clickhouse-server/certs/server.key 32 | relaxed 33 | /etc/clickhouse-server/certs/ca.crt 34 | true 35 | sslv2,sslv3,tlsv1 36 | true 37 | 38 | 39 | 40 | 41 | system 42 | query_log
43 | toYYYYMM(event_date) 44 | 1000 45 |
46 | 47 | 48 | /var/lib/clickhouse/format_schemas/ 49 | 50 | 51 | users.xml 52 | 53 | 54 |
55 | -------------------------------------------------------------------------------- /.static/clickhouse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /resources/metabase-plugin.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | name: Metabase ClickHouse Driver 3 | version: 1.53.4 4 | description: Allows Metabase to connect to ClickHouse databases. 5 | contact-info: 6 | name: ClickHouse 7 | address: https://github.com/ClickHouse/metabase-clickhouse-driver 8 | driver: 9 | name: clickhouse 10 | display-name: ClickHouse 11 | lazy-load: true 12 | parent: sql-jdbc 13 | connection-properties: 14 | - host 15 | - merge: 16 | - port 17 | - default: 8123 18 | - user 19 | - password 20 | - name: dbname 21 | display-name: Databases 22 | placeholder: default 23 | helper-text: "To specify multiple databases, separate them by the space symbol. For example: default data logs." 24 | - name: scan-all-databases 25 | display-name: Scan all databases 26 | type: boolean 27 | default: false 28 | description: Scan all tables from all available ClickHouse databases except the system ones. 29 | 30 | - ssl 31 | - ssh-tunnel 32 | - advanced-options-start 33 | - name: use-no-proxy 34 | display-name: Disable system wide proxy settings 35 | default: false 36 | type: boolean 37 | visible-if: 38 | advanced-options: true 39 | - name: clickhouse-settings 40 | display-name: ClickHouse settings (comma-separated) 41 | placeholder: "allow_experimental_analyzer=1,max_result_rows=100" 42 | visible-if: 43 | advanced-options: true 44 | - name: max-open-connections 45 | display-name: "Max open HTTP connections in the JDBC driver (default: 100)" 46 | placeholder: 100 47 | visible-if: 48 | advanced-options: true 49 | - merge: 50 | - additional-options 51 | - placeholder: "connection_timeout=1000&socket_timeout=300000" 52 | - default-advanced-options 53 | connection-properties-include-tunnel-config: true 54 | init: 55 | - step: load-namespace 56 | namespace: metabase.driver.clickhouse 57 | - step: register-jdbc-driver 58 | class: com.clickhouse.jdbc.ClickHouseDriver 59 | -------------------------------------------------------------------------------- /src/metabase/driver/clickhouse_nippy.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-nippy 2 | (:require [taoensso.nippy :as nippy]) 3 | (:import [java.io DataInput DataOutput])) 4 | 5 | (set! *warn-on-reflection* false) 6 | 7 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 8 | ;; com.clickhouse.data.value.UnsignedByte 9 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 10 | (nippy/extend-freeze com.clickhouse.data.value.UnsignedByte :clickhouse/UnsignedByte 11 | [^com.clickhouse.data.value.UnsignedByte x ^DataOutput data-output] 12 | ;; can't enable *warn-on-reflection* because of this call 13 | (nippy/freeze-to-out! data-output (.toString x))) 14 | 15 | (nippy/extend-thaw :clickhouse/UnsignedByte 16 | [^DataInput data-input] 17 | (com.clickhouse.data.value.UnsignedByte/valueOf (nippy/thaw-from-in! data-input))) 18 | 19 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 20 | ;; com.clickhouse.data.value.UnsignedShort 21 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 22 | (nippy/extend-freeze com.clickhouse.data.value.UnsignedShort :clickhouse/UnsignedShort 23 | [^com.clickhouse.data.value.UnsignedShort x ^DataOutput data-output] 24 | (nippy/freeze-to-out! data-output (.toString x))) 25 | 26 | (nippy/extend-thaw :clickhouse/UnsignedShort 27 | [^DataInput data-input] 28 | (com.clickhouse.data.value.UnsignedShort/valueOf (nippy/thaw-from-in! data-input))) 29 | 30 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 31 | ;; com.clickhouse.data.value.UnsignedInteger 32 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 33 | (nippy/extend-freeze com.clickhouse.data.value.UnsignedInteger :clickhouse/UnsignedInteger 34 | [^com.clickhouse.data.value.UnsignedInteger x ^DataOutput data-output] 35 | (nippy/freeze-to-out! data-output (.toString x))) 36 | 37 | (nippy/extend-thaw :clickhouse/UnsignedInteger 38 | [^DataInput data-input] 39 | (com.clickhouse.data.value.UnsignedInteger/valueOf (nippy/thaw-from-in! data-input))) 40 | 41 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 42 | ;; com.clickhouse.data.value.UnsignedLong 43 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 44 | (nippy/extend-freeze com.clickhouse.data.value.UnsignedLong :clickhouse/UnsignedLong 45 | [^com.clickhouse.data.value.UnsignedLong x ^DataOutput data-output] 46 | (nippy/freeze-to-out! data-output (.toString x))) 47 | 48 | (nippy/extend-thaw :clickhouse/UnsignedLong 49 | [^DataInput data-input] 50 | (com.clickhouse.data.value.UnsignedLong/valueOf (nippy/thaw-from-in! data-input))) 51 | -------------------------------------------------------------------------------- /src/metabase/driver/clickhouse_version.clj: -------------------------------------------------------------------------------- 1 | "Provides the info about the ClickHouse version. Extracted from the main clickhouse.clj file, 2 | as both Driver and QP overrides require access to it, avoiding circular dependencies." 3 | (ns metabase.driver.clickhouse-version 4 | (:require [clojure.core.memoize :as memoize] 5 | [metabase.driver :as driver] 6 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 7 | [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] 8 | [metabase.driver.util :as driver.u] 9 | [metabase.lib.metadata :as lib.metadata] 10 | [metabase.query-processor.store :as qp.store])) 11 | 12 | (set! *warn-on-reflection* true) 13 | 14 | ;; cache the results for 60 minutes; 15 | ;; TTL is here only to eventually clear out old entries/keep it from growing too large 16 | (def ^:private default-cache-ttl (* 60 60 1000)) 17 | 18 | (def ^:private clickhouse-version-query 19 | (str "WITH s AS (SELECT version() AS ver, splitByChar('.', ver) AS verSplit) " 20 | "SELECT s.ver, toInt32(verSplit[1]), toInt32(verSplit[2]) FROM s")) 21 | 22 | (def ^:private ^{:arglists '([db-details])} get-clickhouse-version 23 | (memoize/ttl 24 | (fn [db-details] 25 | (sql-jdbc.execute/do-with-connection-with-options 26 | :clickhouse 27 | (sql-jdbc.conn/connection-details->spec :clickhouse db-details) 28 | nil 29 | (fn [^java.sql.Connection conn] 30 | (with-open [stmt (.createStatement conn) 31 | rset (.executeQuery stmt clickhouse-version-query)] 32 | (when (.next rset) 33 | {:version (.getString rset 1) 34 | :semantic-version {:major (.getInt rset 2) 35 | :minor (.getInt rset 3)}}))))) 36 | :ttl/threshold default-cache-ttl)) 37 | 38 | (defmethod driver/dbms-version :clickhouse 39 | [_driver db] 40 | (get-clickhouse-version (:details db))) 41 | 42 | (defn is-at-least? 43 | "Is ClickHouse version at least `major.minor` (e.g., 24.4)?" 44 | ([major minor] 45 | ;; used from the QP overrides; we don't have access to the DB object 46 | (is-at-least? major minor (lib.metadata/database (qp.store/metadata-provider)))) 47 | ([major minor db] 48 | ;; used from the Driver overrides; we have access to the DB object 49 | (let [version (driver/dbms-version :clickhouse db) 50 | semantic (:semantic-version version)] 51 | (driver.u/semantic-version-gte [(:major semantic) (:minor semantic)] [major minor])))) 52 | 53 | (defn with-min 54 | "Execute `f` if the ClickHouse version is greater or equal to `major.minor` (e.g., 24.4); 55 | otherwise, execute `fallback-f`, if it's provided." 56 | ([major minor f] 57 | (with-min major minor f nil)) 58 | ([major minor f fallback-f] 59 | (if (is-at-least? major minor) 60 | (f) 61 | (when (not (nil? fallback-f)) (fallback-f))))) 62 | -------------------------------------------------------------------------------- /.github/workflows/check-jdbc-snapshot.yml: -------------------------------------------------------------------------------- 1 | # sed -i -E "s/(com.clickhouse\/clickhouse-jdbc \{:mvn\/version )\".+?\"\}/\1\"\"}/" deps.edn 2 | name: Check with the latest JDBC snapshot 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | jdbc-version: 8 | description: "JDBC version to use" 9 | required: true 10 | 11 | env: 12 | # Temporarily using a fork to disable a few failing tests 13 | METABASE_REPOSITORY: slvrtrn/metabase 14 | METABASE_VERSION: 0.53.x-ch 15 | 16 | jobs: 17 | check-local-current-version: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout Metabase Repo 21 | uses: actions/checkout@v4 22 | with: 23 | repository: ${{ env.METABASE_REPOSITORY }} 24 | ref: ${{ env.METABASE_VERSION }} 25 | 26 | - name: Checkout Driver Repo 27 | uses: actions/checkout@v4 28 | with: 29 | path: modules/drivers/clickhouse 30 | 31 | - name: Prepare JDK 21 32 | uses: actions/setup-java@v4 33 | with: 34 | distribution: "temurin" 35 | java-version: "21" 36 | 37 | - name: Add ClickHouse TLS instance to /etc/hosts 38 | run: | 39 | sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts 40 | 41 | - name: Start ClickHouse in Docker 42 | uses: hoverkraft-tech/compose-action@v2.0.0 43 | with: 44 | compose-file: "modules/drivers/clickhouse/docker-compose.yml" 45 | down-flags: "--volumes" 46 | services: | 47 | clickhouse 48 | clickhouse_tls 49 | clickhouse_older_version 50 | clickhouse_cluster_node1 51 | clickhouse_cluster_node2 52 | nginx 53 | 54 | - name: Install Clojure CLI 55 | run: | 56 | curl -O https://download.clojure.org/install/linux-install-1.11.1.1182.sh && 57 | sudo bash ./linux-install-1.11.1.1182.sh 58 | 59 | - name: Setup Node 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: "20" 63 | cache: "yarn" 64 | 65 | - name: Update the JDBC version in deps.edn 66 | working-directory: modules/drivers/clickhouse 67 | run: | 68 | sed -i -E "s/(com.clickhouse\/clickhouse-jdbc \{:mvn\/version )\".+?\"\}/\1\"${{ github.event.inputs.jdbc-version }}\"}/" deps.edn 69 | cat deps.edn 70 | 71 | - name: Prepare stuff for pulses 72 | run: yarn build-static-viz 73 | 74 | # Use custom deps.edn containing "user/clickhouse" alias to include driver sources 75 | - name: Prepare deps.edn 76 | run: | 77 | mkdir -p /home/runner/.config/clojure 78 | cat modules/drivers/clickhouse/.github/deps.edn | sed -e "s|PWD|$PWD|g" > /home/runner/.config/clojure/deps.edn 79 | 80 | - name: Run all tests with the latest ClickHouse version 81 | env: 82 | DRIVERS: clickhouse 83 | run: | 84 | clojure -X:ci:dev:ee:ee-dev:drivers:drivers-dev:test:user/clickhouse 85 | -------------------------------------------------------------------------------- /.docker/clickhouse/cluster/server1_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8123 5 | 9009 6 | clickhouse1 7 | 8 | users.xml 9 | default 10 | default 11 | 12 | 5368709120 13 | 14 | /var/lib/clickhouse/ 15 | /var/lib/clickhouse/tmp/ 16 | /var/lib/clickhouse/user_files/ 17 | /var/lib/clickhouse/access/ 18 | 3 19 | 20 | 21 | debug 22 | /var/log/clickhouse-server/clickhouse-server.log 23 | /var/log/clickhouse-server/clickhouse-server.err.log 24 | 1000M 25 | 10 26 | 1 27 | 28 | 29 | 30 | 31 | 32 | 33 | clickhouse1 34 | 9000 35 | 36 | 37 | clickhouse2 38 | 9000 39 | 40 | 41 | 42 | 43 | 44 | 45 | 9181 46 | 1 47 | /var/lib/clickhouse/coordination/log 48 | /var/lib/clickhouse/coordination/snapshots 49 | 50 | 51 | 10000 52 | 30000 53 | trace 54 | 10000 55 | 56 | 57 | 58 | 59 | 1 60 | clickhouse1 61 | 9000 62 | 63 | 64 | 2 65 | clickhouse2 66 | 9000 67 | 68 | 69 | 70 | 71 | 72 | 73 | clickhouse1 74 | 9181 75 | 76 | 77 | clickhouse2 78 | 9181 79 | 80 | 81 | 82 | 83 | /clickhouse/test_cluster/task_queue/ddl 84 | 85 | 86 | 87 | system 88 | query_log
89 | toYYYYMM(event_date) 90 | 1000 91 |
92 | 93 | 94 | /var/lib/clickhouse/format_schemas/ 95 | 96 | 97 | users.xml 98 | 99 | 100 |
101 | -------------------------------------------------------------------------------- /.docker/clickhouse/cluster/server2_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8123 5 | 9009 6 | clickhouse2 7 | 8 | users.xml 9 | default 10 | default 11 | 12 | 5368709120 13 | 14 | /var/lib/clickhouse/ 15 | /var/lib/clickhouse/tmp/ 16 | /var/lib/clickhouse/user_files/ 17 | /var/lib/clickhouse/access/ 18 | 3 19 | 20 | 21 | debug 22 | /var/log/clickhouse-server/clickhouse-server.log 23 | /var/log/clickhouse-server/clickhouse-server.err.log 24 | 1000M 25 | 10 26 | 1 27 | 28 | 29 | 30 | 31 | 32 | 33 | clickhouse1 34 | 9000 35 | 36 | 37 | clickhouse2 38 | 9000 39 | 40 | 41 | 42 | 43 | 44 | 45 | 9181 46 | 2 47 | /var/lib/clickhouse/coordination/log 48 | /var/lib/clickhouse/coordination/snapshots 49 | 50 | 51 | 10000 52 | 30000 53 | trace 54 | 10000 55 | 56 | 57 | 58 | 59 | 1 60 | clickhouse1 61 | 9000 62 | 63 | 64 | 2 65 | clickhouse2 66 | 9000 67 | 68 | 69 | 70 | 71 | 72 | 73 | clickhouse1 74 | 9181 75 | 76 | 77 | clickhouse2 78 | 9181 79 | 80 | 81 | 82 | 83 | /clickhouse/test_cluster/task_queue/ddl 84 | 85 | 86 | 87 | system 88 | query_log
89 | toYYYYMM(event_date) 90 | 1000 91 |
92 | 93 | 94 | /var/lib/clickhouse/format_schemas/ 95 | 96 | 97 | users.xml 98 | 99 | 100 |
101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

ClickHouse driver for Metabase

5 |

6 |
7 |

8 | 9 | 10 | 11 |

12 | 13 | ## About 14 | 15 | The Metabase team promoted the ClickHouse driver to the core level as of Metabase 54 ([release notes](https://github.com/metabase/metabase/releases/tag/v0.54.1), [driver source](https://github.com/metabase/metabase/tree/v0.54.x/modules/drivers/clickhouse)). 16 | 17 | For the end user, this means the following: 18 | 19 | - Installing the driver manually is unnecessary, as it is now bundled with Metabase. 20 | - Starting from April 2025, the Metabase team will continue maintaining the driver. Please report new issues in [the main Metabase repository](https://github.com/metabase/metabase/issues). 21 | 22 | The ClickHouse team recommends avoiding older Metabase versions (53.x and earlier) with manual driver installation; instead, please use the updated Metabase distribution with the driver built-in. 23 | 24 | ## History 25 | 26 | The request for a ClickHouse Metabase driver was formulated in 2016 in [Metabase issue #3332](https://github.com/metabase/metabase/issues/3332). Several impatient ClickHouse users started the development in the main Metabase repo. In March 2019, after releasing the plugin SDK, the Metabase team [asked to publish the driver separately](https://github.com/metabase/metabase/pull/8491#issuecomment-471721980) in its own repository, and later that month, with Felix Mueller ([@enqueue](https://github.com/enqueue)) leading the efforts, the [initial version of the driver](https://github.com/ClickHouse/metabase-clickhouse-driver/releases/tag/v0.1) was out. 27 | 28 | The original implementation of the driver was based on the following pull requests: 29 | 30 | - [metabase#8491](https://github.com/metabase/metabase/pull/8491) 31 | - [metabase#8722](https://github.com/metabase/metabase/pull/8722) 32 | - [metabase#9469](https://github.com/metabase/metabase/pull/9469) 33 | 34 | The source base in these PRs comprises major contributions from these authors: 35 | 36 | - [@tsl-karlp](https://github.com/tsl-karlp) 37 | - Andrew Grigorev ([@ei-grad](https://github.com/ei-grad)) 38 | - Bogdan Mukvich ([@Badya](https://github.com/Badya)) 39 | - Felix Mueller ([@enqueue](https://github.com/enqueue)) 40 | 41 | > [!NOTE] 42 | > Special thanks to Felix Mueller ([@enqueue](https://github.com/enqueue)), who was the sole maintainer of the project from 2019 to 2022 before transferring it to ClickHouse. 43 | 44 | Starting from November 2022, Serge Klochkov ([@slvrtrn](https://github.com/slvrtrn)) joined as a maintainer. 45 | 46 | In early 2023, the repository was transferred to the ClickHouse organization, promoting it as an [official integration](https://clickhouse.com/blog/metabase-clickhouse-plugin-ga-release). Around that time, the driver also became available in [Metabase Cloud](https://www.metabase.com/cloud). 47 | 48 | In April 2025, the driver source code [was moved](https://github.com/metabase/metabase/pull/54740) to the main Metabase repository. Since [Metabase 54](https://github.com/metabase/metabase/releases/tag/v0.54.1), it is now available as a part of the official Metabase bundle. 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Getting started 2 | 3 | ClickHouse driver for Metabase is an open-source project, 4 | and we welcome any contributions from the community. 5 | Please share your ideas, contribute to the codebase, 6 | and help us maintain up-to-date documentation. 7 | 8 | * Please report any issues you encounter during operations. 9 | * Feel free to create a pull request, preferably with a test or five. 10 | 11 | ## Setting up a development environment 12 | 13 | ### Requirements 14 | 15 | * Clojure 1.11+ 16 | * OpenJDK 17 17 | * Node.js 16.x 18 | * Yarn 19 | 20 | For testing: Docker Compose 21 | 22 | Please refer to the extensive documentation available on the Metabase website: [Guide to writing a Metabase driver](https://www.metabase.com/docs/latest/developers-guide/drivers/start.html) 23 | 24 | ClickHouse driver's code should be inside the main Metabase repository checkout in `modules/drivers/clickhouse` directory. 25 | 26 | Additionally, you need to tweak Metabase's `deps.edn` a bit. 27 | 28 | The easiest way to set up a development environment is as follows (mostly the same as in the [CI](https://github.com/enqueue/metabase-clickhouse-driver/blob/master/.github/workflows/check.yml)): 29 | 30 | * Clone Metabase and ClickHouse driver repositories 31 | ```bash 32 | git clone https://github.com/metabase/metabase.git 33 | cd metabase 34 | git clone https://github.com/enqueue/metabase-clickhouse-driver.git modules/drivers/clickhouse 35 | ``` 36 | 37 | * Create custom Clojure profiles 38 | 39 | ```bash 40 | mkdir -p ~/.clojure 41 | cat modules/drivers/clickhouse/.github/deps.edn | sed -e "s|PWD|$PWD|g" > ~/.clojure/deps.edn 42 | ``` 43 | 44 | Modifying `~/.clojure/deps.edn` will create a new profile: `user/clickhouse`, that adds driver's sources to the class path, and includes all the Metabase tests that are guaranteed to work with the driver. 45 | 46 | * Install the Metabase dependencies: 47 | 48 | ```bash 49 | clojure -X:deps:drivers prep 50 | ``` 51 | 52 | * Build the frontend: 53 | 54 | ```bash 55 | yarn && yarn build-static-viz 56 | ``` 57 | 58 | * Add /etc/hosts entry 59 | 60 | Required for TLS tests. 61 | 62 | ```bash 63 | sudo -- sh -c "echo 127.0.0.1 server.clickhouseconnect.test >> /etc/hosts" 64 | ``` 65 | 66 | * Start Docker containers 67 | 68 | ```bash 69 | docker compose -f modules/drivers/clickhouse/docker-compose.yml up -d 70 | ``` 71 | 72 | Here's an overview of the started containers, which have the ports exposed to the `localhost` (see [docker-compose.yml](./docker-compose.yml)): 73 | 74 | - Metabase with the ClickHouse driver loaded from the JAR file (port: 3000) 75 | - Current ClickHouse version (port: 8123) - the main instance for all tests. 76 | - Current ClickHouse cluster with two nodes (+ nginx as an LB, port: 8127) - required for the set role tests (verifying that the role is set correctly via the query parameters). 77 | - Current ClickHouse version with TLS support (port: 8443) - required for the TLS tests. 78 | - Older ClickHouse version (port: 8124) - required for the string functions tests (switch between UTF8 (current) and non-UTF8 (pre-23.8) versions), as well as to verify that certain features, such as connection impersonation, are disabled on the older server versions. 79 | 80 | Now, you should be able to run the tests: 81 | 82 | ```bash 83 | DRIVERS=clickhouse clojure -X:dev:drivers:drivers-dev:test:user/clickhouse 84 | ``` 85 | 86 | you can see that we have our `:user/clickhouse` profile added to the command above, and with `DRIVERS=clickhouse` we instruct Metabase to run the tests only for ClickHouse. 87 | 88 | NB: Omitting `DRIVERS` will run the tests for all the built-in database drivers. 89 | 90 | If you want to run tests for only a specific namespace: 91 | 92 | ```bash 93 | DRIVERS=clickhouse clojure -X:dev:drivers:drivers-dev:test:user/clickhouse :only metabase.driver.clickhouse-test 94 | ``` 95 | 96 | or even a single test: 97 | 98 | ```bash 99 | DRIVERS=clickhouse clojure -X:dev:drivers:drivers-dev:test:user/clickhouse :only metabase.driver.clickhouse-test/clickhouse-nullable-arrays 100 | ``` 101 | 102 | Testing the driver with the older ClickHouse version (see [docker-compose.yml](./docker-compose.yml)): 103 | 104 | ```bash 105 | MB_CLICKHOUSE_TEST_PORT=8124 DRIVERS=clickhouse clojure -X:dev:drivers:drivers-dev:test:user/clickhouse :only metabase.driver.clickhouse-test 106 | ``` 107 | 108 | ## Building a jar 109 | 110 | You need to add an entry for ClickHouse in `modules/drivers/deps.edn` 111 | 112 | ```clj 113 | {:deps 114 | {... 115 | metabase/clickhouse {:local/root "clickhouse"} 116 | ...}} 117 | ``` 118 | 119 | or just run this from the root Metabase directory, overwriting the entire file: 120 | 121 | ```bash 122 | echo "{:deps {metabase/clickhouse {:local/root \"clickhouse\" }}}" > modules/drivers/deps.edn 123 | ``` 124 | 125 | Now, you should be able to build the final jar: 126 | 127 | ```bash 128 | bin/build-driver.sh clickhouse 129 | ``` 130 | 131 | As the result, `resources/modules/clickhouse.metabase-driver.jar` should be created. 132 | 133 | For smoke testing, there is a Metabase with the link to the driver available as a Docker container: 134 | 135 | ```bash 136 | docker compose -f modules/drivers/clickhouse/docker-compose.yml up -d metabase 137 | ``` 138 | 139 | It should pick up the driver jar as a volume. 140 | -------------------------------------------------------------------------------- /.docker/setup/setup.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import os 4 | import pprint 5 | 6 | import requests 7 | 8 | host = os.environ.get('host') if os.environ.get('host') else 'http://localhost' 9 | port = os.environ.get('port') if os.environ.get('port') else '3000' 10 | admin_email = os.environ.get('admin_email') if os.environ.get('admin_email') else 'admin@example.com' 11 | user_email = os.environ.get('user_email') if os.environ.get('user_email') else 'user@example.com' 12 | password = os.environ.get('password') if os.environ.get('password') else 'metabot1' 13 | site_name = 'ClickHouse test' 14 | 15 | endpoints = { 16 | 'health_check': '/api/health', 17 | 'properties': '/api/session/properties', 18 | 'setup': '/api/setup', 19 | 'database': '/api/database', 20 | 'login': '/api/session', 21 | 'user': '/api/user', 22 | } 23 | for k, v in endpoints.items(): 24 | endpoints[k] = f"{host}:{port}{v}" 25 | 26 | db_base_payload = { 27 | "is_on_demand": False, 28 | "is_full_sync": True, 29 | "is_sample": False, 30 | "cache_ttl": None, 31 | "refingerprint": False, 32 | "auto_run_queries": True, 33 | "schedules": {}, 34 | "details": { 35 | "host": "clickhouse", 36 | "port": 8123, 37 | "user": "default", 38 | "password": None, 39 | "dbname": "default", 40 | "scan-all-databases": False, 41 | "ssl": False, 42 | "tunnel-enabled": False, 43 | "advanced-options": False 44 | }, 45 | "name": "Our ClickHouse", 46 | "engine": "clickhouse" 47 | } 48 | 49 | 50 | def health(): 51 | response = requests.get(endpoints['health_check'], verify=False) 52 | if response.json()['status'] == 'ok': 53 | return 'healthy' 54 | else: 55 | health() 56 | 57 | 58 | def check_response(response, op): 59 | if response.status_code >= 300: 60 | print(f'Unexpected status {response.status_code} for {op}', response.text) 61 | exit(1) 62 | 63 | 64 | if __name__ == '__main__': 65 | print("Checking health") 66 | 67 | if health() == 'healthy' and os.environ.get('retry') is None: 68 | print("Healthy, setting up Metabase") 69 | 70 | session = requests.Session() 71 | session_token = None 72 | try: 73 | token = session.get(endpoints['properties'], verify=False).json()['setup-token'] 74 | setup_payload = { 75 | 'token': f'{token}', 76 | 'user': { 77 | 'first_name': 'Admin', 78 | 'last_name': 'Admin', 79 | 'email': admin_email, 80 | 'site_name': site_name, 81 | 'password': password, 82 | 'password_confirm': password 83 | }, 84 | 'database': None, 85 | 'invite': None, 86 | 'prefs': { 87 | 'site_name': site_name, 88 | 'site_locale': 'en', 89 | 'allow_tracking': False 90 | } 91 | } 92 | print("Getting the setup token") 93 | session_token = session.post(endpoints['setup'], verify=False, json=setup_payload).json()['id'] 94 | except Exception as e: 95 | print("The admin user was already created") 96 | 97 | try: 98 | if session_token is None: 99 | session_token = session.post(endpoints['login'], verify=False, 100 | json={"username": admin_email, "password": password}) 101 | 102 | dbs = session.get(endpoints['database'], verify=False).json() 103 | print("Current databases:") 104 | pprint.pprint(dbs['data']) 105 | 106 | sample_db = next((x for x in dbs['data'] if x['id'] == 1), None) 107 | if sample_db is not None: 108 | print("Deleting the sample database") 109 | res = session.delete(f"{endpoints['database']}/{sample_db['id']}") 110 | check_response(res, 'delete sample db') 111 | else: 112 | print("The sample database was already deleted") 113 | 114 | single_node_db = next((x for x in dbs['data'] 115 | if x['engine'] == 'clickhouse' 116 | and x['details']['host'] == 'clickhouse'), None) 117 | if single_node_db is None: 118 | print("Creating ClickHouse single node db") 119 | single_node_payload = copy.deepcopy(db_base_payload) 120 | single_node_payload['name'] = 'ClickHouse (single node)' 121 | res = session.post(endpoints['database'], verify=False, json=single_node_payload) 122 | check_response(res, 'create single node db') 123 | else: 124 | print("The single node database was already created") 125 | 126 | # cluster_db = next((x for x in dbs['data'] 127 | # if x['engine'] == 'clickhouse' 128 | # and x['details']['host'] == 'nginx'), None) 129 | # if cluster_db is None: 130 | # print("Creating ClickHouse cluster db") 131 | # cluster_db_payload = copy.deepcopy(db_base_payload) 132 | # cluster_db_payload['details']['host'] = 'nginx' 133 | # cluster_db_payload['name'] = 'ClickHouse (cluster)' 134 | # res = session.post(endpoints['database'], verify=False, json=cluster_db_payload) 135 | # check_response(res) 136 | # else: 137 | # print("The cluster database was already created") 138 | 139 | print("Creating a regular user") 140 | user_payload = {"first_name": "User", "last_name": "User", "email": user_email, "password": password} 141 | res = session.post(endpoints['user'], verify=False, json=user_payload) 142 | check_response(res, 'create user') 143 | 144 | print("Done!") 145 | except Exception as e: 146 | logging.exception("Failed to setup Metabase", e) 147 | exit() 148 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | paths-ignore: 9 | - '**/*.md' 10 | pull_request: 11 | paths-ignore: 12 | - '**/*.md' 13 | 14 | env: 15 | # Using a fork to disable a few failing tests 16 | METABASE_REPOSITORY: slvrtrn/metabase 17 | METABASE_VERSION: 0.53.x-ch 18 | 19 | jobs: 20 | check-local-current-version: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout Metabase Repo 24 | uses: actions/checkout@v4 25 | with: 26 | repository: ${{ env.METABASE_REPOSITORY }} 27 | ref: ${{ env.METABASE_VERSION }} 28 | 29 | - name: Checkout Driver Repo 30 | uses: actions/checkout@v4 31 | with: 32 | path: modules/drivers/clickhouse 33 | 34 | - name: Prepare JDK 21 35 | uses: actions/setup-java@v4 36 | with: 37 | distribution: "temurin" 38 | java-version: "21" 39 | 40 | - name: Add ClickHouse TLS instance to /etc/hosts 41 | run: | 42 | sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts 43 | 44 | - name: Start ClickHouse in Docker 45 | uses: hoverkraft-tech/compose-action@v2.0.0 46 | with: 47 | compose-file: "modules/drivers/clickhouse/docker-compose.yml" 48 | down-flags: "--volumes" 49 | services: | 50 | clickhouse 51 | clickhouse_tls 52 | clickhouse_older_version 53 | clickhouse_cluster_node1 54 | clickhouse_cluster_node2 55 | nginx 56 | 57 | - name: Install Clojure CLI 58 | run: | 59 | curl -O https://download.clojure.org/install/linux-install-1.11.1.1182.sh && 60 | sudo bash ./linux-install-1.11.1.1182.sh 61 | 62 | - name: Setup Node 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: "20" 66 | cache: "yarn" 67 | 68 | - name: Get M2 cache 69 | uses: actions/cache@v4 70 | with: 71 | path: | 72 | ~/.m2 73 | ~/.gitlibs 74 | key: ${{ runner.os }}-clickhouse-${{ hashFiles('**/deps.edn') }} 75 | 76 | - name: Prepare stuff for pulses 77 | run: yarn build-static-viz 78 | 79 | # Use custom deps.edn containing "user/clickhouse" alias to include driver sources 80 | - name: Prepare deps.edn 81 | run: | 82 | mkdir -p /home/runner/.config/clojure 83 | cat modules/drivers/clickhouse/.github/deps.edn | sed -e "s|PWD|$PWD|g" > /home/runner/.config/clojure/deps.edn 84 | 85 | - name: Run all tests with the latest ClickHouse version 86 | env: 87 | DRIVERS: clickhouse 88 | run: | 89 | clojure -X:ci:dev:ee:ee-dev:drivers:drivers-dev:test:user/clickhouse 90 | 91 | check-local-older-version: 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: Checkout Metabase Repo 95 | uses: actions/checkout@v4 96 | with: 97 | repository: ${{ env.METABASE_REPOSITORY }} 98 | ref: ${{ env.METABASE_VERSION }} 99 | 100 | - name: Checkout Driver Repo 101 | uses: actions/checkout@v4 102 | with: 103 | path: modules/drivers/clickhouse 104 | 105 | - name: Prepare JDK 21 106 | uses: actions/setup-java@v4 107 | with: 108 | distribution: "temurin" 109 | java-version: "21" 110 | 111 | - name: Add ClickHouse TLS instance to /etc/hosts 112 | run: | 113 | sudo echo "127.0.0.1 server.clickhouseconnect.test" | sudo tee -a /etc/hosts 114 | 115 | - name: Start ClickHouse in Docker 116 | uses: hoverkraft-tech/compose-action@v2.0.0 117 | with: 118 | compose-file: "modules/drivers/clickhouse/docker-compose.yml" 119 | down-flags: "--volumes" 120 | services: | 121 | clickhouse 122 | clickhouse_tls 123 | clickhouse_older_version 124 | clickhouse_cluster_node1 125 | clickhouse_cluster_node2 126 | nginx 127 | 128 | - name: Install Clojure CLI 129 | run: | 130 | curl -O https://download.clojure.org/install/linux-install-1.11.1.1182.sh && 131 | sudo bash ./linux-install-1.11.1.1182.sh 132 | 133 | - name: Get M2 cache 134 | uses: actions/cache@v4 135 | with: 136 | path: | 137 | ~/.m2 138 | ~/.gitlibs 139 | key: ${{ runner.os }}-clickhouse-${{ hashFiles('**/deps.edn') }} 140 | 141 | # Use custom deps.edn containing "user/clickhouse" alias to include driver sources 142 | - name: Prepare deps.edn 143 | run: | 144 | mkdir -p /home/runner/.config/clojure 145 | cat modules/drivers/clickhouse/.github/deps.edn | sed -e "s|PWD|$PWD|g" > /home/runner/.config/clojure/deps.edn 146 | 147 | - name: Run ClickHouse driver tests with 23.3 148 | env: 149 | DRIVERS: clickhouse 150 | MB_CLICKHOUSE_TEST_PORT: 8124 151 | run: | 152 | clojure -X:ci:dev:ee:ee-dev:drivers:drivers-dev:test:user/clickhouse :only metabase.driver.clickhouse-test 153 | 154 | build-jar: 155 | runs-on: ubuntu-latest 156 | needs: [ 'check-local-current-version' ] 157 | steps: 158 | - name: Checkout Metabase Repo 159 | uses: actions/checkout@v4 160 | with: 161 | repository: ${{ env.METABASE_REPOSITORY }} 162 | ref: ${{ env.METABASE_VERSION }} 163 | 164 | - name: Checkout Driver Repo 165 | uses: actions/checkout@v4 166 | with: 167 | path: modules/drivers/clickhouse 168 | 169 | - name: Install Clojure CLI 170 | run: | 171 | curl -O https://download.clojure.org/install/linux-install-1.11.1.1182.sh && 172 | sudo bash ./linux-install-1.11.1.1182.sh 173 | 174 | - name: Build ClickHouse driver 175 | run: | 176 | echo "{:deps {metabase/clickhouse {:local/root \"clickhouse\" }}}" > modules/drivers/deps.edn 177 | bin/build-driver.sh clickhouse 178 | ls -lah resources/modules 179 | 180 | - name: Archive driver JAR 181 | uses: actions/upload-artifact@v4 182 | with: 183 | name: clickhouse.metabase-driver.jar 184 | path: resources/modules/clickhouse.metabase-driver.jar 185 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | ########################################################################################################## 4 | # ClickHouse single node (CH driver + Metabase tests) 5 | ########################################################################################################## 6 | 7 | clickhouse: 8 | image: 'clickhouse/clickhouse-server:25.2-alpine' 9 | container_name: 'metabase-driver-clickhouse-server' 10 | hostname: clickhouse 11 | ports: 12 | - '8123:8123' 13 | - '9000:9000' 14 | environment: 15 | CLICKHOUSE_SKIP_USER_SETUP: 1 16 | ulimits: 17 | nofile: 18 | soft: 262144 19 | hard: 262144 20 | volumes: 21 | - './.docker/clickhouse/single_node/config.xml:/etc/clickhouse-server/config.xml' 22 | - './.docker/clickhouse/single_node/users.xml:/etc/clickhouse-server/users.xml' 23 | 24 | ########################################################################################################## 25 | # ClickHouse single node (CH driver TLS tests only) 26 | ########################################################################################################## 27 | 28 | clickhouse_tls: 29 | build: 30 | context: ./ 31 | dockerfile: .docker/clickhouse/single_node_tls/Dockerfile 32 | container_name: 'metabase-driver-clickhouse-server-tls' 33 | ports: 34 | - '8443:8443' 35 | - '9440:9440' 36 | environment: 37 | CLICKHOUSE_SKIP_USER_SETUP: 1 38 | ulimits: 39 | nofile: 40 | soft: 262144 41 | hard: 262144 42 | volumes: 43 | - './.docker/clickhouse/single_node_tls/config.xml:/etc/clickhouse-server/config.xml' 44 | - './.docker/clickhouse/single_node_tls/users.xml:/etc/clickhouse-server/users.xml' 45 | hostname: server.clickhouseconnect.test 46 | 47 | ########################################################################################################## 48 | # Older ClickHouse version (CH driver tests only) 49 | # For testing pre-23.8 string functions switch between UTF8 and non-UTF8 versions (see clickhouse_qp.clj) 50 | ########################################################################################################## 51 | 52 | clickhouse_older_version: 53 | image: 'clickhouse/clickhouse-server:23.3-alpine' 54 | container_name: 'metabase-driver-clickhouse-server-older-version' 55 | hostname: clickhouse.older 56 | ports: 57 | - '8124:8123' 58 | - '9001:9000' 59 | environment: 60 | CLICKHOUSE_SKIP_USER_SETUP: 1 61 | ulimits: 62 | nofile: 63 | soft: 262144 64 | hard: 262144 65 | volumes: 66 | - './.docker/clickhouse/single_node/config.xml:/etc/clickhouse-server/config.xml' 67 | - './.docker/clickhouse/single_node/users.xml:/etc/clickhouse-server/users.xml' 68 | 69 | ########################################################################################################## 70 | # ClickHouse cluster (CH driver SET ROLE tests only) 71 | # See test/metabase/driver/clickhouse_set_role.clj 72 | ########################################################################################################## 73 | 74 | clickhouse_cluster_node1: 75 | image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-25.2-alpine}' 76 | ulimits: 77 | nofile: 78 | soft: 262144 79 | hard: 262144 80 | hostname: clickhouse1 81 | container_name: metabase-driver-clickhouse-cluster-node-1 82 | ports: 83 | - '8125:8123' 84 | - '9002:9000' 85 | - '9181:9181' 86 | environment: 87 | CLICKHOUSE_SKIP_USER_SETUP: 1 88 | volumes: 89 | - './.docker/clickhouse/cluster/server1_config.xml:/etc/clickhouse-server/config.xml' 90 | - './.docker/clickhouse/cluster/server1_macros.xml:/etc/clickhouse-server/config.d/macros.xml' 91 | - './.docker/clickhouse/users.xml:/etc/clickhouse-server/users.xml' 92 | 93 | clickhouse_cluster_node2: 94 | image: 'clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-25.2-alpine}' 95 | ulimits: 96 | nofile: 97 | soft: 262144 98 | hard: 262144 99 | hostname: clickhouse2 100 | container_name: metabase-driver-clickhouse-cluster-node-2 101 | ports: 102 | - '8126:8123' 103 | - '9003:9000' 104 | - '9182:9181' 105 | environment: 106 | CLICKHOUSE_SKIP_USER_SETUP: 1 107 | volumes: 108 | - './.docker/clickhouse/cluster/server2_config.xml:/etc/clickhouse-server/config.xml' 109 | - './.docker/clickhouse/cluster/server2_macros.xml:/etc/clickhouse-server/config.d/macros.xml' 110 | - './.docker/clickhouse/users.xml:/etc/clickhouse-server/users.xml' 111 | 112 | # Using Nginx as a cluster entrypoint and a round-robin load balancer for HTTP requests 113 | # See .docker/nginx/local.conf for the configuration 114 | nginx: 115 | image: 'nginx:1.23.1-alpine' 116 | hostname: nginx 117 | ports: 118 | - '8127:8123' 119 | volumes: 120 | - './.docker/nginx/local.conf:/etc/nginx/conf.d/local.conf' 121 | container_name: metabase-nginx 122 | 123 | ########################################################################################################## 124 | # Metabase 125 | ########################################################################################################## 126 | 127 | metabase: 128 | image: metabase/metabase-enterprise:v1.53.6.4 129 | container_name: metabase-with-clickhouse-driver 130 | hostname: metabase 131 | environment: 132 | 'MB_HTTP_TIMEOUT': '5000' 133 | 'JAVA_TIMEZONE': 'UTC' 134 | ports: 135 | - '3000:3000' 136 | volumes: 137 | - '../../../resources/modules/clickhouse.metabase-driver.jar:/plugins/clickhouse.jar' 138 | - './.docker/clickhouse/single_node_tls/certificates/ca.crt:/certs/ca.crt' 139 | healthcheck: 140 | test: curl --fail -X GET -I http://localhost:3000/api/health || exit 1 141 | interval: 15s 142 | timeout: 5s 143 | retries: 10 144 | 145 | setup: 146 | build: .docker/setup/. 147 | container_name: metabase-clickhouse-setup 148 | volumes: 149 | - .docker/setup/setup.py:/app/setup.py 150 | depends_on: 151 | metabase: 152 | condition: service_healthy 153 | command: python /app/setup.py 154 | environment: 155 | host: http://metabase 156 | port: 3000 157 | admin_email: 'admin@example.com' 158 | user_email: 'user@example.com' 159 | password: 'metabot1' 160 | -------------------------------------------------------------------------------- /test/metabase/driver/clickhouse_temporal_bucketing_test.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-temporal-bucketing-test 2 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 3 | (:require 4 | [clojure.test :refer :all] 5 | [metabase.query-processor.test-util :as qp.test] 6 | [metabase.test :as mt] 7 | [metabase.test.data :as data] 8 | [metabase.test.data.clickhouse :as ctd])) 9 | 10 | (use-fixtures :once ctd/create-test-db!) 11 | 12 | ;; See temporal_bucketing table definition 13 | ;; Fields values are (both in server and column timezones): 14 | ;; start_of_year == '2022-01-01 00:00:00' 15 | ;; mid_of_year == '2022-06-20 06:32:54' 16 | ;; end_of_year == '2022-12-31 23:59:59' 17 | (deftest clickhouse-temporal-bucketing-server-tz 18 | (mt/test-driver 19 | :clickhouse 20 | (defn- start-of-year [unit] 21 | (qp.test/rows 22 | (ctd/do-with-test-db 23 | (fn [db] 24 | (data/with-db db 25 | (data/run-mbql-query 26 | temporal_bucketing_server_tz 27 | {:breakout [[:field %start_of_year {:temporal-unit unit}]]})))))) 28 | (defn- mid-year [unit] 29 | (qp.test/rows 30 | (ctd/do-with-test-db 31 | (fn [db] 32 | (data/with-db db 33 | (data/run-mbql-query 34 | temporal_bucketing_server_tz 35 | {:breakout [[:field %mid_of_year {:temporal-unit unit}]]})))))) 36 | (defn- end-of-year [unit] 37 | (qp.test/rows 38 | (ctd/do-with-test-db 39 | (fn [db] 40 | (data/with-db db 41 | (data/run-mbql-query 42 | temporal_bucketing_server_tz 43 | {:breakout [[:field %end_of_year {:temporal-unit unit}]]})))))) 44 | (testing "truncate to" 45 | (testing "minute" 46 | (is (= [["2022-06-20T06:32:00Z"]] 47 | (mid-year :minute)))) 48 | (testing "hour" 49 | (is (= [["2022-06-20T06:00:00Z"]] 50 | (mid-year :hour)))) 51 | (testing "day" 52 | (is (= [["2022-06-20T00:00:00Z"]] 53 | (mid-year :day)))) 54 | (testing "month" 55 | (is (= [["2022-06-01T00:00:00Z"]] 56 | (mid-year :month)))) 57 | (testing "quarter" 58 | (is (= [["2022-04-01T00:00:00Z"]] 59 | (mid-year :quarter)))) 60 | (testing "year" 61 | (is (= [["2022-01-01T00:00:00Z"]] 62 | (mid-year :year))))) 63 | (testing "extract" 64 | (testing "minute of hour" 65 | (is (= [[0]] 66 | (start-of-year :minute-of-hour))) 67 | (is (= [[32]] 68 | (mid-year :minute-of-hour))) 69 | (is (= [[59]] 70 | (end-of-year :minute-of-hour)))) 71 | (testing "hour of day" 72 | (is (= [[0]] 73 | (start-of-year :hour-of-day))) 74 | (is (= [[6]] 75 | (mid-year :hour-of-day))) 76 | (is (= [[23]] 77 | (end-of-year :hour-of-day)))) 78 | (testing "day of month" 79 | (is (= [[1]] 80 | (start-of-year :day-of-month))) 81 | (is (= [[20]] 82 | (mid-year :day-of-month))) 83 | (is (= [[31]] 84 | (end-of-year :day-of-month)))) 85 | (testing "day of year" 86 | (is (= [[1]] 87 | (start-of-year :day-of-year))) 88 | (is (= [[171]] 89 | (mid-year :day-of-year))) 90 | (is (= [[365]] 91 | (end-of-year :day-of-year)))) 92 | (testing "month of year" 93 | (is (= [[1]] 94 | (start-of-year :month-of-year))) 95 | (is (= [[6]] 96 | (mid-year :month-of-year))) 97 | (is (= [[12]] 98 | (end-of-year :month-of-year)))) 99 | (testing "quarter of year" 100 | (is (= [[1]] 101 | (start-of-year :quarter-of-year))) 102 | (is (= [[2]] 103 | (mid-year :quarter-of-year))) 104 | (is (= [[4]] 105 | (end-of-year :quarter-of-year))))))) 106 | 107 | (deftest clickhouse-temporal-bucketing-column-tz 108 | (mt/test-driver 109 | :clickhouse 110 | (defn- start-of-year [unit] 111 | (qp.test/rows 112 | (ctd/do-with-test-db 113 | (fn [db] 114 | (data/with-db db 115 | (data/run-mbql-query 116 | temporal_bucketing_column_tz 117 | {:breakout [[:field %start_of_year {:temporal-unit unit}]]})))))) 118 | (defn- mid-year [unit] 119 | (qp.test/rows 120 | (ctd/do-with-test-db 121 | (fn [db] 122 | (data/with-db db 123 | (data/run-mbql-query 124 | temporal_bucketing_column_tz 125 | {:breakout [[:field %mid_of_year {:temporal-unit unit}]]})))))) 126 | (defn- end-of-year [unit] 127 | (qp.test/rows 128 | (ctd/do-with-test-db 129 | (fn [db] 130 | (data/with-db db 131 | (data/run-mbql-query 132 | temporal_bucketing_column_tz 133 | {:breakout [[:field %end_of_year {:temporal-unit unit}]]})))))) 134 | (testing "truncate to" 135 | (testing "minute" 136 | (is (= [["2022-06-20T13:32:00Z"]] 137 | (mid-year :minute)))) 138 | (testing "hour" 139 | (is (= [["2022-06-20T13:00:00Z"]] 140 | (mid-year :hour)))) 141 | (testing "day" 142 | (is (= [["2022-06-20T07:00:00Z"]] 143 | (mid-year :day)))) 144 | (testing "month" 145 | (is (= [["2022-06-01T00:00:00Z"]] 146 | (mid-year :month)))) 147 | (testing "quarter" 148 | (is (= [["2022-04-01T00:00:00Z"]] 149 | (mid-year :quarter)))) 150 | (testing "year" 151 | (is (= [["2022-01-01T00:00:00Z"]] 152 | (mid-year :year))))) 153 | (testing "extract" 154 | (testing "minute of hour" 155 | (is (= [[0]] 156 | (start-of-year :minute-of-hour))) 157 | (is (= [[32]] 158 | (mid-year :minute-of-hour))) 159 | (is (= [[59]] 160 | (end-of-year :minute-of-hour)))) 161 | (testing "hour of day" 162 | (is (= [[0]] 163 | (start-of-year :hour-of-day))) 164 | (is (= [[6]] 165 | (mid-year :hour-of-day))) 166 | (is (= [[23]] 167 | (end-of-year :hour-of-day)))) 168 | (testing "day of month" 169 | (is (= [[1]] 170 | (start-of-year :day-of-month))) 171 | (is (= [[20]] 172 | (mid-year :day-of-month))) 173 | (is (= [[31]] 174 | (end-of-year :day-of-month)))) 175 | (testing "day of year" 176 | (is (= [[1]] 177 | (start-of-year :day-of-year))) 178 | (is (= [[171]] 179 | (mid-year :day-of-year))) 180 | (is (= [[365]] 181 | (end-of-year :day-of-year)))) 182 | (testing "month of year" 183 | (is (= [[1]] 184 | (start-of-year :month-of-year))) 185 | (is (= [[6]] 186 | (mid-year :month-of-year))) 187 | (is (= [[12]] 188 | (end-of-year :month-of-year)))) 189 | (testing "quarter of year" 190 | (is (= [[1]] 191 | (start-of-year :quarter-of-year))) 192 | (is (= [[2]] 193 | (mid-year :quarter-of-year))) 194 | (is (= [[4]] 195 | (end-of-year :quarter-of-year))))))) 196 | -------------------------------------------------------------------------------- /src/metabase/driver/clickhouse_introspection.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-introspection 2 | (:require [clojure.java.jdbc :as jdbc] 3 | [clojure.string :as str] 4 | [metabase.config :as config] 5 | [metabase.driver :as driver] 6 | [metabase.driver.ddl.interface :as ddl.i] 7 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 8 | [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] 9 | [metabase.driver.sql-jdbc.sync.describe-table :as sql-jdbc.describe-table] 10 | [metabase.util :as u]) 11 | (:import (java.sql DatabaseMetaData))) 12 | 13 | (set! *warn-on-reflection* true) 14 | 15 | (def ^:private database-type->base-type 16 | (sql-jdbc.sync/pattern-based-database-type->base-type 17 | [[#"array" :type/Array] 18 | [#"bool" :type/Boolean] 19 | [#"date" :type/Date] 20 | [#"date32" :type/Date] 21 | [#"decimal" :type/Decimal] 22 | [#"enum8" :type/Text] 23 | [#"enum16" :type/Text] 24 | [#"fixedstring" :type/TextLike] 25 | [#"float32" :type/Float] 26 | [#"float64" :type/Float] 27 | [#"int8" :type/Integer] 28 | [#"int16" :type/Integer] 29 | [#"int32" :type/Integer] 30 | [#"int64" :type/BigInteger] 31 | [#"ipv4" :type/IPAddress] 32 | [#"ipv6" :type/IPAddress] 33 | [#"map" :type/Dictionary] 34 | [#"string" :type/Text] 35 | [#"tuple" :type/*] 36 | [#"uint8" :type/Integer] 37 | [#"uint16" :type/Integer] 38 | [#"uint32" :type/Integer] 39 | [#"uint64" :type/BigInteger] 40 | [#"uuid" :type/UUID]])) 41 | 42 | (defn- normalize-db-type 43 | [db-type] 44 | (cond 45 | ;; LowCardinality 46 | (str/starts-with? db-type "lowcardinality") 47 | (normalize-db-type (subs db-type 15 (- (count db-type) 1))) 48 | ;; Nullable 49 | (str/starts-with? db-type "nullable") 50 | (normalize-db-type (subs db-type 9 (- (count db-type) 1))) 51 | ;; for test purposes only: GMT0 is a legacy timezone; 52 | ;; it maps to LocalDateTime instead of OffsetDateTime 53 | ;; (= db-type "datetime64(3, 'gmt0')") 54 | ;; :type/DateTime 55 | ;; DateTime64 56 | (str/starts-with? db-type "datetime64") 57 | :type/DateTimeWithLocalTZ 58 | ;; DateTime 59 | (str/starts-with? db-type "datetime") 60 | :type/DateTimeWithLocalTZ 61 | ;; Enum* 62 | (str/starts-with? db-type "enum") 63 | :type/Text 64 | ;; Map 65 | (str/starts-with? db-type "map") 66 | :type/Dictionary 67 | ;; Tuple 68 | (str/starts-with? db-type "tuple") 69 | :type/* 70 | ;; SimpleAggregateFunction 71 | (str/starts-with? db-type "simpleaggregatefunction") 72 | (normalize-db-type (subs db-type (+ (str/index-of db-type ",") 2) (- (count db-type) 1))) 73 | ;; _ 74 | :else (or (database-type->base-type (keyword db-type)) :type/*))) 75 | 76 | ;; Enum8(UInt8) -> :type/Text, DateTime64(Europe/Amsterdam) -> :type/DateTime, 77 | ;; Nullable(DateTime) -> :type/DateTime, SimpleAggregateFunction(sum, Int64) -> :type/BigInteger, etc 78 | (defmethod sql-jdbc.sync/database-type->base-type :clickhouse 79 | [_ database-type] 80 | (let [db-type (if (keyword? database-type) 81 | (subs (str database-type) 1) 82 | database-type)] 83 | (normalize-db-type (u/lower-case-en db-type)))) 84 | 85 | (defmethod sql-jdbc.sync/excluded-schemas :clickhouse [_] 86 | #{"system" "information_schema" "INFORMATION_SCHEMA"}) 87 | 88 | (def ^:private allowed-table-types 89 | (into-array String 90 | ["TABLE" "VIEW" "FOREIGN TABLE" "REMOTE TABLE" "DICTIONARY" 91 | "MATERIALIZED VIEW" "MEMORY TABLE" "LOG TABLE"])) 92 | 93 | (defn- tables-set 94 | [tables] 95 | (set 96 | (for [table tables] 97 | (let [remarks (:remarks table)] 98 | {:name (:table_name table) 99 | :schema (:table_schem table) 100 | :description (when-not (str/blank? remarks) remarks)})))) 101 | 102 | (defn- get-tables-from-metadata 103 | [^DatabaseMetaData metadata schema-pattern] 104 | (.getTables metadata 105 | nil ; catalog - unused in the source code there 106 | schema-pattern 107 | "%" ; tablePattern "%" = match all tables 108 | allowed-table-types)) 109 | 110 | (defn- not-inner-mv-table? 111 | [table] 112 | (not (str/starts-with? (:table_name table) ".inner"))) 113 | 114 | (defn- ->spec 115 | [db] 116 | (if (u/id db) 117 | (sql-jdbc.conn/db->pooled-connection-spec db) db)) 118 | 119 | (defn- get-all-tables 120 | [db] 121 | (jdbc/with-db-metadata [metadata (->spec db)] 122 | (->> (get-tables-from-metadata metadata "%") 123 | (jdbc/metadata-result) 124 | (vec) 125 | (filter #(and 126 | (not (contains? (sql-jdbc.sync/excluded-schemas :clickhouse) (:table_schem %))) 127 | (not-inner-mv-table? %))) 128 | (tables-set)))) 129 | 130 | ;; Strangely enough, the tests only work with :db keyword, 131 | ;; but the actual sync from the UI uses :dbname 132 | (defn- get-db-name 133 | [db] 134 | (or (get-in db [:details :dbname]) 135 | (get-in db [:details :db]))) 136 | 137 | (defn- get-tables-in-dbs [db-or-dbs] 138 | (->> (for [db (as-> (or (get-db-name db-or-dbs) "default") dbs 139 | (str/split dbs #" ") 140 | (remove empty? dbs) 141 | (map (comp #(ddl.i/format-name :clickhouse %) str/trim) dbs))] 142 | (jdbc/with-db-metadata [metadata (->spec db-or-dbs)] 143 | (jdbc/metadata-result 144 | (get-tables-from-metadata metadata db)))) 145 | (apply concat) 146 | (filter not-inner-mv-table?) 147 | (tables-set))) 148 | 149 | (defmethod driver/describe-database :clickhouse 150 | [_ {{:keys [scan-all-databases]} 151 | :details :as db}] 152 | {:tables 153 | (if 154 | (boolean scan-all-databases) 155 | (get-all-tables db) 156 | (get-tables-in-dbs db))}) 157 | 158 | (defn- ^:private is-db-required? 159 | [field] 160 | (not (str/starts-with? (get field :database-type) "Nullable"))) 161 | 162 | (defmethod driver/describe-table :clickhouse 163 | [_ database table] 164 | (let [table-metadata (sql-jdbc.sync/describe-table :clickhouse database table) 165 | filtered-fields (for [field (:fields table-metadata) 166 | :let [updated-field (update field :database-required 167 | (fn [_] (is-db-required? field)))] 168 | ;; Skip all AggregateFunction (but keeping SimpleAggregateFunction) columns 169 | ;; JDBC does not support that and it crashes the data browser 170 | :when (not (re-matches #"^AggregateFunction\(.+$" 171 | (get field :database-type)))] 172 | updated-field)] 173 | (merge table-metadata {:fields (set filtered-fields)}))) 174 | 175 | (defmethod sql-jdbc.describe-table/get-table-pks :clickhouse 176 | [_driver ^java.sql.Connection conn db-name-or-nil table] 177 | ;; JDBC v2 sets the PKs now, so that :metadata/key-constraints feature should be enabled; 178 | ;; however, enabling :metadata/key-constraints will also enable left-join tests which are currently failing 179 | (if (not config/is-test?) 180 | (sql-jdbc.describe-table/get-table-pks :sql-jdbc conn db-name-or-nil table) 181 | [])) 182 | -------------------------------------------------------------------------------- /test/metabase/test/data/clickhouse_datasets.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE IF EXISTS `metabase_test`; 2 | CREATE DATABASE `metabase_test`; 3 | 4 | CREATE TABLE `metabase_test`.`metabase_test_lowercases` 5 | ( 6 | id UInt8, 7 | mystring Nullable(String) 8 | ) ENGINE = Memory; 9 | 10 | INSERT INTO `metabase_test`.`metabase_test_lowercases` 11 | VALUES (1, 'Я_1'), (2, 'R'), (3, 'Я_2'), (4, 'Я'), (5, 'я'), (6, NULL); 12 | 13 | CREATE TABLE `metabase_test`.`enums_test` 14 | ( 15 | enum1 Enum8('foo' = 0, 'bar' = 1, 'foo bar' = 2), 16 | enum2 Enum16('click' = 0, 'house' = 1), 17 | enum3 Enum8('qaz' = 42, 'qux' = 23) 18 | ) ENGINE = Memory; 19 | 20 | INSERT INTO `metabase_test`.`enums_test` (enum1, enum2, enum3) 21 | VALUES ('foo', 'house', 'qaz'), 22 | ('foo bar', 'click', 'qux'), 23 | ('bar', 'house', 'qaz'); 24 | 25 | CREATE TABLE `metabase_test`.`ipaddress_test` 26 | ( 27 | ipvfour Nullable(IPv4), 28 | ipvsix Nullable(IPv6) 29 | ) Engine = Memory; 30 | 31 | INSERT INTO `metabase_test`.`ipaddress_test` (ipvfour, ipvsix) 32 | VALUES (toIPv4('127.0.0.1'), toIPv6('0:0:0:0:0:0:0:1')), 33 | (toIPv4('0.0.0.0'), toIPv6('2001:438:ffff:0:0:0:407d:1bc1')), 34 | (null, null); 35 | 36 | CREATE TABLE `metabase_test`.`boolean_test` 37 | ( 38 | ID Int32, 39 | b1 Bool, 40 | b2 Nullable(Bool) 41 | ) ENGINE = Memory; 42 | 43 | INSERT INTO `metabase_test`.`boolean_test` (ID, b1, b2) 44 | VALUES (1, true, true), 45 | (2, false, true), 46 | (3, true, false); 47 | 48 | CREATE TABLE `metabase_test`.`maps_test` 49 | ( 50 | m Map(String, UInt64) 51 | ) ENGINE = Memory; 52 | 53 | INSERT INTO `metabase_test`.`maps_test` 54 | VALUES ({'key1':1,'key2':10}), 55 | ({'key1':2,'key2':20}), 56 | ({'key1':3,'key2':30}); 57 | 58 | 59 | CREATE TABLE `metabase_test`.`array_of_tuples_test` 60 | ( 61 | t Array(Tuple(String, UInt32)) 62 | ) Engine = Memory; 63 | 64 | INSERT INTO `metabase_test`.`array_of_tuples_test` (t) 65 | VALUES ([('foobar', 1234), ('qaz', 0)]), 66 | ([]); 67 | 68 | -- Used for testing that AggregateFunction columns are excluded, 69 | -- while SimpleAggregateFunction columns are preserved 70 | CREATE TABLE `metabase_test`.`aggregate_functions_filter_test` 71 | ( 72 | idx UInt8, 73 | a AggregateFunction(uniq, String), 74 | lowest_value SimpleAggregateFunction(min, UInt8), 75 | count SimpleAggregateFunction(sum, Int64) 76 | ) ENGINE Memory; 77 | 78 | INSERT INTO `metabase_test`.`aggregate_functions_filter_test` 79 | (idx, lowest_value, count) 80 | VALUES (42, 144, 255255); 81 | 82 | -- Materialized views (testing .inner tables exclusion) 83 | CREATE TABLE `metabase_test`.`wikistat` 84 | ( 85 | `date` Date, 86 | `project` LowCardinality(String), 87 | `hits` UInt32 88 | ) ENGINE = Memory; 89 | 90 | CREATE MATERIALIZED VIEW `metabase_test`.`wikistat_mv` ENGINE =Memory AS 91 | SELECT date, project, sum(hits) AS hits 92 | FROM `metabase_test`.`wikistat` 93 | GROUP BY date, project; 94 | 95 | INSERT INTO `metabase_test`.`wikistat` 96 | VALUES (now(), 'foo', 10), 97 | (now(), 'bar', 10), 98 | (now(), 'bar', 20); 99 | 100 | -- Used in sum-where tests 101 | CREATE TABLE `metabase_test`.`sum_if_test_int` 102 | ( 103 | id Int64, 104 | int_value Int64, 105 | discriminator String 106 | ) ENGINE = Memory; 107 | 108 | INSERT INTO `metabase_test`.`sum_if_test_int` 109 | VALUES (1, 1, 'foo'), 110 | (2, 1, 'foo'), 111 | (3, 3, 'bar'), 112 | (4, 5, 'bar'); 113 | 114 | CREATE TABLE `metabase_test`.`sum_if_test_float` 115 | ( 116 | id Int64, 117 | float_value Float64, 118 | discriminator String 119 | ) ENGINE = Memory; 120 | 121 | INSERT INTO `metabase_test`.`sum_if_test_float` 122 | VALUES (1, 1.1, 'foo'), 123 | (2, 1.44, 'foo'), 124 | (3, 3.5, 'bar'), 125 | (4, 5.77, 'bar'); 126 | 127 | -- Temporal bucketing tests 128 | CREATE TABLE `metabase_test`.`temporal_bucketing_server_tz` 129 | ( 130 | start_of_year DateTime, 131 | mid_of_year DateTime, 132 | end_of_year DateTime 133 | ) ENGINE = Memory; 134 | 135 | INSERT INTO `metabase_test`.`temporal_bucketing_server_tz` 136 | VALUES ('2022-01-01 00:00:00', 137 | '2022-06-20 06:32:54', 138 | '2022-12-31 23:59:59'); 139 | 140 | CREATE TABLE `metabase_test`.`temporal_bucketing_column_tz` 141 | ( 142 | start_of_year DateTime('America/Los_Angeles'), 143 | mid_of_year DateTime('America/Los_Angeles'), 144 | end_of_year DateTime('America/Los_Angeles') 145 | ) ENGINE = Memory; 146 | 147 | INSERT INTO `metabase_test`.`temporal_bucketing_column_tz` 148 | VALUES (toDateTime('2022-01-01 00:00:00', 'America/Los_Angeles'), 149 | toDateTime('2022-06-20 06:32:54', 'America/Los_Angeles'), 150 | toDateTime('2022-12-31 23:59:59', 'America/Los_Angeles')); 151 | 152 | CREATE TABLE `metabase_test`.`datetime_diff_nullable` ( 153 | idx Int32, 154 | dt64 Nullable(DateTime64(3, 'UTC')), 155 | dt Nullable(DateTime('UTC')), 156 | d Nullable(Date) 157 | ) ENGINE Memory; 158 | 159 | INSERT INTO `metabase_test`.`datetime_diff_nullable` 160 | VALUES (42, '2022-01-01 00:00:00.000', '2022-06-20 06:32:54', '2022-07-22'), 161 | (43, '2022-01-01 00:00:00.000', NULL, NULL), 162 | (44, NULL, '2022-06-20 06:32:54', '2022-07-22'), 163 | (45, NULL, NULL, NULL); 164 | 165 | DROP DATABASE IF EXISTS `metabase_db_scan_test`; 166 | CREATE DATABASE `metabase_db_scan_test`; 167 | 168 | CREATE TABLE `metabase_db_scan_test`.`table1` (i Int32) ENGINE = Memory; 169 | CREATE TABLE `metabase_db_scan_test`.`table2` (i Int64) ENGINE = Memory; 170 | 171 | -- Base type matching tests 172 | CREATE TABLE `metabase_test`.`enums_base_types` ( 173 | c1 Nullable(Enum8('America/New_York')), 174 | c2 Enum8('BASE TABLE' = 1, 'VIEW' = 2, 'FOREIGN TABLE' = 3, 'LOCAL TEMPORARY' = 4, 'SYSTEM VIEW' = 5), 175 | c3 Enum8('NO', 'YES'), 176 | c4 Enum16('SHOW DATABASES' = 0, 'SHOW TABLES' = 1, 'SHOW COLUMNS' = 2), 177 | c5 Nullable(Enum8('GLOBAL' = 0, 'DATABASE' = 1, 'TABLE' = 2)), 178 | c6 Nullable(Enum16('SHOW DATABASES' = 0, 'SHOW TABLES' = 1, 'SHOW COLUMNS' = 2)) 179 | ) ENGINE Memory; 180 | CREATE TABLE `metabase_test`.`date_base_types` ( 181 | c1 Date, 182 | c2 Date32, 183 | c3 Nullable(Date), 184 | c4 Nullable(Date32) 185 | ) ENGINE Memory; 186 | CREATE TABLE `metabase_test`.`datetime_base_types` ( 187 | c1 Nullable(DateTime('America/New_York')), 188 | c2 DateTime('America/New_York'), 189 | c3 DateTime, 190 | c4 DateTime64(3), 191 | c5 DateTime64(9, 'America/New_York'), 192 | c6 Nullable(DateTime64(6, 'America/New_York')), 193 | c7 Nullable(DateTime64(0)), 194 | c8 Nullable(DateTime) 195 | ) ENGINE Memory; 196 | CREATE TABLE `metabase_test`.`integer_base_types` ( 197 | c1 UInt8, 198 | c2 UInt16, 199 | c3 UInt32, 200 | c4 UInt64, 201 | c5 UInt128, 202 | c6 UInt256, 203 | c7 Int8, 204 | c8 Int16, 205 | c9 Int32, 206 | c10 Int64, 207 | c11 Int128, 208 | c12 Int256, 209 | c13 Nullable(Int32) 210 | ) ENGINE Memory; 211 | CREATE TABLE `metabase_test`.`numeric_base_types` ( 212 | c1 Float32, 213 | c2 Float64, 214 | c3 Decimal(4, 2), 215 | c4 Decimal32(7), 216 | c5 Decimal64(12), 217 | c6 Decimal128(24), 218 | c7 Decimal256(42), 219 | c8 Nullable(Float32), 220 | c9 Nullable(Decimal(4, 2)), 221 | c10 Nullable(Decimal256(42)) 222 | ) ENGINE Memory; 223 | CREATE TABLE `metabase_test`.`string_base_types` ( 224 | c1 String, 225 | c2 LowCardinality(String), 226 | c3 FixedString(32), 227 | c4 Nullable(String), 228 | c5 LowCardinality(FixedString(4)) 229 | ) ENGINE Memory; 230 | CREATE TABLE `metabase_test`.`misc_base_types` ( 231 | c1 Boolean, 232 | c2 UUID, 233 | c3 IPv4, 234 | c4 IPv6, 235 | c5 Map(Int32, String), 236 | c6 Nullable(Boolean), 237 | c7 Nullable(UUID), 238 | c8 Nullable(IPv4), 239 | c9 Nullable(IPv6), 240 | c10 Tuple(String, Int32) 241 | ) ENGINE Memory; 242 | CREATE TABLE `metabase_test`.`array_base_types` ( 243 | c1 Array(String), 244 | c2 Array(Nullable(Int32)), 245 | c3 Array(Array(LowCardinality(FixedString(32)))), 246 | c4 Array(Array(Array(String))) 247 | ) ENGINE Memory; 248 | CREATE TABLE `metabase_test`.`low_cardinality_nullable_base_types` ( 249 | c1 LowCardinality(Nullable(String)), 250 | c2 LowCardinality(Nullable(FixedString(16))) 251 | ) ENGINE Memory; 252 | 253 | -- can-connect tests (odd database names) 254 | DROP DATABASE IF EXISTS `Special@Characters~`; 255 | CREATE DATABASE `Special@Characters~`; 256 | 257 | -- arrays inner types test 258 | CREATE TABLE `metabase_test`.`arrays_inner_types` 259 | ( 260 | `arr_str` Array(String), 261 | `arr_nstr` Array(Nullable(String)), 262 | `arr_dec` Array(Decimal(18, 4)), 263 | `arr_ndec` Array(Nullable(Decimal(18, 4))) 264 | ) 265 | ENGINE Memory; 266 | INSERT INTO `metabase_test`.`arrays_inner_types` VALUES ( 267 | ['a', 'b', 'c'], 268 | [NULL, 'd', 'e'], 269 | [1, 2, 3], 270 | [4, NULL, 5] 271 | ); 272 | 273 | CREATE TABLE `metabase_test`.`unsigned_int_types` 274 | ( 275 | `u8` UInt8, 276 | `u16` UInt16, 277 | `u32` UInt32, 278 | `u64` UInt64 279 | ) ENGINE Memory; 280 | INSERT INTO `metabase_test`.`unsigned_int_types` 281 | VALUES (255, 65535, 4294967295, 18446744073709551615); 282 | 283 | CREATE TABLE `metabase_test`.`fixed_strings` 284 | ( 285 | `f1` FixedString(4), 286 | `f2` LowCardinality(FixedString(4)), 287 | `f3` Nullable(FixedString(4)), 288 | `f4` LowCardinality(Nullable(FixedString(4))) 289 | ) ENGINE Memory; 290 | INSERT INTO `metabase_test`.`fixed_strings` 291 | VALUES ('val1', 'val2', 'val3', 'val4'); 292 | -------------------------------------------------------------------------------- /test/metabase/driver/clickhouse_impersonation_test.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-impersonation-test 2 | "SET ROLE (connection impersonation feature) tests on with single node or on-premise cluster setups." 3 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 4 | (:require [clojure.test :refer :all] 5 | [metabase-enterprise.advanced-permissions.api.util-test :as advanced-perms.api.tu] 6 | [metabase.driver :as driver] 7 | [metabase.driver.sql :as driver.sql] 8 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 9 | [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] 10 | [metabase.query-processor.store :as qp.store] 11 | [metabase.test :as mt] 12 | [metabase.test.data.clickhouse :as ctd] 13 | [metabase.util :as u] 14 | [toucan2.tools.with-temp :as t2.with-temp])) 15 | 16 | ;; 53+ metabase.sync has moved to metabase.sync.core 17 | (try 18 | (require '[metabase.sync :as sync]) 19 | (catch java.io.FileNotFoundException e 20 | (when (re-find #"metabase/sync\.clj" (.getMessage e)) 21 | (require '[metabase.sync.core :as sync])))) 22 | 23 | (set! *warn-on-reflection* true) 24 | 25 | (defn- set-role-test! 26 | [details-map] 27 | (let [default-role (driver.sql/default-database-role :clickhouse nil) 28 | spec (sql-jdbc.conn/connection-details->spec :clickhouse details-map)] 29 | (testing "default role is NONE" 30 | (is (= default-role "NONE"))) 31 | (testing "does not throw with an existing role" 32 | (sql-jdbc.execute/do-with-connection-with-options 33 | :clickhouse spec nil 34 | (fn [^java.sql.Connection conn] 35 | (driver/set-role! :clickhouse conn "metabase_test_role"))) 36 | (is true)) 37 | (testing "does not throw with a role containing hyphens" 38 | (sql-jdbc.execute/do-with-connection-with-options 39 | :clickhouse spec nil 40 | (fn [^java.sql.Connection conn] 41 | (driver/set-role! :clickhouse conn "metabase-test-role"))) 42 | (is true)) 43 | (testing "does not throw with the default role" 44 | (sql-jdbc.execute/do-with-connection-with-options 45 | :clickhouse spec nil 46 | (fn [^java.sql.Connection conn] 47 | (driver/set-role! :clickhouse conn default-role) 48 | (fn [^java.sql.Connection conn] 49 | (driver/set-role! :clickhouse conn default-role) 50 | (with-open [stmt (.prepareStatement conn "SELECT * FROM `metabase_test_role_db`.`some_table` ORDER BY i ASC;") 51 | rset (.executeQuery stmt)] 52 | (is (.next rset) true) 53 | (is (.getInt rset 1) 42) 54 | (is (.next rset) true) 55 | (is (.getInt rset 1) 144) 56 | (is (.next rset) false))))) 57 | (is true)))) 58 | 59 | (defn- set-role-throws-test! 60 | [details-map] 61 | (testing "throws when assigning a non-existent role" 62 | (is (thrown? Exception 63 | (sql-jdbc.execute/do-with-connection-with-options 64 | :clickhouse (sql-jdbc.conn/connection-details->spec :clickhouse details-map) nil 65 | (fn [^java.sql.Connection conn] 66 | (driver/set-role! :clickhouse conn "asdf"))))))) 67 | 68 | (defn- do-with-new-metadata-provider 69 | [details thunk] 70 | (t2.with-temp/with-temp 71 | [:model/Database db {:engine :clickhouse :details details}] 72 | (qp.store/with-metadata-provider (u/the-id db) (thunk db)))) 73 | 74 | (deftest clickhouse-set-role 75 | (mt/test-driver 76 | :clickhouse 77 | (let [user-details {:user "metabase_test_user"} 78 | ;; See docker-compose.yml for the port mappings 79 | ;; 24.4+ 80 | single-node-port-details {:port 8123} 81 | single-node-details (merge user-details single-node-port-details) 82 | cluster-port-details {:port 8127} 83 | cluster-details (merge user-details cluster-port-details)] 84 | (testing "single node" 85 | (testing "should support the impersonation feature" 86 | (t2.with-temp/with-temp 87 | [:model/Database db {:engine :clickhouse :details {:user "default" :port 8123}}] 88 | (is (true? (driver/database-supports? :clickhouse :connection-impersonation db))))) 89 | (let [statements ["CREATE DATABASE IF NOT EXISTS `metabase_test_role_db`;" 90 | "CREATE OR REPLACE TABLE `metabase_test_role_db`.`some_table` (i Int32) ENGINE = MergeTree ORDER BY (i);" 91 | "INSERT INTO `metabase_test_role_db`.`some_table` VALUES (42), (144);" 92 | "CREATE ROLE IF NOT EXISTS `metabase_test_role`;" 93 | "CREATE ROLE IF NOT EXISTS `metabase-test-role`;" 94 | "CREATE USER IF NOT EXISTS `metabase_test_user` NOT IDENTIFIED;" 95 | "GRANT SELECT ON `metabase_test_role_db`.* TO `metabase_test_role`,`metabase-test-role`;" 96 | "GRANT `metabase_test_role`, `metabase-test-role` TO `metabase_test_user`;"]] 97 | (ctd/exec-statements statements single-node-port-details) 98 | (do-with-new-metadata-provider 99 | single-node-details 100 | (fn [_db] 101 | (set-role-test! single-node-details) 102 | (set-role-throws-test! single-node-details))))) 103 | (testing "on-premise cluster" 104 | (testing "should support the impersonation feature" 105 | (t2.with-temp/with-temp 106 | [:model/Database db {:engine :clickhouse :details {:user "default" :port 8127}}] 107 | (is (true? (driver/database-supports? :clickhouse :connection-impersonation db))))) 108 | (let [statements ["CREATE DATABASE IF NOT EXISTS `metabase_test_role_db` ON CLUSTER '{cluster}';" 109 | "CREATE OR REPLACE TABLE `metabase_test_role_db`.`some_table` ON CLUSTER '{cluster}' (i Int32) 110 | ENGINE ReplicatedMergeTree('/clickhouse/{cluster}/tables/{database}/{table}/{shard}', '{replica}') 111 | ORDER BY (i);" 112 | "INSERT INTO `metabase_test_role_db`.`some_table` VALUES (42), (144);" 113 | "CREATE ROLE IF NOT EXISTS `metabase_test_role` ON CLUSTER '{cluster}';" 114 | "CREATE ROLE IF NOT EXISTS `metabase-test-role` ON CLUSTER '{cluster}';" 115 | "CREATE USER IF NOT EXISTS `metabase_test_user` ON CLUSTER '{cluster}' NOT IDENTIFIED;" 116 | "GRANT ON CLUSTER '{cluster}' SELECT ON `metabase_test_role_db`.* TO `metabase_test_role`, `metabase-test-role`;" 117 | "GRANT ON CLUSTER '{cluster}' `metabase_test_role`, `metabase-test-role` TO `metabase_test_user`;"]] 118 | (ctd/exec-statements statements cluster-port-details) 119 | (do-with-new-metadata-provider 120 | cluster-details 121 | (fn [_db] 122 | (set-role-test! cluster-details) 123 | (set-role-throws-test! cluster-details))))) 124 | (testing "older ClickHouse version" ;; 23.3 125 | (testing "should NOT support the impersonation feature" 126 | (t2.with-temp/with-temp 127 | [:model/Database db {:engine :clickhouse :details {:user "default" :port 8124}}] 128 | (is (false? (driver/database-supports? :clickhouse :connection-impersonation db))))))))) 129 | 130 | (deftest conn-impersonation-test-clickhouse 131 | (mt/test-driver 132 | :clickhouse 133 | (mt/with-premium-features #{:advanced-permissions} 134 | (let [table-name (str "metabase_impersonation_test.test_" (System/currentTimeMillis)) 135 | select-query (format "SELECT * FROM %s;" table-name) 136 | cluster-port {:port 8127} 137 | cluster-details {:engine :clickhouse 138 | :details {:user "metabase_impersonation_test_user" 139 | :dbname "metabase_impersonation_test" 140 | :port 8127}} 141 | ddl-statements ["CREATE DATABASE IF NOT EXISTS metabase_impersonation_test ON CLUSTER '{cluster}';" 142 | (format "CREATE TABLE %s ON CLUSTER '{cluster}' (s String) 143 | ENGINE ReplicatedMergeTree('/clickhouse/{cluster}/tables/{database}/{table}/{shard}', '{replica}') 144 | ORDER BY (s);" table-name)] 145 | insert-statements [(format "INSERT INTO %s VALUES ('a'), ('b'), ('c');" table-name)] 146 | grant-statements ["CREATE USER IF NOT EXISTS metabase_impersonation_test_user ON CLUSTER '{cluster}' NOT IDENTIFIED;" 147 | "CREATE ROLE IF NOT EXISTS row_a ON CLUSTER '{cluster}';" 148 | "CREATE ROLE IF NOT EXISTS row_b ON CLUSTER '{cluster}';" 149 | "CREATE ROLE IF NOT EXISTS row_c ON CLUSTER '{cluster}';" 150 | "GRANT ON CLUSTER '{cluster}' row_a, row_b, row_c TO metabase_impersonation_test_user;" 151 | (format "GRANT ON CLUSTER '{cluster}' SELECT ON %s TO metabase_impersonation_test_user;" table-name) 152 | (format "CREATE ROW POLICY OR REPLACE policy_row_a ON CLUSTER '{cluster}' 153 | ON %s FOR SELECT USING s = 'a' TO row_a;" table-name) 154 | (format "CREATE ROW POLICY OR REPLACE policy_row_b ON CLUSTER '{cluster}' 155 | ON %s FOR SELECT USING s = 'b' TO row_b;" table-name) 156 | (format "CREATE ROW POLICY OR REPLACE policy_row_c ON CLUSTER '{cluster}' 157 | ON %s FOR SELECT USING s = 'c' TO row_c;" table-name)]] 158 | (ctd/exec-statements ddl-statements cluster-port {"wait_end_of_query" "1"}) 159 | (ctd/exec-statements insert-statements cluster-port {"wait_end_of_query" "1" 160 | "insert_quorum" "2"}) 161 | (ctd/exec-statements grant-statements cluster-port {"wait_end_of_query" "1"}) 162 | (t2.with-temp/with-temp [:model/Database db cluster-details] 163 | (mt/with-db db (sync/sync-database! db) 164 | 165 | (letfn [(check-impersonation! [roles expected] 166 | (advanced-perms.api.tu/with-impersonations! 167 | {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}] 168 | :attributes {"impersonation_attr" roles}} 169 | (is (= expected 170 | (-> {:query select-query} 171 | mt/native-query 172 | mt/process-query 173 | mt/rows)))))] 174 | 175 | (is (= [["a"] ["b"] ["c"]] 176 | (-> {:query select-query} 177 | mt/native-query 178 | mt/process-query 179 | mt/rows))) 180 | 181 | (check-impersonation! "row_a" [["a"]]) 182 | (check-impersonation! "row_b" [["b"]]) 183 | (check-impersonation! "row_c" [["c"]]) 184 | (check-impersonation! "row_a,row_c" [["a"] ["c"]]) 185 | (check-impersonation! "row_b,row_c" [["b"] ["c"]]) 186 | (check-impersonation! "row_a,row_b,row_c" [["a"] ["b"] ["c"]])))))))) 187 | -------------------------------------------------------------------------------- /test/metabase/driver/clickhouse_test.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-test 2 | "Tests for specific behavior of the ClickHouse driver." 3 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 4 | (:require [clojure.test :refer :all] 5 | [metabase.driver :as driver] 6 | [metabase.driver.clickhouse :as clickhouse] 7 | [metabase.driver.clickhouse-qp :as clickhouse-qp] 8 | [metabase.driver.sql-jdbc :as sql-jdbc] 9 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 10 | [metabase.query-processor.compile :as qp.compile] 11 | [metabase.test :as mt] 12 | [metabase.test.data :as data] 13 | [metabase.test.data.interface :as tx] 14 | [metabase.test.data.clickhouse :as ctd] 15 | [taoensso.nippy :as nippy] 16 | [toucan2.tools.with-temp :as t2.with-temp])) 17 | 18 | (set! *warn-on-reflection* true) 19 | 20 | (use-fixtures :once ctd/create-test-db!) 21 | 22 | ;; the mt/with-dynamic-redefs macro was renamed to mt/with-dynamic-fn-redefs for 0.53+ 23 | ;; as 0.52 is still tested by CI we will check which macro is defined and use that 24 | (defmacro with-dynamic-redefs [bindings & body] 25 | (if (resolve `mt/with-dynamic-redefs) 26 | `(mt/with-dynamic-redefs ~bindings ~@body) 27 | `(mt/with-dynamic-fn-redefs ~bindings ~@body))) 28 | 29 | (deftest ^:parallel clickhouse-version 30 | (mt/test-driver 31 | :clickhouse 32 | (t2.with-temp/with-temp 33 | [:model/Database db 34 | {:engine :clickhouse 35 | :details (tx/dbdef->connection-details :clickhouse :db {:database-name "default"})}] 36 | (let [version (driver/dbms-version :clickhouse db)] 37 | (is (number? (get-in version [:semantic-version :major]))) 38 | (is (number? (get-in version [:semantic-version :minor]))) 39 | (is (string? (get version :version))))))) 40 | 41 | (deftest ^:parallel clickhouse-server-timezone 42 | (mt/test-driver 43 | :clickhouse 44 | (is (= "UTC" 45 | (let [details (tx/dbdef->connection-details :clickhouse :db {:database-name "default"}) 46 | spec (sql-jdbc.conn/connection-details->spec :clickhouse details)] 47 | (driver/db-default-timezone :clickhouse spec)))))) 48 | 49 | (deftest ^:parallel clickhouse-connection-string 50 | (mt/with-dynamic-fn-redefs [ ;; This function's implementation requires the connection details to actually connect to the 51 | ;; database, which is orthogonal to the purpose of this test. 52 | clickhouse/cloud? (constantly false)] 53 | (testing "connection with no additional options" 54 | (is (= ctd/default-connection-params 55 | (sql-jdbc.conn/connection-details->spec 56 | :clickhouse 57 | {})))) 58 | (testing "custom connection with additional options" 59 | (is (= (merge 60 | ctd/default-connection-params 61 | {:subname "//myclickhouse:9999/foo?sessionTimeout=42" 62 | :user "bob" 63 | :password "qaz" 64 | :ssl true 65 | :custom_http_params "max_threads=42,allow_experimental_analyzer=0"}) 66 | (sql-jdbc.conn/connection-details->spec 67 | :clickhouse 68 | {:host "myclickhouse" 69 | :port 9999 70 | :user "bob" 71 | :password "qaz" 72 | :dbname "foo" 73 | :additional-options "sessionTimeout=42" 74 | :ssl true 75 | :clickhouse-settings "max_threads=42,allow_experimental_analyzer=0"})))) 76 | (testing "nil dbname handling" 77 | (is (= ctd/default-connection-params 78 | (sql-jdbc.conn/connection-details->spec 79 | :clickhouse {:dbname nil})))) 80 | (testing "schema removal" 81 | (doall 82 | (for [host ["localhost" "http://localhost" "https://localhost"]] 83 | (testing (str "for host " host) 84 | (is (= ctd/default-connection-params 85 | (sql-jdbc.conn/connection-details->spec 86 | :clickhouse {:host host})))))) 87 | (doall 88 | (for [host ["myhost" "http://myhost" "https://myhost"]] 89 | (testing (str "for host " host) 90 | (is (= (merge ctd/default-connection-params 91 | {:subname "//myhost:8123/default"}) 92 | (sql-jdbc.conn/connection-details->spec 93 | :clickhouse {:host host})))))) 94 | (doall 95 | (for [host ["sub.example.com" "http://sub.example.com" "https://sub.example.com"]] 96 | (testing (str "for host " host " with some additional params") 97 | (is (= (merge ctd/default-connection-params 98 | {:subname "//sub.example.com:8443/mydb" :ssl true}) 99 | (sql-jdbc.conn/connection-details->spec 100 | :clickhouse {:host host :dbname "mydb" :port 8443 :ssl true}))))))))) 101 | 102 | (deftest ^:parallel clickhouse-connection-string-select-sequential-consistency 103 | (mt/with-dynamic-fn-redefs [ ;; This function's implementation requires the connection details to actually 104 | ;; connect to the database, which is orthogonal to the purpose of this test. 105 | clickhouse/cloud? (constantly true)] 106 | (testing "connection with no additional options" 107 | (is (= (assoc ctd/default-connection-params :select_sequential_consistency true) 108 | (sql-jdbc.conn/connection-details->spec 109 | :clickhouse 110 | {})))))) 111 | 112 | (deftest clickhouse-connection-fails-test 113 | (mt/test-driver 114 | :clickhouse 115 | (mt/with-temp [:model/Database db {:details (assoc (mt/db) :password "wrongpassword") :engine :clickhouse}] 116 | (testing "sense check that checking the cloud mode fails with a SQLException." 117 | ;; nil arg isn't tested here, as it will pick up the defaults, which is the same as the Docker instance credentials. 118 | (is (thrown? java.sql.SQLException (#'clickhouse/cloud? (:details db))))) 119 | (testing "`driver/database-supports? :uploads` does not throw even if the connection fails." 120 | (is (false? (driver/database-supports? :clickhouse :uploads db))) 121 | (is (false? (driver/database-supports? :clickhouse :uploads nil)))) 122 | (testing "`driver/database-supports? :connection-impersonation` does not throw even if the connection fails." 123 | (is (false? (driver/database-supports? :clickhouse :connection-impersonation db))) 124 | (is (false? (driver/database-supports? :clickhouse :connection-impersonation nil)))) 125 | (testing (str "`sql-jdbc.conn/connection-details->spec` does not throw even if the connection fails, " 126 | "and doesn't include the `select_sequential_consistency` parameter.") 127 | (is (nil? (:select_sequential_consistency (sql-jdbc.conn/connection-details->spec :clickhouse db)))) 128 | (is (nil? (:select_sequential_consistency (sql-jdbc.conn/connection-details->spec :clickhouse nil)))))))) 129 | 130 | (deftest ^:parallel clickhouse-tls 131 | (mt/test-driver 132 | :clickhouse 133 | (let [working-dir (System/getProperty "user.dir") 134 | cert-path (str working-dir "/modules/drivers/clickhouse/.docker/clickhouse/single_node_tls/certificates/ca.crt") 135 | additional-options (str "sslrootcert=" cert-path)] 136 | (testing "simple connection with a single database" 137 | (is (= "UTC" 138 | (driver/db-default-timezone 139 | :clickhouse 140 | (sql-jdbc.conn/connection-details->spec 141 | :clickhouse 142 | {:ssl true 143 | :host "server.clickhouseconnect.test" 144 | :port 8443 145 | :additional-options additional-options}))))) 146 | (testing "connection with multiple databases" 147 | (is (= "UTC" 148 | (driver/db-default-timezone 149 | :clickhouse 150 | (sql-jdbc.conn/connection-details->spec 151 | :clickhouse 152 | {:ssl true 153 | :host "server.clickhouseconnect.test" 154 | :port 8443 155 | :dbname "default system" 156 | :additional-options additional-options})))))))) 157 | 158 | (deftest ^:parallel clickhouse-nippy 159 | (mt/test-driver 160 | :clickhouse 161 | (testing "UnsignedByte" 162 | (let [value (com.clickhouse.data.value.UnsignedByte/valueOf "214")] 163 | (is (= value (nippy/thaw (nippy/freeze value)))))) 164 | (testing "UnsignedShort" 165 | (let [value (com.clickhouse.data.value.UnsignedShort/valueOf "62055")] 166 | (is (= value (nippy/thaw (nippy/freeze value)))))) 167 | (testing "UnsignedInteger" 168 | (let [value (com.clickhouse.data.value.UnsignedInteger/valueOf "4748364")] 169 | (is (= value (nippy/thaw (nippy/freeze value)))))) 170 | (testing "UnsignedLong" 171 | (let [value (com.clickhouse.data.value.UnsignedLong/valueOf "84467440737095")] 172 | (is (= value (nippy/thaw (nippy/freeze value)))))))) 173 | 174 | (deftest ^:parallel clickhouse-query-formatting 175 | (mt/test-driver 176 | :clickhouse 177 | (let [query (data/mbql-query venues {:fields [$id] :order-by [[:asc $id]] :limit 5}) 178 | {compiled :query} (qp.compile/compile-with-inline-parameters query) 179 | pretty (driver/prettify-native-form :clickhouse compiled)] 180 | (testing "compiled" 181 | (is (= "SELECT `test_data`.`venues`.`id` AS `id` FROM `test_data`.`venues` ORDER BY `test_data`.`venues`.`id` ASC LIMIT 5" compiled))) 182 | (testing "pretty" 183 | (is (= "SELECT\n `test_data`.`venues`.`id` AS `id`\nFROM\n `test_data`.`venues`\nORDER BY\n `test_data`.`venues`.`id` ASC\nLIMIT\n 5" pretty)))))) 184 | 185 | (deftest ^:parallel clickhouse-can-connect 186 | (mt/test-driver 187 | :clickhouse 188 | (doall 189 | (for [[username password] [["default" ""] ["user_with_password" "foo@bar!"]] 190 | database ["default" "Special@Characters~"]] 191 | (testing (format "User `%s` can connect to `%s`" username database) 192 | (let [details (merge {:user username :password password} 193 | (tx/dbdef->connection-details :clickhouse :db {:database-name database}))] 194 | (is (true? (driver/can-connect? :clickhouse details))))))))) 195 | 196 | (deftest clickhouse-qp-extract-datetime-timezone 197 | (mt/test-driver 198 | :clickhouse 199 | (is (= "utc" (#'clickhouse-qp/extract-datetime-timezone "datetime('utc')"))) 200 | (is (= "utc" (#'clickhouse-qp/extract-datetime-timezone "datetime64(3, 'utc')"))) 201 | (is (= "europe/amsterdam" (#'clickhouse-qp/extract-datetime-timezone "datetime('europe/amsterdam')"))) 202 | (is (= "europe/amsterdam" (#'clickhouse-qp/extract-datetime-timezone "datetime64(9, 'europe/amsterdam')"))) 203 | (is (= nil (#'clickhouse-qp/extract-datetime-timezone "datetime"))) 204 | (is (= nil (#'clickhouse-qp/extract-datetime-timezone "datetime64"))) 205 | (is (= nil (#'clickhouse-qp/extract-datetime-timezone "datetime64(3)"))))) 206 | 207 | (deftest ^:synchronized clickhouse-insert 208 | (mt/test-driver 209 | :clickhouse 210 | (t2.with-temp/with-temp 211 | [:model/Database db 212 | {:engine :clickhouse 213 | :details (tx/dbdef->connection-details :clickhouse :db {:database-name "default"})}] 214 | (let [table (keyword (format "insert_table_%s" (System/currentTimeMillis)))] 215 | (driver/create-table! :clickhouse (:id db) table {:id "Int64", :name "String"}) 216 | (try 217 | (driver/insert-into! :clickhouse (:id db) table [:id :name] [[42 "Bob"] [43 "Alice"]]) 218 | (is (= #{{:id 42, :name "Bob"} 219 | {:id 43, :name "Alice"}} 220 | (set (sql-jdbc/query :clickhouse db {:select [:*] :from [table]})))) 221 | (finally 222 | (driver/drop-table! :clickhouse (:id db) table))))))) 223 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016-2025 ClickHouse, Inc. 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | 180 | APPENDIX: How to apply the Apache License to your work. 181 | 182 | To apply the Apache License to your work, attach the following 183 | boilerplate notice, with the fields enclosed by brackets "[]" 184 | replaced with your own identifying information. (Don't include 185 | the brackets!) The text should be enclosed in the appropriate 186 | comment syntax for the file format. We also recommend that a 187 | file or class name and description of purpose be included on the 188 | same "printed page" as the copyright notice for easier 189 | identification within third-party archives. 190 | 191 | Copyright 2016-2025 ClickHouse, Inc. 192 | 193 | Licensed under the Apache License, Version 2.0 (the "License"); 194 | you may not use this file except in compliance with the License. 195 | You may obtain a copy of the License at 196 | 197 | http://www.apache.org/licenses/LICENSE-2.0 198 | 199 | Unless required by applicable law or agreed to in writing, software 200 | distributed under the License is distributed on an "AS IS" BASIS, 201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 202 | See the License for the specific language governing permissions and 203 | limitations under the License. 204 | -------------------------------------------------------------------------------- /test/metabase/test/data/clickhouse.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.test.data.clickhouse 2 | "Code for creating / destroying a ClickHouse database from a `DatabaseDefinition`." 3 | (:require 4 | [clojure.java.io :as io] 5 | [clojure.java.jdbc :as jdbc] 6 | [clojure.string :as str] 7 | [clojure.test :refer :all] 8 | [java-time.api :as t] 9 | [metabase.db.query :as mdb.query] 10 | [metabase.driver :as driver] 11 | [metabase.driver.ddl.interface :as ddl.i] 12 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 13 | [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] 14 | [metabase.driver.sql.util :as sql.u] 15 | [metabase.lib.schema.common :as lib.schema.common] 16 | [metabase.query-processor-test.alternative-date-test :as qp.alternative-date-test] 17 | [metabase.query-processor.test-util :as qp.test] 18 | [metabase.sync.core :as sync] 19 | [metabase.test.data.interface :as tx] 20 | [metabase.test.data.sql :as sql.tx] 21 | [metabase.test.data.sql-jdbc :as sql-jdbc.tx] 22 | [metabase.test.data.sql-jdbc.execute :as execute] 23 | [metabase.test.data.sql-jdbc.load-data :as load-data] 24 | [metabase.test.data.sql.ddl :as ddl] 25 | [metabase.util.log :as log] 26 | [metabase.util.malli :as mu] 27 | [toucan2.tools.with-temp :as t2.with-temp])) 28 | 29 | (sql-jdbc.tx/add-test-extensions! :clickhouse) 30 | 31 | (defmethod driver/database-supports? [:clickhouse :metabase.driver.sql-jdbc.sync.describe-table-test/describe-view-fields] 32 | [_driver _feature _db] true) 33 | (defmethod driver/database-supports? [:clickhouse :metabase.driver.sql-jdbc.sync.describe-table-test/describe-materialized-view-fields] 34 | [_driver _feature _db] false) 35 | 36 | (defmethod driver/database-supports? [:clickhouse :metabase.query-processor-test.parameters-test/get-parameter-count] 37 | [_driver _feature _db] false) 38 | 39 | (defmethod qp.alternative-date-test/iso-8601-text-fields-expected-rows :clickhouse 40 | [_driver] 41 | [[1 "foo" (t/offset-date-time "2004-10-19T10:23:54Z") #t "2004-10-19" (t/offset-date-time "1970-01-01T10:23:54Z")] 42 | [2 "bar" (t/offset-date-time "2008-10-19T10:23:54Z") #t "2008-10-19" (t/offset-date-time "1970-01-01T10:23:54Z")] 43 | [3 "baz" (t/offset-date-time "2012-10-19T10:23:54Z") #t "2012-10-19" (t/offset-date-time "1970-01-01T10:23:54Z")]]) 44 | 45 | (def default-connection-params 46 | {:classname "com.clickhouse.jdbc.ClickHouseDriver" 47 | :subprotocol "clickhouse" 48 | :subname "//localhost:8123/default" 49 | :user "default" 50 | :password "" 51 | :ssl false 52 | :use_server_time_zone_for_dates true 53 | :product_name "metabase/1.53.4" 54 | :jdbc_ignore_unsupported_values "true" 55 | :jdbc_schema_term "schema", 56 | :max_open_connections 100 57 | :remember_last_set_roles true 58 | :http_connection_provider "HTTP_URL_CONNECTION" 59 | :custom_http_params ""}) 60 | 61 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Boolean] [_ _] "Boolean") 62 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/BigInteger] [_ _] "Int64") 63 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Char] [_ _] "String") 64 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Date] [_ _] "Date") 65 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/DateTime] [_ _] "DateTime64(3, 'GMT0')") 66 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/DateTimeWithLocalTZ] [_ _] "DateTime64(3, 'UTC')") 67 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Float] [_ _] "Float64") 68 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Integer] [_ _] "Int32") 69 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/IPAddress] [_ _] "IPv4") 70 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Text] [_ _] "String") 71 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/UUID] [_ _] "UUID") 72 | (defmethod sql.tx/field-base-type->sql-type [:clickhouse :type/Time] [_ _] "Time") 73 | 74 | (defmethod tx/sorts-nil-first? :clickhouse [_ _] false) 75 | 76 | (defmethod tx/dbdef->connection-details :clickhouse [_ context {:keys [database-name]}] 77 | (merge 78 | {:host (tx/db-test-env-var-or-throw :clickhouse :host "localhost") 79 | :port (tx/db-test-env-var-or-throw :clickhouse :port 8123) 80 | :timezone :America/Los_Angeles} 81 | (when-let [user (tx/db-test-env-var :clickhouse :user)] 82 | {:user user}) 83 | (when-let [password (tx/db-test-env-var :clickhouse :password)] 84 | {:password password}) 85 | (when (= context :db) 86 | {:db database-name}))) 87 | 88 | (defmethod sql.tx/qualified-name-components :clickhouse 89 | ([_ db-name] [db-name]) 90 | ([_ db-name table-name] [db-name table-name]) 91 | ([_ db-name table-name field-name] [db-name table-name field-name])) 92 | 93 | (defmethod tx/create-db! :clickhouse 94 | [driver {:keys [database-name], :as db-def} & options] 95 | (let [database-name (ddl.i/format-name driver database-name)] 96 | (log/infof "Creating ClickHouse database %s" (pr-str database-name)) 97 | ;; call the default impl for SQL JDBC drivers 98 | (apply (get-method tx/create-db! :sql-jdbc/test-extensions) driver db-def options))) 99 | 100 | (defmethod ddl/insert-rows-dml-statements :clickhouse 101 | [driver table-identifier rows] 102 | (binding [driver/*compile-with-inline-parameters* true] 103 | ((get-method ddl/insert-rows-dml-statements :sql-jdbc/test-extensions) driver table-identifier rows))) 104 | 105 | (mu/defmethod load-data/do-insert! :clickhouse 106 | [driver :- :keyword 107 | ^java.sql.Connection conn :- (lib.schema.common/instance-of-class java.sql.Connection) 108 | table-identifier 109 | rows] 110 | (let [statements (ddl/insert-rows-dml-statements driver table-identifier rows)] 111 | (doseq [sql-args statements 112 | :let [sql-args (if (string? sql-args) 113 | [sql-args] 114 | sql-args)]] 115 | (assert (string? (first sql-args)) 116 | (format "Bad sql-args: %s" (pr-str sql-args))) 117 | (log/tracef "[insert] %s" (pr-str sql-args)) 118 | (try 119 | (jdbc/execute! {:connection conn :transaction? false} 120 | sql-args 121 | {:set-parameters (fn [stmt params] 122 | (sql-jdbc.execute/set-parameters! driver stmt params))}) 123 | (catch Throwable e 124 | (throw (ex-info (format "INSERT FAILED: %s" (ex-message e)) 125 | {:driver driver 126 | :sql-args (into [(str/split-lines (mdb.query/format-sql (first sql-args)))] 127 | (rest sql-args))} 128 | e))))))) 129 | 130 | (defn- quote-name 131 | [name] 132 | (sql.u/quote-name :clickhouse :field (ddl.i/format-name :clickhouse name))) 133 | 134 | (def ^:private non-nullable-types ["Array" "Map" "Tuple"]) 135 | (defn- disallowed-as-nullable? 136 | [ch-type] 137 | (boolean (some #(str/starts-with? ch-type %) non-nullable-types))) 138 | 139 | (defn- field->clickhouse-column 140 | [field] 141 | (let [{:keys [field-name base-type pk?]} field 142 | ch-type (if (map? base-type) 143 | (:native base-type) 144 | (sql.tx/field-base-type->sql-type :clickhouse base-type)) 145 | col-name (quote-name field-name) 146 | ch-col (cond 147 | (or pk? (disallowed-as-nullable? ch-type)) 148 | (format "%s %s" col-name ch-type) 149 | (= ch-type "Time") 150 | (format "%s Nullable(DateTime64) COMMENT 'time'" col-name) 151 | ; _ 152 | :else (format "%s Nullable(%s)" col-name ch-type))] 153 | ch-col)) 154 | 155 | (defn- ->comma-separated-str 156 | [coll] 157 | (->> coll 158 | (interpose ", ") 159 | (apply str))) 160 | 161 | (defmethod sql.tx/create-table-sql :clickhouse 162 | [_ {:keys [database-name]} {:keys [table-name field-definitions]}] 163 | (let [table-name (sql.tx/qualify-and-quote :clickhouse database-name table-name) 164 | pk-fields (filter (fn [{:keys [pk?]}] pk?) field-definitions) 165 | pk-field-names (map #(quote-name (:field-name %)) pk-fields) 166 | fields (->> field-definitions 167 | (map field->clickhouse-column) 168 | (->comma-separated-str)) 169 | order-by (->comma-separated-str pk-field-names)] 170 | (format "CREATE TABLE %s (%s) 171 | ENGINE = MergeTree 172 | ORDER BY (%s) 173 | SETTINGS allow_nullable_key=1" 174 | table-name fields order-by))) 175 | 176 | (defmethod execute/execute-sql! :clickhouse [& args] 177 | (apply execute/sequentially-execute-sql! args)) 178 | 179 | (defmethod load-data/row-xform :clickhouse [_driver _dbdef tabledef] 180 | (load-data/maybe-add-ids-xform tabledef)) 181 | 182 | (defmethod sql.tx/pk-sql-type :clickhouse [_] "Int32") 183 | 184 | (defmethod sql.tx/add-fk-sql :clickhouse [& _] nil) 185 | 186 | (defmethod sql.tx/session-schema :clickhouse [_] "default") 187 | 188 | (defn rows-without-index 189 | "Remove the Metabase index which is the first column in the result set" 190 | [query-result] 191 | (map #(drop 1 %) (qp.test/rows query-result))) 192 | 193 | (def ^:private test-db-initialized? (atom false)) 194 | (defn create-test-db! 195 | "Create a ClickHouse database called `metabase_test` and initialize some test data" 196 | [f] 197 | (when (not @test-db-initialized?) 198 | (let [details (tx/dbdef->connection-details :clickhouse :db {:database-name "metabase_test"})] 199 | ;; (println "### Executing create-test-db! with details:" details) 200 | (jdbc/with-db-connection 201 | [spec (sql-jdbc.conn/connection-details->spec :clickhouse (merge {:engine :clickhouse} details))] 202 | (let [raw-statements (slurp (io/resource "metabase/test/data/clickhouse_datasets.sql")) 203 | statements (as-> raw-statements s 204 | (str/split s #";") 205 | (map str/trim s) 206 | (filter seq s))] 207 | ;; (println "## Executing statements " statements) 208 | (jdbc/db-do-commands spec false statements) 209 | (reset! test-db-initialized? true))) 210 | ;; (println "### Done with executing create-test-db! with details:" details) 211 | )) 212 | (f)) 213 | 214 | #_{:clj-kondo/ignore [:warn-on-reflection]} 215 | (defn exec-statements 216 | ([statements details-map] 217 | (exec-statements statements details-map nil)) 218 | ([statements details-map clickhouse-settings] 219 | (sql-jdbc.execute/do-with-connection-with-options 220 | :clickhouse 221 | (sql-jdbc.conn/connection-details->spec :clickhouse (merge {:engine :clickhouse} details-map)) 222 | {:write? true} 223 | (fn [^java.sql.Connection conn] 224 | (doseq [statement statements] 225 | ;; (println "Executing:" statement) 226 | (let [^com.clickhouse.jdbc.ConnectionImpl clickhouse-conn (.unwrap conn com.clickhouse.jdbc.ConnectionImpl) 227 | query-settings (new com.clickhouse.client.api.query.QuerySettings)] 228 | (with-open [jdbcStmt (.createStatement conn)] 229 | (when clickhouse-settings 230 | (doseq [[k v] clickhouse-settings] (.setOption query-settings k v))) 231 | (.setDefaultQuerySettings clickhouse-conn query-settings) 232 | (.execute jdbcStmt statement)))))))) 233 | 234 | (defn do-with-test-db 235 | "Execute a test function using the test dataset" 236 | [f] 237 | (t2.with-temp/with-temp 238 | [:model/Database database 239 | {:engine :clickhouse 240 | :details (tx/dbdef->connection-details :clickhouse :db {:database-name "metabase_test"})}] 241 | (sync/sync-db-metadata! database) 242 | (f database))) 243 | 244 | (defmethod tx/dataset-already-loaded? :clickhouse 245 | [driver dbdef] 246 | (let [tabledef (first (:table-definitions dbdef)) 247 | db-name (ddl.i/format-name :clickhouse (:database-name dbdef)) 248 | table-name (ddl.i/format-name :clickhouse (:table-name tabledef)) 249 | details (tx/dbdef->connection-details :clickhouse :db {:database-name db-name})] 250 | (sql-jdbc.execute/do-with-connection-with-options 251 | driver 252 | (sql-jdbc.conn/connection-details->spec driver details) 253 | {:write? false} 254 | (fn [^java.sql.Connection conn] 255 | (with-open [rset (.getTables (.getMetaData conn) 256 | #_catalog nil 257 | #_schema-pattern db-name 258 | #_table-pattern table-name 259 | #_types (into-array String ["TABLE"]))] 260 | ;; if the ResultSet returns anything we know the table is already loaded. 261 | (.next rset)))))) 262 | -------------------------------------------------------------------------------- /src/metabase/driver/clickhouse.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse 2 | "Driver for ClickHouse databases" 3 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 4 | (:require [clojure.core.memoize :as memoize] 5 | [clojure.string :as str] 6 | [metabase.config :as config] 7 | [metabase.driver :as driver] 8 | [metabase.driver.clickhouse-introspection] 9 | [metabase.driver.clickhouse-nippy] 10 | [metabase.driver.clickhouse-qp] 11 | [metabase.driver.clickhouse-version :as clickhouse-version] 12 | [metabase.driver.ddl.interface :as ddl.i] 13 | [metabase.driver.sql :as driver.sql] 14 | [metabase.driver.sql-jdbc :as sql-jdbc] 15 | [metabase.driver.sql-jdbc.common :as sql-jdbc.common] 16 | [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] 17 | [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] 18 | [metabase.driver.sql.util :as sql.u] 19 | [metabase.lib.metadata :as lib.metadata] 20 | [metabase.query-processor.store :as qp.store] 21 | [metabase.upload :as upload] 22 | [metabase.util :as u] 23 | [metabase.util.log :as log]) 24 | (:import [com.clickhouse.client.api.query QuerySettings])) 25 | 26 | (set! *warn-on-reflection* true) 27 | 28 | (System/setProperty "clickhouse.jdbc.v2" "true") 29 | (driver/register! :clickhouse :parent #{:sql-jdbc}) 30 | 31 | (defmethod driver/display-name :clickhouse [_] "ClickHouse") 32 | (def ^:private product-name "metabase/1.53.4") 33 | 34 | (defmethod driver/prettify-native-form :clickhouse 35 | [_ native-form] 36 | (sql.u/format-sql-and-fix-params :mysql native-form)) 37 | 38 | (doseq [[feature supported?] {:standard-deviation-aggregations true 39 | :now true 40 | :set-timezone true 41 | :convert-timezone false 42 | :test/jvm-timezone-setting false 43 | :test/date-time-type false 44 | :test/time-type false 45 | :schemas true 46 | :datetime-diff true 47 | :upload-with-auto-pk false 48 | :window-functions/offset false 49 | :window-functions/cumulative (not config/is-test?) 50 | :left-join (not config/is-test?) 51 | :describe-fks false 52 | :actions false 53 | :metadata/key-constraints (not config/is-test?)}] 54 | (defmethod driver/database-supports? [:clickhouse feature] [_driver _feature _db] supported?)) 55 | 56 | (def ^:private default-connection-details 57 | {:user "default" :password "" :dbname "default" :host "localhost" :port "8123"}) 58 | 59 | (defn- connection-details->spec* [details] 60 | (let [;; ensure defaults merge on top of nils 61 | details (reduce-kv (fn [m k v] (assoc m k (or v (k default-connection-details)))) 62 | default-connection-details 63 | details) 64 | {:keys [user password dbname host port ssl clickhouse-settings max-open-connections]} details 65 | ;; if multiple databases were specified for the connection, 66 | ;; use only the first dbname as the "main" one 67 | dbname (first (str/split (str/trim dbname) #" ")) 68 | host (cond ; JDBCv1 used to accept schema in the `host` configuration option 69 | (str/starts-with? host "http://") (subs host 7) 70 | (str/starts-with? host "https://") (subs host 8) 71 | :else host)] 72 | (-> 73 | {:classname "com.clickhouse.jdbc.ClickHouseDriver" 74 | :subprotocol "clickhouse" 75 | :subname (str "//" host ":" port "/" dbname) 76 | :password (or password "") 77 | :user user 78 | :ssl (boolean ssl) 79 | :use_server_time_zone_for_dates true 80 | :product_name product-name 81 | :remember_last_set_roles true 82 | :http_connection_provider "HTTP_URL_CONNECTION" 83 | :jdbc_ignore_unsupported_values "true" 84 | :jdbc_schema_term "schema" 85 | :max_open_connections (or max-open-connections 100) 86 | ;; see also: https://clickhouse.com/docs/en/integrations/java#configuration 87 | :custom_http_params (or clickhouse-settings "")} 88 | (sql-jdbc.common/handle-additional-options details :separator-style :url)))) 89 | 90 | (defmethod sql-jdbc.execute/do-with-connection-with-options :clickhouse 91 | [driver db-or-id-or-spec {:keys [^String session-timezone _write?] :as options} f] 92 | (sql-jdbc.execute/do-with-resolved-connection 93 | driver 94 | db-or-id-or-spec 95 | options 96 | (fn [^java.sql.Connection conn] 97 | (when-not (sql-jdbc.execute/recursive-connection?) 98 | (when session-timezone 99 | (let [^com.clickhouse.jdbc.ConnectionImpl clickhouse-conn (.unwrap conn com.clickhouse.jdbc.ConnectionImpl) 100 | query-settings (new QuerySettings)] 101 | (.setOption query-settings "session_timezone" session-timezone) 102 | (.setDefaultQuerySettings clickhouse-conn query-settings))) 103 | (sql-jdbc.execute/set-best-transaction-level! driver conn) 104 | (sql-jdbc.execute/set-time-zone-if-supported! driver conn session-timezone) 105 | (when-let [db (cond 106 | ;; id? 107 | (integer? db-or-id-or-spec) 108 | (qp.store/with-metadata-provider db-or-id-or-spec 109 | (lib.metadata/database (qp.store/metadata-provider))) 110 | ;; db? 111 | (u/id db-or-id-or-spec) db-or-id-or-spec 112 | ;; otherwise it's a spec and we can't get the db 113 | :else nil)] 114 | (sql-jdbc.execute/set-role-if-supported! driver conn db))) 115 | (f conn)))) 116 | 117 | (def ^:private ^{:arglists '([db-details])} cloud? 118 | "Returns true if the `db-details` are for a ClickHouse Cloud instance, and false otherwise. If it fails to connect 119 | to the database, it throws a java.sql.SQLException." 120 | (memoize/ttl 121 | (fn [db-details] 122 | (let [spec (connection-details->spec* db-details)] 123 | (sql-jdbc.execute/do-with-connection-with-options 124 | :clickhouse spec nil 125 | (fn [^java.sql.Connection conn] 126 | (with-open [stmt (.createStatement conn) 127 | rset (.executeQuery stmt "SELECT value='1' FROM system.settings WHERE name='cloud_mode'")] 128 | (if (.next rset) (.getBoolean rset 1) false)))))) 129 | ;; cache the results for 48 hours; TTL is here only to eventually clear out old entries 130 | :ttl/threshold (* 48 60 60 1000))) 131 | 132 | (defmethod sql-jdbc.conn/connection-details->spec :clickhouse 133 | [_ details] 134 | (cond-> (connection-details->spec* details) 135 | (try (cloud? details) 136 | (catch java.sql.SQLException _e 137 | false)) 138 | ;; select_sequential_consistency guarantees that we can query data from any replica in CH Cloud 139 | ;; immediately after it is written 140 | (assoc :select_sequential_consistency true))) 141 | 142 | (defmethod driver/database-supports? [:clickhouse :uploads] [_driver _feature db] 143 | (if (:details db) 144 | (try (cloud? (:details db)) 145 | (catch java.sql.SQLException _e 146 | false)) 147 | false)) 148 | 149 | (defmethod driver/can-connect? :clickhouse 150 | [driver details] 151 | (if config/is-test? 152 | (try 153 | ;; Default SELECT 1 is not enough for Metabase test suite, 154 | ;; as it works slightly differently than expected there 155 | (let [spec (sql-jdbc.conn/connection-details->spec driver details) 156 | db (ddl.i/format-name driver (or (:dbname details) (:db details) "default"))] 157 | (sql-jdbc.execute/do-with-connection-with-options 158 | driver spec nil 159 | (fn [^java.sql.Connection conn] 160 | (let [stmt (.prepareStatement conn "SELECT count(*) > 0 FROM system.databases WHERE name = ?") 161 | _ (.setString stmt 1 db) 162 | rset (.executeQuery stmt)] 163 | (when (.next rset) 164 | (.getBoolean rset 1)))))) 165 | (catch Throwable e 166 | (log/error e "An exception during ClickHouse connectivity check") 167 | false)) 168 | ;; During normal usage, fall back to the default implementation 169 | (sql-jdbc.conn/can-connect? driver details))) 170 | 171 | (defmethod driver/db-default-timezone :clickhouse 172 | [driver database] 173 | (sql-jdbc.execute/do-with-connection-with-options 174 | driver database nil 175 | (fn [^java.sql.Connection conn] 176 | (with-open [stmt (.createStatement conn) 177 | rset (.executeQuery stmt "SELECT timezone() AS tz")] 178 | (when (.next rset) 179 | (.getString rset 1)))))) 180 | 181 | (defmethod driver/db-start-of-week :clickhouse [_] :monday) 182 | 183 | (defmethod ddl.i/format-name :clickhouse 184 | [_ table-or-field-name] 185 | (when table-or-field-name 186 | (str/replace table-or-field-name #"-" "_"))) 187 | 188 | ;;; ------------------------------------------ Connection Impersonation ------------------------------------------ 189 | 190 | (defmethod driver/upload-type->database-type :clickhouse 191 | [_driver upload-type] 192 | (case upload-type 193 | ::upload/varchar-255 "Nullable(String)" 194 | ::upload/text "Nullable(String)" 195 | ::upload/int "Nullable(Int64)" 196 | ::upload/float "Nullable(Float64)" 197 | ::upload/boolean "Nullable(Boolean)" 198 | ::upload/date "Nullable(Date32)" 199 | ::upload/datetime "Nullable(DateTime64(3))" 200 | ::upload/offset-datetime nil)) 201 | 202 | (defmethod driver/table-name-length-limit :clickhouse 203 | [_driver] 204 | ;; FIXME: This is a lie because you're really limited by a filesystems' limits, because Clickhouse uses 205 | ;; filenames as table/column names. But its an approximation 206 | 206) 207 | 208 | (defn- quote-name [s] 209 | (let [parts (str/split (name s) #"\.")] 210 | (str/join "." (map #(str "`" % "`") parts)))) 211 | 212 | (defn- create-table!-sql 213 | "Creates a ClickHouse table with the given name and column definitions. It assumes the engine is MergeTree, 214 | so it only works with Clickhouse Cloud and single node on-premise deployments at the moment." 215 | [_driver table-name column-definitions & {:keys [primary-key] :as opts}] 216 | (str/join "\n" 217 | [(#'sql-jdbc/create-table!-sql :sql-jdbc table-name column-definitions opts) 218 | "ENGINE = MergeTree" 219 | (format "ORDER BY (%s)" (str/join ", " (map quote-name primary-key))) 220 | ;; disable insert idempotency to allow duplicate inserts 221 | "SETTINGS replicated_deduplication_window = 0"])) 222 | 223 | (defmethod driver/create-table! :clickhouse 224 | [driver db-id table-name column-definitions & {:keys [primary-key]}] 225 | (sql-jdbc.execute/do-with-connection-with-options 226 | driver 227 | db-id 228 | {:write? true} 229 | (fn [^java.sql.Connection conn] 230 | (with-open [stmt (.createStatement conn)] 231 | (.execute stmt (create-table!-sql driver table-name column-definitions :primary-key primary-key)))))) 232 | 233 | (defmethod driver/insert-into! :clickhouse 234 | [driver db-id table-name column-names values] 235 | (when (seq values) 236 | (sql-jdbc.execute/do-with-connection-with-options 237 | driver 238 | db-id 239 | {:write? true} 240 | (fn [^java.sql.Connection conn] 241 | (let [sql (format "INSERT INTO %s (%s) VALUES (%s)" 242 | (quote-name table-name) 243 | (str/join ", " (map quote-name column-names)) 244 | (str/join ", " (repeat (count column-names) "?")))] 245 | (with-open [ps (.prepareStatement conn sql)] 246 | (doseq [row values] 247 | (when (seq row) 248 | (doseq [[idx v] (map-indexed (fn [x y] [(inc x) y]) row)] 249 | (condp isa? (type v) 250 | nil (.setString ps idx nil) 251 | java.lang.String (.setString ps idx v) 252 | java.lang.Boolean (.setBoolean ps idx v) 253 | java.lang.Long (.setLong ps idx v) 254 | java.lang.Double (.setFloat ps idx v) 255 | java.math.BigInteger (.setObject ps idx v) 256 | java.time.LocalDate (.setObject ps idx v) 257 | java.time.LocalDateTime (.setObject ps idx v) 258 | java.time.OffsetDateTime (.setObject ps idx v) 259 | (.setString ps idx (str v)))) 260 | (.addBatch ps))) 261 | (doall (.executeBatch ps)))))))) 262 | 263 | ;;; ------------------------------------------ User Impersonation ------------------------------------------ 264 | 265 | (defmethod driver/database-supports? [:clickhouse :connection-impersonation] 266 | [_driver _feature db] 267 | (if db 268 | (try (clickhouse-version/is-at-least? 24 4 db) 269 | (catch Throwable _e 270 | false)) 271 | false)) 272 | 273 | (defmethod driver.sql/set-role-statement :clickhouse 274 | [_ role] 275 | (let [default-role (driver.sql/default-database-role :clickhouse nil) 276 | quote-if-needed (fn [r] 277 | (if (or (re-matches #"\".*\"" r) (= role default-role)) 278 | r 279 | (format "\"%s\"" r))) 280 | quoted-role (->> (str/split role #",") 281 | (map quote-if-needed) 282 | (str/join ",")) 283 | statement (format "SET ROLE %s" quoted-role)] 284 | statement)) 285 | 286 | (defmethod driver.sql/default-database-role :clickhouse 287 | [_ _] 288 | "NONE") 289 | -------------------------------------------------------------------------------- /test/metabase/driver/clickhouse_substitution_test.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-substitution-test 2 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 3 | (:require [clojure.test :refer :all] 4 | [java-time.api :as t] 5 | [metabase.query-processor :as qp] 6 | [metabase.test :as mt] 7 | [metabase.test.data :as data] 8 | [metabase.test.data.interface :as tx] 9 | [metabase.test.data.clickhouse :as ctd] 10 | [metabase.util :as u] 11 | [schema.core :as s]) 12 | (:import (java.time LocalDate LocalDateTime))) 13 | 14 | (set! *warn-on-reflection* true) 15 | 16 | (defn- get-mbql 17 | [value db] 18 | (let [uuid (str (java.util.UUID/randomUUID))] 19 | {:database (mt/id) 20 | :type "native" 21 | :native {:collection "test-table" 22 | :template-tags 23 | {:x {:id uuid 24 | :name "d" 25 | :display-name "D" 26 | :type "dimension" 27 | :dimension ["field" (mt/id :test-table :d) nil] 28 | :required true}} 29 | :query (format "SELECT * FROM `%s`.`test_table` WHERE {{x}}" db)} 30 | :parameters [{:type "date/all-options" 31 | :value value 32 | :target ["dimension" ["template-tag" "x"]] 33 | :id uuid}]})) 34 | 35 | (def ^:private clock (t/mock-clock (t/instant "2019-11-30T23:00:00Z") (t/zone-id "UTC"))) 36 | (s/defn ^:private local-date-now :- LocalDate [] (LocalDate/now clock)) 37 | (s/defn ^:private local-date-time-now :- LocalDateTime [] (LocalDateTime/now clock)) 38 | 39 | (deftest ^:parallel clickhouse-variables-field-filters-datetime-and-datetime64 40 | (mt/test-driver 41 | :clickhouse 42 | (mt/with-clock clock 43 | (letfn 44 | [(->clickhouse-input 45 | [^LocalDateTime ldt] 46 | [(t/format "yyyy-MM-dd HH:mm:ss" ldt)]) 47 | (get-test-table 48 | [rows native-type] 49 | ["test_table" 50 | [{:field-name "d" 51 | :base-type {:native native-type}}] 52 | (map ->clickhouse-input rows)]) 53 | (->iso-str 54 | [^LocalDateTime ldt] 55 | (t/format "yyyy-MM-dd'T'HH:mm:ss'Z'" ldt))] 56 | (doseq [base-type ["DateTime" "DateTime64"]] 57 | (testing base-type 58 | (testing "on specific" 59 | (let [db (format "mb_vars_on_x_%s" 60 | (u/lower-case-en base-type)) 61 | now (local-date-time-now) 62 | row1 (.minusHours now 14) 63 | row2 (.minusMinutes now 20) 64 | row3 (.plusMinutes now 5) 65 | row4 (.plusHours now 6) 66 | table (get-test-table [row1 row2 row3 row4] base-type)] 67 | (data/dataset 68 | (tx/dataset-definition db table) 69 | (testing "date" 70 | (is (= [[(->iso-str row1)] [(->iso-str row2)] [(->iso-str row3)]] 71 | (ctd/rows-without-index (qp/process-query (get-mbql "2019-11-30" db)))))) 72 | (testing "datetime" 73 | (is (= [[(->iso-str row2)]] 74 | (ctd/rows-without-index (qp/process-query (get-mbql "2019-11-30T22:40:00" db))))))))) 75 | (testing "past/next minutes" 76 | (let [db (format "mb_vars_m_%s" 77 | (u/lower-case-en base-type)) 78 | now (local-date-time-now) 79 | row1 (.minusHours now 14) 80 | row2 (.minusMinutes now 20) 81 | row3 (.plusMinutes now 5) 82 | row4 (.plusHours now 6) 83 | table (get-test-table [row1 row2 row3 row4] base-type)] 84 | (data/dataset 85 | (tx/dataset-definition db table) 86 | (testing "past30minutes" 87 | (is (= [[(->iso-str row2)]] 88 | (ctd/rows-without-index (qp/process-query (get-mbql "past30minutes" db)))))) 89 | (testing "next30minutes" 90 | (is (= [[(->iso-str row3)]] 91 | (ctd/rows-without-index (qp/process-query (get-mbql "next30minutes" db))))))))) 92 | (testing "past/next hours" 93 | (let [db (format "mb_vars__past_next_hours_%s" 94 | (u/lower-case-en base-type)) 95 | now (local-date-time-now) 96 | row1 (.minusHours now 14) 97 | row2 (.minusHours now 2) 98 | row3 (.plusHours now 25) 99 | row4 (.plusHours now 6) 100 | table (get-test-table [row1 row2 row3 row4] base-type)] 101 | (data/dataset 102 | (tx/dataset-definition db table) 103 | (testing "past12hours" 104 | (is (= [[(->iso-str row2)]] 105 | (ctd/rows-without-index (qp/process-query (get-mbql "past12hours" db)))))) 106 | (testing "next12hours" 107 | (is (= [[(->iso-str row4)]] 108 | (ctd/rows-without-index (qp/process-query (get-mbql "next12hours" db))))))))) 109 | (testing "past/next days" 110 | (let [db (format "mb_vars_d_%s" 111 | (u/lower-case-en base-type)) 112 | now (local-date-time-now) 113 | row1 (.minusDays now 14) 114 | row2 (.minusDays now 2) 115 | row3 (.plusDays now 25) 116 | row4 (.plusDays now 6) 117 | table (get-test-table [row1 row2 row3 row4] base-type)] 118 | (data/dataset 119 | (tx/dataset-definition db table) 120 | (testing "past12days" 121 | (is (= [[(->iso-str row2)]] 122 | (ctd/rows-without-index (qp/process-query (get-mbql "past12days" db)))))) 123 | (testing "next12days" 124 | (is (= [[(->iso-str row4)]] 125 | (ctd/rows-without-index (qp/process-query (get-mbql "next12days" db))))))))) 126 | (testing "past/next months/quarters" 127 | (let [db (format "mb_vars_m_q_%s" 128 | (u/lower-case-en base-type)) 129 | now (local-date-time-now) 130 | row1 (.minusMonths now 14) 131 | row2 (.minusMonths now 4) 132 | row3 (.plusMonths now 25) 133 | row4 (.plusMonths now 6) 134 | table (get-test-table [row1 row2 row3 row4] base-type)] 135 | (data/dataset 136 | (tx/dataset-definition db table) 137 | (testing "past12months" 138 | (is (= [[(->iso-str row2)]] 139 | (ctd/rows-without-index (qp/process-query (get-mbql "past12months" db)))))) 140 | (testing "next12months" 141 | (is (= [[(->iso-str row4)]] 142 | (ctd/rows-without-index (qp/process-query (get-mbql "next12months" db)))))) 143 | (testing "past3quarters" 144 | (is (= [[(->iso-str row2)]] 145 | (ctd/rows-without-index (qp/process-query (get-mbql "past3quarters" db)))))) 146 | (testing "next3quarters" 147 | (is (= [[(->iso-str row4)]] 148 | (ctd/rows-without-index (qp/process-query (get-mbql "next3quarters" db))))))))) 149 | (testing "past/next years" 150 | (let [db (format "mb_vars_y_%s" 151 | (u/lower-case-en base-type)) 152 | now (local-date-time-now) 153 | row1 (.minusYears now 14) 154 | row2 (.minusYears now 4) 155 | row3 (.plusYears now 25) 156 | row4 (.plusYears now 6) 157 | table (get-test-table [row1 row2 row3 row4] base-type)] 158 | (data/dataset 159 | (tx/dataset-definition db table) 160 | (testing "past12years" 161 | (is (= [[(->iso-str row2)]] 162 | (ctd/rows-without-index (qp/process-query (get-mbql "past12years" db)))))) 163 | (testing "next12years" 164 | (is (= [[(->iso-str row4)]] 165 | (ctd/rows-without-index (qp/process-query (get-mbql "next12years" db))))))))))))))) 166 | 167 | (deftest ^:parallel clickhouse-variables-field-filters-date-and-date32 168 | (mt/test-driver 169 | :clickhouse 170 | (mt/with-clock clock 171 | (letfn 172 | [(->clickhouse-input 173 | [^LocalDate ld] 174 | [(t/format "yyyy-MM-dd" ld)]) 175 | (get-test-table 176 | [rows native-type] 177 | ["test_table" 178 | [{:field-name "d" 179 | :base-type {:native native-type}}] 180 | (map ->clickhouse-input rows)]) 181 | (->iso-str 182 | [^LocalDate ld] 183 | (str (t/format "yyyy-MM-dd" ld) "T00:00:00Z"))] 184 | (doseq [base-type ["Date" "Date32"]] 185 | (testing base-type 186 | (testing "on specific date" 187 | (let [db (format "mb_vars_on_x_%s" 188 | (u/lower-case-en base-type)) 189 | now (local-date-time-now) 190 | row1 (.minusDays now 14) 191 | row2 now 192 | row3 (.plusDays now 25) 193 | row4 (.plusDays now 6) 194 | table (get-test-table [row1 row2 row3 row4] base-type)] 195 | (data/dataset 196 | (tx/dataset-definition db table) 197 | (is (= [[(->iso-str row2)]] 198 | (ctd/rows-without-index (qp/process-query (get-mbql "2019-11-30" db)))))))) 199 | (testing "past/next days" 200 | (let [db (format "mb_vars_d_%s" 201 | (u/lower-case-en base-type)) 202 | now (local-date-now) 203 | row1 (.minusDays now 14) 204 | row2 (.minusDays now 2) 205 | row3 (.plusDays now 25) 206 | row4 (.plusDays now 6) 207 | table (get-test-table [row1 row2 row3 row4] base-type)] 208 | (data/dataset 209 | (tx/dataset-definition db table) 210 | (testing "past12days" 211 | (is (= [[(->iso-str row2)]] 212 | (ctd/rows-without-index (qp/process-query (get-mbql "past12days" db)))))) 213 | (testing "next12days" 214 | (is (= [[(->iso-str row4)]] 215 | (ctd/rows-without-index (qp/process-query (get-mbql "next12days" db))))))))) 216 | (testing "past/next months/quarters" 217 | (let [db (format "mb_vars_m_q_%s" 218 | (u/lower-case-en base-type)) 219 | now (local-date-now) 220 | row1 (.minusMonths now 14) 221 | row2 (.minusMonths now 4) 222 | row3 (.plusMonths now 25) 223 | row4 (.plusMonths now 6) 224 | table (get-test-table [row1 row2 row3 row4] base-type)] 225 | (data/dataset 226 | (tx/dataset-definition db table) 227 | (testing "past12months" 228 | (is (= [[(->iso-str row2)]] 229 | (ctd/rows-without-index (qp/process-query (get-mbql "past12months" db)))))) 230 | (testing "next12months" 231 | (is (= [[(->iso-str row4)]] 232 | (ctd/rows-without-index (qp/process-query (get-mbql "next12months" db)))))) 233 | (testing "past3quarters" 234 | (is (= [[(->iso-str row2)]] 235 | (ctd/rows-without-index (qp/process-query (get-mbql "past3quarters" db)))))) 236 | (testing "next3quarters" 237 | (is (= [[(->iso-str row4)]] 238 | (ctd/rows-without-index (qp/process-query (get-mbql "next3quarters" db))))))))) 239 | (testing "past/next years" 240 | (let [db (format "mb_vars_y_%s" 241 | (u/lower-case-en base-type)) 242 | now (local-date-now) 243 | row1 (.minusYears now 14) 244 | row2 (.minusYears now 4) 245 | row3 (.plusYears now 25) 246 | row4 (.plusYears now 6) 247 | table (get-test-table [row1 row2 row3 row4] base-type)] 248 | (data/dataset 249 | (tx/dataset-definition db table) 250 | (testing "past12years" 251 | (is (= [[(->iso-str row2)]] 252 | (ctd/rows-without-index (qp/process-query (get-mbql "past12years" db)))))) 253 | (testing "next12years" 254 | (is (= [[(->iso-str row4)]] 255 | (ctd/rows-without-index (qp/process-query (get-mbql "next12years" db))))))))))))))) 256 | 257 | (deftest ^:parallel clickhouse-variables-field-filters-null-dates 258 | (mt/test-driver 259 | :clickhouse 260 | (mt/with-clock clock 261 | (letfn 262 | [(->input-ld 263 | [^LocalDate ld] 264 | [(t/format "yyyy-MM-dd" ld)]) 265 | (->input-ldt 266 | [^LocalDateTime ldt] 267 | [(t/format "yyyy-MM-dd HH:mm:ss" ldt)]) 268 | (->iso-str-ld 269 | [^LocalDate ld] 270 | (str (t/format "yyyy-MM-dd" ld) "T00:00:00Z")) 271 | (->iso-str-ldt 272 | [^LocalDateTime ldt] 273 | (t/format "yyyy-MM-dd'T'HH:mm:ss'Z'" ldt))] 274 | (let [db "mb_vars_null_dates" 275 | now-ld (local-date-now) 276 | now-ldt (local-date-time-now) 277 | table ["test_table" 278 | [{:field-name "d" 279 | :base-type {:native "Date"}} 280 | {:field-name "d32" 281 | :base-type {:native "Date32"}} 282 | {:field-name "dt" 283 | :base-type {:native "DateTime"}} 284 | {:field-name "dt64" 285 | :base-type {:native "DateTime64"}}] 286 | [;; row 1 287 | [(->input-ld now-ld) nil (->input-ldt now-ldt) nil] 288 | ;; row 2 289 | [nil (->input-ld now-ld) nil (->input-ldt now-ldt)]]] 290 | first-row [[(->iso-str-ld now-ld) nil (->iso-str-ldt now-ldt) nil]] 291 | second-row [[nil (->iso-str-ld now-ld) nil (->iso-str-ldt now-ldt)]]] 292 | (data/dataset 293 | (tx/dataset-definition db table) 294 | (letfn 295 | [(get-mbql* 296 | [field value] 297 | (let [uuid (str (java.util.UUID/randomUUID))] 298 | {:database (mt/id) 299 | :type "native" 300 | :native {:collection "test-table" 301 | :template-tags 302 | {:x {:id uuid 303 | :name (str field) 304 | :display-name (str field) 305 | :type "dimension" 306 | :dimension ["field" (mt/id :test-table field) nil] 307 | :required true}} 308 | :query (format "SELECT * FROM `%s`.`test_table` WHERE {{x}}" db)} 309 | :parameters [{:type "date/all-options" 310 | :value value 311 | :target ["dimension" ["template-tag" "x"]] 312 | :id uuid}]}))] 313 | (testing "first row (Date field match)" 314 | (is (= first-row (ctd/rows-without-index (qp/process-query (get-mbql* :d "2019-11-30")))))) 315 | (testing "first row (DateTime field match)" 316 | (is (= first-row (ctd/rows-without-index (qp/process-query (get-mbql* :dt "2019-11-30T23:00:00")))))) 317 | (testing "second row (Date32 field match)" 318 | (is (= second-row (ctd/rows-without-index (qp/process-query (get-mbql* :d32 "2019-11-30")))))) 319 | (testing "second row (DateTime64 field match)" 320 | (is (= second-row (ctd/rows-without-index (qp/process-query (get-mbql* :dt64 "2019-11-30T23:00:00"))))))))))))) 321 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.53.4 2 | 3 | ### Improvements 4 | 5 | * The JDBC driver was updated to [0.8.4](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.8.4). This fixes [#309](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/309), [#300](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/300), [#297](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/297). 6 | 7 | # 1.53.3 8 | 9 | ### Improvements 10 | 11 | * If ClickHouse instance hostname was specified including `http://` or `https://` schema (e.g. `https://sub.example.com`), it will be automatically handled and removed by the driver, instead of failing with a connection error. 12 | * The JDBC driver was updated to [0.8.2](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.8.2) 13 | 14 | # 1.53.2 15 | 16 | ### Bug fixes 17 | 18 | * The JDBC driver was updated to [0.8.1](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.8.1) to fix errors in queries with CTEs ([#297](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/297), [#288](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/288), [tadeboro](https://github.com/tadeboro)). 19 | 20 | # 1.53.1 21 | 22 | ### Bug fixes 23 | 24 | * Fix unsigned integers overflow ([#293](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/293)) 25 | 26 | # 1.53.0 27 | 28 | ### Improvements 29 | 30 | * Adds Metabase 0.53.x support ([#287](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/287), [dpsutton](https://github.com/dpsutton)). 31 | 32 | ### Bug fixes 33 | 34 | * Fixed OOB exception on CSV insert caused by an incompatibility with JDBC v2 ([#286](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/286), [wotbrew](https://github.com/wotbrew)). 35 | 36 | # 1.52.0 37 | 38 | - Formal Metabase 0.52.x+ support 39 | - The driver now uses JDBC v2 ([0.8.0](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.8.0)) 40 | - Various improvements to handling of datetimes with timezones 41 | - `:convert-timezone` feature is disabled for now. 42 | - Added `max-open-connections` setting under "advanced options"; default is 100. 43 | 44 | # 1.51.0 45 | 46 | Adds Metabase 0.51.x support. 47 | 48 | # 1.50.7 49 | 50 | ### Improvements 51 | 52 | * Added a configuration field (under the "advanced options", hidden by default) to override certain ClickHouse settings ([#272](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/272)). 53 | 54 | # 1.50.6 55 | 56 | ### Bug fixes 57 | 58 | * Fixed null pointer exception on CSV insert ([#268](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/268), [crisptrutski](https://github.com/crisptrutski)). 59 | 60 | # 1.50.5 61 | 62 | ### Bug fixes 63 | 64 | * Fixed an error that could occur while setting roles containing hyphens for connection impersonation ([#266](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/266), [sharankow](https://github.com/sharankow)). 65 | 66 | # 1.50.4 67 | 68 | ### Bug fixes 69 | 70 | * Fixed an error while uploading a CSV with an offset datetime column ([#263](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/263), [crisptrutski](https://github.com/crisptrutski)). 71 | 72 | # 1.50.3 73 | 74 | ### Improvements 75 | 76 | * The driver no longer explicitly sets `allow_experimental_analyzer=0` settings on the connection level; the [new ClickHouse analyzer](https://clickhouse.com/docs/en/operations/analyzer) is now enabled by default. 77 | 78 | # 1.50.2 79 | 80 | ### Bug fixes 81 | 82 | * Fixed Array inner type introspection, which could cause reduced performance when querying tables containing arrays. ([#257](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/257)) 83 | 84 | # 1.50.1 85 | 86 | ### New features 87 | 88 | * Enabled `:set-timezone` ([docs](https://www.metabase.com/docs/latest/configuring-metabase/localization#report-timezone)) Metabase feature in the driver. ([#200](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/200)) 89 | * Enabled `:convert-timezone` ([docs](https://www.metabase.com/docs/latest/questions/query-builder/expressions/converttimezone)) Metabase feature in the driver. ([#254](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/254)) 90 | 91 | ### Other 92 | 93 | * The driver now uses [`session_timezone` ClickHouse setting](https://clickhouse.com/docs/en/operations/settings/settings#session_timezone). This is necessary to support the `:set-timezone` and `:convert-timezone` features; however, this setting [was introduced in 23.6](https://clickhouse.com/docs/en/whats-new/changelog/2023#236), which makes it the minimal required ClickHouse version to work with the driver. 94 | 95 | # 1.50.0 96 | 97 | After Metabase 0.50.0, a new naming convention exists for the driver's releases. The new one is intended to reflect the Metabase version the driver is supposed to run on. For example, the driver version 1.50.0 means that it should be used with Metabase v0.50.x or Metabase EE 1.50.x _only_, and it is _not guaranteed_ that this particular version of the driver can work with the previous or the following versions of Metabase. 98 | 99 | ### New features 100 | 101 | * Added Metabase 0.50.x support. 102 | 103 | ### Improvements 104 | 105 | * Bumped the JDBC driver to [0.6.1](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.6.1). 106 | 107 | ### Bug fixes 108 | 109 | * Fixed the issue where the connection impersonation feature support could be incorrectly reported as disabled. 110 | 111 | ### Other 112 | 113 | * The new ClickHouse analyzer, [which is enabled by default in 24.3+](https://clickhouse.com/blog/clickhouse-release-24-03#analyzer-enabled-by-default), is disabled for the queries executed by the driver, as it shows some compatibilities with the queries generated by Metabase (see [this issue](https://github.com/ClickHouse/ClickHouse/issues/64487) for more details). 114 | * The `:window-functions/offset` Metabase feature is currently disabled, as the default implementation generates queries incompatible with ClickHouse. See [this issue](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/245) for tracking. 115 | 116 | # 1.5.1 117 | 118 | Metabase 0.49.14+ only. 119 | 120 | ### Bug fixes 121 | 122 | * Fixed the issue where the Metabase instance could end up broken if the ClickHouse instance was _stopped_ during the upgrade to 1.5.0. ([#242](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/242)) 123 | * Fixed variables substitution with Nullable Date, Date32, DateTime, and DateTime64 columns, where the generated query could fail with NULL values in the database due to an incorrect cast call. ([#243](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/243)) 124 | 125 | # 1.5.0 126 | 127 | Metabase 0.49.14+ only. 128 | 129 | ### New features 130 | 131 | * Added [Metabase CSV Uploads feature](https://www.metabase.com/docs/latest/databases/uploads) support, which is currently enabled with ClickHouse Cloud only. On-premise deployments support will be added in the next release. ([calherries](https://github.com/calherries), [#236](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/236), [#238](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/238)) 132 | * Added [Metabase connection impersonation feature](https://www.metabase.com/learn/permissions/impersonation) support. This feature will be enabled by the driver only if ClickHouse version 24.4+ is detected. ([#219](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/219)) 133 | 134 | ### Improvements 135 | 136 | * Proper role setting support on cluster deployments (related issue: [#192](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/192)). 137 | * Bump the JDBC driver to [0.6.0-patch5](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.6.0-patch5). 138 | 139 | ### Bug fixes 140 | 141 | * Fixed missing data for the last day when using filters with DateTime columns. ([#202](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/202), [#229](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/229)) 142 | 143 | # 1.4.0 144 | 145 | ### New features 146 | * Metabase 0.49.x support. 147 | 148 | ### Bug fixes 149 | * Fixed an incorrect substitution for the current day filter with DateTime columns. ([#216](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/216)) 150 | 151 | # 1.3.4 152 | 153 | ### New features 154 | 155 | * If introspected ClickHouse version is lower than 23.8, the driver will not use [startsWithUTF8](https://clickhouse.com/docs/en/sql-reference/functions/string-functions#startswithutf8) and fall back to its [non-UTF8 counterpart](https://clickhouse.com/docs/en/sql-reference/functions/string-functions#startswith) instead. There is a drawback in this compatibility mode: potentially incorrect filtering results when working with non-latin strings. If your use case includes filtering by columns with such strings and you experience these issues, consider upgrading your ClickHouse server to 23.8+. ([#224](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/224)) 156 | 157 | # 1.3.3 158 | 159 | ### Bug fixes 160 | * Fixed an issue where it was not possible to create a connection with multiple databases using TLS. ([#215](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/215)) 161 | 162 | # 1.3.2 163 | 164 | ### Bug fixes 165 | * Remove `can-connect?` method override which could cause issues with editing or creating new connections. ([#212](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/212)) 166 | 167 | 168 | # 1.3.1 169 | 170 | ### Bug fixes 171 | * Fixed incorrect serialization of `Array(UInt8)` columns ([#209](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/209)) 172 | 173 | # 1.3.0 174 | 175 | ### New features 176 | * Metabase 0.48.x support 177 | 178 | ### Bug fixes 179 | * Fixed last/next minutes/hours filters with variables creating incorrect queries due to unnecessary `CAST col AS date` call. 180 | 181 | # 1.2.5 182 | 183 | Metabase 0.47.7+ only. 184 | 185 | ### New features 186 | * Added [datetimeDiff](https://www.metabase.com/docs/latest/questions/query-builder/expressions/datetimediff) function support ([#117](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/117)) 187 | 188 | # 1.2.4 189 | 190 | Metabase 0.47.7+ only. 191 | 192 | ### Bug fixes 193 | * Fixed UI question -> SQL conversion creating incorrect queries due to superfluous spaces in columns/tables/database names. 194 | 195 | # 1.2.3 196 | 197 | ### Bug fixes 198 | 199 | * Fixed `LowCardinality(Nullable)` types introspection, where it was incorrectly reported as `type/*` ([#203](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/203)) 200 | 201 | # 1.2.2 202 | 203 | ### Bug fixes 204 | * Removed forward slash from serialized IPv4/IPv6 columns. NB: IPv4/IPv6 columns are temporarily resolved as `type/TextLike` instead of `type/IPAddress` base type due to an unexpected result in Metabase 0.47 type check. 205 | * Removed superfluous CAST calls from generated queries that use Date* columns and/or intervals 206 | 207 | # 1.2.1 208 | ### New features 209 | * Use HoneySQL2 in the driver 210 | 211 | # 1.2.0 212 | 213 | ### New features 214 | * Metabase 0.47 support 215 | * Connection impersonation support (0.47 feature) 216 | 217 | ### Bug fixes 218 | * More correct general database type -> base type mapping 219 | * `DateTime64` is now correctly mapped to `:type/DateTime` 220 | * `database-required` field property is now correctly set to `true` if a field is not `Nullable` 221 | 222 | # 1.1.7 223 | 224 | ### New features 225 | 226 | * JDBC driver upgrade (v0.4.1 -> [v0.4.6](https://github.com/ClickHouse/clickhouse-java/releases/tag/v0.4.6)) 227 | * Support DateTime64 by [@lucas-tubi](https://github.com/lucas-tubi) ([#165](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/165)) 228 | * Use native `startsWith`/`endsWith` instead of `LIKE str%`/`LIKE %str` 229 | 230 | # 1.1.6 231 | 232 | ### Bug fixes 233 | 234 | * Fixed temporal bucketing issues (see [#155](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/155)) 235 | 236 | # 1.1.5 237 | 238 | ### Bug fixes 239 | 240 | * Fixed Nippy error on cached questions (see [#147](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/147)) 241 | 242 | # 1.1.4 243 | 244 | ### Bug fixes 245 | 246 | * Fixed `sum-where` behavior where previously it could not be applied to Int columns (see [#156](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/156)) 247 | 248 | # 1.1.3 249 | 250 | ### New features 251 | 252 | * Hide `.inner` tables of Materialized Views. 253 | * Resolve `Map` base type to `type/Dictionary`. 254 | * Database name can now contain multiple schemas in the UI field (space-separated by default), which tells the driver to scan selected databases. Separator can be set in `metabase.driver.clickhouse/SEPARATOR`. (@veschin) 255 | 256 | # 1.1.2 257 | 258 | ### Bug fixes 259 | 260 | * Now the driver is able to scan and work with `SimpleAggregateFunction` columns: those were excluded by mistake in 1.0.2. 261 | 262 | 263 | # 1.1.1 264 | 265 | ### New features 266 | 267 | * Metabase 0.46.x compatibility. 268 | * Added [cljc.java-time](https://clojars.org/com.widdindustries/cljc.java-time) to dependencies, as it is no longer loaded by Metabase. 269 | 270 | # 1.1.0 271 | 272 | ### New features 273 | 274 | * Update JDBC driver to v0.4.1. 275 | * Use new `product_name` additional option instead of `client_name` 276 | * Replace `sql-jdbc.execute/read-column [:clickhouse Types/ARRAY]` with `sql-jdbc.execute/read-column-thunk [:clickhouse Types/ARRAY]` to be compatible with Metabase 0.46 breaking changes once it is released. 277 | 278 | ### Bug fixes 279 | 280 | * Fix `sql-jdbc.execute/read-column-thunk [:clickhouse Types/TIME]` return type. 281 | 282 | # 1.0.4 283 | 284 | ### New features 285 | 286 | * Adds a new "Scan all databases" UI toggle (disabled by default), which tells the driver to scan all available databases (excluding `system` and `information_schema`) instead of only the database it is connected to. 287 | * Database input moved below host/port/username/password in the UI. 288 | 289 | # 1.0.3 290 | 291 | ### Bug fixes 292 | 293 | * Fixed NPE that could be thrown by the driver in case of empty database name input. 294 | 295 | # 1.0.2 296 | 297 | ### Bug fixes 298 | 299 | * As the underlying JDBC driver version does not support columns with `(Simple)AggregationFunction` type, these columns are now excluded from the table metadata and data browser result sets to prevent sync or data browsing errors. 300 | 301 | # 1.0.1 302 | 303 | ### Bug fixes 304 | 305 | * Boolean base type inference fix by [@s-huk](https://github.com/s-huk) (see [#134](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/134)) 306 | 307 | # 1.0.0 308 | 309 | Formal stable release milestone. 310 | 311 | ### New features 312 | 313 | * Added HTTP User-Agent (via clickhouse-jdbc `client_name` setting) with the plugin info according to the [language client spec](https://docs.google.com/document/d/1924Dvy79KXIhfqKpi1EBVY3133pIdoMwgCQtZ-uhEKs/edit#heading=h.ah33hoz5xei2) 314 | 315 | # 0.9.2 316 | 317 | ### New features 318 | 319 | * Allow to bypass system-wide proxy settings [#120](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/120) 320 | 321 | It's the first plugin release from the ClickHouse organization. 322 | 323 | From now on, the plugin is distributed under the Apache 2.0 License. 324 | 325 | # 0.9.1 326 | 327 | ### New features 328 | 329 | * Metabase 0.45.x compatibility [#107](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/107) 330 | * Added SSH tunnel option [#116](https://github.com/ClickHouse/metabase-clickhouse-driver/pull/116) 331 | 332 | # 0.9.0 333 | 334 | ### New features 335 | 336 | * Using https://github.com/ClickHouse/clickhouse-jdbc `v0.3.2-patch11` 337 | 338 | ### Bug fixes 339 | 340 | * URLs with underscores [#23](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/23) 341 | * `now()` timezones issues [#81](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/81) 342 | * Boolean errors [#88](https://github.com/ClickHouse/metabase-clickhouse-driver/issues/88) 343 | 344 | NB: there are messages like this in the Metabase logs 345 | 346 | ``` 347 | 2022-12-07 11:20:58,056 WARN internal.ClickHouseConnectionImpl :: [JDBC Compliant Mode] Transaction is not supported. You may change jdbcCompliant to false to throw SQLException instead. 348 | 2022-12-07 11:20:58,056 WARN internal.ClickHouseConnectionImpl :: [JDBC Compliant Mode] Transaction [ce0e121a-419a-4414-ac39-30f79eff7afd] (0 queries & 0 savepoints) is committed. 349 | ``` 350 | 351 | Unfortunately, this is the behaviour of the underlying JDBC driver now. 352 | 353 | Please consider raising the log level for `com.clickhouse.jdbc.internal.ClickHouseConnectionImpl` to `ERROR`. 354 | 355 | # 0.8.3 356 | 357 | ### New features 358 | 359 | * Enable additional options for ClickHouse connection 360 | 361 | # 0.8.2 362 | 363 | ### New features 364 | 365 | * Compatibility with Metabase 0.44 366 | -------------------------------------------------------------------------------- /test/metabase/driver/clickhouse_introspection_test.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-introspection-test 2 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 3 | (:require 4 | [clojure.test :refer :all] 5 | [metabase.driver :as driver] 6 | [metabase.driver.common :as driver.common] 7 | [metabase.query-processor :as qp] 8 | [metabase.query-processor.test-util :as qp.test] 9 | [metabase.test :as mt] 10 | [metabase.test.data :as data] 11 | [metabase.test.data.clickhouse :as ctd] 12 | [metabase.test.data.interface :as tx] 13 | [toucan2.tools.with-temp :as t2.with-temp])) 14 | 15 | (use-fixtures :once ctd/create-test-db!) 16 | 17 | (defn- desc-table 18 | [table-name] 19 | (into #{} (map #(select-keys % [:name :database-type :base-type :database-required]) 20 | (:fields (ctd/do-with-test-db 21 | #(driver/describe-table :clickhouse % {:name table-name})))))) 22 | 23 | (deftest ^:parallel clickhouse-base-types-test-enums 24 | (mt/test-driver 25 | :clickhouse 26 | (testing "enums" 27 | (let [table-name "enums_base_types"] 28 | (is (= #{{:base-type :type/Text, 29 | :database-required false, 30 | :database-type "Nullable(Enum8('America/New_York' = 1))", 31 | :name "c1"} 32 | {:base-type :type/Text, 33 | :database-required true, 34 | :database-type "Enum8('BASE TABLE' = 1, 'VIEW' = 2, 'FOREIGN TABLE' = 3, 'LOCAL TEMPORARY' = 4, 'SYSTEM VIEW' = 5)", 35 | :name "c2"} 36 | {:base-type :type/Text, 37 | :database-required true, 38 | :database-type "Enum8('NO' = 1, 'YES' = 2)", 39 | :name "c3"} 40 | {:base-type :type/Text, 41 | :database-required true, 42 | :database-type "Enum16('SHOW DATABASES' = 0, 'SHOW TABLES' = 1, 'SHOW COLUMNS' = 2)", 43 | :name "c4"} 44 | {:base-type :type/Text, 45 | :database-required false, 46 | :database-type "Nullable(Enum8('GLOBAL' = 0, 'DATABASE' = 1, 'TABLE' = 2))", 47 | :name "c5"} 48 | {:base-type :type/Text, 49 | :database-required false, 50 | :database-type "Nullable(Enum16('SHOW DATABASES' = 0, 'SHOW TABLES' = 1, 'SHOW COLUMNS' = 2))", 51 | :name "c6"}} 52 | (desc-table table-name))))))) 53 | 54 | (deftest ^:parallel clickhouse-base-types-test-dates 55 | (mt/test-driver 56 | :clickhouse 57 | (testing "dates" 58 | (let [table-name "date_base_types"] 59 | (is (= #{{:base-type :type/Date, 60 | :database-required true, 61 | :database-type "Date", 62 | :name "c1"} 63 | {:base-type :type/Date, 64 | :database-required true, 65 | :database-type "Date32", 66 | :name "c2"} 67 | {:base-type :type/Date, 68 | :database-required false, 69 | :database-type "Nullable(Date)", 70 | :name "c3"} 71 | {:base-type :type/Date, 72 | :database-required false, 73 | :database-type "Nullable(Date32)", 74 | :name "c4"}} 75 | (desc-table table-name))))))) 76 | 77 | (deftest ^:parallel clickhouse-base-types-test-datetimes 78 | (mt/test-driver 79 | :clickhouse 80 | (testing "datetimes" 81 | (let [table-name "datetime_base_types"] 82 | (is (= #{{:base-type :type/DateTimeWithLocalTZ, 83 | :database-required false, 84 | :database-type "Nullable(DateTime('America/New_York'))", 85 | :name "c1"} 86 | {:base-type :type/DateTimeWithLocalTZ, 87 | :database-required true, 88 | :database-type "DateTime('America/New_York')", 89 | :name "c2"} 90 | {:base-type :type/DateTimeWithLocalTZ, 91 | :database-required true, 92 | :database-type "DateTime", 93 | :name "c3"} 94 | {:base-type :type/DateTimeWithLocalTZ, 95 | :database-required true, 96 | :database-type "DateTime64(3)", 97 | :name "c4"} 98 | {:base-type :type/DateTimeWithLocalTZ, 99 | :database-required true, 100 | :database-type "DateTime64(9, 'America/New_York')", 101 | :name "c5"} 102 | {:base-type :type/DateTimeWithLocalTZ, 103 | :database-required false, 104 | :database-type "Nullable(DateTime64(6, 'America/New_York'))", 105 | :name "c6"} 106 | {:base-type :type/DateTimeWithLocalTZ, 107 | :database-required false, 108 | :database-type "Nullable(DateTime64(0))", 109 | :name "c7"} 110 | {:base-type :type/DateTimeWithLocalTZ, 111 | :database-required false, 112 | :database-type "Nullable(DateTime)", 113 | :name "c8"}} 114 | (desc-table table-name))))))) 115 | 116 | (deftest ^:parallel clickhouse-base-types-test-integers 117 | (mt/test-driver 118 | :clickhouse 119 | (testing "integers" 120 | (let [table-name "integer_base_types"] 121 | (is (= #{{:base-type :type/Integer, 122 | :database-required true, 123 | :database-type "UInt8", 124 | :name "c1"} 125 | {:base-type :type/Integer, 126 | :database-required true, 127 | :database-type "UInt16", 128 | :name "c2"} 129 | {:base-type :type/Integer, 130 | :database-required true, 131 | :database-type "UInt32", 132 | :name "c3"} 133 | {:base-type :type/BigInteger, 134 | :database-required true, 135 | :database-type "UInt64", 136 | :name "c4"} 137 | {:base-type :type/*, 138 | :database-required true, 139 | :database-type "UInt128", 140 | :name "c5"} 141 | {:base-type :type/*, 142 | :database-required true, 143 | :database-type "UInt256", 144 | :name "c6"} 145 | {:base-type :type/Integer, 146 | :database-required true, 147 | :database-type "Int8", 148 | :name "c7"} 149 | {:base-type :type/Integer, 150 | :database-required true, 151 | :database-type "Int16", 152 | :name "c8"} 153 | {:base-type :type/Integer, 154 | :database-required true, 155 | :database-type "Int32", 156 | :name "c9"} 157 | {:base-type :type/BigInteger, 158 | :database-required true, 159 | :database-type "Int64", 160 | :name "c10"} 161 | {:base-type :type/*, 162 | :database-required true, 163 | :database-type "Int128", 164 | :name "c11"} 165 | {:base-type :type/*, 166 | :database-required true, 167 | :database-type "Int256", 168 | :name "c12"} 169 | {:base-type :type/Integer, 170 | :database-required false, 171 | :database-type "Nullable(Int32)", 172 | :name "c13"}} 173 | (desc-table table-name))))))) 174 | 175 | (deftest ^:parallel clickhouse-base-types-test-numerics 176 | (mt/test-driver 177 | :clickhouse 178 | (testing "numerics" 179 | (let [table-name "numeric_base_types"] 180 | (is (= #{{:base-type :type/Float, 181 | :database-required true, 182 | :database-type "Float32", 183 | :name "c1"} 184 | {:base-type :type/Float, 185 | :database-required true, 186 | :database-type "Float64", 187 | :name "c2"} 188 | {:base-type :type/Decimal, 189 | :database-required true, 190 | :database-type "Decimal(4, 2)", 191 | :name "c3"} 192 | {:base-type :type/Decimal, 193 | :database-required true, 194 | :database-type "Decimal(9, 7)", 195 | :name "c4"} 196 | {:base-type :type/Decimal, 197 | :database-required true, 198 | :database-type "Decimal(18, 12)", 199 | :name "c5"} 200 | {:base-type :type/Decimal, 201 | :database-required true, 202 | :database-type "Decimal(38, 24)", 203 | :name "c6"} 204 | {:base-type :type/Decimal, 205 | :database-required true, 206 | :database-type "Decimal(76, 42)", 207 | :name "c7"} 208 | {:base-type :type/Float, 209 | :database-required false, 210 | :database-type "Nullable(Float32)", 211 | :name "c8"} 212 | {:base-type :type/Decimal, 213 | :database-required false, 214 | :database-type "Nullable(Decimal(4, 2))", 215 | :name "c9"} 216 | {:base-type :type/Decimal, 217 | :database-required false, 218 | :database-type "Nullable(Decimal(76, 42))", 219 | :name "c10"}} 220 | (desc-table table-name))))))) 221 | 222 | (deftest ^:parallel clickhouse-base-types-test-strings 223 | (mt/test-driver 224 | :clickhouse 225 | (testing "strings" 226 | (let [table-name "string_base_types"] 227 | (is (= #{{:base-type :type/Text, 228 | :database-required true, 229 | :database-type "String", 230 | :name "c1"} 231 | {:base-type :type/Text, 232 | :database-required true, 233 | :database-type "LowCardinality(String)", 234 | :name "c2"} 235 | {:base-type :type/TextLike, 236 | :database-required true, 237 | :database-type "FixedString(32)", 238 | :name "c3"} 239 | {:base-type :type/Text, 240 | :database-required false, 241 | :database-type "Nullable(String)", 242 | :name "c4"} 243 | {:base-type :type/TextLike, 244 | :database-required true, 245 | :database-type "LowCardinality(FixedString(4))", 246 | :name "c5"}} 247 | (desc-table table-name))))))) 248 | 249 | (deftest ^:parallel clickhouse-base-types-test-arrays 250 | (mt/test-driver 251 | :clickhouse 252 | (testing "arrays" 253 | (let [table-name "array_base_types"] 254 | (is (= #{{:base-type :type/Array, 255 | :database-required true, 256 | :database-type "Array(String)", 257 | :name "c1"} 258 | {:base-type :type/Array, 259 | :database-required true, 260 | :database-type "Array(Nullable(Int32))", 261 | :name "c2"} 262 | {:base-type :type/Array, 263 | :database-required true, 264 | :database-type "Array(Array(LowCardinality(FixedString(32))))", 265 | :name "c3"} 266 | {:base-type :type/Array, 267 | :database-required true, 268 | :database-type "Array(Array(Array(String)))", 269 | :name "c4"}} 270 | (desc-table table-name))))))) 271 | 272 | (deftest ^:parallel clickhouse-base-types-test-low-cardinality-nullable 273 | (mt/test-driver 274 | :clickhouse 275 | (testing "low cardinality nullable" 276 | (let [table-name "low_cardinality_nullable_base_types"] 277 | (is (= #{{:base-type :type/Text, 278 | :database-required true, 279 | :database-type "LowCardinality(Nullable(String))", 280 | :name "c1"} 281 | {:base-type :type/TextLike, 282 | :database-required true, 283 | :database-type "LowCardinality(Nullable(FixedString(16)))", 284 | :name "c2"}} 285 | (desc-table table-name))))))) 286 | 287 | (deftest ^:parallel clickhouse-base-types-test-misc 288 | (mt/test-driver 289 | :clickhouse 290 | (testing "everything else" 291 | (let [table-name "misc_base_types"] 292 | (is (= #{{:base-type :type/Boolean, 293 | :database-required true, 294 | :database-type "Bool", 295 | :name "c1"} 296 | {:base-type :type/UUID, 297 | :database-required true, 298 | :database-type "UUID", 299 | :name "c2"} 300 | {:base-type :type/IPAddress, 301 | :database-required true, 302 | :database-type "IPv4", 303 | :name "c3"} 304 | {:base-type :type/IPAddress, 305 | :database-required true, 306 | :database-type "IPv6", 307 | :name "c4"} 308 | {:base-type :type/Dictionary, 309 | :database-required true, 310 | :database-type "Map(Int32, String)", 311 | :name "c5"} 312 | {:base-type :type/Boolean, 313 | :database-required false, 314 | :database-type "Nullable(Bool)", 315 | :name "c6"} 316 | {:base-type :type/UUID, 317 | :database-required false, 318 | :database-type "Nullable(UUID)", 319 | :name "c7"} 320 | {:base-type :type/IPAddress, 321 | :database-required false, 322 | :database-type "Nullable(IPv4)", 323 | :name "c8"} 324 | {:base-type :type/IPAddress, 325 | :database-required false, 326 | :database-type "Nullable(IPv6)", 327 | :name "c9"} 328 | {:base-type :type/*, 329 | :database-required true, 330 | :database-type "Tuple(String, Int32)", 331 | :name "c10"}} 332 | (desc-table table-name))))))) 333 | 334 | (deftest ^:parallel clickhouse-boolean-type-metadata 335 | (mt/test-driver 336 | :clickhouse 337 | (let [result (-> {:query "SELECT false, 123, true"} mt/native-query qp/process-query) 338 | [[c1 _ c3]] (-> result qp.test/rows)] 339 | (testing "column should be of type :type/Boolean" 340 | (is (= :type/Boolean (-> result :data :results_metadata :columns first :base_type))) 341 | (is (= :type/Boolean (transduce identity (driver.common/values->base-type) [c1, c3]))) 342 | (is (= :type/Boolean (driver.common/class->base-type (class c1)))))))) 343 | 344 | (def ^:private base-field 345 | {:database-is-auto-increment false 346 | :json-unfolding false 347 | :database-required true}) 348 | 349 | (deftest ^:parallel clickhouse-filtered-aggregate-functions-test-table-metadata 350 | (mt/test-driver 351 | :clickhouse 352 | (is (= {:name "aggregate_functions_filter_test" 353 | :fields #{(merge base-field 354 | {:name "idx" 355 | :database-type "UInt8" 356 | :base-type :type/Integer 357 | :database-position 0}) 358 | (merge base-field 359 | {:name "lowest_value" 360 | :database-type "SimpleAggregateFunction(min, UInt8)" 361 | :base-type :type/Integer 362 | :database-position 2}) 363 | (merge base-field 364 | {:name "count" 365 | :database-type "SimpleAggregateFunction(sum, Int64)" 366 | :base-type :type/BigInteger 367 | :database-position 3})}} 368 | (ctd/do-with-test-db 369 | (fn [db] 370 | (driver/describe-table :clickhouse db {:name "aggregate_functions_filter_test"}))))))) 371 | 372 | (deftest ^:parallel clickhouse-filtered-aggregate-functions-test-result-set 373 | (mt/test-driver 374 | :clickhouse 375 | (is (= [[42 144 255255]] 376 | (qp.test/formatted-rows 377 | [int int int] 378 | :format-nil-values 379 | (ctd/do-with-test-db 380 | (fn [db] 381 | (data/with-db db 382 | (data/run-mbql-query 383 | aggregate_functions_filter_test 384 | {}))))))))) 385 | 386 | (def ^:private test-tables 387 | #{{:description nil, 388 | :name "table1", 389 | :schema "metabase_db_scan_test"} 390 | {:description nil, 391 | :name "table2", 392 | :schema "metabase_db_scan_test"}}) 393 | 394 | (deftest ^:parallel clickhouse-describe-database-single 395 | (mt/test-driver 396 | :clickhouse 397 | (t2.with-temp/with-temp 398 | [:model/Database db {:engine :clickhouse 399 | :details (merge {:scan-all-databases nil} 400 | (tx/dbdef->connection-details 401 | :clickhouse :db 402 | {:database-name "metabase_db_scan_test"}))}] 403 | (let [describe-result (driver/describe-database :clickhouse db)] 404 | (is (= {:tables test-tables} describe-result)))))) 405 | 406 | (deftest ^:parallel clickhouse-describe-database-all 407 | (mt/test-driver 408 | :clickhouse 409 | (t2.with-temp/with-temp 410 | [:model/Database db {:engine :clickhouse 411 | :details (merge {:scan-all-databases true} 412 | (tx/dbdef->connection-details 413 | :clickhouse :db 414 | {:database-name "default"}))}] 415 | (let [describe-result (driver/describe-database :clickhouse db)] 416 | ;; check the existence of at least some test tables here 417 | (doseq [table test-tables] 418 | (is (contains? (:tables describe-result) table))) 419 | ;; should not contain any ClickHouse system tables 420 | (is (not (some #(= (:schema %) "system") 421 | (:tables describe-result)))) 422 | (is (not (some #(= (:schema %) "information_schema") 423 | (:tables describe-result)))) 424 | (is (not (some #(= (:schema %) "INFORMATION_SCHEMA") 425 | (:tables describe-result)))))))) 426 | 427 | (deftest ^:parallel clickhouse-describe-database-multiple 428 | (mt/test-driver 429 | :clickhouse 430 | (t2.with-temp/with-temp 431 | [:model/Database db {:engine :clickhouse 432 | :details (tx/dbdef->connection-details 433 | :clickhouse :db 434 | {:database-name "metabase_db_scan_test information_schema"})}] 435 | (let [{:keys [tables] :as _describe-result} 436 | (driver/describe-database :clickhouse db) 437 | tables-table {:name "tables" 438 | :description nil 439 | :schema "information_schema"} 440 | columns-table {:name "columns" 441 | :description nil 442 | :schema "information_schema"}] 443 | 444 | ;; tables from `metabase_db_scan_test` 445 | (doseq [table test-tables] 446 | (is (contains? tables table))) 447 | 448 | ;; tables from `information_schema` 449 | (is (contains? tables tables-table)) 450 | (is (contains? tables columns-table)))))) 451 | -------------------------------------------------------------------------------- /src/metabase/driver/clickhouse_qp.clj: -------------------------------------------------------------------------------- 1 | (ns metabase.driver.clickhouse-qp 2 | "CLickHouse driver: QueryProcessor-related definition" 3 | #_{:clj-kondo/ignore [:unsorted-required-namespaces]} 4 | (:require [clojure.string :as str] 5 | [honey.sql :as sql] 6 | [java-time.api :as t] 7 | [metabase.driver.clickhouse-nippy] 8 | [metabase.driver.clickhouse-version :as clickhouse-version] 9 | [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] 10 | [metabase.driver.sql.query-processor :as sql.qp :refer [add-interval-honeysql-form]] 11 | [metabase.driver.sql.util :as sql.u] 12 | [metabase.driver.sql.util.unprepare :as unprepare] 13 | [metabase.legacy-mbql.util :as mbql.u] 14 | [metabase.query-processor.timezone :as qp.timezone] 15 | [metabase.util :as u] 16 | [metabase.util.date-2 :as u.date] 17 | [metabase.util.honey-sql-2 :as h2x]) 18 | (:import [java.sql ResultSet ResultSetMetaData Types] 19 | [java.time 20 | LocalDate 21 | LocalDateTime 22 | LocalTime 23 | OffsetDateTime 24 | OffsetTime 25 | ZonedDateTime] 26 | java.util.Arrays)) 27 | 28 | ;; (set! *warn-on-reflection* true) ;; isn't enabled because of Arrays/toString call 29 | 30 | (defmethod sql.qp/quote-style :clickhouse [_] :mysql) 31 | 32 | ;; without try, there might be test failures when QP is not yet initialized 33 | ;; e.g., when a test is preparing the dataset 34 | (defn- get-report-timezone-id-safely 35 | [] 36 | (try 37 | (qp.timezone/report-timezone-id-if-supported) 38 | (catch Throwable _e nil))) 39 | 40 | ;; datetime('europe/amsterdam') -> europe/amsterdam 41 | (defn- extract-datetime-timezone 42 | [db-type] 43 | (when (and db-type (string? db-type)) 44 | (cond 45 | ;; e.g. DateTime64(3, 'Europe/Amsterdam') 46 | (str/starts-with? db-type "datetime64") 47 | (if (> (count db-type) 17) (subs db-type 15 (- (count db-type) 2)) nil) 48 | ;; e.g. DateTime('Europe/Amsterdam') 49 | (str/starts-with? db-type "datetime") 50 | (if (> (count db-type) 12) (subs db-type 10 (- (count db-type) 2)) nil) 51 | ;; _ 52 | :else nil))) 53 | 54 | (defn- remove-low-cardinality-and-nullable 55 | [db-type] 56 | (when (and db-type (string? db-type)) 57 | (let [db-type-lowercase (u/lower-case-en db-type) 58 | without-low-car (if (str/starts-with? db-type-lowercase "lowcardinality(") 59 | (subs db-type-lowercase 15 (- (count db-type-lowercase) 1)) 60 | db-type-lowercase) 61 | without-nullable (if (str/starts-with? without-low-car "nullable(") 62 | (subs without-low-car 9 (- (count without-low-car) 1)) 63 | without-low-car)] 64 | without-nullable))) 65 | 66 | (defn- in-report-timezone 67 | [expr] 68 | (let [report-timezone (get-report-timezone-id-safely) 69 | lower (u/lower-case-en (h2x/database-type expr)) 70 | db-type (remove-low-cardinality-and-nullable lower)] 71 | (if (and report-timezone (string? db-type) (str/starts-with? db-type "datetime")) 72 | (let [timezone (extract-datetime-timezone db-type)] 73 | (if (not (= timezone (u/lower-case-en report-timezone))) 74 | [:'toTimeZone expr (h2x/literal report-timezone)] 75 | expr)) 76 | expr))) 77 | 78 | (defmethod sql.qp/date [:clickhouse :default] 79 | [_ _ expr] 80 | expr) 81 | 82 | ;;; ------------------------------------------------------------------------------------ 83 | ;;; Extract functions 84 | ;;; ------------------------------------------------------------------------------------ 85 | 86 | (defn- date-extract 87 | [ch-fn expr db-type] 88 | (-> [ch-fn (in-report-timezone expr)] 89 | (h2x/with-database-type-info db-type))) 90 | 91 | (defmethod sql.qp/date [:clickhouse :day-of-week] 92 | [_ _ expr] 93 | ;; a tick in the function name prevents HSQL2 to make the function call UPPERCASE 94 | ;; https://cljdoc.org/d/com.github.seancorfield/honeysql/2.4.1011/doc/getting-started/other-databases#clickhouse 95 | (sql.qp/adjust-day-of-week 96 | :clickhouse (date-extract :'toDayOfWeek expr "uint8"))) 97 | 98 | (defmethod sql.qp/date [:clickhouse :month-of-year] 99 | [_ _ expr] 100 | (date-extract :'toMonth expr "uint8")) 101 | 102 | (defmethod sql.qp/date [:clickhouse :minute-of-hour] 103 | [_ _ expr] 104 | (date-extract :'toMinute expr "uint8")) 105 | 106 | (defmethod sql.qp/date [:clickhouse :hour-of-day] 107 | [_ _ expr] 108 | (date-extract :'toHour expr "uint8")) 109 | 110 | (defmethod sql.qp/date [:clickhouse :day-of-month] 111 | [_ _ expr] 112 | (date-extract :'toDayOfMonth expr "uint8")) 113 | 114 | (defmethod sql.qp/date [:clickhouse :day-of-year] 115 | [_ _ expr] 116 | (date-extract :'toDayOfYear expr "uint16")) 117 | 118 | (defmethod sql.qp/date [:clickhouse :week-of-year-iso] 119 | [_ _ expr] 120 | (date-extract :'toISOWeek expr "uint8")) 121 | 122 | (defmethod sql.qp/date [:clickhouse :quarter-of-year] 123 | [_ _ expr] 124 | (date-extract :'toQuarter expr "uint8")) 125 | 126 | (defmethod sql.qp/date [:clickhouse :year-of-era] 127 | [_ _ expr] 128 | (date-extract :'toYear expr "uint16")) 129 | 130 | ;;; ------------------------------------------------------------------------------------ 131 | ;;; Truncate functions 132 | ;;; ------------------------------------------------------------------------------------ 133 | 134 | (defn- date-trunc 135 | [ch-fn expr] 136 | [ch-fn (in-report-timezone expr)]) 137 | 138 | (defn- to-start-of-week 139 | [expr] 140 | (date-trunc :'toMonday expr)) 141 | 142 | (defmethod sql.qp/date [:clickhouse :minute] 143 | [_ _ expr] 144 | (date-trunc :'toStartOfMinute expr)) 145 | 146 | (defmethod sql.qp/date [:clickhouse :hour] 147 | [_ _ expr] 148 | (date-trunc :'toStartOfHour expr)) 149 | 150 | (defmethod sql.qp/date [:clickhouse :day] 151 | [_ _ expr] 152 | (date-trunc :'toStartOfDay expr)) 153 | 154 | (defmethod sql.qp/date [:clickhouse :week] 155 | [driver _ expr] 156 | (sql.qp/adjust-start-of-week driver to-start-of-week expr)) 157 | 158 | (defmethod sql.qp/date [:clickhouse :month] 159 | [_ _ expr] 160 | (date-trunc :'toStartOfMonth expr)) 161 | 162 | (defmethod sql.qp/date [:clickhouse :quarter] 163 | [_ _ expr] 164 | (date-trunc :'toStartOfQuarter expr)) 165 | 166 | (defmethod sql.qp/date [:clickhouse :year] 167 | [_ _ expr] 168 | (date-trunc :'toStartOfYear expr)) 169 | 170 | ;;; ------------------------------------------------------------------------------------ 171 | ;;; Unix timestamps functions 172 | ;;; ------------------------------------------------------------------------------------ 173 | 174 | (defmethod sql.qp/unix-timestamp->honeysql [:clickhouse :seconds] 175 | [_ _ expr] 176 | (h2x/->datetime expr)) 177 | 178 | (defmethod sql.qp/unix-timestamp->honeysql [:clickhouse :milliseconds] 179 | [_ _ expr] 180 | (let [report-timezone (get-report-timezone-id-safely) 181 | inner-expr (h2x// expr 1000)] 182 | (if report-timezone 183 | [:'toDateTime64 inner-expr 3 report-timezone] 184 | [:'toDateTime64 inner-expr 3]))) 185 | 186 | (defmethod sql.qp/unix-timestamp->honeysql [:clickhouse :microseconds] 187 | [_ _ expr] 188 | (let [report-timezone (get-report-timezone-id-safely) 189 | inner-expr [:'toInt64 (h2x// expr 1000)]] 190 | (if report-timezone 191 | [:'fromUnixTimestamp64Milli inner-expr report-timezone] 192 | [:'fromUnixTimestamp64Milli inner-expr]))) 193 | 194 | ;;; ------------------------------------------------------------------------------------ 195 | ;;; HoneySQL forms 196 | ;;; ------------------------------------------------------------------------------------ 197 | 198 | (defmethod sql.qp/->honeysql [:clickhouse :convert-timezone] 199 | [driver [_ arg target-timezone source-timezone]] 200 | (let [expr (sql.qp/->honeysql driver (cond-> arg (string? arg) u.date/parse)) 201 | with-tz-info? (h2x/is-of-type? expr #"(?:nullable\(|lowcardinality\()?(datetime64\(\d, {0,1}'.*|datetime\(.*)") 202 | _ (sql.u/validate-convert-timezone-args with-tz-info? target-timezone source-timezone)] 203 | (if (not with-tz-info?) 204 | [:'plus 205 | expr 206 | [:'toIntervalSecond 207 | [:'minus 208 | [:'timeZoneOffset [:'toTimeZone expr target-timezone]] 209 | [:'timeZoneOffset [:'toTimeZone expr source-timezone]]]]] 210 | [:'toTimeZone expr target-timezone]))) 211 | 212 | (defmethod sql.qp/current-datetime-honeysql-form :clickhouse 213 | [_] 214 | (let [report-timezone (get-report-timezone-id-safely) 215 | [expr db-type] (if report-timezone 216 | [[:'now64 [:raw 9] (h2x/literal report-timezone)] (format "DateTime64(9, '%s')" report-timezone)] 217 | [[:'now64 [:raw 9]] "DateTime64(9)"])] 218 | (h2x/with-database-type-info expr db-type))) 219 | 220 | (defn- date-time-parse-fn 221 | [nano] 222 | (if (zero? nano) :'parseDateTimeBestEffort :'parseDateTime64BestEffort)) 223 | 224 | (defmethod sql.qp/->honeysql [:clickhouse LocalDateTime] 225 | [_ ^java.time.LocalDateTime t] 226 | (let [formatted (t/format "yyyy-MM-dd HH:mm:ss.SSS" t) 227 | report-tz (or (get-report-timezone-id-safely) "UTC")] 228 | (if (zero? (.getNano t)) 229 | [:'parseDateTimeBestEffort formatted report-tz] 230 | [:'parseDateTime64BestEffort formatted 3 report-tz]))) 231 | 232 | (defmethod sql.qp/->honeysql [:clickhouse ZonedDateTime] 233 | [_ ^java.time.ZonedDateTime t] 234 | (let [formatted (t/format "yyyy-MM-dd HH:mm:ss.SSSZZZZZ" t) 235 | fn (date-time-parse-fn (.getNano t))] 236 | [fn formatted])) 237 | 238 | (defmethod sql.qp/->honeysql [:clickhouse OffsetDateTime] 239 | [_ ^java.time.OffsetDateTime t] 240 | ;; copy-paste due to reflection warnings 241 | (let [formatted (t/format "yyyy-MM-dd HH:mm:ss.SSSZZZZZ" t) 242 | fn (date-time-parse-fn (.getNano t))] 243 | [fn formatted])) 244 | 245 | (defmethod sql.qp/->honeysql [:clickhouse LocalDate] 246 | [_ ^java.time.LocalDate t] 247 | [:'parseDateTimeBestEffort t]) 248 | 249 | (defn- local-date-time 250 | [^java.time.LocalTime t] 251 | (t/local-date-time (t/local-date 1970 1 1) t)) 252 | 253 | (defmethod sql.qp/->honeysql [:clickhouse LocalTime] 254 | [driver ^java.time.LocalTime t] 255 | (sql.qp/->honeysql driver (local-date-time t))) 256 | 257 | (defmethod sql.qp/->honeysql [:clickhouse OffsetTime] 258 | [driver ^java.time.OffsetTime t] 259 | (sql.qp/->honeysql driver (t/offset-date-time 260 | (local-date-time (.toLocalTime t)) 261 | (.getOffset t)))) 262 | 263 | (defn- args->float64 264 | [args] 265 | (map (fn [arg] [:'toFloat64 (sql.qp/->honeysql :clickhouse arg)]) args)) 266 | 267 | (defn- interval? [expr] 268 | (mbql.u/is-clause? :interval expr)) 269 | 270 | (defmethod sql.qp/->honeysql [:clickhouse :+] 271 | [driver [_ & args]] 272 | (if (some interval? args) 273 | (if-let [[field intervals] (u/pick-first (complement interval?) args)] 274 | (reduce (fn [hsql-form [_ amount unit]] 275 | (add-interval-honeysql-form driver hsql-form amount unit)) 276 | (sql.qp/->honeysql driver field) 277 | intervals) 278 | (throw (ex-info "Summing intervals is not supported" {:args args}))) 279 | (into [:+] (args->float64 args)))) 280 | 281 | (defmethod sql.qp/->honeysql [:clickhouse :log] 282 | [driver [_ field]] 283 | [:'log10 (sql.qp/->honeysql driver field)]) 284 | 285 | (defn- format-expr 286 | [expr] 287 | (first (sql/format-expr (sql.qp/->honeysql :clickhouse expr) {:nested true}))) 288 | 289 | (defmethod sql.qp/->honeysql [:clickhouse :percentile] 290 | [_ [_ field p]] 291 | [:raw (format "quantile(%s)(%s)" (format-expr p) (format-expr field))]) 292 | 293 | (defmethod sql.qp/->honeysql [:clickhouse :regex-match-first] 294 | [driver [_ arg pattern]] 295 | [:'extract (sql.qp/->honeysql driver arg) pattern]) 296 | 297 | (defmethod sql.qp/->honeysql [:clickhouse :stddev] 298 | [driver [_ field]] 299 | [:'stddevPop (sql.qp/->honeysql driver field)]) 300 | 301 | (defmethod sql.qp/->honeysql [:clickhouse :median] 302 | [driver [_ field]] 303 | [:'median (sql.qp/->honeysql driver field)]) 304 | 305 | ;; Substring does not work for Enums, so we need to cast to String 306 | (defmethod sql.qp/->honeysql [:clickhouse :substring] 307 | [driver [_ arg start length]] 308 | (let [str [:'toString (sql.qp/->honeysql driver arg)]] 309 | (if length 310 | [:'substring str 311 | (sql.qp/->honeysql driver start) 312 | (sql.qp/->honeysql driver length)] 313 | [:'substring str 314 | (sql.qp/->honeysql driver start)]))) 315 | 316 | (defmethod sql.qp/->honeysql [:clickhouse :var] 317 | [driver [_ field]] 318 | [:'varPop (sql.qp/->honeysql driver field)]) 319 | 320 | (defmethod sql.qp/->float :clickhouse 321 | [_ value] 322 | [:'toFloat64 value]) 323 | 324 | (defmethod sql.qp/->honeysql [:clickhouse :value] 325 | [driver value] 326 | (let [[_ value {base-type :base_type}] value] 327 | (when (some? value) 328 | (condp #(isa? %2 %1) base-type 329 | :type/IPAddress [:'toIPv4 value] 330 | (sql.qp/->honeysql driver value))))) 331 | 332 | (defmethod sql.qp/->honeysql [:clickhouse :=] 333 | [driver [op field value]] 334 | (let [[qual valuevalue fieldinfo] value 335 | hsql-field (sql.qp/->honeysql driver field) 336 | hsql-value (sql.qp/->honeysql driver value)] 337 | (if (and (isa? qual :value) 338 | (isa? (:base_type fieldinfo) :type/Text) 339 | (nil? valuevalue)) 340 | [:or 341 | [:= hsql-field hsql-value] 342 | [:= [:'empty hsql-field] 1]] 343 | ((get-method sql.qp/->honeysql [:sql :=]) driver [op field value])))) 344 | 345 | (defmethod sql.qp/->honeysql [:clickhouse :!=] 346 | [driver [op field value]] 347 | (let [[qual valuevalue fieldinfo] value 348 | hsql-field (sql.qp/->honeysql driver field) 349 | hsql-value (sql.qp/->honeysql driver value)] 350 | (if (and (isa? qual :value) 351 | (isa? (:base_type fieldinfo) :type/Text) 352 | (nil? valuevalue)) 353 | [:and 354 | [:!= hsql-field hsql-value] 355 | [:= [:'notEmpty hsql-field] 1]] 356 | ((get-method sql.qp/->honeysql [:sql :!=]) driver [op field value])))) 357 | 358 | ;; I do not know why the tests expect nil counts for empty results 359 | ;; but that's how it is :-) 360 | ;; 361 | ;; It would even be better if we could use countIf and sumIf directly 362 | ;; 363 | ;; metabase.query-processor-test.count-where-test 364 | ;; metabase.query-processor-test.share-test 365 | (defmethod sql.qp/->honeysql [:clickhouse :count-where] 366 | [driver [_ pred]] 367 | [:case 368 | [:> [:'count] 0] 369 | [:sum [:case (sql.qp/->honeysql driver pred) 1 :else 0]] 370 | :else nil]) 371 | 372 | (defmethod sql.qp/->honeysql [:clickhouse :sum-where] 373 | [driver [_ field pred]] 374 | [:sum [:case (sql.qp/->honeysql driver pred) (sql.qp/->honeysql driver field) 375 | :else 0]]) 376 | 377 | (defmethod sql.qp/add-interval-honeysql-form :clickhouse 378 | [_ dt amount unit] 379 | (h2x/+ dt [:raw (format "INTERVAL %d %s" (int amount) (name unit))])) 380 | 381 | (defn- clickhouse-string-fn 382 | [fn-name field value options] 383 | (let [hsql-field (sql.qp/->honeysql :clickhouse field) 384 | hsql-value (sql.qp/->honeysql :clickhouse value)] 385 | (if (get options :case-sensitive true) 386 | [fn-name hsql-field hsql-value] 387 | [fn-name [:'lowerUTF8 hsql-field] [:'lowerUTF8 hsql-value]]))) 388 | 389 | (defmethod sql.qp/->honeysql [:clickhouse :starts-with] 390 | [_ [_ field value options]] 391 | (let [starts-with (clickhouse-version/with-min 23 8 392 | (constantly :'startsWithUTF8) 393 | (constantly :'startsWith))] 394 | (clickhouse-string-fn starts-with field value options))) 395 | 396 | (defmethod sql.qp/->honeysql [:clickhouse :ends-with] 397 | [_ [_ field value options]] 398 | (let [ends-with (clickhouse-version/with-min 23 8 399 | (constantly :'endsWithUTF8) 400 | (constantly :'endsWith))] 401 | (clickhouse-string-fn ends-with field value options))) 402 | 403 | (defmethod sql.qp/->honeysql [:clickhouse :contains] 404 | [_ [_ field value options]] 405 | (let [hsql-field (sql.qp/->honeysql :clickhouse field) 406 | hsql-value (sql.qp/->honeysql :clickhouse value) 407 | position-fn (if (get options :case-sensitive true) 408 | :'positionUTF8 409 | :'positionCaseInsensitiveUTF8)] 410 | [:> [position-fn hsql-field hsql-value] 0])) 411 | 412 | (defmethod sql.qp/->honeysql [:clickhouse :datetime-diff] 413 | [driver [_ x y unit]] 414 | (let [x (sql.qp/->honeysql driver x) 415 | y (sql.qp/->honeysql driver y)] 416 | (case unit 417 | ;; Week: Metabase tests expect a bit different result from what `age` provides 418 | (:week) 419 | [:'intDiv [:'dateDiff (h2x/literal :day) (date-trunc :'toStartOfDay x) (date-trunc :'toStartOfDay y)] [:raw 7]] 420 | ;; ------------------------- 421 | (:year :month :quarter :day) 422 | [:'age (h2x/literal unit) (date-trunc :'toStartOfDay x) (date-trunc :'toStartOfDay y)] 423 | ;; ------------------------- 424 | (:hour :minute :second) 425 | [:'age (h2x/literal unit) (in-report-timezone x) (in-report-timezone y)]))) 426 | 427 | ;; We do not have Time data types, so we cheat a little bit 428 | (defmethod sql.qp/cast-temporal-string [:clickhouse :Coercion/ISO8601->Time] 429 | [_driver _special_type expr] 430 | [:'parseDateTimeBestEffort [:'concat "1970-01-01T" expr]]) 431 | 432 | (defmethod sql.qp/cast-temporal-byte [:clickhouse :Coercion/ISO8601->Time] 433 | [_driver _special_type expr] 434 | expr) 435 | 436 | ;;; ------------------------------------------------------------------------------------ 437 | ;;; JDBC-related functions 438 | ;;; ------------------------------------------------------------------------------------ 439 | 440 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/TINYINT] 441 | [_ ^ResultSet rs ^ResultSetMetaData _ ^Integer i] 442 | (fn [] 443 | (.getObject rs i))) 444 | 445 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/SMALLINT] 446 | [_ ^ResultSet rs ^ResultSetMetaData _ ^Integer i] 447 | (fn [] 448 | (.getObject rs i))) 449 | 450 | ;; This is for tests only - some of them expect nil values 451 | ;; getInt/getLong return 0 in case of a NULL value in the result set 452 | ;; the only way to check if it was actually NULL - call ResultSet.wasNull afterwards 453 | (defn- with-null-check 454 | [^ResultSet rs value] 455 | (if (.wasNull rs) nil value)) 456 | 457 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/BIGINT] 458 | [_ ^ResultSet rs ^ResultSetMetaData _ ^Integer i] 459 | (fn [] 460 | (with-null-check rs (.getBigDecimal rs i)))) 461 | 462 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/INTEGER] 463 | [_ ^ResultSet rs ^ResultSetMetaData _ ^Integer i] 464 | (fn [] 465 | (with-null-check rs (.getLong rs i)))) 466 | 467 | (def ^:private utc-zone-id (java.time.ZoneId/of "UTC")) 468 | (defn- zdt-in-report-timezone 469 | [^ZonedDateTime zdt] 470 | (let [maybe-report-timezone (get-report-timezone-id-safely)] 471 | (if maybe-report-timezone 472 | (.withZoneSameInstant zdt (java.time.ZoneId/of maybe-report-timezone)) 473 | (if (= (.getId (.getZone zdt)) "GMT0") ;; for test purposes only; GMT0 is a legacy tz 474 | (.withZoneSameInstant zdt utc-zone-id) 475 | zdt)))) 476 | 477 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/DATE] 478 | [_ ^ResultSet rs ^ResultSetMetaData _rsmeta ^Integer i] 479 | (fn [] 480 | (when-let [sql-date (.getDate rs i)] 481 | (.toLocalDate sql-date)))) 482 | 483 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/TIMESTAMP] 484 | [_ ^ResultSet rs ^ResultSetMetaData rsmeta ^Integer i] 485 | (fn [] 486 | (when-let [zdt (.getObject rs i ZonedDateTime)] 487 | (let [db-type (remove-low-cardinality-and-nullable (.getColumnTypeName rsmeta i))] 488 | (if (= db-type "datetime64(3, 'gmt0')") 489 | ;; a hack for some MB test assertions only; GMT0 is a legacy tz 490 | (.toLocalDateTime (zdt-in-report-timezone zdt)) 491 | ;; this is the normal behavior 492 | (.toOffsetDateTime (.withZoneSameInstant 493 | (zdt-in-report-timezone zdt) 494 | utc-zone-id))))))) 495 | 496 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/TIME] 497 | [_ ^ResultSet rs ^ResultSetMetaData _ ^Integer i] 498 | (fn [] 499 | (.getObject rs i OffsetTime))) 500 | 501 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/NUMERIC] 502 | [_ ^ResultSet rs ^ResultSetMetaData rsmeta ^Integer i] 503 | (fn [] 504 | ; count is NUMERIC cause UInt64 is too large for the canonical SQL BIGINT, 505 | ; and defaults to BigDecimal, but we want it to be coerced to java Long 506 | ; cause it still fits and the tests are expecting that 507 | (if (= (.getColumnLabel rsmeta i) "count") 508 | (.getLong rs i) 509 | (.getBigDecimal rs i)))) 510 | 511 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/ARRAY] 512 | [_ ^ResultSet rs ^ResultSetMetaData _rsmeta ^Integer i] 513 | (fn [] 514 | (when-let [arr (.getArray rs i)] 515 | (Arrays/deepToString (.getArray arr))))) 516 | 517 | (defn- ipv4-column->string 518 | [^ResultSet rs ^Integer i] 519 | (when-let [inet-address (.getObject rs i java.net.Inet4Address)] 520 | (.getHostAddress inet-address))) 521 | 522 | (defn- ipv6-column->string 523 | [^ResultSet rs ^Integer i] 524 | (when-let [inet-address (.getObject rs i java.net.Inet6Address)] 525 | (.getHostAddress inet-address))) 526 | 527 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/OTHER] 528 | [_ ^ResultSet rs ^ResultSetMetaData rsmeta ^Integer i] 529 | (fn [] 530 | (let [normalized-db-type (remove-low-cardinality-and-nullable 531 | (.getColumnTypeName rsmeta i))] 532 | (cond 533 | (= normalized-db-type "ipv4") 534 | (ipv4-column->string rs i) 535 | (= normalized-db-type "ipv6") 536 | (ipv6-column->string rs i) 537 | ;; _ 538 | :else (.getObject rs i))))) 539 | 540 | (defmethod sql-jdbc.execute/read-column-thunk [:clickhouse Types/VARCHAR] 541 | [_ ^ResultSet rs ^ResultSetMetaData rsmeta ^Integer i] 542 | (fn [] 543 | (let [normalized-db-type (remove-low-cardinality-and-nullable 544 | (.getColumnTypeName rsmeta i))] 545 | (cond 546 | ;; Enum8/Enum16 547 | (str/starts-with? normalized-db-type "enum") 548 | (.getString rs i) 549 | ;; _ 550 | :else (.getObject rs i))))) 551 | 552 | (defmethod unprepare/unprepare-value [:clickhouse LocalDate] 553 | [_ t] 554 | (format "'%s'" (t/format "yyyy-MM-dd" t))) 555 | 556 | (defmethod unprepare/unprepare-value [:clickhouse LocalTime] 557 | [_ t] 558 | (format "'%s'" (t/format "HH:mm:ss.SSS" t))) 559 | 560 | (defmethod unprepare/unprepare-value [:clickhouse OffsetTime] 561 | [_ t] 562 | (format "'%s'" (t/format "HH:mm:ss.SSSZZZZZ" t))) 563 | 564 | (defmethod unprepare/unprepare-value [:clickhouse LocalDateTime] 565 | [_ t] 566 | (format "'%s'" (t/format "yyyy-MM-dd HH:mm:ss.SSS" t))) 567 | 568 | (defmethod unprepare/unprepare-value [:clickhouse OffsetDateTime] 569 | [_ ^OffsetDateTime t] 570 | (format "%s('%s')" 571 | (if (zero? (.getNano t)) "parseDateTimeBestEffort" "parseDateTime64BestEffort") 572 | (t/format "yyyy-MM-dd HH:mm:ss.SSSZZZZZ" t))) 573 | 574 | (defmethod unprepare/unprepare-value [:clickhouse ZonedDateTime] 575 | [_ t] 576 | (format "'%s'" (t/format "yyyy-MM-dd HH:mm:ss.SSSZZZZZ" t))) 577 | --------------------------------------------------------------------------------