mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Use tanstackdb for records query
This commit is contained in:
@@ -32,6 +32,7 @@ import { RadarDataGetQueryHandler } from './interactions/radar-data-get';
|
|||||||
import { MessageListQueryHandler } from './messages/message-list';
|
import { MessageListQueryHandler } from './messages/message-list';
|
||||||
import { NodeChildrenGetQueryHandler } from './nodes/node-children-get';
|
import { NodeChildrenGetQueryHandler } from './nodes/node-children-get';
|
||||||
import { NodeGetQueryHandler } from './nodes/node-get';
|
import { NodeGetQueryHandler } from './nodes/node-get';
|
||||||
|
import { NodeListQueryHandler } from './nodes/node-list';
|
||||||
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
|
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
|
||||||
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
|
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
|
||||||
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
|
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
|
||||||
@@ -60,13 +61,16 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
|||||||
'node.reaction.list': new NodeReactionsListQueryHandler(app),
|
'node.reaction.list': new NodeReactionsListQueryHandler(app),
|
||||||
'node.reactions.aggregate': new NodeReactionsAggregateQueryHandler(app),
|
'node.reactions.aggregate': new NodeReactionsAggregateQueryHandler(app),
|
||||||
'node.get': new NodeGetQueryHandler(app),
|
'node.get': new NodeGetQueryHandler(app),
|
||||||
|
'node.list': new NodeListQueryHandler(app),
|
||||||
'node.tree.get': new NodeTreeGetQueryHandler(app),
|
'node.tree.get': new NodeTreeGetQueryHandler(app),
|
||||||
'record.list': new RecordListQueryHandler(app),
|
'record.list': new RecordListQueryHandler(app),
|
||||||
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),
|
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),
|
||||||
'user.search': new UserSearchQueryHandler(app),
|
'user.search': new UserSearchQueryHandler(app),
|
||||||
'workspace.list': new WorkspaceListQueryHandler(app),
|
'workspace.list': new WorkspaceListQueryHandler(app),
|
||||||
'workspace.storage.get': new WorkspaceStorageGetQueryHandler(app),
|
'workspace.storage.get': new WorkspaceStorageGetQueryHandler(app),
|
||||||
'workspace.storage.users.get': new WorkspaceStorageUsersGetQueryHandler(app),
|
'workspace.storage.users.get': new WorkspaceStorageUsersGetQueryHandler(
|
||||||
|
app
|
||||||
|
),
|
||||||
'user.list': new UserListQueryHandler(app),
|
'user.list': new UserListQueryHandler(app),
|
||||||
'file.list': new FileListQueryHandler(app),
|
'file.list': new FileListQueryHandler(app),
|
||||||
'emoji.list': new EmojiListQueryHandler(app),
|
'emoji.list': new EmojiListQueryHandler(app),
|
||||||
|
|||||||
35
packages/client/src/handlers/queries/nodes/node-list.ts
Normal file
35
packages/client/src/handlers/queries/nodes/node-list.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { SelectNode } from '@colanode/client/databases';
|
||||||
|
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
|
||||||
|
import { mapNode } from '@colanode/client/lib';
|
||||||
|
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
|
||||||
|
import { NodeListQueryInput } from '@colanode/client/queries/nodes/node-list';
|
||||||
|
import { LocalNode } from '@colanode/client/types/nodes';
|
||||||
|
|
||||||
|
export class NodeListQueryHandler
|
||||||
|
extends WorkspaceQueryHandlerBase
|
||||||
|
implements QueryHandler<NodeListQueryInput>
|
||||||
|
{
|
||||||
|
public async handleQuery(input: NodeListQueryInput): Promise<LocalNode[]> {
|
||||||
|
const rows = await this.fetchNodes(input);
|
||||||
|
return rows.map(mapNode) as LocalNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkForChanges(): Promise<
|
||||||
|
ChangeCheckResult<NodeListQueryInput>
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
hasChanges: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchNodes(input: NodeListQueryInput): Promise<SelectNode[]> {
|
||||||
|
const workspace = this.getWorkspace(input.userId);
|
||||||
|
|
||||||
|
const rows = await workspace.database
|
||||||
|
.selectFrom('nodes')
|
||||||
|
.selectAll()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export * from './icons/icon-svg-get';
|
|||||||
export * from './emojis/emoji-svg-get';
|
export * from './emojis/emoji-svg-get';
|
||||||
export * from './apps/tabs-list';
|
export * from './apps/tabs-list';
|
||||||
export * from './servers/server-list';
|
export * from './servers/server-list';
|
||||||
|
export * from './nodes/node-list';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
export interface QueryMap {}
|
export interface QueryMap {}
|
||||||
|
|||||||
15
packages/client/src/queries/nodes/node-list.ts
Normal file
15
packages/client/src/queries/nodes/node-list.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { LocalNode } from '@colanode/client/types/nodes';
|
||||||
|
|
||||||
|
export type NodeListQueryInput = {
|
||||||
|
type: 'node.list';
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module '@colanode/client/queries' {
|
||||||
|
interface QueryMap {
|
||||||
|
'node.list': {
|
||||||
|
input: NodeListQueryInput;
|
||||||
|
output: LocalNode[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Collection } from '@tanstack/react-db';
|
import { Collection, createLiveQueryCollection, eq } from '@tanstack/react-db';
|
||||||
|
|
||||||
import { Download, Upload, User } from '@colanode/client/types';
|
import {
|
||||||
|
Download,
|
||||||
|
LocalNode,
|
||||||
|
LocalRecordNode,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
} from '@colanode/client/types';
|
||||||
import { createAccountsCollection } from '@colanode/ui/collections/accounts';
|
import { createAccountsCollection } from '@colanode/ui/collections/accounts';
|
||||||
import { createDownloadsCollection } from '@colanode/ui/collections/downloads';
|
import { createDownloadsCollection } from '@colanode/ui/collections/downloads';
|
||||||
import { createMetadataCollection } from '@colanode/ui/collections/metadata';
|
import { createMetadataCollection } from '@colanode/ui/collections/metadata';
|
||||||
|
import { createNodesCollection } from '@colanode/ui/collections/nodes';
|
||||||
import { createServersCollection } from '@colanode/ui/collections/servers';
|
import { createServersCollection } from '@colanode/ui/collections/servers';
|
||||||
import { createTabsCollection } from '@colanode/ui/collections/tabs';
|
import { createTabsCollection } from '@colanode/ui/collections/tabs';
|
||||||
import { createTempFilesCollection } from '@colanode/ui/collections/temp-files';
|
import { createTempFilesCollection } from '@colanode/ui/collections/temp-files';
|
||||||
@@ -17,12 +24,19 @@ class WorkspaceCollections {
|
|||||||
public readonly users: Collection<User, string>;
|
public readonly users: Collection<User, string>;
|
||||||
public readonly downloads: Collection<Download, string>;
|
public readonly downloads: Collection<Download, string>;
|
||||||
public readonly uploads: Collection<Upload, string>;
|
public readonly uploads: Collection<Upload, string>;
|
||||||
|
public readonly nodes: Collection<LocalNode, string>;
|
||||||
|
public readonly records: Collection<LocalRecordNode>;
|
||||||
|
|
||||||
constructor(userId: string) {
|
constructor(userId: string) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.users = createUsersCollection(userId);
|
this.users = createUsersCollection(userId);
|
||||||
this.downloads = createDownloadsCollection(userId);
|
this.downloads = createDownloadsCollection(userId);
|
||||||
this.uploads = createUploadsCollection(userId);
|
this.uploads = createUploadsCollection(userId);
|
||||||
|
this.nodes = createNodesCollection(userId);
|
||||||
|
|
||||||
|
this.records = createLiveQueryCollection((q) =>
|
||||||
|
q.from({ node: this.nodes }).where(({ node }) => eq(node.type, 'record'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
packages/ui/src/collections/nodes.ts
Normal file
84
packages/ui/src/collections/nodes.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { createCollection } from '@tanstack/react-db';
|
||||||
|
// import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
|
import { LocalNode } from '@colanode/client/types';
|
||||||
|
|
||||||
|
export const createNodesCollection = (userId: string) => {
|
||||||
|
return createCollection<LocalNode, string>({
|
||||||
|
getKey(item) {
|
||||||
|
return item.id;
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
sync({ begin, write, commit, markReady }) {
|
||||||
|
window.colanode
|
||||||
|
.executeQuery({
|
||||||
|
type: 'node.list',
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
.then((nodes) => {
|
||||||
|
console.log('nodes', nodes);
|
||||||
|
begin();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
write({ type: 'insert', value: node });
|
||||||
|
}
|
||||||
|
|
||||||
|
commit();
|
||||||
|
markReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscriptionId = window.eventBus.subscribe((event) => {
|
||||||
|
if (event.type === 'node.created') {
|
||||||
|
begin();
|
||||||
|
write({ type: 'insert', value: event.node });
|
||||||
|
commit();
|
||||||
|
} else if (event.type === 'node.updated') {
|
||||||
|
begin();
|
||||||
|
write({ type: 'update', value: event.node });
|
||||||
|
commit();
|
||||||
|
} else if (event.type === 'node.deleted') {
|
||||||
|
begin();
|
||||||
|
write({ type: 'delete', value: event.node });
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup: () => window.eventBus.unsubscribe(subscriptionId),
|
||||||
|
loadSubset: async (options) => {
|
||||||
|
console.log('loadSubset', options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// onInsert: async ({ transaction }) => {
|
||||||
|
// for (const mutation of transaction.mutations) {
|
||||||
|
// await window.colanode.executeMutation({
|
||||||
|
// type: 'node.create',
|
||||||
|
// userId,
|
||||||
|
// node: mutation.modified,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// onUpdate: async ({ transaction }) => {
|
||||||
|
// for (const mutation of transaction.mutations) {
|
||||||
|
// const attributes = cloneDeep(mutation.modified.attributes);
|
||||||
|
// await window.colanode.executeMutation({
|
||||||
|
// type: 'node.update',
|
||||||
|
// userId,
|
||||||
|
// nodeId: mutation.key,
|
||||||
|
// attributes,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// onDelete: async ({ transaction }) => {
|
||||||
|
// for (const mutation of transaction.mutations) {
|
||||||
|
// await window.colanode.executeMutation({
|
||||||
|
// type: 'node.delete',
|
||||||
|
// userId,
|
||||||
|
// nodeId: mutation.key,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,14 +3,14 @@ import { InView } from 'react-intersection-observer';
|
|||||||
import { TableViewEmptyPlaceholder } from '@colanode/ui/components/databases/tables/table-view-empty-placeholder';
|
import { TableViewEmptyPlaceholder } from '@colanode/ui/components/databases/tables/table-view-empty-placeholder';
|
||||||
import { TableViewRow } from '@colanode/ui/components/databases/tables/table-view-row';
|
import { TableViewRow } from '@colanode/ui/components/databases/tables/table-view-row';
|
||||||
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
|
||||||
import { useRecordsQuery } from '@colanode/ui/hooks/use-records-query';
|
import { useRecordsInfiniteQuery } from '@colanode/ui/hooks/use-records-infinite-query';
|
||||||
|
|
||||||
export const TableViewBody = () => {
|
export const TableViewBody = () => {
|
||||||
const view = useDatabaseView();
|
const view = useDatabaseView();
|
||||||
const { records, hasMore, loadMore, isPending } = useRecordsQuery(
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
view.filters,
|
useRecordsInfiniteQuery(view.filters, view.sorts);
|
||||||
view.sorts
|
|
||||||
);
|
const records = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
@@ -21,8 +21,8 @@ export const TableViewBody = () => {
|
|||||||
<InView
|
<InView
|
||||||
rootMargin="200px"
|
rootMargin="200px"
|
||||||
onChange={(inView) => {
|
onChange={(inView) => {
|
||||||
if (inView && hasMore && !isPending) {
|
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||||
loadMore();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></InView>
|
></InView>
|
||||||
|
|||||||
@@ -506,11 +506,13 @@ export const View = ({ view }: ViewProps) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{match(view.attributes.layout)
|
<div className="w-full h-full group/database">
|
||||||
.with('table', () => <TableView />)
|
{match(view.attributes.layout)
|
||||||
.with('board', () => <BoardView />)
|
.with('table', () => <TableView />)
|
||||||
.with('calendar', () => <CalendarView />)
|
.with('board', () => <BoardView />)
|
||||||
.exhaustive()}
|
.with('calendar', () => <CalendarView />)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
</DatabaseViewContext.Provider>
|
</DatabaseViewContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
704
packages/ui/src/hooks/use-records-infinite-query.ts
Normal file
704
packages/ui/src/hooks/use-records-infinite-query.ts
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
import {
|
||||||
|
Ref,
|
||||||
|
and,
|
||||||
|
coalesce,
|
||||||
|
eq,
|
||||||
|
gt,
|
||||||
|
gte,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
isUndefined,
|
||||||
|
length,
|
||||||
|
lt,
|
||||||
|
lte,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
useLiveInfiniteQuery,
|
||||||
|
} from '@tanstack/react-db';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { LocalRecordNode } from '@colanode/client/types';
|
||||||
|
import {
|
||||||
|
DatabaseViewFieldFilterAttributes,
|
||||||
|
DatabaseViewFilterAttributes,
|
||||||
|
DatabaseViewSortAttributes,
|
||||||
|
FieldAttributes,
|
||||||
|
SpecialId,
|
||||||
|
isStringArray,
|
||||||
|
} from '@colanode/core';
|
||||||
|
import { collections } from '@colanode/ui/collections';
|
||||||
|
import { useDatabase } from '@colanode/ui/contexts/database';
|
||||||
|
import { useWorkspace } from '@colanode/ui/contexts/workspace';
|
||||||
|
|
||||||
|
const RECORDS_PER_PAGE = 100;
|
||||||
|
|
||||||
|
type BooleanExpression = ReturnType<typeof eq>;
|
||||||
|
type RecordRef = Ref<LocalRecordNode>;
|
||||||
|
|
||||||
|
type OrderByDefinition = {
|
||||||
|
direction: DatabaseViewSortAttributes['direction'];
|
||||||
|
selector: (record: RecordRef) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldValuePrimitive =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| string[]
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
type ExpressionValue<T> =
|
||||||
|
| T
|
||||||
|
| Ref<T>
|
||||||
|
| Ref<T | null>
|
||||||
|
| Ref<T | undefined>
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
type StringValueExpression = Parameters<typeof ilike>[0];
|
||||||
|
type NumberValueExpression = ExpressionValue<number>;
|
||||||
|
type BooleanValueExpression = ExpressionValue<boolean>;
|
||||||
|
type ArrayValueExpression = ExpressionValue<string[]>;
|
||||||
|
|
||||||
|
const DEFAULT_ORDERING: OrderByDefinition = {
|
||||||
|
direction: 'asc',
|
||||||
|
selector: (record) => record.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRecordsInfiniteQuery = (
|
||||||
|
filters: DatabaseViewFilterAttributes[],
|
||||||
|
sorts: DatabaseViewSortAttributes[],
|
||||||
|
count?: number
|
||||||
|
) => {
|
||||||
|
const workspace = useWorkspace();
|
||||||
|
const database = useDatabase();
|
||||||
|
|
||||||
|
const pageSize = count ?? RECORDS_PER_PAGE;
|
||||||
|
const fieldsById = useMemo(
|
||||||
|
() => buildFieldsById(database.fields ?? []),
|
||||||
|
[database.fields]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasFilterDefinitions = useMemo(
|
||||||
|
() => hasUsableFilters(filters, fieldsById),
|
||||||
|
[filters, fieldsById]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = useLiveInfiniteQuery(
|
||||||
|
(q) => {
|
||||||
|
let query = q
|
||||||
|
.from({ records: collections.workspace(workspace.userId).records })
|
||||||
|
.where(({ records }) => eq(records.attributes.databaseId, database.id));
|
||||||
|
|
||||||
|
if (hasFilterDefinitions) {
|
||||||
|
query = query.where(({ records }) => {
|
||||||
|
return (
|
||||||
|
buildFiltersExpression(
|
||||||
|
filters,
|
||||||
|
fieldsById,
|
||||||
|
workspace.userId,
|
||||||
|
records
|
||||||
|
) ?? eq(1, 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderings = buildSortDefinitions(sorts, fieldsById);
|
||||||
|
const effectiveOrderings =
|
||||||
|
orderings.length > 0 ? orderings : [DEFAULT_ORDERING];
|
||||||
|
|
||||||
|
effectiveOrderings.forEach(({ selector, direction }) => {
|
||||||
|
query = query.orderBy(({ records }) => selector(records), direction);
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pageSize: pageSize,
|
||||||
|
getNextPageParam: (lastPage, allPages) =>
|
||||||
|
lastPage.length === pageSize ? allPages.length : undefined,
|
||||||
|
},
|
||||||
|
[workspace.userId, database.id, database.fields, filters, sorts, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldsById = (fields: FieldAttributes[]) => {
|
||||||
|
return fields.reduce<Record<string, FieldAttributes>>((acc, field) => {
|
||||||
|
acc[field.id] = field;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUsableFilters = (
|
||||||
|
filters: DatabaseViewFilterAttributes[],
|
||||||
|
fieldsById: Record<string, FieldAttributes>
|
||||||
|
): boolean => {
|
||||||
|
return filters.some((filter) => {
|
||||||
|
if (filter.type === 'group') {
|
||||||
|
return filter.filters.some((nested) =>
|
||||||
|
hasFieldReference(nested.fieldId, fieldsById)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasFieldReference(filter.fieldId, fieldsById);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFieldReference = (
|
||||||
|
fieldId: string,
|
||||||
|
fieldsById: Record<string, FieldAttributes>
|
||||||
|
) => {
|
||||||
|
if (fieldId === SpecialId.Name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldsById[fieldId] != null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSortDefinitions = (
|
||||||
|
sorts: DatabaseViewSortAttributes[],
|
||||||
|
fieldsById: Record<string, FieldAttributes>
|
||||||
|
): OrderByDefinition[] => {
|
||||||
|
return sorts
|
||||||
|
.map((sort) => createSortDefinition(sort, fieldsById))
|
||||||
|
.filter((sort): sort is OrderByDefinition => sort != null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSortDefinition = (
|
||||||
|
sort: DatabaseViewSortAttributes,
|
||||||
|
fieldsById: Record<string, FieldAttributes>
|
||||||
|
): OrderByDefinition | null => {
|
||||||
|
if (sort.fieldId === SpecialId.Name) {
|
||||||
|
return {
|
||||||
|
direction: sort.direction,
|
||||||
|
selector: (record) => record.attributes.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fieldsById[sort.fieldId];
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'created_at') {
|
||||||
|
return {
|
||||||
|
direction: sort.direction,
|
||||||
|
selector: (record) => record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'created_by') {
|
||||||
|
return {
|
||||||
|
direction: sort.direction,
|
||||||
|
selector: (record) => record.createdBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: sort.direction,
|
||||||
|
selector: (record) => record.attributes.fields[field.id]?.value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFiltersExpression = (
|
||||||
|
filters: DatabaseViewFilterAttributes[],
|
||||||
|
fieldsById: Record<string, FieldAttributes>,
|
||||||
|
currentUserId: string,
|
||||||
|
record: RecordRef
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
const expressions = filters
|
||||||
|
.map((filter) =>
|
||||||
|
filter.type === 'group'
|
||||||
|
? buildFilterGroupExpression(filter, fieldsById, currentUserId, record)
|
||||||
|
: buildFieldFilterExpression(filter, fieldsById, currentUserId, record)
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(expression): expression is BooleanExpression => expression != null
|
||||||
|
);
|
||||||
|
|
||||||
|
return combineWithAnd(expressions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilterGroupExpression = (
|
||||||
|
filter: Extract<DatabaseViewFilterAttributes, { type: 'group' }>,
|
||||||
|
fieldsById: Record<string, FieldAttributes>,
|
||||||
|
currentUserId: string,
|
||||||
|
record: RecordRef
|
||||||
|
) => {
|
||||||
|
const expressions = filter.filters
|
||||||
|
.map((nestedFilter) =>
|
||||||
|
buildFieldFilterExpression(
|
||||||
|
nestedFilter,
|
||||||
|
fieldsById,
|
||||||
|
currentUserId,
|
||||||
|
record
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(expression): expression is BooleanExpression => expression != null
|
||||||
|
);
|
||||||
|
|
||||||
|
return filter.operator === 'or'
|
||||||
|
? combineWithOr(expressions)
|
||||||
|
: combineWithAnd(expressions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
fieldsById: Record<string, FieldAttributes>,
|
||||||
|
currentUserId: string,
|
||||||
|
record: RecordRef
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (filter.fieldId === SpecialId.Name) {
|
||||||
|
return buildStringFilterExpression(filter, record.attributes.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fieldsById[filter.fieldId];
|
||||||
|
if (!field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return buildBooleanFilterExpression(filter, record, field.id);
|
||||||
|
case 'collaborator':
|
||||||
|
return buildCollaboratorFilterExpression(
|
||||||
|
filter,
|
||||||
|
record,
|
||||||
|
field.id,
|
||||||
|
currentUserId
|
||||||
|
);
|
||||||
|
case 'created_at':
|
||||||
|
return buildDateComparisonExpression(filter, record.createdAt, false);
|
||||||
|
case 'created_by':
|
||||||
|
return buildCreatedByFilterExpression(filter, record, currentUserId);
|
||||||
|
case 'date':
|
||||||
|
return buildDateComparisonExpression(
|
||||||
|
filter,
|
||||||
|
getFieldValue<StringValueExpression>(record, field.id),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
case 'email':
|
||||||
|
return buildStringFilterExpression(
|
||||||
|
filter,
|
||||||
|
getFieldValue<StringValueExpression>(record, field.id)
|
||||||
|
);
|
||||||
|
case 'file':
|
||||||
|
return null;
|
||||||
|
case 'multi_select':
|
||||||
|
return buildArrayFieldFilterExpression(filter, record, field.id);
|
||||||
|
case 'number':
|
||||||
|
return buildNumberFilterExpression(filter, record, field.id);
|
||||||
|
case 'phone':
|
||||||
|
return buildStringFilterExpression(
|
||||||
|
filter,
|
||||||
|
getFieldValue<StringValueExpression>(record, field.id)
|
||||||
|
);
|
||||||
|
case 'relation':
|
||||||
|
return buildArrayFieldFilterExpression(filter, record, field.id);
|
||||||
|
case 'select':
|
||||||
|
return buildSelectFilterExpression(filter, record, field.id);
|
||||||
|
case 'text':
|
||||||
|
return buildStringFilterExpression(
|
||||||
|
filter,
|
||||||
|
getFieldValue<StringValueExpression>(record, field.id)
|
||||||
|
);
|
||||||
|
case 'url':
|
||||||
|
return buildStringFilterExpression(
|
||||||
|
filter,
|
||||||
|
getFieldValue<StringValueExpression>(record, field.id)
|
||||||
|
);
|
||||||
|
case 'updated_at':
|
||||||
|
return buildDateComparisonExpression(filter, record.updatedAt, true);
|
||||||
|
case 'updated_by':
|
||||||
|
return buildUpdatedByFilterExpression(filter, record, currentUserId);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBooleanFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string
|
||||||
|
) => {
|
||||||
|
const fieldValue = getFieldValue<BooleanValueExpression>(record, fieldId);
|
||||||
|
if (filter.operator === 'is_true') {
|
||||||
|
return eq(fieldValue, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_false') {
|
||||||
|
return or(eq(fieldValue, false), isValueMissing(fieldValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCollaboratorFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string,
|
||||||
|
currentUserId: string
|
||||||
|
) => {
|
||||||
|
const fieldValue = getFieldValue<ArrayValueExpression>(record, fieldId);
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildArrayIsEmpty(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildArrayIsNotEmpty(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_me') {
|
||||||
|
return buildArrayContains(fieldValue, [currentUserId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_me') {
|
||||||
|
return buildArrayDoesNotContain(fieldValue, [currentUserId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value) || filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_in') {
|
||||||
|
return buildArrayContains(fieldValue, filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_in') {
|
||||||
|
return buildArrayDoesNotContain(fieldValue, filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCreatedByFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
currentUserId: string
|
||||||
|
) => {
|
||||||
|
if (filter.operator === 'is_me') {
|
||||||
|
return eq(record.createdBy, currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_me') {
|
||||||
|
return not(eq(record.createdBy, currentUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value) || filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisons = filter.value.map((value) => eq(record.createdBy, value));
|
||||||
|
const combined = combineWithOr(comparisons);
|
||||||
|
|
||||||
|
if (!combined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.operator === 'is_in' ? combined : not(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUpdatedByFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
currentUserId: string
|
||||||
|
) => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return isValueMissing(record.updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return isValuePresent(record.updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_me') {
|
||||||
|
return eq(record.updatedBy, currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_me') {
|
||||||
|
return not(eq(record.updatedBy, currentUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value) || filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisons = filter.value.map((value) => eq(record.updatedBy, value));
|
||||||
|
const combined = combineWithOr(comparisons);
|
||||||
|
|
||||||
|
if (!combined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.operator === 'is_in' ? combined : not(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateComparisonExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
valueRef: StringValueExpression,
|
||||||
|
includeEmptyChecks: boolean
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (includeEmptyChecks) {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return isValueMissing(valueRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return isValuePresent(valueRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = normalizeDateValue(filter.value);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return eq(valueRef, value);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return not(eq(valueRef, value));
|
||||||
|
case 'is_on_or_after':
|
||||||
|
return gte(valueRef, value);
|
||||||
|
case 'is_on_or_before':
|
||||||
|
return lte(valueRef, value);
|
||||||
|
case 'is_after':
|
||||||
|
return gt(valueRef, value);
|
||||||
|
case 'is_before':
|
||||||
|
return lt(valueRef, value);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNumberFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string
|
||||||
|
) => {
|
||||||
|
const fieldValue = getFieldValue<NumberValueExpression>(record, fieldId);
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return isValueMissing(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return isValuePresent(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filter.value !== 'number' || Number.isNaN(filter.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return eq(fieldValue, filter.value);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return not(eq(fieldValue, filter.value));
|
||||||
|
case 'is_greater_than':
|
||||||
|
return gt(fieldValue, filter.value);
|
||||||
|
case 'is_less_than':
|
||||||
|
return lt(fieldValue, filter.value);
|
||||||
|
case 'is_greater_than_or_equal_to':
|
||||||
|
return gte(fieldValue, filter.value);
|
||||||
|
case 'is_less_than_or_equal_to':
|
||||||
|
return lte(fieldValue, filter.value);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStringFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
valueRef: StringValueExpression
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return isValueMissing(valueRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return isValuePresent(valueRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getStringFilterValue(filter.value);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.operator) {
|
||||||
|
case 'is_equal_to':
|
||||||
|
return eq(valueRef, value);
|
||||||
|
case 'is_not_equal_to':
|
||||||
|
return not(eq(valueRef, value));
|
||||||
|
case 'contains':
|
||||||
|
return ilike(valueRef, `%${value}%`);
|
||||||
|
case 'does_not_contain':
|
||||||
|
return not(ilike(valueRef, `%${value}%`));
|
||||||
|
case 'starts_with':
|
||||||
|
return ilike(valueRef, `${value}%`);
|
||||||
|
case 'ends_with':
|
||||||
|
return ilike(valueRef, `%${value}`);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayFieldFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
const fieldValue = getFieldValue<ArrayValueExpression>(record, fieldId);
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return buildArrayIsEmpty(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return buildArrayIsNotEmpty(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value) || filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_in') {
|
||||||
|
return buildArrayContains(fieldValue, filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_in') {
|
||||||
|
return buildArrayDoesNotContain(fieldValue, filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSelectFilterExpression = (
|
||||||
|
filter: DatabaseViewFieldFilterAttributes,
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string
|
||||||
|
) => {
|
||||||
|
const fieldValue = getFieldValue<StringValueExpression>(record, fieldId);
|
||||||
|
if (filter.operator === 'is_empty') {
|
||||||
|
return isValueMissing(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.operator === 'is_not_empty') {
|
||||||
|
return isValuePresent(fieldValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStringArray(filter.value) || filter.value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisons = filter.value.map((value) => eq(fieldValue, value));
|
||||||
|
const combined = combineWithOr(comparisons);
|
||||||
|
|
||||||
|
if (!combined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.operator === 'is_in' ? combined : not(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldValue = <T = FieldValuePrimitive>(
|
||||||
|
record: RecordRef,
|
||||||
|
fieldId: string
|
||||||
|
): T => {
|
||||||
|
return record.attributes.fields[fieldId]?.value as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayIsEmpty = (
|
||||||
|
valueRef: ArrayValueExpression
|
||||||
|
): BooleanExpression => {
|
||||||
|
return or(isValueMissing(valueRef), eq(length(coalesce(valueRef, [])), 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayIsNotEmpty = (
|
||||||
|
valueRef: ArrayValueExpression
|
||||||
|
): BooleanExpression => {
|
||||||
|
return gt(length(coalesce(valueRef, [])), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayContains = (
|
||||||
|
valueRef: ArrayValueExpression,
|
||||||
|
values: readonly string[]
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = coalesce(valueRef, [] as string[]);
|
||||||
|
const expressions = values.map((value) => inArray(value, normalized));
|
||||||
|
return combineWithOr(expressions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArrayDoesNotContain = (
|
||||||
|
valueRef: ArrayValueExpression,
|
||||||
|
values: readonly string[]
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
const contains = buildArrayContains(valueRef, values);
|
||||||
|
return contains ? not(contains) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const combineWithAnd = (
|
||||||
|
expressions: BooleanExpression[]
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: BooleanExpression | null = null;
|
||||||
|
for (const expression of expressions) {
|
||||||
|
result = result ? and(result, expression) : expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const combineWithOr = (
|
||||||
|
expressions: BooleanExpression[]
|
||||||
|
): BooleanExpression | null => {
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: BooleanExpression | null = null;
|
||||||
|
for (const expression of expressions) {
|
||||||
|
result = result ? or(result, expression) : expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValueMissing = (value: unknown): BooleanExpression => {
|
||||||
|
return or(isNull(value), isUndefined(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValuePresent = (value: unknown): BooleanExpression => {
|
||||||
|
return not(isValueMissing(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStringFilterValue = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateValue = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString().split('T')[0] ?? null;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user