diff --git a/imports/client.tsx b/imports/client.tsx index 29cc5aef..c08209a4 100644 --- a/imports/client.tsx +++ b/imports/client.tsx @@ -1,5 +1,5 @@ import atob from 'atob'; -import { gql, useQuery, useSubscription, useApolloClient } from '@apollo/client/index.js'; +import { gql, useQuery, useSubscription, useApolloClient, Observable } from '@apollo/client/index.js'; import type { ApolloQueryResult } from '@apollo/client/index.js'; import { generateApolloClient, IApolloClient } from '@deep-foundation/hasura/client.js'; import { useLocalStore } from '@deep-foundation/store/local.js'; @@ -14,6 +14,7 @@ import { corePckg } from './core.js'; import { BoolExpCan, BoolExpHandler, QueryLink, BoolExpSelector, BoolExpTree, BoolExpValue, MutationInputLink, MutationInputLinkPlain, MutationInputValue } from './client_types.js'; import get from 'get-value'; import {debug} from './debug.js' +import { Traveler as NativeTraveler } from './traveler.js'; const moduleLog = debug.extend('client'); const log = debug.extend('log'); @@ -268,6 +269,19 @@ export function parseJwt (token): { userId: number; role: string; roles: string[ ...other, }; }; + +export interface Subscription { + closed: boolean; + unsubscribe(): void; +} + +export interface Observer { + start?(subscription: Subscription): any; + next?(value: T): void; + error?(errorValue: any): void; + complete?(): void; +}; + export interface DeepClientOptions> { linkId?: number; token?: string; @@ -301,6 +315,7 @@ export interface DeepClientOptions> { export interface DeepClientResult extends ApolloQueryResult { error?: any; + subscribe?: (observer: Observer) => Subscription; } export type DeepClientPackageSelector = string; @@ -339,7 +354,8 @@ export interface DeepClientInstance> { stringify(any?: any): string; - select(exp: Exp, options?: ReadOptions): Promise>; + select(exp: Exp, options?: ReadOptions): Promise>; + subscribe(exp: Exp, options?: ReadOptions): Observable; insert(objects: InsertObjects , options?: WriteOptions):Promise>; @@ -377,6 +393,8 @@ export interface DeepClientInstance> { useDeep: typeof useDeep; DeepProvider: typeof DeepProvider; DeepContext: typeof DeepContext; + + Traveler(links: Link[]): NativeTraveler; } export interface DeepClientAuthResult { @@ -615,6 +633,8 @@ export class DeepClient> implements DeepClientInstance { ['strings', 'numbers', 'objects'].includes(table) ? this.valuesSelectReturning : table === 'selectors' ? this.selectorsSelectReturning : table === 'files' ? this.filesSelectReturning : `id`); + const tableNamePostfix = options?.tableNamePostfix; + const aggregate = options?.aggregate; // console.log(`returning: ${returning}; options.returning:${options?.returning}`) const variables = options?.variables; @@ -625,7 +645,8 @@ export class DeepClient> implements DeepClientInstance { queries: [ generateQueryData({ tableName: table, - returning, + tableNamePostfix: tableNamePostfix || aggregate ? '_aggregate' : '', + returning: aggregate ? `aggregate { ${aggregate} }` : returning, variables: { ...variables, ...query, @@ -634,12 +655,80 @@ export class DeepClient> implements DeepClientInstance { name: name, })); - return { ...q, data: (q)?.data?.q0 }; + return { ...q, data: aggregate ? (q)?.data?.q0?.aggregate?.[aggregate] : (q)?.data?.q0 }; } catch (e) { + console.log(generateQueryData({ + tableName: table, + tableNamePostfix: tableNamePostfix || aggregate ? '_aggregate' : '', + returning: aggregate ? `aggregate { ${aggregate} }` : returning, + variables: { + ...variables, + ...query, + } })('a', 0)); throw new Error(`DeepClient Select Error: ${e.message}`, { cause: e }); } }; + /** + * deep.subscribe + * @example + * deep.subscribe({ up: { link_id: 380 } }).subscribe({ next: (links) => {}, error: (err) => {} }); + */ + subscribe(exp: Exp, options?: ReadOptions): Observable { + if (!exp) { + return new Observable((observer) => { + observer.error('!exp'); + }); + } + const query = serializeQuery(exp, options?.table || 'links'); + const table = options?.table || this.table; + const returning = options?.returning ?? + (table === 'links' ? this.linksSelectReturning : + ['strings', 'numbers', 'objects'].includes(table) ? this.valuesSelectReturning : + table === 'selectors' ? this.selectorsSelectReturning : + table === 'files' ? this.filesSelectReturning : `id`); + const tableNamePostfix = options?.tableNamePostfix; + const aggregate = options?.aggregate; + + // console.log(`returning: ${returning}; options.returning:${options?.returning}`) + const variables = options?.variables; + const name = options?.name || this.defaultSelectName; + + try { + const apolloObservable = this.apolloClient.subscribe({ + ...generateQuery({ + operation: 'subscription', + queries: [ + generateQueryData({ + tableName: table, + tableNamePostfix: tableNamePostfix || aggregate ? '_aggregate' : '', + returning: returning || aggregate ? `aggregate { ${aggregate} }` : returning, + variables: { + ...variables, + ...query, + } }), + ], + name: name, + }), + }); + + const observable = new Observable((observer) => { + const subscription = apolloObservable.subscribe({ + next: (data: any) => { + observer.next(aggregate ? data?.q0?.aggregate?.[aggregate] : data?.q0); + }, + error: (error) => observer.error(error), + }); + return () => subscription.unsubscribe(); + }); + + // @ts-ignore + return observable; + } catch (e) { + throw new Error(`DeepClient Subscription Error: ${e.message}`, { cause: e }); + } + }; + async insert(objects: InsertObjects, options?: WriteOptions):Promise> { const _objects = Object.prototype.toString.call(objects) === '[object Array]' ? objects : [objects]; checkAndFillShorts(_objects); @@ -997,6 +1086,10 @@ export class DeepClient> implements DeepClientInstance { } return await import(path); } + + Traveler(links: Link[]) { + return new NativeTraveler(this, links); + }; } export const JWT = gql`query JWT($linkId: Int) { @@ -1074,6 +1167,7 @@ export function useDeepQuery = ( export type Options = { table?: TTable; + tableNamePostfix?: string; returning?: string; variables?: any; name?: string; + aggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max'; }; export type ReadOptions = Options; diff --git a/imports/gql/query.ts b/imports/gql/query.ts index a539bd86..d834ff67 100644 --- a/imports/gql/query.ts +++ b/imports/gql/query.ts @@ -15,6 +15,7 @@ const fieldsInputs = (tableName): IGenerateQueryFieldTypes => ({ }); export interface IGenerateQueryDataOptions { + tableNamePostfix?: string; tableName: string; operation?: 'query' | 'subscription'; queryName?: string; @@ -44,17 +45,18 @@ export interface IGenerateQueryDataResult extends IGenerateQueryDataOptions { export const generateQueryData = ({ tableName, + tableNamePostfix = '', operation = 'query', queryName = `${tableName}`, returning = `id`, variables, }: IGenerateQueryDataOptions): IGenerateQueryDataBuilder => { - log('generateQuery', { tableName, operation, queryName, returning, variables }); + log('generateQuery', { tableName, tableNamePostfix, operation, queryName, returning, variables }); const fields = ['distinct_on', 'limit', 'offset', 'order_by', 'where']; const fieldTypes = fieldsInputs(tableName); return (alias: string, index: number): IGenerateQueryDataResult => { - log('generateQueryBuilder', { tableName, operation, queryName, returning, variables, alias, index }); + log('generateQueryBuilder', { tableName, tableNamePostfix, operation, queryName, returning, variables, alias, index }); const defs = []; const args = []; for (let f = 0; f < fields.length; f++) { @@ -72,8 +74,9 @@ export const generateQueryData = ({ } const result = { tableName, + tableNamePostfix, operation, - queryName, + queryName: queryName+tableNamePostfix, returning, variables, resultReturning: returning, diff --git a/imports/minilinks.ts b/imports/minilinks.ts index 66e76b8c..283f152f 100644 --- a/imports/minilinks.ts +++ b/imports/minilinks.ts @@ -1,6 +1,10 @@ import _remove from 'lodash/remove.js'; import _isEqual from 'lodash/isEqual.js'; import _isEqualWith from 'lodash/isEqualWith.js'; +import _mean from 'lodash/mean.js'; +import _sum from 'lodash/sum.js'; +import _min from 'lodash/min.js'; +import _max from 'lodash/max.js'; import EventEmitter from 'events'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Debug from 'debug'; @@ -8,6 +12,7 @@ import { inherits } from 'util'; import { minilinksQuery, minilinksQueryIs } from './minilinks-query.js'; import { QueryLink } from './client_types.js'; import { useDebounceCallback } from '@react-hook/debounce'; +import { Observable } from '@apollo/client'; const debug = Debug('deeplinks:minilinks'); const log = debug.extend('log'); @@ -42,6 +47,11 @@ export interface LinkHashFields { export interface Link extends LinkPlain, LinkRelations>, LinkHashFields {} +export type MinilinksQueryOptionAggregate = 'count' | 'sum' | 'avg' | 'min' | 'max'; +export interface MinilinksQueryOptions { + aggregate?: A; +} + export interface MinilinksResult { links: Link[]; types: { [id: number]: Link[] }; @@ -51,7 +61,9 @@ export interface MinilinksResult { byType: { [id: number]: Link[] }; options: MinilinksGeneratorOptions; emitter: EventEmitter; - query(query: QueryLink | number): Link[]; + query(query: QueryLink | number): Link[] | any; + select(query: QueryLink | number): Link[] | any; + subscribe(query: QueryLink | number): Observable; add(linksArray: any[]): { anomalies?: MinilinkError[]; errors?: MinilinkError[]; @@ -203,8 +215,45 @@ export class MinilinkCollection(query, this); + + query(query: QueryLink | number, options?: MinilinksQueryOptions): A extends string ? any : L[] { + const result = minilinksQuery(query, this); + if (options?.aggregate === 'count') return result?.length as any; + if (options?.aggregate === 'avg') return _mean(result?.map(l => l?.value?.value)) as any; + if (options?.aggregate === 'sum') return _sum(result?.map(l => l?.value?.value)) as any; + if (options?.aggregate === 'min') return _min(result?.map(l => l?.value?.value)) as any; + if (options?.aggregate === 'max') return _max(result?.map(l => l?.value?.value)) as any; + return result; + } + select(query: QueryLink | number, options?: MinilinksQueryOptions): L[] | any { + return this.query(query, options); + } + + /** + * minilinks.subscribe + * @example + * minilinks.subscribe({ type_id: 2 }).subscribe({ next: (links) => {}, error: (err) => {} }); + */ + subscribe(query: QueryLink | number): Observable { + const ml = this; + return new Observable((observer) => { + let prev = ml.query(query); + observer.next(prev); + let listener = (oldL, newL) => { + const data = ml.query(query); + if (!_isEqual(prev, data)) { + observer.next(data); + } + }; + ml.emitter.on('added', listener); + ml.emitter.on('updated', listener); + ml.emitter.on('removed', listener); + return () => { + ml.emitter.removeListener('added', listener); + ml.emitter.removeListener('updated', listener); + ml.emitter.removeListener('removed', listener); + } + }); } add(linksArray: any[]): { anomalies?: MinilinkError[]; @@ -592,36 +641,20 @@ export function useMinilinksQuery>(ml, query: QueryLink | * Recalculates when data in minilinks changes. (Take query into useMemo!). */ export function useMinilinksSubscription>(ml, query: QueryLink | number) { - // console.log('54353246234562346435') - const listenerRef = useRef(); - const dRef = useRef(); + const observerRef = useRef(); const [d, setD] = useState(); const qRef = useRef(query); qRef.current = query; useEffect(() => { - if (listenerRef.current) ml.emitter.removeListener('added', listenerRef.current); - if (listenerRef.current) ml.emitter.removeListener('updated', listenerRef.current); - if (listenerRef.current) ml.emitter.removeListener('removed', listenerRef.current); - listenerRef.current = (oldL, newL) => { - const prev = d || dRef.current; - const data = ml.query(qRef.current); - if (!_isEqual(prev, data)) { - setD(data); - } - }; - ml.emitter.on('added', listenerRef.current); - ml.emitter.on('updated', listenerRef.current); - ml.emitter.on('removed', listenerRef.current); + !!observerRef.current && observerRef.current.unsubscribe(); + const obs = observerRef.current = ml.subscribe(qRef.current); + const sub = obs.subscribe({ + next: (links) => setD(links), + error: (error) => { throw new Error(error) }, + }); return () => { - ml.emitter.removeListener('added', listenerRef.current); - ml.emitter.removeListener('updated', listenerRef.current); - ml.emitter.removeListener('removed', listenerRef.current); + sub.unsubscribe(); } }, []); - // const iterationsInterval = setInterval(() => { - // setIteration((i: number) => i === Number.MAX_SAFE_INTEGER ? 0 : i+1) - // }, 1000); - // return () => clearInterval(iterationsInterval); - const data = dRef.current = d ? d : ml.query(query); - return data; + return d || ml.query(query); }; \ No newline at end of file diff --git a/imports/traveler.ts b/imports/traveler.ts new file mode 100644 index 00000000..5a1a2802 --- /dev/null +++ b/imports/traveler.ts @@ -0,0 +1,148 @@ +import { DeepClient, Exp } from "./client"; +import { QueryLink } from "./client_types"; +import { Link } from "./minilinks"; + +type Direction = "from" | "to" | "type" | "out" | "in" | "typed" | "up" | "down"; + +type Mode = 'local' | 'remote'; + +interface Travel { + query: Exp; + direction?: Direction; +} + +export const inversions = { + from: 'out', + to: 'in', + type: 'typed', + out: 'from', + in: 'to', + typed: 'type', + up: 'down', + down: 'up', +}; + +export class Traveler { + deep: DeepClient>; + links: Link[] = []; + travels: Travel<"links">[]; + mode: Mode = 'remote'; + + constructor(deep, links: Link[] = [], travels: Travel[] = [], mode: Mode = 'remote') { + this.deep = deep; + this.links = links; + this.travels = travels; + this.mode = mode; + } + + from(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'from' }], this.mode); + } + to(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'to' }], this.mode); + } + type(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'type' }], this.mode); + } + out(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'out' }], this.mode); + } + in(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'in' }], this.mode); + } + typed(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'typed' }], this.mode); + } + + up(query: Exp<"tree">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'up' }], this.mode); + } + down(query: Exp<"tree">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query, direction: 'down' }], this.mode); + } + + and(query: Exp<"links">) { + return new Traveler(this.deep, this.links, [...this.travels, { query: query }], this.mode); + } + + get query(): Exp<"links"> { + let current: any = { id: { _in: this.links.map(l => l.id) } }; + for (let t = 0; t < this.travels.length; t++) { + const travel = this.travels[t]; + if (!travel.direction) { + current = { _and: [(travel.query as any || {}), current] }; + } else if (['up','down'].includes(inversions[travel.direction])) { + current = { + [inversions[travel.direction]]: { + ...(travel.query as any || {}), + [inversions[travel.direction] === 'down' ? 'link' : 'parent']: current, + }, + }; + } else { + current = { ...(travel.query as any || {}), [inversions[travel.direction]]: current }; + } + } + return current; + } + + select() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query); + } else { + return this.deep.minilinks.select(query as QueryLink); + } + } + subscription() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.subscribe(query); + } else { + return this.deep.minilinks.subscribe(query as QueryLink); + } + } + + count() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query, { aggregate: 'count' }); + } else { + return this.deep.minilinks.select(query as QueryLink)?.length; + } + } + sum() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query, { aggregate: 'sum' }); + } else { + } + } + avg() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query, { aggregate: 'avg' }); + } else { + } + } + min() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query, { aggregate: 'min' }); + } else { + } + } + max() { + const query = this.query; + if (this.mode === 'remote') { + return this.deep.select(query, { aggregate: 'max' }); + } else { + } + } + + get local() { + return new Traveler(this.deep, this.links, this.travels, 'local'); + } + get remote() { + return new Traveler(this.deep, this.links, this.travels, 'remote'); + } +}