/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React, { useState, useMemo } from 'react'; import { Card, Row, Col, Statistic, Table, Tag, Input, Select, Tooltip } from 'antd'; import { DatabaseOutlined, CheckCircleOutlined, ApiOutlined, KeyOutlined, SearchOutlined, LinkOutlined, BugOutlined, } from '@ant-design/icons'; import type { DatabaseData, DatabaseInfo, TimeGrains } from './types'; interface DatabaseIndexProps { data: DatabaseData; } // Type for table entries (includes both regular DBs and compatible DBs) interface TableEntry { name: string; categories: string[]; // Multiple categories supported score: number; max_score: number; timeGrainCount: number; time_grains?: TimeGrains; hasDrivers: boolean; hasAuthMethods: boolean; hasConnectionString: boolean; hasCustomErrors: boolean; customErrorCount: number; joins?: boolean; subqueries?: boolean; supports_dynamic_schema?: boolean; supports_catalog?: boolean; ssh_tunneling?: boolean; supports_file_upload?: boolean; query_cancelation?: boolean; query_cost_estimation?: boolean; user_impersonation?: boolean; sql_validation?: boolean; documentation?: DatabaseInfo['documentation']; // For compatible databases isCompatible?: boolean; compatibleWith?: string; compatibleDescription?: string; } // Map category constant names to display names const CATEGORY_DISPLAY_NAMES: Record = { 'CLOUD_AWS': 'Cloud - AWS', 'CLOUD_GCP': 'Cloud - Google', 'CLOUD_AZURE': 'Cloud - Azure', 'CLOUD_DATA_WAREHOUSES': 'Cloud Data Warehouses', 'APACHE_PROJECTS': 'Apache Projects', 'TRADITIONAL_RDBMS': 'Traditional RDBMS', 'ANALYTICAL_DATABASES': 'Analytical Databases', 'SEARCH_NOSQL': 'Search & NoSQL', 'QUERY_ENGINES': 'Query Engines', 'TIME_SERIES': 'Time Series Databases', 'OTHER': 'Other Databases', 'OPEN_SOURCE': 'Open Source', 'HOSTED_OPEN_SOURCE': 'Hosted Open Source', 'PROPRIETARY': 'Proprietary', }; // Category colors for visual distinction const CATEGORY_COLORS: Record = { 'Cloud - AWS': 'orange', 'Cloud - Google': 'blue', 'Cloud - Azure': 'cyan', 'Cloud Data Warehouses': 'purple', 'Apache Projects': 'red', 'Traditional RDBMS': 'green', 'Analytical Databases': 'magenta', 'Search & NoSQL': 'gold', 'Query Engines': 'lime', 'Time Series Databases': 'volcano', 'Other Databases': 'default', // Licensing categories 'Open Source': 'geekblue', 'Hosted Open Source': 'cyan', 'Proprietary': 'default', }; // Convert category constant to display name function getCategoryDisplayName(cat: string): string { return CATEGORY_DISPLAY_NAMES[cat] || cat; } // Get categories for a database - uses categories from metadata when available // Falls back to name-based inference for compatible databases without categories function getCategories( name: string, documentationCategories?: string[] ): string[] { // Prefer categories from documentation metadata (computed by Python) if (documentationCategories && documentationCategories.length > 0) { return documentationCategories.map(getCategoryDisplayName); } // Fallback: infer from name (for compatible databases without categories) const nameLower = name.toLowerCase(); if (nameLower.includes('aws') || nameLower.includes('amazon')) return ['Cloud - AWS']; if (nameLower.includes('google') || nameLower.includes('bigquery')) return ['Cloud - Google']; if (nameLower.includes('azure') || nameLower.includes('microsoft')) return ['Cloud - Azure']; if (nameLower.includes('snowflake') || nameLower.includes('databricks')) return ['Cloud Data Warehouses']; if ( nameLower.includes('apache') || nameLower.includes('druid') || nameLower.includes('hive') || nameLower.includes('spark') ) return ['Apache Projects']; if ( nameLower.includes('postgres') || nameLower.includes('mysql') || nameLower.includes('sqlite') || nameLower.includes('mariadb') ) return ['Traditional RDBMS']; if ( nameLower.includes('clickhouse') || nameLower.includes('vertica') || nameLower.includes('starrocks') ) return ['Analytical Databases']; if ( nameLower.includes('elastic') || nameLower.includes('solr') || nameLower.includes('couchbase') ) return ['Search & NoSQL']; if (nameLower.includes('trino') || nameLower.includes('presto')) return ['Query Engines']; return ['Other Databases']; } // Count supported time grains function countTimeGrains(db: DatabaseInfo): number { if (!db.time_grains) return 0; return Object.values(db.time_grains).filter(Boolean).length; } // Format time grain name for display (e.g., FIVE_MINUTES -> "5 min") function formatTimeGrain(grain: string): string { const mapping: Record = { SECOND: 'Second', FIVE_SECONDS: '5 sec', THIRTY_SECONDS: '30 sec', MINUTE: 'Minute', FIVE_MINUTES: '5 min', TEN_MINUTES: '10 min', FIFTEEN_MINUTES: '15 min', THIRTY_MINUTES: '30 min', HALF_HOUR: '30 min', HOUR: 'Hour', SIX_HOURS: '6 hours', DAY: 'Day', WEEK: 'Week', WEEK_STARTING_SUNDAY: 'Week (Sun)', WEEK_STARTING_MONDAY: 'Week (Mon)', WEEK_ENDING_SATURDAY: 'Week (→Sat)', WEEK_ENDING_SUNDAY: 'Week (→Sun)', MONTH: 'Month', QUARTER: 'Quarter', QUARTER_YEAR: 'Quarter', YEAR: 'Year', }; return mapping[grain] || grain; } // Get list of supported time grains for tooltip function getSupportedTimeGrains(timeGrains?: TimeGrains): string[] { if (!timeGrains) return []; return Object.entries(timeGrains) .filter(([, supported]) => supported) .map(([grain]) => formatTimeGrain(grain)); } const DatabaseIndex: React.FC = ({ data }) => { const [searchText, setSearchText] = useState(''); const [categoryFilter, setCategoryFilter] = useState(null); const { statistics, databases } = data; // Convert databases object to array, including compatible databases const databaseList = useMemo(() => { const entries: TableEntry[] = []; Object.entries(databases).forEach(([name, db]) => { // Add the main database // Use categories from documentation metadata (computed by Python) when available entries.push({ ...db, name, categories: getCategories(name, db.documentation?.categories), timeGrainCount: countTimeGrains(db), hasDrivers: (db.documentation?.drivers?.length ?? 0) > 0, hasAuthMethods: (db.documentation?.authentication_methods?.length ?? 0) > 0, hasConnectionString: Boolean( db.documentation?.connection_string || (db.documentation?.drivers?.length ?? 0) > 0 ), hasCustomErrors: (db.documentation?.custom_errors?.length ?? 0) > 0, customErrorCount: db.documentation?.custom_errors?.length ?? 0, isCompatible: false, }); // Add compatible databases from this database's documentation const compatibleDbs = db.documentation?.compatible_databases ?? []; compatibleDbs.forEach((compat) => { // Check if this compatible DB already exists as a main entry const existsAsMain = Object.keys(databases).some( (dbName) => dbName.toLowerCase() === compat.name.toLowerCase() ); if (!existsAsMain) { // Compatible databases: use their categories if defined, or infer from name entries.push({ name: compat.name, categories: getCategories(compat.name, compat.categories), // Compatible DBs inherit scores from parent score: db.score, max_score: db.max_score, timeGrainCount: countTimeGrains(db), hasDrivers: false, hasAuthMethods: false, hasConnectionString: Boolean(compat.connection_string), hasCustomErrors: false, customErrorCount: 0, joins: db.joins, subqueries: db.subqueries, supports_dynamic_schema: db.supports_dynamic_schema, supports_catalog: db.supports_catalog, ssh_tunneling: db.ssh_tunneling, documentation: { description: compat.description, connection_string: compat.connection_string, pypi_packages: compat.pypi_packages, }, isCompatible: true, compatibleWith: name, compatibleDescription: `Uses ${name} driver`, }); } }); }); return entries; }, [databases]); // Filter and sort databases const filteredDatabases = useMemo(() => { return databaseList .filter((db) => { const matchesSearch = !searchText || db.name.toLowerCase().includes(searchText.toLowerCase()) || db.documentation?.description ?.toLowerCase() .includes(searchText.toLowerCase()); const matchesCategory = !categoryFilter || db.categories.includes(categoryFilter); return matchesSearch && matchesCategory; }) .sort((a, b) => b.score - a.score); }, [databaseList, searchText, categoryFilter]); // Get unique categories and counts for filter const { categories, categoryCounts } = useMemo(() => { const counts: Record = {}; databaseList.forEach((db) => { // Count each category the database belongs to db.categories.forEach((cat) => { counts[cat] = (counts[cat] || 0) + 1; }); }); return { categories: Object.keys(counts).sort(), categoryCounts: counts, }; }, [databaseList]); // Table columns const columns = [ { title: 'Database', dataIndex: 'name', key: 'name', sorter: (a: TableEntry, b: TableEntry) => a.name.localeCompare(b.name), render: (name: string, record: TableEntry) => { // Convert name to URL slug const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); // Link to parent for compatible DBs, otherwise to own page const linkTarget = record.isCompatible && record.compatibleWith ? `/docs/databases/supported/${toSlug(record.compatibleWith)}` : `/docs/databases/supported/${toSlug(name)}`; return (
{name} {record.isCompatible && record.compatibleWith && ( } color="geekblue" style={{ marginLeft: 8, fontSize: '11px' }} > {record.compatibleWith} compatible )}
{record.documentation?.description?.slice(0, 80)} {(record.documentation?.description?.length ?? 0) > 80 ? '...' : ''}
); }, }, { title: 'Categories', dataIndex: 'categories', key: 'categories', width: 220, filters: categories.map((cat) => ({ text: cat, value: cat })), onFilter: (value: React.Key | boolean, record: TableEntry) => record.categories.includes(value as string), render: (cats: string[]) => (
{cats.map((cat) => ( {cat} ))}
), }, { title: 'Score', dataIndex: 'score', key: 'score', width: 80, sorter: (a: TableEntry, b: TableEntry) => a.score - b.score, defaultSortOrder: 'descend' as const, render: (score: number, record: TableEntry) => ( 150 ? '#52c41a' : score > 100 ? '#1890ff' : '#666', fontWeight: score > 150 ? 'bold' : 'normal', }} > {score}/{record.max_score} ), }, { title: 'Time Grains', dataIndex: 'timeGrainCount', key: 'timeGrainCount', width: 100, sorter: (a: TableEntry, b: TableEntry) => a.timeGrainCount - b.timeGrainCount, render: (count: number, record: TableEntry) => { if (count === 0) return -; const grains = getSupportedTimeGrains(record.time_grains); return ( {grains.map((grain) => ( {grain} ))} } placement="top" > {count} grains ); }, }, { title: 'Features', key: 'features', width: 280, filters: [ { text: 'JOINs', value: 'joins' }, { text: 'Subqueries', value: 'subqueries' }, { text: 'Dynamic Schema', value: 'dynamic_schema' }, { text: 'Catalog', value: 'catalog' }, { text: 'SSH Tunneling', value: 'ssh' }, { text: 'File Upload', value: 'file_upload' }, { text: 'Query Cancel', value: 'query_cancel' }, { text: 'Cost Estimation', value: 'cost_estimation' }, { text: 'User Impersonation', value: 'impersonation' }, { text: 'SQL Validation', value: 'sql_validation' }, ], onFilter: (value: React.Key | boolean, record: TableEntry) => { switch (value) { case 'joins': return Boolean(record.joins); case 'subqueries': return Boolean(record.subqueries); case 'dynamic_schema': return Boolean(record.supports_dynamic_schema); case 'catalog': return Boolean(record.supports_catalog); case 'ssh': return Boolean(record.ssh_tunneling); case 'file_upload': return Boolean(record.supports_file_upload); case 'query_cancel': return Boolean(record.query_cancelation); case 'cost_estimation': return Boolean(record.query_cost_estimation); case 'impersonation': return Boolean(record.user_impersonation); case 'sql_validation': return Boolean(record.sql_validation); default: return true; } }, render: (_: unknown, record: TableEntry) => (
{record.joins && JOINs} {record.subqueries && Subqueries} {record.supports_dynamic_schema && Dynamic Schema} {record.supports_catalog && Catalog} {record.ssh_tunneling && SSH} {record.supports_file_upload && File Upload} {record.query_cancelation && Query Cancel} {record.query_cost_estimation && Cost Est.} {record.user_impersonation && Impersonation} {record.sql_validation && SQL Validation}
), }, { title: 'Documentation', key: 'docs', width: 180, render: (_: unknown, record: TableEntry) => (
{record.hasConnectionString && ( } color="default"> Connection )} {record.hasDrivers && ( } color="default"> Drivers )} {record.hasAuthMethods && ( } color="default"> Auth )} {record.hasCustomErrors && ( } color="volcano"> Errors )}
), }, ]; return (
{/* Statistics Cards */} } /> } suffix={`/ ${statistics.totalDatabases}`} /> } /> } /> {/* Filters */} } value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear />