betodealmeida commented on code in PR #36805: URL: https://github.com/apache/superset/pull/36805#discussion_r2692112658
########## docs/scripts/generate-database-docs.mjs: ########## @@ -0,0 +1,706 @@ +/** + * 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. + */ + +/** + * This script generates database documentation data from the Python lib.py script. + * It outputs a JSON file that can be imported by React components for rendering. + * + * Usage: node scripts/generate-database-docs.mjs + * + * The script can run in two modes: + * 1. With Flask app (full diagnostics) - requires superset to be installed + * 2. Fallback mode (documentation only) - uses just the DATABASE_DOCS from lib.py + */ + +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '../..'); +const DOCS_DIR = path.resolve(__dirname, '..'); +const DATA_OUTPUT_DIR = path.join(DOCS_DIR, 'src/data'); +const DATA_OUTPUT_FILE = path.join(DATA_OUTPUT_DIR, 'databases.json'); +const MDX_OUTPUT_DIR = path.join(DOCS_DIR, 'docs/databases'); + +/** + * Try to run the full lib.py script with Flask context + */ +function tryRunFullScript() { + try { + console.log('Attempting to run lib.py with Flask context...'); + const pythonCode = ` +import sys +import json +sys.path.insert(0, '.') +from superset.app import create_app +from superset.db_engine_specs.lib import generate_yaml_docs +app = create_app() +with app.app_context(): + docs = generate_yaml_docs() + print(json.dumps(docs, default=str)) +`; + const result = spawnSync('python', ['-c', pythonCode], { + cwd: ROOT_DIR, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, SUPERSET_SECRET_KEY: 'docs-build-key' }, + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr || 'Python script failed'); + } + return JSON.parse(result.stdout); + } catch (error) { + console.log('Full script execution failed, using fallback mode...'); + console.log(' Reason:', error.message?.split('\n')[0] || 'Unknown error'); + return null; + } +} + +/** + * Extract DATABASE_DOCS from lib.py using pure AST parsing + * No superset imports required - works in CI without dependencies + */ +function extractDatabaseDocs() { + console.log('Extracting DATABASE_DOCS directly from lib.py...'); + + try { + const pythonCode = ` +import sys +import json +import ast +import re + +# Read the lib.py file +with open('superset/db_engine_specs/lib.py', 'r') as f: + content = f.read() + +# Find the DATABASE_DOCS dictionary using regex to extract the block +# This avoids needing to execute any superset code +# Handle both plain assignment and type-annotated assignment +match = re.search(r'^DATABASE_DOCS[^=]*=\\s*({.*?^})', content, re.MULTILINE | re.DOTALL) +if not match: + print(json.dumps({})) + sys.exit(0) + +docs_str = match.group(1) + +# Parse the extracted dictionary as a Python literal +tree = ast.parse(docs_str, mode='eval') + +# Safely evaluate the AST - only allow literals +def eval_node(node): + if isinstance(node, ast.Expression): + return eval_node(node.body) + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + # Handle string concatenation + return eval_node(node.left) + eval_node(node.right) + elif isinstance(node, ast.Dict): + return {eval_node(k): eval_node(v) for k, v in zip(node.keys, node.values)} + elif isinstance(node, ast.List): + return [eval_node(e) for e in node.elts] + elif isinstance(node, ast.Set): + return {eval_node(e) for e in node.elts} + elif isinstance(node, ast.Tuple): + return tuple(eval_node(e) for e in node.elts) + elif isinstance(node, ast.Constant): + return node.value + elif isinstance(node, ast.Str): # Python 3.7 compat + return node.s + elif isinstance(node, ast.Num): # Python 3.7 compat + return node.n + elif isinstance(node, ast.NameConstant): # Python 3.7 compat + return node.value + elif isinstance(node, ast.Name): + # Handle True, False, None + if node.id == 'True': + return True + elif node.id == 'False': + return False + elif node.id == 'None': + return None + else: + return node.id # Return name as string for unknown refs + elif isinstance(node, ast.Call): + # Handle set() calls + if isinstance(node.func, ast.Name) and node.func.id == 'set': + if node.args: + return set(eval_node(node.args[0])) + return set() + return None + else: + return None + +result = eval_node(tree) +print(json.dumps(result if result else {}, default=str)) +`; + const result = spawnSync('python3', ['-c', pythonCode], { + cwd: ROOT_DIR, + encoding: 'utf-8', + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr || 'Python script failed'); + } + const databaseDocs = JSON.parse(result.stdout); + if (Object.keys(databaseDocs).length === 0) { + throw new Error('No DATABASE_DOCS found'); + } + + // Build the expected format: wrap documentation in full entry structure + const databases = {}; + for (const [name, docs] of Object.entries(databaseDocs)) { + databases[name] = { + engine: name.toLowerCase().replace(/\s+/g, '_'), + engine_name: name, + documentation: docs, + time_grains: {}, + score: 0, + max_score: 0, + joins: true, + subqueries: true, + supports_dynamic_schema: false, + supports_catalog: false, + supports_dynamic_catalog: false, + ssh_tunneling: false, + query_cancelation: false, + supports_file_upload: false, + user_impersonation: false, + query_cost_estimation: false, + sql_validation: false, + }; + } + console.log(`Extracted ${Object.keys(databases).length} databases from lib.py`); + return databases; + } catch (err) { + console.error('AST extraction failed:', err.message); + // Fall back to simple extraction + return extractDatabaseDocsSimple(); + } +} + +/** + * Simple extraction fallback - just reads DATABASE_DOCS keys and builds placeholder entries + * No superset imports required - works in CI without dependencies + */ +function extractDatabaseDocsSimple() { + console.log('Using simple extraction method (no diagnostics)...'); + // This is a final fallback - just return null and let caller use existing data + return null; +} + +/** + * Build statistics from the database data + */ +function buildStatistics(databases) { + const stats = { + totalDatabases: Object.keys(databases).length, + withDocumentation: 0, + withConnectionString: 0, + withDrivers: 0, + withAuthMethods: 0, + supportsJoins: 0, + supportsSubqueries: 0, + supportsDynamicSchema: 0, + supportsCatalog: 0, + averageScore: 0, + maxScore: 0, + byCategory: {}, + }; + + let totalScore = 0; + + for (const [name, db] of Object.entries(databases)) { + const docs = db.documentation || {}; + + if (Object.keys(docs).length > 0) stats.withDocumentation++; + if (docs.connection_string || docs.drivers?.length > 0) + stats.withConnectionString++; + if (docs.drivers?.length > 0) stats.withDrivers++; + if (docs.authentication_methods?.length > 0) stats.withAuthMethods++; + if (db.joins) stats.supportsJoins++; + if (db.subqueries) stats.supportsSubqueries++; + if (db.supports_dynamic_schema) stats.supportsDynamicSchema++; + if (db.supports_catalog) stats.supportsCatalog++; + + totalScore += db.score || 0; + if (db.max_score > stats.maxScore) stats.maxScore = db.max_score; + + // Categorize databases + const category = categorizeDatabase(name); + if (!stats.byCategory[category]) { + stats.byCategory[category] = []; + } + stats.byCategory[category].push(name); + } + + stats.averageScore = Math.round(totalScore / stats.totalDatabases); + + return stats; +} + +/** + * Categorize a database by its type + */ +function categorizeDatabase(name) { Review Comment: This PR is super cool, but I do have a concern about maintainability (that applies to this function and a few other places): **If we add a new database to Superset, how many files do we need to touch in order to keep the documentation up-to-date?** Right now it seems that it would require adding a new file to `docs/docs/`, adding the logo, and updating 2 functions — BTW why do we have both `categorizeDatabase` and `getCategory`? They seem to do the same thing. Ideally we'd want to have this information centralized in the DB engine spec, so that no additional work is necessary. The current approach we have is to use `lib.py` to figure out attributes, but we could define a schema for the metadata: ```python class MyDBEngineSpec(BaseEngineSpec): metadata: DBEngineSpecMetadata = { "name": "MyDB", "description": "Not to be confused with MySQL", "logo": "https://mydb.example.com/logo.png", # not sure if Apache would be OK with this "supported_features": {"joins", "query-cost-estimation"}, "supported_time_grains": {...}, ..., } ``` Then we could use the AST parser (I think?) to extract the `metadata` dict and trivially convert to JSON. It adds more work, since we're moving away from the auto-detection approach, and the metadata could fall out of sync (for example, if someone adds a time grain but forgets to update `supported_time_grains` in the metadata). But we could have a CI action that tests that `supported_time_grains` is correct. I'm not saying we should do this in this PR, just proposing some ideas. 😄 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
