Skip to content

Conversation

@sw34
Copy link
Contributor

@sw34 sw34 commented Sep 25, 2025

implements sparse fieldsets as shown in the specification https://jsonapi.org/format/#fetching-sparse-fieldsets

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 25, 2025

📝 Walkthrough

Walkthrough

Adds sparse fieldset support to the REST RequestHandler: introduces buildPartialSelect to parse fields[] and return Prisma select objects, threads this into buildRelationSelect and multiple read/relationship handlers, and includes new tests covering sparse fields with includes, relationships, pagination, and JSON:API responses.

Changes

Cohort / File(s) Summary
REST handler: partial select integration
packages/server/src/api/rest/index.ts
Added buildPartialSelect(type, query); extended buildRelationSelect signature to accept query and call buildPartialSelect; updated processSingleRead, processFetchRelated, processReadRelationship, and processCollectionRead to merge partial relation selects; added duplicatedFieldsParameter error.
REST API tests: sparse fieldsets & query features
packages/server/tests/api/rest-partial.test.ts
New Jest tests seeding Prisma data and validating sparse fieldset behavior for primary and included resources, related fetches, deep includes, filtering, sorting, pagination, links/meta, and JSON:API structure.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant REST_Handler as REST Handler
  participant BuildRelation as buildRelationSelect
  participant BuildPartial as buildPartialSelect
  participant Prisma

  Client->>REST_Handler: GET /resource?include=rel&fields[type]=a,b
  REST_Handler->>BuildRelation: buildRelationSelect(type, include, query)
  BuildRelation->>BuildPartial: buildPartialSelect(relatedType, query)
  BuildPartial-->>BuildRelation: { select } or { error }
  alt valid select
    BuildRelation-->>REST_Handler: relation select merged
    REST_Handler->>Prisma: query with select/include
    Prisma-->>REST_Handler: data
    REST_Handler-->>Client: 200 + JSON:API response
  else error
    BuildRelation-->>REST_Handler: error
    REST_Handler-->>Client: 400 + error body
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested reviewers

  • ymc9

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title “Rest api sparse fieldsets” directly references the core change introduced by the pull request—namely, adding sparse fieldset support to the REST API—and is concise and specific enough for team members to understand the primary feature being implemented.
Description Check ✅ Passed The description clearly states that the pull request implements sparse fieldsets according to the JSON:API specification and is directly related to the changeset, providing relevant context without straying off-topic.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/server/tests/api/rest-partial.test.ts (3)

411-445: Typo: “efect” → “effect”.

Fix test titles for clarity.

Apply this diff:

-        it('does not efect toplevel filtering', async () => {
+        it('does not effect toplevel filtering', async () => {

And similarly for the next test below.


446-478: Typo: “efect” → “effect”.

Same correction for the sorting test name.

Apply this diff:

-        it('does not efect toplevel sorting', async () => {
+        it('does not effect toplevel sorting', async () => {

479-505: Typo and add a guardrail test.

  • Fix “efect” → “effect”.
  • Recommend adding a case for GET /user/:id/posts without fields[post] to catch regressions for default related selection (it would have exposed the boolean-spread bug).

Do you want me to add that test snippet?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 80ebb55 and f92fea4.

📒 Files selected for processing (2)
  • packages/server/src/api/rest/index.ts (12 hunks)
  • packages/server/tests/api/rest-partial.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/server/tests/api/rest-partial.test.ts (2)
packages/testtools/src/schema.ts (1)
  • loadSchema (172-248)
packages/server/src/api/rest/index.ts (2)
  • makeHandler (2096-2099)
  • makeHandler (2101-2101)
packages/server/src/api/rest/index.ts (1)
packages/runtime/src/local-helpers/lower-case-first.ts (1)
  • lowerCaseFirst (1-3)
🔇 Additional comments (6)
packages/server/src/api/rest/index.ts (6)

180-183: Good addition: explicit error for duplicated fields parameter.

Clear error semantics for duplicated fields params will help clients debug malformed requests.


519-527: Include handling now respects per-relation sparse fieldsets.

Passing the query into buildRelationSelect and merging its select into args.include looks correct. Ensure nested includes still serialize as expected when combined with relationship ID inclusion.

If you have complex nested includes, please run a quick manual check for a case like include=posts.author with fields[post] and fields[user] both set, to verify the merged select/include shape.


529-541: Converting include into select when sparse fieldsets are used is correct.

Prisma disallows mixing select and include at the same level; your conversion avoids that. Nice defensive merge order so relation selections win over placeholders.


739-747: Include merge for collection reads looks good.

buildRelationSelect’s output merged into include preserves prior relationship ID selection and is compatible with later select conversion.


749-761: Top-level sparse fieldsets: correct select/include reconciliation.

Merging select first, then folding include into select, avoids Prisma select/include conflicts and preserves relation selections.


1861-1914: buildRelationSelect: integrates sparse fieldsets for included relations.

Logic is sound. One caution: when a leaf inclusion has no fields[...] override, you set the leaf to true. If this result is later passed to injectRelationQuery (as in processFetchRelated), it will need to be normalized to an object (addressed in the earlier fix).

Please re-test GET /:type/:id/:relationship without any fields[...] to ensure filtering/sorting/pagination on the related collection still works after applying the fix.

Copy link
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Hi @sw34 , thanks a lot for making this PR and it overall looks great to me. Please check my review comments. Would be like to change the docs as well? Or I can take care of it if you're busy.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
packages/server/src/api/rest/index.ts (2)

806-831: buildPartialSelect: honor modelNameMapping and validate fields to fail fast.
Currently only fields[internalType] is read and arbitrary field names pass through until Prisma errors. Support mapped names and reject unknown fields early.

Apply:

-    private buildPartialSelect(type: string, query: Record<string, string | string[]> | undefined) {
-        const selectFieldsQuery = query?.[`fields[${type}]`];
+    private buildPartialSelect(type: string, query: Record<string, string | string[]> | undefined) {
+        // accept fields[Internal], fields[internal], and fields[Mapped]
+        const internal = lowerCaseFirst(type);
+        const mapped = this.mapModelName(internal);
+        const keys = [`fields[${type}]`, `fields[${internal}]`, `fields[${mapped}]`];
+        const present = keys.filter((k) => query?.[k] !== undefined);
+        const selectFieldsQuery = present.length ? (query as any)[present[0]] : undefined;
         if (!selectFieldsQuery) {
             return { select: undefined, error: undefined };
         }
 
-        if (Array.isArray(selectFieldsQuery)) {
+        // duplicated fields[...] (e.g., both mapped and internal provided)
+        if (Array.isArray(selectFieldsQuery) || present.length > 1) {
             return {
                 select: undefined,
-                error: this.makeError('duplicatedFieldsParameter', `duplicated fields query for type ${type}`),
+                error: this.makeError('duplicatedFieldsParameter', `duplicated fields query for type ${type}`),
             };
         }
 
-        const typeInfo = this.typeMap[lowerCaseFirst(type)];
+        const typeInfo = this.typeMap[internal];
         if (!typeInfo) {
             return { select: undefined, error: this.makeUnsupportedModelError(type) };
         }
 
-        const selectFieldNames = selectFieldsQuery.split(',').filter((i) => i);
+        const selectFieldNames = selectFieldsQuery.split(',').map((s: string) => s.trim()).filter((i: string) => i);
+        // validate requested fields
+        const unknown = selectFieldNames.filter((f: string) => !typeInfo.fields[f]);
+        if (unknown.length) {
+            return {
+                select: undefined,
+                error: this.makeError('invalidValue', `unknown field(s) for ${type}: ${unknown.join(', ')}`),
+            };
+        }
 
         const fields = selectFieldNames.reduce((acc, curr) => ({ ...acc, [curr]: true }), {});
 
         return {
             select: { ...this.makeIdSelect(typeInfo.idFields), ...fields },
         };
     }

If you’d like, I can add tests for:

  • fields[mappedName] acceptance
  • invalid field name → 400 invalid-value with bad field list

1861-1919: buildRelationSelect: preserve existing branch shape when multiple include paths meet.
When assembling multiple paths, if a relation key already exists with select/include, reusing that container avoids overwriting earlier work.

Apply:

                 for (let i = 0; i < parts.length; i++) {
                     const relation = parts[i];
                     const relationInfo = currType.relationships[relation];
@@
-                    // handle partial results for requested type
+                    // handle partial results for requested type
                     const { select, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query);
                     if (error) return { select: undefined, error };
 
-                    if (i !== parts.length - 1) {
-                        if (select) {
-                            currPayload[relation] = { select: { ...select } };
-                            currPayload = currPayload[relation].select;
-                        } else {
-                            currPayload[relation] = { include: { ...currPayload[relation]?.include } };
-                            currPayload = currPayload[relation].include;
-                        }
-                    } else {
+                    if (i !== parts.length - 1) {
+                        // if branch exists, descend into it
+                        const existing = currPayload[relation];
+                        if (existing?.select) {
+                            currPayload = existing.select;
+                        } else if (existing?.include) {
+                            currPayload = existing.include;
+                        } else if (select) {
+                            currPayload[relation] = { select: { ...select } };
+                            currPayload = currPayload[relation].select;
+                        } else {
+                            currPayload[relation] = { include: { ...(existing?.include ?? {}) } };
+                            currPayload = currPayload[relation].include;
+                        }
+                    } else {
                         currPayload[relation] = select
                             ? {
                                   select: { ...select },
                               }
                             : true;
                     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f92fea4 and e042bd7.

📒 Files selected for processing (2)
  • packages/server/src/api/rest/index.ts (12 hunks)
  • packages/server/tests/api/rest-partial.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server/tests/api/rest-partial.test.ts
🧰 Additional context used
🧬 Code graph analysis (1)
packages/server/src/api/rest/index.ts (1)
packages/runtime/src/local-helpers/lower-case-first.ts (1)
  • lowerCaseFirst (1-3)
🔇 Additional comments (4)
packages/server/src/api/rest/index.ts (4)

180-183: Good: explicit error for duplicated fields parameter.
Clear, typed entry; paramCase yields a sensible code.


519-527: Top‑level sparse select wiring looks correct.
Passing query into buildRelationSelect and lifting include→select when fields[...] is present is consistent with Prisma and JSON:API expectations. No blockers.

Please confirm you have a test asserting select with nested include at leaf level works (select: { posts: { include: { author: true } } }). If missing, I can add one.

Also applies to: 529-541


577-586: Defensive default for related selection to avoid shape mismatches (and align with prior feedback).
When no fields[...] is provided and there’s no include, select[relationship] becomes boolean true. Later augmentation assumes an object. Prefer always using an object and provide a sensible default (IDs + scalar fields).

Apply:

-        // handle partial results for requested type
-        if (!select) {
-            const { select: partialFields, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query);
-            if (error) return error;
-
-            select = partialFields ? { [relationship]: { select: { ...partialFields } } } : { [relationship]: true };
-        }
+        // handle partial results for requested type
+        if (!select) {
+            const relatedType = lowerCaseFirst(relationInfo.type);
+            const { select: partialFields, error } = this.buildPartialSelect(relatedType, query);
+            if (error) return error;
+            if (partialFields) {
+                select = { [relationship]: { select: { ...partialFields } } };
+            } else {
+                const relatedTypeInfo = this.typeMap[relatedType];
+                const scalarSelect = Object.fromEntries(
+                    Object.entries(relatedTypeInfo.fields)
+                        .filter(([, f]) => !f.isDataModel)
+                        .map(([name]) => [name, true])
+                );
+                select = {
+                    [relationship]: {
+                        select: {
+                            ...this.makeIdSelect(relationInfo.idFields),
+                            ...scalarSelect,
+                        },
+                    },
+                };
+            }
+        }

Also applies to: 588-595


739-747: Collection read: sparse fieldsets + pagination integration LGTM.

  • include→select coercion when fields[...] present is sound.
  • Promise.all for data+count is good.

Also applies to: 749-761, 782-785

Copy link
Member

@ymc9 ymc9 left a comment

Choose a reason for hiding this comment

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

Awesome! It looks great now and I'll merge and make a patch release shortly. Thanks @sw34 !

@ymc9 ymc9 merged commit 40a48c9 into zenstackhq:dev Sep 30, 2025
12 checks passed
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.

2 participants