UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

935 lines (810 loc) 26.2 kB
import { Alepha, z } from "alepha"; import { sql } from "drizzle-orm"; import { beforeEach, describe, expect, it } from "vitest"; import { $entity } from "../core/primitives/$entity.ts"; import { db } from "../core/providers/DatabaseTypeProvider.ts"; import { SqliteModelBuilder } from "../core/services/SqliteModelBuilder.ts"; import { AlephaOrmPostgres } from "../postgres/index.ts"; import { PostgresModelBuilder } from "../postgres/services/PostgresModelBuilder.ts"; import { testComplexRelationships, testCustomConfig, testExpressionIndex, testModelBuilderFeatures, } from "./ModelBuilder-tests.ts"; describe("ModelBuilder", () => { describe("PostgresModelBuilder", () => { let builder: PostgresModelBuilder; let options: { tables: Map<string, unknown>; enums: Map<string, unknown>; schemas: Map<string, unknown>; schema: string; }; beforeEach(() => { builder = new PostgresModelBuilder(); options = { tables: new Map(), enums: new Map(), schemas: new Map(), schema: "public", }; }); it("should build a basic table", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), email: z.email(), }), }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with single column index", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), username: z.text(), }), indexes: ["email", "username"], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with unique index", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), username: z.text(), }), indexes: [ { column: "email", unique: true, name: "unique_email_idx", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with composite index", () => { const entity = $entity({ name: "posts", schema: z.object({ id: db.primaryKey(), userId: z.text(), createdAt: z.string().meta({ format: "date-time" }), title: z.text(), }), indexes: [ { columns: ["userId", "createdAt"], name: "user_created_idx", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("posts")).toBe(true); const table = options.tables.get("posts"); expect(table).toBeDefined(); }); it("should build table with unique composite index", () => { const entity = $entity({ name: "user_roles", schema: z.object({ id: db.primaryKey(), userId: z.text(), roleId: z.text(), }), indexes: [ { columns: ["userId", "roleId"], unique: true, name: "unique_user_role", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("user_roles")).toBe(true); const table = options.tables.get("user_roles"); expect(table).toBeDefined(); }); it("should build table with foreign keys", () => { // First create the users table const usersEntity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), }), }); builder.buildTable(usersEntity, options); const usersTable = options.tables.get("users") as any; // Then create posts table with foreign key const postsEntity = $entity({ name: "posts", schema: z.object({ id: db.primaryKey(), userId: z.text(), title: z.text(), }), foreignKeys: [ { name: "posts_user_fk", columns: ["userId"], foreignColumns: [() => usersEntity.cols.id], }, ], }); builder.buildTable(postsEntity, options); expect(options.tables.has("posts")).toBe(true); const table = options.tables.get("posts"); expect(table).toBeDefined(); }); it("should build table with unique constraint", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), username: z.text(), }), constraints: [ { columns: ["email"], unique: true, name: "unique_user_email", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with check constraint", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), age: z.integer(), }), constraints: [ { columns: ["age"], check: sql`age >= 0 AND age <= 150`, name: "valid_age_range", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with composite unique constraint", () => { const entity = $entity({ name: "user_settings", schema: z.object({ id: db.primaryKey(), userId: z.text(), settingKey: z.text(), settingValue: z.text(), }), constraints: [ { columns: ["userId", "settingKey"], unique: true, name: "unique_user_setting", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("user_settings")).toBe(true); const table = options.tables.get("user_settings"); expect(table).toBeDefined(); }); it("should build table with custom config function", () => { const customConfig = () => []; const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), }), config: customConfig, }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); // Config is passed to the table but may not be called immediately // The important thing is that the table is created with the config const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with all options combined", () => { // Create referenced table first const rolesEntity = $entity({ name: "roles", schema: z.object({ id: db.primaryKey(), name: z.text(), }), }); builder.buildTable(rolesEntity, options); const rolesTable = options.tables.get("roles") as any; const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), username: z.text(), age: z.integer(), roleId: z.text(), createdAt: z.string().meta({ format: "date-time" }), }), indexes: [ "email", { column: "username", unique: true, }, { columns: ["roleId", "createdAt"], name: "role_created_idx", }, ], foreignKeys: [ { columns: ["roleId"], foreignColumns: [() => rolesEntity.cols.id], }, ], constraints: [ { columns: ["age"], check: sql`age >= 18`, name: "adult_users_only", }, { columns: ["email", "username"], unique: true, name: "unique_email_username", }, ], config: (self) => [], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should convert camelCase to snake_case for column names", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), firstName: z.text(), lastName: z.text(), emailAddress: z.email(), }), indexes: ["firstName", "lastName"], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); // Column names should be converted to snake_case internally }); it("should build table with plain bigint column", () => { const entity = $entity({ name: "counters", schema: z.object({ id: db.primaryKey(), totalViews: z.bigint(), }), }); builder.buildTable(entity, options); expect(options.tables.has("counters")).toBe(true); const table = options.tables.get("counters") as any; expect(table).toBeDefined(); // Verify the bigint column exists expect(table.totalViews).toBeDefined(); }); it("should use correct column name for identity primary key", () => { const entity = $entity({ name: "events", schema: z.object({ eventId: db.identityPrimaryKey(), name: z.text(), }), }); builder.buildTable(entity, options); expect(options.tables.has("events")).toBe(true); const table = options.tables.get("events") as any; expect(table).toBeDefined(); expect(table.eventId).toBeDefined(); // Column should use snake_case name expect(table.eventId.config.name).toBe("event_id"); }); it("should build table with expression-based index", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), realm: z.text(), username: z.text(), }), indexes: [ { expressions: (self) => [self.realm, sql`LOWER(${self.username})`], unique: true, name: "users_realm_username_lower_idx", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with expression-only index", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), }), indexes: [ { expressions: (self) => [sql`LOWER(${self.email})`], unique: true, name: "users_email_lower_idx", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); }); it("should not recreate table if it already exists", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), }), }); builder.buildTable(entity, options); const firstTable = options.tables.get("users"); // Try to build again builder.buildTable(entity, options); const secondTable = options.tables.get("users"); expect(firstTable).toBe(secondTable); }); }); describe("SqliteModelBuilder", () => { let builder: SqliteModelBuilder; let options: { tables: Map<string, unknown>; enums: Map<string, unknown>; schemas: Map<string, unknown>; schema: string; }; beforeEach(() => { builder = new SqliteModelBuilder(); options = { tables: new Map(), enums: new Map(), schemas: new Map(), schema: "public", }; }); it("should build a basic table", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), email: z.email(), }), }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with indexes", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), username: z.text(), }), indexes: [ "email", { column: "username", unique: true, }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with composite index", () => { const entity = $entity({ name: "posts", schema: z.object({ id: db.primaryKey(), userId: z.text(), createdAt: z.string().meta({ format: "date-time" }), }), indexes: [ { columns: ["userId", "createdAt"], unique: false, }, ], }); builder.buildTable(entity, options); expect(options.tables.has("posts")).toBe(true); const table = options.tables.get("posts"); expect(table).toBeDefined(); }); it("should build table with foreign keys", () => { // First create the users table const usersEntity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), name: z.text(), }), }); builder.buildTable(usersEntity, options); const usersTable = options.tables.get("users") as any; // Then create posts table with foreign key const postsEntity = $entity({ name: "posts", schema: z.object({ id: db.primaryKey(), userId: z.text(), title: z.text(), }), foreignKeys: [ { columns: ["userId"], foreignColumns: [() => usersEntity.cols.id], }, ], }); builder.buildTable(postsEntity, options); expect(options.tables.has("posts")).toBe(true); const table = options.tables.get("posts"); expect(table).toBeDefined(); }); it("should build table with constraints", () => { const entity = $entity({ name: "products", schema: z.object({ id: db.primaryKey(), name: z.text(), sku: z.text(), price: z.number(), }), constraints: [ { columns: ["sku"], unique: true, name: "unique_sku", }, { columns: ["price"], check: sql`price > 0`, name: "positive_price", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("products")).toBe(true); const table = options.tables.get("products"); expect(table).toBeDefined(); }); it("should build table with expression-based index", () => { const entity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), realm: z.text(), username: z.text(), }), indexes: [ { expressions: (self) => [self.realm, sql`LOWER(${self.username})`], unique: true, name: "users_realm_username_lower_idx", }, ], }); builder.buildTable(entity, options); expect(options.tables.has("users")).toBe(true); const table = options.tables.get("users"); expect(table).toBeDefined(); }); it("should build table with an array-of-enum column (stored as json)", () => { // Regression: an enum array element used to fall through to the // "Unsupported schema for field" throw — the Sqlite builder lacked the // enum branch the Postgres builder already had. Stored as JSON, exactly // like an array-of-string column. const entity = $entity({ name: "products", schema: z.object({ id: db.primaryKey(), channels: z.array(z.enum(["desk", "portal", "mobile"])), }), }); expect(() => builder.buildTable(entity, options)).not.toThrow(); expect(options.tables.has("products")).toBe(true); expect(options.tables.get("products")).toBeDefined(); }); it("should throw error for sequences", () => { expect(() => { builder.buildSequence({ name: "test_seq", options: {} } as any, { sequences: new Map(), schema: "public", }); }).toThrow("SQLite does not support sequences"); }); it("should build table with all options combined", () => { const entity = $entity({ name: "complex_table", schema: z.object({ id: db.primaryKey(), name: z.text(), email: z.email(), status: z.text(), }), indexes: [ "name", { columns: ["email", "status"], unique: true, name: "unique_email_status", }, ], constraints: [ { columns: ["status"], check: sql`status IN ('active', 'inactive')`, name: "valid_status", }, ], config: (self) => [], }); builder.buildTable(entity, options); expect(options.tables.has("complex_table")).toBe(true); const table = options.tables.get("complex_table"); expect(table).toBeDefined(); }); }); describe("abstract methods", () => { it("should convert camelCase to snake_case correctly", () => { const builder = new PostgresModelBuilder(); // Access the protected method via type assertion const toColumnName = (builder as any).toColumnName.bind(builder); expect(toColumnName("id")).toBe("id"); expect(toColumnName("userId")).toBe("user_id"); expect(toColumnName("firstName")).toBe("first_name"); expect(toColumnName("emailAddress")).toBe("email_address"); expect(toColumnName("createdAt")).toBe("created_at"); expect(toColumnName("isActiveUser")).toBe("is_active_user"); }); }); describe("type safety", () => { it("should enforce type-safe foreign key references", () => { const roleEntity = $entity({ name: "roles", schema: z.object({ id: db.primaryKey(), name: z.string(), }), }); const userEntity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), email: z.email(), roleId: z.integer(), }), foreignKeys: [ { columns: ["roleId"], // This should reference an EntityColumn from roleEntity foreignColumns: [() => roleEntity.cols.id], }, ], }); // Test that we can access the column references expect(roleEntity.cols.id).toBeDefined(); expect(roleEntity.cols.id.name).toBe("id"); expect(roleEntity.cols.id.entity).toBe(roleEntity); expect(userEntity.cols.email).toBeDefined(); expect(userEntity.cols.email.name).toBe("email"); // Test that foreign key references work const fkDef = userEntity.options.foreignKeys![0]; expect(fkDef.columns).toEqual(["roleId"]); // Execute the foreign column reference function const foreignCol = fkDef.foreignColumns[0](); expect(foreignCol).toBeDefined(); expect(foreignCol.name).toBe("id"); expect(foreignCol.entity.name).toBe("roles"); }); it("should support multiple foreign key references", () => { const categoryEntity = $entity({ name: "categories", schema: z.object({ id: db.primaryKey(), name: z.string(), }), }); const userEntity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), username: z.string(), }), }); const postEntity = $entity({ name: "posts", schema: z.object({ id: db.primaryKey(), title: z.string(), userId: z.integer(), categoryId: z.integer(), }), foreignKeys: [ { columns: ["userId"], foreignColumns: [() => userEntity.cols.id], }, { columns: ["categoryId"], foreignColumns: [() => categoryEntity.cols.id], }, ], }); const fks = postEntity.options.foreignKeys!; expect(fks).toHaveLength(2); // Check first foreign key (userId -> users.id) const userFk = fks[0]; const userForeignCol = userFk.foreignColumns[0](); expect(userForeignCol.entity.name).toBe("users"); expect(userForeignCol.name).toBe("id"); // Check second foreign key (categoryId -> categories.id) const categoryFk = fks[1]; const categoryForeignCol = categoryFk.foreignColumns[0](); expect(categoryForeignCol.entity.name).toBe("categories"); expect(categoryForeignCol.name).toBe("id"); }); it("should support composite foreign keys", () => { const tenantEntity = $entity({ name: "tenants", schema: z.object({ id: db.primaryKey(), code: z.string(), name: z.string(), }), }); const userEntity = $entity({ name: "users", schema: z.object({ id: db.primaryKey(), tenantId: z.integer(), tenantCode: z.string(), username: z.string(), }), foreignKeys: [ { columns: ["tenantId", "tenantCode"], foreignColumns: [ () => tenantEntity.cols.id, () => tenantEntity.cols.code, ], }, ], }); const fk = userEntity.options.foreignKeys![0]; expect(fk.columns).toEqual(["tenantId", "tenantCode"]); expect(fk.foreignColumns).toHaveLength(2); const foreignCol1 = fk.foreignColumns[0](); const foreignCol2 = fk.foreignColumns[1](); expect(foreignCol1.name).toBe("id"); expect(foreignCol2.name).toBe("code"); expect(foreignCol1.entity.name).toBe("tenants"); expect(foreignCol2.entity.name).toBe("tenants"); }); it("should maintain referential integrity through EntityColumn", () => { const entity1 = $entity({ name: "entity1", schema: z.object({ id: db.primaryKey(), value: z.string(), }), }); const entity2 = $entity({ name: "entity2", schema: z.object({ id: db.primaryKey(), entity1Id: z.integer(), entity1Value: z.string(), }), foreignKeys: [ { name: "entity2_entity1_fk", columns: ["entity1Id", "entity1Value"], foreignColumns: [() => entity1.cols.id, () => entity1.cols.value], }, ], }); // Verify that the foreign key correctly references entity1's columns const fk = entity2.options.foreignKeys![0]; expect(fk.name).toBe("entity2_entity1_fk"); const idRef = fk.foreignColumns[0](); const valueRef = fk.foreignColumns[1](); // Both columns should reference the same entity expect(idRef.entity).toBe(valueRef.entity); expect(idRef.entity.name).toBe("entity1"); // But different columns expect(idRef.name).toBe("id"); expect(valueRef.name).toBe("value"); }); }); describe("integration", () => { it("should handle all entity options correctly (sqlite)", async () => { await testModelBuilderFeatures( Alepha.create({ env: { DATABASE_URL: "sqlite://:memory:" } }), ); }); it("should handle all entity options correctly (postgres)", async () => { await testModelBuilderFeatures(Alepha.create().with(AlephaOrmPostgres)); }); it("should handle custom config (sqlite)", async () => { await testCustomConfig( Alepha.create({ env: { DATABASE_URL: "sqlite://:memory:" } }), ); }); it("should handle custom config (postgres)", async () => { await testCustomConfig(Alepha.create().with(AlephaOrmPostgres)); }); it("should handle complex nested relationships (sqlite)", async () => { await testComplexRelationships( Alepha.create({ env: { DATABASE_URL: "sqlite://:memory:" } }), ); }); it("should handle complex nested relationships (postgres)", async () => { await testComplexRelationships(Alepha.create().with(AlephaOrmPostgres)); }); it("should enforce LOWER expression unique index (sqlite)", async () => { await testExpressionIndex( Alepha.create({ env: { DATABASE_URL: "sqlite://:memory:" } }), ); }); it("should enforce LOWER expression unique index (postgres)", async () => { await testExpressionIndex(Alepha.create().with(AlephaOrmPostgres)); }); }); });