Skip to content

Conversation

@lsmith77
Copy link
Contributor

@lsmith77 lsmith77 commented Jul 17, 2025

this makes it possible to use a natural key even while also using surrogate primary keys

https://www.prisma.io/dataguide/datamodeling/correctness-constraints#the-primary-key

Using the surrogate key might be sensible since it is more compact for foreign keys. Also the tag name might be editable. But inside the API it makes no sense to use the surrogate key.

ie. /tags/1 makes no sense. /tags/blue is the much better developer experience.

In order to make use of this a unique key needs to be defined and referenced in the RESTApiHandler Options:

RESTApiHandler({
      ...
      externalIdMapping: {Post: source__post_id},
    });
  }

ie. in the model

model Post {
    id Int @id @default(autoincrement()) @deny('all', true)
    post_id Int
    source String
    title String?
    @@unique([source, post_id], name: "source__post_id")
}

This will result in a compound id in the API based on the fields in the unique index similar to when using a compound @id.

Note as shown in the above example, if the surrogate key should be hidden in the API response, it is possible to do this via @deny('all', true)

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jul 17, 2025

📝 Walkthrough
## Walkthrough

This change introduces optional support for external ID mapping in the REST API request handler. It adds an optional `externalIdMapping` option, a new private method for ID field resolution that prioritizes external mappings, and updates all internal usages to leverage the new logic, affecting ID extraction and serialization processes.

## Changes

| Cohort / File(s)                                                                                     | Change Summary                                                                                          |
|------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| **REST API Handler: External ID Mapping** <br> `packages/server/src/api/rest/index.ts`               | Adds optional `externalIdMapping` option, introduces private `getIdFields` method with external mapping support, replaces direct global calls with method calls, and normalizes mapping keys. |
| **REST API Handler Tests: External ID Mapping** <br> `packages/server/tests/api/rest.test.ts`        | Adds new test suite verifying REST API behavior with external ID mapping, including compound unique index usage and related entity resolution. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Client
    participant RequestHandler
    participant ModelMeta

    Client->>RequestHandler: API request with model
    RequestHandler->>RequestHandler: getIdFields(modelMeta, model)
    alt External ID mapping exists
        RequestHandler->>ModelMeta: Lookup unique constraints for external key
        ModelMeta-->>RequestHandler: Return ID fields
    else No external ID mapping
        RequestHandler->>Global: getIdFields(modelMeta, model)
        Global-->>RequestHandler: Return ID fields
    end
    RequestHandler-->>Client: Processed response using resolved ID fields

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~15 minutes

Possibly related PRs

Suggested reviewers

  • ymc9

</details>

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!NOTE]
> <details open="true">
> <summary>⚡️ Unit Test Generation is now available in beta!</summary>
> 
> Learn more [here](https://docs.coderabbit.ai/finishing-touches/unit-test-generation), or try it out under "Finishing Touches" below.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f8e152cfc7b8f85a6b0d9ef3e3ea8538df9cb704 and 582a83688c8b1a9b71706edec1de193581e2b8be.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `packages/server/tests/api/rest.test.ts` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* packages/server/tests/api/rest.test.ts

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)</summary>

* GitHub Check: OSSAR-Scan
* GitHub Check: build-test (20.x)
* GitHub Check: build-test (20.x)
* GitHub Check: build-test (20.x)
* GitHub Check: dependency-review
* GitHub Check: Analyze (javascript-typescript)

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings
<details>
<summary>🧪 Generate unit tests</summary>

- [ ] <!-- {"checkboxId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Create PR with unit tests
- [ ] <!-- {"checkboxId": "07f1e7d6-8a8e-4e23-9900-8731c2c87f58", "radioGroupId": "utg-output-choice-group-unknown_comment_id"} -->   Post copyable unit tests in a comment

</details>

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

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.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=zenstackhq/zenstack&utm_content=2188):

> ‼️ **IMPORTANT**
> Auto-reply has been disabled for this repository in the CodeRabbit settings. The CodeRabbit bot will not respond to your replies unless it is explicitly tagged.
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai generate unit tests` to generate unit tests for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->

@lsmith77 lsmith77 changed the title @@externalid support natural keys in the API Jul 17, 2025
@lsmith77 lsmith77 changed the title support natural keys in the API feat: support natural keys in the API Jul 17, 2025
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: 1

🧹 Nitpick comments (2)
packages/schema/src/res/stdlib.zmodel (1)

741-744: Enhance documentation with usage examples and validation guidance.

The new @@externalId attribute definition is syntactically correct, but the documentation could be more comprehensive to help developers understand its intended usage.

Consider expanding the documentation to include:

  • Usage examples showing how it relates to unique constraints
  • Clarification that the name parameter should match an existing unique constraint name
  • Explanation of how this integrates with the runtime behavior
 /**
- * Specifies an external identifier for the model other than a primary key
+ * Specifies an external identifier for the model other than a primary key.
+ * The name parameter should match an existing unique constraint name that will be used
+ * for external API interactions instead of the primary key.
+ * 
+ * @param name: The name of the unique constraint to use as external identifier
+ * 
+ * @example
+ * model Tag {
+ *   id   Int    @id @default(autoincrement())
+ *   name String @unique
+ *   
+ *   @@externalId("name")
+ * }
  */
 attribute @@externalId(_ name: String)
packages/runtime/src/cross/utils.ts (1)

59-61: Consider adding type safety for attribute argument access.

The current implementation assumes a specific structure for the attribute arguments without type checking.

Consider adding type validation:

-        if (externalId && externalId.args[0] !== undefined) {
-            entries = entries.filter((entry) => entry.name === externalId.args[0].value);
+        if (externalId && externalId.args?.[0]?.value && typeof externalId.args[0].value === 'string') {
+            const externalIdName = externalId.args[0].value as string;
+            entries = entries.filter((entry) => entry.name === externalIdName);

This ensures the external ID name is actually a string before using it for filtering.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 037fc2c and 9c860e8.

📒 Files selected for processing (2)
  • packages/runtime/src/cross/utils.ts (1 hunks)
  • packages/schema/src/res/stdlib.zmodel (1 hunks)

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 @lsmith77 ,

I have two main concerns with introducing the @externalId attribute:

  1. A toplevel attribute ideally should have a meaning from data modeling point of view. From Prisma ORM API point view, one can already address an entity either with an id or a unique field, so such a configuration feels redundant.
  2. The getIdFields helper is called in many places in the code base, and there might be places where it's assumed it returns ID fields ... I'm not sure though, just feeling changing its semantic can be quite dangerous.

Do you feel again this can be a configuration just at the rest api handler (and open api) level? Since its effect is just there.

@lsmith77
Copy link
Contributor Author

I can certainly refactor it to be a Handler option like the model name mapping. That being said, the argument you bring forth to some degree also applies to the @@allow/@@deny annotations, that do not really apply to data modelling but rather to the API only.

As for changing the semantics of getIdFields(). That is very hard for me to ascertain the risks involved given that I still have a very limited overview of the codebase.

Your proposal would rather be we do not call this method but rather receive a list of fields from the Handler config, which is then only checked at runtime against the defined fields (in order to map the model attributes to RDBMS field names to handle if the model used @map)?

I guess this would end up duplicating a fair bit of code which comes with its own set of risks. The advantage is of course it should prevent unwanted side-effects and whatever bugs it introduces would be isolated to this feature. If you feel more comfortable with this approach I am happy to implement it.

@lsmith77
Copy link
Contributor Author

FTR I just tried to quickly implement this but it appears that it is not enough to override all calls to getIdFields() in the Rest Handler. In fact just using GET calls, the above changes seem to have no impact at all:

diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts
index 75196b1a..8c5b4461 100644
--- a/packages/server/src/api/rest/index.ts
+++ b/packages/server/src/api/rest/index.ts
@@ -8,6 +8,7 @@ import {
     clone,
     enumerate,
     getIdFields,
+    requireField,
     isPrismaClientKnownRequestError,
 } from '@zenstackhq/runtime';
 import { lowerCaseFirst, upperCaseFirst, paramCase } from '@zenstackhq/runtime/local-helpers';
@@ -52,6 +53,8 @@ export type Options = {
     urlSegmentCharset?: string;
 
     modelNameMapping?: Record<string, string>;
+
+    externalIdMapping: Record<string, string>;
 };
 
 type RelationshipInfo = {
@@ -238,6 +241,7 @@ class RequestHandler extends APIHandlerBase {
     private urlPatternMap: Record<UrlPatterns, UrlPattern>;
     private modelNameMapping: Record<string, string>;
     private reverseModelNameMapping: Record<string, string>;
+    private externalIdMapping: Record<string, string>;
 
     constructor(private readonly options: Options) {
         super();
@@ -251,6 +255,7 @@ class RequestHandler extends APIHandlerBase {
         this.reverseModelNameMapping = Object.fromEntries(
             Object.entries(this.modelNameMapping).map(([k, v]) => [v, k])
         );
+        this.externalIdMapping = options.externalIdMapping ?? {};
         this.urlPatternMap = this.buildUrlPatternMap(segmentCharset);
     }
 
@@ -1166,11 +1171,27 @@ class RequestHandler extends APIHandlerBase {
     }
 
     //#region utilities
+    private getIdFields(modelMeta: ModelMeta, model: string): FieldInfo[] {
+        if (!(model in this.externalIdMapping)) {
+            return getIdFields(modelMeta, model);
+        }
+
+        const metaData = modelMeta.models[lowerCaseFirst(model)] ?? {};
+        const uniqueConstraints = metaData.uniqueConstraints ?? {};
+        const externalIdName = this.externalIdMapping[model];
+        for (const [name, constraint] of Object.entries(uniqueConstraints)) {
+            if (name === externalIdName) {
+                return constraint.fields.map((f) => requireField(modelMeta, model, f));
+            }
+        }
+
+        return [];
+    }
 
     private buildTypeMap(logger: LoggerConfig | undefined, modelMeta: ModelMeta): void {
         this.typeMap = {};
         for (const [model, { fields }] of Object.entries(modelMeta.models)) {
-            const idFields = getIdFields(modelMeta, model);
+            const idFields = this.getIdFields(modelMeta, model);
             if (idFields.length === 0) {
                 logWarning(logger, `Not including model ${model} in the API because it has no ID field`);
                 continue;
@@ -1186,7 +1207,7 @@ class RequestHandler extends APIHandlerBase {
                 if (!fieldInfo.isDataModel) {
                     continue;
                 }
-                const fieldTypeIdFields = getIdFields(modelMeta, fieldInfo.type);
+                const fieldTypeIdFields = this.getIdFields(modelMeta, fieldInfo.type);
                 if (fieldTypeIdFields.length === 0) {
                     logWarning(
                         logger,
@@ -1214,7 +1235,7 @@ class RequestHandler extends APIHandlerBase {
         const linkers: Record<string, Linker<any>> = {};
 
         for (const model of Object.keys(modelMeta.models)) {
-            const ids = getIdFields(modelMeta, model);
+            const ids = this.getIdFields(modelMeta, model);
             const mappedModel = this.mapModelName(model);
 
             if (ids.length < 1) {
@@ -1266,7 +1287,7 @@ class RequestHandler extends APIHandlerBase {
                 if (!fieldSerializer) {
                     continue;
                 }
-                const fieldIds = getIdFields(modelMeta, fieldMeta.type);
+                const fieldIds = this.getIdFields(modelMeta, fieldMeta.type);
                 if (fieldIds.length > 0) {
                     const mappedModel = this.mapModelName(model);
 
@@ -1306,7 +1327,7 @@ class RequestHandler extends APIHandlerBase {
         if (!data) {
             return undefined;
         }
-        const ids = getIdFields(modelMeta, model);
+        const ids = this.getIdFields(modelMeta, model);
         if (ids.length === 0) {
             return undefined;
         } else {

So it seems like further investigation is needed where all getIdFields() needs to be overridden to get the desired change in behavior. Which however then might lead to additional complexity introduced in even more places :-/

@ymc9
Copy link
Member

ymc9 commented Jul 20, 2025

Author

Hi @lsmith77 , sorry I didn't put it clearly in my previous comments. What I meant was @@externalId's effect is only at the HTTP API layer (specifically about the REST flavor API). On the contrary, @@allow and @@deny are built-in features at the data access layer. The enhanced PrismaClient recognizes these rules and uses them to enforce access control, regardless the access originates from hand-coded ORM calls or requests to the automatic HTTP CRUD endpoints.

The rules are effective in manually written backend code:

const db = enhance(prisma, { user: getAuthUser() });
// only posts allowed to be read are returned
const posts = await db.post.findMany();

, and when requested from automatic http api:

// requesting "/api/model/post" will only return readable posts
app.use(
    '/api/model',
    ZenStackMiddleware({
        handler: RestApiHandler(...),
        getPrisma: (request) => enhance(prisma, { user: getAuthUser(request) }),
    })
);

@lsmith77
Copy link
Contributor Author

OK thx for this additional explanation.
I am off this week but will look into this further next week but as my above explanation shows, it will require changes beyond the Rest Handler to get this to work. In ligt of this, are you still open to such a change?

@ymc9
Copy link
Member

ymc9 commented Jul 27, 2025

OK thx for this additional explanation. I am off this week but will look into this further next week but as my above explanation shows, it will require changes beyond the Rest Handler to get this to work. In ligt of this, are you still open to such a change?

Yes, an option in the rest handler sounds good to me.

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: 1

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

1179-1195: Good proof of concept implementation with room for future enhancements.

This implementation successfully demonstrates the external ID concept by leveraging existing unique constraints. As mentioned in the PR objectives, future enhancements could include:

  1. Validation at schema compilation time to ensure @@externalId references an existing unique constraint
  2. Storing external ID metadata in the model to avoid runtime lookups
  3. Adding support for composite external IDs if needed

The current approach is clean and appropriate for validating the concept.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 35d4d70 and ec07a4a.

📒 Files selected for processing (2)
  • packages/runtime/src/cross/utils.ts (1 hunks)
  • packages/server/src/api/rest/index.ts (9 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/runtime/src/cross/utils.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: ymc9
PR: zenstackhq/zenstack#2187
File: packages/server/src/api/rest/index.ts:287-289
Timestamp: 2025-07-16T03:09:04.699Z
Learning: In REST API URL pattern matching with model name mapping, only the `type` parameter (representing model names) should be mapped using `modelNameMapping`. The `relationship` parameter represents field names on models and should not be mapped, as field names are distinct from model names in the API structure.
packages/server/src/api/rest/index.ts (1)

Learnt from: ymc9
PR: #2187
File: packages/server/src/api/rest/index.ts:287-289
Timestamp: 2025-07-16T03:09:04.699Z
Learning: In REST API URL pattern matching with model name mapping, only the type parameter (representing model names) should be mapped using modelNameMapping. The relationship parameter represents field names on models and should not be mapped, as field names are distinct from model names in the API structure.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: build-test (20.x)
  • GitHub Check: build-test (20.x)
  • GitHub Check: dependency-review
  • GitHub Check: OSSAR-Scan
  • GitHub Check: build-test (20.x)
🔇 Additional comments (3)
packages/server/src/api/rest/index.ts (3)

57-57: Good architectural choice for the external ID mapping option.

Making externalIdMapping a required option ensures explicit configuration and follows the same pattern as other mapping options in the handler.


259-262: Consistent normalization of external ID mapping.

The normalization follows the same pattern as modelNameMapping, ensuring consistent handling of model names throughout the handler.


1200-1200: All ID field retrievals correctly updated to use the new method.

The consistent replacement of the global getIdFields function with the instance method ensures that external ID mappings are applied uniformly across all operations including type mapping, serialization, and relationship handling.

Also applies to: 1216-1216, 1244-1244, 1296-1296, 1336-1336

@lsmith77 lsmith77 force-pushed the externalid branch 2 times, most recently from 3d5022e to 20410b9 Compare July 30, 2025 11:20
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

♻️ Duplicate comments (1)
packages/server/src/api/rest/index.ts (1)

1179-1195: Improve error handling and fix potential runtime errors

The implementation has several issues that need to be addressed:

  1. Potential undefined access: The code doesn't handle the case where modelMeta.models[modelLower] is undefined before accessing uniqueConstraints.

  2. Inconsistent model name usage: Using model in error messages and requireField while using modelLower for metadata lookup could cause confusion.

Apply this diff to fix these issues:

 private getIdFields(modelMeta: ModelMeta, model: string): FieldInfo[] {
     const modelLower = lowerCaseFirst(model);
     if (!(modelLower in this.externalIdMapping)) {
         return getIdFields(modelMeta, model);
     }
 
-    const metaData = modelMeta.models[modelLower] ?? {};
+    const metaData = modelMeta.models[modelLower];
+    if (!metaData) {
+        throw new Error(`Model ${modelLower} not found in metadata`);
+    }
     const externalIdName = this.externalIdMapping[modelLower];
     const uniqueConstraints = metaData.uniqueConstraints ?? {};
+    const availableKeys = Object.keys(uniqueConstraints);
     for (const [name, constraint] of Object.entries(uniqueConstraints)) {
         if (name === externalIdName) {
-            return constraint.fields.map((f) => requireField(modelMeta, model, f));
+            return constraint.fields.map((f) => requireField(modelMeta, modelLower, f));
         }
     }
 
-    throw new Error(`Model ${model} does not have unique key ${externalIdName}`);
+    throw new Error(`Model ${modelLower} does not have unique key "${externalIdName}". Available unique keys: ${availableKeys.join(', ') || 'none'}`);
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ec07a4a and 20410b9.

📒 Files selected for processing (1)
  • packages/server/src/api/rest/index.ts (9 hunks)
🧰 Additional context used
🧠 Learnings (1)
packages/server/src/api/rest/index.ts (1)

Learnt from: ymc9
PR: #2187
File: packages/server/src/api/rest/index.ts:287-289
Timestamp: 2025-07-16T03:09:04.699Z
Learning: In REST API URL pattern matching with model name mapping, only the type parameter (representing model names) should be mapped using modelNameMapping. The relationship parameter represents field names on models and should not be mapped, as field names are distinct from model names in the API structure.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: build-test (20.x)
  • GitHub Check: build-test (20.x)
  • GitHub Check: build-test (20.x)
  • GitHub Check: dependency-review
🔇 Additional comments (2)
packages/server/src/api/rest/index.ts (2)

244-244: LGTM!

The initialization correctly handles the optional mapping and normalizes keys consistently with the model name mapping pattern.

Also applies to: 259-263


1200-1200: Consistent application of external ID mapping

All calls to retrieve ID fields have been correctly updated to use the new instance method, ensuring external ID mapping is applied uniformly throughout the handler.

Also applies to: 1216-1216, 1244-1244, 1296-1296, 1336-1336

@lsmith77 lsmith77 force-pushed the externalid branch 2 times, most recently from a682d40 to 3b4a931 Compare July 30, 2025 11:50
@lsmith77
Copy link
Contributor Author

@ymc9 ok got it to work. the issue came from model sometimes not having lowerCaseFirst applied already.

the second commit is not necessary but felt like a sensible optimization but I am happy to revert if you prefer.

@lsmith77
Copy link
Contributor Author

lsmith77 commented Jul 30, 2025

I will now look into updating the openapi plugin so that filter[id] sets the appropriate string type where necessary. Or should I rather just hide this filter?

Actually upon further investigation it appears that right now you just list all the compound id fields as separate filters, so I guess there isn't really anything to do here.

We will likely just add our own logic to remove filter[id] entirely since we do not want people to be able to interact with the surrogate key in our API. But I guess this will remain custom code.

@lsmith77
Copy link
Contributor Author

Hmm we would however like to not include the id in the output. But I cannot use @ignore on the field unless we change all our foreign keys to use the natural keys as well, which we obviously do not want to do.

What would you advise here? How could be prevent exposing id while still being able to use it as a surrogate key in the model?

model Project {
    id                       Int                   @id @default(autoincrement())
    ..
image

@lsmith77
Copy link
Contributor Author

ok found a solution using @deny:

id                       Int                   @id @default(autoincrement()) @deny('all', true)

@lsmith77 lsmith77 requested a review from ymc9 July 31, 2025 10:03
@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 4, 2025

@ymc9 could you have another look if this is now the right direction and what is left to do to get this merged?

@ymc9
Copy link
Member

ymc9 commented Aug 6, 2025

Hi @lsmith77 , I've left a few review comments. Please take a look when you have time. Thanks!

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: 4

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b4a931 and 5a3a17c.

📒 Files selected for processing (2)
  • packages/server/src/api/rest/index.ts (9 hunks)
  • packages/server/tests/api/rest.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server/src/api/rest/index.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: build-test (20.x)
  • GitHub Check: OSSAR-Scan
  • GitHub Check: build-test (20.x)
  • GitHub Check: build-test (20.x)
  • GitHub Check: dependency-review

@ymc9
Copy link
Member

ymc9 commented Aug 7, 2025

Hi @lsmith77 , could you merge from the latest code from the branch? I've fixed some environment issues that were causing test failures.

…T API

this makes it possible to use a natural key even while also using surrogate primary keys
@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 7, 2025

@ymc9 ok looks better now!

3 topics remain:

  1. performance optimization in getIdFields() should be reverted
  2. only unique indexes without a custom name work -> out of scope as its a known limitation
  3. passing an ID that doesn't include the appropriate number of "chunks" causes a 400 and not 404 -> out of scope but should be fixed in another PR

I have taken care of 1) and if you provide a hint for 3) I will work on this in a followup PR.

@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 7, 2025

Ok figured out 3)

First up, it seems like you consider an invalid resource ID to be a 400 anyway, so maybe there is nothing to fix here:

            invalidId: {
                status: 400,
                title: 'Resource ID is invalid',
            },

I see two possible fixes.

  1. Look for undefined for any field in where of the args.

  2. Catch the PrismaClientValidationError exception

diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts
index 0bf02762..c4d2fb64 100644
--- a/packages/server/src/api/rest/index.ts
+++ b/packages/server/src/api/rest/index.ts
@@ -521,16 +521,24 @@ class RequestHandler extends APIHandlerBase {
             include = allIncludes;
         }
 
-        const entity = await prisma[type].findUnique(args);
+        try {
+            const entity = await prisma[type].findUnique(args);
 
-        if (entity) {
+            if (entity) {
+                return {
+                    status: 200,
+                    body: await this.serializeItems(type, entity, { include }),
+                };
+            }
+            // how to only catch PrismaClientValidationError ?
+        } catch (e) {
             return {
-                status: 200,
-                body: await this.serializeItems(type, entity, { include }),
+                status: 404,
+                body: 'notFound due to invalid resource ID',
             };
-        } else {
-            return this.makeError('notFound');
         }
+
+        return this.makeError('notFound');
     }
 
     private async processFetchRelated(

@ymc9
Copy link
Member

ymc9 commented Aug 8, 2025

Resource ID is invalid

I searched for references and it seems the "invalidId" error is never used ... not sure why but maybe the usage was removed with a previous refactor.

If the reason comes from Prisma rejecting an invalid unique filter, I believe the fix is larger, because a real fix should really be systematically validate the entire input object that's passed to Prisma.

I suggest we can get this PR merged and worry about the larger fix when it hurts.

Btw, do you want to work on the documentation for this new feature too? It'd be great if you have time!

@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 8, 2025

Ok great. Yeah I will add a doc PR next once this is merged, ok?

@ymc9
Copy link
Member

ymc9 commented Aug 8, 2025

Ok great. Yeah I will add a doc PR next once this is merged, ok?

Sounds good. Thank you! The related doc is here: https://github.com/zenstackhq/zenstack-docs/blob/main/docs/reference/server-adapters/api-handlers/rest.mdx

I'm merging this PR @lsmith77

@ymc9 ymc9 merged commit 8a2725b into zenstackhq:dev Aug 8, 2025
12 checks passed
@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 8, 2025

I have created #2211

@lsmith77
Copy link
Contributor Author

lsmith77 commented Aug 8, 2025

here is the doc PR zenstackhq/zenstack-docs#480

@lsmith77 lsmith77 deleted the externalid branch August 21, 2025 13:28
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