Repository: xataio/client-ts (ts)

Issue:
Unit test code generation
As far as I can see, there are no tests for the codegen part, which makes it prone to regressions and similar issues. Let's add unit tests to this to ensure predictability. I'm happy to do this myself since we're all otherwise quite busy.

Pull request:
Unit test code generation
# Why we need this

Considering future changes to codegen, this PR fixes #33 and adds unit tests to ensure we don't break it and it behaves as `expect`ed.

Patch:
diff --git a/.gitignore b/.gitignore
index d1d750bc..1af2bbd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,6 @@ yarn-debug.log*
 yarn-error.log*
 lerna-debug.log*
 xata
-schema.json
 
 # Diagnostic reports (https://nodejs.org/api/report.html)
 report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
diff --git a/codegen/src/codegen.ts b/codegen/src/codegen.ts
index 6a1c215c..d5d8fdc3 100644
--- a/codegen/src/codegen.ts
+++ b/codegen/src/codegen.ts
@@ -7,6 +7,13 @@ import { Column, fileSchema, Table } from './schema';
 import prettier from 'prettier';
 import { getExtensionFromLanguage } from './getExtensionFromLanguage';
 
+type GenerateOptions = {
+  schemaFilePath: string;
+  outputFilePath: string;
+  language?: Language;
+  writeFile?: typeof fs.writeFile;
+};
+
 function getTypeName(tableName: string) {
   const snglr = singular(tableName);
   return snglr.substring(0, 1).toUpperCase() + snglr.substring(1);
@@ -93,7 +100,12 @@ function parseSchema(input: string) {
 
 export type Language = 'typescript' | 'javascript' | 'js' | 'ts';
 
-export async function generate(schemaFile: string, output: string, language: Language) {
+export async function generate({
+  outputFilePath: output,
+  schemaFilePath: schemaFile,
+  language = 'ts',
+  writeFile = fs.writeFile
+}: GenerateOptions) {
   const fullOutputPath = path.resolve(process.cwd(), `${output}${getExtensionFromLanguage(language)}`);
   const input = await readSchema(schemaFile);
   const schema = parseSchema(input);
@@ -138,9 +150,11 @@ export async function generate(schemaFile: string, output: string, language: Lan
   `;
 
     const pretty = prettier.format(code, { parser: 'typescript' });
-    await fs.writeFile(fullOutputPath, pretty);
-  } else {
-    const code = `
+    await writeFile(fullOutputPath, pretty);
+    return;
+  }
+
+  const code = `
     /** @typedef { import('@xata.io/client').Repository } Repository */
     import {
       BaseClient,
@@ -164,7 +178,6 @@ export async function generate(schemaFile: string, output: string, language: Lan
     }
   `;
 
-    const pretty = prettier.format(code, { parser: 'babel' });
-    await fs.writeFile(fullOutputPath, pretty);
-  }
+  const pretty = prettier.format(code, { parser: 'babel' });
+  await writeFile(fullOutputPath, pretty);
 }
diff --git a/codegen/src/generateWithOutput.ts b/codegen/src/generateWithOutput.ts
index 93e3fc37..6ab3b576 100644
--- a/codegen/src/generateWithOutput.ts
+++ b/codegen/src/generateWithOutput.ts
@@ -16,7 +16,7 @@ export const generateWithOutput = async ({
 }) => {
   spinner.text = 'Found schema, generating...';
 
-  await generate(schema, out, lang);
+  await generate({ schemaFilePath: schema, outputFilePath: out, language: lang });
 
   spinner.succeed(
     `Your XataClient is generated at ./${relative(process.cwd(), `${out}${getExtensionFromLanguage(lang)}`)}.`
diff --git a/package-lock.json b/package-lock.json
index 0f21f2d8..52c42031 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6132,9 +6132,9 @@
       }
     },
     "node_modules/diff-sequences": {
-      "version": "27.4.0",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.4.0.tgz",
-      "integrity": "sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+      "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
       "dev": true,
       "engines": {
         "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
@@ -8781,15 +8781,15 @@
       }
     },
     "node_modules/jest-diff": {
-      "version": "27.4.6",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.4.6.tgz",
-      "integrity": "sha512-zjaB0sh0Lb13VyPsd92V7HkqF6yKRH9vm33rwBt7rPYrpQvS1nCvlIy2pICbKta+ZjWngYLNn4cCK4nyZkjS/w==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+      "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
       "dev": true,
       "dependencies": {
         "chalk": "^4.0.0",
-        "diff-sequences": "^27.4.0",
-        "jest-get-type": "^27.4.0",
-        "pretty-format": "^27.4.6"
+        "diff-sequences": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
       },
       "engines": {
         "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
@@ -8999,9 +8999,9 @@
       }
     },
     "node_modules/jest-get-type": {
-      "version": "27.4.0",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.4.0.tgz",
-      "integrity": "sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+      "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
       "dev": true,
       "engines": {
         "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
@@ -12366,9 +12366,9 @@
       }
     },
     "node_modules/pretty-format": {
-      "version": "27.4.6",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.4.6.tgz",
-      "integrity": "sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
       "dev": true,
       "dependencies": {
         "ansi-regex": "^5.0.1",
@@ -19422,9 +19422,9 @@
       "dev": true
     },
     "diff-sequences": {
-      "version": "27.4.0",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.4.0.tgz",
-      "integrity": "sha512-YqiQzkrsmHMH5uuh8OdQFU9/ZpADnwzml8z0O5HvRNda+5UZsaX/xN+AAxfR2hWq1Y7HZnAzO9J5lJXOuDz2Ww==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+      "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
       "dev": true
     },
     "dir-glob": {
@@ -21411,15 +21411,15 @@
       }
     },
     "jest-diff": {
-      "version": "27.4.6",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.4.6.tgz",
-      "integrity": "sha512-zjaB0sh0Lb13VyPsd92V7HkqF6yKRH9vm33rwBt7rPYrpQvS1nCvlIy2pICbKta+ZjWngYLNn4cCK4nyZkjS/w==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+      "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
       "dev": true,
       "requires": {
         "chalk": "^4.0.0",
-        "diff-sequences": "^27.4.0",
-        "jest-get-type": "^27.4.0",
-        "pretty-format": "^27.4.6"
+        "diff-sequences": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
       },
       "dependencies": {
         "ansi-styles": {
@@ -21576,9 +21576,9 @@
       }
     },
     "jest-get-type": {
-      "version": "27.4.0",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.4.0.tgz",
-      "integrity": "sha512-tk9o+ld5TWq41DkK14L4wox4s2D9MtTpKaAVzXfr5CUKm5ZK2ExcaFE0qls2W71zE/6R2TxxrK9w2r6svAFDBQ==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+      "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
       "dev": true
     },
     "jest-haste-map": {
@@ -24154,9 +24154,9 @@
       "dev": true
     },
     "pretty-format": {
-      "version": "27.4.6",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.4.6.tgz",
-      "integrity": "sha512-NblstegA1y/RJW2VyML+3LlpFjzx62cUrtBIKIWDXEDkjNeleA7Od7nrzcs/VLQvAeV4CgSYhrN39DRN88Qi/g==",
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
       "dev": true,
       "requires": {
         "ansi-regex": "^5.0.1",
diff --git a/tsconfig.json b/tsconfig.json
index 5839eba2..90d10c6f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,5 +16,5 @@
     "noEmit": false,
     "outDir": "dist"
   },
-  "include": ["test"]
+  "include": ["test", "./jest/jest.d.ts"]
 }


Test patch:
diff --git a/test/__snapshots__/codegen.test.ts.snap b/test/__snapshots__/codegen.test.ts.snap
new file mode 100644
index 00000000..cf8811a7
--- /dev/null
+++ b/test/__snapshots__/codegen.test.ts.snap
@@ -0,0 +1,127 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`codegen should generate correct JavaScript 1`] = `
+"/** @typedef { import('@xata.io/client').Repository } Repository */
+import { BaseClient, Query, RestRespositoryFactory } from \\"@xata.io/client\\";
+
+/**
+ * @typedef {Object} Author
+ * @property {string} id
+ * @property {Object} xata
+ * @property {() => Promise<Author>} read
+ * @property {() => Promise<Author>} update
+ * @property {() => Promise<void>} delete
+ * @property {string=} name
+ * @property {string=} email
+ * @property {string=} photoUrl
+ * @property {string=} bio
+ * @property {string=} title
+ 
+ */
+
+/**
+ * @typedef {Object} Post
+ * @property {string} id
+ * @property {Object} xata
+ * @property {() => Promise<Post>} read
+ * @property {() => Promise<Post>} update
+ * @property {() => Promise<void>} delete
+ * @property {string=} title
+ * @property {string=} summary
+ * @property {string=} content
+ * @property {string[]=} tags
+ * @property {number=} likes
+ * @property {Author=} author
+ * @property {string=} date
+ * @property {string=} slug
+ * @property {boolean=} published
+ 
+ */
+
+/**
+ * @typedef {Object} Person
+ * @property {string} id
+ * @property {Object} xata
+ * @property {() => Promise<Person>} read
+ * @property {() => Promise<Person>} update
+ * @property {() => Promise<void>} delete
+ * @property {string=} slug
+ * @property {string=} name
+ * @property {string=} title
+ * @property {string=} photoUrl
+ 
+ */
+
+const links = { authors: [], posts: [[\\"author\\", \\"authors\\"]], people: [] };
+
+export class XataClient extends BaseClient {
+  constructor(options) {
+    super(options, links);
+    const factory = options.repositoryFactory || new RestRespositoryFactory();
+    /** @type {{ \\"authors\\": Repository; \\"posts\\": Repository; \\"people\\": Repository }} */
+    this.db = {
+      authors: factory.createRepository(this, \\"authors\\"),
+      posts: factory.createRepository(this, \\"posts\\"),
+      people: factory.createRepository(this, \\"people\\"),
+    };
+  }
+}
+"
+`;
+
+exports[`codegen should generate correct TypeScript 1`] = `
+"import {
+  BaseClient,
+  Query,
+  Repository,
+  RestRespositoryFactory,
+  XataClientOptions,
+  XataRecord,
+} from \\"@xata.io/client\\";
+
+export interface Author extends XataRecord {
+  name?: string;
+  email?: string;
+  photoUrl?: string;
+  bio?: string;
+  title?: string;
+}
+
+export interface Post extends XataRecord {
+  title?: string;
+  summary?: string;
+  content?: string;
+  tags?: string[];
+  likes?: number;
+  author?: Author;
+  date?: string;
+  slug?: string;
+  published?: boolean;
+}
+
+export interface Person extends XataRecord {
+  slug?: string;
+  name?: string;
+  title?: string;
+  photoUrl?: string;
+}
+
+const links = { authors: [], posts: [[\\"author\\", \\"authors\\"]], people: [] };
+
+export class XataClient extends BaseClient<{
+  authors: Repository<Author>;
+  posts: Repository<Post>;
+  people: Repository<Person>;
+}> {
+  constructor(options: XataClientOptions) {
+    super(options, links);
+    const factory = options.repositoryFactory || new RestRespositoryFactory();
+    this.db = {
+      authors: factory.createRepository(this, \\"authors\\"),
+      posts: factory.createRepository(this, \\"posts\\"),
+      people: factory.createRepository(this, \\"people\\"),
+    };
+  }
+}
+"
+`;
diff --git a/test/codegen.test.ts b/test/codegen.test.ts
new file mode 100644
index 00000000..fcee004d
--- /dev/null
+++ b/test/codegen.test.ts
@@ -0,0 +1,28 @@
+import { join } from 'path';
+import { generate } from '../codegen/src/codegen';
+
+describe('codegen', () => {
+  it('should generate correct TypeScript', async () => {
+    const schemaFilePath = join(__dirname, 'mocks', 'schema.json');
+    const outputFilePath = 'hahaha';
+
+    const writeFile = jest.fn();
+    await generate({ schemaFilePath, outputFilePath, writeFile });
+
+    const [path, content] = writeFile.mock.calls[0];
+    expect(path).toEqual(join(process.cwd(), 'hahaha.ts'));
+    expect(content).toMatchSnapshot();
+  });
+
+  it('should generate correct JavaScript', async () => {
+    const schemaFilePath = join(__dirname, 'mocks', 'schema.json');
+    const outputFilePath = 'hahaha';
+
+    const writeFile = jest.fn();
+    await generate({ schemaFilePath, outputFilePath, writeFile: writeFile, language: 'javascript' });
+
+    const [path, content] = writeFile.mock.calls[0];
+    expect(path).toEqual(join(process.cwd(), 'hahaha.js'));
+    expect(content).toMatchSnapshot();
+  });
+});
diff --git a/test/mocks/schema.json b/test/mocks/schema.json
new file mode 100644
index 00000000..3357a83d
--- /dev/null
+++ b/test/mocks/schema.json
@@ -0,0 +1,95 @@
+{
+  "formatVersion": "1.0",
+  "tables": [
+    {
+      "name": "authors",
+      "columns": [
+        {
+          "name": "name",
+          "type": "string"
+        },
+        {
+          "name": "email",
+          "type": "email"
+        },
+        {
+          "name": "photoUrl",
+          "type": "string"
+        },
+        {
+          "name": "bio",
+          "type": "text"
+        },
+        {
+          "name": "title",
+          "type": "string"
+        }
+      ]
+    },
+    {
+      "name": "posts",
+      "columns": [
+        {
+          "name": "title",
+          "type": "string"
+        },
+        {
+          "name": "summary",
+          "type": "text"
+        },
+        {
+          "name": "content",
+          "type": "text"
+        },
+        {
+          "name": "tags",
+          "type": "multiple"
+        },
+        {
+          "name": "likes",
+          "type": "int"
+        },
+        {
+          "name": "author",
+          "type": "link",
+          "link": {
+            "table": "authors"
+          }
+        },
+        {
+          "name": "date",
+          "type": "string"
+        },
+        {
+          "name": "slug",
+          "type": "string"
+        },
+        {
+          "name": "published",
+          "type": "bool"
+        }
+      ]
+    },
+    {
+      "name": "people",
+      "columns": [
+        {
+          "name": "slug",
+          "type": "string"
+        },
+        {
+          "name": "name",
+          "type": "string"
+        },
+        {
+          "name": "title",
+          "type": "string"
+        },
+        {
+          "name": "photoUrl",
+          "type": "string"
+        }
+      ]
+    }
+  ]
+}