Skip to content

feat(core): support field-level and range filters in where clause#1064

Merged
ascorbic merged 7 commits into
emdash-cms:mainfrom
Glacier-Luo:feat/where-field-and-range-filters
Jun 1, 2026
Merged

feat(core): support field-level and range filters in where clause#1064
ascorbic merged 7 commits into
emdash-cms:mainfrom
Glacier-Luo:feat/where-field-and-range-filters

Conversation

@Glacier-Luo

@Glacier-Luo Glacier-Luo commented May 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

The loader's where option previously only processed taxonomy-based keys (via JOIN). Non-taxonomy field names were silently discarded, forcing sites with large content libraries to load entire collections into memory and filter in JavaScript.

This adds two capabilities to getEmDashCollection's where clause:

  • Exact/multi-value match on content table columns{ series: "main" } or { series: ["main", "side"] }
  • Range comparisons{ published_at: { gte: "2024-01-01T00:00:00Z", lt: "2025-01-01T00:00:00Z" } }

Both execute at the SQL layer with parameterized queries and validated identifiers. Taxonomy filtering remains unchanged (backward compatible).

Motivation: A fiction serialization site with ~4000 chapters spanning 10+ years. The timeline page needs to filter by year (published_at range) and by series (field exact match). Without SQL-level field filtering, the only options are wasteful full-table JS filtering or dropping to raw getDb() and losing preview, visual editing, byline hydration, and request caching.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.6 (Claude Code)

Screenshots / test output

 Test Files  209 passed (209)
      Tests  3305 passed (3305)

Usage examples

// Exact match on a content field
const { entries } = await getEmDashCollection("posts", {
  where: { series: "main" },
});

// Date range filtering
const { entries } = await getEmDashCollection("posts", {
  where: { published_at: { gte: "2024-01-01T00:00:00Z", lt: "2025-01-01T00:00:00Z" } },
  orderBy: { published_at: "asc" },
});

// Combined: taxonomy + field + range
const { entries } = await getEmDashCollection("posts", {
  where: { category: "fiction", series: "main", published_at: { gte: "2024-01-01" } },
});

The loader's `where` option previously only processed taxonomy-based
keys (via JOIN). Non-taxonomy field names were silently discarded,
forcing sites with large content libraries to load entire collections
into memory and filter in JavaScript.

This adds two capabilities:
- Exact/multi-value match on content table columns (AND col = ? / IN)
- Range comparisons (gt, gte, lt, lte) for date and string fields

Both are executed at the SQL layer with parameterized queries and
validated identifiers. Taxonomy filtering remains unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented May 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ba6368c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented May 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1064

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1064

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1064

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1064

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1064

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1064

emdash

npm i https://pkg.pr.new/emdash@1064

create-emdash

npm i https://pkg.pr.new/create-emdash@1064

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1064

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1064

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1064

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1064

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1064

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1064

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1064

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1064

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1064

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1064

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1064

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1064

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1064

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1064

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1064

commit: ba6368c

@ascorbic

Copy link
Copy Markdown
Collaborator

/review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice feature — the SQL-layer field/range filtering is well-scoped, parameterized correctly, and consistent with how sql.ref() is used elsewhere in the loader. Tests cover the main matrices (exact, IN, gt/gte/lt/lte, combined with taxonomy, with pagination). The minor-bump changeset is appropriate.

A few non-blocking observations below. The biggest one is the silent footgun behavior when field names don't exist or don't match the validation regex — a typo today returns the wrong rows with no signal, and one of the tests even codifies that. Worth at least mentioning in the JSDoc, even if behavior stays as-is.

Comment thread packages/core/src/loader.ts Outdated
const conditions: ReturnType<typeof sql>[] = [];

for (const [key, value] of Object.entries(fields)) {
if (!FIELD_NAME_PATTERN.test(key)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Invalid field names are silently dropped here, and the test on line 122 of loader-field-filters.test.ts even codifies this (returns all entries when every key fails the regex). This is a debugging footgun: a typo like series_ vs seriesX would return wrong rows with no signal — particularly bad given the motivating use case is filtering ~4k chapters where "too many results" looks like a working query.

The same FIELD_NAME_PATTERN is used for column references throughout the loader, but in those paths (orderBy, cursor) the field comes from internal code, not user input. Here the input is external.

Consider either:

  1. Throwing a clear error for invalid keys (preferred — fail fast)
  2. Logging a console.warn so the issue surfaces in dev
  3. At minimum, documenting in the JSDoc on where that invalid identifiers are silently ignored

Also applies to filtering by valid identifiers that aren't real columns — those will hit a SQL error, but the message will reference an unknown column rather than the user's mistake.

}
} else {
conditions.push(sql`${ref} = ${value}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two runtime values that the WhereValue type doesn't admit but JS users can still pass:

  • null → falls through to the else branch and emits column = NULL, which is never true in SQL. The user probably meant IS NULL. Silent footgun.
  • undefined → same path, emits column = ? bound to undefined. Driver behavior varies (better-sqlite3 throws; pg coerces to NULL).

A single guard like if (value == null) continue; at the top of the loop body would make both well-behaved. If you want explicit null filtering as a future feature, that can be a separate { isNull: true } operator.

Comment thread packages/core/src/loader.ts Outdated
@@ -423,7 +478,7 @@ export interface CollectionFilter {
/**
* Filter by field values or taxonomy terms

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The expanded JSDoc on query.ts's CollectionFilter.where mentions ranges and field filters with examples; this copy still says only "Filter by field values or taxonomy terms". Worth syncing so the IDE hover shows the new capability regardless of which CollectionFilter symbol someone hits.

Suggested change
* Filter by field values or taxonomy terms
/**
* Filter by field values, taxonomy terms, or ranges.
*
* Taxonomy names are detected automatically and filtered via JOIN.
* Other keys are treated as column filters on the content table.
* Invalid identifiers (not matching `/^[a-zA-Z_][a-zA-Z0-9_]*$/`) are silently ignored.
*
* @example { category: 'news' } - taxonomy term
* @example { series: 'main' } - exact match on a content field
* @example { published_at: { gte: '2024-01-01', lt: '2025-01-01' } } - date range
*/

Comment thread packages/core/src/loader.ts Outdated
const limit = filter?.limit;
const cursor = filter?.cursor;
const where = filter?.where;
const where = filter?.where as Record<string, WhereValue> | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cast is redundant — filter?.where is already typed as Record<string, WhereValue> | undefined after the interface change two lines up the file.

Suggested change
const where = filter?.where as Record<string, WhereValue> | undefined;
const where = filter?.where;

Comment thread packages/core/src/loader.ts Outdated
for (const [key, value] of Object.entries(where)) {
if (taxNames.has(key)) {
taxonomyFilters[key] = value;
if (taxNames.has(key) && !isWhereRange(value)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Edge case worth a thought: a range value against a taxonomy key (e.g. where: { category: { gte: 'a' } }) hits the else branch and is routed to fieldFilters, which will then emit ec_<type>.category >= 'a'. There's no category column on the content table (categories live in the join), so this produces a confusing "no such column" SQL error rather than a helpful "ranges aren't supported on taxonomies" message.

The TS type does allow WhereRange for any key. Two reasonable options:

  1. Detect this case explicitly and throw a clear error.
  2. Treat range-on-taxonomy as a no-op and document it.

Low priority since taxonomy slugs aren't a common range-filter target, but it's a sharp edge in the API surface.

Comment thread packages/core/src/query.ts Outdated
? stableStringify(v as Record<string, unknown>)
: v;
}
return JSON.stringify(ordered);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the recursion returns a string but then assigns that string back as a value in ordered, so the outer JSON.stringify(ordered) re-escapes the inner JSON. It works (still deterministic), but the resulting cache key has confusing escape characters like '{\"foo\":\"{\\\"gte\\\":\\\"x\\\"}\"}'.

A cleaner version returns the ordered object and only stringifies once at the top:

Suggested change
return JSON.stringify(ordered);
function stableStringify(value: Record<string, unknown>): string {
return JSON.stringify(stableOrder(value));
}
function stableOrder(value: Record<string, unknown>): Record<string, unknown> {
const keys = Object.keys(value).toSorted();
const ordered: Record<string, unknown> = {};
for (const k of keys) {
const v = value[k];
ordered[k] =
v !== null && typeof v === "object" && !Array.isArray(v)
? stableOrder(v as Record<string, unknown>)
: v;
}
return ordered;
}

No behavior change beyond cache key string length; just easier to debug if you ever log the key.

@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Posted 6 line-anchored comments covering:

  1. Medium: Silent skip of invalid field names — debugging footgun, especially given the motivating use case (4k chapters)
  2. Low/Med: null/undefined runtime values fall to the equality branch, producing = NULL instead of IS NULL
  3. Nit: JSDoc on CollectionFilter.where in loader.ts not synced with the expanded version in query.ts
  4. Nit: Redundant as cast on filter?.where
  5. Low: Range applied to a taxonomy key routes to fieldFilters and produces a confusing "no such column" SQL error
  6. Nit: stableStringify double-escapes nested objects (works, but ugly to debug)

No blocking issues — the feature is sound, parameterization is correct, tests are thorough.

github run

@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label May 30, 2026
- Warn on invalid field names instead of silently ignoring
- Skip null/undefined values to prevent SQL `= NULL` footgun
- Warn and skip range operators on taxonomy keys
- Warn when a second taxonomy key is passed (only one supported)
- Handle missing column errors gracefully (return empty results)
- Add dialect-safe isMissingColumnError to db-errors.ts
- Sync JSDoc for `where` to document ranges and field filters
- Remove redundant type cast on `filter?.where`
- Fix stableStringify double-escaping by separating order and serialize
- Add tests for null values and taxonomy range edge cases
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review size/XL and removed size/L review/awaiting-author Reviewed; waiting on the author to respond labels Jun 1, 2026
Glacier-Luo and others added 2 commits June 1, 2026 16:14
Use existing isRecord() type guard instead of `as Record<string, unknown>`
to satisfy oxlint --type-aware --deny-warnings.
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 1, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a well-scoped, cleanly implemented feature that solves a real pain point (SQL-level field filtering in getEmDashCollection). The approach fits EmDash's architecture: it layers onto the existing loader without breaking taxonomy filtering, validates identifiers before they reach SQL, parameterizes all values, preserves locale/cursor semantics, and keeps backwards compatibility.

The implementation is solid. I checked SQL safety, cache-key stability, backwards compatibility, locale handling, and cursor pagination correctness — all look good. The tests added are comprehensive for the happy path.

Two suggestions, neither blocking:

  1. The isMissingColumnError PostgreSQL heuristic can falsely match table errors when the table name happens to contain "column" (e.g. relation "column_stats" does not exist). The end-behavior is harmless (same silent empty-result as a missing-table error), but the warning log will mislabel it as a filter issue.
  2. The missing-column fallback path is untested. A test that drops a column or mocks a query returning a missing-column error would shore up coverage for that graceful-degradation branch.

if (!message) return false;

// SQLite / D1: "no such column: foo"
if (message.includes("no such column")) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The PostgreSQL heuristic can produce false positives when a missing table or relation happens to contain the word "column" in its name (e.g. relation "column_stats" does not exist). isMissingTableError already catches that case, so the net behavior is still correct, but the warning logged in loader.ts will mislabel a missing-table error as a missing-column error.

Suggested change
if (message.includes("no such column")) return true;
if (message.includes("does not exist") && message.includes("column") && !message.includes("relation")) return true;

PostgreSQL missing-column errors don't include "relation", so excluding it eliminates the false positive without losing real matches.

@@ -693,13 +761,17 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The isMissingColumnError fallback path (returning empty results when a where filter references a non-existent column) is not covered by the new test file. Since this is a new graceful-degradation code path, a targeted test that triggers a missing-column error (e.g. by querying with a typo like serie or by temporarily dropping the column mid-test) would prevent regressions.

Glacier-Luo and others added 2 commits June 1, 2026 20:35
- Exclude "relation" keyword in PostgreSQL check to prevent false
  positives on table names containing "column"
- Add test for missing column graceful fallback in where filter
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 1, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a clean follow-up to my previous review. Both prior suggestions have been addressed:

  1. isMissingColumnError false positives — The PostgreSQL heuristic now excludes messages containing "relation", preventing table names like column_stats from being misidentified as missing-column errors.

  2. Missing-column test coverage — A new test verifies that a where filter referencing a non-existent column gracefully returns an empty result set with a warning log, rather than propagating the SQL error.

I also verified the additional follow-up commits:

  • stableStringify has been split into stableOrder + JSON.stringify, fixing a cache-key stability bug where nested WhereRange objects with keys in different orders would produce different cache keys.
  • The unsafe type assertion in stableOrder was replaced with the existing isRecord() type guard.

The implementation remains sound: SQL identifiers are validated before reaching the query builder, all values are parameterized, taxonomy and field filters compose correctly, locale filtering and cursor pagination are preserved, and backwards compatibility is maintained. No new issues found.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic ascorbic merged commit 33f76b8 into emdash-cms:main Jun 1, 2026
37 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants