Use tanstackdb for records query

This commit is contained in:
Hakan Shehu
2025-11-20 10:29:27 -08:00
parent 7149ef5466
commit 86cef58b26
9 changed files with 874 additions and 15 deletions

View File

@@ -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),

View 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;
}
}

View File

@@ -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 {}

View 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[];
};
}
}

View File

@@ -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'))
);
} }
} }

View 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,
// });
// }
// },
});
};

View File

@@ -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>

View File

@@ -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>
); );
}; };

View 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;
};