Skip to content

第二章: 实体与列类型

本章目标

  • 掌握 @Entity 的常用选项(表名、引擎、注释)
  • 记住 TypeScript 类型与 MySQL 列类型的对应关系
  • 会用 @Column 的全部常用选项控制列行为
  • 理解 transformer(值转换器)并能写一个简单的双向转换
  • 知道 decimal、datetime、enum 这三个高频坑

核心概念

实体(Entity)就像表格的设计图纸:类名对应表名,属性对应列,装饰器(@Entity@Column)就是图纸上的标注——这一列多长、能不能为空、有没有默认值。

TypeORM 启动时(synchronize: true)读图纸建表;运行时把行数据按图纸"浇铸"成类实例。

知识点详解

@Entity 选项(以 TypeORM 1.1.1 的 EntityOptions 为准)

ts
@Entity({
  name: "t_member", // 数据库表名;不指定则按命名策略生成(默认类名小写)
  engine: "InnoDB", // 建表引擎(仅建表时生效;表已存在时改了也没用)
  comment: "会员表", // 表注释
})
class Member {
  /* ... */
}
选项说明
name表名
engineMySQL 引擎,如 "InnoDB" / "MyISAM"
comment表注释
database指定该实体属于哪个数据库(MySQL/SqlServer 用)
schemaSchema 名(Postgres/SqlServer 用,MySQL 用 database
synchronize设为 false 则该实体不参与自动同步和迁移
orderBy查询该表时的默认排序

TypeScript 类型 ↔ MySQL 列类型映射(实测结果)

TypeScript 写法MySQL 实际列类型读回的 JS 类型
@PrimaryGeneratedColumn() id: numberint + AUTO_INCREMENTnumber
@Column() name: stringvarchar(255)string
@Column({ type: "int" }) n: numberintnumber
@Column({ type: "decimal", precision: 10, scale: 2 })decimal(10,2)⚠️ string"299.90"
@Column({ type: "boolean" }) b: booleantinyintboolean
@Column({ type: "datetime" }) d: DatedatetimeDate
@Column({ type: "text" }) / { type: "longtext" }text / longtextstring
@Column({ type: "json" })jsonobject(自动解析)
@Column({ type: "enum", enum: [...] })enum('a','b','c')string
@Column({ type: "simple-json" })text(内容是一段 JSON 字符串)object
@Column({ type: "simple-array" })text(逗号拼接)string[]
@Column({ type: "blob" }) data: BufferblobBuffer

说明:simple-json / simple-array 是 TypeORM 的"伪类型"——数据库里只是 text,进出时由 TypeORM 帮你序列化/反序列化。json 才是 MySQL 原生 JSON 列,可以做 JSON 路径查询。

@Column 选项全解(以 1.1.1 的 ColumnOptions 为准)

选项作用示例
type列类型"varchar""int""decimal""text""json"
name数据库列名(与属性名不同时用){ name: "user_name" }
length长度{ length: 50 } → varchar(50)
nullable允许 NULL,默认 false{ nullable: true }
unique唯一约束{ unique: true }
default数据库默认值{ default: 0 }
comment列注释{ comment: "昵称" }
select默认查询是否返回,默认 true密码字段设 select: false
insertsave/insert 是否写入,默认 true数据库自维护列可设 false
updatesave/update 是否更新,默认 true创建时间、注册来源设 update: false
precision / scaledecimal 的总位数/小数位{ precision: 10, scale: 2 }
unsigned无符号(仅 MySQL){ type: "int", unsigned: true }
charset / collation列级字符集/排序规则{ collation: "utf8mb4_bin" }
onUpdateON UPDATE 触发器(仅 MySQL){ onUpdate: "CURRENT_TIMESTAMP" }
enum / enumName枚举候选值/枚举约束名{ type: "enum", enum: ["a","b"] }
transformer值转换器(见下)

⚠️ 1.1.1 里没有 widthzerofill 选项(早期版本的 int 显示宽度已随 MySQL 8 淘汰),写了会直接报类型错误。

transformer:值转换器

实体里的类型和数据库里的存储形式不一致时,用 transformer 做双向转换:

ts
import { ValueTransformer } from "typeorm";

// JS 里是 boolean,数据库里存 'Y'/'N' 字符串
const ynTransformer: ValueTransformer = {
  to: (value?: boolean) => (value ? "Y" : "N"),   // JS → 数据库
  from: (value?: string) => value === "Y",         // 数据库 → JS
};

@Column({ type: "char", length: 1, transformer: ynTransformer })
isVip: boolean;

注意:用了 transformer 就要自己选好底层列类型。上面的列如果写成 boolean + length 会直接报错(boolean/tinyint 不支持 length),所以要显式 type: "char", length: 1

动手实验

演示(1)

ts
import "reflect-metadata";
import { DataSource, Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import {
  createDatabaseIfNotExists,
  BASE_DATASOURCE_OPTIONS,
} from "../utils/db";

@Entity()
class Product {
  @PrimaryGeneratedColumn() // int + AUTO_INCREMENT
  id: number;

  @Column() // string → varchar(255)
  name: string;

  @Column({ type: "int" }) // 显式指定 int
  stock: number;

  @Column({ type: "decimal", precision: 10, scale: 2 }) // decimal(10,2)
  price: number;

  @Column({ type: "boolean" }) // boolean → tinyint(1)
  onSale: boolean;

  @Column({ type: "datetime" }) // Date → datetime
  producedAt: Date;

  @Column({ type: "text", nullable: true }) // 长文本
  description: string | null;

  @Column({ type: "longtext", nullable: true }) // 超长文本
  detailHtml: string | null;

  @Column({ type: "json", nullable: true }) // 原生 JSON
  attrs: Record<string, any> | null;

  @Column({
    type: "enum",
    enum: ["small", "medium", "large"],
    default: "medium",
  })
  size: "small" | "medium" | "large";

  @Column({ type: "simple-json", nullable: true }) // 存在 text 列里的 JSON 字符串
  extra: Record<string, any> | null;

  @Column({ type: "simple-array", nullable: true }) // 逗号拼接存 text 列
  tags: string[] | null;

  @Column({ type: "blob", nullable: true }) // 二进制
  thumbnail: Buffer | null;
}

async function main() {
  const dbName = "typeorm_ch02";
  await createDatabaseIfNotExists(dbName);

  const ds = new DataSource({
    ...BASE_DATASOURCE_OPTIONS,
    database: dbName,
    entities: [Product],
    synchronize: true,
    logging: false,
  });
  await ds.initialize();
  console.log("✅ 1. DataSource 初始化成功,product 表已同步");

  // ---------- 1. 打印 MySQL 中真实的列类型 ----------
  const columns: any[] = await ds.query(
    `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
       FROM information_schema.COLUMNS
      WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'product'
      ORDER BY ORDINAL_POSITION`,
    [dbName],
  );
  console.log("✅ 2. MySQL 实际列类型(information_schema):");
  for (const c of columns) {
    console.log(
      `   ${c.COLUMN_NAME.padEnd(12)} ${c.COLUMN_TYPE.padEnd(16)} NULL=${c.IS_NULLABLE} 默认=${c.COLUMN_DEFAULT}`,
    );
  }

  // ---------- 2. 插入一条 ----------
  const repo = ds.getRepository(Product);
  await repo.clear();

  const saved = await repo.save({
    name: "机械键盘",
    stock: 100,
    price: 299.9,
    onSale: true,
    producedAt: new Date("2026-01-15T08:30:00"),
    description: "87 键,热插拔",
    detailHtml: "<p>详细介绍略……</p>",
    attrs: { color: "黑", weightKg: 1.2 },
    size: "medium",
    extra: { gift: true },
    tags: ["电竞", "办公", "RGB"],
    thumbnail: Buffer.from([0x89, 0x50, 0x4e, 0x47]), // PNG 文件头
  });
  console.log("✅ 3. 插入成功,id =", saved.id);

  // ---------- 3. 读回,逐个观察 JS 侧类型 ----------
  const found = (await repo.findOneBy({ id: saved.id }))!;
  console.log("✅ 4. 读回后的字段与 JS 类型:");
  const show = (label: string, v: any) =>
    console.log(
      `   ${label.padEnd(12)} typeof=${Buffer.isBuffer(v) ? "Buffer" : Array.isArray(v) ? "array" : typeof v} 值=`,
      v,
    );
  show("stock", found.stock);
  show("price", found.price); // ⚠️ decimal 读回是字符串!
  show("onSale", found.onSale);
  show("producedAt", found.producedAt);
  show("attrs", found.attrs);
  show("size", found.size);
  show("extra", found.extra);
  show("tags", found.tags);
  show("thumbnail", found.thumbnail);

  await ds.destroy();
  console.log("🎉 第 02 章演示 1 完成");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

演示(二)

ts
/**
 * 第 02 章 · 演示 2:@Column 选项全解 + @Entity 选项 + transformer
 * - @Entity:自定义表名、engine、comment
 * - @Column:length / nullable / unique / default / comment / select / insert / update / precision / scale / unsigned / charset / collation
 * - transformer:JS 侧 boolean ↔ DB 侧 0/1 的自定义双向转换
 */
import "reflect-metadata";
import {
  DataSource,
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ValueTransformer,
} from "typeorm";
import {
  createDatabaseIfNotExists,
  BASE_DATASOURCE_OPTIONS,
} from "../utils/db";

/** 自定义 transformer:实体里是 boolean,数据库里存 "Y"/"N" 字符串 */
const ynTransformer: ValueTransformer = {
  to: (value?: boolean) => (value ? "Y" : "N"), // JS → 数据库
  from: (value?: string) => value === "Y", // 数据库 → JS
};

@Entity({
  name: "t_member", // 数据库里的表名(默认会用类名小写 member)
  engine: "InnoDB", // 建表引擎
  comment: "会员表(演示 @Entity 选项)",
})
class Member {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50, comment: "昵称" }) // varchar(50)
  nickname: string;

  @Column({ length: 100, unique: true, comment: "邮箱,唯一索引" })
  email: string;

  @Column({ type: "int", unsigned: true, default: 0, comment: "积分" })
  points: number;

  @Column({
    type: "decimal",
    precision: 8,
    scale: 2,
    default: 0,
    comment: "余额",
  })
  balance: number;

  @Column({ nullable: true, comment: "可空字段" })
  bio: string | null;

  @Column({
    select: false, // 默认查询不返回此字段(适合密码等大/敏感字段)
    comment: "密码哈希",
  })
  passwordHash: string;

  @Column({
    update: false, // 只允许首次插入写入,之后的 save/update 不会更新它
    comment: "注册来源,写入后不可改",
  })
  registerFrom: string;

  @Column({
    charset: "utf8mb4",
    collation: "utf8mb4_bin", // 区分大小写的排序规则
    comment: "校验码,大小写敏感",
  })
  verifyCode: string;

  @Column({
    type: "char",
    length: 1,
    transformer: ynTransformer,
    comment: "是否 VIP,存 Y/N",
  })
  isVip: boolean;
}

async function main() {
  const dbName = "typeorm_ch02";
  await createDatabaseIfNotExists(dbName);

  const ds = new DataSource({
    ...BASE_DATASOURCE_OPTIONS,
    database: dbName,
    entities: [Member],
    synchronize: true,
    logging: false,
  });
  await ds.initialize();
  console.log("✅ 1. t_member 表已同步(自定义表名/引擎/表注释)");

  const table: any[] = await ds.query(
    `SELECT TABLE_NAME, ENGINE, TABLE_COMMENT FROM information_schema.TABLES
      WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 't_member'`,
    [dbName],
  );
  console.log("   表信息:", table[0]);

  const repo = ds.getRepository(Member);
  await repo.clear();

  // ---------- 2. 插入(default 生效:points/balance 不传也有值) ----------
  const saved = await repo.save({
    nickname: "阿强",
    email: "aqiang@example.com",
    bio: null,
    passwordHash: "HASH_ABC123",
    registerFrom: "web",
    verifyCode: "Ab12",
    isVip: true,
  });
  console.log("✅ 2. 插入成功(未传 points/balance,走数据库默认值):");
  console.log(
    "   points =",
    saved.points,
    "| balance =",
    saved.balance,
    "| bio =",
    saved.bio,
  );

  // ---------- 3. select: false 的效果 ----------
  const found = (await repo.findOneBy({ id: saved.id }))!;
  console.log("✅ 3. 默认 find 不返回 passwordHash:", found.passwordHash); // undefined
  // 想查出来,要在 find 选项里显式 select(第 05 章细讲)
  const withPwd = await repo.findOne({
    where: { id: saved.id },
    select: { id: true, passwordHash: true },
  });
  console.log("   显式 select 后:", withPwd!.passwordHash);

  // ---------- 4. update: false 的效果 ----------
  await repo.save({ id: saved.id, registerFrom: "app", nickname: "阿强2" });
  const afterSave = (await repo.findOneBy({ id: saved.id }))!;
  console.log(
    "✅ 4. save 尝试改 registerFrom='app':实际仍是 =",
    afterSave.registerFrom,
    ";nickname 变成 =",
    afterSave.nickname,
  );

  // ---------- 5. transformer 的效果 ----------
  console.log("✅ 5. transformer:JS 侧 isVip =", afterSave.isVip, "(boolean)");
  const raw: any[] = await ds.query(`SELECT isVip FROM t_member WHERE id = ?`, [
    saved.id,
  ]);
  console.log("   数据库里真实存储 =", raw[0].isVip, "(字符串 Y/N)");

  // ---------- 6. unique 约束的效果 ----------
  try {
    await repo.insert({
      nickname: "重复邮箱",
      email: "aqiang@example.com",
      passwordHash: "x",
      registerFrom: "web",
      verifyCode: "Zz99",
      isVip: false,
    });
  } catch (e: any) {
    console.log(
      "✅ 6. unique 生效:重复 email 插入被拒 →",
      e.code,
      e.sqlMessage?.slice(0, 50),
    );
  }

  await ds.destroy();
  console.log("🎉 第 02 章演示 2 完成");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

小结

  • @Entity 控制表级:表名、引擎、注释;@Column 控制列级:类型、长度、约束、默认值、读写行为
  • string→varchar(255)、number→int、boolean→tinyint、Date→datetime;json 原生、simple-json/simple-array 实为 text
  • select/insert/update 三个开关控制列在查询、插入、更新中的参与与否
  • transformer 是 JS 值 ↔ 数据库存储值的双向翻译器
  • 三大高频坑:decimal 读出是字符串、datetime 时区、enum 变更需改表