@lightdash/query-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ Copyright (c) 2021-present Telescope Technology Limited (trading as “Lightdash”)
2
+
3
+ Portions of this software are licensed as follows:
4
+
5
+ * All content that resides under the "packages/backend/src/ee" directory of this repository, if that directory exists, is licensed under the license defined in "packages/backend/src/ee/LICENSE".
6
+ * All third party components incorporated into the Lightdash Software are licensed under the original license provided by the owner of the applicable component.
7
+ * Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # @lightdash/query-sdk
2
+
3
+ A React SDK for building custom data apps against the Lightdash semantic layer.
4
+
5
+ ## Quick start
6
+
7
+ ```tsx
8
+ import {
9
+ createClient,
10
+ LightdashProvider,
11
+ useLightdash,
12
+ } from '@lightdash/query-sdk';
13
+
14
+ const lightdash = createClient();
15
+
16
+ function App() {
17
+ return (
18
+ <LightdashProvider client={lightdash}>
19
+ <Dashboard />
20
+ </LightdashProvider>
21
+ );
22
+ }
23
+
24
+ function Dashboard() {
25
+ const { data, loading, error } = useLightdash(
26
+ lightdash
27
+ .model('orders')
28
+ .dimensions(['customer_segment'])
29
+ .metrics(['total_revenue', 'order_count'])
30
+ .filters([
31
+ {
32
+ field: 'order_date',
33
+ operator: 'inThePast',
34
+ value: 90,
35
+ unit: 'days',
36
+ },
37
+ ])
38
+ .sorts([{ field: 'total_revenue', direction: 'desc' }])
39
+ .limit(10),
40
+ );
41
+
42
+ if (loading) return <p>Loading...</p>;
43
+ if (error) return <p>Error: {error.message}</p>;
44
+
45
+ return (
46
+ <ul>
47
+ {data.map((row, i) => (
48
+ <li key={i}>
49
+ {row.customer_segment}: {row.total_revenue}
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ );
54
+ }
55
+ ```
56
+
57
+ Result rows are flat objects with raw typed values (numbers are numbers, strings are strings).
58
+
59
+ ## Authentication
60
+
61
+ The SDK reads credentials from env vars. For Vite projects, add a `.env` file:
62
+
63
+ ```
64
+ VITE_LIGHTDASH_API_KEY=your-pat-token
65
+ VITE_LIGHTDASH_URL=https://app.lightdash.cloud
66
+ VITE_LIGHTDASH_PROJECT_UUID=your-project-uuid
67
+ ```
68
+
69
+ For Node/E2B environments, use unprefixed names (`LIGHTDASH_API_KEY`, etc.).
70
+
71
+ Calling `createClient()` with no arguments reads from env vars. You can also pass config explicitly:
72
+
73
+ ```ts
74
+ const lightdash = createClient({
75
+ apiKey: token,
76
+ baseUrl: 'https://app.lightdash.cloud',
77
+ projectUuid: 'uuid',
78
+ });
79
+ ```
80
+
81
+ ## Query builder
82
+
83
+ Queries are built with a chainable, immutable API. Field names use short names (e.g. `driver_name`), and the SDK qualifies them automatically for the API.
84
+
85
+ ```ts
86
+ lightdash
87
+ .model('orders')
88
+ .dimensions(['customer_name', 'order_date'])
89
+ .metrics(['total_revenue', 'order_count'])
90
+ .filters([
91
+ { field: 'status', operator: 'equals', value: 'completed' },
92
+ { field: 'amount', operator: 'greaterThan', value: 1000 },
93
+ { field: 'order_date', operator: 'inThePast', value: 90, unit: 'days' },
94
+ ])
95
+ .sorts([{ field: 'total_revenue', direction: 'desc' }])
96
+ .limit(100);
97
+ ```
98
+
99
+ Supported filter operators: `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `inThePast`, `notInThePast`, `inTheNext`, `inTheCurrent`, `notInTheCurrent`, `inBetween`, `notInBetween`, `isNull`, `notNull`, `startsWith`, `endsWith`, `include`, `doesNotInclude`.
100
+
101
+ ## Results
102
+
103
+ `useLightdash(query)` returns:
104
+
105
+ | Field | Type | Description |
106
+ | --------- | --------------- | ---------------------------------------------------------------- |
107
+ | `data` | `Row[]` | Array of flat objects. Numbers are numbers, strings are strings. |
108
+ | `loading` | `boolean` | True while the query is running. |
109
+ | `error` | `Error \| null` | Error if the query failed. |
110
+ | `refetch` | `() => void` | Re-run the query. |
111
+
112
+ ## User context
113
+
114
+ ```ts
115
+ const user = await lightdash.auth.getUser();
116
+ // { name: 'John Doe', email: '...', role: 'admin', orgId: '...', attributes: {} }
117
+ ```
118
+
119
+ ## How it works
120
+
121
+ 1. `createClient()` sets up auth and the API transport
122
+ 2. `<LightdashProvider>` makes the transport available to hooks via React context
123
+ 3. `useLightdash(query)` posts to the async metric query endpoint, polls for results, and returns flat rows
124
+ 4. Field IDs are auto-qualified (`driver_name` becomes `fct_race_results_driver_name` for the API)
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ pnpm -F query-sdk typecheck # type check
130
+ pnpm -F query-sdk lint # lint
131
+ pnpm -F query-sdk fix-format # format with oxfmt
132
+ ```
133
+
134
+ See `example/` for a working F1 dashboard demo.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * React context provider for the Lightdash client.
3
+ *
4
+ * Usage:
5
+ * const lightdash = createClient({ apiKey, baseUrl, projectUuid })
6
+ * <LightdashProvider client={lightdash}>
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ import { type LightdashClient } from './client';
10
+ import type { Transport } from './types';
11
+ export declare function useTransport(): Transport;
12
+ export declare function useLightdashClient(): LightdashClient | null;
13
+ type LightdashProviderProps = {
14
+ children: ReactNode;
15
+ } & ({
16
+ client: LightdashClient;
17
+ transport?: never;
18
+ } | {
19
+ transport: Transport;
20
+ client?: never;
21
+ });
22
+ export declare function LightdashProvider({ children, ...props }: LightdashProviderProps): import("react/jsx-runtime").JSX.Element;
23
+ export {};
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * React context provider for the Lightdash client.
4
+ *
5
+ * Usage:
6
+ * const lightdash = createClient({ apiKey, baseUrl, projectUuid })
7
+ * <LightdashProvider client={lightdash}>
8
+ */
9
+ import { createContext, useContext, useMemo } from 'react';
10
+ const LightdashContext = createContext(null);
11
+ export function useTransport() {
12
+ const ctx = useContext(LightdashContext);
13
+ if (!ctx) {
14
+ throw new Error('useLightdash must be used inside <LightdashProvider>. ' +
15
+ 'Wrap your app in <LightdashProvider client={...}>.');
16
+ }
17
+ return ctx.transport;
18
+ }
19
+ export function useLightdashClient() {
20
+ const ctx = useContext(LightdashContext);
21
+ return ctx?.client ?? null;
22
+ }
23
+ export function LightdashProvider({ children, ...props }) {
24
+ const client = ('client' in props ? props.client : null) ?? null;
25
+ const transport = client?.transport ??
26
+ ('transport' in props ? props.transport : null) ??
27
+ null;
28
+ const value = useMemo(() => {
29
+ if (!transport) {
30
+ throw new Error('LightdashProvider requires either a client or transport prop.');
31
+ }
32
+ return { client, transport };
33
+ }, [client, transport]);
34
+ return (_jsx(LightdashContext.Provider, { value: value, children: children }));
35
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * API transport — executes queries against the real Lightdash async metrics endpoint.
3
+ *
4
+ * Flow:
5
+ * 1. POST /api/v2/projects/{projectUuid}/query/metric-query
6
+ * → returns { queryUuid }
7
+ * 2. Poll GET /api/v2/projects/{projectUuid}/query/{queryUuid}
8
+ * → returns results when status is 'ready'
9
+ */
10
+ import type { LightdashClientConfig, Transport } from './types';
11
+ export declare function createApiTransport(config: LightdashClientConfig): Transport;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * API transport — executes queries against the real Lightdash async metrics endpoint.
3
+ *
4
+ * Flow:
5
+ * 1. POST /api/v2/projects/{projectUuid}/query/metric-query
6
+ * → returns { queryUuid }
7
+ * 2. Poll GET /api/v2/projects/{projectUuid}/query/{queryUuid}
8
+ * → returns results when status is 'ready'
9
+ */
10
+ const POLL_INTERVAL_MS = 500;
11
+ const MAX_POLL_ATTEMPTS = 120; // 60 seconds max
12
+ /**
13
+ * Convert SDK filter definitions into the Lightdash API filter format.
14
+ * The API expects { dimensions: { id, and: [...rules] } }
15
+ */
16
+ function buildApiFilters(filters) {
17
+ if (filters.length === 0) {
18
+ return {};
19
+ }
20
+ // For now, all filters are AND-ed on dimensions.
21
+ // TODO: support metric filters and OR groups
22
+ const rules = filters.map((f, i) => ({
23
+ id: `sdk-filter-${i}`,
24
+ target: { fieldId: f.fieldId },
25
+ operator: f.operator,
26
+ values: f.values,
27
+ ...(f.settings ? { settings: f.settings } : {}),
28
+ }));
29
+ return {
30
+ dimensions: {
31
+ id: 'sdk-root',
32
+ and: rules,
33
+ },
34
+ };
35
+ }
36
+ async function apiFetch(config, method, path, body) {
37
+ // Use relative paths when a proxy is available (dev server),
38
+ // or the full base URL when calling the API directly (production).
39
+ const useProxy = config.useProxy ?? false;
40
+ const baseUrl = useProxy ? '' : config.baseUrl.replace(/\/$/, '');
41
+ const url = `${baseUrl}${path}`;
42
+ const res = await fetch(url, {
43
+ method,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ Authorization: `ApiKey ${config.apiKey}`,
47
+ },
48
+ ...(body ? { body: JSON.stringify(body) } : {}),
49
+ });
50
+ if (!res.ok) {
51
+ const text = await res.text();
52
+ let message;
53
+ try {
54
+ const parsed = JSON.parse(text);
55
+ message = parsed.error?.message ?? parsed.message ?? text;
56
+ }
57
+ catch {
58
+ message = text;
59
+ }
60
+ throw new Error(`Lightdash API error (${res.status}): ${message}`);
61
+ }
62
+ const json = (await res.json());
63
+ return json.results;
64
+ }
65
+ function mapColumnType(type) {
66
+ if (/timestamp/i.test(type))
67
+ return 'timestamp';
68
+ if (/date/i.test(type))
69
+ return 'date';
70
+ if (/number|int|float|average|count|sum|min|max|median|percentile/i.test(type))
71
+ return 'number';
72
+ if (/boolean/i.test(type))
73
+ return 'boolean';
74
+ return 'string';
75
+ }
76
+ export function createApiTransport(config) {
77
+ return {
78
+ async executeQuery(query) {
79
+ const table = query.exploreName;
80
+ // Lightdash field IDs are `{table}_{column}`.
81
+ // The SDK lets users write short names like 'driver_name'
82
+ // and we qualify them to 'fct_race_results_driver_name'.
83
+ const qualify = (fieldId) => fieldId.startsWith(`${table}_`)
84
+ ? fieldId
85
+ : `${table}_${fieldId}`;
86
+ const qualifiedDims = query.dimensions.map(qualify);
87
+ const qualifiedMetrics = query.metrics.map(qualify);
88
+ // Step 1: Execute async query
89
+ const body = {
90
+ query: {
91
+ exploreName: table,
92
+ dimensions: qualifiedDims,
93
+ metrics: qualifiedMetrics,
94
+ filters: buildApiFilters(query.filters.map((f) => ({
95
+ ...f,
96
+ fieldId: qualify(f.fieldId),
97
+ }))),
98
+ sorts: query.sorts.map((s) => ({
99
+ fieldId: qualify(s.fieldId),
100
+ descending: s.descending,
101
+ })),
102
+ limit: query.limit,
103
+ tableCalculations: [],
104
+ },
105
+ };
106
+ const execResult = await apiFetch(config, 'POST', `/api/v2/projects/${config.projectUuid}/query/metric-query`, body);
107
+ const { queryUuid, fields } = execResult;
108
+ // Step 2: Poll for results
109
+ let attempts = 0;
110
+ while (attempts < MAX_POLL_ATTEMPTS) {
111
+ const pollResult = await apiFetch(config, 'GET', `/api/v2/projects/${config.projectUuid}/query/${queryUuid}`);
112
+ if (pollResult.status === 'ready') {
113
+ // Build a mapping from qualified → short field names
114
+ // so app code uses row.driver_name, not row.fct_race_results_driver_name
115
+ const allShort = [...query.dimensions, ...query.metrics];
116
+ const allQualified = [
117
+ ...qualifiedDims,
118
+ ...qualifiedMetrics,
119
+ ];
120
+ const qualifiedToShort = new Map();
121
+ for (let i = 0; i < allShort.length; i++) {
122
+ qualifiedToShort.set(allQualified[i], allShort[i]);
123
+ }
124
+ // Map columns from field metadata
125
+ const columns = allQualified.map((qFieldId) => {
126
+ const shortName = qualifiedToShort.get(qFieldId) ?? qFieldId;
127
+ const fieldMeta = fields[qFieldId];
128
+ const colMeta = pollResult.columns[qFieldId];
129
+ return {
130
+ name: shortName,
131
+ label: fieldMeta?.label ?? shortName,
132
+ type: mapColumnType(colMeta?.type ?? fieldMeta?.type ?? 'string'),
133
+ };
134
+ });
135
+ // Map rows: extract raw values, keep formatted for format()
136
+ const formattedCache = new Map();
137
+ const rows = pollResult.rows.map((apiRow) => {
138
+ const row = {};
139
+ for (const qFieldId of allQualified) {
140
+ const shortName = qualifiedToShort.get(qFieldId) ?? qFieldId;
141
+ const cell = apiRow[qFieldId];
142
+ if (!cell) {
143
+ row[shortName] = null;
144
+ continue;
145
+ }
146
+ const { raw, formatted } = cell.value;
147
+ // Store formatted value for the format() function
148
+ if (!formattedCache.has(shortName)) {
149
+ formattedCache.set(shortName, new Map());
150
+ }
151
+ formattedCache.get(shortName).set(raw, formatted);
152
+ // Convert raw to typed value
153
+ if (raw === null || raw === undefined) {
154
+ row[shortName] = null;
155
+ }
156
+ else if (typeof raw === 'number') {
157
+ row[shortName] = raw;
158
+ }
159
+ else if (typeof raw === 'boolean') {
160
+ row[shortName] = raw;
161
+ }
162
+ else {
163
+ row[shortName] = String(raw);
164
+ }
165
+ }
166
+ return row;
167
+ });
168
+ const format = (row, fieldId) => {
169
+ const rawVal = row[fieldId];
170
+ const cache = formattedCache.get(fieldId);
171
+ if (cache) {
172
+ const formatted = cache.get(rawVal);
173
+ if (formatted !== undefined)
174
+ return formatted;
175
+ }
176
+ return String(rawVal ?? '');
177
+ };
178
+ return { rows, columns, format };
179
+ }
180
+ if (pollResult.status === 'error' ||
181
+ pollResult.status === 'expired') {
182
+ throw new Error(`Query failed: ${pollResult.error ?? 'unknown error'}`);
183
+ }
184
+ if (pollResult.status === 'cancelled') {
185
+ throw new Error('Query was cancelled');
186
+ }
187
+ // Still running — wait and retry
188
+ // eslint-disable-next-line no-promise-executor-return
189
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
190
+ attempts++;
191
+ }
192
+ throw new Error('Query timed out waiting for results');
193
+ },
194
+ async getUser() {
195
+ const user = await apiFetch(config, 'GET', '/api/v1/user');
196
+ return {
197
+ name: `${user.firstName} ${user.lastName}`.trim(),
198
+ email: user.email,
199
+ role: user.role,
200
+ orgId: user.organizationUuid,
201
+ attributes: user.userAttributes ?? {},
202
+ };
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Lightdash client.
3
+ *
4
+ * Usage:
5
+ * // Auto-configure from env vars (Vite reads .env automatically)
6
+ * const lightdash = createClient()
7
+ *
8
+ * // Or explicit config
9
+ * const lightdash = createClient({
10
+ * apiKey: 'pat_xxx',
11
+ * baseUrl: 'https://app.lightdash.cloud',
12
+ * projectUuid: 'uuid',
13
+ * })
14
+ */
15
+ import { QueryBuilder } from './query';
16
+ import type { LightdashClientConfig, LightdashUser, Transport } from './types';
17
+ export declare class LightdashClient {
18
+ readonly config: LightdashClientConfig;
19
+ readonly transport: Transport;
20
+ readonly auth: {
21
+ getUser: () => Promise<LightdashUser>;
22
+ };
23
+ constructor(config: LightdashClientConfig, transport?: Transport);
24
+ /** Start building a query against a model */
25
+ model(exploreName: string): QueryBuilder;
26
+ }
27
+ /**
28
+ * Create a Lightdash client.
29
+ *
30
+ * With no args, reads from env vars:
31
+ * const lightdash = createClient()
32
+ *
33
+ * With explicit config (used when token comes from parent frame):
34
+ * const lightdash = createClient({ apiKey, baseUrl, projectUuid })
35
+ */
36
+ export declare function createClient(config?: LightdashClientConfig): LightdashClient;
package/dist/client.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Lightdash client.
3
+ *
4
+ * Usage:
5
+ * // Auto-configure from env vars (Vite reads .env automatically)
6
+ * const lightdash = createClient()
7
+ *
8
+ * // Or explicit config
9
+ * const lightdash = createClient({
10
+ * apiKey: 'pat_xxx',
11
+ * baseUrl: 'https://app.lightdash.cloud',
12
+ * projectUuid: 'uuid',
13
+ * })
14
+ */
15
+ import { createApiTransport } from './apiTransport';
16
+ import { QueryBuilder } from './query';
17
+ export class LightdashClient {
18
+ constructor(config, transport) {
19
+ this.config = config;
20
+ this.transport = transport ?? createApiTransport(config);
21
+ this.auth = {
22
+ getUser: () => this.transport.getUser(),
23
+ };
24
+ }
25
+ /** Start building a query against a model */
26
+ model(exploreName) {
27
+ return new QueryBuilder(exploreName);
28
+ }
29
+ }
30
+ /**
31
+ * Resolve config from env vars.
32
+ *
33
+ * Vite (.env file, statically replaced at build time):
34
+ * VITE_LIGHTDASH_API_KEY=pat_xxx
35
+ * VITE_LIGHTDASH_URL=https://app.lightdash.cloud
36
+ * VITE_LIGHTDASH_PROJECT_UUID=uuid
37
+ *
38
+ * Node/E2B (runtime):
39
+ * LIGHTDASH_API_KEY=pat_xxx
40
+ * LIGHTDASH_URL=https://app.lightdash.cloud
41
+ * LIGHTDASH_PROJECT_UUID=uuid
42
+ */
43
+ function configFromEnv() {
44
+ // Vite statically replaces import.meta.env.VITE_X at build time.
45
+ // These must be written out in full -- dynamic access won't work.
46
+ const apiKey = import.meta.env?.VITE_LIGHTDASH_API_KEY ??
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ globalThis.process?.env?.LIGHTDASH_API_KEY;
49
+ const baseUrl = import.meta.env?.VITE_LIGHTDASH_URL ??
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ globalThis.process?.env?.LIGHTDASH_URL;
52
+ const projectUuid = import.meta.env?.VITE_LIGHTDASH_PROJECT_UUID ??
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ globalThis.process?.env?.LIGHTDASH_PROJECT_UUID;
55
+ if (!apiKey || !baseUrl || !projectUuid)
56
+ return null;
57
+ // Auto-enable proxy when running on a different origin (dev server)
58
+ const useProxy = typeof window !== 'undefined' &&
59
+ window.location.origin !== new URL(baseUrl).origin;
60
+ return { apiKey, baseUrl, projectUuid, useProxy };
61
+ }
62
+ /**
63
+ * Create a Lightdash client.
64
+ *
65
+ * With no args, reads from env vars:
66
+ * const lightdash = createClient()
67
+ *
68
+ * With explicit config (used when token comes from parent frame):
69
+ * const lightdash = createClient({ apiKey, baseUrl, projectUuid })
70
+ */
71
+ export function createClient(config) {
72
+ const resolved = config ?? configFromEnv();
73
+ if (!resolved) {
74
+ throw new Error('Missing Lightdash client config. Either pass { apiKey, baseUrl, projectUuid } ' +
75
+ 'or set env vars: VITE_LIGHTDASH_API_KEY, VITE_LIGHTDASH_URL, VITE_LIGHTDASH_PROJECT_UUID');
76
+ }
77
+ return new LightdashClient(resolved);
78
+ }
@@ -0,0 +1,4 @@
1
+ export { createClient, LightdashClient } from './client';
2
+ export { useLightdash } from './useLightdash';
3
+ export { LightdashProvider } from './LightdashProvider';
4
+ export type { Filter, FilterOperator, FilterValue, LightdashClientConfig, LightdashUser, Row, Sort, UnitOfTime, } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // Client
2
+ export { createClient, LightdashClient } from './client';
3
+ // React hook
4
+ export { useLightdash } from './useLightdash';
5
+ // Provider
6
+ export { LightdashProvider } from './LightdashProvider';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Chainable query builder.
3
+ *
4
+ * Usage:
5
+ * lightdash
6
+ * .model('orders')
7
+ * .dimensions(['customer_segment', 'order_date'])
8
+ * .metrics(['total_revenue', 'order_count'])
9
+ * .filters([{ field: 'order_date', operator: 'inThePast', value: 90, unit: 'days' }])
10
+ * .sorts([{ field: 'total_revenue', direction: 'desc' }])
11
+ * .limit(100)
12
+ *
13
+ * The builder is immutable -- each method returns a new instance.
14
+ */
15
+ import type { Filter, InternalFilterDefinition, QueryDefinition, Sort } from './types';
16
+ export declare class QueryBuilder {
17
+ private readonly _explore;
18
+ private readonly _dimensions;
19
+ private readonly _metrics;
20
+ private readonly _filters;
21
+ private readonly _sorts;
22
+ private readonly _limit;
23
+ constructor(explore: string, dimensions?: string[], metrics?: string[], filters?: InternalFilterDefinition[], sorts?: {
24
+ fieldId: string;
25
+ descending: boolean;
26
+ }[], limit?: number);
27
+ /** Set dimension fields (GROUP BY columns) */
28
+ dimensions(fields: string[]): QueryBuilder;
29
+ /** Set metric fields (aggregations) */
30
+ metrics(fields: string[]): QueryBuilder;
31
+ /** Add filters */
32
+ filters(filters: Filter[]): QueryBuilder;
33
+ /** Add sorts */
34
+ sorts(sorts: Sort[]): QueryBuilder;
35
+ /** Set the maximum number of rows to return (default: 500) */
36
+ limit(n: number): QueryBuilder;
37
+ /** Convert to a plain QueryDefinition object */
38
+ build(): QueryDefinition;
39
+ }
package/dist/query.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Chainable query builder.
3
+ *
4
+ * Usage:
5
+ * lightdash
6
+ * .model('orders')
7
+ * .dimensions(['customer_segment', 'order_date'])
8
+ * .metrics(['total_revenue', 'order_count'])
9
+ * .filters([{ field: 'order_date', operator: 'inThePast', value: 90, unit: 'days' }])
10
+ * .sorts([{ field: 'total_revenue', direction: 'desc' }])
11
+ * .limit(100)
12
+ *
13
+ * The builder is immutable -- each method returns a new instance.
14
+ */
15
+ export class QueryBuilder {
16
+ constructor(explore, dimensions = [], metrics = [], filters = [], sorts = [], limit = 500) {
17
+ this._explore = explore;
18
+ this._dimensions = dimensions;
19
+ this._metrics = metrics;
20
+ this._filters = filters;
21
+ this._sorts = sorts;
22
+ this._limit = limit;
23
+ }
24
+ /** Set dimension fields (GROUP BY columns) */
25
+ dimensions(fields) {
26
+ return new QueryBuilder(this._explore, [...this._dimensions, ...fields], this._metrics, this._filters, this._sorts, this._limit);
27
+ }
28
+ /** Set metric fields (aggregations) */
29
+ metrics(fields) {
30
+ return new QueryBuilder(this._explore, this._dimensions, [...this._metrics, ...fields], this._filters, this._sorts, this._limit);
31
+ }
32
+ /** Add filters */
33
+ filters(filters) {
34
+ const converted = filters.map((f) => {
35
+ const values = [];
36
+ if (f.value !== undefined) {
37
+ if (Array.isArray(f.value)) {
38
+ values.push(...f.value);
39
+ }
40
+ else {
41
+ values.push(f.value);
42
+ }
43
+ }
44
+ return {
45
+ fieldId: f.field,
46
+ operator: f.operator,
47
+ values,
48
+ settings: f.unit ? { unitOfTime: f.unit } : null,
49
+ };
50
+ });
51
+ return new QueryBuilder(this._explore, this._dimensions, this._metrics, [...this._filters, ...converted], this._sorts, this._limit);
52
+ }
53
+ /** Add sorts */
54
+ sorts(sorts) {
55
+ const converted = sorts.map((s) => ({
56
+ fieldId: s.field,
57
+ descending: s.direction === 'desc',
58
+ }));
59
+ return new QueryBuilder(this._explore, this._dimensions, this._metrics, this._filters, [...this._sorts, ...converted], this._limit);
60
+ }
61
+ /** Set the maximum number of rows to return (default: 500) */
62
+ limit(n) {
63
+ return new QueryBuilder(this._explore, this._dimensions, this._metrics, this._filters, this._sorts, n);
64
+ }
65
+ /** Convert to a plain QueryDefinition object */
66
+ build() {
67
+ return {
68
+ exploreName: this._explore,
69
+ dimensions: this._dimensions,
70
+ metrics: this._metrics,
71
+ filters: this._filters,
72
+ sorts: this._sorts,
73
+ limit: this._limit,
74
+ };
75
+ }
76
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Core types for the Lightdash SDK.
3
+ */
4
+ export type UnitOfTime = 'days' | 'weeks' | 'months' | 'quarters' | 'years';
5
+ export type FilterValue = string | number | boolean;
6
+ export type FilterOperator = 'equals' | 'notEquals' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' | 'isNull' | 'notNull' | 'startsWith' | 'endsWith' | 'include' | 'doesNotInclude' | 'inThePast' | 'notInThePast' | 'inTheNext' | 'inTheCurrent' | 'notInTheCurrent' | 'inBetween' | 'notInBetween';
7
+ export type Filter = {
8
+ field: string;
9
+ operator: FilterOperator;
10
+ value?: FilterValue | FilterValue[];
11
+ unit?: UnitOfTime;
12
+ };
13
+ export type Sort = {
14
+ field: string;
15
+ direction: 'asc' | 'desc';
16
+ };
17
+ export type InternalFilterDefinition = {
18
+ fieldId: string;
19
+ operator: string;
20
+ values: FilterValue[];
21
+ settings: {
22
+ unitOfTime: UnitOfTime;
23
+ } | null;
24
+ };
25
+ export type QueryDefinition = {
26
+ exploreName: string;
27
+ dimensions: string[];
28
+ metrics: string[];
29
+ filters: InternalFilterDefinition[];
30
+ sorts: {
31
+ fieldId: string;
32
+ descending: boolean;
33
+ }[];
34
+ limit: number;
35
+ };
36
+ export type ColumnType = 'string' | 'number' | 'date' | 'timestamp' | 'boolean';
37
+ export type Column = {
38
+ name: string;
39
+ label: string;
40
+ type: ColumnType;
41
+ };
42
+ export type Row = Record<string, string | number | boolean | null>;
43
+ export type FormatFunction = (row: Row, fieldId: string) => string;
44
+ export type QueryResult = {
45
+ rows: Row[];
46
+ columns: Column[];
47
+ format: FormatFunction;
48
+ };
49
+ export type LightdashClientConfig = {
50
+ /** Lightdash instance URL */
51
+ baseUrl: string;
52
+ /** Project UUID */
53
+ projectUuid: string;
54
+ /** API key (PAT or scoped token) */
55
+ apiKey: string;
56
+ /** Use relative /api paths instead of baseUrl (for dev proxy setups) */
57
+ useProxy?: boolean;
58
+ };
59
+ export type LightdashUser = {
60
+ name: string;
61
+ email: string;
62
+ role: string;
63
+ orgId: string;
64
+ attributes: Record<string, string>;
65
+ };
66
+ export type Transport = {
67
+ executeQuery: (query: QueryDefinition) => Promise<QueryResult>;
68
+ getUser: () => Promise<LightdashUser>;
69
+ };
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Core types for the Lightdash SDK.
3
+ */
4
+ export {};
@@ -0,0 +1,28 @@
1
+ /**
2
+ * React hook for executing a Lightdash query.
3
+ *
4
+ * Usage:
5
+ * const { data, loading, error } = useLightdash(
6
+ * lightdash
7
+ * .model('orders')
8
+ * .metrics(['total_revenue'])
9
+ * .dimensions(['customer_segment'])
10
+ * )
11
+ *
12
+ * // data is an array of flat objects:
13
+ * // [{ customer_segment: 'Enterprise', total_revenue: 124000 }]
14
+ */
15
+ import type { QueryBuilder } from './query';
16
+ import type { Row } from './types';
17
+ type UseLightdashResult = {
18
+ /** Result rows as flat objects. Numbers are numbers, strings are strings. */
19
+ data: Row[];
20
+ /** True while the query is executing */
21
+ loading: boolean;
22
+ /** Error if the query failed, null otherwise */
23
+ error: Error | null;
24
+ /** Re-run the query */
25
+ refetch: () => void;
26
+ };
27
+ export declare function useLightdash(query: QueryBuilder): UseLightdashResult;
28
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * React hook for executing a Lightdash query.
3
+ *
4
+ * Usage:
5
+ * const { data, loading, error } = useLightdash(
6
+ * lightdash
7
+ * .model('orders')
8
+ * .metrics(['total_revenue'])
9
+ * .dimensions(['customer_segment'])
10
+ * )
11
+ *
12
+ * // data is an array of flat objects:
13
+ * // [{ customer_segment: 'Enterprise', total_revenue: 124000 }]
14
+ */
15
+ import { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import { useTransport } from './LightdashProvider';
17
+ export function useLightdash(query) {
18
+ const transport = useTransport();
19
+ const [data, setData] = useState([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState(null);
22
+ const [fetchCount, setFetchCount] = useState(0);
23
+ const queryKey = useMemo(() => JSON.stringify(query.build()), [query]);
24
+ const refetch = useCallback(() => {
25
+ setFetchCount((c) => c + 1);
26
+ }, []);
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ setLoading(true);
30
+ setError(null);
31
+ const definition = query.build();
32
+ transport
33
+ .executeQuery(definition)
34
+ .then((res) => {
35
+ if (!cancelled) {
36
+ setData(res.rows);
37
+ setLoading(false);
38
+ }
39
+ })
40
+ .catch((err) => {
41
+ if (!cancelled) {
42
+ setError(err instanceof Error ? err : new Error(String(err)));
43
+ setLoading(false);
44
+ }
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ // queryKey tracks query identity. query is intentionally omitted.
50
+ }, [queryKey, transport, fetchCount]); // eslint-disable-line
51
+ return { data, loading, error, refetch };
52
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@lightdash/query-sdk",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "SDK for building custom data apps against the Lightdash semantic layer",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "peerDependencies": {
20
+ "react": "^18.x || ^19.x"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "19.2.2",
24
+ "typescript": "^5.0.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc --project tsconfig.build.json",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "eslint src/",
30
+ "format": "oxfmt ./src --check",
31
+ "fix-format": "oxfmt ./src",
32
+ "release": "pnpm publish --no-git-checks --access public"
33
+ }
34
+ }