Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,53 @@ Common field options:
- `index`: Create database index
- `nullable`: Allow null values

Then you can generate ORM config using: `npx goovee generate`. Once executed, this CLI will generate a class for each model defined in the project with the fields defined. It will also generate the global client of the application which should be used to access data. All generated code will be in the folder goovee/.generated.
Then you can generate ORM config using: `npx goovee generate`. Once executed, this CLI will generate a class for each model defined in the project with the fields defined. It will also generate the global client of the application which should be used to access data. All generated code will be in the folder `goovee/.generated` by default, or as configured by `outDir`.

### Code Generation Configuration

Configure `transpile` to generate code as an external package (prevents bundler mangling in Next.js):

Configure `transpile` in `goovee.config.json`:

```json
{
"schema": {
"outDir": "node_modules/@goovee/generated",
"transpile": true
}
}
```

Or with a custom package name (for monorepo):

```json
{
"schema": {
"outDir": "packages/db",
"transpile": { "packageName": "@myorg/db" }
}
}
```

Then configure Next.js to skip mangling:

```js
// next.config.mjs
const nextConfig = {
serverExternalPackages: ["@goovee/generated"], // or your custom package name
};
export default nextConfig;
```

For monorepo workspaces, add to your app's `package.json`:

```json
{
"dependencies": {
"@myorg/db": "workspace:*"
}
}
```

Then, you need to create a client getter function. This should be done once in the project in an index file at the root of goovee folder to be used later for data access or modification in the application.

Expand Down
31 changes: 31 additions & 0 deletions src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";

import { buildPackageJson } from "./generate";

describe("buildPackageJson", () => {
it("uses module type and default name when transpile is true", () => {
expect(buildPackageJson(true)).toEqual({
name: "@goovee/generated",
version: "1.0.0",
type: "module",
exports: {
"./models": "./models/index.js",
"./client": "./client/index.js",
},
});
});

it("uses commonjs type when transpile.module is commonjs", () => {
expect(buildPackageJson({ module: "commonjs" }).type).toBe("commonjs");
});

it("uses module type for non-commonjs transpile config", () => {
expect(buildPackageJson({ module: "esnext" }).type).toBe("module");
});

it("uses transpile.packageName as the package name", () => {
expect(buildPackageJson({ packageName: "@myorg/db" }).name).toBe(
"@myorg/db",
);
});
});
26 changes: 25 additions & 1 deletion src/cli/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import fs from "node:fs";
import path from "node:path";

import { Command } from "commander";

import { generateClient, transpileClient } from "../../client/generator";
import type { TranspileConfig } from "../../client/types";
import { expandConfig, loadConfig } from "../config";

export function buildPackageJson(
transpile: boolean | TranspileConfig,
): Record<string, unknown> {
const opts =
typeof transpile === "object" && transpile !== null ? transpile : {};
return {
name: opts.packageName ?? "@goovee/generated",
version: "1.0.0",
type: opts.module === "commonjs" ? "commonjs" : "module",
exports: {
"./models": "./models/index.js",
"./client": "./client/index.js",
},
};
}

export const generate = new Command()
.name("generate")
.description("Generate goovee client from the schema")
Expand All @@ -29,7 +47,9 @@ export const generate = new Command()
}

// generate client
const files = generateClient(dirs, outDir);
const files = generateClient(dirs, outDir, {
transpile: !!schema.transpile,
});

// transpile client?
if (schema?.transpile) {
Expand All @@ -41,5 +61,9 @@ export const generate = new Command()
for (const file of files) {
fs.rmSync(file, { force: true });
}
fs.writeFileSync(
path.join(outDir, "package.json"),
JSON.stringify(buildPackageJson(schema.transpile), null, 2) + "\n",
);
}
});
52 changes: 52 additions & 0 deletions src/client/generator/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,58 @@ describe("client generator tests", () => {
cleanUp(outDir, files);
});

it("should emit extensionless imports without transpile", () => {
const schemaDir = path.join(__dirname, "..", "..", "test", "schema");
const outDir = fs.mkdtempSync(path.join(__dirname, "noext."));
const files = generateClient([schemaDir], outDir);

const tsFiles = files.filter((f) => f.endsWith(".ts"));
expect(tsFiles.length).toBeGreaterThan(0);
for (const file of tsFiles) {
const code = fs.readFileSync(file, { encoding: "utf-8" });
// reject any relative import with an extension, e.g. `from "./Foo.js"`
expect(code).not.toMatch(/from "\.\/[A-Z][A-Za-z]*\.[a-z]+"/);
}

cleanUp(outDir, files);
});

it("should emit .js imports when transpile is requested", () => {
const schemaDir = path.join(__dirname, "..", "..", "test", "schema");
const outDir = fs.mkdtempSync(path.join(__dirname, "ext."));
const files = generateClient([schemaDir], outDir, { transpile: true });

const code = fs.readFileSync(path.join(outDir, "models", "Contact.ts"), {
encoding: "utf-8",
});
// require at least one relative import with a `.js` extension
expect(code).toMatch(/from "\.\/[A-Z][A-Za-z]*\.js"/);

const clientCode = fs.readFileSync(
path.join(outDir, "client", "index.ts"),
{ encoding: "utf-8" },
);
expect(clientCode).toContain('from "../models/index.js"');

cleanUp(outDir, files);
});

it("should produce transpiled output with resolvable .js imports", () => {
const schemaDir = path.join(__dirname, "..", "..", "test", "schema");
const outDir = fs.mkdtempSync(path.join(__dirname, "e2e."));
const files = generateClient([schemaDir], outDir, { transpile: true });
const outputFiles = transpileClient(files, { target: "esnext" });

const code = fs.readFileSync(path.join(outDir, "models", "Contact.js"), {
encoding: "utf-8",
});
// require at least one relative `.js` import; tsc may emit either quote style
expect(code).toMatch(/from ['"]\.\/[A-Z][A-Za-z]*\.js['"]/);

cleanUp(outDir, files);
cleanUp(outDir, outputFiles);
});

it("should transpile client", () => {
const schemaDir = path.join(__dirname, "..", "..", "test", "schema");
const outDir = fs.mkdtempSync(path.join(__dirname, "transpile."));
Expand Down
25 changes: 18 additions & 7 deletions src/client/generator/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export const createFile = (outDir: string, fileName: string, content: any) => {
return fileName;
};

const createClient = (schema: EntityOptions[], names: string[]) => {
const createClient = (
schema: EntityOptions[],
names: string[],
extension: string,
) => {
const file = new CodeFile("index.ts");
const pkgName = "@goovee/orm";
file.write(`\
Expand All @@ -43,7 +47,7 @@ import {
file.write(`${name},`);
file.write("\n");
}
file.write('} from "../models";');
file.write(`} from "../models/index${extension}";`);
file.write("\n");
file.write("\n");

Expand Down Expand Up @@ -86,9 +90,14 @@ export function createSchema(): GraphQLSchema {
return file.toJSON();
};

export const generateClient = (schemaDirs: string[], outDir: string) => {
export const generateClient = (
schemaDirs: string[],
outDir: string,
options?: { transpile?: boolean },
) => {
const modelsDir = path.join(outDir, "models");
const clientDir = path.join(outDir, "client");
const extension = options?.transpile ? ".js" : "";

const schema = schemaDirs.flatMap(readSchema);
const files: string[] = [];
Expand All @@ -103,9 +112,11 @@ export const generateClient = (schemaDirs: string[], outDir: string) => {
visited.add(name);
}

files.push(...generateSchema(modelsDir, { schema, naming: "goovee" }));
files.push(
...generateSchema(modelsDir, { schema, naming: "goovee", extension }),
);

createFile(clientDir, "index.ts", createClient(schema, names));
createFile(clientDir, "index.ts", createClient(schema, names, extension));

files.push(path.join(clientDir, "index.ts"));

Expand Down Expand Up @@ -146,7 +157,7 @@ const TS_MODULE_TYPES: Record<string, ts.ModuleKind> = {
nodenext: ts.ModuleKind.NodeNext,
};

function prepateCompilerOptions(options?: TranspileConfig) {
function prepareCompilerOptions(options?: TranspileConfig) {
const cfgFile = ts.findConfigFile(".", ts.sys.fileExists, "tsconfig.json");
const cfg = cfgFile
? ts.readConfigFile(cfgFile, ts.sys.readFile).config || {}
Expand All @@ -166,7 +177,7 @@ function prepateCompilerOptions(options?: TranspileConfig) {
}

export const transpileClient = (files: string[], options?: TranspileConfig) => {
const opts = prepateCompilerOptions(options);
const opts = prepareCompilerOptions(options);
const outputFiles: string[] = [];

const filePaths = files.map((x) => path.resolve(x));
Expand Down
4 changes: 3 additions & 1 deletion src/client/parser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ export class ParserContext {

isStringField(repo: Repository<any>, name: string): boolean {
if (!this.schema) return false;
const ns = repo.metadata.connection.namingStrategy;
const schemaDef = this.schema.find(
(x) => x.name === repo.metadata.targetName,
(x) =>
ns.tableName(x.name, x.table) === repo.metadata.tableNameWithoutPrefix,
);
const fieldDef = schemaDef?.fields?.find((x) => x.name === name);
return fieldDef?.type === "String";
Expand Down
2 changes: 1 addition & 1 deletion src/client/repository/query/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const doQuery = async (
);
}

const rr = repo.manager.getRepository(relation.inverseEntityMetadata.name);
const rr = repo.manager.getRepository(relation.inverseEntityMetadata.target);
const nq = relationQuery(repo.manager, relation)
.clone()
.setParameter("__parents", ids);
Expand Down
11 changes: 11 additions & 0 deletions src/client/repository/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ describe("EntityRepository protected field guards", () => {
({
metadata: {
name: "Thing",
tableName: "thing",
tableNameWithoutPrefix: "thing",
findRelationWithPropertyPath: () => null,
connection: {
namingStrategy: {
tableName: (
targetName: string,
userSpecifiedName: string | undefined,
) => userSpecifiedName ?? targetName.toLowerCase(),
},
},
},
create: vi.fn(),
save: vi.fn(),
Expand All @@ -19,6 +29,7 @@ describe("EntityRepository protected field guards", () => {
__schema: [
{
name: "Thing",
table: "thing",
fields: [
{ name: "secret", type: "String", internal: true },
{ name: "auditStamp", type: "DateTime", readonly: true },
Expand Down
16 changes: 10 additions & 6 deletions src/client/repository/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ import { isValueSame, valueOrID } from "./utils";
import { getSimpleSelect } from "../parser/processors/select-processor";

function getProtectedFields(
repo: OrmRepository<any>,
client: QueryClient,
entityName: string,
mode: "create" | "update",
): Set<string> {
const ns = repo.metadata.connection.namingStrategy;
const schema: EntityOptions[] = (client as any).__schema ?? [];
const entity = schema.find((e) => e.name === entityName);
const entity = schema.find(
(e) =>
ns.tableName(e.name, e.table) === repo.metadata.tableNameWithoutPrefix,
);
if (!entity?.fields) return new Set();
return new Set(
entity.fields
Expand Down Expand Up @@ -81,7 +85,7 @@ class RelationHandlers {
const update = Array.isArray(data.update) ? data.update[0] : data.update;

const rMeta = relation.inverseEntityMetadata;
const rRepo = repo.manager.getRepository(rMeta.name);
const rRepo = repo.manager.getRepository(rMeta.target);

// Import EntityRepository here to avoid circular dependency
const { EntityRepository } = await import("./repository");
Expand Down Expand Up @@ -124,7 +128,7 @@ class RelationHandlers {
const field = relation.propertyName;
const inverseField = relation.inverseRelation?.propertyName;
const rMeta = relation.inverseEntityMetadata;
const rRepo = repo.manager.getRepository(rMeta.name);
const rRepo = repo.manager.getRepository(rMeta.target);

const link = inverseField
? { [inverseField]: { select: { id: obj.id } } }
Expand Down Expand Up @@ -287,7 +291,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
meta: EntityMetadata,
data: CreateArgs<T>,
) {
const protectedFields = getProtectedFields(this.#client, meta.name, "create");
const protectedFields = getProtectedFields(repo, this.#client, "create");
rejectProtectedFields(Object.keys(data), protectedFields, meta.name);

const attrs: Record<string, any> = {};
Expand Down Expand Up @@ -429,7 +433,7 @@ export class EntityRepository<T extends Entity> implements Repository<T> {
const { id, version, ...rest } = data;

const meta = repo.metadata;
const protectedFields = getProtectedFields(this.#client, meta.name, "update");
const protectedFields = getProtectedFields(repo, this.#client, "update");
rejectProtectedFields(Object.keys(rest), protectedFields, meta.name);

const attrs: Record<string, any> = {};
Expand Down
6 changes: 6 additions & 0 deletions src/client/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export type TranspileConfig = {
* Defaults to `esnext`
*/
module?: "commonjs" | "esnext";

/**
* Package name written into the generated `package.json`.
* Defaults to `@goovee/generated`.
*/
packageName?: string;
};

/**
Expand Down
Loading