Skip to content

Commit

Permalink
[search ui] Add asset filter fields to search index (#20372)
Browse files Browse the repository at this point in the history
This PR enables adds the following asset filters to the search index
results:
- asset owner
- compute kind
- code location
- asset group

This involves:
1. Querying for these fields per-asset on `SECONDARY_SEARCH_QUERY`
2. Grouping by field to determine the # of assets per filter
3. Adding each filter to the list of possible search results

Open questions:
- Perf impact of fetching these additional fields for each asset in
graphQL?
  • Loading branch information
clairelin135 authored Mar 12, 2024
1 parent 7097886 commit 2b3ed46
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import styled from 'styled-components';
import {AssetGlobalLineageButton, AssetPageHeader} from './AssetPageHeader';
import {ASSET_CATALOG_TABLE_QUERY} from './AssetsCatalogTable';
import {fetchRecentlyVisitedAssetsFromLocalStorage} from './RecentlyVisitedAssetsStorage';
import {AssetTableFragment} from './types/AssetTableFragment.types';
import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types';
import {
AssetCatalogTableQuery,
AssetCatalogTableQueryVariables,
Expand All @@ -33,7 +33,7 @@ type AssetCountsResult = {
countPerCodeLocation: CountPerCodeLocation[];
};

type GroupMetadata = {
export type GroupMetadata = {
groupName: string;
repositoryLocationName: string;
repositoryName: string;
Expand All @@ -49,7 +49,14 @@ type CountPerCodeLocation = {
assetCount: number;
};

function buildAssetCountBySection(assets: AssetTableFragment[]): AssetCountsResult {
type AssetDefinitionMetadata = {
definition: Pick<
AssetTableDefinitionFragment,
'owners' | 'computeKind' | 'groupName' | 'repository'
> | null;
};

export function buildAssetCountBySection(assets: AssetDefinitionMetadata[]): AssetCountsResult {
const assetCountByOwner: Record<string, number> = {};
const assetCountByComputeKind: Record<string, number> = {};
const assetCountByGroup: Record<string, number> = {};
Expand Down Expand Up @@ -173,7 +180,7 @@ const linkToAssetGraphComputeKind = (computeKind: string) => {
})}`;
};

const linkToCodeLocation = (repoAddress: RepoAddress) => {
export const linkToCodeLocation = (repoAddress: RepoAddress) => {
return `/locations/${repoAddressAsURLString(repoAddress)}/assets`;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ const DEBOUNCE_MSEC = 100;

export const SearchDialog = ({searchPlaceholder}: {searchPlaceholder: string}) => {
const history = useHistory();
const {initialize, loading, searchPrimary, searchSecondary} = useGlobalSearch();
const {initialize, loading, searchPrimary, searchSecondary} = useGlobalSearch({
includeAssetFilters: false,
});
const trackEvent = useTrackEvent();

const [state, dispatch] = React.useReducer(reducer, initialState);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import * as React from 'react';
import {Link} from 'react-router-dom';
import styled from 'styled-components';

import {SearchResult, SearchResultType} from './types';
import {AssetFilterSearchResultType, SearchResult, SearchResultType} from './types';

const iconForType = (type: SearchResultType): IconName => {
const iconForType = (type: SearchResultType | AssetFilterSearchResultType): IconName => {
switch (type) {
case SearchResultType.Asset:
return 'asset';
Expand Down
12 changes: 11 additions & 1 deletion js_modules/dagster-ui/packages/ui-core/src/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ export enum SearchResultType {
Resource,
}

export enum AssetFilterSearchResultType {
// Add types with corresponding strings to distinguish
// between SearchResultType.AssetGroup
ComputeKind = 'AssetFilterSearchResultType.ComputeKind',
CodeLocation = 'AssetFilterSearchResultType.CodeLocation',
Owner = 'AssetFilterSearchResultType.Owner',
AssetGroup = 'AssetFilterSearchResultType.AssetGroup',
}

export type SearchResult = {
label: string;
description: string;
href: string;
type: SearchResultType;
type: SearchResultType | AssetFilterSearchResultType;
tags?: string;
numResults?: number;
};

export type ReadyResponse = {type: 'ready'};
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 119 additions & 8 deletions js_modules/dagster-ui/packages/ui-core/src/search/useGlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {gql} from '@apollo/client';
import qs from 'qs';
import {useCallback, useEffect, useRef} from 'react';

import {QueryResponse, WorkerSearchResult, createSearchWorker} from './createSearchWorker';
import {SearchResult, SearchResultType} from './types';
import {AssetFilterSearchResultType, SearchResult, SearchResultType} from './types';
import {
SearchPrimaryQuery,
SearchPrimaryQueryVariables,
Expand All @@ -12,10 +13,31 @@ import {
import {useIndexedDBCachedQuery} from './useIndexedDBCachedQuery';
import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment';
import {displayNameForAssetKey, isHiddenAssetGroupJob} from '../asset-graph/Utils';
import {
GroupMetadata,
buildAssetCountBySection,
linkToCodeLocation,
} from '../assets/AssetsOverview';
import {assetDetailsPathForKey} from '../assets/assetDetailsPathForKey';
import {buildRepoPathForHuman} from '../workspace/buildRepoAddress';
import {workspacePath} from '../workspace/workspacePath';

const linkToAssetTableWithGroupFilter = (groupMetadata: GroupMetadata) => {
return `/assets?${qs.stringify({groups: JSON.stringify([groupMetadata])})}`;
};

const linkToAssetTableWithComputeKindFilter = (computeKind: string) => {
return `/assets?${qs.stringify({
computeKindTags: JSON.stringify([computeKind]),
})}`;
};

const linkToAssetTableWithOwnerFilter = (owner: string) => {
return `/assets?${qs.stringify({
owners: JSON.stringify([owner]),
})}`;
};

const primaryDataToSearchResults = (input: {data?: SearchPrimaryQuery}) => {
const {data} = input;

Expand Down Expand Up @@ -140,24 +162,87 @@ const primaryDataToSearchResults = (input: {data?: SearchPrimaryQuery}) => {
return allEntries;
};

const secondaryDataToSearchResults = (input: {data?: SearchSecondaryQuery}) => {
const secondaryDataToSearchResults = (
input: {data?: SearchSecondaryQuery},
includeAssetFilters: boolean,
) => {
const {data} = input;
if (!data?.assetsOrError || data.assetsOrError.__typename === 'PythonError') {
return [];
}

const {nodes} = data.assetsOrError;
return nodes

const assets = nodes
.filter(({definition}) => definition !== null)
.map(({key}) => {
.map(({key, definition}) => {
return {
label: displayNameForAssetKey(key),
href: assetDetailsPathForKey(key),
segments: key.path,
description: 'Asset',
description: `Asset in ${buildRepoPathForHuman(
definition!.repository.name,
definition!.repository.location.name,
)}`,
type: SearchResultType.Asset,
};
});

if (!includeAssetFilters) {
return [...assets];
} else {
const countsBySection = buildAssetCountBySection(nodes);

const computeKindResults: SearchResult[] = Object.entries(
countsBySection.countsByComputeKind,
).map(([computeKind, count]) => ({
label: computeKind,
description: '',
type: AssetFilterSearchResultType.ComputeKind,
href: linkToAssetTableWithComputeKindFilter(computeKind),
numResults: count,
}));

const codeLocationResults: SearchResult[] = countsBySection.countPerCodeLocation.map(
(codeLocationAssetCount) => ({
label: buildRepoPathForHuman(
codeLocationAssetCount.repoAddress.name,
codeLocationAssetCount.repoAddress.location,
),
description: '',
type: AssetFilterSearchResultType.CodeLocation,
href: linkToCodeLocation(codeLocationAssetCount.repoAddress),
numResults: codeLocationAssetCount.assetCount,
}),
);

const groupResults: SearchResult[] = countsBySection.countPerAssetGroup.map(
(groupAssetCount) => ({
label: groupAssetCount.groupMetadata.groupName,
description: '',
type: AssetFilterSearchResultType.AssetGroup,
href: linkToAssetTableWithGroupFilter(groupAssetCount.groupMetadata),
numResults: groupAssetCount.assetCount,
}),
);

const ownerResults: SearchResult[] = Object.entries(countsBySection.countsByOwner).map(
([owner, count]) => ({
label: owner,
description: '',
type: AssetFilterSearchResultType.Owner,
href: linkToAssetTableWithOwnerFilter(owner),
numResults: count,
}),
);
return [
...assets,
...computeKindResults,
...codeLocationResults,
...ownerResults,
...groupResults,
];
}
};

const fuseOptions = {
Expand All @@ -174,6 +259,12 @@ type IndexBuffer = {
cancel: () => void;
};

// These are the versions of the primary and secondary data queries. They are used to
// version the cache in indexedDB. When the data in the cache must be invalidated, this version
// should be bumped to prevent fetching stale data.
export const SEARCH_PRIMARY_DATA_VERSION = 1;
export const SEARCH_SECONDARY_DATA_VERSION = 1;

/**
* Perform global search populated by two lazy queries, to be initialized upon some
* interaction with the search input. Each query result list is packaged and sent to a worker
Expand All @@ -188,7 +279,7 @@ type IndexBuffer = {
*
* A `terminate` function is provided, but it's probably not necessary to use it.
*/
export const useGlobalSearch = () => {
export const useGlobalSearch = ({includeAssetFilters}: {includeAssetFilters: boolean}) => {
const primarySearch = useRef<WorkerSearchResult | null>(null);
const secondarySearch = useRef<WorkerSearchResult | null>(null);

Expand All @@ -199,6 +290,7 @@ export const useGlobalSearch = () => {
} = useIndexedDBCachedQuery<SearchPrimaryQuery, SearchPrimaryQueryVariables>({
query: SEARCH_PRIMARY_QUERY,
key: 'SearchPrimary',
version: SEARCH_PRIMARY_DATA_VERSION,
});

const {
Expand All @@ -208,6 +300,7 @@ export const useGlobalSearch = () => {
} = useIndexedDBCachedQuery<SearchSecondaryQuery, SearchSecondaryQueryVariables>({
query: SEARCH_SECONDARY_QUERY,
key: 'SearchSecondary',
version: SEARCH_SECONDARY_DATA_VERSION,
});

const consumeBufferEffect = useCallback(
Expand Down Expand Up @@ -238,13 +331,13 @@ export const useGlobalSearch = () => {
if (!secondaryData) {
return;
}
const results = secondaryDataToSearchResults({data: secondaryData});
const results = secondaryDataToSearchResults({data: secondaryData}, includeAssetFilters);
if (!secondarySearch.current) {
secondarySearch.current = createSearchWorker('secondary', fuseOptions);
}
secondarySearch.current.update(results);
consumeBufferEffect(secondarySearchBuffer, secondarySearch.current);
}, [consumeBufferEffect, secondaryData]);
}, [consumeBufferEffect, secondaryData, includeAssetFilters]);

const primarySearchBuffer = useRef<IndexBuffer | null>(null);
const secondarySearchBuffer = useRef<IndexBuffer | null>(null);
Expand Down Expand Up @@ -389,6 +482,24 @@ export const SEARCH_SECONDARY_QUERY = gql`
}
definition {
id
computeKind
groupName
owners {
... on TeamAssetOwner {
team
}
... on UserAssetOwner {
email
}
}
repository {
id
name
location {
id
name
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,30 @@ import React from 'react';

const ONE_WEEK = 1000 * 60 * 60 * 24 * 7;

type CacheData<TQuery> = {
data: TQuery;
version: number;
};

/**
* Returns data from the indexedDB cache initially while loading is true.
* Fetches data from the network/cache initially and does not receive any updates afterwards
*/
export function useIndexedDBCachedQuery<TQuery, TVariables extends OperationVariables>({
key,
query,
version,
variables,
}: {
key: string;
query: DocumentNode;
version: number;
variables?: TVariables;
}) {
const client = useApolloClient();

const lru = React.useMemo(
() => cache<string, TQuery>({dbName: `indexdbQueryCache:${key}`, maxCount: 1}),
() => cache<string, CacheData<TQuery>>({dbName: `indexdbQueryCache:${key}`, maxCount: 1}),
[key],
);

Expand All @@ -33,11 +40,13 @@ export function useIndexedDBCachedQuery<TQuery, TVariables extends OperationVari
if (await lru.has('cache')) {
const {value} = await lru.get('cache');
if (value) {
setData(value);
if (version === (value.version || null)) {
setData(value.data);
}
}
}
})();
}, [lru]);
}, [lru, version]);

const didFetch = React.useRef(false);

Expand All @@ -55,11 +64,15 @@ export function useIndexedDBCachedQuery<TQuery, TVariables extends OperationVari
variables,
});
setLoading(false);
lru.set('cache', data, {
expiry: new Date(Date.now() + ONE_WEEK),
});
lru.set(
'cache',
{data, version},
{
expiry: new Date(Date.now() + ONE_WEEK),
},
);
setData(data);
}, [client, lru, query, variables]);
}, [client, lru, query, variables, version]);

return {
fetch,
Expand Down

1 comment on commit 2b3ed46

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for dagit-core-storybook ready!

✅ Preview
https://dagit-core-storybook-nde65q4kw-elementl.vercel.app

Built with commit 2b3ed46.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.