datajunction-ui 0.0.156 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.156",
3
+ "version": "0.0.158",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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 href={`/create/transform/${namespace}`}>
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 href={`/create/metric/${namespace}`}>
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 href={`/create/dimension/${namespace}`}>
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/${namespace}`}>
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 (catalog, schema, table) {
842
- const response = await fetch(
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