Repository: xataio/client-ts
Pull request #66 (2022-03-24 08:38:50)

Implement PATCH operations in Update
Closes: #26 


Patch:
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index f089b3a2..660e84a9 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -489,7 +489,7 @@ class RecordsApi {
     recordId: Schemas.RecordID,
     record: Record<string, any>,
     options: Types.InsertRecordWithIDQueryParams = {}
-  ): Promise<Types.InsertRecordWithIDResponse> {
+  ): Promise<Responses.RecordUpdateResponse> {
     return operationsByTag.records.insertRecordWithID({
       pathParams: { workspace, dbBranchName: `${database}:${branch}`, tableName, recordId },
       queryParams: options,
@@ -498,6 +498,40 @@ class RecordsApi {
     });
   }
 
+  public updateRecordWithID(
+    workspace: Schemas.WorkspaceID,
+    database: Schemas.DBName,
+    branch: Schemas.BranchName,
+    tableName: Schemas.TableName,
+    recordId: Schemas.RecordID,
+    record: Record<string, any>,
+    options: Types.UpdateRecordWithIDQueryParams = {}
+  ): Promise<Responses.RecordUpdateResponse> {
+    return operationsByTag.records.updateRecordWithID({
+      pathParams: { workspace, dbBranchName: `${database}:${branch}`, tableName, recordId },
+      queryParams: options,
+      body: record,
+      ...this.extraProps
+    });
+  }
+
+  public upsertRecordWithID(
+    workspace: Schemas.WorkspaceID,
+    database: Schemas.DBName,
+    branch: Schemas.BranchName,
+    tableName: Schemas.TableName,
+    recordId: Schemas.RecordID,
+    record: Record<string, any>,
+    options: Types.UpsertRecordWithIDQueryParams = {}
+  ): Promise<Responses.RecordUpdateResponse> {
+    return operationsByTag.records.upsertRecordWithID({
+      pathParams: { workspace, dbBranchName: `${database}:${branch}`, tableName, recordId },
+      queryParams: options,
+      body: record,
+      ...this.extraProps
+    });
+  }
+
   public deleteRecord(
     workspace: Schemas.WorkspaceID,
     database: Schemas.DBName,
@@ -554,4 +588,17 @@ class RecordsApi {
       ...this.extraProps
     });
   }
+
+  public searchBranch(
+    workspace: Schemas.WorkspaceID,
+    database: Schemas.DBName,
+    branch: Schemas.BranchName,
+    query: Types.SearchBranchRequestBody
+  ): Promise<Responses.SearchResponse> {
+    return operationsByTag.records.searchBranch({
+      pathParams: { workspace, dbBranchName: `${database}:${branch}` },
+      body: query,
+      ...this.extraProps
+    });
+  }
 }
diff --git a/client/src/api/components.ts b/client/src/api/components.ts
index 6dd78916..c10a73b1 100644
--- a/client/src/api/components.ts
+++ b/client/src/api/components.ts
@@ -293,92 +293,13 @@ export const inviteWorkspaceMember = (variables: InviteWorkspaceMemberVariables)
     ...variables
   });
 
-export type UpdateWorkspaceMemberInvitePathParams = {
-  /*
-   * Workspace name
-   */
-  workspaceId: Schemas.WorkspaceID;
-  /*
-   * Invite identifier
-   */
-  inviteId: Schemas.InviteID;
-};
-
-export type UpdateWorkspaceMemberInviteRequestBody = {
-  role: Schemas.Role;
-};
-
-export type UpdateWorkspaceMemberInviteVariables = {
-  body: UpdateWorkspaceMemberInviteRequestBody;
-  pathParams: UpdateWorkspaceMemberInvitePathParams;
-} & FetcherExtraProps;
-
-/**
- * This operation provides a way to update an invite.
- * The role can be updated while the email cannot.
- */
-export const updateWorkspaceMemberInvite = (variables: UpdateWorkspaceMemberInviteVariables) =>
-  fetch<Schemas.WorkspaceInvite, UpdateWorkspaceMemberInviteRequestBody, {}, {}, UpdateWorkspaceMemberInvitePathParams>(
-    { url: '/workspaces/{workspaceId}/invites/{inviteId}', method: 'patch', ...variables }
-  );
-
-export type CancelWorkspaceMemberInvitePathParams = {
-  /*
-   * Workspace name
-   */
-  workspaceId: Schemas.WorkspaceID;
-  /*
-   * Invite identifier
-   */
-  inviteId: Schemas.InviteID;
-};
-
-export type CancelWorkspaceMemberInviteVariables = {
-  pathParams: CancelWorkspaceMemberInvitePathParams;
-} & FetcherExtraProps;
-
-/**
- * This operation provides a way to cancel invites by deleting them. Already accepted invites cannot be deleted.
- */
-export const cancelWorkspaceMemberInvite = (variables: CancelWorkspaceMemberInviteVariables) =>
-  fetch<undefined, undefined, {}, {}, CancelWorkspaceMemberInvitePathParams>({
-    url: '/workspaces/{workspaceId}/invites/{inviteId}',
-    method: 'delete',
-    ...variables
-  });
-
-export type ResendWorkspaceMemberInvitePathParams = {
-  /*
-   * Workspace name
-   */
-  workspaceId: Schemas.WorkspaceID;
-  /*
-   * Invite identifier
-   */
-  inviteId: Schemas.InviteID;
-};
-
-export type ResendWorkspaceMemberInviteVariables = {
-  pathParams: ResendWorkspaceMemberInvitePathParams;
-} & FetcherExtraProps;
-
-/**
- * This operation provides a way to resend an Invite notification. Invite notifications can only be sent for Invites not yet accepted.
- */
-export const resendWorkspaceMemberInvite = (variables: ResendWorkspaceMemberInviteVariables) =>
-  fetch<undefined, undefined, {}, {}, ResendWorkspaceMemberInvitePathParams>({
-    url: '/workspaces/{workspaceId}/invites/{inviteId}/resend',
-    method: 'post',
-    ...variables
-  });
-
 export type AcceptWorkspaceMemberInvitePathParams = {
   /*
    * Workspace name
    */
   workspaceId: Schemas.WorkspaceID;
   /*
-   * Invite Key (secret) for the invited user
+   * Invite ID
    */
   inviteKey: Schemas.InviteKey;
 };
@@ -1104,13 +1025,6 @@ export type InsertRecordWithIDQueryParams = {
   ifVersion?: number;
 };
 
-export type InsertRecordWithIDResponse = {
-  id: string;
-  xata: {
-    version: number;
-  };
-};
-
 export type InsertRecordWithIDVariables = {
   body?: Record<string, any>;
   pathParams: InsertRecordWithIDPathParams;
@@ -1122,13 +1036,83 @@ export type InsertRecordWithIDVariables = {
  */
 export const insertRecordWithID = (variables: InsertRecordWithIDVariables) =>
   fetch<
-    InsertRecordWithIDResponse,
+    Responses.RecordUpdateResponse,
     Record<string, any>,
     {},
     InsertRecordWithIDQueryParams,
     InsertRecordWithIDPathParams
   >({ url: '/db/{dbBranchName}/tables/{tableName}/data/{recordId}', method: 'put', ...variables });
 
+export type UpdateRecordWithIDPathParams = {
+  /*
+   * The DBBranchName matches the pattern `{db_name}:{branch_name}`.
+   */
+  dbBranchName: Schemas.DBBranchName;
+  /*
+   * The Table name
+   */
+  tableName: Schemas.TableName;
+  /*
+   * The Record name
+   */
+  recordId: Schemas.RecordID;
+  workspace: string;
+};
+
+export type UpdateRecordWithIDQueryParams = {
+  ifVersion?: number;
+};
+
+export type UpdateRecordWithIDVariables = {
+  body?: Record<string, any>;
+  pathParams: UpdateRecordWithIDPathParams;
+  queryParams?: UpdateRecordWithIDQueryParams;
+} & FetcherExtraProps;
+
+export const updateRecordWithID = (variables: UpdateRecordWithIDVariables) =>
+  fetch<
+    Responses.RecordUpdateResponse,
+    Record<string, any>,
+    {},
+    UpdateRecordWithIDQueryParams,
+    UpdateRecordWithIDPathParams
+  >({ url: '/db/{dbBranchName}/tables/{tableName}/data/{recordId}', method: 'patch', ...variables });
+
+export type UpsertRecordWithIDPathParams = {
+  /*
+   * The DBBranchName matches the pattern `{db_name}:{branch_name}`.
+   */
+  dbBranchName: Schemas.DBBranchName;
+  /*
+   * The Table name
+   */
+  tableName: Schemas.TableName;
+  /*
+   * The Record name
+   */
+  recordId: Schemas.RecordID;
+  workspace: string;
+};
+
+export type UpsertRecordWithIDQueryParams = {
+  ifVersion?: number;
+};
+
+export type UpsertRecordWithIDVariables = {
+  body?: Record<string, any>;
+  pathParams: UpsertRecordWithIDPathParams;
+  queryParams?: UpsertRecordWithIDQueryParams;
+} & FetcherExtraProps;
+
+export const upsertRecordWithID = (variables: UpsertRecordWithIDVariables) =>
+  fetch<
+    Responses.RecordUpdateResponse,
+    Record<string, any>,
+    {},
+    UpsertRecordWithIDQueryParams,
+    UpsertRecordWithIDPathParams
+  >({ url: '/db/{dbBranchName}/tables/{tableName}/data/{recordId}', method: 'post', ...variables });
+
 export type DeleteRecordPathParams = {
   /*
    * The DBBranchName matches the pattern `{db_name}:{branch_name}`.
@@ -1973,7 +1957,7 @@ export type SearchBranchPathParams = {
 
 export type SearchBranchRequestBody = {
   /*
-   * An array with the tables in which to search. By default, all tables are searched.
+   * An array with the tables in which to search. By default, all tables are included.
    */
   tables?: string[];
   /*
@@ -1982,6 +1966,17 @@ export type SearchBranchRequestBody = {
    * @minLength 1
    */
   query: string;
+  /*
+   * Maximum [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) for the search terms. The Levenshtein
+   * distance is the number of one charcter changes needed to make two strings equal. The default is 1, meaning that single
+   * character typos per word are tollerated by search. You can set it to 0 to remove the typo tollerance or set it to 2
+   * to allow two typos in a word.
+   *
+   * @default 1
+   * @maximum 2
+   * @minimum 0
+   */
+  fuzziness?: number;
 };
 
 export type SearchBranchVariables = {
@@ -1990,7 +1985,7 @@ export type SearchBranchVariables = {
 } & FetcherExtraProps;
 
 /**
- * Run a free text search operation across the Database.
+ * Run a free text search operation across the database branch.
  */
 export const searchBranch = (variables: SearchBranchVariables) =>
   fetch<Responses.SearchResponse, SearchBranchRequestBody, {}, {}, SearchBranchPathParams>({
@@ -2011,9 +2006,6 @@ export const operationsByTag = {
     updateWorkspaceMemberRole,
     removeWorkspaceMember,
     inviteWorkspaceMember,
-    updateWorkspaceMemberInvite,
-    cancelWorkspaceMemberInvite,
-    resendWorkspaceMemberInvite,
     acceptWorkspaceMemberInvite
   },
   database: { getDatabaseList, createDatabase, deleteDatabase },
@@ -2044,6 +2036,8 @@ export const operationsByTag = {
   records: {
     insertRecord,
     insertRecordWithID,
+    updateRecordWithID,
+    upsertRecordWithID,
     deleteRecord,
     getRecord,
     bulkInsertTableRecords,
diff --git a/client/src/api/parameters.ts b/client/src/api/parameters.ts
index 24e4944c..1b83a432 100644
--- a/client/src/api/parameters.ts
+++ b/client/src/api/parameters.ts
@@ -7,8 +7,6 @@ import type * as Schemas from './schemas';
 
 export type APIKeyNameParam = Schemas.APIKeyName;
 
-export type InviteIDParam = Schemas.InviteID;
-
 export type InviteKeyParam = Schemas.InviteKey;
 
 export type UserIDParam = Schemas.UserID;
diff --git a/client/src/api/responses.ts b/client/src/api/responses.ts
index 5e3ed175..694073e6 100644
--- a/client/src/api/responses.ts
+++ b/client/src/api/responses.ts
@@ -28,6 +28,13 @@ export type BranchMigrationPlan = {
   migration: Schemas.BranchMigration;
 };
 
+export type RecordUpdateResponse = {
+  id: string;
+  xata: {
+    version: number;
+  };
+};
+
 export type QueryResponse = {
   records: Schemas.XataRecord[];
   meta: Schemas.RecordsMetadata;
diff --git a/client/src/api/schemas.ts b/client/src/api/schemas.ts
index 42bcc747..a598f85d 100644
--- a/client/src/api/schemas.ts
+++ b/client/src/api/schemas.ts
@@ -191,7 +191,7 @@ export type Table = {
  */
 export type Column = {
   name: string;
-  type: 'bool' | 'int' | 'float' | 'string' | 'text' | 'email' | 'multiple' | 'link' | 'object';
+  type: 'bool' | 'int' | 'string' | 'text' | 'email' | 'multiple' | 'link' | 'object';
   link?: {
     table: string;
   };
diff --git a/client/src/index.ts b/client/src/index.ts
index ee1e6c95..8aa12db9 100644
--- a/client/src/index.ts
+++ b/client/src/index.ts
@@ -1,4 +1,13 @@
-import { bulkInsertTableRecords, deleteRecord, getRecord, insertRecord, insertRecordWithID, queryTable } from './api';
+import {
+  bulkInsertTableRecords,
+  deleteRecord,
+  getRecord,
+  insertRecord,
+  insertRecordWithID,
+  queryTable,
+  updateRecordWithID,
+  upsertRecordWithID
+} from './api';
 import { FetcherExtraProps, FetchImpl } from './api/fetcher';
 import { buildSortFilter } from './schema/filters';
 import { Page } from './schema/pagination';
@@ -10,8 +19,9 @@ export interface XataRecord {
   xata: {
     version: number;
   };
+
   read(): Promise<this>;
-  update(data: Selectable<this>): Promise<this>;
+  update(data: Partial<Selectable<this>>): Promise<this>;
   delete(): Promise<void>;
 }
 
@@ -22,7 +32,9 @@ export abstract class Repository<T extends XataRecord> extends Query<T> {
 
   abstract read(id: string): Promise<T | null>;
 
-  abstract update(id: string, object: Partial<T>): Promise<T>;
+  abstract update(id: string, object: Partial<Selectable<T>>): Promise<T>;
+
+  abstract upsert(id: string, object: Selectable<T>): Promise<T>;
 
   abstract delete(id: string): void;
 
@@ -71,16 +83,31 @@ export class RestRepository<T extends XataRecord> extends Repository<T> {
     };
   }
 
-  async create(object: T): Promise<T> {
+  async create(object: Selectable<T>): Promise<T> {
     const fetchProps = await this.#getFetchProps();
 
     const record = transformObjectLinks(object);
 
-    const response = await insertRecord({
-      pathParams: { workspace: '{workspaceId}', dbBranchName: '{dbBranch}', tableName: this.#table },
-      body: record,
-      ...fetchProps
-    });
+    const response = object.id
+      ? await insertRecordWithID({
+          pathParams: {
+            workspace: '{workspaceId}',
+            dbBranchName: '{dbBranch}',
+            tableName: this.#table,
+            recordId: object.id
+          },
+          body: record,
+          ...fetchProps
+        })
+      : await insertRecord({
+          pathParams: {
+            workspace: '{workspaceId}',
+            dbBranchName: '{dbBranch}',
+            tableName: this.#table
+          },
+          body: record,
+          ...fetchProps
+        });
 
     const finalObject = await this.read(response.id);
     if (!finalObject) {
@@ -121,17 +148,34 @@ export class RestRepository<T extends XataRecord> extends Repository<T> {
     return this.#client.initObject(this.#table, response);
   }
 
-  async update(recordId: string, object: Partial<T>): Promise<T> {
+  async update(recordId: string, object: Partial<Selectable<T>>): Promise<T> {
     const fetchProps = await this.#getFetchProps();
 
-    const response = await insertRecordWithID({
+    const response = await updateRecordWithID({
       pathParams: { workspace: '{workspaceId}', dbBranchName: '{dbBranch}', tableName: this.#table, recordId },
       body: object,
       ...fetchProps
     });
 
-    // TODO: Review this, not sure we are properly initializing the object
-    return this.#client.initObject(this.#table, response);
+    const item = await this.read(response.id);
+    if (!item) throw new Error('The server failed to save the record');
+
+    return item;
+  }
+
+  async upsert(recordId: string, object: Selectable<T>): Promise<T> {
+    const fetchProps = await this.#getFetchProps();
+
+    const response = await upsertRecordWithID({
+      pathParams: { workspace: '{workspaceId}', dbBranchName: '{dbBranch}', tableName: this.#table, recordId },
+      body: object,
+      ...fetchProps
+    });
+
+    const item = await this.read(response.id);
+    if (!item) throw new Error('The server failed to save the record');
+
+    return item;
   }
 
   async delete(recordId: string) {
diff --git a/client/src/schema/query.ts b/client/src/schema/query.ts
index 68a7ad63..d9a3ecab 100644
--- a/client/src/schema/query.ts
+++ b/client/src/schema/query.ts
@@ -5,7 +5,7 @@ import { Constraint, DeepConstraint, FilterConstraints, SortDirection, SortFilte
 import { PaginationOptions, Page, Paginable, PaginationQueryMeta } from './pagination';
 import { Selectable, SelectableColumn, Select } from './selection';
 
-export type QueryOptions<T> = {
+export type QueryOptions<T extends XataRecord> = {
   page?: PaginationOptions;
   columns?: Extract<keyof Selectable<T>, string>[];
   //filter?: FilterConstraints<T>;
diff --git a/client/src/schema/selection.ts b/client/src/schema/selection.ts
index 6a8a3d1c..c1b90f2e 100644
--- a/client/src/schema/selection.ts
+++ b/client/src/schema/selection.ts
@@ -1,4 +1,5 @@
 import { XataRecord } from '..';
+import { PartialBy } from '../util/lang';
 import { StringKeys, UnionToIntersection, Values } from '../util/types';
 import { Query } from './query';
 
@@ -19,7 +20,8 @@ type OmitMethods<T> = {
   [key in keyof T as T[key] extends Function ? never : key]: T[key];
 };
 
-export type Selectable<T> = Omit<OmitQueries<OmitMethods<T>>, 'id' | 'xata'>;
+type InternalProperties = keyof Omit<XataRecord, 'id'>;
+export type Selectable<T extends XataRecord> = Omit<PartialBy<T, 'id'>, InternalProperties>;
 
 export type SelectableColumn<O> =
   | '*'
diff --git a/client/src/util/lang.ts b/client/src/util/lang.ts
index b97f5e17..30fe5755 100644
--- a/client/src/util/lang.ts
+++ b/client/src/util/lang.ts
@@ -5,3 +5,5 @@ function notEmpty<T>(value: T | null | undefined): value is T {
 export function compact<T>(arr: Array<T | null | undefined>): T[] {
   return arr.filter(notEmpty);
 }
+
+export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;


Test patch:
diff --git a/client/src/index.test.ts b/client/src/index.test.ts
index 10e91385..de62d40b 100644
--- a/client/src/index.test.ts
+++ b/client/src/index.test.ts
@@ -263,7 +263,7 @@ describe('request', () => {
 type ExpectedRequest = {
   method: string;
   path: string;
-  body: unknown;
+  body?: unknown;
 };
 
 async function expectRequest(
@@ -443,11 +443,14 @@ describe('read', () => {
 });
 
 describe('Repository.update', () => {
-  test('updates and object successfully', async () => {
+  test('updates an object successfully', async () => {
     const { fetch, users } = buildClient();
 
     const object = { id: 'rec_1234', xata: { version: 1 }, name: 'Ada' } as User;
-    const expected = { method: 'PUT', path: `/tables/users/data/${object.id}`, body: object };
+    const expected = [
+      { method: 'PUT', path: `/tables/users/data/${object.id}`, body: object },
+      { method: 'GET', path: `/tables/users/data/${object.id}` }
+    ];
     const result = await expectRequest(
       fetch,
       expected,
@@ -469,7 +472,19 @@ describe('Repository.update', () => {
               "Content-Type": "application/json",
               "Host": "my-workspace-5df34do.staging.xatabase.co",
             },
-            "method": "PUT",
+            "method": "PATCH",
+          },
+        ],
+        Array [
+          "https://my-workspace-5df34do.staging.xatabase.co/db/xata:main/tables/users/data/rec_1234",
+          Object {
+            "body": undefined,
+            "headers": Object {
+              "Authorization": "Bearer 1234",
+              "Content-Type": "application/json",
+              "Host": "my-workspace-5df34do.staging.xatabase.co",
+            },
+            "method": "GET",
           },
         ],
       ]
diff --git a/test/integration.test.ts b/test/integration.test.ts
index 654e7a8f..5d224024 100644
--- a/test/integration.test.ts
+++ b/test/integration.test.ts
@@ -345,4 +345,83 @@ describe('integration tests', () => {
     //@ts-expect-error
     expect(user?.address).toBeUndefined();
   });
+
+  test('Partial update of a user', async () => {
+    const user = await client.db.users.create({
+      full_name: 'John Doe',
+      email: 'john@doe.com',
+      address: {
+        street: '123 Main St'
+      }
+    });
+
+    const updatedUserResponse = await client.db.users.update(user.id, {
+      address: { street: 'New street', zipcode: 11 }
+    });
+
+    const updatedUser = await client.db.users.filter({ id: user.id }).getOne();
+    if (!updatedUser) throw new Error('No user found');
+
+    await user.delete();
+
+    expect(user.id).toBe(updatedUser.id);
+    expect(user.address?.street).toBe('123 Main St');
+    expect(user.address?.zipcode).toBeUndefined();
+
+    expect(updatedUserResponse.address?.street).toBe('New street');
+    expect(updatedUserResponse.address?.zipcode).toBe(11);
+    expect(updatedUserResponse.full_name).toBe(user.full_name);
+
+    expect(updatedUser.address?.street).toBe('New street');
+    expect(updatedUser.address?.zipcode).toBe(11);
+    expect(updatedUser.full_name).toBe(user.full_name);
+  });
+
+  test('Partial update from itself', async () => {
+    const user = await client.db.users.create({
+      full_name: 'John Doe 2',
+      email: 'john2@doe.com',
+      address: {
+        street: '456 Main St'
+      }
+    });
+
+    const updatedUserResponse = await user.update({
+      address: { street: 'New street 2', zipcode: 22 }
+    });
+
+    const updatedUser = await client.db.users.filter({ id: user.id }).getOne();
+    if (!updatedUser) throw new Error('No user found');
+
+    await user.delete();
+
+    expect(user.address?.street).not.toBe('New street 2');
+
+    expect(updatedUserResponse.address?.street).toBe('New street 2');
+    expect(updatedUserResponse.address?.zipcode).toBe(22);
+    expect(updatedUserResponse.full_name).toBe(user.full_name);
+
+    expect(updatedUser.address?.street).toBe('New street 2');
+    expect(updatedUser.address?.zipcode).toBe(22);
+    expect(updatedUser.full_name).toBe(user.full_name);
+  });
+
+  test('Upsert of a user', async () => {
+    const user = await client.db.users.upsert('my-good-old-john', {
+      full_name: 'John Doe 3',
+      email: 'john3@doe.com'
+    });
+
+    const apiUser = await client.db.users.filter({ id: user.id }).getOne();
+    if (!apiUser) throw new Error('No user found');
+
+    await user.delete();
+
+    expect(user.id).toBe('my-good-old-john');
+    expect(user.full_name).toBe('John Doe 3');
+
+    expect(user.id).toBe(apiUser.id);
+    expect(user.full_name).toBe(apiUser.full_name);
+    expect(user.email).toBe(apiUser.email);
+  });
 });