diff --git a/apps/dev-playground/client/eslint.config.js b/apps/dev-playground/client/eslint.config.js index d5f2c0b..fe3c545 100644 --- a/apps/dev-playground/client/eslint.config.js +++ b/apps/dev-playground/client/eslint.config.js @@ -6,7 +6,7 @@ import globals from "globals"; import tseslint from "typescript-eslint"; export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(["dist", "src/routeTree.gen.ts"]), { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/apps/dev-playground/client/src/appKitTypes.d.ts b/apps/dev-playground/client/src/appKitTypes.d.ts index bc7094a..07e759c 100644 --- a/apps/dev-playground/client/src/appKitTypes.d.ts +++ b/apps/dev-playground/client/src/appKitTypes.d.ts @@ -61,5 +61,15 @@ declare module "@databricks/app-kit-ui/react" { app_name: string; total_cost_usd: number; }[]; + sql_helpers_test: { + string_value: string; + number_value: number; + boolean_value: boolean; + date_value: string; + timestamp_value: string; + binary_value: string; + binary_hex: string; + binary_length: number; + }; } } diff --git a/apps/dev-playground/client/src/components/analytics/usage-trends-chart.tsx b/apps/dev-playground/client/src/components/analytics/usage-trends-chart.tsx index c8a04ca..996676d 100644 --- a/apps/dev-playground/client/src/components/analytics/usage-trends-chart.tsx +++ b/apps/dev-playground/client/src/components/analytics/usage-trends-chart.tsx @@ -1,3 +1,4 @@ +import { sql } from "@databricks/app-kit-ui/js"; import { BarChart } from "@databricks/app-kit-ui/react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -21,7 +22,7 @@ export function UsageTrendsChart({ }: UsageTrendsChartProps) { const spendDataParams = { ...queryParams, - groupBy, + groupBy: sql.string(groupBy), }; return ( diff --git a/apps/dev-playground/client/src/lib/utils/filter-utils.ts b/apps/dev-playground/client/src/lib/utils/filter-utils.ts index 2e9d126..cdba1fb 100644 --- a/apps/dev-playground/client/src/lib/utils/filter-utils.ts +++ b/apps/dev-playground/client/src/lib/utils/filter-utils.ts @@ -1,3 +1,4 @@ +import { sql } from "@databricks/app-kit-ui/js"; import type { Aggregation, DashboardFilters } from "@/lib/types"; const ALLOWED_PERIODS = ["day", "week", "month"] as const; @@ -56,14 +57,16 @@ export function buildWorkflowParams( ) { const { startDate, endDate } = getDateRange(filters); + const aggregationLevel = sanitizeAggregationLevel(aggregation.period); + return { filters: { - startDate: startDate.toISOString().split("T")[0], - endDate: endDate.toISOString().split("T")[0], - aggregationLevel: sanitizeAggregationLevel(aggregation.period), - appId: filters.apps !== "all" ? filters.apps : "all", - creator: filters.creator !== "all" ? filters.creator : "all", - groupBy: "default", + startDate: sql.date(startDate), + endDate: sql.date(endDate), + aggregationLevel: sql.string(aggregationLevel), + appId: sql.string(filters.apps !== "all" ? filters.apps : "all"), + creator: sql.string(filters.creator !== "all" ? filters.creator : "all"), + groupBy: sql.string("default"), }, }; } diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index fefd80c..884804f 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -8,125 +8,150 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as TelemetryRouteRouteImport } from "./routes/telemetry.route"; -import { Route as ReconnectRouteRouteImport } from "./routes/reconnect.route"; -import { Route as DataVisualizationRouteRouteImport } from "./routes/data-visualization.route"; -import { Route as AnalyticsRouteRouteImport } from "./routes/analytics.route"; -import { Route as IndexRouteImport } from "./routes/index"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' +import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' +import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' +import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route' +import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' +import { Route as IndexRouteImport } from './routes/index' const TelemetryRouteRoute = TelemetryRouteRouteImport.update({ - id: "/telemetry", - path: "/telemetry", + id: '/telemetry', + path: '/telemetry', getParentRoute: () => rootRouteImport, -} as any); +} as any) +const SqlHelpersRouteRoute = SqlHelpersRouteRouteImport.update({ + id: '/sql-helpers', + path: '/sql-helpers', + getParentRoute: () => rootRouteImport, +} as any) const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ - id: "/reconnect", - path: "/reconnect", + id: '/reconnect', + path: '/reconnect', getParentRoute: () => rootRouteImport, -} as any); +} as any) const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({ - id: "/data-visualization", - path: "/data-visualization", + id: '/data-visualization', + path: '/data-visualization', getParentRoute: () => rootRouteImport, -} as any); +} as any) const AnalyticsRouteRoute = AnalyticsRouteRouteImport.update({ - id: "/analytics", - path: "/analytics", + id: '/analytics', + path: '/analytics', getParentRoute: () => rootRouteImport, -} as any); +} as any) const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/analytics": typeof AnalyticsRouteRoute; - "/data-visualization": typeof DataVisualizationRouteRoute; - "/reconnect": typeof ReconnectRouteRoute; - "/telemetry": typeof TelemetryRouteRoute; + '/': typeof IndexRoute + '/analytics': typeof AnalyticsRouteRoute + '/data-visualization': typeof DataVisualizationRouteRoute + '/reconnect': typeof ReconnectRouteRoute + '/sql-helpers': typeof SqlHelpersRouteRoute + '/telemetry': typeof TelemetryRouteRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/analytics": typeof AnalyticsRouteRoute; - "/data-visualization": typeof DataVisualizationRouteRoute; - "/reconnect": typeof ReconnectRouteRoute; - "/telemetry": typeof TelemetryRouteRoute; + '/': typeof IndexRoute + '/analytics': typeof AnalyticsRouteRoute + '/data-visualization': typeof DataVisualizationRouteRoute + '/reconnect': typeof ReconnectRouteRoute + '/sql-helpers': typeof SqlHelpersRouteRoute + '/telemetry': typeof TelemetryRouteRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/analytics": typeof AnalyticsRouteRoute; - "/data-visualization": typeof DataVisualizationRouteRoute; - "/reconnect": typeof ReconnectRouteRoute; - "/telemetry": typeof TelemetryRouteRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/analytics': typeof AnalyticsRouteRoute + '/data-visualization': typeof DataVisualizationRouteRoute + '/reconnect': typeof ReconnectRouteRoute + '/sql-helpers': typeof SqlHelpersRouteRoute + '/telemetry': typeof TelemetryRouteRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; + fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | "/" - | "/analytics" - | "/data-visualization" - | "/reconnect" - | "/telemetry"; - fileRoutesByTo: FileRoutesByTo; - to: "/" | "/analytics" | "/data-visualization" | "/reconnect" | "/telemetry"; + | '/' + | '/analytics' + | '/data-visualization' + | '/reconnect' + | '/sql-helpers' + | '/telemetry' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/analytics' + | '/data-visualization' + | '/reconnect' + | '/sql-helpers' + | '/telemetry' id: - | "__root__" - | "/" - | "/analytics" - | "/data-visualization" - | "/reconnect" - | "/telemetry"; - fileRoutesById: FileRoutesById; + | '__root__' + | '/' + | '/analytics' + | '/data-visualization' + | '/reconnect' + | '/sql-helpers' + | '/telemetry' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - AnalyticsRouteRoute: typeof AnalyticsRouteRoute; - DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute; - ReconnectRouteRoute: typeof ReconnectRouteRoute; - TelemetryRouteRoute: typeof TelemetryRouteRoute; + IndexRoute: typeof IndexRoute + AnalyticsRouteRoute: typeof AnalyticsRouteRoute + DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute + ReconnectRouteRoute: typeof ReconnectRouteRoute + SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute + TelemetryRouteRoute: typeof TelemetryRouteRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/telemetry": { - id: "/telemetry"; - path: "/telemetry"; - fullPath: "/telemetry"; - preLoaderRoute: typeof TelemetryRouteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/reconnect": { - id: "/reconnect"; - path: "/reconnect"; - fullPath: "/reconnect"; - preLoaderRoute: typeof ReconnectRouteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/data-visualization": { - id: "/data-visualization"; - path: "/data-visualization"; - fullPath: "/data-visualization"; - preLoaderRoute: typeof DataVisualizationRouteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/analytics": { - id: "/analytics"; - path: "/analytics"; - fullPath: "/analytics"; - preLoaderRoute: typeof AnalyticsRouteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/telemetry': { + id: '/telemetry' + path: '/telemetry' + fullPath: '/telemetry' + preLoaderRoute: typeof TelemetryRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/sql-helpers': { + id: '/sql-helpers' + path: '/sql-helpers' + fullPath: '/sql-helpers' + preLoaderRoute: typeof SqlHelpersRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/reconnect': { + id: '/reconnect' + path: '/reconnect' + fullPath: '/reconnect' + preLoaderRoute: typeof ReconnectRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/data-visualization': { + id: '/data-visualization' + path: '/data-visualization' + fullPath: '/data-visualization' + preLoaderRoute: typeof DataVisualizationRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/analytics': { + id: '/analytics' + path: '/analytics' + fullPath: '/analytics' + preLoaderRoute: typeof AnalyticsRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -135,8 +160,9 @@ const rootRouteChildren: RootRouteChildren = { AnalyticsRouteRoute: AnalyticsRouteRoute, DataVisualizationRouteRoute: DataVisualizationRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, + SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index 8c83fe6..546d8ec 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -56,6 +56,14 @@ function RootComponent() { Telemetry + + + diff --git a/apps/dev-playground/client/src/routes/data-visualization.route.tsx b/apps/dev-playground/client/src/routes/data-visualization.route.tsx index 4f28be0..c28433b 100644 --- a/apps/dev-playground/client/src/routes/data-visualization.route.tsx +++ b/apps/dev-playground/client/src/routes/data-visualization.route.tsx @@ -1,3 +1,4 @@ +import { sql } from "@databricks/app-kit-ui/js"; import { AreaChart, BarChart, @@ -58,22 +59,20 @@ function CodeSnippet({ code }: { code: string }) { function DataVisualizationRoute() { // Default params for the demo - last 30 days - const endDate = new Date().toISOString().split("T")[0]; - const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; + const endDate = new Date(); + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const commonParams = { - startDate, - endDate, - aggregationLevel: "day", + startDate: sql.date(startDate), + endDate: sql.date(endDate), + aggregationLevel: sql.string("day"), }; const spendDataParams = { ...commonParams, - appId: "all", - creator: "all", - groupBy: "default", + appId: sql.string("all"), + creator: sql.string("all"), + groupBy: sql.string("default"), }; return ( diff --git a/apps/dev-playground/client/src/routes/index.tsx b/apps/dev-playground/client/src/routes/index.tsx index 6c632af..3d83562 100644 --- a/apps/dev-playground/client/src/routes/index.tsx +++ b/apps/dev-playground/client/src/routes/index.tsx @@ -103,6 +103,25 @@ function IndexRoute() { + + +
+

+ SQL Helpers +

+

+ Type-safe parameter helpers for Databricks SQL queries. Test + each helper interactively and see the generated parameter + objects. +

+ +
+
diff --git a/apps/dev-playground/client/src/routes/sql-helpers.route.tsx b/apps/dev-playground/client/src/routes/sql-helpers.route.tsx new file mode 100644 index 0000000..f311d58 --- /dev/null +++ b/apps/dev-playground/client/src/routes/sql-helpers.route.tsx @@ -0,0 +1,501 @@ +import { sql } from "@databricks/app-kit-ui/js"; +import { useAnalyticsQuery } from "@databricks/app-kit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { codeToHtml } from "shiki"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +export const Route = createFileRoute("/sql-helpers")({ + component: SqlHelpersRoute, +}); + +function CodeBlock({ + code, + lang = "typescript", +}: { + code: string; + lang?: string; +}) { + const [html, setHtml] = useState(""); + + useEffect(() => { + codeToHtml(code, { + lang, + theme: "dark-plus", + }).then(setHtml); + }, [code, lang]); + + return ( +
+ ); +} + +function ResultDisplay({ + data, + loading, + error, +}: { + data: any; + loading: boolean; + error: any; +}) { + if (loading) { + return ( +
+
+ Executing query... +
+ ); + } + + if (error) { + return ( +
+ Error:{" "} + {error.message || String(error)} +
+ ); + } + + if (!data || data.length === 0) { + return
No results
; + } + + return ( +
+
+ + Success checkmark + + + Query executed successfully +
+
+        {JSON.stringify(data[0], null, 2)}
+      
+
+ ); +} + +function HelperCard({ + title, + description, + type, + children, + code, + resultCode, +}: { + title: string; + description: string; + type: string; + children: React.ReactNode; + code: string; + resultCode: string; +}) { + const [showCode, setShowCode] = useState(false); + + return ( + + +
+ + {type} + + {title} +
+ {description} +
+ + {children} + +
+ +
+ + {showCode && ( +
+
+
+ Usage: +
+ +
+
+
+ Result object: +
+ +
+
+ )} +
+
+ ); +} + +function SqlHelpersRoute() { + // State for each input type + const [stringValue, setStringValue] = useState("Hello, Databricks!"); + const [numberValue, setNumberValue] = useState("42"); + const [booleanValue, setBooleanValue] = useState(true); + const [dateValue, setDateValue] = useState(() => { + const today = new Date(); + return today.toISOString().split("T")[0]; + }); + const [timestampValue, setTimestampValue] = useState(() => { + return new Date().toISOString().slice(0, 19); + }); + const [binaryInput, setBinaryInput] = useState( + "0x53, 0x70, 0x61, 0x72, 0x6B", + ); // "Spark" as bytes + + // Parse bytes input (supports: "0x53, 0x70" or "83, 112" or "53 70") + const binaryBytes = useMemo(() => { + try { + const cleaned = binaryInput.replace(/,/g, " ").trim(); + if (!cleaned) return new Uint8Array([]); + + const parts = cleaned.split(/\s+/).filter(Boolean); + const bytes = parts.map((part) => { + if (part.startsWith("0x") || part.startsWith("0X")) { + return parseInt(part, 16); + } + return parseInt(part, 10); + }); + + if (bytes.some((b) => Number.isNaN(b) || b < 0 || b > 255)) { + return new Uint8Array([]); + } + + return new Uint8Array(bytes); + } catch { + return new Uint8Array([]); + } + }, [binaryInput]); + + // Build parameters + const queryParams = useMemo(() => { + try { + return { + stringParam: sql.string(stringValue), + numberParam: sql.number(Number(numberValue)), + booleanParam: sql.boolean(booleanValue), + dateParam: sql.date(dateValue), + timestampParam: sql.timestamp(`${timestampValue}Z`), + binaryParam: sql.binary(binaryBytes), + }; + } catch { + return null; + } + }, [ + stringValue, + numberValue, + booleanValue, + dateValue, + timestampValue, + binaryBytes, + ]); + + const { data, loading, error } = useAnalyticsQuery( + "sql_helpers_test", + queryParams ?? {}, + ); + + // Helper to show the marker result + const getMarkerResult = (fn: () => any) => { + try { + return JSON.stringify(fn(), null, 2); + } catch (e: any) { + return `Error: ${e.message}`; + } + }; + + return ( +
+
+
+

SQL Helpers

+

+ Type-safe parameter helpers for Databricks SQL queries. Test each + helper interactively and see the generated parameter objects. +

+
+ + {/* Live Query Test */} + + + + + Lightning bolt + + + Live Query Test + + + All parameters below are sent to the SQL warehouse in real-time + + + + + + + +
+ {/* STRING */} + sql.string(stringValue))} + > + setStringValue(e.target.value)} + placeholder="Enter a string value" + className="font-mono" + /> + + + {/* NUMBER */} + sql.number(Number(numberValue)))} + > + setNumberValue(e.target.value)} + placeholder="Enter a number" + className="font-mono" + /> + + + {/* BOOLEAN */} + sql.boolean(booleanValue))} + > +
+ + +
+
+ + {/* DATE */} + sql.date(dateValue))} + > + setDateValue(e.target.value)} + className="font-mono" + /> + + + {/* TIMESTAMP */} + + sql.timestamp(`${timestampValue}Z`), + )} + > + setTimestampValue(e.target.value)} + className="font-mono" + /> + + + {/* BINARY */} + `0x${b.toString(16).padStart(2, "0")}`) + .join(", ")}]); +const params = { + data: sql.binary(bytes) +}; +// Result: { __sql_type: "STRING", value: "${Array.from(binaryBytes) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join("")}" } +// SQL: SELECT UNHEX(:data) as binary_value + +// From ArrayBuffer: +const buffer = new Uint8Array([0x48, 0x69]).buffer; +const params2 = { + data: sql.binary(buffer) // converts to hex "4869" +}; + +// From hex string directly: +const params3 = { + data: sql.binary("537061726B") // already hex, just validates +};`} + resultCode={getMarkerResult(() => sql.binary(binaryBytes))} + > +
+ setBinaryInput(e.target.value)} + placeholder="0x53, 0x70, 0x61 or 83, 112, 97" + className="font-mono" + /> +
+
+ Uint8Array: [{Array.from(binaryBytes).join(", ")}] ( + {binaryBytes.length} bytes) +
+
+ Hex output:{" "} + {Array.from(binaryBytes) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join("") || "(empty)"} +
+
+ As text: "{(() => { + try { + return new TextDecoder().decode(binaryBytes); + } catch { + return "(invalid UTF-8)"; + } + })()}" +
+
+
+
+
+ + {/* Query Reference */} + + + SQL Query Reference + + The test query used to validate all parameter types + + + + + + +
+
+ ); +} diff --git a/apps/dev-playground/config/queries/schema.ts b/apps/dev-playground/config/queries/schema.ts index 2b8806a..4ac9621 100644 --- a/apps/dev-playground/config/queries/schema.ts +++ b/apps/dev-playground/config/queries/schema.ts @@ -43,4 +43,14 @@ export const querySchemas = { total_cost_usd: z.number(), }), ), + sql_helpers_test: z.object({ + string_value: z.string(), + number_value: z.number(), + boolean_value: z.boolean(), + date_value: z.string(), + timestamp_value: z.string(), + binary_value: z.string(), + binary_hex: z.string(), + binary_length: z.number(), + }), }; diff --git a/apps/dev-playground/config/queries/sql_helpers_test.sql b/apps/dev-playground/config/queries/sql_helpers_test.sql new file mode 100644 index 0000000..2f0b756 --- /dev/null +++ b/apps/dev-playground/config/queries/sql_helpers_test.sql @@ -0,0 +1,9 @@ +SELECT + :stringParam as string_value, + :numberParam as number_value, + :booleanParam as boolean_value, + :dateParam as date_value, + :timestampParam as timestamp_value, + UNHEX(:binaryParam) as binary_value, + :binaryParam as binary_hex, + LENGTH(UNHEX(:binaryParam)) as binary_length diff --git a/biome.json b/biome.json index a609e28..e2f6ab6 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,14 @@ }, "files": { "ignoreUnknown": true, - "includes": ["**", "!**/dist", "!**/*.d.ts", "!**/build", "!**/coverage"] + "includes": [ + "**", + "!**/dist", + "!**/*.d.ts", + "!**/build", + "!**/coverage", + "!**/routeTree.gen.ts" + ] }, "formatter": { "enabled": true, diff --git a/packages/app-kit-ui/src/js/index.ts b/packages/app-kit-ui/src/js/index.ts index bacf64a..3e9ef58 100644 --- a/packages/app-kit-ui/src/js/index.ts +++ b/packages/app-kit-ui/src/js/index.ts @@ -1 +1,5 @@ +export { + isSQLTypeMarker, + sql, +} from "shared"; export * from "./sse"; diff --git a/packages/app-kit-ui/tsconfig.json b/packages/app-kit-ui/tsconfig.json index 3bbb154..1bffd99 100644 --- a/packages/app-kit-ui/tsconfig.json +++ b/packages/app-kit-ui/tsconfig.json @@ -6,7 +6,8 @@ "lib": ["ES2021", "DOM"], "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "shared": ["../shared/src"] } }, "include": ["src/**/*"], diff --git a/packages/app-kit/src/analytics/analytics.ts b/packages/app-kit/src/analytics/analytics.ts index f89ed02..9b6a408 100644 --- a/packages/app-kit/src/analytics/analytics.ts +++ b/packages/app-kit/src/analytics/analytics.ts @@ -5,13 +5,14 @@ import type { IAppRouter, PluginExecuteConfig, QuerySchemas, + SQLTypeMarker, StreamExecutionSettings, } from "shared"; -import { generateQueryRegistryTypes } from "@/utils/type-generator"; import { SQLWarehouseConnector } from "../connectors"; import { Plugin, toPlugin } from "../plugin"; import type { Request, Response } from "../utils"; import { getRequestContext } from "../utils"; +import { generateQueryRegistryTypes } from "../utils/type-generator"; import { queryDefaults } from "./defaults"; import { QueryProcessor } from "./query"; import { @@ -137,7 +138,7 @@ export class AnalyticsPlugin extends Plugin { async query( query: string, - parameters?: Record, + parameters?: Record, signal?: AbortSignal, { asUser = false }: { asUser?: boolean } = {}, ): Promise { diff --git a/packages/app-kit/src/analytics/query.ts b/packages/app-kit/src/analytics/query.ts index f9121ec..39c9a2f 100644 --- a/packages/app-kit/src/analytics/query.ts +++ b/packages/app-kit/src/analytics/query.ts @@ -1,12 +1,15 @@ -import type { sql } from "@databricks/sdk-experimental"; import { createHash } from "node:crypto"; +import type { sql } from "@databricks/sdk-experimental"; +import { isSQLTypeMarker, type SQLTypeMarker, sql as sqlHelpers } from "shared"; import { getRequestContext } from "../utils"; +type SQLParameterValue = SQLTypeMarker | null | undefined; + export class QueryProcessor { async processQueryParams( query: string, - parameters?: Record, - ): Promise> { + parameters?: Record, + ): Promise> { const processed = { ...parameters }; // extract all params from the query @@ -18,7 +21,7 @@ export class QueryProcessor { const requestContext = getRequestContext(); const workspaceId = await requestContext.workspaceId; if (workspaceId) { - processed.workspaceId = workspaceId; + processed.workspaceId = sqlHelpers.string(workspaceId); } } @@ -31,7 +34,7 @@ export class QueryProcessor { convertToSQLParameters( query: string, - parameters?: Record, + parameters?: Record, ): { statement: string; parameters: sql.StatementParameterListItem[] } { const sqlParameters: sql.StatementParameterListItem[] = []; @@ -65,87 +68,22 @@ export class QueryProcessor { private _createParameter( key: string, - value: any, + value: SQLParameterValue, ): sql.StatementParameterListItem | null { if (value === null || value === undefined) { return null; } - if (value === "" && key.includes("Filter")) { - return null; - } - - if (key.includes("Date") || key.includes("date")) { - if (typeof value === "string") { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw new Error(`Invalid date format for parameter ${key}: ${value}`); - } - return { - name: key, - value: value, - type: "DATE", - }; - } - if (value instanceof Date) { - return { - name: key, - value: value.toISOString().split("T")[0], - type: "DATE", - }; - } - } - - if (key.includes("Time") || key.includes("timestamp")) { - if (value instanceof Date) { - return { - name: key, - value: value.toISOString(), - type: "TIMESTAMP", - }; - } - - return { - name: key, - value: String(value), - type: "TIMESTAMP", - }; - } - - if (key === "aggregationLevel") { - const validLevels = ["hour", "day", "week", "month", "year"]; - if (!validLevels.includes(value)) { - throw new Error( - `Invalid aggregation level: ${value}. Must be one of: ${validLevels.join( - ", ", - )}`, - ); - } - return { - name: key, - value: value, - type: "STRING", - }; - } - if (typeof value === "boolean") { - return { - name: key, - value: String(value), - type: "BOOLEAN", - }; - } - - if (typeof value === "number") { - return { - name: key, - value: String(value), - type: "NUMERIC", - }; + if (!isSQLTypeMarker(value)) { + throw new Error( + `Parameter "${key}" must be a SQL type. Use sql.string(), sql.number(), sql.date(), sql.timestamp(), or sql.boolean().`, + ); } return { name: key, - value: String(value), - type: "STRING", + value: value.value, + type: value.__sql_type, }; } } diff --git a/packages/app-kit/src/analytics/tests/analytics.test.ts b/packages/app-kit/src/analytics/tests/analytics.test.ts index cfb0be6..3eb105a 100644 --- a/packages/app-kit/src/analytics/tests/analytics.test.ts +++ b/packages/app-kit/src/analytics/tests/analytics.test.ts @@ -5,6 +5,7 @@ import { runWithRequestContext, setupDatabricksEnv, } from "@tools/test-helpers"; +import { sql } from "shared"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { AnalyticsPlugin, analytics } from "../analytics"; import type { IAnalyticsConfig } from "../types"; @@ -167,7 +168,7 @@ describe("Analytics Plugin", () => { const handler = getHandler("POST", "/users/me/query/:query_key"); const mockReq = createMockRequest({ params: { query_key: "user_profile" }, - body: { parameters: { user_id: 123 } }, + body: { parameters: { user_id: sql.number(123) } }, headers: { "x-forwarded-access-token": "user-token-123" }, }); const mockRes = createMockResponse(); @@ -227,7 +228,7 @@ describe("Analytics Plugin", () => { const handler = getHandler("POST", "/query/:query_key"); const mockReq = createMockRequest({ params: { query_key: "test_query" }, - body: { parameters: { foo: "bar" } }, + body: { parameters: { foo: sql.string("bar") } }, }); await runWithRequestContext(async () => { @@ -268,7 +269,7 @@ describe("Analytics Plugin", () => { const mockReq1 = createMockRequest({ params: { query_key: "user_profile" }, - body: { parameters: { user_id: 1 } }, + body: { parameters: { user_id: sql.number(1) } }, headers: { "x-forwarded-access-token": "user-token-1" }, }); const mockRes1 = createMockResponse(); @@ -281,7 +282,7 @@ describe("Analytics Plugin", () => { const mockReq2 = createMockRequest({ params: { query_key: "user_profile" }, - body: { parameters: { user_id: 2 } }, + body: { parameters: { user_id: sql.number(2) } }, headers: { "x-forwarded-access-token": "user-token-2" }, }); const mockRes2 = createMockResponse(); @@ -294,7 +295,7 @@ describe("Analytics Plugin", () => { const mockReq1Again = createMockRequest({ params: { query_key: "user_profile" }, - body: { parameters: { user_id: 1 } }, + body: { parameters: { user_id: sql.number(1) } }, headers: { "x-forwarded-access-token": "user-token-1" }, }); const mockRes1Again = createMockResponse(); diff --git a/packages/app-kit/src/analytics/tests/query.test.ts b/packages/app-kit/src/analytics/tests/query.test.ts index 974f873..6c1cb84 100644 --- a/packages/app-kit/src/analytics/tests/query.test.ts +++ b/packages/app-kit/src/analytics/tests/query.test.ts @@ -1,4 +1,5 @@ import { runWithRequestContext } from "@tools/test-helpers"; +import { sql } from "shared"; import { describe, expect, test } from "vitest"; import { QueryProcessor } from "../query"; @@ -8,7 +9,10 @@ describe("QueryProcessor", () => { describe("convertToSQLParameters - Parameter Injection Protection", () => { test("should accept valid parameters that exist in query", () => { const query = "SELECT * FROM users WHERE id = :user_id AND name = :name"; - const parameters = { user_id: 123, name: "Alice" }; + const parameters = { + user_id: sql.number(123), + name: sql.string("Alice"), + }; const result = processor.convertToSQLParameters(query, parameters); @@ -22,7 +26,10 @@ describe("QueryProcessor", () => { test("should reject parameters that do not exist in query", () => { const query = "SELECT * FROM users WHERE id = :user_id"; - const parameters = { user_id: 123, malicious_param: "DROP TABLE" }; + const parameters = { + user_id: sql.number(123), + malicious_param: sql.string("DROP TABLE"), + }; expect(() => { processor.convertToSQLParameters(query, parameters); @@ -34,9 +41,9 @@ describe("QueryProcessor", () => { test("should reject multiple invalid parameters", () => { const query = "SELECT * FROM users WHERE id = :user_id"; const parameters = { - user_id: 123, - admin_flag: true, - delete_all: true, + user_id: sql.number(123), + admin_flag: sql.boolean(true), + delete_all: sql.boolean(true), }; expect(() => { @@ -47,7 +54,10 @@ describe("QueryProcessor", () => { test("should allow parameters with underscores and mixed case", () => { const query = "SELECT * FROM orders WHERE customer_id = :customer_id AND order_Date = :order_Date"; - const parameters = { customer_id: 456, order_Date: "2024-01-01" }; + const parameters = { + customer_id: sql.number(456), + order_Date: sql.date("2024-01-01"), + }; const result = processor.convertToSQLParameters(query, parameters); @@ -58,7 +68,7 @@ describe("QueryProcessor", () => { test("should handle query with no parameters", () => { const query = "SELECT * FROM users"; - const parameters = { user_id: 123 }; + const parameters = { user_id: sql.number(123) }; expect(() => { processor.convertToSQLParameters(query, parameters); @@ -89,7 +99,7 @@ describe("QueryProcessor", () => { test("should handle parameters with null/undefined values (filtered out)", () => { const query = "SELECT * FROM users WHERE id = :user_id AND status = :status"; - const parameters = { user_id: 123, status: null }; + const parameters = { user_id: sql.number(123), status: null }; const result = processor.convertToSQLParameters(query, parameters); @@ -102,12 +112,12 @@ describe("QueryProcessor", () => { const query = "SELECT * FROM orders WHERE customer_id = :customer_id AND status = :status"; const attackParameters = { - customer_id: 123, - status: "pending", + customer_id: sql.number(123), + status: sql.string("pending"), // Attack: try to inject additional parameters - admin_override: true, - bypass_auth: "true", - internal_flag: 1, + admin_override: sql.boolean(true), + bypass_auth: sql.string("true"), + internal_flag: sql.number(1), }; expect(() => { @@ -118,7 +128,7 @@ describe("QueryProcessor", () => { test("should handle duplicate parameter names in query correctly", () => { const query = "SELECT * FROM users WHERE (status = :status OR backup_status = :status)"; - const parameters = { status: "active" }; + const parameters = { status: sql.string("active") }; const result = processor.convertToSQLParameters(query, parameters); @@ -138,34 +148,40 @@ describe("QueryProcessor", () => { return await processor.processQueryParams(query, parameters); }, { - workspaceId: Promise.resolve("workspace-123"), + workspaceId: Promise.resolve("1234567890"), }, ); - expect(result.workspaceId).toBe("workspace-123"); + expect(result.workspaceId).toEqual({ + __sql_type: "STRING", + value: "1234567890", + }); }); test("should not override workspace_id if already provided", async () => { const query = "SELECT * FROM data WHERE workspace_id = :workspaceId"; - const parameters = { workspaceId: "custom-workspace" }; + const parameters = { workspaceId: sql.number("9876543210") }; const result = await runWithRequestContext( async () => { return await processor.processQueryParams(query, parameters); }, { - workspaceId: Promise.resolve("workspace-123"), + workspaceId: Promise.resolve("1234567890"), }, ); - expect(result.workspaceId).toBe("custom-workspace"); + expect(result.workspaceId).toEqual({ + __sql_type: "NUMERIC", + value: "9876543210", + }); }); }); describe("_createParameter - Type Handling", () => { - test("should handle date parameters", () => { + test("should handle date parameters with sql.date()", () => { const query = "SELECT * FROM events WHERE event_date = :startDate"; - const parameters = { startDate: "2024-01-01" }; + const parameters = { startDate: sql.date("2024-01-01") }; const result = processor.convertToSQLParameters(query, parameters); @@ -176,32 +192,24 @@ describe("QueryProcessor", () => { }); }); - test("should reject invalid date format", () => { - const query = "SELECT * FROM events WHERE event_date = :startDate"; - const parameters = { startDate: "01/01/2024" }; - - expect(() => { - processor.convertToSQLParameters(query, parameters); - }).toThrow("Invalid date format for parameter startDate: 01/01/2024"); - }); - - test("should handle timestamp parameters", () => { + test("should handle timestamp parameters with sql.timestamp()", () => { const query = "SELECT * FROM events WHERE created_at = :createdTime"; - const date = new Date("2024-01-01T12:00:00Z"); - const parameters = { createdTime: date }; + const parameters = { + createdTime: sql.timestamp(new Date("2024-01-01T12:00:00Z")), + }; const result = processor.convertToSQLParameters(query, parameters); expect(result.parameters[0]).toEqual({ name: "createdTime", - value: date.toISOString(), + value: "2024-01-01T12:00:00Z", type: "TIMESTAMP", }); }); - test("should handle boolean parameters", () => { + test("should handle boolean parameters with sql.boolean()", () => { const query = "SELECT * FROM users WHERE is_active = :isActive"; - const parameters = { isActive: true }; + const parameters = { isActive: sql.boolean(true) }; const result = processor.convertToSQLParameters(query, parameters); @@ -212,9 +220,9 @@ describe("QueryProcessor", () => { }); }); - test("should handle numeric parameters", () => { + test("should handle numeric parameters with sql.number()", () => { const query = "SELECT * FROM users WHERE age = :age"; - const parameters = { age: 25 }; + const parameters = { age: sql.number(25) }; const result = processor.convertToSQLParameters(query, parameters); @@ -225,9 +233,9 @@ describe("QueryProcessor", () => { }); }); - test("should validate aggregationLevel parameter", () => { + test("should handle string parameters with sql.string()", () => { const query = "SELECT * FROM metrics WHERE level = :aggregationLevel"; - const parameters = { aggregationLevel: "day" }; + const parameters = { aggregationLevel: sql.string("day") }; const result = processor.convertToSQLParameters(query, parameters); @@ -238,14 +246,14 @@ describe("QueryProcessor", () => { }); }); - test("should reject invalid aggregationLevel", () => { - const query = "SELECT * FROM metrics WHERE level = :aggregationLevel"; - const parameters = { aggregationLevel: "invalid" }; + test("should reject non-SQL type parameters", () => { + const query = "SELECT * FROM users WHERE id = :userId"; + const parameters = { userId: 123 as any }; expect(() => { processor.convertToSQLParameters(query, parameters); }).toThrow( - "Invalid aggregation level: invalid. Must be one of: hour, day, week, month, year", + 'Parameter "userId" must be a SQL type. Use sql.string(), sql.number(), sql.date(), sql.timestamp(), or sql.boolean().', ); }); }); diff --git a/packages/app-kit/src/index.ts b/packages/app-kit/src/index.ts index ce83141..47db9af 100644 --- a/packages/app-kit/src/index.ts +++ b/packages/app-kit/src/index.ts @@ -1,3 +1,13 @@ +export type { + BasePluginConfig, + IAppRouter, + SQLTypeMarker, + StreamExecutionSettings, +} from "shared"; +export { + isSQLTypeMarker, + sql, +} from "shared"; export { analytics } from "./analytics"; export { CacheManager } from "./cache"; export { createApp } from "./core"; @@ -11,9 +21,4 @@ export { type Span, SpanStatusCode, } from "./telemetry"; -export type { - BasePluginConfig, - IAppRouter, - StreamExecutionSettings, -} from "shared"; export { getRequestContext } from "./utils"; diff --git a/packages/app-kit/src/server/index.ts b/packages/app-kit/src/server/index.ts index afc8ea4..e62b78b 100644 --- a/packages/app-kit/src/server/index.ts +++ b/packages/app-kit/src/server/index.ts @@ -4,10 +4,10 @@ import path from "node:path"; import dotenv from "dotenv"; import express from "express"; import type { PluginPhase } from "shared"; -import { generatePluginRegistryTypes } from "@/utils/type-generator"; import { Plugin, toPlugin } from "../plugin"; import { instrumentations } from "../telemetry"; import { databricksClientMiddleware, isRemoteServerEnabled } from "../utils"; +import { generatePluginRegistryTypes } from "../utils/type-generator"; import { DevModeManager } from "./dev-mode"; import type { ServerConfig } from "./types"; import { getQueries, getRoutes } from "./utils"; diff --git a/packages/app-kit/src/server/types.ts b/packages/app-kit/src/server/types.ts index 5145815..8e7d6a1 100644 --- a/packages/app-kit/src/server/types.ts +++ b/packages/app-kit/src/server/types.ts @@ -1,5 +1,5 @@ import type { BasePluginConfig } from "shared"; -import type { Plugin } from "@/plugin"; +import type { Plugin } from "../plugin"; export interface ServerConfig extends BasePluginConfig { port?: number; diff --git a/packages/app-kit/src/utils/type-generator.ts b/packages/app-kit/src/utils/type-generator.ts index 0d633ea..5387267 100644 --- a/packages/app-kit/src/utils/type-generator.ts +++ b/packages/app-kit/src/utils/type-generator.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { QuerySchemas } from "shared"; import { createAuxiliaryTypeStore, printNode, zodToTs } from "zod-to-ts"; -import { type Plugin, routeSchemaRegistry } from "@/plugin"; +import { type Plugin, routeSchemaRegistry } from "../plugin"; interface AppKitRegistry { pluginRegistry: string; diff --git a/packages/app-kit/tsdown.config.ts b/packages/app-kit/tsdown.config.ts index 0f2d5d8..6c51e27 100644 --- a/packages/app-kit/tsdown.config.ts +++ b/packages/app-kit/tsdown.config.ts @@ -38,8 +38,5 @@ export default defineConfig([ to: "dist/server/denied.html", }, ], - exports: { - devExports: "development", - }, }, ]); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2be599e..4f09a67 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./cache"; export * from "./execute"; export * from "./plugin"; +export * from "./sql"; export * from "./tunnel"; diff --git a/packages/shared/src/sql/helpers.ts b/packages/shared/src/sql/helpers.ts new file mode 100644 index 0000000..8f77ee9 --- /dev/null +++ b/packages/shared/src/sql/helpers.ts @@ -0,0 +1,358 @@ +/** + * Object that identifies a typed SQL parameter. + * Created using sql.date(), sql.string(), sql.number(), sql.boolean(), sql.timestamp(), sql.binary(), or sql.interval(). + */ +export interface SQLTypeMarker { + __sql_type: + | "DATE" + | "TIMESTAMP" + | "STRING" + | "NUMERIC" + | "BOOLEAN" + | "BINARY"; + value: string; +} + +/** + * SQL helper namespace + */ +export const sql = { + /** + * Creates a DATE type parameter + * Accepts Date objects or ISO date strings (YYYY-MM-DD format) + * @param value - Date object or ISO date string + * @returns Marker object for DATE type parameter + * @example + * ```typescript + * const params = { startDate: sql.date(new Date("2024-01-01")) }; + * params = { startDate: "2024-01-01" } + * ``` + * @example + * ```typescript + * const params = { startDate: sql.date("2024-01-01") }; + * params = { startDate: "2024-01-01" } + * ``` + */ + date(value: Date | string): SQLTypeMarker { + let dateValue: string = ""; + + // check if value is a Date object + if (value instanceof Date) { + dateValue = value.toISOString().split("T")[0]; + } + // check if value is a string + else if (typeof value === "string") { + // validate format + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new Error( + `sql.date() expects Date or ISO date string (YYYY-MM-DD format), got: ${value}`, + ); + } + dateValue = value; + } + // if value is not a Date object or string, throw an error + else { + throw new Error( + `sql.date() expects Date or ISO date string (YYYY-MM-DD format), got: ${typeof value}`, + ); + } + + return { + __sql_type: "DATE", + value: dateValue, + }; + }, + + /** + * Creates a TIMESTAMP type parameter + * Accepts Date objects, ISO timestamp strings, or Unix timestamp numbers + * @param value - Date object, ISO timestamp string, or Unix timestamp number + * @returns Marker object for TIMESTAMP type parameter + * @example + * ```typescript + * const params = { createdTime: sql.timestamp(new Date("2024-01-01T12:00:00Z")) }; + * params = { createdTime: "2024-01-01T12:00:00Z" } + * ``` + * @example + * ```typescript + * const params = { createdTime: sql.timestamp("2024-01-01T12:00:00Z") }; + * params = { createdTime: "2024-01-01T12:00:00Z" } + * ``` + * @example + * ```typescript + * const params = { createdTime: sql.timestamp(1704110400000) }; + * params = { createdTime: "2024-01-01T12:00:00Z" } + * ``` + */ + timestamp(value: Date | string | number): SQLTypeMarker { + let timestampValue: string = ""; + + if (value instanceof Date) { + timestampValue = value.toISOString().replace(/\.000(Z|[+-])/, "$1"); + } else if (typeof value === "string") { + const isoRegex = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})?$/; + if (!isoRegex.test(value)) { + throw new Error( + `sql.timestamp() expects ISO timestamp string (YYYY-MM-DDTHH:MM:SS.mmmZ or YYYY-MM-DDTHH:MM:SS.mmm+HH:MM), got: ${value}`, + ); + } + timestampValue = value; + } else if (typeof value === "number") { + const date = new Date(value > 1e12 ? value : value * 1000); + timestampValue = date.toISOString().replace(/\.000(Z|[+-])/, "$1"); + } else { + throw new Error( + `sql.timestamp() expects Date, ISO timestamp string, or Unix timestamp number, got: ${typeof value}`, + ); + } + + return { + __sql_type: "TIMESTAMP", + value: timestampValue, + }; + }, + + /** + * Creates a NUMERIC type parameter + * Accepts numbers or numeric strings + * @param value - Number or numeric string + * @returns Marker object for NUMERIC type parameter + * @example + * ```typescript + * const params = { userId: sql.number(123) }; + * params = { userId: "123" } + * ``` + * @example + * ```typescript + * const params = { userId: sql.number("123") }; + * params = { userId: "123" } + * ``` + */ + number(value: number | string): SQLTypeMarker { + let numValue: string = ""; + + // check if value is a number + if (typeof value === "number") { + numValue = value.toString(); + } + // check if value is a string + else if (typeof value === "string") { + if (value === "" || Number.isNaN(Number(value))) { + throw new Error( + `sql.number() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, + ); + } + numValue = value; + } + // if value is not a number or string, throw an error + else { + throw new Error( + `sql.number() expects number or numeric string, got: ${typeof value}`, + ); + } + + return { + __sql_type: "NUMERIC", + value: numValue, + }; + }, + + /** + * Creates a STRING type parameter + * Accepts strings, numbers, or booleans + * @param value - String, number, or boolean + * @returns Marker object for STRING type parameter + * @example + * ```typescript + * const params = { name: sql.string("John") }; + * params = { name: "John" } + * ``` + * @example + * ```typescript + * const params = { name: sql.string(123) }; + * params = { name: "123" } + * ``` + * @example + * ```typescript + * const params = { name: sql.string(true) }; + * params = { name: "true" } + * ``` + */ + string(value: string | number | boolean): SQLTypeMarker { + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" + ) { + throw new Error( + `sql.string() expects string or number or boolean, got: ${typeof value}`, + ); + } + + let stringValue: string = ""; + + if (typeof value === "string") { + stringValue = value; + } else { + stringValue = value.toString(); + } + + return { + __sql_type: "STRING", + value: stringValue, + }; + }, + + /** + * Create a BOOLEAN type parameter + * Accepts booleans, strings, or numbers + * @param value - Boolean, string, or number + * @returns Marker object for BOOLEAN type parameter + * @example + * ```typescript + * const params = { isActive: sql.boolean(true) }; + * params = { isActive: "true" } + * ``` + * @example + * ```typescript + * const params = { isActive: sql.boolean("true") }; + * params = { isActive: "true" } + * ``` + * @example + * ```typescript + * const params = { isActive: sql.boolean(1) }; + * params = { isActive: "true" } + * ``` + * @example + * ```typescript + * const params = { isActive: sql.boolean("false") }; + * params = { isActive: "false" } + * ``` + * @example + * ```typescript + * const params = { isActive: sql.boolean(0) }; + * params = { isActive: "false" } + * ``` + * @returns + */ + boolean(value: boolean | string | number): SQLTypeMarker { + if ( + typeof value !== "boolean" && + typeof value !== "string" && + typeof value !== "number" + ) { + throw new Error( + `sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: ${typeof value}`, + ); + } + + let booleanValue: string = ""; + + if (typeof value === "boolean") { + booleanValue = value.toString(); + } + // check if value is a number + else if (typeof value === "number") { + if (value !== 1 && value !== 0) { + throw new Error( + `sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: ${value}`, + ); + } + booleanValue = value === 1 ? "true" : "false"; + } + // check if value is a string + else if (typeof value === "string") { + if (value !== "true" && value !== "false") { + throw new Error( + `sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: ${value}`, + ); + } + booleanValue = value; + } + + return { + __sql_type: "BOOLEAN", + value: booleanValue, + }; + }, + + /** + * Creates a BINARY parameter as hex-encoded STRING + * Accepts Uint8Array, ArrayBuffer, or hex string + * Note: Databricks SQL Warehouse doesn't support BINARY as parameter type, + * so this helper returns a STRING with hex encoding. Use UNHEX(:param) in your SQL. + * @param value - Uint8Array, ArrayBuffer, or hex string + * @returns Marker object with STRING type and hex-encoded value + * @example + * ```typescript + * // From Uint8Array: + * const params = { data: sql.binary(new Uint8Array([0x53, 0x70, 0x61, 0x72, 0x6b])) }; + * // Returns: { __sql_type: "STRING", value: "537061726B" } + * // SQL: SELECT UNHEX(:data) as binary_value + * ``` + * @example + * ```typescript + * // From hex string: + * const params = { data: sql.binary("537061726B") }; + * // Returns: { __sql_type: "STRING", value: "537061726B" } + * ``` + */ + binary(value: Uint8Array | ArrayBuffer | string): SQLTypeMarker { + let hexValue: string = ""; + + if (value instanceof Uint8Array) { + // if value is a Uint8Array, convert it to a hex string + hexValue = Array.from(value) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + } else if (value instanceof ArrayBuffer) { + // if value is an ArrayBuffer, convert it to a hex string + hexValue = Array.from(new Uint8Array(value)) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + } else if (typeof value === "string") { + // validate hex string + if (!/^[0-9A-Fa-f]*$/.test(value)) { + throw new Error( + `sql.binary() expects Uint8Array, ArrayBuffer, or hex string, got invalid hex: ${value}`, + ); + } + hexValue = value.toUpperCase(); + } else { + throw new Error( + `sql.binary() expects Uint8Array, ArrayBuffer, or hex string, got: ${typeof value}`, + ); + } + + return { + __sql_type: "STRING", + value: hexValue, + }; + }, +}; + +/** + * Type guard to check if a value is a SQL type marker + * @param value - Value to check + * @returns True if the value is a SQL type marker, false otherwise + * @example + * ```typescript + * const value = { + * __sql_type: "DATE", + * value: "2024-01-01", + * }; + * const isSQLTypeMarker = isSQLTypeMarker(value); + * console.log(isSQLTypeMarker); // true + * ``` + */ +export function isSQLTypeMarker(value: any): value is SQLTypeMarker { + return ( + value !== null && + typeof value === "object" && + "__sql_type" in value && + "value" in value && + typeof value.__sql_type === "string" && + typeof value.value === "string" + ); +} diff --git a/packages/shared/src/sql/index.ts b/packages/shared/src/sql/index.ts new file mode 100644 index 0000000..d4e09d7 --- /dev/null +++ b/packages/shared/src/sql/index.ts @@ -0,0 +1 @@ +export * from "./helpers"; diff --git a/packages/shared/src/sql/tests/sql-helpers.test.ts b/packages/shared/src/sql/tests/sql-helpers.test.ts new file mode 100644 index 0000000..9b62f48 --- /dev/null +++ b/packages/shared/src/sql/tests/sql-helpers.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; +import { isSQLTypeMarker, sql } from "../helpers"; + +describe("SQL Helpers", () => { + describe("date()", () => { + it("should create a DATE type parameter from a Date object", () => { + const date = new Date("2024-01-01"); + const result = sql.date(date); + expect(result).toEqual({ + __sql_type: "DATE", + value: "2024-01-01", + }); + }); + + it("should create a DATE type parameter from an ISO date string", () => { + const date = "2024-01-01"; + const result = sql.date(date); + expect(result).toEqual({ + __sql_type: "DATE", + value: "2024-01-01", + }); + }); + + it("should reject invalid date format", () => { + const date = "01/01/2024"; + expect(() => sql.date(date)).toThrow( + "sql.date() expects Date or ISO date string (YYYY-MM-DD format), got: 01/01/2024", + ); + }); + + it("should reject invalid date value", () => { + const date = 1234567890; + expect(() => sql.date(date as any)).toThrow( + "sql.date() expects Date or ISO date string (YYYY-MM-DD format), got: number", + ); + }); + }); + + describe("number()", () => { + it("should create a NUMERIC type parameter from a number", () => { + const number = 1234567890; + const result = sql.number(number); + expect(result).toEqual({ + __sql_type: "NUMERIC", + value: "1234567890", + }); + }); + + it("should create a NUMERIC type parameter from a numeric string", () => { + const number = "1234567890"; + const result = sql.number(number); + expect(result).toEqual({ + __sql_type: "NUMERIC", + value: "1234567890", + }); + }); + + it("should reject non-numeric string", () => { + const number = "hello"; + expect(() => sql.number(number as any)).toThrow( + "sql.number() expects number or numeric string, got: hello", + ); + }); + + it("should reject empty string", () => { + expect(() => sql.number("")).toThrow( + "sql.number() expects number or numeric string, got: empty string", + ); + }); + + it("should reject boolean value", () => { + const number = true; + expect(() => sql.number(number as any)).toThrow( + "sql.number() expects number or numeric string, got: boolean", + ); + }); + }); + + describe("string()", () => { + it("should create a STRING type parameter from a string", () => { + const string = "Hello, world!"; + const result = sql.string(string); + expect(result).toEqual({ + __sql_type: "STRING", + value: "Hello, world!", + }); + }); + it("should create a STRING type parameter from a number", () => { + const number = 1234567890; + const result = sql.string(number); + expect(result).toEqual({ + __sql_type: "STRING", + value: "1234567890", + }); + }); + it("should create a STRING type parameter from a boolean", () => { + const boolean = true; + const result = sql.string(boolean); + expect(result).toEqual({ + __sql_type: "STRING", + value: "true", + }); + }); + it("should reject invalid string value", () => { + const number = null; + expect(() => sql.string(number as any)).toThrow( + "sql.string() expects string or number or boolean, got: object", + ); + }); + }); + + describe("boolean()", () => { + it("should create a BOOLEAN type parameter from a boolean", () => { + const boolean = true; + const result = sql.boolean(boolean); + expect(result).toEqual({ + __sql_type: "BOOLEAN", + value: "true", + }); + }); + + it("should create a BOOLEAN type parameter from a string", () => { + const string = "true"; + const result = sql.boolean(string); + expect(result).toEqual({ + __sql_type: "BOOLEAN", + value: "true", + }); + }); + it("should create a BOOLEAN type parameter from a number", () => { + const number = 1; + const result = sql.boolean(number); + expect(result).toEqual({ + __sql_type: "BOOLEAN", + value: "true", + }); + }); + it("should reject invalid type ", () => { + const rand = null; + expect(() => sql.boolean(rand as any)).toThrow( + "sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: object", + ); + }); + + it("should reject invalid number value", () => { + const number = 7; + expect(() => sql.boolean(number as any)).toThrow( + "sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: 7", + ); + }); + + it("should reject invalid string value", () => { + const string = "hello"; + expect(() => sql.boolean(string as any)).toThrow( + "sql.boolean() expects boolean or string (true or false) or number (1 or 0), got: hello", + ); + }); + }); + + describe("binary()", () => { + it("should create a STRING type with hex value from Uint8Array", () => { + // "Spark" in bytes → hex "537061726B" + const data = new Uint8Array([0x53, 0x70, 0x61, 0x72, 0x6b]); + const result = sql.binary(data); + expect(result).toEqual({ + __sql_type: "STRING", + value: "537061726B", + }); + }); + + it("should create a STRING type with hex value from ArrayBuffer", () => { + const buffer = new Uint8Array([0x1a, 0xbf]).buffer; + const result = sql.binary(buffer); + expect(result).toEqual({ + __sql_type: "STRING", + value: "1ABF", + }); + }); + + it("should accept hex string and normalize to uppercase", () => { + const result = sql.binary("1abf"); + expect(result).toEqual({ + __sql_type: "STRING", + value: "1ABF", + }); + }); + + it("should accept empty string", () => { + const result = sql.binary(""); + expect(result).toEqual({ + __sql_type: "STRING", + value: "", + }); + }); + + it("should accept empty Uint8Array", () => { + const result = sql.binary(new Uint8Array([])); + expect(result).toEqual({ + __sql_type: "STRING", + value: "", + }); + }); + + it("should handle arbitrary bytes including non-UTF8", () => { + // 0xFF 0xFE are valid hex bytes even if not valid UTF-8 + const bytes = new Uint8Array([0xff, 0xfe]); + const result = sql.binary(bytes); + expect(result).toEqual({ + __sql_type: "STRING", + value: "FFFE", + }); + }); + + it("should reject invalid hex string", () => { + expect(() => sql.binary("GHIJ")).toThrow( + "sql.binary() expects Uint8Array, ArrayBuffer, or hex string, got invalid hex: GHIJ", + ); + }); + + it("should reject invalid type", () => { + expect(() => sql.binary(123 as any)).toThrow( + "sql.binary() expects Uint8Array, ArrayBuffer, or hex string, got: number", + ); + }); + }); + + describe("timestamp()", () => { + it("should create a TIMESTAMP type parameter from a Date object", () => { + const date = new Date("2024-01-01T12:00:00Z"); + const result = sql.timestamp(date); + expect(result).toEqual({ + __sql_type: "TIMESTAMP", + value: "2024-01-01T12:00:00Z", + }); + }); + + it("should create a TIMESTAMP type parameter from an ISO timestamp string", () => { + const timestamp = "2024-01-01T12:00:00Z"; + const result = sql.timestamp(timestamp); + expect(result).toEqual({ + __sql_type: "TIMESTAMP", + value: "2024-01-01T12:00:00Z", + }); + }); + + it("should create a TIMESTAMP type parameter from a Unix timestamp number", () => { + const timestamp = 1704110400000; + const result = sql.timestamp(timestamp); + expect(result).toEqual({ + __sql_type: "TIMESTAMP", + value: "2024-01-01T12:00:00Z", + }); + }); + + it("should reject invalid timestamp string", () => { + const timestamp = "2024-01-01"; + expect(() => sql.timestamp(timestamp as any)).toThrow( + "sql.timestamp() expects ISO timestamp string (YYYY-MM-DDTHH:MM:SS.mmmZ or YYYY-MM-DDTHH:MM:SS.mmm+HH:MM), got: 2024-01-01", + ); + }); + + it("should reject invalid timestamp number", () => { + const timestamp = "2024-01-01"; + expect(() => sql.timestamp(timestamp as any)).toThrow( + "sql.timestamp() expects ISO timestamp string (YYYY-MM-DDTHH:MM:SS.mmmZ or YYYY-MM-DDTHH:MM:SS.mmm+HH:MM), got: 2024-01-01", + ); + }); + + it("should reject invalid timestamp value", () => { + const timestamp = null; + expect(() => sql.timestamp(timestamp as any)).toThrow( + "sql.timestamp() expects Date, ISO timestamp string, or Unix timestamp number, got: object", + ); + }); + }); +}); + +describe("SQL Type Marker", () => { + it("should return true if the value is a SQL type marker", () => { + const value = { + __sql_type: "TIMESTAMP", + value: "2024-01-01T12:00:00Z", + }; + expect(isSQLTypeMarker(value)).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0c157ab..317e56b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,14 @@ export default defineConfig({ environment: "node", }, }, + { + plugins: [tsconfigPaths()], + test: { + name: "shared", + root: "./packages/shared", + environment: "node", + }, + }, ], }, });