Repository: xataio/client-ts
Pull request #57 (2022-03-21 13:05:45)

Allow selecting/sorting columns
Internal types are still a work in progress that needs to be cleansed. I will probably split a bit the code into smaller files and utility types (and document how/why inference works).

The following now just works and is type-safe:

![image](https://user-images.githubusercontent.com/2181866/159297186-d2753539-a207-4866-b605-ca15e9cf4c9d.png)

I still need to review ``links`` in general (they won't probably work yet)


Patch:
diff --git a/client/src/index.ts b/client/src/index.ts
index a42601e4..1ff193fe 100644
--- a/client/src/index.ts
+++ b/client/src/index.ts
@@ -11,11 +11,11 @@ export interface XataRecord {
 }
 
 export type Queries<T> = {
-  [key in keyof T as T[key] extends Query<infer A, infer B> ? key : never]: T[key];
+  [key in keyof T as T[key] extends Query<any> ? key : never]: T[key];
 };
 
 export type OmitQueries<T> = {
-  [key in keyof T as T[key] extends Query<infer A, infer B> ? never : key]: T[key];
+  [key in keyof T as T[key] extends Query<any> ? never : key]: T[key];
 };
 
 export type OmitLinks<T> = {
@@ -29,13 +29,38 @@ export type OmitMethods<T> = {
 
 export type Selectable<T> = Omit<OmitQueries<OmitMethods<T>>, 'id' | 'xata'>;
 
-export type Select<T, K extends keyof T> = Pick<T, K> & Queries<T> & XataRecord;
+type StringKeys<O> = Extract<keyof O, string>;
+type Values<O> = O[keyof O];
+
+export type SelectableColumn<O> =
+  | '*'
+  | (O extends Array<unknown>
+      ? never // TODO: Review with multiple: true
+      : O extends Record<string, any>
+      ?
+          | '*'
+          | Values<{
+              [K in StringKeys<O>]: O[K] extends Record<string, any> ? `${K}.${SelectableColumn<O[K]>}` : K;
+            }>
+      : '');
+
+type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
+
+export type Select<T, K extends SelectableColumn<T>> = UnionToIntersection<K extends keyof T ? Pick<T, K> : T> &
+  Queries<T> &
+  XataRecord;
 
 export type Include<T> = {
   [key in keyof T as T[key] extends XataRecord ? key : never]?: boolean | Array<keyof Selectable<T[key]>>;
 };
 
 type SortDirection = 'asc' | 'desc';
+type SortFilterExtended<T> = {
+  column: keyof T;
+  direction?: SortDirection;
+};
+
+type SortFilter<T> = SortFilterExtended<T> | keyof T;
 
 type Operator =
   | '$gt'
@@ -98,22 +123,16 @@ type PaginationOptions = CursorNavigationOptions & OffsetNavigationOptions;
 
 type BulkQueryOptions<T> = {
   page?: PaginationOptions;
-  /** TODO: Not implemented yet
-  filter?: FilterConstraints<T>;
-  sort?:
-    | {
-        column: keyof T;
-        direction?: SortDirection;
-      }
-    | keyof T;
-**/
+  columns?: Array<keyof Selectable<T>>;
+  //filter?: FilterConstraints<T>;
+  sort?: SortFilter<T> | SortFilter<T>[];
 };
 
-type QueryOrConstraint<T, R> = Query<T, R> | Constraint<T>;
+type QueryOrConstraint<T extends XataRecord, R extends XataRecord> = Query<T, R> | Constraint<T>;
 
 type QueryMeta = { page: { cursor: string; more: boolean } };
 
-interface BasePage<T, R> {
+interface BasePage<T extends XataRecord, R extends XataRecord> {
   query: Query<T, R>;
   meta: QueryMeta;
   records: R[];
@@ -126,7 +145,7 @@ interface BasePage<T, R> {
   hasNextPage(): boolean;
 }
 
-class Page<T, R> implements BasePage<T, R> {
+class Page<T extends XataRecord, R extends XataRecord> implements BasePage<T, R> {
   readonly query: Query<T, R>;
   readonly meta: QueryMeta;
   readonly records: R[];
@@ -159,7 +178,7 @@ class Page<T, R> implements BasePage<T, R> {
   }
 }
 
-export class Query<T, R = T> implements BasePage<T, R> {
+export class Query<T extends XataRecord, R extends XataRecord = T> implements BasePage<T, R> {
   table: string;
   repository: Repository<T>;
 
@@ -168,6 +187,7 @@ export class Query<T, R = T> implements BasePage<T, R> {
   readonly $not?: QueryOrConstraint<T, R>[];
   readonly $none?: QueryOrConstraint<T, R>[];
   readonly $sort?: Record<string, SortDirection>;
+  readonly columns: SelectableColumn<T>[] = ['*'];
 
   // Cursor pagination
   readonly query: Query<T, R> = this;
@@ -293,7 +313,11 @@ export class Query<T, R = T> implements BasePage<T, R> {
     return q;
   }
 
-  async getPaginated(options?: BulkQueryOptions<T>): Promise<Page<T, R>> {
+  async getPaginated<Options extends BulkQueryOptions<T>>(
+    options: Options = {} as Options
+  ): Promise<
+    Page<T, typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R>
+  > {
     return this.repository._runQuery(this, options);
   }
 
@@ -316,12 +340,20 @@ export class Query<T, R = T> implements BasePage<T, R> {
     }
   }
 
-  async getMany(options?: BulkQueryOptions<T>): Promise<R[]> {
+  async getMany<Options extends BulkQueryOptions<T>>(
+    options: Options = {} as Options
+  ): Promise<
+    (typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R)[]
+  > {
     const { records } = await this.getPaginated(options);
     return records;
   }
 
-  async getOne(options: Omit<BulkQueryOptions<T>, 'page'> = {}): Promise<R | null> {
+  async getOne<Options extends Omit<BulkQueryOptions<T>, 'page'>>(
+    options: Options = {} as Options
+  ): Promise<
+    (typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R) | null
+  > {
     const records = await this.getMany({ ...options, page: { size: 1 } });
     return records[0] || null;
   }
@@ -331,11 +363,6 @@ export class Query<T, R = T> implements BasePage<T, R> {
     return 0;
   }
 
-  include(columns: Include<T>) {
-    // TODO
-    return this;
-  }
-
   async nextPage(size?: number, offset?: number): Promise<Page<T, R>> {
     return this.firstPage(size, offset);
   }
@@ -357,9 +384,9 @@ export class Query<T, R = T> implements BasePage<T, R> {
   }
 }
 
-export abstract class Repository<T> extends Query<T, Selectable<T>> {
-  select<K extends keyof Selectable<T>>(...columns: K[]) {
-    return new Query<T, Select<T, K>>(this.repository, this.table, {});
+export abstract class Repository<T extends XataRecord> extends Query<T> {
+  select<K extends SelectableColumn<T>>(columns: K[]) {
+    return new Query<T, Select<T, K>>(this.repository, this.table, { columns });
   }
 
   abstract create(object: Selectable<T>): Promise<T>;
@@ -373,10 +400,15 @@ export abstract class Repository<T> extends Query<T, Selectable<T>> {
   abstract delete(id: string): void;
 
   // Used by the Query object internally
-  abstract _runQuery<R>(query: Query<T, R>, options?: BulkQueryOptions<T>): Promise<Page<T, R>>;
+  abstract _runQuery<R extends XataRecord, Options extends BulkQueryOptions<T>>(
+    query: Query<T, R>,
+    options: Options
+  ): Promise<
+    Page<T, typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R>
+  >;
 }
 
-export class RestRepository<T> extends Repository<T> {
+export class RestRepository<T extends XataRecord> extends Repository<T> {
   client: BaseClient<any>;
   fetch: any;
 
@@ -434,8 +466,8 @@ export class RestRepository<T> extends Repository<T> {
     return resp.json();
   }
 
-  select<K extends keyof T>(...columns: K[]) {
-    return new Query<T, Select<T, K>>(this.repository, this.table, {});
+  select<K extends SelectableColumn<T>>(columns: K[]) {
+    return new Query<T, Select<T, K>>(this.repository, this.table, { columns });
   }
 
   async create(object: T): Promise<T> {
@@ -507,7 +539,12 @@ export class RestRepository<T> extends Repository<T> {
     await this.request('DELETE', `/tables/${this.table}/data/${id}`);
   }
 
-  async _runQuery<R>(query: Query<T, R>, options?: BulkQueryOptions<T>): Promise<Page<T, R>> {
+  async _runQuery<R extends XataRecord, Options extends BulkQueryOptions<T>>(
+    query: Query<T, R>,
+    options: Options
+  ): Promise<
+    Page<T, typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R>
+  > {
     const filter = {
       $any: query.$any,
       $all: query.$all,
@@ -517,8 +554,9 @@ export class RestRepository<T> extends Repository<T> {
 
     const body = {
       filter: Object.values(filter).some(Boolean) ? filter : undefined,
-      sort: query.$sort,
-      page: options?.page
+      sort: buildSortFilter(options?.sort) ?? query.$sort,
+      page: options?.page,
+      columns: options?.columns ?? query.columns
     };
 
     const response = await this.request<{
@@ -530,18 +568,23 @@ export class RestRepository<T> extends Repository<T> {
     }
 
     const { meta, records: objects } = response;
-    const records = objects.map((record) => this.client.initObject<R>(this.table, record));
+    const records = objects.map((record) =>
+      this.client.initObject<
+        typeof options['columns'] extends SelectableColumn<T>[] ? Select<T, typeof options['columns'][number]> : R
+      >(this.table, record)
+    );
 
-    return new Page(query, meta, records);
+    // TODO: We should properly type this any
+    return new Page<T, any>(query, meta, records);
   }
 }
 
 interface RepositoryFactory {
-  createRepository<T>(client: BaseClient<any>, table: string): Repository<T>;
+  createRepository<T extends XataRecord>(client: BaseClient<any>, table: string): Repository<T>;
 }
 
 export class RestRespositoryFactory implements RepositoryFactory {
-  createRepository<T>(client: BaseClient<any>, table: string): Repository<T> {
+  createRepository<T extends XataRecord>(client: BaseClient<any>, table: string): Repository<T> {
     return new RestRepository<T>(client, table);
   }
 }
@@ -666,3 +709,23 @@ const transformObjectLinks = (object: any) => {
     return { ...acc, [key]: value };
   }, {});
 };
+
+function buildSortFilter<T>(filter?: SortFilter<T> | SortFilter<T>[]): { [key: string]: SortDirection } | undefined {
+  if (!filter) return undefined;
+
+  const filters: SortFilter<T>[] = Array.isArray(filter) ? filter : [filter];
+
+  return filters.reduce((acc, item) => {
+    if (typeof item === 'string') {
+      return { ...acc, [item]: 'asc' };
+    } else if (isObjectSortFilter(item)) {
+      return { ...acc, [item.column]: item.direction };
+    } else {
+      return acc;
+    }
+  }, {});
+}
+
+function isObjectSortFilter<T>(filter: SortFilter<T>): filter is SortFilterExtended<T> {
+  return typeof filter === 'object' && filter.column !== undefined;
+}


Test patch:
diff --git a/client/src/index.test.ts b/client/src/index.test.ts
index 2763b00f..5eb0e43c 100644
--- a/client/src/index.test.ts
+++ b/client/src/index.test.ts
@@ -290,7 +290,7 @@ describe('query', () => {
     test('simple query', async () => {
       const { users } = buildClient();
 
-      const expected = { method: 'POST', path: '/tables/users/query', body: {} };
+      const expected = { method: 'POST', path: '/tables/users/query', body: { columns: ['*'] } };
       expectRequest(users, expected, () => users.getMany(), {
         records: [],
         meta: { page: { cursor: '', more: false } }
@@ -300,7 +300,11 @@ describe('query', () => {
     test('query with one filter', async () => {
       const { users } = buildClient();
 
-      const expected = { method: 'POST', path: '/tables/users/query', body: { filter: { $all: [{ name: 'foo' }] } } };
+      const expected = {
+        method: 'POST',
+        path: '/tables/users/query',
+        body: { filter: { $all: [{ name: 'foo' }] }, columns: ['*'] }
+      };
       expectRequest(users, expected, () => users.filter('name', 'foo').getMany(), {
         records: [],
         meta: { page: { cursor: '', more: false } }
@@ -312,14 +316,15 @@ describe('query', () => {
     test('returns a single object', async () => {
       const { users } = buildClient();
 
-      const result = { records: [{ id: '1234' }], meta: { page: { cursor: '', more: false } } };
-      const expected = { method: 'POST', path: '/tables/users/query', body: { page: { size: 1 } } };
+      const result = { records: [{ id: '1234', name: 'Name' }], meta: { page: { cursor: '', more: false } } };
+      const expected = { method: 'POST', path: '/tables/users/query', body: { page: { size: 1 }, columns: ['name'] } };
       expectRequest(
         users,
         expected,
         async () => {
-          const first = await users.select().getOne();
+          const first = await users.select(['name']).getOne();
           expect(first?.id).toBe(result.records[0].id);
+          expect(first?.name).toBe(result.records[0].name);
         },
         result
       );
@@ -329,7 +334,7 @@ describe('query', () => {
       const { users } = buildClient();
 
       const result = { records: [], meta: { page: { cursor: '', more: false } } };
-      const expected = { method: 'POST', path: '/tables/users/query', body: { page: { size: 1 } } };
+      const expected = { method: 'POST', path: '/tables/users/query', body: { page: { size: 1 }, columns: ['*'] } };
       expectRequest(
         users,
         expected,
diff --git a/test/integration.test.ts b/test/integration.test.ts
index 1c12538e..6055b3a5 100644
--- a/test/integration.test.ts
+++ b/test/integration.test.ts
@@ -19,20 +19,20 @@ const client = new XataClient({
 jest.setTimeout(50000);
 
 beforeAll(async () => {
-  const teams = await client.db.teams.select().getMany();
+  const teams = await client.db.teams.getMany();
   for (const team of teams) {
     await team.delete();
   }
 
-  const users = await client.db.users.select().getMany();
+  const users = await client.db.users.getMany();
   for (const user of users) {
     await user.delete();
   }
 
   await client.db.users.createMany(mockUsers);
 
-  const ownerAnimals = await client.db.users.select().filter('full_name', 'Owner of team animals').getOne();
-  const ownerFruits = await client.db.users.select().filter('full_name', 'Owner of team fruits').getOne();
+  const ownerAnimals = await client.db.users.filter('full_name', 'Owner of team animals').getOne();
+  const ownerFruits = await client.db.users.filter('full_name', 'Owner of team fruits').getOne();
   if (!ownerAnimals || !ownerFruits) {
     throw new Error('Could not find owner of team animals or owner of team fruits');
   }
@@ -57,14 +57,14 @@ beforeAll(async () => {
 
 describe('integration tests', () => {
   test('equal filter', async () => {
-    const teams = await client.db.teams.select().filter('name', 'Team fruits').getMany();
+    const teams = await client.db.teams.filter('name', 'Team fruits').getMany();
 
     expect(teams).toHaveLength(1);
     expect(teams[0].name).toBe('Team fruits');
   });
 
   test('operator filter', async () => {
-    const teams = await client.db.teams.select().filter('name', contains('fruits')).getMany();
+    const teams = await client.db.teams.filter('name', contains('fruits')).getMany();
 
     expect(teams).toHaveLength(2);
     expect(teams[0].name).toBe('Team fruits');
@@ -72,7 +72,7 @@ describe('integration tests', () => {
   });
 
   test.skip('operator filter on multiple column', async () => {
-    const teams = await client.db.teams.select().filter('labels', ['banana']).getMany();
+    const teams = await client.db.teams.filter('labels', ['banana']).getMany();
 
     expect(teams).toHaveLength(2);
     expect(teams[0].name).toBe('Mixed team fruits & animals');
@@ -80,18 +80,14 @@ describe('integration tests', () => {
   });
 
   test('multiple filter', async () => {
-    const teams = await client.db.teams
-      .select()
-      .filter('name', contains('fruits'))
-      .filter('name', contains('Mixed'))
-      .getMany();
+    const teams = await client.db.teams.filter('name', contains('fruits')).filter('name', contains('Mixed')).getMany();
 
     expect(teams).toHaveLength(1);
     expect(teams[0].name).toBe('Mixed team fruits & animals');
   });
 
   test('sort ascending', async () => {
-    const teams = await client.db.teams.select().sort('name', 'asc').getMany();
+    const teams = await client.db.teams.sort('name', 'asc').getMany();
 
     expect(teams).toHaveLength(3);
     expect(teams[0].name).toBe('Mixed team fruits & animals');
@@ -100,7 +96,7 @@ describe('integration tests', () => {
   });
 
   test('sort descending', async () => {
-    const teams = await client.db.teams.select().sort('name', 'desc').getMany();
+    const teams = await client.db.teams.sort('name', 'desc').getMany();
 
     expect(teams).toHaveLength(3);
     expect(teams[0].name).toBe('Team fruits');
@@ -109,7 +105,7 @@ describe('integration tests', () => {
   });
 
   test('single filter and sort ascending', async () => {
-    const teams = await client.db.teams.select().filter('name', contains('fruits')).sort('name', 'asc').getMany();
+    const teams = await client.db.teams.filter('name', contains('fruits')).sort('name', 'asc').getMany();
 
     expect(teams).toHaveLength(2);
     expect(teams[0].name).toBe('Mixed team fruits & animals');
@@ -117,16 +113,34 @@ describe('integration tests', () => {
   });
 
   test('single filter and sort descending', async () => {
-    const teams = await client.db.teams.select().filter('name', contains('fruits')).sort('name', 'desc').getMany();
+    const teams = await client.db.teams.filter('name', contains('fruits')).sort('name', 'desc').getMany();
 
     expect(teams).toHaveLength(2);
     expect(teams[0].name).toBe('Team fruits');
     expect(teams[1].name).toBe('Mixed team fruits & animals');
   });
 
+  test('sort ascending in getMany', async () => {
+    const teams = await client.db.teams.getMany({ sort: 'name' });
+
+    expect(teams).toHaveLength(3);
+    expect(teams[0].name).toBe('Mixed team fruits & animals');
+    expect(teams[1].name).toBe('Team animals');
+    expect(teams[2].name).toBe('Team fruits');
+  });
+
+  test('sort descending in getMany', async () => {
+    const teams = await client.db.teams.getMany({ sort: { column: 'name', direction: 'desc' } });
+
+    expect(teams).toHaveLength(3);
+    expect(teams[0].name).toBe('Team fruits');
+    expect(teams[1].name).toBe('Team animals');
+    expect(teams[2].name).toBe('Mixed team fruits & animals');
+  });
+
   // TODO: This was not failing until now
   test.skip('negative filter', async () => {
-    const q = client.db.teams.select();
+    const q = client.db.teams;
     const teams = await q.not(q.filter('name', 'Team fruits')).sort('name', 'asc').getMany();
 
     expect(teams).toHaveLength(2);
@@ -136,7 +150,6 @@ describe('integration tests', () => {
 
   test('filter on object', async () => {
     const users = await client.db.users
-      .select()
       .filter({
         address: {
           zipcode: 100
@@ -150,7 +163,6 @@ describe('integration tests', () => {
 
   test('filter on object with operator', async () => {
     const users = await client.db.users
-      .select()
       .filter({
         address: {
           zipcode: lt(150)
@@ -164,7 +176,6 @@ describe('integration tests', () => {
 
   test('filter on link', async () => {
     const teams = await client.db.teams
-      .select()
       .filter({
         owner: {
           full_name: 'Owner of team fruits'
@@ -253,7 +264,7 @@ describe('integration tests', () => {
     expect(teams[1].id).toBeDefined();
     expect(teams[1].name).toBe('Team planes');
   });
-  
+
   test('query implements iterator', async () => {
     const owners = [];
 
@@ -275,4 +286,27 @@ describe('integration tests', () => {
     expect(owners).toHaveLength(2);
     expect(owners.map((user) => user.full_name).sort()).toEqual(['Owner of team animals', 'Owner of team fruits']);
   });
+
+  test('includes selected columns in query', async () => {
+    const user = await client.db.users.select(['full_name']).getOne();
+
+    expect(user).toBeDefined();
+    expect(user?.id).toBeDefined();
+    expect(user?.full_name).toBeDefined();
+    //@ts-expect-error
+    expect(user?.email).toBeUndefined();
+  });
+
+  test('includes selected columns in getOne', async () => {
+    const user = await client.db.users.getOne({
+      columns: ['full_name', 'email']
+    });
+
+    expect(user).toBeDefined();
+    expect(user?.id).toBeDefined();
+    expect(user?.full_name).toBeDefined();
+    expect(user?.email).toBeDefined();
+    //@ts-expect-error
+    expect(user?.address).toBeUndefined();
+  });
 });