Skip to content

feat: allow detailed filtering of pubvalues and updatedAt/createdAt through the api #985

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 45 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2cb8148
feat: add generic filtering capabalities to getPubsWithRelatedValues
tefkah Feb 24, 2025
e1c6566
docs: add a little bit of documentation
tefkah Feb 24, 2025
9d23663
fix: properly pass through filters to api
tefkah Feb 24, 2025
e375ed3
fix: add basic test for api filtering
tefkah Feb 24, 2025
4060019
fix: updatedAt/createdAt tests
tefkah Feb 24, 2025
3dec08a
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
2b2916d
fix: remove passthrough
tefkah Feb 25, 2025
ffb0324
docs: add hardcoded docs for the filters rather than fixing zod-openapi
tefkah Feb 25, 2025
59bde03
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
54c141b
Merge branch 'main' into tfk/pub-filter
tefkah Feb 25, 2025
295aef2
Merge branch 'main' into tfk/pub-filter
tefkah Feb 26, 2025
a23ee8e
fix: support more natural query syntax
tefkah Feb 26, 2025
0935bb5
dev: add integration test for parsing behavior
tefkah Feb 26, 2025
55b9d24
dev: add explicit jsonquery test with client
tefkah Feb 26, 2025
7429d81
fix: fix type error
tefkah Feb 26, 2025
5a91c8b
fix: allow booleans
tefkah Feb 26, 2025
a6e99ae
fix: remove passthrough
tefkah Feb 26, 2025
59e33f9
fix: add a ton of tests for filter parsing, and make it work as expected
tefkah Feb 26, 2025
44eebdb
fix: actually make logical filters work correctly
tefkah Feb 26, 2025
5d01997
feat: add little typesafe mapping lib
tefkah Feb 27, 2025
eedd289
chore: remove comment
tefkah Feb 27, 2025
5a2f497
fix: make typing of pub-filters better
tefkah Feb 27, 2025
64329ac
dev: rework the tests, expose not working filtering (despair)
tefkah Feb 27, 2025
0713300
Merge branch 'main' into tfk/pub-filter
tefkah Mar 3, 2025
956fef8
Merge branch 'tfk/pub-filter' of https://github.com/pubpub/platform i…
tefkah Mar 3, 2025
5300d20
fix: IT WORKS
tefkah Mar 3, 2025
d42f02b
Merge branch 'main' into tfk/pub-filter
tefkah Mar 4, 2025
4270bff
Merge branch 'main' into tfk/pub-filter
tefkah Mar 4, 2025
f5cfa43
chore: merge
tefkah Mar 12, 2025
a8f158a
fix: update
tefkah Mar 13, 2025
5764520
fix: remove children
tefkah Mar 13, 2025
f89c454
tests: add some more validation tests
tefkah Mar 13, 2025
61dba3c
chore: make running tests less verbose
tefkah Mar 13, 2025
0f2d22f
chore: remove some comments
tefkah Mar 13, 2025
3d2200f
chore: remove log file
tefkah Mar 13, 2025
540fb5e
Merge branch 'main' into tfk/pub-filter
tefkah Mar 17, 2025
8a44c92
chore: remove some comments
tefkah Mar 17, 2025
3f8ed5b
Merge branch 'main' into tfk/pub-filter
tefkah Mar 18, 2025
bb8b21b
feat: add existence checks and improve the whole thing bunches
tefkah Mar 19, 2025
196cf75
feat: add a benchmarking utility
tefkah Mar 19, 2025
aa5ced6
fix: improve typesafety
tefkah Mar 19, 2025
757024e
Merge branch 'main' into tfk/pub-filter
tefkah Mar 19, 2025
6033304
refactor: simplify the validation lib
tefkah Mar 19, 2025
91a59de
Merge branch 'main' into tfk/pub-filter
tefkah Mar 19, 2025
1828cc8
fix: fix type errors in test
tefkah Mar 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ MAILGUN_SMTP_USERNAME="xxx"
OTEL_SERVICE_NAME="pubpub-v7-dev" # should be shared across components but not environments
HONEYCOMB_API_KEY="xxx"

KYSELY_DEBUG="true"
# KYSELY_DEBUG="true"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I've always wondered what this does...


GCLOUD_KEY_FILE='xxx'

102 changes: 97 additions & 5 deletions core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { User } from "lucia";

import { headers } from "next/headers";
import { createNextHandler } from "@ts-rest/serverless/next";
import { createNextHandler, RequestValidationError } from "@ts-rest/serverless/next";
import { jsonObjectFrom } from "kysely/helpers/postgres";
import qs from "qs";
import { z } from "zod";

import type {
Expand All @@ -19,8 +20,9 @@ import type {
ApiAccessPermissionConstraintsInput,
LastModifiedBy,
} from "db/types";
import { siteApi } from "contracts";
import { baseFilterSchema, filterSchema, siteApi } from "contracts";
import { ApiAccessScope, ApiAccessType, Capabilities, MembershipType } from "db/public";
import { assert } from "utils";

import type { CapabilityTarget } from "~/lib/authorization/capabilities";
import { db } from "~/kysely/database";
Expand All @@ -29,6 +31,7 @@ import { userCan } from "~/lib/authorization/capabilities";
import { getStage } from "~/lib/db/queries";
import { createLastModifiedBy } from "~/lib/lastModifiedBy";
import {
BadRequestError,
createPubRecursiveNew,
deletePub,
doesPubExist,
Expand All @@ -47,6 +50,7 @@ import {
import { validateApiAccessToken } from "~/lib/server/apiAccessTokens";
import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug";
import { findCommunityBySlug } from "~/lib/server/community";
import { validateFilter } from "~/lib/server/pub-filters-validate";
import { getPubType, getPubTypesForCommunity } from "~/lib/server/pubtype";
import { getStages } from "~/lib/server/stages";
import { getMember, getSuggestedUsers, SAFE_USER_SELECT } from "~/lib/server/user";
Expand Down Expand Up @@ -226,6 +230,81 @@ const shouldReturnRepresentation = async () => {
return false;
};

/**
* manually parses the `?filters` query param.
* necessary because ts-rest only supports parsing object in query params
* if they're uri encoded.
*
* eg this does not fly
* ```
* ?filters[community-slug:fieldName][$eq]=value
* ```
* but this does
* ```
* ?filters=%7B%22%7B%22updatedAt%22%3A%20%7B%22%24gte%22%3A%20%222025-01-01%22%7D%2C%22field-slug%22%3A%20%7B%22%24eq%22%3A%20%22some-value%22%7D%7D`
* ```
*
* the latter is what a ts-rest client sends if `json-query: true`. we want to support both syntaxes.
Comment on lines +233 to +247
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not so much wanting to support both syntaxes, i just want to support the human readable one. turns out it's easier to support both.

*
*/
const manuallyParsePubFilterQueryParams = (url: string, query?: Record<string, any>) => {
if (!query || Object.keys(query).length === 0) {
return query;
}

// check if we already have properly structured filters
if (query.filters && typeof query.filters === "object") {
try {
const validatedFilters = filterSchema.parse(query.filters);
return {
...query,
filters: validatedFilters,
};
} catch (e) {
throw new RequestValidationError(null, null, e, null);
}
}

// check if we have filter-like keys (using bracket notation)
const filterLikeKeys = Object.keys(query).filter((key) => key.startsWith("filters["));

if (filterLikeKeys.length === 0) {
return query;
}

const queryString = url.split("?")[1];
if (!queryString) {
return query;
}

try {
// parse with qs
const parsedQuery = qs.parse(queryString, {
depth: 10,
arrayLimit: 100,
allowDots: false, // don't convert dots to objects (use brackets only)
ignoreQueryPrefix: true, // remove the leading '?' if present
});

if (!parsedQuery.filters) {
return query; // no filters found after parsing
}

const validatedFilters = filterSchema.parse(parsedQuery.filters);

return {
...query,
...parsedQuery,
filters: validatedFilters,
};
} catch (e) {
if (e instanceof z.ZodError) {
throw new RequestValidationError(null, null, e, null);
}
throw new BadRequestError(`Error parsing filters: ${e.message}`);
}
};

const handler = createNextHandler(
siteApi,
{
Expand Down Expand Up @@ -267,14 +346,24 @@ const handler = createNextHandler(
body: pub,
};
},
getMany: async ({ query }) => {
getMany: async ({ query }, { request }) => {
const { user, community } = await checkAuthorization({
token: { scope: ApiAccessScope.pub, type: ApiAccessType.read },
// TODO: figure out capability here
cookies: false,
});

const { pubTypeId, stageId, ...rest } = query;
const { pubTypeId, stageId, filters, ...rest } = query ?? {};

const manuallyParsedFilters = manuallyParsePubFilterQueryParams(request.url, query);

if (manuallyParsedFilters?.filters) {
try {
await validateFilter(community.id, manuallyParsedFilters.filters);
} catch (e) {
throw new BadRequestError(e.message);
}
}

const pubs = await getPubsWithRelatedValues(
{
Expand All @@ -283,7 +372,10 @@ const handler = createNextHandler(
stageId,
userId: user.id,
},
rest
{
...rest,
filters: manuallyParsedFilters?.filters,
}
);

return {
Expand Down
9 changes: 5 additions & 4 deletions core/lib/__tests__/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ export const setup = async () => {

logger.info("Resetting database...");
const result = spawnSync(
"MINIMAL_SEED=true pnpm --filter core exec dotenv -e ./.env.test -e ./.env.test.local prisma migrate reset -- --preview-feature --force",
"pnpm --filter core exec dotenv -e ./.env.test -e ./.env.test.local prisma migrate reset -- --preview-feature --force",
{
shell: true,
stdio: "inherit",
// stdio: "inherit",
}
);
const { stderr, error } = result;

if (error) {
if (!error) {
logger.info("Database reset successful");
} else {
logger.error(
"Something went wrong while trying to reset the database before running tests."
);
Expand Down
2 changes: 1 addition & 1 deletion core/lib/__tests__/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ expect.extend({
message: () =>
pass
? `Expected pub ${isNot ? "not" : ""} to have values ${JSON.stringify(expected)}, and it does ${isNot ? "not" : ""}`
: `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(sortedPubValues.values, expected)}`,
: `Expected pub ${isNot ? "not to" : "to"} match values ${this.utils.diff(expected, sortedPubValues.values)}`,
};
},
});
2 changes: 1 addition & 1 deletion core/lib/env/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
KYSELY_DEBUG: z.string().optional(),
KYSELY_ARTIFICIAL_LATENCY: z.coerce.number().optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
LOG_LEVEL: z.enum(["benchmark", "debug", "info", "warn", "error"]).optional(),
MAILGUN_SMTP_PASSWORD: selfHostedOptional(z.string()),
MAILGUN_SMTP_USERNAME: selfHostedOptional(z.string()),
MAILGUN_SMTP_HOST: selfHostedOptional(z.string()),
Expand Down
71 changes: 71 additions & 0 deletions core/lib/mapping.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i created these functions bc they were kind of helpful at some point, but then i kind of moved in a different direction and no longer needed them. i think theyre neat though, might come in handy later. please let me know if its better to remove them!

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Prettify, TupleToCustomObject } from "./types";

/**
* Type-safe version of Object.entries()
* Returns an array of tuples containing key-value pairs with proper typing
* Includes all properties that exist in the object, even if undefined
*/
export function entries<
const T extends Record<string, unknown>,
const KeepUndefined extends boolean = false,
>(
Comment on lines +3 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooh nice!

obj: T,
keepUndefined?: KeepUndefined
): T extends T
? { [K in keyof T]-?: [K, KeepUndefined extends true ? T[K] : NonNullable<T[K]>] }[keyof T][]
: never {
const newObj = Object.entries(obj);

if (keepUndefined) {
return newObj as any;
}

return newObj.filter(([_, v]) => v !== undefined) as any;
}

/**
* Type-safe version of Object.fromEntries()
* Creates an object from an array of key-value pairs with proper typing
*/
export function fromEntries<const T extends [PropertyKey, any][]>(
entries: T
): { [K in T[number][0]]: Extract<T[number], [K, any]>[1] } {
return Object.fromEntries(entries) as any;
}

type MapToEntries<
T extends readonly unknown[],
M extends readonly string[],
C extends any[] = [],
Buffer extends Record<string, any> = {},
> = C["length"] extends T["length"]
? Buffer
: MapToEntries<
T,
M,
[...C, C["length"]],
Prettify<
Buffer & {
[NewKey in M[C["length"]]]: T[C["length"]];
}
>
>;

export function mapToEntries<
const T extends readonly unknown[],
const M extends readonly string[],
C extends any[] = [],
Buffer extends Record<string, any> = {},
>(obj: T, mapping: M): T extends T ? MapToEntries<T, M, C, Buffer> : never {
const result: Record<string, any> = {};
for (let i = 0; i < Math.min(obj.length, mapping.length); i++) {
result[mapping[i]] = obj[i];
}
return result as any;
}

export function keys<const T extends Record<string, any>>(
obj: T
): T extends T ? (keyof T)[] : never {
return Object.keys(obj) as any;
}
7 changes: 7 additions & 0 deletions core/lib/server/__snapshots__/pub-filters.db.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`SQL generation > generates correct SQL for 'mixed array and object syntax with de…' 1`] = `"select * from "pubs" where ((exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $1 and (value::text ilike $2 and not value::text ilike $3)) or exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $4 and ("value" >= $5 or "value" = $6))) and not (exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $7 and "value" = $8) or exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $9 and not "value" > $10)))"`;

exports[`SQL generation > generates correct SQL for 'multiple field-level logical operator…' 1`] = `"select * from "pubs" where (exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $1 and (value::text ilike $2 or not (value::text ilike $3 and value::text like $4))) and exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $5 and (not "value" < $6 and ("value" = $7 or "value" = $8))))"`;

exports[`SQL generation > generates correct SQL for 'object syntax: complex mixture of fie…' 1`] = `"select * from "pubs" where ((exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $1 and (value::text ilike $2 or value::text ilike $3)) and exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $4 and not "value" < $5)) or not (exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $6 and not ("value" >= $7 and "value" <= $8)) and exists (select 1 as "exists_check" from "pub_values" inner join "pub_fields" on "pub_fields"."id" = "pub_values"."fieldId" where "pub_values"."pubId" = "pubs"."id" and "pub_fields"."slug" = $9 and "value" = $10)))"`;
Loading
Loading