From 27779130c405d5418ec791822fdef80ff222f6e2 Mon Sep 17 00:00:00 2001 From: Sean Coughlin Date: Fri, 4 Apr 2025 09:24:30 -0400 Subject: [PATCH 1/3] Create query visualizer --- app/visualizer/layout.tsx | 17 ++ app/visualizer/page.tsx | 180 +++++++++++++ components/layout/Header.tsx | 10 + .../visualizer/VisualizationExplainer.tsx | 234 +++++++++++++++++ .../visualizations/GroupByVisualization.tsx | 123 +++++++++ .../visualizations/JoinVisualization.tsx | 111 ++++++++ .../visualizations/QueryVisualizer.tsx | 152 +++++++++++ .../visualizations/SelectVisualization.tsx | 58 +++++ .../visualizations/WhereVisualization.tsx | 53 ++++ lib/queryParser.ts | 245 ++++++++++++++++++ 10 files changed, 1183 insertions(+) create mode 100644 app/visualizer/layout.tsx create mode 100644 app/visualizer/page.tsx create mode 100644 components/visualizer/VisualizationExplainer.tsx create mode 100644 components/visualizer/visualizations/GroupByVisualization.tsx create mode 100644 components/visualizer/visualizations/JoinVisualization.tsx create mode 100644 components/visualizer/visualizations/QueryVisualizer.tsx create mode 100644 components/visualizer/visualizations/SelectVisualization.tsx create mode 100644 components/visualizer/visualizations/WhereVisualization.tsx create mode 100644 lib/queryParser.ts diff --git a/app/visualizer/layout.tsx b/app/visualizer/layout.tsx new file mode 100644 index 0000000..b382b78 --- /dev/null +++ b/app/visualizer/layout.tsx @@ -0,0 +1,17 @@ +import { Metadata } from "next"; +import { defaultMetadata } from "../sitemapmetadata"; + +export const metadata: Metadata = { + ...defaultMetadata, + title: "SQL Query Visualizer - See Your Queries in Action", + description: + "Visualize how SQL queries interact with databases through interactive diagrams and visual explanations", +}; + +export default function VisualizerLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/visualizer/page.tsx b/app/visualizer/page.tsx new file mode 100644 index 0000000..55d22de --- /dev/null +++ b/app/visualizer/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { SqlEditor } from "@/components/lessons/SqlEditor"; +import { initializeDatabase } from "@/lib/lessons"; +import { useSqlJs } from "@/hooks/useSqlJs"; +import Loading from "@/components/ui/loading"; +import QueryVisualizer from "@/components/visualizer/visualizations/QueryVisualizer"; +import VisualizationExplainer from "@/components/visualizer/VisualizationExplainer"; +import { SqlResult } from "@/types/database"; +import { parseQuery } from "@/lib/queryParser"; + +export default function VisualizerPage() { + const [dbInitialized, setDbInitialized] = useState(false); + const [queryType, setQueryType] = useState(null); + const [parsedQuery, setParsedQuery] = useState(null); + const [queryResults, setQueryResults] = useState(null); + const [currentQuery, setCurrentQuery] = useState(""); + + const { + isLoading, + error, + executeQuery, + initializeDatabase: initDb, + db, + } = useSqlJs(); + + // Initialize the database when component loads + useEffect(() => { + if (db && !dbInitialized) { + const sql = initializeDatabase(); + const success = initDb(sql); + if (success) { + setDbInitialized(true); + } + } + }, [db, dbInitialized, initDb]); + + const handleExecuteQuery = (sql: string) => { + setCurrentQuery(sql); + + // Parse the query to determine its type and structure + const parsed = parseQuery(sql); + setParsedQuery(parsed); + setQueryType(parsed.type); + + // Execute the query + const result = executeQuery(sql); + + if (result.results && result.results.length > 0) { + setQueryResults(result.results[0]); + } else { + setQueryResults(null); + } + + return result; + }; + + const sampleQueries = { + "Basic SELECT": "SELECT * FROM Customers LIMIT 5;", + "Filtering (WHERE)": "SELECT name, price FROM Products WHERE price > 100;", + "JOIN Example": + "SELECT c.first_name, c.last_name, o.order_date, o.total_amount FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id LIMIT 5;", + "GROUP BY": + "SELECT category, COUNT(*) as count, AVG(price) as avg_price FROM Products GROUP BY category;", + "Nested Query": + "SELECT name, price FROM Products WHERE price > (SELECT AVG(price) FROM Products);", + }; + + const loadSampleQuery = (query: string) => { + // We don't execute the query here, just set it in the editor + // The user will need to click Run Query to execute it + setCurrentQuery(query); + }; + + if (isLoading) { + return ( + + ); + } + + if (error) { + return ( +
+
+

+ Error Loading SQL Engine +

+

{error}

+

+ Try refreshing the page or check your console for more details. +

+
+
+ ); + } + + if (!dbInitialized) { + return ; + } + + return ( +
+
+
+

+ SQL Query Visualizer +

+

+ Visualize how SQL queries work and understand the query execution + process +

+
+ +
+ + + SQL Editor +
+ {Object.entries(sampleQueries).map(([name, query]) => ( + + ))} +
+
+ + + +
+ + {queryResults && ( + <> + + + Query Visualization + + + + + + + + + Query Explanation + + + + + + + )} +
+
+
+ ); +} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 673588f..600e178 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -47,6 +47,16 @@ export default function Header() { > Sandbox + + Visualizer +
diff --git a/components/visualizer/VisualizationExplainer.tsx b/components/visualizer/VisualizationExplainer.tsx new file mode 100644 index 0000000..6b28a4b --- /dev/null +++ b/components/visualizer/VisualizationExplainer.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { ParsedQuery } from "@/lib/queryParser"; + +interface VisualizationExplainerProps { + query: string; + queryType: string | null; + parsedQuery: ParsedQuery | null; + resultCount: number; +} + +const VisualizationExplainer: React.FC = ({ + query, + queryType, + parsedQuery, + resultCount, +}) => { + if (!parsedQuery) { + return ( +
+ Execute a query to see its explanation +
+ ); + } + + const renderSelectExplanation = () => { + if (!parsedQuery) return null; + + const parts = []; + + // FROM explanation + parts.push( +
+

FROM

+

+ The query starts by accessing the table(s):{" "} + + {parsedQuery.from.tables.join(", ")} + + .{" "} + {parsedQuery.from.tables.length > 1 && + "Multiple tables are specified, which will be combined."} +

+
+ ); + + // JOIN explanation + if (parsedQuery.join && parsedQuery.join.length > 0) { + parts.push( +
+

JOIN

+

+ The query combines data from multiple tables using{" "} + {parsedQuery.join.map((join, idx) => ( + + + {join.type} JOIN + {" "} + on the condition{" "} + + {join.on} + + {idx < parsedQuery.join!.length - 1 ? " and " : ""} + + ))} + . Joins connect related data across tables based on matching values. +

+
+ ); + } + + // WHERE explanation + if (parsedQuery.where) { + parts.push( +
+

WHERE

+

+ The data is filtered using the condition:{" "} + + {parsedQuery.where.conditions.join(" AND ")} + + . Only rows that satisfy this condition are included in the results. +

+
+ ); + } + + // GROUP BY explanation + if (parsedQuery.groupBy) { + parts.push( +
+

GROUP BY

+

+ The results are grouped by{" "} + + {parsedQuery.groupBy.columns.join(", ")} + + . This means rows with the same values in these columns are + combined, allowing for aggregate functions like COUNT, SUM, AVG to + compute summaries for each group. +

+
+ ); + } + + // ORDER BY explanation + if (parsedQuery.orderBy) { + parts.push( +
+

ORDER BY

+

+ The results are sorted by{" "} + + {parsedQuery.orderBy.columns.join(", ")} + {" "} + in{" "} + + {parsedQuery.orderBy.direction} + {" "} + order. This determines the sequence in which rows appear in the + final result. +

+
+ ); + } + + // LIMIT explanation + if (parsedQuery.limit !== undefined) { + parts.push( +
+

LIMIT

+

+ The query returns a maximum of{" "} + + {parsedQuery.limit} + {" "} + rows + {parsedQuery.offset !== undefined && ( + <> + {", starting from position "} + + {parsedQuery.offset + 1} + + + )} + . This controls the size of the result set. +

+
+ ); + } + + // SELECT explanation (positioned last in explanation but usually first in execution) + parts.push( +
+

SELECT

+

+ {parsedQuery.select.allColumns + ? "All columns (*) are selected from the specified table(s)." + : `The query selects the specific columns: `} + {!parsedQuery.select.allColumns && ( + + {parsedQuery.select.columns.join(", ")} + + )} + {". "} + The final result contains {resultCount} row + {resultCount !== 1 ? "s" : ""}. +

+
+ ); + + return parts; + }; + + return ( +
+
+

Query Execution Flow

+

+ SQL queries are conceptually executed in a specific order, different + from how they're written. Here's how your query is processed: +

+ +
    +
  1. + FROM: Database identifies which table(s) to query +
  2. + {parsedQuery.join && parsedQuery.join.length > 0 && ( +
  3. + JOIN: Tables are combined based on the specified + conditions +
  4. + )} + {parsedQuery.where && ( +
  5. + WHERE: Rows are filtered based on conditions +
  6. + )} + {parsedQuery.groupBy && ( +
  7. + GROUP BY: Rows are grouped for aggregation +
  8. + )} +
  9. + SELECT: Specified columns are retrieved +
  10. + {parsedQuery.orderBy && ( +
  11. + ORDER BY: Results are sorted +
  12. + )} + {parsedQuery.limit !== undefined && ( +
  13. + LIMIT/OFFSET: Result set is restricted to + specified rows +
  14. + )} +
+
+ +
+

Step-by-Step Explanation

+ {parsedQuery.type === "SELECT" ? ( + renderSelectExplanation() + ) : ( +

+ Explanation for {parsedQuery.type} queries is not yet supported. +

+ )} +
+
+ ); +}; + +export default VisualizationExplainer; diff --git a/components/visualizer/visualizations/GroupByVisualization.tsx b/components/visualizer/visualizations/GroupByVisualization.tsx new file mode 100644 index 0000000..c863004 --- /dev/null +++ b/components/visualizer/visualizations/GroupByVisualization.tsx @@ -0,0 +1,123 @@ +import React from "react"; + +interface GroupByVisualizationProps { + columns: string[]; + selectColumns: string[]; +} + +const GroupByVisualization: React.FC = ({ + columns, + selectColumns, +}) => { + // Detect if there are aggregate functions in the SELECT clause + const hasAggregations = selectColumns.some((col) => { + const lowerCol = col.toLowerCase(); + return ( + lowerCol.includes("count(") || + lowerCol.includes("sum(") || + lowerCol.includes("avg(") || + lowerCol.includes("min(") || + lowerCol.includes("max(") + ); + }); + + // Extract aggregate functions for display + const aggregateFunctions = selectColumns + .filter((col) => { + const lowerCol = col.toLowerCase(); + return ( + lowerCol.includes("count(") || + lowerCol.includes("sum(") || + lowerCol.includes("avg(") || + lowerCol.includes("min(") || + lowerCol.includes("max(") + ); + }) + .map((col) => { + // Extract just the function name and its argument + const match = col.match(/(\w+)\(([^)]+)\)(?:\s+as\s+(\w+))?/i); + if (match) { + return { + function: match[1].toUpperCase(), + column: match[2], + alias: match[3] || null, + }; + } + return { function: col, column: "", alias: null }; + }); + + return ( +
+
+

+ Rows are grouped by{" "} + {columns.length === 1 ? "this column" : "these columns"}: +

+
+ {columns.map((column, idx) => ( +
+ {column} +
+ ))} +
+ + {hasAggregations && ( +
+

+ Applying these aggregate functions for each group: +

+
+ {aggregateFunctions.map((agg, idx) => ( +
+ + {agg.function} + + ({agg.column}) + {agg.alias && ( + + → renamed as "{agg.alias}" + + )} +
+ ))} +
+
+ )} +
+ +
+
+ + + +
+
+ The GROUP BY clause collects rows with the same values into summary + rows. + {hasAggregations + ? " This allows aggregate functions to calculate summary values for each group." + : " Without aggregate functions, it's similar to using DISTINCT to remove duplicates."} +
+
+
+ ); +}; + +export default GroupByVisualization; diff --git a/components/visualizer/visualizations/JoinVisualization.tsx b/components/visualizer/visualizations/JoinVisualization.tsx new file mode 100644 index 0000000..654293e --- /dev/null +++ b/components/visualizer/visualizations/JoinVisualization.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +interface JoinVisualizationProps { + joins: Array<{ + type: string; + table: string; + on: string; + }>; + tables: string[]; +} + +const JoinVisualization: React.FC = ({ + joins, + tables, +}) => { + // Get the main table (first table in the FROM clause) + const mainTable = tables[0]; + + // Helper to get join type description + const getJoinTypeDescription = (type: string) => { + switch (type.toUpperCase()) { + case "INNER": + return "Includes rows that match in both tables"; + case "LEFT": + return "Includes all rows from the left table, plus matching rows from the right table"; + case "RIGHT": + return "Includes all rows from the right table, plus matching rows from the left table"; + case "FULL": + return "Includes all rows from both tables"; + default: + return "Joins tables"; + } + }; + + // Helper to get join type color + const getJoinTypeColor = (type: string) => { + switch (type.toUpperCase()) { + case "INNER": + return "bg-purple-100 border-purple-400"; + case "LEFT": + return "bg-blue-100 border-blue-400"; + case "RIGHT": + return "bg-green-100 border-green-400"; + case "FULL": + return "bg-indigo-100 border-indigo-400"; + default: + return "bg-gray-100 border-gray-400"; + } + }; + + return ( +
+
+

+ Starting with table {mainTable}{" "} + and combining with: +

+
+ {joins.map((join, idx) => ( +
+
+
+ {join.type.toUpperCase()} JOIN with {join.table} +
+
+ {getJoinTypeDescription(join.type)} +
+
+
+
+ Join Condition: +
+ + {join.on} + +
+
+ ))} +
+
+ +
+
+ + + +
+
+ JOINs combine rows from two or more tables based on related columns, + creating a unified result set. +
+
+
+ ); +}; + +export default JoinVisualization; diff --git a/components/visualizer/visualizations/QueryVisualizer.tsx b/components/visualizer/visualizations/QueryVisualizer.tsx new file mode 100644 index 0000000..82c5c0b --- /dev/null +++ b/components/visualizer/visualizations/QueryVisualizer.tsx @@ -0,0 +1,152 @@ +import React from "react"; +import { SqlResult } from "@/types/database"; +import { ParsedQuery } from "@/lib/queryParser"; +import { ResultTable } from "@/components/lessons/ResultTable"; +import SelectVisualization from "./SelectVisualization"; +import JoinVisualization from "./JoinVisualization"; +import GroupByVisualization from "./GroupByVisualization"; +import WhereVisualization from "./WhereVisualization"; + +interface QueryVisualizerProps { + queryResults: SqlResult; + queryType: string | null; + parsedQuery: ParsedQuery | null; +} + +const QueryVisualizer: React.FC = ({ + queryResults, + queryType, + parsedQuery, +}) => { + if (!queryResults || !parsedQuery) { + return ( +
+ Execute a query to see its visualization +
+ ); + } + + return ( +
+
+ {/* Visualization panels based on query type */} +
+ {parsedQuery.type === "SELECT" && ( + <> + {/* SELECT Visualization */} +
+

+ SELECT Operation +

+ +
+ + {/* WHERE Visualization */} + {parsedQuery.where && ( +
+

+ WHERE Conditions +

+ +
+ )} + + {/* JOIN Visualization */} + {parsedQuery.join && parsedQuery.join.length > 0 && ( +
+

+ JOIN Operation +

+ +
+ )} + + {/* GROUP BY Visualization */} + {parsedQuery.groupBy && ( +
+

+ GROUP BY Operation +

+ +
+ )} + + {/* ORDER BY Information */} + {parsedQuery.orderBy && ( +
+

+ ORDER BY +

+

+ Results are sorted by{" "} + + {parsedQuery.orderBy.columns.join(", ")} + {" "} + in{" "} + + {parsedQuery.orderBy.direction} + {" "} + order. +

+
+ )} + + {/* LIMIT Information */} + {parsedQuery.limit !== undefined && ( +
+

+ LIMIT +

+

+ Results are limited to{" "} + + {parsedQuery.limit} + {" "} + rows + {parsedQuery.offset !== undefined && ( + <> + {" "} + starting from row{" "} + + {parsedQuery.offset + 1} + + + )} + . +

+
+ )} + + )} +
+ + {/* Results display */} +
+
+

Query Results

+
+ {queryResults.values.length} rows returned +
+
+
+ +
+
+
+
+ ); +}; + +export default QueryVisualizer; diff --git a/components/visualizer/visualizations/SelectVisualization.tsx b/components/visualizer/visualizations/SelectVisualization.tsx new file mode 100644 index 0000000..da11d57 --- /dev/null +++ b/components/visualizer/visualizations/SelectVisualization.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +interface SelectVisualizationProps { + columns: string[]; + allColumns: boolean; + tables: string[]; +} + +const SelectVisualization: React.FC = ({ + columns, + allColumns, + tables, +}) => { + return ( +
+ {allColumns ? ( +
+
+ * +
+
+

+ Selecting all columns from{" "} + {tables.length === 1 ? "the table" : "tables"} {tables.join(", ")} +

+
+
+ ) : ( +
+

+ Selecting{" "} + + {columns.length} specific column{columns.length !== 1 ? "s" : ""} + {" "} + from {tables.length === 1 ? "the table" : "tables"}{" "} + {tables.join(", ")} +

+
+ {columns.map((column, idx) => ( +
+ {column} +
+ ))} +
+
+ )} +
+ The SELECT clause determines which columns will appear in the final + result. +
+
+ ); +}; + +export default SelectVisualization; diff --git a/components/visualizer/visualizations/WhereVisualization.tsx b/components/visualizer/visualizations/WhereVisualization.tsx new file mode 100644 index 0000000..e6af863 --- /dev/null +++ b/components/visualizer/visualizations/WhereVisualization.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +interface WhereVisualizationProps { + conditions: string[]; +} + +const WhereVisualization: React.FC = ({ + conditions, +}) => { + return ( +
+
+

+ Records must satisfy these conditions to be included in results: +

+
+ {conditions.map((condition, idx) => ( +
+ {condition} +
+ ))} +
+
+
+
+ + + +
+
+ The WHERE clause filters rows from the data source, eliminating rows + that don't match your conditions. +
+
+
+ ); +}; + +export default WhereVisualization; diff --git a/lib/queryParser.ts b/lib/queryParser.ts new file mode 100644 index 0000000..acd83b1 --- /dev/null +++ b/lib/queryParser.ts @@ -0,0 +1,245 @@ +/** + * A simple SQL query parser to extract key components for visualization + * + * Note: This is a simplified parser and doesn't handle all SQL syntax variations + * It's meant for educational purposes to identify basic query structure for visualization + */ + +export interface ParsedQuery { + type: string; + select: { + columns: string[]; + allColumns: boolean; + }; + from: { + tables: string[]; + alias?: Record; + }; + where?: { + conditions: string[]; + }; + join?: { + type: string; + table: string; + on: string; + }[]; + groupBy?: { + columns: string[]; + }; + orderBy?: { + columns: string[]; + direction: string; + }; + limit?: number; + offset?: number; +} + +export function parseQuery(query: string): ParsedQuery { + // Normalize the query for easier parsing + const normalizedQuery = query.replace(/\s+/g, " ").trim(); + + // Basic structure + const result: ParsedQuery = { + type: "UNKNOWN", + select: { + columns: [], + allColumns: false, + }, + from: { + tables: [], + alias: {}, + }, + }; + + // Determine query type + if (normalizedQuery.toUpperCase().startsWith("SELECT")) { + result.type = "SELECT"; + } else if (normalizedQuery.toUpperCase().startsWith("INSERT")) { + result.type = "INSERT"; + } else if (normalizedQuery.toUpperCase().startsWith("UPDATE")) { + result.type = "UPDATE"; + } else if (normalizedQuery.toUpperCase().startsWith("DELETE")) { + result.type = "DELETE"; + } + + // Only process SELECT queries for now + if (result.type === "SELECT") { + // Extract SELECT columns + const selectMatch = normalizedQuery.match(/SELECT\s+(.*?)\s+FROM/i); + if (selectMatch && selectMatch[1]) { + const columnsStr = selectMatch[1]; + if (columnsStr.trim() === "*") { + result.select.allColumns = true; + } else { + // Split columns handling potential functions like COUNT(*) + const columns = splitWithAwareness(columnsStr); + result.select.columns = columns.map((col) => col.trim()); + } + } + + // Extract FROM tables + const fromMatch = normalizedQuery.match( + /FROM\s+(.*?)(?:\s+WHERE|\s+GROUP BY|\s+ORDER BY|\s+LIMIT|\s*;|\s*$)/i + ); + if (fromMatch && fromMatch[1]) { + const fromClause = fromMatch[1].trim(); + + // Check for JOINs + if (fromClause.toUpperCase().includes("JOIN")) { + result.join = []; + + // Split out the first table + const firstTableMatch = fromClause.match( + /(.*?)(?:\s+(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\s*JOIN)/i + ); + if (firstTableMatch && firstTableMatch[1]) { + const mainTableParts = firstTableMatch[1].trim().split(/\s+/); + result.from.tables.push(mainTableParts[0]); + + // Check for alias + if (mainTableParts.length > 1) { + result.from.alias = result.from.alias || {}; + result.from.alias[mainTableParts[0]] = + mainTableParts[mainTableParts.length - 1]; + } + } + + // Extract JOIN clauses + const joinPattern = + /(?:(LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\s*JOIN)\s+(.*?)\s+ON\s+(.*?)(?:\s+(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\s*JOIN|\s+WHERE|\s+GROUP BY|\s+ORDER BY|\s+LIMIT|\s*;|\s*$)/gi; + let joinMatch; + + while ((joinMatch = joinPattern.exec(fromClause)) !== null) { + const joinType = joinMatch[1] ? joinMatch[1].toUpperCase() : "INNER"; + const joinTable = joinMatch[2].trim(); + const joinCondition = joinMatch[3].trim(); + + // Extract alias if any + const tableParts = joinTable.split(/\s+/); + const actualTable = tableParts[0]; + const tableAlias = + tableParts.length > 1 + ? tableParts[tableParts.length - 1] + : actualTable; + + result.from.tables.push(actualTable); + result.from.alias = result.from.alias || {}; + result.from.alias[actualTable] = tableAlias; + + result.join.push({ + type: joinType, + table: actualTable, + on: joinCondition, + }); + } + } else { + // Simple FROM clause + const tables = fromClause.split(","); + tables.forEach((table) => { + const tableParts = table.trim().split(/\s+/); + const actualTable = tableParts[0]; + result.from.tables.push(actualTable); + + // Check for alias + if (tableParts.length > 1) { + result.from.alias = result.from.alias || {}; + result.from.alias[actualTable] = tableParts[tableParts.length - 1]; + } + }); + } + } + + // Extract WHERE conditions + const whereMatch = normalizedQuery.match( + /WHERE\s+(.*?)(?:\s+GROUP BY|\s+ORDER BY|\s+LIMIT|\s*;|\s*$)/i + ); + if (whereMatch && whereMatch[1]) { + result.where = { + conditions: [whereMatch[1].trim()], + }; + } + + // Extract GROUP BY + const groupByMatch = normalizedQuery.match( + /GROUP BY\s+(.*?)(?:\s+HAVING|\s+ORDER BY|\s+LIMIT|\s*;|\s*$)/i + ); + if (groupByMatch && groupByMatch[1]) { + result.groupBy = { + columns: groupByMatch[1].split(",").map((col) => col.trim()), + }; + } + + // Extract ORDER BY + const orderByMatch = normalizedQuery.match( + /ORDER BY\s+(.*?)(?:\s+LIMIT|\s*;|\s*$)/i + ); + if (orderByMatch && orderByMatch[1]) { + const orderByClause = orderByMatch[1].trim(); + const orderDirectionMatch = orderByClause.match(/(.*?)\s+(ASC|DESC)$/i); + + if (orderDirectionMatch) { + result.orderBy = { + columns: orderDirectionMatch[1].split(",").map((col) => col.trim()), + direction: orderDirectionMatch[2].toUpperCase(), + }; + } else { + result.orderBy = { + columns: orderByClause.split(",").map((col) => col.trim()), + direction: "ASC", // Default + }; + } + } + + // Extract LIMIT + const limitMatch = normalizedQuery.match( + /LIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+))?/i + ); + if (limitMatch) { + result.limit = parseInt(limitMatch[1], 10); + + if (limitMatch[2]) { + result.offset = parseInt(limitMatch[2], 10); + } + } + + // Check for OFFSET without LIMIT + if (!result.offset) { + const offsetMatch = normalizedQuery.match(/OFFSET\s+(\d+)/i); + if (offsetMatch) { + result.offset = parseInt(offsetMatch[1], 10); + } + } + } + + return result; +} + +// Helper function to split by commas but be aware of function calls like COUNT(*) +function splitWithAwareness(str: string): string[] { + const result: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + + if (char === "(") { + depth++; + current += char; + } else if (char === ")") { + depth--; + current += char; + } else if (char === "," && depth === 0) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} From 287b8a7363c9cd4f8fd73932f2853b5fa51dfe85 Mon Sep 17 00:00:00 2001 From: Sean Coughlin Date: Fri, 4 Apr 2025 09:30:36 -0400 Subject: [PATCH 2/3] Replace query visualizer with static visualization --- app/visualizer/page.tsx | 222 ++++++---- .../visualizer/VisualizationExplainer.tsx | 234 ----------- .../operations/GroupByOperation.tsx | 360 ++++++++++++++++ .../visualizer/operations/JoinOperation.tsx | 387 ++++++++++++++++++ .../visualizer/operations/SelectOperation.tsx | 236 +++++++++++ .../visualizer/operations/WhereOperation.tsx | 345 ++++++++++++++++ .../visualizations/GroupByVisualization.tsx | 123 ------ .../visualizations/JoinVisualization.tsx | 111 ----- .../visualizations/QueryVisualizer.tsx | 152 ------- .../visualizations/SelectVisualization.tsx | 58 --- .../visualizations/WhereVisualization.tsx | 53 --- public/images/groupby-icon.svg | 10 + public/images/join-icon.svg | 5 + public/images/select-icon.svg | 6 + public/images/where-icon.svg | 3 + 15 files changed, 1483 insertions(+), 822 deletions(-) delete mode 100644 components/visualizer/VisualizationExplainer.tsx create mode 100644 components/visualizer/operations/GroupByOperation.tsx create mode 100644 components/visualizer/operations/JoinOperation.tsx create mode 100644 components/visualizer/operations/SelectOperation.tsx create mode 100644 components/visualizer/operations/WhereOperation.tsx delete mode 100644 components/visualizer/visualizations/GroupByVisualization.tsx delete mode 100644 components/visualizer/visualizations/JoinVisualization.tsx delete mode 100644 components/visualizer/visualizations/QueryVisualizer.tsx delete mode 100644 components/visualizer/visualizations/SelectVisualization.tsx delete mode 100644 components/visualizer/visualizations/WhereVisualization.tsx create mode 100644 public/images/groupby-icon.svg create mode 100644 public/images/join-icon.svg create mode 100644 public/images/select-icon.svg create mode 100644 public/images/where-icon.svg diff --git a/app/visualizer/page.tsx b/app/visualizer/page.tsx index 55d22de..adfc457 100644 --- a/app/visualizer/page.tsx +++ b/app/visualizer/page.tsx @@ -2,21 +2,23 @@ import { useState, useEffect } from "react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { SqlEditor } from "@/components/lessons/SqlEditor"; import { initializeDatabase } from "@/lib/lessons"; import { useSqlJs } from "@/hooks/useSqlJs"; import Loading from "@/components/ui/loading"; -import QueryVisualizer from "@/components/visualizer/visualizations/QueryVisualizer"; -import VisualizationExplainer from "@/components/visualizer/VisualizationExplainer"; import { SqlResult } from "@/types/database"; import { parseQuery } from "@/lib/queryParser"; +import Image from "next/image"; + +// SQL operation visualizations +import SelectOperation from "@/components/visualizer/operations/SelectOperation"; +import WhereOperation from "@/components/visualizer/operations/WhereOperation"; +import JoinOperation from "@/components/visualizer/operations/JoinOperation"; +import GroupByOperation from "@/components/visualizer/operations/GroupByOperation"; export default function VisualizerPage() { const [dbInitialized, setDbInitialized] = useState(false); - const [queryType, setQueryType] = useState(null); - const [parsedQuery, setParsedQuery] = useState(null); + const [activeOperation, setActiveOperation] = useState("select"); const [queryResults, setQueryResults] = useState(null); - const [currentQuery, setCurrentQuery] = useState(""); const { isLoading, @@ -33,45 +35,49 @@ export default function VisualizerPage() { const success = initDb(sql); if (success) { setDbInitialized(true); + + // Run the initial SELECT query to show data on load + handleOperationChange("select"); } } }, [db, dbInitialized, initDb]); - const handleExecuteQuery = (sql: string) => { - setCurrentQuery(sql); - - // Parse the query to determine its type and structure - const parsed = parseQuery(sql); - setParsedQuery(parsed); - setQueryType(parsed.type); + // Function to run a preset query for the selected operation + const handleOperationChange = (operation: string) => { + setActiveOperation(operation); + + let query = ""; + + // Define preset queries for each operation + switch (operation) { + case "select": + query = + "SELECT customer_id, first_name, last_name, email FROM Customers LIMIT 5;"; + break; + case "where": + query = + "SELECT name, price, category FROM Products WHERE price > 70 ORDER BY price DESC;"; + break; + case "join": + query = + "SELECT c.first_name, c.last_name, o.order_date, o.total_amount FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id LIMIT 6;"; + break; + case "groupby": + query = + "SELECT category, COUNT(*) as product_count, AVG(price) as avg_price FROM Products GROUP BY category;"; + break; + default: + query = "SELECT * FROM Customers LIMIT 5;"; + } // Execute the query - const result = executeQuery(sql); + const result = executeQuery(query); if (result.results && result.results.length > 0) { setQueryResults(result.results[0]); } else { setQueryResults(null); } - - return result; - }; - - const sampleQueries = { - "Basic SELECT": "SELECT * FROM Customers LIMIT 5;", - "Filtering (WHERE)": "SELECT name, price FROM Products WHERE price > 100;", - "JOIN Example": - "SELECT c.first_name, c.last_name, o.order_date, o.total_amount FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id LIMIT 5;", - "GROUP BY": - "SELECT category, COUNT(*) as count, AVG(price) as avg_price FROM Products GROUP BY category;", - "Nested Query": - "SELECT name, price FROM Products WHERE price > (SELECT AVG(price) FROM Products);", - }; - - const loadSampleQuery = (query: string) => { - // We don't execute the query here, just set it in the editor - // The user will need to click Run Query to execute it - setCurrentQuery(query); }; if (isLoading) { @@ -111,70 +117,104 @@ export default function VisualizerPage() { SQL Query Visualizer

- Visualize how SQL queries work and understand the query execution - process + Explore how different SQL operations transform data through visual + explanations

-
- - - SQL Editor -
- {Object.entries(sampleQueries).map(([name, query]) => ( - - ))} -
-
- - - -
- - {queryResults && ( - <> - - - Query Visualization - - - - - - - - - Query Explanation - - - - - - + {/* Operation Selector */} +
+

+ Choose an SQL Operation +

+
+ handleOperationChange("select")} + icon="/images/select-icon.svg" + color="bg-blue-100 hover:bg-blue-200 text-blue-800" + /> + handleOperationChange("where")} + icon="/images/where-icon.svg" + color="bg-yellow-100 hover:bg-yellow-200 text-yellow-800" + /> + handleOperationChange("join")} + icon="/images/join-icon.svg" + color="bg-purple-100 hover:bg-purple-200 text-purple-800" + /> + handleOperationChange("groupby")} + icon="/images/groupby-icon.svg" + color="bg-green-100 hover:bg-green-200 text-green-800" + /> +
+
+ + {/* Operation Visualization */} +
+ {activeOperation === "select" && ( + + )} + {activeOperation === "where" && ( + + )} + {activeOperation === "join" && ( + + )} + {activeOperation === "groupby" && ( + )}
); } + +// Helper component for operation selection buttons +interface OperationButtonProps { + name: string; + description: string; + isActive: boolean; + onClick: () => void; + icon: string; + color: string; +} + +function OperationButton({ + name, + description, + isActive, + onClick, + icon, + color, +}: OperationButtonProps) { + return ( + + ); +} diff --git a/components/visualizer/VisualizationExplainer.tsx b/components/visualizer/VisualizationExplainer.tsx deleted file mode 100644 index 6b28a4b..0000000 --- a/components/visualizer/VisualizationExplainer.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from "react"; -import { ParsedQuery } from "@/lib/queryParser"; - -interface VisualizationExplainerProps { - query: string; - queryType: string | null; - parsedQuery: ParsedQuery | null; - resultCount: number; -} - -const VisualizationExplainer: React.FC = ({ - query, - queryType, - parsedQuery, - resultCount, -}) => { - if (!parsedQuery) { - return ( -
- Execute a query to see its explanation -
- ); - } - - const renderSelectExplanation = () => { - if (!parsedQuery) return null; - - const parts = []; - - // FROM explanation - parts.push( -
-

FROM

-

- The query starts by accessing the table(s):{" "} - - {parsedQuery.from.tables.join(", ")} - - .{" "} - {parsedQuery.from.tables.length > 1 && - "Multiple tables are specified, which will be combined."} -

-
- ); - - // JOIN explanation - if (parsedQuery.join && parsedQuery.join.length > 0) { - parts.push( -
-

JOIN

-

- The query combines data from multiple tables using{" "} - {parsedQuery.join.map((join, idx) => ( - - - {join.type} JOIN - {" "} - on the condition{" "} - - {join.on} - - {idx < parsedQuery.join!.length - 1 ? " and " : ""} - - ))} - . Joins connect related data across tables based on matching values. -

-
- ); - } - - // WHERE explanation - if (parsedQuery.where) { - parts.push( -
-

WHERE

-

- The data is filtered using the condition:{" "} - - {parsedQuery.where.conditions.join(" AND ")} - - . Only rows that satisfy this condition are included in the results. -

-
- ); - } - - // GROUP BY explanation - if (parsedQuery.groupBy) { - parts.push( -
-

GROUP BY

-

- The results are grouped by{" "} - - {parsedQuery.groupBy.columns.join(", ")} - - . This means rows with the same values in these columns are - combined, allowing for aggregate functions like COUNT, SUM, AVG to - compute summaries for each group. -

-
- ); - } - - // ORDER BY explanation - if (parsedQuery.orderBy) { - parts.push( -
-

ORDER BY

-

- The results are sorted by{" "} - - {parsedQuery.orderBy.columns.join(", ")} - {" "} - in{" "} - - {parsedQuery.orderBy.direction} - {" "} - order. This determines the sequence in which rows appear in the - final result. -

-
- ); - } - - // LIMIT explanation - if (parsedQuery.limit !== undefined) { - parts.push( -
-

LIMIT

-

- The query returns a maximum of{" "} - - {parsedQuery.limit} - {" "} - rows - {parsedQuery.offset !== undefined && ( - <> - {", starting from position "} - - {parsedQuery.offset + 1} - - - )} - . This controls the size of the result set. -

-
- ); - } - - // SELECT explanation (positioned last in explanation but usually first in execution) - parts.push( -
-

SELECT

-

- {parsedQuery.select.allColumns - ? "All columns (*) are selected from the specified table(s)." - : `The query selects the specific columns: `} - {!parsedQuery.select.allColumns && ( - - {parsedQuery.select.columns.join(", ")} - - )} - {". "} - The final result contains {resultCount} row - {resultCount !== 1 ? "s" : ""}. -

-
- ); - - return parts; - }; - - return ( -
-
-

Query Execution Flow

-

- SQL queries are conceptually executed in a specific order, different - from how they're written. Here's how your query is processed: -

- -
    -
  1. - FROM: Database identifies which table(s) to query -
  2. - {parsedQuery.join && parsedQuery.join.length > 0 && ( -
  3. - JOIN: Tables are combined based on the specified - conditions -
  4. - )} - {parsedQuery.where && ( -
  5. - WHERE: Rows are filtered based on conditions -
  6. - )} - {parsedQuery.groupBy && ( -
  7. - GROUP BY: Rows are grouped for aggregation -
  8. - )} -
  9. - SELECT: Specified columns are retrieved -
  10. - {parsedQuery.orderBy && ( -
  11. - ORDER BY: Results are sorted -
  12. - )} - {parsedQuery.limit !== undefined && ( -
  13. - LIMIT/OFFSET: Result set is restricted to - specified rows -
  14. - )} -
-
- -
-

Step-by-Step Explanation

- {parsedQuery.type === "SELECT" ? ( - renderSelectExplanation() - ) : ( -

- Explanation for {parsedQuery.type} queries is not yet supported. -

- )} -
-
- ); -}; - -export default VisualizationExplainer; diff --git a/components/visualizer/operations/GroupByOperation.tsx b/components/visualizer/operations/GroupByOperation.tsx new file mode 100644 index 0000000..85fc642 --- /dev/null +++ b/components/visualizer/operations/GroupByOperation.tsx @@ -0,0 +1,360 @@ +import React from "react"; +import { SqlResult } from "@/types/database"; +import { ResultTable } from "@/components/lessons/ResultTable"; + +interface GroupByOperationProps { + results: SqlResult | null; +} + +const GroupByOperation: React.FC = ({ results }) => { + if (!results) { + return
No results available
; + } + + // Example query that's being visualized + const exampleQuery = + "SELECT category, COUNT(*) as product_count, AVG(price) as avg_price FROM Products GROUP BY category;"; + + return ( +
+
+

+ GROUP BY Operation +

+
+ {exampleQuery} +
+
+ +
+ {/* Visual explanation */} +
+
+

+ How GROUP BY Works +

+ +
+ {/* Initial data */} +
+
+ Original Product Data +
+
+
+
+ Name +
+
+ Price +
+
+ Category +
+
+
+
+
Laptop Pro
+
$1299.99
+
Electronics
+
+
+
Smartphone X
+
$799.99
+
Electronics
+
+
+
Headphones
+
$159.99
+
Electronics
+
+
+
Coffee Maker
+
$89.99
+
+ Home Appliances +
+
+
+
Blender
+
$69.99
+
+ Home Appliances +
+
+
+
Running Shoes
+
$79.99
+
+ Sportswear +
+
+
+
+
+ + {/* Grouping process arrows */} +
+
+
+ + + +
+
+ GROUP BY category +
+
+ + + +
+
+
+ + {/* Grouped data */} +
+
+ Grouped Results with Aggregation +
+
+
+
+ Category +
+
+ COUNT(*) +
+
+ AVG(price) +
+
+
+
+
Electronics
+
3 products
+
$753.32
+
+
+
Home Appliances
+
2 products
+
$79.99
+
+
+
Sportswear
+
1 product
+
$79.99
+
+
+
+
+
+ +
+

+ GROUP BY acts like a sorting hat for your data +

+

+ The GROUP BY clause organizes rows into groups based on matching + values in specified columns. Think of it as sorting items into + different buckets, then calculating summaries for each bucket. +

+

In our example:

+
    +
  • + + GROUP BY category + {" "} + - Organizes products into categories +
  • +
  • + + COUNT(*) as product_count + {" "} + - Counts the number of products in each category +
  • +
  • + + AVG(price) as avg_price + {" "} + - Calculates the average price for each category +
  • +
+

+ Without GROUP BY, aggregate functions like COUNT() would reduce + all rows to a single summary value. GROUP BY lets you create + summaries for specific segments of your data. +

+
+
+ +
+

+ Common Aggregate Functions +

+
+
+
COUNT()
+

+ Counts the number of rows or non-NULL values +

+
+ COUNT(*) - Counts all rows +
+ COUNT(column) - Counts non-NULL values +
+
+
+
SUM()
+

+ Calculates the total of values in a column +

+
+ SUM(price) - Total price of all items +
+
+
+
AVG()
+

+ Calculates the average of values +

+
+ AVG(rating) - Average rating +
+
+
+
MIN() / MAX()
+

+ Finds the smallest/largest values +

+
+ MIN(price) - Cheapest product +
+ MAX(price) - Most expensive product +
+
+
+
+
+ + {/* Query Results */} +
+
+
+

Query Results

+
+
+ +
+
+ +
+

+ SQL Query Execution Order +

+
+
+ 1 +
+
FROM Products
+
+
+
+ 2 +
+
(WHERE clause if present)
+
+
+
+ 3 +
+
+ GROUP BY category +
+
+
+
+ 4 +
+
(HAVING clause if present)
+
+
+
+ 5 +
+
+ SELECT category, COUNT(*), AVG(price) +
+
+

+ GROUP BY happens before SELECT is applied but after filtering with + WHERE! +

+
+ +
+

+ Important GROUP BY Rules +

+
    +
  • + + + + Every column in your SELECT list must either be in the GROUP BY + clause or inside an aggregate function +
  • +
  • + + + + Use HAVING (not WHERE) to filter groups based on aggregate + values +
  • +
+
+
+
+
+ ); +}; + +export default GroupByOperation; diff --git a/components/visualizer/operations/JoinOperation.tsx b/components/visualizer/operations/JoinOperation.tsx new file mode 100644 index 0000000..09699de --- /dev/null +++ b/components/visualizer/operations/JoinOperation.tsx @@ -0,0 +1,387 @@ +import React from "react"; +import { SqlResult } from "@/types/database"; +import { ResultTable } from "@/components/lessons/ResultTable"; + +interface JoinOperationProps { + results: SqlResult | null; +} + +const JoinOperation: React.FC = ({ results }) => { + if (!results) { + return
No results available
; + } + + // Example query that's being visualized + const exampleQuery = + "SELECT c.first_name, c.last_name, o.order_date, o.total_amount FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id LIMIT 6;"; + + return ( +
+
+

+ JOIN Operation +

+
+ {exampleQuery} +
+
+ +
+ {/* Visual explanation */} +
+
+

+ How JOIN Works +

+ +
+ {/* Customers table representation */} +
+
+ Customers +
+
+
+ customer_id +
+
+
+ 1 +
+
+ 2 +
+
+ 3 +
+
+
+
+
+ name +
+
+
+ John +
+
+ Jane +
+
+ Mike +
+
+
+
+ + {/* Join lines */} + + + + + + + JOIN ON + + + customer_id = customer_id + + + + {/* Orders table representation */} +
+
+ Orders +
+
+
+ customer_id +
+
+
+ 1 +
+
+ 1 +
+
+ 2 +
+
+
+
+
+ amount +
+
+
+ $50 +
+
+ $25 +
+
+ $75 +
+
+
+
+ + {/* Down arrows */} +
+ + + +
+
+ + + +
+ + {/* Result set */} +
+
+ Combined Result +
+
+
+
+ customer name +
+
+
+ John +
+
+ John +
+
+ Jane +
+
+
+
+
+ order amount +
+
+
+ $50 +
+
+ $25 +
+
+ $75 +
+
+
+
+
+
+ +
+

+ JOIN acts like a matchmaker for your tables +

+

+ The JOIN operation combines rows from two or more tables based + on a related column. Think of it as a marriage ceremony where + rows are paired based on matching values in the specified + columns. +

+

In our example:

+
    +
  • + + Customers c JOIN Orders o + {" "} + - Combines these two tables +
  • +
  • + + ON c.customer_id = o.customer_id + {" "} + - The matching condition +
  • +
  • + Each customer might have multiple orders, so one customer can + appear multiple times in the results +
  • +
  • + If a customer has no orders (or vice versa with RIGHT JOIN), + they won't appear in an INNER JOIN result +
  • +
+
+
+ +
+

Types of JOINs

+
+
+
+ INNER JOIN +
+
+
+
+
+
+
+
+

+ Returns rows when there is a match in both tables +

+
+
+
+ LEFT JOIN +
+
+
+
+
+
+
+
+

+ Returns all rows from the left table, plus matched rows from + the right table +

+
+
+
+ RIGHT JOIN +
+
+
+
+
+
+
+
+

+ Returns all rows from the right table, plus matched rows from + the left table +

+
+
+
+ FULL JOIN +
+
+
+
+
+
+
+
+

+ Returns rows when there is a match in either left or right + table +

+
+
+
+
+ + {/* Query Results */} +
+
+
+

Query Results

+
+
+ +
+
+ +
+

+ SQL Query Execution Order +

+
+
+ 1 +
+
FROM Customers
+
+
+
+ 2 +
+
+ JOIN Orders ON customer_id = customer_id +
+
+
+
+ 3 +
+
+ SELECT first_name, last_name, order_date, total_amount +
+
+
+
+ 4 +
+
LIMIT 6
+
+

+ JOINs are processed early in query execution, effectively creating + a temporary combined table before other operations! +

+
+
+
+
+ ); +}; + +export default JoinOperation; diff --git a/components/visualizer/operations/SelectOperation.tsx b/components/visualizer/operations/SelectOperation.tsx new file mode 100644 index 0000000..1a1c4a1 --- /dev/null +++ b/components/visualizer/operations/SelectOperation.tsx @@ -0,0 +1,236 @@ +import React from "react"; +import { SqlResult } from "@/types/database"; +import { ResultTable } from "@/components/lessons/ResultTable"; + +interface SelectOperationProps { + results: SqlResult | null; +} + +const SelectOperation: React.FC = ({ results }) => { + if (!results) { + return
No results available
; + } + + // Example query that's being visualized + const exampleQuery = + "SELECT customer_id, first_name, last_name, email FROM Customers LIMIT 5;"; + + return ( +
+
+

+ SELECT Operation +

+
+ {exampleQuery} +
+
+ +
+ {/* Visual explanation */} +
+
+

+ How SELECT Works +

+
+
+ {/* Visual database table representation */} +
+
+
+
+
+ Customers Table +
+
+
+ customer_id +
+
+ first_name +
+
+ last_name +
+
+ email +
+
+ join_date +
+
+ phone +
+
+
+ Selects specific columns... +
+
+
+ + {/* Arrow pointing down */} +
+ + + +
+ + {/* Result set */} +
+
+ Result Set +
+
+
+ customer_id +
+
+ first_name +
+
+ last_name +
+
+ email +
+
+
+
+
+ +
+

+ SELECT acts like a projector of data +

+

+ The SELECT statement determines which{" "} + columns will appear in + your query results. Think of it as a spotlight that illuminates + only the specific data attributes you want to see. +

+

In our example:

+
    +
  • + + customer_id, first_name, last_name, email + {" "} + - These are the columns we're selecting +
  • +
  • + + FROM Customers + {" "} + - The table we're selecting from +
  • +
  • + LIMIT 5 - + Restricts the output to only 5 rows +
  • +
+
+
+ +
+

Key Concepts

+
+
+
+ 1 +
+
+

Column Selection

+

+ Choose specific columns instead of using{" "} + SELECT * to improve query performance and + readability +

+
+
+
+
+ 2 +
+
+

Column Order

+

+ Columns appear in results in the same order specified in the + SELECT statement +

+
+
+
+
+ 3 +
+
+

Column Aliases

+

+ You can rename columns using AS for more + meaningful output (e.g.,{" "} + first_name AS "First Name") +

+
+
+
+
+
+ + {/* Query Results */} +
+
+
+

Query Results

+
+
+ +
+
+ +
+

+ SQL Query Execution Order +

+
+
+ 1 +
+
FROM Customers
+
+
+
+ 2 +
+
+ SELECT customer_id, first_name, last_name, email +
+
+
+
+ 3 +
+
LIMIT 5
+
+

+ Note: This shows conceptual execution order, not the order in + which SQL is written! +

+
+
+
+
+ ); +}; + +export default SelectOperation; diff --git a/components/visualizer/operations/WhereOperation.tsx b/components/visualizer/operations/WhereOperation.tsx new file mode 100644 index 0000000..2d7302a --- /dev/null +++ b/components/visualizer/operations/WhereOperation.tsx @@ -0,0 +1,345 @@ +import React from "react"; +import { SqlResult } from "@/types/database"; +import { ResultTable } from "@/components/lessons/ResultTable"; + +interface WhereOperationProps { + results: SqlResult | null; +} + +const WhereOperation: React.FC = ({ results }) => { + if (!results) { + return
No results available
; + } + + // Example query that's being visualized + const exampleQuery = + "SELECT name, price, category FROM Products WHERE price > 70 ORDER BY price DESC;"; + + return ( +
+
+

+ WHERE Operation +

+
+ {exampleQuery} +
+
+ +
+ {/* Visual explanation */} +
+
+

+ How WHERE Works +

+
+
+ {/* Full dataset representation */} +
+
+
+ All Products +
+
+
+
Smartphone X
+
+ $799.99 +
+
Electronics
+
+
+
Laptop Pro
+
+ $1299.99 +
+
Electronics
+
+
+
Wireless Headphones
+
+ $159.99 +
+
Electronics
+
+
+
Coffee Maker
+
+ $89.99 +
+
+ Home Appliances +
+
+
+
Running Shoes
+
+ $79.99 +
+
Sportswear
+
+
+
Blender
+
+ $69.99 +
+
+ Home Appliances +
+
+
+
+
+ + {/* Filter representation */} +
+
+
+ price > 70 +
+
+
+ + + +
+
+ + + +
+
+ + {/* Filtered result set */} +
+
+ Filtered Products +
+
+
+
Smartphone X
+
$799.99
+
Electronics
+
+
+
Laptop Pro
+
+ $1299.99 +
+
Electronics
+
+
+
Wireless Headphones
+
$159.99
+
Electronics
+
+
+
Coffee Maker
+
$89.99
+
+ Home Appliances +
+
+
+
Running Shoes
+
$79.99
+
Sportswear
+
+ {/* This one is filtered out */} +
+
Blender
+
$69.99
+
+ Home Appliances +
+
+
+
+
+
+ +
+

+ WHERE acts like a sieve for your data +

+

+ The WHERE clause filters rows, allowing only those that match + specific conditions to pass through to your results. Think of it + as a bouncer that checks each row's credentials before allowing + it into your result set. +

+

In our example:

+
    +
  • + + price > 70 + {" "} + - Only includes products that cost more than $70 +
  • +
  • + The Blender at $69.99 is excluded because it doesn't meet the + condition +
  • +
  • + All other products pass through the filter because their + prices exceed $70 +
  • +
+
+
+ +
+

+ Common WHERE Operators +

+
+
+
Comparison
+
    +
  • + = Equal to +
  • +
  • + > Greater than +
  • +
  • + < Less than +
  • +
  • + >= Greater than or equal +
  • +
  • + <= Less than or equal +
  • +
  • + <> or != Not equal +
  • +
+
+
+
Logical
+
    +
  • + AND Both conditions must be true +
  • +
  • + OR Either condition can be true +
  • +
  • + NOT Negates a condition +
  • +
+
+
+
+ Pattern Matching +
+
    +
  • + LIKE Pattern matching with wildcards +
  • +
  • + % Any sequence of characters +
  • +
  • + _ Any single character +
  • +
+
+
+
Other
+
    +
  • + IN Matches any in a list +
  • +
  • + BETWEEN Within a range +
  • +
  • + IS NULL Is a null value +
  • +
  • + IS NOT NULL Is not a null value +
  • +
+
+
+
+
+ + {/* Query Results */} +
+
+
+

Query Results

+
+
+ +
+
+ +
+

+ SQL Query Execution Order +

+
+
+ 1 +
+
FROM Products
+
+
+
+ 2 +
+
+ WHERE price > 70 +
+
+
+
+ 3 +
+
SELECT name, price, category
+
+
+
+ 4 +
+
ORDER BY price DESC
+
+

+ The WHERE clause is applied early in query execution, before + selecting columns! +

+
+
+
+
+ ); +}; + +export default WhereOperation; diff --git a/components/visualizer/visualizations/GroupByVisualization.tsx b/components/visualizer/visualizations/GroupByVisualization.tsx deleted file mode 100644 index c863004..0000000 --- a/components/visualizer/visualizations/GroupByVisualization.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from "react"; - -interface GroupByVisualizationProps { - columns: string[]; - selectColumns: string[]; -} - -const GroupByVisualization: React.FC = ({ - columns, - selectColumns, -}) => { - // Detect if there are aggregate functions in the SELECT clause - const hasAggregations = selectColumns.some((col) => { - const lowerCol = col.toLowerCase(); - return ( - lowerCol.includes("count(") || - lowerCol.includes("sum(") || - lowerCol.includes("avg(") || - lowerCol.includes("min(") || - lowerCol.includes("max(") - ); - }); - - // Extract aggregate functions for display - const aggregateFunctions = selectColumns - .filter((col) => { - const lowerCol = col.toLowerCase(); - return ( - lowerCol.includes("count(") || - lowerCol.includes("sum(") || - lowerCol.includes("avg(") || - lowerCol.includes("min(") || - lowerCol.includes("max(") - ); - }) - .map((col) => { - // Extract just the function name and its argument - const match = col.match(/(\w+)\(([^)]+)\)(?:\s+as\s+(\w+))?/i); - if (match) { - return { - function: match[1].toUpperCase(), - column: match[2], - alias: match[3] || null, - }; - } - return { function: col, column: "", alias: null }; - }); - - return ( -
-
-

- Rows are grouped by{" "} - {columns.length === 1 ? "this column" : "these columns"}: -

-
- {columns.map((column, idx) => ( -
- {column} -
- ))} -
- - {hasAggregations && ( -
-

- Applying these aggregate functions for each group: -

-
- {aggregateFunctions.map((agg, idx) => ( -
- - {agg.function} - - ({agg.column}) - {agg.alias && ( - - → renamed as "{agg.alias}" - - )} -
- ))} -
-
- )} -
- -
-
- - - -
-
- The GROUP BY clause collects rows with the same values into summary - rows. - {hasAggregations - ? " This allows aggregate functions to calculate summary values for each group." - : " Without aggregate functions, it's similar to using DISTINCT to remove duplicates."} -
-
-
- ); -}; - -export default GroupByVisualization; diff --git a/components/visualizer/visualizations/JoinVisualization.tsx b/components/visualizer/visualizations/JoinVisualization.tsx deleted file mode 100644 index 654293e..0000000 --- a/components/visualizer/visualizations/JoinVisualization.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from "react"; - -interface JoinVisualizationProps { - joins: Array<{ - type: string; - table: string; - on: string; - }>; - tables: string[]; -} - -const JoinVisualization: React.FC = ({ - joins, - tables, -}) => { - // Get the main table (first table in the FROM clause) - const mainTable = tables[0]; - - // Helper to get join type description - const getJoinTypeDescription = (type: string) => { - switch (type.toUpperCase()) { - case "INNER": - return "Includes rows that match in both tables"; - case "LEFT": - return "Includes all rows from the left table, plus matching rows from the right table"; - case "RIGHT": - return "Includes all rows from the right table, plus matching rows from the left table"; - case "FULL": - return "Includes all rows from both tables"; - default: - return "Joins tables"; - } - }; - - // Helper to get join type color - const getJoinTypeColor = (type: string) => { - switch (type.toUpperCase()) { - case "INNER": - return "bg-purple-100 border-purple-400"; - case "LEFT": - return "bg-blue-100 border-blue-400"; - case "RIGHT": - return "bg-green-100 border-green-400"; - case "FULL": - return "bg-indigo-100 border-indigo-400"; - default: - return "bg-gray-100 border-gray-400"; - } - }; - - return ( -
-
-

- Starting with table {mainTable}{" "} - and combining with: -

-
- {joins.map((join, idx) => ( -
-
-
- {join.type.toUpperCase()} JOIN with {join.table} -
-
- {getJoinTypeDescription(join.type)} -
-
-
-
- Join Condition: -
- - {join.on} - -
-
- ))} -
-
- -
-
- - - -
-
- JOINs combine rows from two or more tables based on related columns, - creating a unified result set. -
-
-
- ); -}; - -export default JoinVisualization; diff --git a/components/visualizer/visualizations/QueryVisualizer.tsx b/components/visualizer/visualizations/QueryVisualizer.tsx deleted file mode 100644 index 82c5c0b..0000000 --- a/components/visualizer/visualizations/QueryVisualizer.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React from "react"; -import { SqlResult } from "@/types/database"; -import { ParsedQuery } from "@/lib/queryParser"; -import { ResultTable } from "@/components/lessons/ResultTable"; -import SelectVisualization from "./SelectVisualization"; -import JoinVisualization from "./JoinVisualization"; -import GroupByVisualization from "./GroupByVisualization"; -import WhereVisualization from "./WhereVisualization"; - -interface QueryVisualizerProps { - queryResults: SqlResult; - queryType: string | null; - parsedQuery: ParsedQuery | null; -} - -const QueryVisualizer: React.FC = ({ - queryResults, - queryType, - parsedQuery, -}) => { - if (!queryResults || !parsedQuery) { - return ( -
- Execute a query to see its visualization -
- ); - } - - return ( -
-
- {/* Visualization panels based on query type */} -
- {parsedQuery.type === "SELECT" && ( - <> - {/* SELECT Visualization */} -
-

- SELECT Operation -

- -
- - {/* WHERE Visualization */} - {parsedQuery.where && ( -
-

- WHERE Conditions -

- -
- )} - - {/* JOIN Visualization */} - {parsedQuery.join && parsedQuery.join.length > 0 && ( -
-

- JOIN Operation -

- -
- )} - - {/* GROUP BY Visualization */} - {parsedQuery.groupBy && ( -
-

- GROUP BY Operation -

- -
- )} - - {/* ORDER BY Information */} - {parsedQuery.orderBy && ( -
-

- ORDER BY -

-

- Results are sorted by{" "} - - {parsedQuery.orderBy.columns.join(", ")} - {" "} - in{" "} - - {parsedQuery.orderBy.direction} - {" "} - order. -

-
- )} - - {/* LIMIT Information */} - {parsedQuery.limit !== undefined && ( -
-

- LIMIT -

-

- Results are limited to{" "} - - {parsedQuery.limit} - {" "} - rows - {parsedQuery.offset !== undefined && ( - <> - {" "} - starting from row{" "} - - {parsedQuery.offset + 1} - - - )} - . -

-
- )} - - )} -
- - {/* Results display */} -
-
-

Query Results

-
- {queryResults.values.length} rows returned -
-
-
- -
-
-
-
- ); -}; - -export default QueryVisualizer; diff --git a/components/visualizer/visualizations/SelectVisualization.tsx b/components/visualizer/visualizations/SelectVisualization.tsx deleted file mode 100644 index da11d57..0000000 --- a/components/visualizer/visualizations/SelectVisualization.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; - -interface SelectVisualizationProps { - columns: string[]; - allColumns: boolean; - tables: string[]; -} - -const SelectVisualization: React.FC = ({ - columns, - allColumns, - tables, -}) => { - return ( -
- {allColumns ? ( -
-
- * -
-
-

- Selecting all columns from{" "} - {tables.length === 1 ? "the table" : "tables"} {tables.join(", ")} -

-
-
- ) : ( -
-

- Selecting{" "} - - {columns.length} specific column{columns.length !== 1 ? "s" : ""} - {" "} - from {tables.length === 1 ? "the table" : "tables"}{" "} - {tables.join(", ")} -

-
- {columns.map((column, idx) => ( -
- {column} -
- ))} -
-
- )} -
- The SELECT clause determines which columns will appear in the final - result. -
-
- ); -}; - -export default SelectVisualization; diff --git a/components/visualizer/visualizations/WhereVisualization.tsx b/components/visualizer/visualizations/WhereVisualization.tsx deleted file mode 100644 index e6af863..0000000 --- a/components/visualizer/visualizations/WhereVisualization.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; - -interface WhereVisualizationProps { - conditions: string[]; -} - -const WhereVisualization: React.FC = ({ - conditions, -}) => { - return ( -
-
-

- Records must satisfy these conditions to be included in results: -

-
- {conditions.map((condition, idx) => ( -
- {condition} -
- ))} -
-
-
-
- - - -
-
- The WHERE clause filters rows from the data source, eliminating rows - that don't match your conditions. -
-
-
- ); -}; - -export default WhereVisualization; diff --git a/public/images/groupby-icon.svg b/public/images/groupby-icon.svg new file mode 100644 index 0000000..92c5faf --- /dev/null +++ b/public/images/groupby-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/images/join-icon.svg b/public/images/join-icon.svg new file mode 100644 index 0000000..13101af --- /dev/null +++ b/public/images/join-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/images/select-icon.svg b/public/images/select-icon.svg new file mode 100644 index 0000000..868c482 --- /dev/null +++ b/public/images/select-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/images/where-icon.svg b/public/images/where-icon.svg new file mode 100644 index 0000000..d3128c3 --- /dev/null +++ b/public/images/where-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 626d62734c5d34c49c2f28abd3eb8b736f51846a Mon Sep 17 00:00:00 2001 From: Sean Coughlin Date: Fri, 4 Apr 2025 09:46:35 -0400 Subject: [PATCH 3/3] Refactor and simplify visualizer --- app/visualizer/page.tsx | 16 +++-- .../{operations => }/GroupByOperation.tsx | 0 .../{operations => }/JoinOperation.tsx | 2 +- .../{operations => }/SelectOperation.tsx | 6 +- .../{operations => }/WhereOperation.tsx | 58 ++++++++++++------- 5 files changed, 48 insertions(+), 34 deletions(-) rename components/visualizer/{operations => }/GroupByOperation.tsx (100%) rename components/visualizer/{operations => }/JoinOperation.tsx (99%) rename components/visualizer/{operations => }/SelectOperation.tsx (98%) rename components/visualizer/{operations => }/WhereOperation.tsx (86%) diff --git a/app/visualizer/page.tsx b/app/visualizer/page.tsx index adfc457..45fe377 100644 --- a/app/visualizer/page.tsx +++ b/app/visualizer/page.tsx @@ -1,19 +1,17 @@ "use client"; import { useState, useEffect } from "react"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { initializeDatabase } from "@/lib/lessons"; import { useSqlJs } from "@/hooks/useSqlJs"; import Loading from "@/components/ui/loading"; import { SqlResult } from "@/types/database"; -import { parseQuery } from "@/lib/queryParser"; -import Image from "next/image"; // SQL operation visualizations -import SelectOperation from "@/components/visualizer/operations/SelectOperation"; -import WhereOperation from "@/components/visualizer/operations/WhereOperation"; -import JoinOperation from "@/components/visualizer/operations/JoinOperation"; -import GroupByOperation from "@/components/visualizer/operations/GroupByOperation"; +import SelectOperation from "@/components/visualizer/SelectOperation"; +import WhereOperation from "@/components/visualizer/WhereOperation"; +import JoinOperation from "@/components/visualizer/JoinOperation"; +import GroupByOperation from "@/components/visualizer/GroupByOperation"; +import Image from "next/image"; export default function VisualizerPage() { const [dbInitialized, setDbInitialized] = useState(false); @@ -204,12 +202,12 @@ function OperationButton({ return (