diff --git a/plugin/filter/render.go b/plugin/filter/render.go index 0bd8fdb60..9d3bc60af 100644 --- a/plugin/filter/render.go +++ b/plugin/filter/render.go @@ -207,6 +207,17 @@ func (r *renderer) renderScalarComparison(field Field, op ComparisonOperator, ri } columnExpr := field.columnExpr(r.dialect) + if lit == nil { + switch op { + case CompareEq: + return renderResult{sql: fmt.Sprintf("%s IS NULL", columnExpr)}, nil + case CompareNeq: + return renderResult{sql: fmt.Sprintf("%s IS NOT NULL", columnExpr)}, nil + default: + return renderResult{}, errors.Errorf("operator %s not supported for null comparison", op) + } + } + placeholder := "" switch field.Type { case FieldTypeString: diff --git a/plugin/filter/schema.go b/plugin/filter/schema.go index 8cfd2d915..c172eb62a 100644 --- a/plugin/filter/schema.go +++ b/plugin/filter/schema.go @@ -297,7 +297,7 @@ func NewAttachmentSchema() Schema { cel.Variable("filename", cel.StringType), cel.Variable("mime_type", cel.StringType), cel.Variable("create_time", cel.IntType), - cel.Variable("memo_id", cel.IntType), + cel.Variable("memo_id", cel.AnyType), nowFunction, } diff --git a/store/test/attachment_filter_test.go b/store/test/attachment_filter_test.go index a46d31a82..3ae64b024 100644 --- a/store/test/attachment_filter_test.go +++ b/store/test/attachment_filter_test.go @@ -328,9 +328,18 @@ func TestAttachmentFilterNullMemoId(t *testing.T) { tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("with_memo.png").MimeType("image/png").MemoID(&memo.ID)) tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("no_memo.png").MimeType("image/png")) - attachments := tc.ListWithFilter(`memo_id == ` + formatInt32(memo.ID)) + // Test: memo_id == null + attachments := tc.ListWithFilter(`memo_id == null`) + require.Len(t, attachments, 1) + require.Equal(t, "no_memo.png", attachments[0].Filename) + require.Nil(t, attachments[0].MemoID) + + // Test: memo_id != null + attachments = tc.ListWithFilter(`memo_id != null`) require.Len(t, attachments, 1) require.Equal(t, "with_memo.png", attachments[0].Filename) + require.NotNil(t, attachments[0].MemoID) + require.Equal(t, memo.ID, *attachments[0].MemoID) } func TestAttachmentFilterEmptyFilename(t *testing.T) { diff --git a/web/src/pages/Attachments.tsx b/web/src/pages/Attachments.tsx index 21be4edb4..ae4f37af2 100644 --- a/web/src/pages/Attachments.tsx +++ b/web/src/pages/Attachments.tsx @@ -156,7 +156,19 @@ const Attachments = () => { // Delete all unused attachments const handleDeleteUnusedAttachments = useCallback(async () => { try { - await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name))); + let allUnusedAttachments: Attachment[] = []; + let nextPageToken = ""; + do { + const response = await attachmentServiceClient.listAttachments({ + pageSize: 1000, + pageToken: nextPageToken, + filter: "memo_id == null", + }); + allUnusedAttachments = [...allUnusedAttachments, ...response.attachments]; + nextPageToken = response.nextPageToken; + } while (nextPageToken); + + await Promise.all(allUnusedAttachments.map((attachment) => deleteAttachment(attachment.name))); toast.success(t("resource.delete-all-unused-success")); } catch (error) { handleError(error, toast.error, { @@ -166,7 +178,7 @@ const Attachments = () => { } finally { await handleRefetch(); } - }, [unusedAttachments, t, handleRefetch, deleteAttachment]); + }, [t, handleRefetch, deleteAttachment]); // Handle search input change const handleSearchChange = useCallback((e: React.ChangeEvent) => {