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 { NodeChildrenGetQueryHandler } from './nodes/node-children-get';
|
||||
import { NodeGetQueryHandler } from './nodes/node-get';
|
||||
import { NodeListQueryHandler } from './nodes/node-list';
|
||||
import { NodeReactionsListQueryHandler } from './nodes/node-reaction-list';
|
||||
import { NodeReactionsAggregateQueryHandler } from './nodes/node-reactions-aggregate';
|
||||
import { NodeTreeGetQueryHandler } from './nodes/node-tree-get';
|
||||
@@ -60,13 +61,16 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
|
||||
'node.reaction.list': new NodeReactionsListQueryHandler(app),
|
||||
'node.reactions.aggregate': new NodeReactionsAggregateQueryHandler(app),
|
||||
'node.get': new NodeGetQueryHandler(app),
|
||||
'node.list': new NodeListQueryHandler(app),
|
||||
'node.tree.get': new NodeTreeGetQueryHandler(app),
|
||||
'record.list': new RecordListQueryHandler(app),
|
||||
'record.field.value.count': new RecordFieldValueCountQueryHandler(app),
|
||||
'user.search': new UserSearchQueryHandler(app),
|
||||
'workspace.list': new WorkspaceListQueryHandler(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),
|
||||
'file.list': new FileListQueryHandler(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 './apps/tabs-list';
|
||||
export * from './servers/server-list';
|
||||
export * from './nodes/node-list';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
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 { createDownloadsCollection } from '@colanode/ui/collections/downloads';
|
||||
import { createMetadataCollection } from '@colanode/ui/collections/metadata';
|
||||
import { createNodesCollection } from '@colanode/ui/collections/nodes';
|
||||
import { createServersCollection } from '@colanode/ui/collections/servers';
|
||||
import { createTabsCollection } from '@colanode/ui/collections/tabs';
|
||||
import { createTempFilesCollection } from '@colanode/ui/collections/temp-files';
|
||||
@@ -17,12 +24,19 @@ class WorkspaceCollections {
|
||||
public readonly users: Collection<User, string>;
|
||||
public readonly downloads: Collection<Download, string>;
|
||||
public readonly uploads: Collection<Upload, string>;
|
||||
public readonly nodes: Collection<LocalNode, string>;
|
||||
public readonly records: Collection<LocalRecordNode>;
|
||||
|
||||
constructor(userId: string) {
|
||||
this.userId = userId;
|
||||
this.users = createUsersCollection(userId);
|
||||
this.downloads = createDownloadsCollection(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 { TableViewRow } from '@colanode/ui/components/databases/tables/table-view-row';
|
||||
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 = () => {
|
||||
const view = useDatabaseView();
|
||||
const { records, hasMore, loadMore, isPending } = useRecordsQuery(
|
||||
view.filters,
|
||||
view.sorts
|
||||
);
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useRecordsInfiniteQuery(view.filters, view.sorts);
|
||||
|
||||
const records = data;
|
||||
|
||||
return (
|
||||
<div className="border-t">
|
||||
@@ -21,8 +21,8 @@ export const TableViewBody = () => {
|
||||
<InView
|
||||
rootMargin="200px"
|
||||
onChange={(inView) => {
|
||||
if (inView && hasMore && !isPending) {
|
||||
loadMore();
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
></InView>
|
||||
|
||||
@@ -506,11 +506,13 @@ export const View = ({ view }: ViewProps) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{match(view.attributes.layout)
|
||||
.with('table', () => <TableView />)
|
||||
.with('board', () => <BoardView />)
|
||||
.with('calendar', () => <CalendarView />)
|
||||
.exhaustive()}
|
||||
<div className="w-full h-full group/database">
|
||||
{match(view.attributes.layout)
|
||||
.with('table', () => <TableView />)
|
||||
.with('board', () => <BoardView />)
|
||||
.with('calendar', () => <CalendarView />)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</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