Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ generator zero {
// When true, the generator will remap table names to camel case using Zero's `.from()` method.
// You can read more about it here https://zero.rocicorp.dev/docs/zero-schema#name-mapping
remapTablesToCamelCase = true
// When true, the generator will remap column names (Prisma fields) to camel case using Zero's `.from()` method.
// This follows the same logic as `remapTablesToCamelCase` but applies to columns.
remapColumnsToCamelCase = true
// Optional list of Prisma Model names you want to exclude from the generated schema.
// Helpful if you want to exclude Views (not supported by Zero) or other tables
// you don't want Zero client to have access to
Expand Down
80 changes: 80 additions & 0 deletions src/__tests__/__snapshots__/generator.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,47 @@ export type User = Row<typeof schema.tables.User>;
"
`;

exports[`Generator > Schema Generation > should generate correct schema for model with @map attributes 1`] = `
"// Generated by Zero Schema Generator

import {
table,
string,
boolean,
number,
json,
enumeration,
relationships,
createSchema,
type Row,
} from "@rocicorp/zero";

// Define tables

export const messageTable = table("message")
.columns({
id: string(),
senderID: string().from('sender_id').optional(),
mediumID: string().from('medium_id').optional(),
})
.primaryKey("id");

// Define schema

export const schema = createSchema(
{
tables: [
messageTable,
],
}
);

// Define types
export type Schema = typeof schema;
export type message = Row<typeof schema.tables.message>;
"
`;

exports[`Generator > Schema Generation > should handle enums as unions correctly 1`] = `
"// Generated by Zero Schema Generator

Expand Down Expand Up @@ -286,6 +327,45 @@ export type User = Row<typeof schema.tables.User>;
"
`;

exports[`Generator > Schema Generation > should handle model @@map directive correctly 1`] = `
"// Generated by Zero Schema Generator

import {
table,
string,
boolean,
number,
json,
enumeration,
relationships,
createSchema,
type Row,
} from "@rocicorp/zero";

// Define tables

export const cdrTable = table("cdr")
.columns({
id: string(),
})
.primaryKey("id");

// Define schema

export const schema = createSchema(
{
tables: [
cdrTable,
],
}
);

// Define types
export type Schema = typeof schema;
export type cdr = Row<typeof schema.tables.cdr>;
"
`;

exports[`Generator > Schema Generation > should handle relationships correctly 1`] = `
"// Generated by Zero Schema Generator

Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,27 @@ describe("Generator", () => {
// Verify the output includes .from() calls for mapped columns
expect(content).toContain("senderID: string().from('sender_id').optional()");
expect(content).toContain("mediumID: string().from('medium_id').optional()");
expect(content).toMatchSnapshot(); // Add snapshot assertion for the @map test
});

it("should handle model @@map directive correctly", async () => {
// Define the model with @@map
const cdrModel = createModel(
"cdr", // Original Prisma model name
[createField("id", "String", { isId: true })], // Example field
{ dbName: "xml_cdr" } // Simulates @@map("xml_cdr")
);

// Generate options and run generator
const options = createTestOptions(createMockDMMF([cdrModel]));
await onGenerate(options);

// Get generated content
const [, contentBuffer] = vi.mocked(fs.writeFile).mock.calls[0];
const content = contentBuffer.toString();

// Assert against snapshot
expect(content).toMatchSnapshot();
});
});

Expand Down
206 changes: 206 additions & 0 deletions src/__tests__/schemaMapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe("Schema Mapper", () => {
prettier: false,
resolvePrettierConfig: false,
remapTablesToCamelCase: false,
remapColumnsToCamelCase: false, // Add default value
};

describe("excludeTables", () => {
Expand Down Expand Up @@ -337,4 +338,209 @@ describe("Schema Mapper", () => {
expect(joinTable.columns.B.type).toBe("number()");
}
});
describe("remapColumnsToCamelCase", () => {
const configWithRemap: Config = {
...baseConfig,
remapColumnsToCamelCase: true,
};

it("should remap column names to camel case", () => {
const model = createModel("TestModel", [
createField("id", "String", { isId: true }),
createField("user_id", "String"),
createField("created_at", "DateTime"),
]);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

expect(transformedModel.columns).toHaveProperty("userId");
expect(transformedModel.columns).toHaveProperty("createdAt");
expect(transformedModel.columns).not.toHaveProperty("user_id");
expect(transformedModel.columns).not.toHaveProperty("created_at");
// Check mappedName is set correctly when remapping occurs without @map
expect(transformedModel.columns.userId.mappedName).toBe("user_id");
expect(transformedModel.columns.createdAt.mappedName).toBe("created_at");
});

it("should preserve column name if already in camel case", () => {
const model = createModel("TestModel", [
createField("id", "String", { isId: true }),
createField("userId", "String"),
]);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

expect(transformedModel.columns).toHaveProperty("userId");
// Check mappedName is undefined when no remapping or @map
expect(transformedModel.columns.userId.mappedName).toBeUndefined();
});

it("should handle column names with leading/multiple underscores", () => {
const model = createModel("TestModel", [
createField("id", "String", { isId: true }),
createField("_internal_field", "String"),
createField("__private_data", "String"),
]);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

expect(transformedModel.columns).toHaveProperty("_internalField");
expect(transformedModel.columns).toHaveProperty("__privateData");
expect(transformedModel.columns._internalField.mappedName).toBe("_internal_field");
expect(transformedModel.columns.__privateData.mappedName).toBe("__private_data");
});

it("should handle @map attribute correctly when remapping", () => {
const model = createModel("TestModel", [
createField("id", "String", { isId: true }),
createField("user_identifier", "String", { dbName: "user_id_in_db" }), // Prisma name differs from DB name
]);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

// Key should be camelCase of Prisma name
expect(transformedModel.columns).toHaveProperty("userIdentifier");
expect(transformedModel.columns).not.toHaveProperty("user_identifier");
expect(transformedModel.columns).not.toHaveProperty("userIdInDb");
// mappedName should be the dbName from @map
expect(transformedModel.columns.userIdentifier.mappedName).toBe("user_id_in_db");
});

it("should handle @map attribute when Prisma name is already camelCase", () => {
const model = createModel("TestModel", [
createField("id", "String", { isId: true }),
createField("userId", "String", { dbName: "user_uuid" }), // Prisma name already camelCase
]);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

expect(transformedModel.columns).toHaveProperty("userId");
// mappedName should still be the dbName from @map
expect(transformedModel.columns.userId.mappedName).toBe("user_uuid");
});

it("should remap primary key fields", () => {
const model = createModel(
"TestModel",
[createField("primary_key_part_1", "String"), createField("primary_key_part_2", "String")],
{ primaryKey: { name: null, fields: ["primary_key_part_1", "primary_key_part_2"] } }
);
const dmmf = createMockDMMF([model]);
const result = transformSchema(dmmf, configWithRemap);
const transformedModel = result.models[0];

// Expect the default change-case behavior with mergeAmbiguousCharacters: true
expect(transformedModel.primaryKey).toEqual(["primaryKeyPart_1", "primaryKeyPart_2"]);
expect(transformedModel.columns).toHaveProperty("primaryKeyPart_1");
expect(transformedModel.columns).toHaveProperty("primaryKeyPart_2");
});

it("should remap foreign key fields in relationships (1:N)", () => {
const userModel = createModel("User", [
createField("user_id", "String", { isId: true }),
createField("posts", "Post", { isList: true, relationName: "UserPosts" }),
]);
const postModel = createModel("Post", [
createField("post_id", "String", { isId: true }),
createField("author_user_id", "String"), // Foreign key
createField("author", "User", {
relationName: "UserPosts",
relationFromFields: ["author_user_id"],
relationToFields: ["user_id"],
}),
]);
const dmmf = createMockDMMF([userModel, postModel]);
const result = transformSchema(dmmf, configWithRemap);

const transformedUser = result.models.find(m => m.modelName === "User");
const transformedPost = result.models.find(m => m.modelName === "Post");

expect(transformedUser?.columns).toHaveProperty("userId");
expect(transformedPost?.columns).toHaveProperty("postId");
expect(transformedPost?.columns).toHaveProperty("authorUserId"); // FK column remapped

// Check relationship on User side (many)
const userPostsRel = transformedUser?.relationships?.posts;
expect(userPostsRel?.type).toBe("many");
if (userPostsRel && "sourceField" in userPostsRel) {
expect(userPostsRel.sourceField).toEqual(["userId"]); // Remapped PK
expect(userPostsRel.destField).toEqual(["authorUserId"]); // Remapped FK
} else {
throw new Error("Unexpected relationship structure for User.posts");
}

// Check relationship on Post side (one)
const postAuthorRel = transformedPost?.relationships?.author;
expect(postAuthorRel?.type).toBe("one");
if (postAuthorRel && "sourceField" in postAuthorRel) {
expect(postAuthorRel.sourceField).toEqual(["authorUserId"]); // Remapped FK
expect(postAuthorRel.destField).toEqual(["userId"]); // Remapped PK
} else {
throw new Error("Unexpected relationship structure for Post.author");
}
});

it("should remap fields in implicit M:N join table relationships", () => {
const postModel = createModel("Post", [
createField("post_id", "Int", { isId: true }), // Remapped ID
createField("categories", "Category", {
isList: true,
relationName: "PostToCategory",
kind: "object",
}),
]);

const categoryModel = createModel("Category", [
createField("category_id", "Int", { isId: true }), // Remapped ID
createField("posts", "Post", {
isList: true,
relationName: "PostToCategory",
kind: "object",
}),
]);

const dmmf = createMockDMMF([postModel, categoryModel]);
const result = transformSchema(dmmf, configWithRemap);

const joinTable = result.models.find((m) => m.modelName === "_PostToCategory");
expect(joinTable).toBeDefined();

// Check relationships within the join table model
const relA = joinTable?.relationships?.modelA; // Assuming Category comes first alphabetically
const relB = joinTable?.relationships?.modelB; // Assuming Post comes second

expect(relA?.type).toBe("one");
expect(relB?.type).toBe("one");

if (relA && "destField" in relA) {
expect(relA.destField).toEqual(["categoryId"]); // Should point to remapped ID
} else {
throw new Error("Unexpected relationship structure for joinTable.modelA");
}
if (relB && "destField" in relB) {
expect(relB.destField).toEqual(["postId"]); // Should point to remapped ID
} else {
throw new Error("Unexpected relationship structure for joinTable.modelB");
}

// Also check the chained relationships on the original models
const postCategoriesRel = result.models.find(m => m.modelName === "Post")?.relationships?.categories;
expect(postCategoriesRel?.type).toBe("many");
if (postCategoriesRel && "chain" in postCategoriesRel) {
expect(postCategoriesRel.chain[0].sourceField).toEqual(["postId"]); // Remapped Post ID
expect(postCategoriesRel.chain[0].destField).toEqual(["B"]); // Join table column
expect(postCategoriesRel.chain[1].sourceField).toEqual(["A"]); // Join table column
expect(postCategoriesRel.chain[1].destField).toEqual(["categoryId"]); // Remapped Category ID
} else {
throw new Error("Unexpected relationship structure for Post.categories");
}
});

});
});
// End of main describe block
1 change: 1 addition & 0 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function onGenerate(options: GeneratorOptions) {
prettier: generator.config.prettier === "true", // Default false,
resolvePrettierConfig: generator.config.resolvePrettierConfig !== "false", // Default true
remapTablesToCamelCase: generator.config.remapTablesToCamelCase === "true", // Default false
remapColumnsToCamelCase: generator.config.remapColumnsToCamelCase === "true", // Default false
excludeTables: loadExcludeTables(generator),
enumAsUnion: generator.config.enumAsUnion === "true",
} satisfies Config;
Expand Down
4 changes: 2 additions & 2 deletions src/generators/codeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function generateColumnDefinition(name: string, mapping: ZeroTypeMapping): strin
}

function generateModelSchema(model: ZeroModel): string {
let output = `export const ${model.zeroTableName} = table("${model.tableName}")`;
let output = `export const ${model.zeroTableName} = table("${model.modelName}")`;

// Add .from() if we have an original table name
if (model.originalTableName) {
Expand Down Expand Up @@ -176,7 +176,7 @@ function generateSchema(schema: TransformedSchema): string {
output += "// Define types\n";
output += "export type Schema = typeof schema;\n";
schema.models.forEach((model) => {
output += `export type ${model.modelName} = Row<typeof schema.tables.${model.tableName}>;\n`;
output += `export type ${model.modelName} = Row<typeof schema.tables.${model.modelName}>;\n`;
});

return output;
Expand Down
Loading