@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 +25 -0
- package/README.md +134 -0
- package/dist/LightdashProvider.d.ts +23 -0
- package/dist/LightdashProvider.js +35 -0
- package/dist/apiTransport.d.ts +11 -0
- package/dist/apiTransport.js +205 -0
- package/dist/client.d.ts +36 -0
- package/dist/client.js +78 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/query.d.ts +39 -0
- package/dist/query.js +76 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +4 -0
- package/dist/useLightdash.d.ts +28 -0
- package/dist/useLightdash.js +52 -0
- package/package.json +34 -0
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
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/query.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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,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
|
+
}
|