datajunction-ui 0.0.157 → 0.0.158
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/package.json +1 -1
- package/src/app/components/AddNodeDropdown.jsx +51 -4
- package/src/app/components/__tests__/AddNodeDropdown.test.jsx +117 -0
- package/src/app/pages/AddEditNodePage/NodeQueryField.jsx +67 -0
- package/src/app/pages/AddEditNodePage/__tests__/NodeQueryField.test.jsx +4 -0
- package/src/app/pages/AddEditNodePage/__tests__/catalogTables.test.jsx +65 -0
- package/src/app/pages/AddEditNodePage/catalogTables.js +24 -0
- package/src/app/services/DJService.js +17 -9
package/package.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useCurrentUser } from '../providers/UserProvider';
|
|
4
|
+
import DJClientContext from '../providers/djclient';
|
|
5
|
+
|
|
6
|
+
const PERSONAL_NS_PREFIX =
|
|
7
|
+
process.env.REACT_APP_PERSONAL_NAMESPACE_PREFIX || 'users';
|
|
8
|
+
|
|
9
|
+
function resolvePersonalNamespace(namespace, username) {
|
|
10
|
+
if (namespace && namespace !== 'default') return namespace;
|
|
11
|
+
if (!username) return 'default';
|
|
12
|
+
const handle = username
|
|
13
|
+
.split('@')[0]
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9_]/g, '_');
|
|
16
|
+
return `${PERSONAL_NS_PREFIX}.${handle}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
export default function AddNodeDropdown({ namespace }) {
|
|
20
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
21
|
+
const { currentUser } = useCurrentUser();
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const ns = resolvePersonalNamespace(namespace, currentUser?.username);
|
|
24
|
+
const isPersonalFallback =
|
|
25
|
+
(!namespace || namespace === 'default') && currentUser?.username;
|
|
26
|
+
|
|
27
|
+
const goTo = path => async e => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
if (isPersonalFallback) {
|
|
30
|
+
// Create the personal namespace if it doesn't exist yet (idempotent on the server)
|
|
31
|
+
try {
|
|
32
|
+
await djClient.addNamespace(ns);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('Failed to ensure namespace exists:', err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
navigate(path);
|
|
38
|
+
};
|
|
39
|
+
|
|
2
40
|
return (
|
|
3
41
|
<span
|
|
4
42
|
className="menu-link"
|
|
@@ -13,17 +51,26 @@ export default function AddNodeDropdown({ namespace }) {
|
|
|
13
51
|
Register Table
|
|
14
52
|
</div>
|
|
15
53
|
</a>
|
|
16
|
-
<a
|
|
54
|
+
<a
|
|
55
|
+
href={`/create/transform/${ns}`}
|
|
56
|
+
onClick={goTo(`/create/transform/${ns}`)}
|
|
57
|
+
>
|
|
17
58
|
<div className="node_type__transform node_type_creation_heading">
|
|
18
59
|
Transform
|
|
19
60
|
</div>
|
|
20
61
|
</a>
|
|
21
|
-
<a
|
|
62
|
+
<a
|
|
63
|
+
href={`/create/metric/${ns}`}
|
|
64
|
+
onClick={goTo(`/create/metric/${ns}`)}
|
|
65
|
+
>
|
|
22
66
|
<div className="node_type__metric node_type_creation_heading">
|
|
23
67
|
Metric
|
|
24
68
|
</div>
|
|
25
69
|
</a>
|
|
26
|
-
<a
|
|
70
|
+
<a
|
|
71
|
+
href={`/create/dimension/${ns}`}
|
|
72
|
+
onClick={goTo(`/create/dimension/${ns}`)}
|
|
73
|
+
>
|
|
27
74
|
<div className="node_type__dimension node_type_creation_heading">
|
|
28
75
|
Dimension
|
|
29
76
|
</div>
|
|
@@ -31,7 +78,7 @@ export default function AddNodeDropdown({ namespace }) {
|
|
|
31
78
|
<a href={`/create/tag`}>
|
|
32
79
|
<div className="entity__tag node_type_creation_heading">Tag</div>
|
|
33
80
|
</a>
|
|
34
|
-
<a href={`/create/cube/${
|
|
81
|
+
<a href={`/create/cube/${ns}`} onClick={goTo(`/create/cube/${ns}`)}>
|
|
35
82
|
<div className="node_type__cube node_type_creation_heading">
|
|
36
83
|
Cube
|
|
37
84
|
</div>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
import AddNodeDropdown from '../AddNodeDropdown';
|
|
6
|
+
import DJClientContext from '../../providers/djclient';
|
|
7
|
+
import UserContext from '../../providers/UserProvider';
|
|
8
|
+
|
|
9
|
+
const buildClient = () => ({
|
|
10
|
+
DataJunctionAPI: {
|
|
11
|
+
addNamespace: jest.fn().mockResolvedValue({ status: 201, json: {} }),
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const renderDropdown = ({ namespace, user, djClient = buildClient() }) => {
|
|
16
|
+
const userCtx = {
|
|
17
|
+
currentUser: user,
|
|
18
|
+
loading: false,
|
|
19
|
+
error: null,
|
|
20
|
+
refetchUser: async () => {},
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
djClient,
|
|
24
|
+
...render(
|
|
25
|
+
<MemoryRouter initialEntries={['/']}>
|
|
26
|
+
<DJClientContext.Provider value={djClient}>
|
|
27
|
+
<UserContext.Provider value={userCtx}>
|
|
28
|
+
<Routes>
|
|
29
|
+
<Route
|
|
30
|
+
path="/"
|
|
31
|
+
element={<AddNodeDropdown namespace={namespace} />}
|
|
32
|
+
/>
|
|
33
|
+
<Route path="/create/:nodeType/:ns" element={<div>landed</div>} />
|
|
34
|
+
</Routes>
|
|
35
|
+
</UserContext.Provider>
|
|
36
|
+
</DJClientContext.Provider>
|
|
37
|
+
</MemoryRouter>,
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('<AddNodeDropdown />', () => {
|
|
43
|
+
it('uses the explicit namespace when provided', () => {
|
|
44
|
+
renderDropdown({
|
|
45
|
+
namespace: 'finance.metrics',
|
|
46
|
+
user: { username: 'alice' },
|
|
47
|
+
});
|
|
48
|
+
expect(screen.getByText('Transform').closest('a')).toHaveAttribute(
|
|
49
|
+
'href',
|
|
50
|
+
'/create/transform/finance.metrics',
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByText('Metric').closest('a')).toHaveAttribute(
|
|
53
|
+
'href',
|
|
54
|
+
'/create/metric/finance.metrics',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('falls back to users.<handle> when at the top-level namespace', () => {
|
|
59
|
+
renderDropdown({ namespace: 'default', user: { username: 'alice' } });
|
|
60
|
+
expect(screen.getByText('Transform').closest('a')).toHaveAttribute(
|
|
61
|
+
'href',
|
|
62
|
+
'/create/transform/users.alice',
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to users.<handle> when namespace is undefined', () => {
|
|
67
|
+
renderDropdown({ namespace: undefined, user: { username: 'alice' } });
|
|
68
|
+
expect(screen.getByText('Transform').closest('a')).toHaveAttribute(
|
|
69
|
+
'href',
|
|
70
|
+
'/create/transform/users.alice',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('strips email domain and sanitizes the handle', () => {
|
|
75
|
+
renderDropdown({
|
|
76
|
+
namespace: 'default',
|
|
77
|
+
user: { username: 'Alice.B@netflix.com' },
|
|
78
|
+
});
|
|
79
|
+
// dots in the handle get replaced with underscores
|
|
80
|
+
expect(screen.getByText('Transform').closest('a')).toHaveAttribute(
|
|
81
|
+
'href',
|
|
82
|
+
'/create/transform/users.alice_b',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('creates the personal namespace on click when falling back', async () => {
|
|
87
|
+
const { djClient } = renderDropdown({
|
|
88
|
+
namespace: 'default',
|
|
89
|
+
user: { username: 'alice' },
|
|
90
|
+
});
|
|
91
|
+
fireEvent.click(screen.getByText('Transform'));
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(djClient.DataJunctionAPI.addNamespace).toHaveBeenCalledWith(
|
|
94
|
+
'users.alice',
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does NOT call addNamespace when an explicit namespace is provided', async () => {
|
|
100
|
+
const { djClient } = renderDropdown({
|
|
101
|
+
namespace: 'finance.metrics',
|
|
102
|
+
user: { username: 'alice' },
|
|
103
|
+
});
|
|
104
|
+
fireEvent.click(screen.getByText('Transform'));
|
|
105
|
+
// give the click a tick to settle
|
|
106
|
+
await new Promise(r => setTimeout(r, 0));
|
|
107
|
+
expect(djClient.DataJunctionAPI.addNamespace).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('leaves the Register Table link unconditional', () => {
|
|
111
|
+
renderDropdown({ namespace: 'default', user: { username: 'alice' } });
|
|
112
|
+
expect(screen.getByText('Register Table').closest('a')).toHaveAttribute(
|
|
113
|
+
'href',
|
|
114
|
+
'/create/source',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -6,11 +6,29 @@ import React from 'react';
|
|
|
6
6
|
import { ErrorMessage, Field, useFormikContext } from 'formik';
|
|
7
7
|
import CodeMirror from '@uiw/react-codemirror';
|
|
8
8
|
import { langs } from '@uiw/codemirror-extensions-langs';
|
|
9
|
+
import { extractCatalogTables } from './catalogTables';
|
|
9
10
|
|
|
10
11
|
export const NodeQueryField = ({ djClient, value }) => {
|
|
11
12
|
const [schema, setSchema] = React.useState([]);
|
|
12
13
|
const formik = useFormikContext();
|
|
13
14
|
const sqlExt = langs.sql({ schema: schema });
|
|
15
|
+
const autoRegisterTimer = React.useRef(null);
|
|
16
|
+
const registeredTables = React.useRef(new Set());
|
|
17
|
+
// useRef so the onChange closure always sees the latest catalog list
|
|
18
|
+
const knownCatalogsRef = React.useRef([]);
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (typeof djClient.catalogs !== 'function') return;
|
|
22
|
+
Promise.resolve(djClient.catalogs())
|
|
23
|
+
.then(catalogs => {
|
|
24
|
+
knownCatalogsRef.current = (catalogs || []).map(c =>
|
|
25
|
+
c.name.toLowerCase(),
|
|
26
|
+
);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {
|
|
29
|
+
// Auto-register is best-effort; failing to load catalogs just disables it.
|
|
30
|
+
});
|
|
31
|
+
}, [djClient]);
|
|
14
32
|
|
|
15
33
|
const initialAutocomplete = async context => {
|
|
16
34
|
// Based on the parsed prefix, we load node names with that prefix
|
|
@@ -44,6 +62,55 @@ export const NodeQueryField = ({ djClient, value }) => {
|
|
|
44
62
|
setSchema(schema);
|
|
45
63
|
}
|
|
46
64
|
}
|
|
65
|
+
|
|
66
|
+
// Auto-register any catalog-qualified tables typed in the SQL
|
|
67
|
+
if (knownCatalogsRef.current.length > 0) {
|
|
68
|
+
clearTimeout(autoRegisterTimer.current);
|
|
69
|
+
autoRegisterTimer.current = setTimeout(
|
|
70
|
+
() => autoRegisterCatalogTables(value),
|
|
71
|
+
600,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const autoRegisterCatalogTables = async sql => {
|
|
77
|
+
const tables = extractCatalogTables(sql, knownCatalogsRef.current);
|
|
78
|
+
for (const [catalog, tableSchema, table] of tables) {
|
|
79
|
+
const key = `${catalog}.${tableSchema}.${table}`;
|
|
80
|
+
if (registeredTables.current.has(key)) continue;
|
|
81
|
+
// If autocomplete already saw this node, DJ knows about it — skip silently
|
|
82
|
+
if (schema[key] !== undefined) {
|
|
83
|
+
registeredTables.current.add(key);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
registeredTables.current.add(key);
|
|
87
|
+
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await djClient.registerTable(
|
|
91
|
+
catalog,
|
|
92
|
+
tableSchema,
|
|
93
|
+
table,
|
|
94
|
+
'',
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
registeredTables.current.delete(key);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const { status, json } = response;
|
|
101
|
+
if (status === 200 || status === 201) {
|
|
102
|
+
const nodeName = json?.name || key;
|
|
103
|
+
const columns = (json?.columns || []).map(col => col.name);
|
|
104
|
+
schema[nodeName] = columns;
|
|
105
|
+
if (table && table !== nodeName) {
|
|
106
|
+
schema[table] = columns;
|
|
107
|
+
}
|
|
108
|
+
setSchema(schema);
|
|
109
|
+
} else if (status !== 409) {
|
|
110
|
+
// 409 = already exists (silent); other errors: allow retry on next edit
|
|
111
|
+
registeredTables.current.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
47
114
|
};
|
|
48
115
|
|
|
49
116
|
return (
|
|
@@ -7,6 +7,10 @@ describe('NodeQueryField', () => {
|
|
|
7
7
|
const mockDjClient = {
|
|
8
8
|
nodes: jest.fn(),
|
|
9
9
|
node: jest.fn(),
|
|
10
|
+
catalogs: jest.fn().mockResolvedValue([]),
|
|
11
|
+
registerTable: jest
|
|
12
|
+
.fn()
|
|
13
|
+
.mockResolvedValue({ status: 200, json: { name: '' } }),
|
|
10
14
|
};
|
|
11
15
|
const renderWithFormik = (djClient = mockDjClient) => {
|
|
12
16
|
return render(
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { extractCatalogTables } from '../catalogTables';
|
|
2
|
+
|
|
3
|
+
describe('extractCatalogTables', () => {
|
|
4
|
+
it('extracts a 3-part identifier when the catalog matches', () => {
|
|
5
|
+
const result = extractCatalogTables(
|
|
6
|
+
'select * from prodhive.dse_dev.identity_ab_alloc_f f',
|
|
7
|
+
['prodhive'],
|
|
8
|
+
);
|
|
9
|
+
expect(result).toEqual([['prodhive', 'dse_dev', 'identity_ab_alloc_f']]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('extracts multiple distinct tables', () => {
|
|
13
|
+
const result = extractCatalogTables(
|
|
14
|
+
'select a.* from prodhive.s1.t1 a join prodhive.s2.t2 b on a.id = b.id',
|
|
15
|
+
['prodhive'],
|
|
16
|
+
);
|
|
17
|
+
expect(result).toEqual([
|
|
18
|
+
['prodhive', 's1', 't1'],
|
|
19
|
+
['prodhive', 's2', 't2'],
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('deduplicates the same table referenced twice in the same query', () => {
|
|
24
|
+
const result = extractCatalogTables(
|
|
25
|
+
'select * from prodhive.s.t a join prodhive.s.t b on a.id = b.id',
|
|
26
|
+
['prodhive'],
|
|
27
|
+
);
|
|
28
|
+
expect(result).toEqual([['prodhive', 's', 't']]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('skips 3-part identifiers whose catalog is not in the known list', () => {
|
|
32
|
+
const result = extractCatalogTables(
|
|
33
|
+
'select * from foo.bar.baz, prodhive.s.t',
|
|
34
|
+
['prodhive'],
|
|
35
|
+
);
|
|
36
|
+
expect(result).toEqual([['prodhive', 's', 't']]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('matches catalog name case-insensitively', () => {
|
|
40
|
+
const result = extractCatalogTables('select * from ProdHive.s.t', [
|
|
41
|
+
'prodhive',
|
|
42
|
+
]);
|
|
43
|
+
expect(result).toEqual([['ProdHive', 's', 't']]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles backtick-quoted identifiers', () => {
|
|
47
|
+
const result = extractCatalogTables(
|
|
48
|
+
'select * from `prodhive`.`my_schema`.`my_table`',
|
|
49
|
+
['prodhive'],
|
|
50
|
+
);
|
|
51
|
+
expect(result).toEqual([['prodhive', 'my_schema', 'my_table']]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns nothing when no catalog is known', () => {
|
|
55
|
+
const result = extractCatalogTables('select * from prodhive.s.t', []);
|
|
56
|
+
expect(result).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns nothing when the SQL contains no 3-part identifiers', () => {
|
|
60
|
+
const result = extractCatalogTables('select * from my_node where x > 0', [
|
|
61
|
+
'prodhive',
|
|
62
|
+
]);
|
|
63
|
+
expect(result).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Matches catalog.schema.table (3-part dot-separated identifiers, backtick-quoted or plain)
|
|
2
|
+
const CATALOG_TABLE_PATTERN =
|
|
3
|
+
/`?([a-zA-Z_][a-zA-Z0-9_]*)`?\.`?([a-zA-Z_][a-zA-Z0-9_]*)`?\.`?([a-zA-Z_][a-zA-Z0-9_]*)`?/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts all `catalog.schema.table` references in `sql` whose catalog is in
|
|
7
|
+
* `knownCatalogs` (case-insensitive). Returns deduplicated [catalog, schema, table] tuples.
|
|
8
|
+
*/
|
|
9
|
+
export function extractCatalogTables(sql, knownCatalogs) {
|
|
10
|
+
const re = new RegExp(CATALOG_TABLE_PATTERN.source, 'g');
|
|
11
|
+
const known = new Set(knownCatalogs.map(c => c.toLowerCase()));
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const out = [];
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(sql)) !== null) {
|
|
16
|
+
const [, catalog, schema, table] = m;
|
|
17
|
+
if (!known.has(catalog.toLowerCase())) continue;
|
|
18
|
+
const key = `${catalog}.${schema}.${table}`;
|
|
19
|
+
if (seen.has(key)) continue;
|
|
20
|
+
seen.add(key);
|
|
21
|
+
out.push([catalog, schema, table]);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
@@ -838,17 +838,25 @@ export const DataJunctionAPI = {
|
|
|
838
838
|
return { status: response.status, json: await response.json() };
|
|
839
839
|
},
|
|
840
840
|
|
|
841
|
-
registerTable: async function (
|
|
842
|
-
|
|
841
|
+
registerTable: async function (
|
|
842
|
+
catalog,
|
|
843
|
+
schema,
|
|
844
|
+
table,
|
|
845
|
+
sourceNodeNamespace = undefined,
|
|
846
|
+
) {
|
|
847
|
+
const url = new URL(
|
|
843
848
|
`${DJ_URL}/register/table/${catalog}/${schema}/${table}`,
|
|
844
|
-
{
|
|
845
|
-
method: 'POST',
|
|
846
|
-
headers: {
|
|
847
|
-
'Content-Type': 'application/json',
|
|
848
|
-
},
|
|
849
|
-
credentials: 'include',
|
|
850
|
-
},
|
|
851
849
|
);
|
|
850
|
+
if (sourceNodeNamespace !== undefined) {
|
|
851
|
+
url.searchParams.set('source_node_namespace', sourceNodeNamespace);
|
|
852
|
+
}
|
|
853
|
+
const response = await fetch(url.toString(), {
|
|
854
|
+
method: 'POST',
|
|
855
|
+
headers: {
|
|
856
|
+
'Content-Type': 'application/json',
|
|
857
|
+
},
|
|
858
|
+
credentials: 'include',
|
|
859
|
+
});
|
|
852
860
|
return { status: response.status, json: await response.json() };
|
|
853
861
|
},
|
|
854
862
|
|