diff --git a/documentation/reference/function/parquet.md b/documentation/reference/function/parquet.md index bfdded44..05eb2074 100644 --- a/documentation/reference/function/parquet.md +++ b/documentation/reference/function/parquet.md @@ -29,7 +29,7 @@ Reads a parquet file as a table. With this function, query a Parquet file located at the QuestDB copy root directory. Both relative and absolute file paths are supported. -```questdb-sql title="read_parquet example" +```questdb-sql title="read_parquet example" execute SELECT * FROM diff --git a/documentation/reference/sql/sample-by.md b/documentation/reference/sql/sample-by.md index ab57c889..bc165db8 100644 --- a/documentation/reference/sql/sample-by.md +++ b/documentation/reference/sql/sample-by.md @@ -86,7 +86,7 @@ other than the timestamp. Specify the shape of the query using `FROM` and `TO`: -```questdb-sql title='Pre-filling trip data' demo +```questdb-sql title='Pre-filling trip data' demo execute SELECT pickup_datetime as t, count() FROM trips SAMPLE BY 1d FROM '2008-12-28' TO '2009-01-05' FILL(NULL); diff --git a/documentation/why-questdb.md b/documentation/why-questdb.md index 89e5f77a..ebdb8ac2 100644 --- a/documentation/why-questdb.md +++ b/documentation/why-questdb.md @@ -106,7 +106,7 @@ efficiency and therefore value. Write blazing-fast queries and create real-time [Grafana](/docs/third-party-tools/grafana/) via familiar SQL: -```questdb-sql title='Navigate time with SQL' demo +```questdb-sql title='Navigate time with SQL' demo execute SELECT timestamp, symbol, first(price) AS open, @@ -115,7 +115,8 @@ SELECT max(price), sum(amount) AS volume FROM trades -WHERE timestamp > dateadd('d', -1, now()) +WHERE timestamp > dateadd('d', -1, now()) + AND symbol = 'BTC-USD' SAMPLE BY 15m; ``` diff --git a/src/components/QuestDbSqlRunnerEmbedded/index.tsx b/src/components/QuestDbSqlRunnerEmbedded/index.tsx new file mode 100644 index 00000000..072cc76c --- /dev/null +++ b/src/components/QuestDbSqlRunnerEmbedded/index.tsx @@ -0,0 +1,138 @@ +import { useState, useCallback, useEffect, CSSProperties } from 'react'; + +interface Column { name: string; type: string; } +interface QuestDBSuccessfulResponse {query: string; columns?: Column[]; dataset?: any[][]; count?: number; ddl?: boolean; error?: undefined; } +interface QuestDBErrorResponse {query: string; error: string; position?: number; ddl?: undefined; dataset?: undefined; columns?: undefined; } +type QuestDBResponse = QuestDBSuccessfulResponse | QuestDBErrorResponse; + +const QUESTDB_DEMO_URL_EMBEDDED: string = 'https://demo.questdb.io'; +const ROW_LIMIT = 20; + +interface QuestDbSqlRunnerEmbeddedProps { + queryToExecute: string; + questdbUrl?: string; +} + +const embeddedResultStyles: { [key: string]: CSSProperties } = { + error: { + color: 'red', padding: '0.5rem', border: '1px solid red', + borderRadius: '4px', backgroundColor: '#ffebee', whiteSpace: 'pre-wrap', marginBottom: '0.5rem', + }, +}; + + +export function QuestDbSqlRunnerEmbedded({ + queryToExecute, + questdbUrl = QUESTDB_DEMO_URL_EMBEDDED, + }: QuestDbSqlRunnerEmbeddedProps): JSX.Element | null { + const [columns, setColumns] = useState([]); + const [dataset, setDataset] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [rowCount, setRowCount] = useState(null); + const [nonTabularResponse, setNonTabularResponse] = useState(null); + + const executeQuery = useCallback(async () => { + if (!queryToExecute || !queryToExecute.trim()) { + setLoading(false); setError(null); setColumns([]); setDataset([]); + setNonTabularResponse(null); setRowCount(null); + return; + } + + setLoading(true); setError(null); setColumns([]); setDataset([]); + setNonTabularResponse(null); setRowCount(null); + + const encodedQuery = encodeURIComponent(queryToExecute); + const url = `${questdbUrl}/exec?query=${encodedQuery}&count=true&timings=true&limit=${ROW_LIMIT}`; + + try { + const response = await fetch(url); + const responseBody = await response.text(); + + if (!response.ok) { + try { + const errorJson = JSON.parse(responseBody) as { error?: string; position?: number }; + throw new Error(`Bad query: ${errorJson.error || responseBody} at position ${errorJson.position || 'N/A'}`); + } catch (e: any) { + if (e.message.startsWith('Bad query')) throw e; + throw new Error(`HTTP Error ${response.status}: ${response.statusText}. Response: ${responseBody}`); + } + } + const result = JSON.parse(responseBody) as QuestDBResponse; + if (result.error) { + setError(`Query Error: ${result.error}${result.position ? ` at position ${result.position}` : ''}`); + } else if (result.dataset) { + if (result.columns) setColumns(result.columns); + else if (result.dataset.length > 0 && Array.isArray(result.dataset[0])) { + setColumns(result.dataset[0].map((_, i) => ({ name: `col${i+1}`, type: 'UNKNOWN' }))); + } + setDataset(result.dataset || []); + setRowCount(result.count !== undefined ? result.count : (result.dataset?.length || 0)); + } else { + setNonTabularResponse(`Query executed. Response: ${JSON.stringify(result)}`); + } + } catch (err: any) { + console.error("Fetch or Parsing Error:", err); + setError(err.message || 'An unexpected error occurred.'); + } finally { + setLoading(false); + } + }, [queryToExecute, questdbUrl]); + + useEffect(() => { + // Auto-execute when the component is rendered with a valid query, or when the query changes. + if (queryToExecute && queryToExecute.trim()) { + executeQuery(); + } else { + // Clear results if the query becomes empty or invalid after being valid + setError(null); setColumns([]); setDataset([]); setNonTabularResponse(null); + setRowCount(null); setLoading(false); + } + }, [queryToExecute, executeQuery]); // executeQuery depends on questdbUrl + + // Render loading state, error, or results + if (loading) { + return

Executing query...

; + } + + // If there's an error or any data to show, wrap it in the container + // Only render the container if there's something to show (error, data, or non-tabular response) + // or if it was loading (handled above). + // If query was empty and nothing executed, this component will render null effectively. + const hasContent = error || nonTabularResponse || (columns.length > 0 && dataset.length >= 0); + + if (!hasContent && !queryToExecute?.trim()) { // If query is empty and no prior error/data + return null; + } + + + return ( +
+ {error &&
Error: {error}
} + {nonTabularResponse && !error && ( +
+

Response:

+
{nonTabularResponse}
+
+ )} + {columns && columns.length > 0 && dataset.length >= 0 && !nonTabularResponse && !error && ( +
+ {dataset.length === 0 &&

Query executed successfully, but returned no rows.

} + {dataset.length > 0 && ( +
+ + {columns.map((col, i) => )} + + {dataset.map((row, rI) => ( + {columns.map((_c, cI) => )} + ))} + +
{col.name} ({col.type})
{row[cI] === null ? 'NULL' : typeof row[cI] === 'boolean' ? row[cI].toString() : String(row[cI])}
+
+ )} + {rowCount !== null &&

Showing {Math.min(rowCount, ROW_LIMIT)} out of {rowCount} rows

} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/theme/CodeBlock/Content/String.tsx b/src/theme/CodeBlock/Content/String.tsx index 87b2300b..4b2abfd7 100644 --- a/src/theme/CodeBlock/Content/String.tsx +++ b/src/theme/CodeBlock/Content/String.tsx @@ -1,73 +1,104 @@ -import clsx from "clsx" -import { useThemeConfig, usePrismTheme } from "@docusaurus/theme-common" +import React, { useState, useEffect } from "react"; +import clsx from "clsx"; +import { useThemeConfig, usePrismTheme } from "@docusaurus/theme-common"; import { parseLanguage, parseLines, containsLineNumbers, useCodeWordWrap, -} from "@docusaurus/theme-common/internal" -import { Highlight, type Language } from "prism-react-renderer" -import Line from "@theme/CodeBlock/Line" -import CopyButton from "@theme/CodeBlock/CopyButton" -import WordWrapButton from "@theme/CodeBlock/WordWrapButton" -import Container from "@theme/CodeBlock/Container" -import type { Props as OriginalProps } from "@theme/CodeBlock" +} from "@docusaurus/theme-common/internal"; +import { Highlight, type Language } from "prism-react-renderer"; +import Line from "@theme/CodeBlock/Line"; +import CopyButton from "@theme/CodeBlock/CopyButton"; +import WordWrapButton from "@theme/CodeBlock/WordWrapButton"; +import Container from "@theme/CodeBlock/Container"; +import type { Props as OriginalProps } from "@theme/CodeBlock"; -import styles from "./styles.module.css" +import { QuestDbSqlRunnerEmbedded } from '@site/src/components/QuestDbSqlRunnerEmbedded'; -type Props = OriginalProps & { demo?: boolean } +import styles from "./styles.module.css"; -const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/ -const codeBlockDemoRegex = /\bdemo\b/ +type Props = OriginalProps & { + demo?: boolean; + execute?: boolean; // For inline SQL execution + questdbUrl?: string; // URL for QuestDB instance +}; -function normalizeLanguage(language: string | undefined): string | undefined { - return language?.toLowerCase() -} +const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/; +const codeBlockDemoRegex = /\bdemo\b/; +const codeBlockExecuteRegex = /\bexecute\b/; -function parseCodeBlockTitle(metastring?: string): string { - return metastring?.match(codeBlockTitleRegex)?.groups?.title ?? "" -} +function normalizeLanguage(language: string | undefined): string | undefined { return language?.toLowerCase(); } +function parseCodeBlockTitle(metastring?: string): string { return metastring?.match(codeBlockTitleRegex)?.groups?.title ?? ""; } +function parseCodeBlockDemo(metastring?: string): boolean { return codeBlockDemoRegex.test(metastring ?? ""); } +function parseCodeBlockExecute(metastring?: string): boolean { return codeBlockExecuteRegex.test(metastring ?? "");} -function parseCodeBlockDemo(metastring?: string): boolean { - return codeBlockDemoRegex.test(metastring ?? "") -} export default function CodeBlockString({ - children, - className: blockClassName = "", - metastring, - title: titleProp, - showLineNumbers: showLineNumbersProp, - language: languageProp, - demo: demoProp, -}: Props): JSX.Element { + children, + className: blockClassName = "", + metastring, + title: titleProp, + showLineNumbers: showLineNumbersProp, + language: languageProp, + demo: demoProp, + execute: executeProp, + questdbUrl: questdbUrlProp, + }: Props): JSX.Element { const { prism: { defaultLanguage, magicComments }, - } = useThemeConfig() + } = useThemeConfig(); const language = normalizeLanguage( languageProp ?? parseLanguage(blockClassName) ?? defaultLanguage, - ) + ); - const prismTheme = usePrismTheme() - const wordWrap = useCodeWordWrap() + const prismTheme = usePrismTheme(); + const wordWrap = useCodeWordWrap(); - const title = parseCodeBlockTitle(metastring) || titleProp - const demo = parseCodeBlockDemo(metastring) || demoProp + const title = parseCodeBlockTitle(metastring) || titleProp; + const demo = parseCodeBlockDemo(metastring) || demoProp; + const enableExecute = parseCodeBlockExecute(metastring) || executeProp; - const { lineClassNames, code } = parseLines(children, { + const { lineClassNames, code: initialCode, tokens: initialTokens } = parseLines(children, { metastring, language, magicComments, - }) - const showLineNumbers = showLineNumbersProp ?? containsLineNumbers(metastring) + }); + const showLineNumbers = showLineNumbersProp ?? containsLineNumbers(metastring); + + const [editableCode, setEditableCode] = useState<string>(initialCode); + const [isEditing, setIsEditing] = useState<boolean>(false); + + useEffect(() => { + setEditableCode(initialCode); + }, [initialCode]); const demoUrl = demo - ? `https://demo.questdb.io/?query=${encodeURIComponent(code)}&executeQuery=true` - : null + ? `https://demo.questdb.io/?query=${encodeURIComponent(editableCode)}&executeQuery=true` // Use editableCode + : null; const handleDemoClick = () => { - window.posthog.capture("demo_started", { title }) - } + if (typeof (window as any).posthog?.capture === 'function') { + (window as any).posthog.capture("demo_started", { title }); + } + }; + + const [showExecutionResults, setShowExecutionResults] = useState<boolean>(false); + + const currentQuestDbUrl = questdbUrlProp; + + const handleExecuteToggle = () => { + setShowExecutionResults(prev => !prev); + }; + + const handleEditToggle = () => { + setIsEditing(prev => !prev); + }; + + const currentLineClassNames = isEditing + ? lineClassNames + : parseLines(editableCode, { metastring, language, magicComments }).lineClassNames; + return ( <Container @@ -75,8 +106,9 @@ export default function CodeBlockString({ className={clsx( blockClassName, language && - !blockClassName.includes(`language-${language}`) && - `language-${language}`, + !blockClassName.includes(`language-${language}`) && + `language-${language}`, + isEditing && styles.codeBlockEditing )} > {title && ( @@ -96,39 +128,69 @@ export default function CodeBlockString({ </div> )} <div className={styles.codeBlockContent}> - <Highlight - theme={prismTheme} - code={code} - language={(language ?? "text") as Language} - > - {({ className, style, tokens, getLineProps, getTokenProps }) => ( - <pre - tabIndex={0} - ref={wordWrap.codeBlockRef} - className={clsx(className, styles.codeBlock, "thin-scrollbar")} - style={style} - > - <code - className={clsx( - styles.codeBlockLines, - showLineNumbers && styles.codeBlockLinesWithNumbering, - )} + {isEditing ? ( + <textarea + value={editableCode} + onChange={(e) => setEditableCode(e.target.value)} + className={clsx(styles.codeBlock, styles.editableCodeArea, "thin-scrollbar")} + spellCheck="false" + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + rows={Math.max(10, editableCode.split('\n').length)} + style={{ + width: '100%', + fontFamily: 'var(--ifm-font-family-monospace)', + fontSize: 'var(--ifm-code-font-size)', + lineHeight: 'var(--ifm-pre-line-height)', + backgroundColor: prismTheme.plain.backgroundColor, + color: prismTheme.plain.color, + border: 'none', + resize: 'vertical', + }} + /> + ) : ( + <Highlight + theme={prismTheme} + code={editableCode} // Use editableCode + language={(language ?? "text") as Language} + > + {({ className, style, tokens, getLineProps, getTokenProps }) => ( + <pre + tabIndex={0} + ref={wordWrap.codeBlockRef} + className={clsx(className, styles.codeBlock, "thin-scrollbar")} + style={style} > - {tokens.map((line, i) => ( - <Line - key={i} - line={line} - getLineProps={getLineProps} - getTokenProps={getTokenProps} - classNames={lineClassNames[i]} - showLineNumbers={showLineNumbers} - /> - ))} - </code> - </pre> - )} - </Highlight> + <code + className={clsx( + styles.codeBlockLines, + showLineNumbers && styles.codeBlockLinesWithNumbering, + )} + > + {tokens.map((line, i) => ( + <Line + key={i} + line={line} + getLineProps={getLineProps} + getTokenProps={getTokenProps} + classNames={currentLineClassNames[i]} + showLineNumbers={showLineNumbers} + /> + ))} + </code> + </pre> + )} + </Highlight> + )} <div className={styles.buttonGroup}> + <button + onClick={handleEditToggle} + className={clsx(styles.codeButton, styles.editButton)} + title={isEditing ? "View Code" : "Edit Code"} + > + {isEditing ? 'View Code' : 'Edit Code'} + </button> {(wordWrap.isEnabled || wordWrap.isCodeScrollable) && ( <WordWrapButton className={styles.codeButton} @@ -136,9 +198,25 @@ export default function CodeBlockString({ isEnabled={wordWrap.isEnabled} /> )} - <CopyButton className={styles.codeButton} code={code} /> + <CopyButton className={styles.codeButton} code={editableCode} /> + {enableExecute && ( + <button + onClick={handleExecuteToggle} + className={clsx(styles.codeButton, styles.executeButton)} + title={showExecutionResults ? "Hide execution results" : "Execute this query"} + > + {showExecutionResults ? 'Hide Results' : 'Execute Query'} + </button> + )} </div> </div> + + {enableExecute && showExecutionResults && ( + <QuestDbSqlRunnerEmbedded + queryToExecute={editableCode} + questdbUrl={currentQuestDbUrl} + /> + )} </Container> - ) -} + ); +} \ No newline at end of file