Compare commits

...

22 Commits

Author SHA1 Message Date
Filipe Silva
3714c8ca41 feat: salesforce source 2025-10-15 15:43:01 +02:00
Nicola Mometto
0a2d4e30bc restrict to analytics for snowflake 2025-10-15 14:27:42 +01:00
William Zimrin
0ed1edd911 show shell errors 2025-10-15 15:15:06 +02:00
Filipe Silva
b45332996b feat: add sources ui 2025-10-15 14:59:45 +02:00
William Zimrin
945bcec95e fix shell commands 2025-10-15 14:51:13 +02:00
William Zimrin
02ef507961 possibly useful 2025-10-15 14:28:47 +02:00
William Zimrin
f8ebc879d4 possible shell support and return sources id 2025-10-15 14:12:18 +02:00
Nicola Mometto
070516339f enable transforms 2025-10-15 12:56:17 +01:00
William Zimrin
7adda82ea4 dummy github shell command 2025-10-15 13:27:11 +02:00
William Zimrin
2d04d929f7 rename db to Sources 2025-10-15 13:02:27 +02:00
William Zimrin
70e2c03f33 remove dead code 2025-10-15 12:22:04 +02:00
William Zimrin
77d6584ffc create postgres and bigquery catalogs 2025-10-15 12:21:06 +02:00
Nicola Mometto
ee9a50abe1 enable select-privileges check 2025-10-15 11:13:08 +01:00
William Zimrin
7d0a98b4e5 create snowflake catalogs 2025-10-15 11:59:01 +02:00
Nicola Mometto
d233cc0509 exclude schemas 2025-10-15 10:38:10 +01:00
Nicola Mometto
fd1af8fb19 fix for JSON 2025-10-15 10:05:46 +01:00
Nicola Mometto
85dad5c293 properly fudge 2025-10-15 09:45:26 +01:00
Nicola Mometto
09cf376f19 drop system catalogs 2025-10-15 09:26:16 +01:00
Nicola Mometto
2a457467bb fudge catalog 2025-10-15 09:08:21 +01:00
Nicola Mometto
f8546e5f37 trino connection w/o cataog restriction 2025-10-15 09:08:18 +01:00
Nicola Mometto
77dad99d59 remove catalog 2025-10-15 09:08:15 +01:00
William Zimrin
8117725c5a dummy add source route 2025-10-15 09:57:16 +02:00
11 changed files with 700 additions and 51 deletions

View File

@@ -179,7 +179,11 @@ export type AddDataModalOpenedEvent = ValidateEvent<{
}>;
export type AddDataModalTabEvent = ValidateEvent<{
event: "csv_tab_clicked" | "sheets_tab_clicked" | "database_tab_clicked";
event:
| "csv_tab_clicked"
| "sheets_tab_clicked"
| "database_tab_clicked"
| "sources_tab_clicked";
triggered_from: "add-data-modal";
}>;

View File

@@ -11,6 +11,7 @@ import S from "./AddDataModal.module.css";
import { CSVPanel } from "./Panels/CSVPanel";
import { DatabasesPanel } from "./Panels/DatabasesPanel";
import { PanelsHeader } from "./Panels/PanelsHeader";
import { SourcesPanel } from "./Panels/SourcesPanel";
import { trackAddDataEvent } from "./analytics";
import { useAddDataPermissions } from "./use-add-data-permission";
import {
@@ -28,7 +29,7 @@ interface AddDataModalProps {
interface Tabs {
name: string;
value: "csv" | "db" | "gsheets";
value: "csv" | "db" | "gsheets" | "sources";
isVisible: boolean;
iconName: IconName;
}
@@ -64,6 +65,7 @@ export const AddDataModal = ({
const eventMapping = {
db: "database_tab_clicked",
sources: "sources_tab_clicked",
csv: "csv_tab_clicked",
gsheets: "sheets_tab_clicked",
} as const satisfies Record<AddDataTab, string>;
@@ -75,6 +77,12 @@ export const AddDataModal = ({
const tabs = useMemo(() => {
return [
{ name: t`Database`, value: "db", isVisible: true, iconName: "database" },
{
name: t`Sources`,
value: "sources",
isVisible: true,
iconName: "connections",
},
{
name: t`CSV`,
value: "csv",
@@ -154,6 +162,12 @@ export const AddDataModal = ({
<Tabs.Panel value="db" className={S.panel}>
<DatabasesPanel canSeeContent={isAdmin} />
</Tabs.Panel>
<Tabs.Panel value="sources" className={S.panel}>
<SourcesPanel
canSeeContent={isAdmin}
onAddDataModalClose={onClose}
/>
</Tabs.Panel>
<Tabs.Panel value="gsheets" className={S.panel}>
<PLUGIN_UPLOAD_MANAGEMENT.GdriveAddDataPanel
onAddDataModalClose={onClose}

View File

@@ -0,0 +1,37 @@
.search {
width: 100%;
border: 1px solid var(--mb-color-border);
border-radius: var(--default-border-radius);
}
.options {
padding: 0;
margin-bottom: var(--mantine-spacing-sm);
border-radius: var(--default-border-radius);
border: 1px solid var(--mb-color-border);
overflow: hidden;
.option {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: 0;
cursor: pointer;
display: flex;
height: 2.5rem;
&:not(:last-child) {
border-bottom: 1px solid var(--mb-color-border);
}
&:hover,
&[data-combobox-selected="true"] {
background-color: var(--mb-color-background-hover);
color: inherit;
}
}
}
.noResultsIcon {
background-color: var(--mb-color-bg-medium);
border-radius: 100%;
color: var(--mb-color-text-medium);
}

View File

@@ -0,0 +1,360 @@
import { useState } from "react";
import { t } from "ttag";
import {
Button,
Center,
Combobox,
Group,
Icon,
ScrollArea,
Stack,
Text,
TextInput,
useCombobox,
} from "metabase/ui";
import S from "./SourcesList.module.css";
interface DetailField {
field: string;
label: string;
placeholder: string;
type?: "text" | "password";
}
interface Source {
name: string;
value: string;
category: "database" | "application";
detailFields: DetailField[];
}
const SOURCES: Source[] = [
{
name: "PostgreSQL",
value: "postgres",
category: "database",
detailFields: [
{
field: "connection_string",
label: "Connection string",
placeholder: "Enter connection string",
},
],
},
{
name: "Snowflake",
value: "snowflake",
category: "database",
detailFields: [
{
field: "connection_string",
label: "Connection string",
placeholder: "Enter connection string",
},
],
},
{
name: "BigQuery",
value: "bigquery-cloud-sdk",
category: "database",
detailFields: [
{
field: "connection_string",
label: "Connection string",
placeholder: "Enter connection string",
},
],
},
{
name: "Stripe",
value: "stripe",
category: "application",
detailFields: [
{
field: "api_key",
label: "API key",
placeholder: "Enter API key",
},
],
},
{
name: "Pipedrive",
value: "pipedrive",
category: "application",
detailFields: [
{
field: "api_key",
label: "API key",
placeholder: "Enter API key",
},
],
},
{
name: "Google Sheets",
value: "google-sheets",
category: "application",
detailFields: [
{
field: "spreadsheet_url",
label: "Spreadsheet URL",
placeholder: "Enter spreadsheet URL",
},
],
},
{
name: "Notion",
value: "notion",
category: "application",
detailFields: [
{
field: "api_key",
label: "API key",
placeholder: "Enter API key",
},
],
},
{
name: "Salesforce",
value: "salesforce",
category: "application",
detailFields: [
{
field: "username",
label: "Username",
placeholder: "Enter username",
},
{
field: "password",
label: "Password",
placeholder: "Enter password",
type: "password",
},
{
field: "security_token",
label: "Security token",
placeholder: "Enter security token",
},
],
},
];
interface SourcesListProps {
onSubmit: (data: {
name: string;
source: string;
details: Record<string, string>;
}) => Promise<{ success: boolean; error?: string }>;
}
export const SourcesList = ({ onSubmit }: SourcesListProps) => {
const combobox = useCombobox();
const [search, setSearch] = useState("");
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
const [formData, setFormData] = useState<{
name: string;
details: Record<string, string>;
}>({
name: "",
details: {},
});
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const searchResults = SOURCES.filter(({ name }) =>
name.toLowerCase().includes(search.toLowerCase().trim()),
);
const handleSourceSelect = (sourceKey: string) => {
const source = SOURCES.find((s) => s.value === sourceKey);
if (source) {
setSelectedSource(source);
setFormData({ name: "", details: {} });
setError(null);
}
};
const handleSubmit = async () => {
if (!selectedSource) {
return;
}
setError(null);
setIsSubmitting(true);
const result = await onSubmit({
name: formData.name,
source: selectedSource.value,
details: formData.details,
});
setIsSubmitting(false);
if (!result.success) {
setError(result.error || t`Failed to add source. Please try again.`);
}
};
const isFormValid =
formData.name.trim() !== "" &&
selectedSource?.detailFields.every(
(field) => formData.details[field.field]?.trim() !== "",
);
const handleBack = () => {
setSelectedSource(null);
setFormData({ name: "", details: {} });
setError(null);
};
return (
<Stack gap="lg" h="100%" style={{ overflow: "hidden" }}>
{!selectedSource ? (
<Stack gap="lg" style={{ flex: 1, overflow: "auto" }}>
<Combobox
store={combobox}
classNames={{
search: S.search,
options: S.options,
option: S.option,
}}
>
<Combobox.Search
placeholder={t`Search sources`}
leftSection={<Icon name="search" />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
}}
/>
{searchResults.length > 0 ? (
<ScrollArea type="hover" scrollHideDelay={300}>
<Combobox.Options>
{searchResults.map(({ value: sourceKey, name }) => {
return (
<Combobox.Option
key={sourceKey}
value={sourceKey}
onClick={() => handleSourceSelect(sourceKey)}
>
<Group gap="sm">
<SourceLogo source={sourceKey} />
<span>{name}</span>
</Group>
</Combobox.Option>
);
})}
</Combobox.Options>
</ScrollArea>
) : (
<NoSourceFound />
)}
</Combobox>
</Stack>
) : (
<Stack gap="md" style={{ flex: 1, overflow: "auto" }}>
<Button
variant="subtle"
leftSection={<Icon name="chevronleft" />}
onClick={handleBack}
>
{t`Back to sources`}
</Button>
<Stack gap="md">
<Group gap="sm">
<SourceLogo source={selectedSource.value} />
<strong>{selectedSource.name}</strong>
</Group>
{error && (
<Text c="error" size="sm">
{error}
</Text>
)}
<TextInput
label={t`Name`}
placeholder={t`Enter a name for this source`}
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
/>
{selectedSource.detailFields.map((detailField) => (
<TextInput
key={detailField.field}
label={detailField.label}
placeholder={detailField.placeholder}
type={detailField.type || "text"}
value={formData.details[detailField.field] || ""}
onChange={(e) =>
setFormData({
...formData,
details: {
...formData.details,
[detailField.field]: e.target.value,
},
})
}
required
/>
))}
<Button
onClick={handleSubmit}
disabled={!isFormValid || isSubmitting}
>
{isSubmitting ? t`Adding...` : t`Add`}
</Button>
</Stack>
</Stack>
)}
</Stack>
);
};
const SourceLogo = ({ source }: { source: string }) => {
const sourceLogoMap: Record<string, string> = {
postgres: "postgres.svg",
snowflake: "snowflake.svg",
"bigquery-cloud-sdk": "bigquery.svg",
stripe: "database",
pipedrive: "database",
};
const logo = sourceLogoMap[source];
const isImageLogo = logo && !logo.includes("database");
return (
<Center h="lg" w="lg">
{isImageLogo ? (
<img src={`app/assets/img/drivers/${logo}`} width="100%" />
) : (
<Icon name="database" c="brand" />
)}
</Center>
);
};
const NoSourceFound = () => {
return (
<Stack
gap="md"
align="center"
pt="lg"
maw="22.5rem"
c="text-medium"
m="0 auto"
>
<Center className={S.noResultsIcon} w="3rem" h="3rem">
<Icon name="database" c="inherit" />
</Center>
<span>{t`Sorry, we couldn't find this data source.`}</span>
</Stack>
);
};

View File

@@ -0,0 +1,69 @@
import { push } from "react-router-redux";
import { databaseApi } from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import { DatabasePanelEmptyState } from "./AddDataModalEmptyStates";
import { SourcesList } from "./SourcesList";
export const SourcesPanel = ({
canSeeContent,
onAddDataModalClose,
}: {
canSeeContent: boolean;
onAddDataModalClose: () => void;
}) => {
const dispatch = useDispatch();
const handleSourceSubmit = async (data: {
name: string;
source: string;
details: Record<string, string>;
}): Promise<{ success: boolean; error?: string }> => {
try {
const response = await fetch("/api/database/source", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error:
errorData.message ||
`Failed to add source (${response.status} ${response.statusText})`,
};
}
const result = await response.json();
if (result.id) {
dispatch(
databaseApi.util.invalidateTags([{ type: "database", id: "LIST" }]),
);
onAddDataModalClose();
dispatch(push(`/browse/databases/${result.id}`));
}
return { success: true };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: "An unexpected error occurred",
};
}
};
return canSeeContent ? (
<SourcesList onSubmit={handleSourceSubmit} />
) : (
<DatabasePanelEmptyState />
);
};

View File

@@ -1,7 +1,11 @@
import { trackSimpleEvent } from "metabase/lib/analytics";
import type { Engine } from "metabase-types/api";
export const trackAddDataEvent = (
event: "csv_tab_clicked" | "sheets_tab_clicked" | "database_tab_clicked",
event:
| "csv_tab_clicked"
| "sheets_tab_clicked"
| "database_tab_clicked"
| "sources_tab_clicked",
) => {
trackSimpleEvent({
event,

View File

@@ -1,6 +1,6 @@
import type { Database } from "metabase-types/api";
const validTabArray = ["db", "csv", "gsheets"] as const;
const validTabArray = ["db", "sources", "csv", "gsheets"] as const;
const validTabs = new Set<string>(validTabArray);
export type AddDataTab = (typeof validTabArray)[number];

View File

@@ -12,13 +12,6 @@ driver:
- merge:
- port
- default: 8080
- merge:
- dbname
- name: catalog
placeholder: tpch
display-name: Catalog
required: true
helper-text: Starburst Catalogs contain schemas and reference data sources via a connector.
- name: schema
display-name: Schema (optional)
required: false

View File

@@ -11,6 +11,7 @@
[metabase.driver :as driver]
[metabase.driver-api.core :as driver-api]
[metabase.driver.sql :as driver.sql]
[metabase.driver.sql-jdbc :as sql-jdbc]
[metabase.driver.sql-jdbc.common :as sql-jdbc.common]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
@@ -64,6 +65,7 @@
:convert-timezone true
:connection/multiple-databases true
:metadata/key-constraints false
:transforms/table true
:now true
:database-routing true}]
(defmethod driver/database-supports? [:starburst feature] [_ _ _] supported?))
@@ -92,14 +94,12 @@
:else (throw e))))
(defmethod driver/can-connect? :starburst
[driver {:keys [catalog], :as details}]
[driver details]
(try
((get-method driver/can-connect? :sql-jdbc) driver details)
(sql-jdbc.conn/with-connection-spec-for-testing-connection [spec [driver details]]
;; jdbc/query is used to see if we throw, we want to ignore the results
(let [query (format "SHOW CATALOGS LIKE '%s'" catalog)
response (jdbc/query spec query)]
(= [{:catalog catalog}] response)))
(some? (jdbc/query spec "SHOW CATALOGS")))
(catch Throwable e
(handle-execution-error-details e details))))
@@ -387,7 +387,7 @@
[#"(?i)decimal.*" :type/Decimal]
[#"(?i)varchar.*" :type/Text]
[#"(?i)char.*" :type/Text]
[#"(?i)json" :type/Text]
[#"(?i)json" :type/JSON]
[#"(?i)date" :type/Date]
[#"(?i)^timestamp$" :type/DateTime]
[#"(?i)^timestamp\(\d+\)$" :type/DateTime]
@@ -425,7 +425,7 @@
(def excluded-schemas
"The set of schemas that should be excluded when querying all schemas."
#{"information_schema"})
#{"information_schema" "system" "pg_catalog"})
(defmethod sql-jdbc.sync/database-type->base-type :starburst
[_ field-type]
@@ -435,36 +435,37 @@
(defmethod sql-jdbc.sync.interface/have-select-privilege? :starburst
[driver ^Connection conn table-schema table-name]
(try
;; Both Hive and Iceberg plugins for Trino expose one another's tables
;; at the metadata level, even though they are not queryable through that catalog.
;; So rather than using SHOW TABLES, we will DESCRIBE the table to check for
;; queryability. If the table is not queryable for this reason, we will return
;; false. It's a slight stretch of the concept of "permissions," but it is true
;; that we cannot query these tables...
(let [catalog (some-> conn .getCatalog)
sql (describe-table-sql driver catalog table-schema table-name)]
(with-open [stmt (.prepareStatement conn sql)
rs (.executeQuery stmt)]
(.next rs)))
(catch SQLException e
;; The actual exception thrown is TrinoException with error code UNSUPPORTED_TABLE_TYPE (133001),
;; but we can't check the type directly since the relevant io.trino.spi.* classes are not
;; included in trino-jdbc. We check the vendor-specific error code instead.
;; See HiveMetadata.java and UnknownTableTypeException.java in trinodb/trino
(if (= 133001 (.getErrorCode e))
(do
(log/debugf e "Table %s.%s is not accessible through this catalog (mixed catalog table type)"
table-schema table-name)
false)
(throw e)))))
(let [[catalog table-schema] (str/split table-schema #"\.")]
(try
;; Both Hive and Iceberg plugins for Trino expose one another's tables
;; at the metadata level, even though they are not queryable through that catalog.
;; So rather than using SHOW TABLES, we will DESCRIBE the table to check for
;; queryability. If the table is not queryable for this reason, we will return
;; false. It's a slight stretch of the concept of "permissions," but it is true
;; that we cannot query these tables...
(let [sql (describe-table-sql driver catalog table-schema table-name)]
(with-open [stmt (.prepareStatement conn sql)
rs (.executeQuery stmt)]
(.next rs)))
(catch SQLException e
;; The actual exception thrown is TrinoException with error code UNSUPPORTED_TABLE_TYPE (133001),
;; but we can't check the type directly since the relevant io.trino.spi.* classes are not
;; included in trino-jdbc. We check the vendor-specific error code instead.
;; See HiveMetadata.java and UnknownTableTypeException.java in trinodb/trino
(if (= 133001 (.getErrorCode e))
(do
(log/debugf e "Table %s.%s is not accessible through this catalog (mixed catalog table type)"
table-schema table-name)
false)
(throw e))))))
(defn- describe-schema
"Gets a set of maps for all tables in the given `catalog` and `schema`."
[driver ^Connection conn catalog schema]
(with-open [stmt (.createStatement conn)]
(let [sql (describe-schema-sql driver catalog schema)
rs (sql-jdbc.execute/execute-statement! driver stmt sql)]
rs (sql-jdbc.execute/execute-statement! driver stmt sql)
schema (format "%s.%s" catalog schema)]
(into
#{}
(comp (filter (fn [{table-name :table :as _full}]
@@ -486,27 +487,43 @@
(describe-schema driver conn catalog schema))))
(jdbc/reducible-result-set rs {})))))
(defn- all-catalogs
[driver ^Connection conn]
(with-open [stmt (.createStatement conn)]
(let [rs (sql-jdbc.execute/execute-statement! driver stmt "SHOW CATALOGS")]
(into [] (comp (map (fn [{:keys [catalog] :as _full}] catalog))
(filter (fn [catalog] (not (contains? #{"tpch" "system"} catalog)))))
(jdbc/reducible-result-set rs {})))))
(defmethod driver/describe-database* :starburst
[driver {{:keys [catalog schema] :as _details} :details :as database}]
[driver {{:keys [schema] :as _details} :details :as database}]
(sql-jdbc.execute/do-with-connection-with-options
driver
database
nil
(fn [^Connection conn]
(let [schemas (if schema
#{(describe-schema driver conn catalog schema)}
(all-schemas driver conn catalog))]
(let [catalogs (all-catalogs driver conn)
schemas (into
#{}
(mapcat
(fn [catalog]
(let [schema (if (= catalog "snowflake") "analytics" schema)]
(if schema
#{(describe-schema driver conn catalog schema)}
(all-schemas driver conn catalog))))
catalogs))]
{:tables (reduce set/union #{} schemas)}))))
(defmethod driver/describe-table :starburst
[driver {{:keys [catalog] :as _details} :details :as database} {schema :schema, table-name :name}]
[driver database {schema :schema, table-name :name}]
(sql-jdbc.execute/do-with-connection-with-options
driver
database
nil
(fn [^Connection conn]
(with-open [stmt (.createStatement conn)]
(let [sql (describe-table-sql driver catalog schema table-name)
(let [[catalog schema] (str/split schema #"\.")
sql (describe-table-sql driver catalog schema table-name)
rs (sql-jdbc.execute/execute-statement! driver stmt sql)]
{:schema schema
:name table-name
@@ -557,19 +574,38 @@
(sql-jdbc.execute/set-best-transaction-level! driver conn)
(let [underlying-conn (pooled-conn->starburst-conn conn)]
(when-not (str/blank? (get options :session-timezone))
;; set session time zone if defined
;; set session time zone if defined
(.setTimeZoneId underlying-conn (get options :session-timezone))))
(try
(.setReadOnly conn true)
(cond
(:write? options)
(.setAutoCommit conn true)
:else
(.setReadOnly conn true))
(catch Throwable e
(log/warn e "Error setting starburst connection to read-only")))
;; as with statement and prepared-statement, cannot set holdability on the connection level
;; as with statement and prepared-statement, cannot set holdability on the connection level
conn
(catch Throwable e
(.close conn)
(throw e)))
(f conn))))
(defmethod driver/execute-raw-queries! :starburst
[driver conn-spec queries]
(sql-jdbc.execute/do-with-connection-with-options
driver
conn-spec
{:write? true}
(fn [^Connection conn]
(try
(doall (for [[sql params] queries]
(#'sql-jdbc.execute/create-and-execute-statement! driver conn sql params)))
(catch Throwable t
(.rollback conn)
(throw t))))))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Reading Columns from Result Set |
;;; +----------------------------------------------------------------------------------------------------------------+
@@ -702,6 +738,12 @@
(remove-impersonation conn)
rs)
(catch Throwable e (handle-execution-error e))))
(executeUpdate []
(try
(let [rs (.executeUpdate stmt)]
(remove-impersonation conn)
rs)
(catch Throwable e (handle-execution-error e))))
(setMaxRows [nb] (.setMaxRows stmt nb))
(setObject
([index obj] (.setObject stmt index obj))
@@ -744,6 +786,12 @@
(remove-impersonation conn)
rs)
(catch Throwable e (handle-execution-error e))))
(executeUpdate []
(try
(let [rs (.executeUpdate stmt)]
(remove-impersonation conn)
rs)
(catch Throwable e (handle-execution-error e))))
(execute []
(try
(let [rs (.execute stmt)]
@@ -789,6 +837,12 @@
(catch Throwable e
(log/debug e "Error setting statement fetch direction to FETCH_FORWARD")))
(proxy [java.sql.Statement] []
(executeUpdate [sql]
(try
(let [rs (.executeUpdate stmt sql)]
(remove-impersonation conn)
rs)
(catch Throwable e (handle-execution-error e))))
(execute [sql]
(try
(let [rs (.execute stmt sql)]
@@ -1030,3 +1084,18 @@
(defmethod sql.qp/->honeysql [:starburst ::sql.qp/cast-to-text]
[driver [_ expr]]
(sql.qp/->honeysql driver [::sql.qp/cast expr "varchar"]))
(defmethod sql-jdbc/impl-table-known-to-not-exist? :starburst
[_ e]
(= 46 (.getErrorCode e)))
(defmethod driver/compile-transform :sql
[driver {:keys [query output-table]}]
(sql.qp/format-honeysql driver
{:create-table-as (str/join "." [(namespace output-table) (name output-table)])
:raw query}))
(defmethod driver/compile-drop-table :starburst
[_ table]
[(format "DROP TABLE IF EXISTS %s" (str/join "." [(namespace table) (name table)]))])

View File

@@ -154,7 +154,10 @@
& components :- [:* {:min 1} [:maybe [:or :keyword ms/NonBlankString [:fn identifier?]]]]]
[::identifier
identifier-type
(vec (for [component components
(vec (for [component (if (string? (first components))
(clojure.core/concat (str/split (first components) #"\.")
(rest components))
components)
component (if (identifier? component)
(last component)
[component])

View File

@@ -1,6 +1,7 @@
(ns metabase.warehouses.api
"/api/database endpoints."
(:require
[clojure.java.shell :as shell]
[clojure.string :as str]
[medley.core :as m]
[metabase.analytics.core :as analytics]
@@ -13,6 +14,7 @@
[metabase.database-routing.core :as database-routing]
[metabase.driver :as driver]
[metabase.driver.settings :as driver.settings]
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.lib-be.core :as lib-be]
@@ -878,6 +880,100 @@
{:status 400
:body (dissoc details-or-error :valid)}))))
(defn- get-connection-details [s]
(let [[host param-str] (str/split s #"\?")]
(->> (str/split param-str #"&")
(into {:host host}
(map #(let [[key val] (str/split % #"=")]
[(keyword key) val]))))))
(defn- execute-query [driver database query]
(sql-jdbc.execute/do-with-connection-with-options
driver
database
nil
(fn [^java.sql.Connection conn]
(with-open [stmt (.createStatement conn)]
(.execute stmt query)))))
(defn- shell-load [& params]
(log/info "running ./load " (first params) "...")
(let [{:keys [exit err out]} (apply shell/sh "./load" (concat params [:dir "../netabase"]))]
(when (= exit 1)
(throw (ex-info "Load command errored"
{:err err
:out out})))))
(defn- add-catalog [name source details]
(let [database (t2/select-one :model/Database :name "Sources")
driver (:engine database)]
(case source
"snowflake" (let [params (get-connection-details (:connection_string details))
query (format "CREATE CATALOG %s
USING snowflake
WITH (
\"connection-url\" = '%s',
\"connection-user\" = '%s',
\"connection-password\" = '%s',
\"snowflake.account\" = '%s',
\"snowflake.database\" = '%s',
\"snowflake.role\" = '%s',
\"snowflake.warehouse\" = '%s',
\"case-insensitive-name-matching\" = 'true',
\"case-insensitive-name-matching.cache-ttl\" = '1h'
)"
name
(:host params)
(:user params)
(:password params)
(-> (subs (:host params) 17)
(str/split #"\.snowflakecomputing\.com")
first)
(:database params)
(:role params)
(:warehouse params))]
(execute-query driver database query))
"postgres" (let [params (get-connection-details (:connection_string details))
query (format "CREATE CATALOG %s
USING postgresql
WITH (
\"connection-url\" = '%s',
\"connection-user\" = '%s',
\"connection-password\" = '%s'
)"
name
(:host params)
(:user params)
(:password params))]
(execute-query driver database query))
"bigquery" (let [query (format "CREATE CATALOG %s
USING bigquery
WITH (
\"bigquery.project-id\" = '%s',
\"bigquery.credentials-key\" = '%s'
)"
name
(:project_id details)
(:credentials_key details))]
(execute-query driver database query))
("stripe" "pipedrive" "notion") (shell-load source name (:api_key details))
"google-sheets" (shell-load source name (:spreadsheet_url details))
"salesforce" (shell-load source name (:username details) (:password details) (:security_token details)))
(sync/sync-database! database {:scan :schema})
{:id (:id database)}))
(api.macros/defendpoint :post "/source"
"Add a new `Source`."
[_route-params
_query-params
{:keys [name source details]}
:- [:map
[:name ms/NonBlankString]
[:source ms/NonBlankString]
[:details ms/Map]]]
(api/check-superuser)
(add-catalog name source details))
(api.macros/defendpoint :post "/validate"
"Validate that we can connect to a database given a set of details."
;; TODO - why do we pass the DB in under the key `details`?