Skip to content
Merged
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
87 changes: 87 additions & 0 deletions tests/code-generator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {describe, expect, it} from 'vitest';
import {generateCode} from '../src/generators/code-generator';
import type {TransformedSchema} from '../src/types';

describe('code generator', () => {
it('handles models without relationships and unknown type strings', () => {
const schema: TransformedSchema = {
enums: [],
models: [
{
tableName: 'Weird',
originalTableName: null,
modelName: 'Weird',
zeroTableName: 'weirdTable',
columns: {
id: {type: 'Custom()', isOptional: false, mappedName: null},
},
relationships: undefined as any,
primaryKey: ['id'],
},
],
};

const output = generateCode(schema);

expect(output).toContain('export const weirdTable = table("Weird")');
expect(output).toContain('export const schema = createSchema({');
expect(output).not.toContain('relationships(');
expect(output).toContain(
'import {\n createBuilder,\n createSchema,\n table,\n} from "@rocicorp/zero";',
);
});

it('omits relationships section when none exist', () => {
const schema: TransformedSchema = {
enums: [],
models: [],
};

const output = generateCode(schema);

expect(output).toContain('tables: [\n ],');
expect(output).not.toContain('relationships: [');
});

it('only includes relationship entries for models that define them', () => {
const schema: TransformedSchema = {
enums: [],
models: [
{
tableName: 'WithRel',
originalTableName: null,
modelName: 'WithRel',
zeroTableName: 'withRelTable',
columns: {
id: {type: 'string()', isOptional: false, mappedName: null},
},
relationships: {
plain: {
type: 'one',
sourceField: ['id'],
destField: ['id'],
destSchema: 'plainTable',
},
},
primaryKey: ['id'],
},
{
tableName: 'Plain',
originalTableName: null,
modelName: 'Plain',
zeroTableName: 'plainTable',
columns: {
id: {type: 'string()', isOptional: false, mappedName: null},
},
relationships: {},
primaryKey: ['id'],
},
],
};

const output = generateCode(schema);

expect(output).toContain('withRelTableRelationships');
expect(output).not.toContain('plainTableRelationships');
});
});
169 changes: 169 additions & 0 deletions tests/generator-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type {DMMF, GeneratorOptions} from '@prisma/generator-helper';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {createEnum, createField, createMockDMMF, createModel} from './utils';

vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
mkdir: vi.fn(),
readFile: vi.fn(),
}));

function createTestOptions(dmmf: DMMF.Document): GeneratorOptions {
return {
generator: {
output: {value: 'generated', fromEnvVar: null},
name: 'test-generator',
config: {},
provider: {value: 'test-provider', fromEnvVar: null},
binaryTargets: [],
previewFeatures: [],
sourceFilePath: '',
},
dmmf,
schemaPath: '',
datasources: [],
otherGenerators: [],
version: '0.0.0',
datamodel: '',
};
}

describe('Generator configuration', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unmock('prettier');
vi.unmock('@prisma/generator-helper');
});

it('throws when output directory is missing', async () => {
const {onGenerate} = await import('../src/generator');

const options = createTestOptions(createMockDMMF([]));
(options.generator as any).output = undefined;

await expect(onGenerate(options)).rejects.toThrow(
'Output directory is required',
);
});

it('formats output with prettier when enabled', async () => {
const format = vi.fn(
async (code: string, options?: unknown) =>
`formatted:${code}-${JSON.stringify(options)}`,
);
const resolveConfig = vi.fn(async () => ({semi: false}));

vi.doMock('prettier', () => ({
format,
resolveConfig,
}));

const {onGenerate} = await import('../src/generator');
const options = createTestOptions(
createMockDMMF(
[
createModel('User', [
createField('id', 'String', {isId: true}),
createField('role', 'Role', {kind: 'enum'}),
]),
],
[createEnum('Role', ['ADMIN'])],
),
);
options.generator.config.prettier = 'true';

await onGenerate(options);

expect(resolveConfig).toHaveBeenCalledWith('schema.ts');
expect(format).toHaveBeenCalled();
const formatOptions = format.mock.calls[0]?.[1] as Record<string, unknown>;
expect(formatOptions?.parser).toBe('typescript');
});

it('throws a helpful error when prettier cannot be loaded', async () => {
vi.doMock('prettier', () => {
throw new Error('module not found');
});

const {onGenerate} = await import('../src/generator');
const options = createTestOptions(createMockDMMF([]));
options.generator.config.prettier = 'true';

await expect(onGenerate(options)).rejects.toThrow(
'⚠️ prisma-zero: prettier could not be found. Install it locally with\n npm i -D prettier',
);
});

it('skips resolving prettier config when disabled', async () => {
const format = vi.fn(async (code: string) => code);
const resolveConfig = vi.fn();

vi.doMock('prettier', () => ({
format,
resolveConfig,
}));

const {onGenerate} = await import('../src/generator');
const options = createTestOptions(createMockDMMF([]));
options.generator.config.prettier = 'true';
options.generator.config.resolvePrettierConfig = 'false';

await onGenerate(options);

expect(resolveConfig).not.toHaveBeenCalled();
expect(format).toHaveBeenCalled();
});

it('validates excludeTables configuration', async () => {
const {onGenerate} = await import('../src/generator');
const options = createTestOptions(createMockDMMF([]));
options.generator.config.excludeTables = 'posts' as any;

await expect(onGenerate(options)).rejects.toThrow(
'excludeTables must be an array',
);
});

it('passes excludeTables through to schema transformation', async () => {
const mapperModule = await import('../src/mappers/schema-mapper');
const transformSpy = vi.spyOn(mapperModule, 'transformSchema');
const {onGenerate} = await import('../src/generator');

const options = createTestOptions(
createMockDMMF([
createModel('User', [
createField('id', 'String', {isId: true}),
createField('name', 'String'),
]),
]),
);
options.generator.config.excludeTables = ['User'];

await onGenerate(options);

expect(transformSpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({excludeTables: ['User']}),
);
});

it('exposes manifest information through generatorHandler', async () => {
const handler = vi.fn();
vi.doMock('@prisma/generator-helper', () => ({
default: {generatorHandler: handler},
generatorHandler: handler,
}));

const {version} = await import('../package.json');
await import('../src/generator');

expect(handler).toHaveBeenCalledTimes(1);
const manifest = handler.mock.calls[0]?.[0]?.onManifest?.();
expect(manifest).toEqual({
version,
defaultOutput: 'generated/zero',
prettyName: 'Zero Schema',
});
});
});
116 changes: 116 additions & 0 deletions tests/schema-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,38 @@ describe('Schema Mapper', () => {
});

describe('Relationships', () => {
it('uses back-references when relationFromFields is only defined on the target', () => {
const userModel = createModel('User', [
createField('id', 'String', {isId: true}),
createField('profile', 'Profile', {
relationName: 'UserProfile',
kind: 'object',
}),
]);

const profileModel = createModel('Profile', [
createField('id', 'String', {isId: true}),
createField('userId', 'String'),
createField('user', 'User', {
relationName: 'UserProfile',
kind: 'object',
relationFromFields: ['userId'],
relationToFields: ['id'],
}),
]);

const dmmf = createMockDMMF([userModel, profileModel]);
const result = transformSchema(dmmf, baseConfig);

const user = result.models.find(m => m.modelName === 'User');
expect(user?.relationships.profile).toEqual({
sourceField: ['id'],
destField: ['userId'],
destSchema: 'profileTable',
type: 'one',
});
});

it('should correctly map one-to-many relationship with composite key on parent', () => {
const parentModel = createModel(
'Parent',
Expand Down Expand Up @@ -480,4 +512,88 @@ describe('Schema Mapper', () => {
);
});
});

describe('Error handling', () => {
it('throws when a model has no primary key', () => {
const model = createModel('NoPK', [createField('name', 'String')]);

expect(() =>
transformSchema(createMockDMMF([model]), baseConfig),
).toThrow('No primary key found for NoPK');
});

it('throws when a relationship target model is missing', () => {
const model = createModel('Post', [
createField('id', 'String', {isId: true}),
createField('author', 'User', {
relationName: 'Author',
kind: 'object',
}),
]);

expect(() =>
transformSchema(createMockDMMF([model]), baseConfig),
).toThrow('Target model User not found for relationship author');
});

it('throws when implicit many-to-many models are missing id fields', () => {
const postModel = createModel('Post', [
createField('id', 'String', {isId: true}),
createField('tags', 'Tag', {
isList: true,
relationName: 'PostTags',
kind: 'object',
}),
]);

const tagModel = createModel(
'Tag',
[
createField('label', 'String'),
createField('posts', 'Post', {
isList: true,
relationName: 'PostTags',
kind: 'object',
}),
],
{primaryKey: {fields: ['label'], name: null}},
);

expect(() =>
transformSchema(createMockDMMF([postModel, tagModel]), baseConfig),
).toThrow('Implicit relation PostTags: Model Tag has no @id field.');
});

it('throws when implicit many-to-many models have primary keys without @id', () => {
const alphaModel = createModel(
'Alpha',
[
createField('key', 'String'),
createField('betas', 'Beta', {
isList: true,
relationName: 'AlphaBeta',
kind: 'object',
}),
],
{primaryKey: {fields: ['key'], name: null}},
);

const betaModel = createModel(
'Beta',
[
createField('key', 'String'),
createField('alphas', 'Alpha', {
isList: true,
relationName: 'AlphaBeta',
kind: 'object',
}),
],
{primaryKey: {fields: ['key'], name: null}},
);

expect(() =>
transformSchema(createMockDMMF([alphaModel, betaModel]), baseConfig),
).toThrow('Implicit relation AlphaBeta: Model Alpha has no @id field.');
});
});
});