Refactored queries to make searching other object types possible and to

include leaf operands that translate to different server-side operands
depending on the type of object requested.
master
Sander Vocke 4 years ago
parent cda62f0a80
commit 9df02ccb48
  1. 2
      client/src/components/querybuilder/QBNodeElem.tsx
  2. 2
      client/src/components/querybuilder/QueryBuilder.tsx
  3. 9
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  4. 23
      client/src/components/windows/manage_links/LinksStatusWidget.tsx
  5. 195
      client/src/components/windows/query/QueryWindow.tsx
  6. 40
      client/src/lib/backend/queries.tsx
  7. 112
      client/src/lib/query/Query.tsx

@ -22,7 +22,7 @@ export function QBNodeElem(props: NodeProps) {
} else {
ops.splice(idx, 1);
}
let newNode = simplify({ operands: ops, nodeOp: e.nodeOp });
let newNode = simplify({ operands: ops, nodeOp: e.nodeOp }, null);
props.onReplace(newNode);
}

@ -27,7 +27,7 @@ export interface IProps {
}
export default function QueryBuilder(props: IProps) {
const simpleQuery = simplify(props.query);
const simpleQuery = simplify(props.query, null);
const showQuery = props.editing ?
addPlaceholders(simpleQuery, null) : simpleQuery;

@ -49,11 +49,6 @@ async function makeTasks(
linkAlbums: boolean,
addTaskCb: (t: Task) => void,
) {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
@ -66,9 +61,9 @@ async function makeTasks(
let store = maybeStore as IntegrationWith;
let doForType = async (type: ResourceType) => {
let ids: number[] = ((await queryItems(
[type],
type,
queryNot({
a: whichProp[type],
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
}),

@ -2,7 +2,7 @@ import { Box, LinearProgress, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { $enum } from 'ts-enum-util';
import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { queryItems } from '../../../lib/backend/queries';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import StoreLinkIcon from '../../common/StoreLinkIcon';
@ -21,20 +21,15 @@ export default function LinksStatusWidget(props: {
let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({});
let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
[ResourceType.Album]: 'albums',
}
let r: any = await queryItems(
[type],
type,
{
a: whichProp[type],
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
},
@ -49,13 +44,11 @@ export default function LinksStatusWidget(props: {
// Start retrieving total counts
useEffect(() => {
(async () => {
let counts: any = await queryItems(
[ResourceType.Track, ResourceType.Artist, ResourceType.Album],
undefined,
undefined,
undefined,
QueryResponseType.Count
);
let counts: Counts = {
albums: await queryAlbums(undefined, undefined, undefined, QueryResponseType.Count) as number,
tracks: await queryTracks(undefined, undefined, undefined, QueryResponseType.Count) as number,
artists: await queryArtists(undefined, undefined, undefined, QueryResponseType.Count) as number,
}
console.log("Got total counts: ", counts)
setTotalCounts(counts);
}

@ -1,29 +1,50 @@
import React, { useEffect, useReducer, useCallback } from 'react';
import { Box, LinearProgress } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { QueryElem, QueryLeafBy, QueryLeafElem, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder from '../../querybuilder/QueryBuilder';
import TrackTable from '../../tables/ResultsTable';
import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { WindowState } from '../Windows';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails} from '../../../api/api';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails } from '../../../api/api';
import { ServerStreamResponseOptions } from 'http2';
import { TrackChangesSharp } from '@material-ui/icons';
import { v4 as genUuid } from 'uuid';
var _ = require('lodash');
export interface ResultsForQuery {
for: QueryElem,
results: any[],
export enum QueryItemType {
Artists,
Tracks,
Albums,
Tags,
};
export interface ResultsForQuery {
kind: QueryItemType,
results: (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]
),
}
export interface QueryWindowState extends WindowState {
editingQuery: boolean,
query: QueryElem | null,
resultsForQuery: ResultsForQuery | null,
editingQuery: boolean, // Is the editor in "edit mode"
query: QueryElem | null, // The actual on-screen query
includeTypes: QueryItemType[], // which item types do we actually request results for?
// Whenever queries change, new requests are fired to the server.
// Each request gets a unique id hash.
// In this results record, we store the query IDs which
// we want to show results for.
resultsForQueries: Record<string, ResultsForQuery | null>;
}
export enum QueryWindowStateActions {
SetQuery = "setQuery",
FiredNewQueries = "firedNewQueries",
SetEditingQuery = "setEditingQuery",
SetResultsForQuery = "setResultsForQuery",
ReceivedResult = "receivedResult",
}
async function getArtistNames(filter: string) {
@ -74,23 +95,55 @@ async function getTagItems(): Promise<any> {
return tags;
}
export interface FireNewQueriesData {
query: QueryElem | null,
includeTypes: QueryItemType[],
resultIds: string[],
}
export interface ReceivedResultData {
result: ResultsForQuery,
id: string,
}
export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) {
case QueryWindowStateActions.SetQuery:
return { ...state, query: action.value }
case QueryWindowStateActions.ReceivedResult:
var arr = action.value as ReceivedResultData;
if (Object.keys(state.resultsForQueries).includes(arr.id)) {
//console.log("Storing result:", arr);
var _n = _.cloneDeep(state);
_n.resultsForQueries[arr.id] = arr.result;
return _n;
}
//console.log("Discarding result:", arr);
return state;
case QueryWindowStateActions.FiredNewQueries:
var newState: QueryWindowState = _.cloneDeep(state);
let _action = action.value as FireNewQueriesData;
// Invalidate results
newState.resultsForQueries = {};
// Add a null result for each of the new IDs.
// Results will be added in as they come.
_action.resultIds && _action.resultIds.forEach((r: string) => {
newState.resultsForQueries[r] = null;
})
newState.query = _action.query;
newState.includeTypes = _action.includeTypes;
return newState;
case QueryWindowStateActions.SetEditingQuery:
return { ...state, editingQuery: action.value }
case QueryWindowStateActions.SetResultsForQuery:
return { ...state, resultsForQuery: action.value }
default:
throw new Error("Unimplemented QueryWindow state update.")
}
}
export default function QueryWindow(props: {}) {
const [state, dispatch] = useReducer(QueryWindowReducer, {
editingQuery: false,
query: null,
resultsForQuery: null,
resultsForQueries: {},
includeTypes: [QueryItemType.Tracks, QueryItemType.Artists, QueryItemType.Albums, QueryItemType.Tags],
});
return <QueryWindowControlled state={state} dispatch={dispatch} />
@ -100,45 +153,70 @@ export function QueryWindowControlled(props: {
state: QueryWindowState,
dispatch: (action: any) => void,
}) {
let { query, editingQuery: editing, resultsForQuery: resultsFor } = props.state;
let { query, editingQuery, resultsForQueries, includeTypes } = props.state;
let { dispatch } = props;
let setQuery = (q: QueryElem | null) => {
props.dispatch({ type: QueryWindowStateActions.SetQuery, value: q });
}
let setEditingQuery = (e: boolean) => {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
}
let setResultsForQuery = useCallback((r: ResultsForQuery | null) => {
dispatch({ type: QueryWindowStateActions.SetResultsForQuery, value: r });
}, [dispatch]);
const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : [];
const doQuery = useCallback(async (_query: QueryElem) => {
const tracks: QueryResponseTrackDetails[] = await queryTracks(
_query,
0,
100, //TODO: pagination
QueryResponseType.Details
) as QueryResponseTrackDetails[];
if (_.isEqual(query, _query)) {
setResultsForQuery({
for: _query,
results: tracks,
// Call this function to fire new queries and prepare to receive their results.
// This will also set the query into the window state.
const doQueries = async (_query: QueryElem | null, itemTypes: QueryItemType[]) => {
var promises: Promise<any>[] = [];
var ids: string[] = itemTypes.map((i: any) => genUuid());
var query_fns = {
[QueryItemType.Albums]: queryAlbums,
[QueryItemType.Artists]: queryArtists,
[QueryItemType.Tracks]: queryTracks,
[QueryItemType.Tags]: queryTags,
};
let stateUpdateData: FireNewQueriesData = {
query: _query,
includeTypes: itemTypes,
resultIds: ids
};
// First dispatch to the state that we are firing new queries.
// This will update the query on the window page and invalidate
// any previous results on-screen.
dispatch({
type: QueryWindowStateActions.FiredNewQueries,
value: stateUpdateData
})
if (_query) {
itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push(
(async () => {
let results = (await query_fns[itemType](
_query,
0, // TODO: pagination
100,
QueryResponseType.Details
)) as (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]);
let r: ReceivedResultData = {
id: ids[idx],
result: {
kind: itemType,
results: results
}
};
dispatch({ type: QueryWindowStateActions.ReceivedResult, value: r })
})()
);
})
}
}, [query, setResultsForQuery]);
useEffect(() => {
if (query) {
doQuery(query);
} else {
setResultsForQuery(null);
}
}, [query, doQuery, setResultsForQuery]);
await Promise.all(promises);
};
let setEditingQuery = (e: boolean) => {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
}
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
@ -147,8 +225,10 @@ export function QueryWindowControlled(props: {
>
<QueryBuilder
query={query}
onChangeQuery={setQuery}
editing={editing}
onChangeQuery={(q: QueryElem | null) => {
doQueries(q, includeTypes)
}}
editing={editingQuery}
onChangeEditing={setEditingQuery}
requestFunctions={{
getArtists: getArtistNames,
@ -162,10 +242,15 @@ export function QueryWindowControlled(props: {
m={1}
width="80%"
>
<TrackTable
tracks={showResults}
/>
{loading && <LinearProgress />}
{Object.values(resultsForQueries).map((r: ResultsForQuery | null) => <>
{r !== null && r.kind == QueryItemType.Tracks && <TrackTable
tracks={r.results as QueryResponseTrackDetails[]}
/>}
{r !== null && r.kind == QueryItemType.Albums && <>Found {r.results.length} albums.</>}
{r !== null && r.kind == QueryItemType.Artists && <>Found {r.results.length} artists.</>}
{r !== null && r.kind == QueryItemType.Tags && <>Found {r.results.length} tags.</>}
{r === null && <LinearProgress />}
</>)}
</Box>
</Box>
}

@ -1,26 +1,34 @@
import * as serverApi from '../../api/api';
import { QueryElem, toApiQuery } from '../query/Query';
import { QueryElem, QueryFor, simplify, toApiQuery } from '../query/Query';
import backendRequest from './request';
export async function queryItems(
types: serverApi.ResourceType[],
type: serverApi.ResourceType,
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponse> {
console.log("Types:", types);
const queryForMapping : any = {
[serverApi.ResourceType.Album]: QueryFor.Albums,
[serverApi.ResourceType.Artist]: QueryFor.Artists,
[serverApi.ResourceType.Tag]: QueryFor.Tags,
[serverApi.ResourceType.Track]: QueryFor.Tracks,
};
const simplified = simplify(query || null, queryForMapping[type]);
var q: serverApi.QueryRequest = {
query: query ? toApiQuery(query) : {},
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {},
offsetsLimits: {
artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined,
artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined,
albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined,
albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined,
trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined,
trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined,
tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined,
tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined,
artistOffset: (type == serverApi.ResourceType.Artist) ? (offset || 0) : undefined,
artistLimit: (type == serverApi.ResourceType.Artist) ? (limit || -1) : undefined,
albumOffset: (type == serverApi.ResourceType.Album) ? (offset || 0) : undefined,
albumLimit: (type == serverApi.ResourceType.Album) ? (limit || -1) : undefined,
trackOffset: (type == serverApi.ResourceType.Track) ? (offset || 0) : undefined,
trackLimit: (type == serverApi.ResourceType.Track) ? (limit || -1) : undefined,
tagOffset: (type == serverApi.ResourceType.Tag) ? (offset || 0) : undefined,
tagLimit: (type == serverApi.ResourceType.Tag) ? (limit || -1) : undefined,
},
ordering: {
orderBy: {
@ -50,7 +58,7 @@ export async function queryArtists(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseArtistDetails[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType);
let r = await queryItems(serverApi.ResourceType.Artist, query, offset, limit, responseType);
return r.artists;
}
@ -60,7 +68,7 @@ export async function queryAlbums(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseAlbumDetails[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType);
let r = await queryItems(serverApi.ResourceType.Album, query, offset, limit, responseType);
return r.albums;
}
@ -70,7 +78,7 @@ export async function queryTracks(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTrackDetails[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType);
let r = await queryItems(serverApi.ResourceType.Track, query, offset, limit, responseType);
return r.tracks;
}
@ -80,6 +88,6 @@ export async function queryTags(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTagDetails[] | number[] | number> {
let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType);
let r = await queryItems(serverApi.ResourceType.Tag, query, offset, limit, responseType);
return r.tags;
}

@ -1,5 +1,12 @@
import * as serverApi from '../../api/api';
export enum QueryFor {
Artists = 0,
Albums,
Tags,
Tracks,
}
export enum QueryLeafBy {
ArtistName = 0,
ArtistId,
@ -9,9 +16,7 @@ export enum QueryLeafBy {
TagId,
TrackName,
TrackId,
TrackStoreLinks,
ArtistStoreLinks,
AlbumStoreLinks,
StoreLinks,
}
export enum QueryLeafOp {
@ -26,7 +31,7 @@ export interface TagQueryInfo {
}
export function isTagQueryInfo(e: any): e is TagQueryInfo {
return (typeof e === 'object') && 'matchIds' in e && 'fullName' in e;
}
}
export type QueryLeafOperand = string | number | TagQueryInfo;
@ -77,6 +82,43 @@ export function queryNot(arg: QueryElem) {
export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) :
serverApi.QueryElemProperty | null {
return {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
[QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.StoreLinks]:
(queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks :
(queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks :
(queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks :
null,
[QueryLeafBy.TagInfo]: null,
}[l];
}
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) :
serverApi.QueryLeafOp | null {
return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
[QueryLeafOp.Placeholder]: null,
}[l];
}
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null) :
serverApi.QueryNodeOp | null {
return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}[l];
}
// Take a query and add placeholders. The placeholders are empty
// leaves. They should be placed so that all possible node combinations
// from the existing nodes could have an added combinational leaf.
@ -162,11 +204,11 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
return q;
}
export function simplify(q: QueryElem | null): QueryElem | null {
export function simplify(q: QueryElem | null, queryFor: QueryFor | null): QueryElem | null {
if (q && isNodeElem(q)) {
var newOperands: QueryElem[] = [];
q.operands.forEach((o: QueryElem) => {
const s = simplify(o);
const s = simplify(o, queryFor);
if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
@ -174,33 +216,18 @@ export function simplify(q: QueryElem | null): QueryElem | null {
return { operands: newOperands, nodeOp: q.nodeOp };
}
return q;
if (q && isLeafElem(q)) {
if (mapToServerLeafOp(q.leafOp, queryFor) === null ||
mapToServerProperty(q.a, queryFor) === null) {
return null;
}
}
return q;
}
export function toApiQuery(q: QueryElem) : serverApi.Query {
const propsMapping: any = {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
[QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.TrackStoreLinks]: serverApi.QueryElemProperty.trackStoreLinks,
[QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks,
[QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks,
}
const leafOpsMapping: any = {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
}
const nodeOpsMapping: any = {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}
if(isLeafElem(q) && isTagQueryInfo(q.b)) {
export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Query {
if (isLeafElem(q) && isTagQueryInfo(q.b)) {
// Special case for tag queries by ID
const r: serverApi.QueryElem = {
prop: serverApi.QueryElemProperty.tagId,
@ -208,18 +235,29 @@ export function toApiQuery(q: QueryElem) : serverApi.Query {
propOperand: q.b.matchIds,
}
return r;
} else if(isLeafElem(q)) {
} else if (isLeafElem(q)) {
// If the property to operate on is non-existent
// (e.g. store links for a tag query), throw.
let a = mapToServerProperty(q.a, queryFor);
let op = mapToServerLeafOp(q.leafOp, queryFor);
if (a === null || op === null) {
throw 'Found a null leaf in query tree. Was it simplified first?';
}
// "Regular" queries
const r: serverApi.QueryElem = {
prop: propsMapping[q.a],
propOperator: leafOpsMapping[q.leafOp],
prop: a,
propOperator: op,
propOperand: q.b,
}
return r;
} else if(isNodeElem(q)) {
} else if (isNodeElem(q)) {
let op = mapToServerNodeOp(q.nodeOp, queryFor);
if (op === null) {
throw 'Found a null node in query tree. Was it simplified first?'
}
const r = {
children: q.operands.map((op: any) => toApiQuery(op)),
childrenOperator: nodeOpsMapping[q.nodeOp]
children: q.operands.map((op: any) => toApiQuery(op, queryFor)),
childrenOperator: op
}
return r;
}

Loading…
Cancel
Save