Skip to content

feat: Rails-style scopes#1892

Open
stephenh wants to merge 43 commits into
mainfrom
feat/scopes
Open

feat: Rails-style scopes#1892
stephenh wants to merge 43 commits into
mainfrom
feat/scopes

Conversation

@stephenh

@stephenh stephenh commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator

Add Scope Queries

Fixes #573

Summary

Adds Rails-style, typed scope queries for reusable Joist em.find filters:

await Author.adult.popular.find(em);
await Author.named("a").adult.find(em);
await Author.adult.where({ firstName: "a1" }).findOne(em);

Scopes are static entity properties, but execution still requires an explicit EntityManager via terminal methods like .find(em), .findOne(em), .findCount(em), and .findIds(em).

Biggest FYI Achieving the chained Author.adult.popular syntax in TypeScript currently requires running joist-codegen after each change to a scope definition, so that we can update an always-codegened AuthorScopes type to help drive the DSL's type checking.

Implementation

  • Represents scopes as immutable ordered operations that compile to Joist's existing FilterAndSettings<T> / em.find path.
  • Supports object filters, alias-condition filters, parameterized scopes with scope.fn, named-scope chaining, and builder chaining with .where, .orderBy, .limit, .offset, and .softDeletes:
    • Object filter: static adult = scope({ age: { gte: 18 } })
    • Alias-condition filter: static popular = scope((a) => a.isPopular.eq(true))
    • Parameterized scope: static named = scope.fn((prefix: string) => (a) => a.firstName.like(${prefix}%))
  • Generates per-entity authorScope function constants in each <Entity>Codegen.ts, mirroring existing <entity>Config constants.
  • Generates <Entity>Scopes and <Entity>Scope types from a syntax-only codegen pre-scan of user-owned entity files; users need to run joist-codegen after each scope declaration change so the generated chainable scope types stay current.
  • Regenerates test fixture codegen so every entity has an empty or populated <Entity>Scopes interface and <Entity>Scope alias.

Example

import {
  AuthorCodegen,
  authorConfig as config,
  authorScope as scope,
  type AuthorScope,
} from "./entities";

export class Author extends AuthorCodegen {
  static adult = scope({ age: { gte: 18 } });
  static popular = scope((a) => a.isPopular.eq(true));
  static named = scope.fn((prefix) => (a) => a.firstName.like(`${prefix}%`));
}

After codegen refreshes AuthorScopes, those declarations become chainable:

await Author.adult.popular.find(em);
await Author.named("a").adult.find(em);

Tests

  • Adds packages/tests/integration/src/EntityManager.scopes.test.ts for runtime behavior and compile-time type assertions.
  • Covers object-form scopes, alias-condition scopes, parameterized scopes, named-scope chaining, static scopes composed from other scopes, builder methods, terminal methods, populate typing, immutability, unknown-scope runtime errors, and toFindArgs output.

Verification

Verified:

cd packages/tests/integration && yarn jest -- src/EntityManager.scopes.test.ts
cd docs && yarn build
yarn build

Review Notes

  • The codegen scanner is intentionally syntax-only: scope declarations must be static properties with an explicit <Entity>Scope type, or a function type returning <Entity>Scope, and an initializer rooted at scope or the entity class.
  • Parameterized scopes should use static properties, not static methods, so codegen can discover them.
  • Collection-bound scopes and implicit ambient-EM execution are intentionally out of scope for this first implementation.
  • For repeated complex nested relation filters, prefer combining that relation filter in a single scope; root-level repeated fields can also use alias-condition scopes for explicit AND semantics.

Non-Scope Diff Notes

The main..@ diff also currently contains scope design markdown files, an added batched-paginated-find blog post, and an unrelated GraphQL resolver hint test/behavior change. Consider splitting those out if this PR should stay scope-only.

@pkg-pr-new

pkg-pr-new Bot commented Jun 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

joist-codegen

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-codegen@1892

joist-core

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-core@1892

joist-graphql-codegen

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-graphql-codegen@1892

joist-graphql-resolver-utils

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-graphql-resolver-utils@1892

joist-knex

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-knex@1892

joist-migration-utils

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-migration-utils@1892

joist-orm

npm i https://pkg.pr.new/joist-orm/joist-orm@1892

joist-test-utils

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-test-utils@1892

joist-utils

npm i https://pkg.pr.new/joist-orm/joist-orm/joist-utils@1892

commit: 6f7fd87

@stephenh stephenh changed the title Feat/scopes feat: Rails-style scopes Jun 21, 2026
@stephenh stephenh marked this pull request as ready for review June 22, 2026 13:45
@stephenh stephenh requested a review from zgavin June 22, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement something like Rail's scopes

1 participant