From 4cee0e502cda6cbb76ad150fe2d6aa716f585a22 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala Date: Wed, 21 May 2025 17:55:25 +0200 Subject: [PATCH 1/4] allow in-place sql execution wip --- documentation/reference/function/parquet.md | 2 +- documentation/reference/sql/sample-by.md | 2 +- documentation/why-questdb.md | 2 +- .../QuestDbSqlRunnerEmbedded/index.tsx | 137 ++++++++++++++++++ src/theme/CodeBlock/Content/String.tsx | 117 +++++++++------ 5 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 src/components/QuestDbSqlRunnerEmbedded/index.tsx diff --git a/documentation/reference/function/parquet.md b/documentation/reference/function/parquet.md index bfdded448..05eb2074f 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 ab57c889e..bc165db86 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 89e5f77a3..0549f9f7a 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, diff --git a/src/components/QuestDbSqlRunnerEmbedded/index.tsx b/src/components/QuestDbSqlRunnerEmbedded/index.tsx new file mode 100644 index 000000000..1c1293da7 --- /dev/null +++ b/src/components/QuestDbSqlRunnerEmbedded/index.tsx @@ -0,0 +1,137 @@ +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'; + +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=20`; + + 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(`QuestDB Error (HTTP ${response.status}): ${errorJson.error || responseBody} at position ${errorJson.position || 'N/A'}`); + } catch (e: any) { + if (e.message.startsWith('QuestDB Error')) 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 &&

Total rows: {rowCount}

} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/theme/CodeBlock/Content/String.tsx b/src/theme/CodeBlock/Content/String.tsx index 87b2300b1..007f361dd 100644 --- a/src/theme/CodeBlock/Content/String.tsx +++ b/src/theme/CodeBlock/Content/String.tsx @@ -1,73 +1,88 @@ -import clsx from "clsx" -import { useThemeConfig, usePrismTheme } from "@docusaurus/theme-common" +import React, { useState } from "react"; // Ensure React and useState are imported +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'; // Adjust path as needed -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, { metastring, language, magicComments, - }) - const showLineNumbers = showLineNumbersProp ?? containsLineNumbers(metastring) + }); + const showLineNumbers = showLineNumbersProp ?? containsLineNumbers(metastring); const demoUrl = demo ? `https://demo.questdb.io/?query=${encodeURIComponent(code)}&executeQuery=true` - : null + : 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; // If passed, use it, otherwise QuestDbSqlRunnerEmbedded will use its default. + + const handleExecuteToggle = () => { + setShowExecutionResults(prev => !prev); + }; return ( <Container @@ -137,8 +152,24 @@ export default function CodeBlockString({ /> )} <CopyButton className={styles.codeButton} code={code} /> + {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={code} + questdbUrl={currentQuestDbUrl} + /> + )} </Container> - ) -} + ); +} \ No newline at end of file From cc79d65535d845324ccf11ca1b950dc9f34962f3 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala <jaromir.hamala@gmail.com> Date: Wed, 21 May 2025 18:18:26 +0200 Subject: [PATCH 2/4] show just BTC so it looks nicer when executed in-place --- documentation/why-questdb.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/why-questdb.md b/documentation/why-questdb.md index 0549f9f7a..ebdb8ac27 100644 --- a/documentation/why-questdb.md +++ b/documentation/why-questdb.md @@ -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; ``` From 43b3a7dc7980164076d17e0bf4a4ae1d92daab05 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala <jaromir.hamala@gmail.com> Date: Wed, 21 May 2025 18:25:53 +0200 Subject: [PATCH 3/4] show row limits --- src/components/QuestDbSqlRunnerEmbedded/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/QuestDbSqlRunnerEmbedded/index.tsx b/src/components/QuestDbSqlRunnerEmbedded/index.tsx index 1c1293da7..6c5878f5c 100644 --- a/src/components/QuestDbSqlRunnerEmbedded/index.tsx +++ b/src/components/QuestDbSqlRunnerEmbedded/index.tsx @@ -6,6 +6,7 @@ interface QuestDBErrorResponse {query: string; error: string; position?: number; type QuestDBResponse = QuestDBSuccessfulResponse | QuestDBErrorResponse; const QUESTDB_DEMO_URL_EMBEDDED: string = 'https://demo.questdb.io'; +const ROW_LIMIT = 20; interface QuestDbSqlRunnerEmbeddedProps { queryToExecute: string; @@ -42,7 +43,7 @@ export function QuestDbSqlRunnerEmbedded({ setNonTabularResponse(null); setRowCount(null); const encodedQuery = encodeURIComponent(queryToExecute); - const url = `${questdbUrl}/exec?query=${encodedQuery}&count=true&timings=true&limit=20`; + const url = `${questdbUrl}/exec?query=${encodedQuery}&count=true&timings=true&limit=${ROW_LIMIT}`; try { const response = await fetch(url); @@ -129,7 +130,7 @@ export function QuestDbSqlRunnerEmbedded({ </table> </div> )} - {rowCount !== null && <p>Total rows: {rowCount}</p>} + {rowCount !== null && <p>Showing {Math.min(rowCount, ROW_LIMIT)} out of {rowCount} rows</p>} </div> )} </div> From 4433a6acf477e8770834185c358b142dafe676b4 Mon Sep 17 00:00:00 2001 From: Jaromir Hamala <jaromir.hamala@gmail.com> Date: Thu, 22 May 2025 09:26:29 +0200 Subject: [PATCH 4/4] wip - editable boxes --- .../QuestDbSqlRunnerEmbedded/index.tsx | 4 +- src/theme/CodeBlock/Content/String.tsx | 127 ++++++++++++------ 2 files changed, 89 insertions(+), 42 deletions(-) diff --git a/src/components/QuestDbSqlRunnerEmbedded/index.tsx b/src/components/QuestDbSqlRunnerEmbedded/index.tsx index 6c5878f5c..072cc76cd 100644 --- a/src/components/QuestDbSqlRunnerEmbedded/index.tsx +++ b/src/components/QuestDbSqlRunnerEmbedded/index.tsx @@ -52,9 +52,9 @@ export function QuestDbSqlRunnerEmbedded({ if (!response.ok) { try { const errorJson = JSON.parse(responseBody) as { error?: string; position?: number }; - throw new Error(`QuestDB Error (HTTP ${response.status}): ${errorJson.error || responseBody} at position ${errorJson.position || 'N/A'}`); + throw new Error(`Bad query: ${errorJson.error || responseBody} at position ${errorJson.position || 'N/A'}`); } catch (e: any) { - if (e.message.startsWith('QuestDB Error')) throw e; + if (e.message.startsWith('Bad query')) throw e; throw new Error(`HTTP Error ${response.status}: ${response.statusText}. Response: ${responseBody}`); } } diff --git a/src/theme/CodeBlock/Content/String.tsx b/src/theme/CodeBlock/Content/String.tsx index 007f361dd..4b2abfd7f 100644 --- a/src/theme/CodeBlock/Content/String.tsx +++ b/src/theme/CodeBlock/Content/String.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; // Ensure React and useState are imported +import React, { useState, useEffect } from "react"; import clsx from "clsx"; import { useThemeConfig, usePrismTheme } from "@docusaurus/theme-common"; import { @@ -14,7 +14,7 @@ import WordWrapButton from "@theme/CodeBlock/WordWrapButton"; import Container from "@theme/CodeBlock/Container"; import type { Props as OriginalProps } from "@theme/CodeBlock"; -import { QuestDbSqlRunnerEmbedded } from '@site/src/components/QuestDbSqlRunnerEmbedded'; // Adjust path as needed +import { QuestDbSqlRunnerEmbedded } from '@site/src/components/QuestDbSqlRunnerEmbedded'; import styles from "./styles.module.css"; @@ -59,15 +59,22 @@ export default function CodeBlockString({ 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 [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` + ? `https://demo.questdb.io/?query=${encodeURIComponent(editableCode)}&executeQuery=true` // Use editableCode : null; const handleDemoClick = () => { @@ -78,20 +85,30 @@ export default function CodeBlockString({ const [showExecutionResults, setShowExecutionResults] = useState<boolean>(false); - const currentQuestDbUrl = questdbUrlProp; // If passed, use it, otherwise QuestDbSqlRunnerEmbedded will use its default. + 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 as="div" className={clsx( blockClassName, language && - !blockClassName.includes(`language-${language}`) && - `language-${language}`, + !blockClassName.includes(`language-${language}`) && + `language-${language}`, + isEditing && styles.codeBlockEditing )} > {title && ( @@ -111,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} @@ -151,7 +198,7 @@ export default function CodeBlockString({ isEnabled={wordWrap.isEnabled} /> )} - <CopyButton className={styles.codeButton} code={code} /> + <CopyButton className={styles.codeButton} code={editableCode} /> {enableExecute && ( <button onClick={handleExecuteToggle} @@ -166,7 +213,7 @@ export default function CodeBlockString({ {enableExecute && showExecutionResults && ( <QuestDbSqlRunnerEmbedded - queryToExecute={code} + queryToExecute={editableCode} questdbUrl={currentQuestDbUrl} /> )}