演员和角色

如果我们要像这样保存演员和角色记录:

Actor/Actress Character
Keanu Reeves Neo
Laurence Fishburne Morpheus
Carrie-Anne Moss Trinity

我们需要一张 演员(MovieCast) 表,其内容如:

MovieCastId MovieId PersonId Character
11 2 (Matrix) 77 (Keanu Reeves) Neo
12 2 (Matrix) 99 (Laurence Fisburne) Morpheus
13 2 (Matrix) 30 (Carrie-Anne Moss) Trinitity

很显然,我们还需要一张 人员(Person) 表,因为我们要通过其 id 关联演员信息。

这里用 人员(Person) 表示演员会更好,因为演员后来可能成为导演,编剧等。

创建 人员(Person) 和 演员(MovieCast) 表

现在是时候创建这两张表的迁移类(migration):

MovieTutorial.Web/Modules/Common/Migrations/DefaultDB/ DefaultDB_20160528_141600_PersonAndMovieCast.cs:

  1. using FluentMigrator;
  2. using System;
  3. namespace MovieTutorial.Migrations.DefaultDB
  4. {
  5. [Migration(20151025170200)]
  6. public class DefaultDB_20160528_141600_PersonAndMovieCast : Migration
  7. {
  8. public override void Up()
  9. {
  10. Create.Table("Person").InSchema("mov")
  11. .WithColumn("PersonId").AsInt32().Identity()
  12. .PrimaryKey().NotNullable()
  13. .WithColumn("Firstname").AsString(50).NotNullable()
  14. .WithColumn("Lastname").AsString(50).NotNullable()
  15. .WithColumn("BirthDate").AsDateTime().Nullable()
  16. .WithColumn("BirthPlace").AsString(100).Nullable()
  17. .WithColumn("Gender").AsInt32().Nullable()
  18. .WithColumn("Height").AsInt32().Nullable();
  19. Create.Table("MovieCast").InSchema("mov")
  20. .WithColumn("MovieCastId").AsInt32().Identity()
  21. .PrimaryKey().NotNullable()
  22. .WithColumn("MovieId").AsInt32().NotNullable()
  23. .ForeignKey("FK_MovieCast_MovieId", "mov", "Movie", "MovieId")
  24. .WithColumn("PersonId").AsInt32().NotNullable()
  25. .ForeignKey("FK_MovieCast_PersonId", "mov", "Person", "PersonId")
  26. .WithColumn("Character").AsString(50).Nullable();
  27. }
  28. public override void Down()
  29. {
  30. }
  31. }
  32. }

为 人员(Person) 表生成代码

我们首先为 人员(Person) 表生成代码:

Person Code Generation

把 性别(Gender) 修改为枚举

人员(Person)表的 性别(Gender)列应该存放枚举类型的数据,在 PersonRow.cs 所在文件夹创建文件 Gender.cs,并定义一个 Gender 枚举:

  1. using Serenity.ComponentModel;
  2. using System.ComponentModel;
  3. namespace MovieTutorial.MovieDB
  4. {
  5. [EnumKey("MovieDB.Gender")]
  6. public enum Gender
  7. {
  8. [Description("Male")]
  9. Male = 1,
  10. [Description("Female")]
  11. Female = 2
  12. }
  13. }

修改 PersonRow.cs 定义的 Gender 属性,如:

  1. //...
  2. [DisplayName("Gender")]
  3. public Gender? Gender
  4. {
  5. get { return (Gender?)Fields.Gender[this]; }
  6. set { Fields.Gender[this] = (Int32?)value; }
  7. }
  8. //...

为保持一致性,把 PersonForm.cs 和 PersonColumns.cs 的 Gender 属性类型 Int32 改为 Gender 枚举类型。

重新生成 T4 模板

当我们定义并使用一个新的枚举,我们应该重新生成解决方案,以便 T4 模板重新生成内容:

现在当你启动项目后,你应该可以输入演员信息:

Person Editing

声明 FullName 字段

编辑对话框的标题只显示人员的 姓氏(Carrie-Anne),显示完整名字会更好,并且搜索也应该按列表中的全名进行搜索。

因此,让我们编辑 PersonRow.cs:

  1. namespace MovieTutorial.MovieDB.Entities
  2. {
  3. //...
  4. public sealed class PersonRow : Row, IIdRow, INameRow
  5. {
  6. //... remove QuickSearch from FirstName
  7. [DisplayName("First Name"), Size(50), NotNull]
  8. public String Firstname
  9. {
  10. get { return Fields.Firstname[this]; }
  11. set { Fields.Firstname[this] = value; }
  12. }
  13. [DisplayName("Last Name"), Size(50), NotNull]
  14. public String Lastname
  15. {
  16. get { return Fields.Lastname[this]; }
  17. set { Fields.Lastname[this] = value; }
  18. }
  19. [DisplayName("Full Name"),
  20. Expression("(t0.Firstname + ' ' + t0.Lastname)"), QuickSearch]
  21. public String Fullname
  22. {
  23. get { return Fields.Fullname[this]; }
  24. set { Fields.Fullname[this] = value; }
  25. }
  26. //... change NameField to Fullname
  27. StringField INameRow.NameField
  28. {
  29. get { return Fields.Fullname; }
  30. }
  31. //...
  32. public class RowFields : RowFieldsBase
  33. {
  34. public readonly Int32Field PersonId;
  35. public readonly StringField Firstname;
  36. public readonly StringField Lastname;
  37. public readonly StringField Fullname;
  38. //...
  39. }
  40. }
  41. }

我们在 Fullname 属性上面添加 SQL 表达式 Expression(“(t0.Firstname + ‘ ‘ + t0.Lastname)”),因此,这是一个在服务器端计算的字段。

通过给 FullName 属性添加 QuickSearch 特性,列表现在将默认以 Fullname 字段进行搜索,而不再以 Firstname 搜索。

但对话框显示的仍然是 Firstname。要显示 Fullname,我们需要生成项目让 T4 模板转换模板。

为什么必须转换模板吗?

查看 PersonDialog.ts 文件后你就会明白:

  1. namespace MovieTutorial.MovieDB {
  2. @Serenity.Decorators.registerClass()
  3. @Serenity.Decorators.responsive()
  4. export class PersonDialog extends Serenity.EntityDialog<PersonRow, any> {
  5. protected getFormKey() { return PersonForm.formKey; }
  6. protected getIdProperty() { return PersonRow.idProperty; }
  7. protected getLocalTextPrefix() { return PersonRow.localTextPrefix; }
  8. protected getNameProperty() { return PersonRow.nameProperty; }
  9. protected getService() { return PersonService.baseUrl; }
  10. protected form = new PersonForm(this.idPrefix);
  11. }
  12. }

在这里我们看到 getNameProperty() 方法返回 PersonRow.nameProperty。TypeScript 文件(MovieDB.PersonRow.ts) 中的 PersonRow 就是由我们的 T4 模板生成的。

因此,除非我们转换 T4 模板,否则我们在 PersonRow.cs 中对 name 属性的更改将不会反映在 *Modules/Common/Imports/ServerTypings/ ServerTypings.tt” 下的 MovieDB.PersonRow.ts 文件:

  1. namespace MovieTutorial.MovieDB {
  2. export interface PersonRow {
  3. PersonId?: number;
  4. Firstname?: string;
  5. Lastname?: string;
  6. Fullname?: string;
  7. //...
  8. }
  9. export namespace PersonRow {
  10. export const idProperty = 'PersonId';
  11. export const nameProperty = 'Fullname';
  12. export const localTextPrefix = 'MovieDB.Person';
  13. export namespace Fields {
  14. export declare const PersonId: string;
  15. //...
  16. }
  17. //...
  18. }
  19. }

此元数据 (PersonRow 的 nameProperty 常量) 是由 ServerTypings.tt 生成 TypeScript 文件(MovieDB.PersonRow.ts)的代码。

同样,idPropertylocalTextPrefixEnum Types 等也是由 ServerTypings.tt 文件生成的。因此,当你的更改对生成文件中的元数据有影响时,你就应该转换 T4 模板以将该修改信息应用到对应的 TypeScript 文件中。

你应该总是在转换模板之前生成项目,因为 T4 模板文件引用了 MovieTutorial.Web 项目输出的 DLL。否则你将会为一个旧版本的 Web 项目生成代码。

声明 PersonRow 检索脚本(Lookup Script)

让我们继续在 PersonRow.cs 文件中为 人员(Person)表添加一个 LookupScript 特性:

  1. namespace MovieTutorial.MovieDB.Entities
  2. {
  3. //...
  4. [LookupScript("MovieDB.Person")]
  5. public sealed class PersonRow : Row, IIdRow, INameRow
  6. //...

我们会在稍后的编辑影片中使用它。

再次生成项目,你将看到 MovieDB.PersonRow.ts 中有一个返回 lookupKey 类型的 getLookup() 方法。

  1. namespace MovieTutorial.MovieDB {
  2. export interface PersonRow {
  3. //...
  4. }
  5. export namespace PersonRow {
  6. export const idProperty = 'PersonId';
  7. export const nameProperty = 'Fullname';
  8. export const localTextPrefix = 'MovieDB.Person';
  9. export const lookupKey = 'MovieDB.Person';
  10. export function getLookup(): Q.Lookup<PersonRow> {
  11. return Q.getLookup<PersonRow>('MovieDB.Person');
  12. }
  13. //...
  14. }

为 演员(MovieCast)表生成代码

使用 sergen 为 演员(MovieCast)表生成代码:

Movie Cast Code Generation

生成代码之后,由于我们不需要一个单独的页面编辑 演员(MovieCast)表,所以你可以删除下列的文件:

  1. MovieCastIndex.cshtml
  2. MovieCastPage.cs
  3. MovieDialog.ts
  4. MovieGrid.ts

再次生成项目。

演员(MovieCast)表主从关系的编辑逻辑

到目前为止,我们为每张表创建一个页面,并在该页面显示列表和编辑记录。这一次,我们将使用不同的策略。

我们将在影片对话框中列出演员信息,并允许他们与影片一起进行编辑。此外,演员也将与影片实体在同一个事务中保存。

因此,编辑演员的信息将保存在内存中,当用户点击影片对话框中的保存按钮时,影片和其演员会同时(同一事务)保存到数据库。

也有可能需要单独编辑演员信息的情况,但我们这里仅演示其中一种实现方式。

对于一些主从关系,如订单/订单详细,由于需要保持主从表的一致性,从表不应该允许进行独立编辑。 Serene 已经在 Northwind/Order 的编辑对话框中为该情况提供了示例。

创建演员(MovieCast)列表的编辑器

在 MovieCastRow.cs (位于 MovieTutorial.Web/Modules/MovieDB/MovieCast/)目录下面,创建文件 MovieCastEditor.ts,其内容如下:

  1. /// <reference path="../../Common/Helpers/GridEditorBase.ts" />
  2. namespace MovieTutorial.MovieDB {
  3. @Serenity.Decorators.registerEditor()
  4. export class MovieCastEditor
  5. extends Common.GridEditorBase<MovieCastRow> {
  6. protected getColumnsKey() { return "MovieDB.MovieCast"; }
  7. protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
  8. constructor(container: JQuery) {
  9. super(container);
  10. }
  11. }
  12. }

此编辑器继承自 Serene 的 Common.GridEditorBase 类,这是一种在内存中编辑内容的特殊网格类型。它也是订单对话框中订单详细信息编辑器的基类。

在文件顶部的 <reference /> 很重要。TypeScript 有输入文件的顺序问题。如果我们不把它放在那里,TypeScript 有时会在输出 MovieCastEditor 之后再输出 GridEditorBase ,导致出现运行时错误。

作为一个经验法则,如果你有一些类继承自另一个项目(不是 Serenity 的类),你应该在文件中包含该基类文件的引用。

这有助于在其他类使用 GridEditorBase 之前,TypeScript 把它转换为 javascript 。

若要从服务器端引用此新的编辑器类型,则需要生成并转换所有模板。

此基类可能会在以后的版本中集成到 Serenity 。在这种情况下,其命名空间可能会变为 Serenity,代替当前的 Serene 或 MovieTutorial。

在影片窗体中使用 MovieCastEditor

打开 MovieForm.cs,在 Description and Storyline 属性之间添加 CastList 属性,如:

  1. namespace MovieTutorial.MovieDB.Forms
  2. {
  3. //...
  4. public class MovieForm
  5. {
  6. public String Title { get; set; }
  7. [TextAreaEditor(Rows = 3)]
  8. public String Description { get; set; }
  9. [MovieCastEditor]
  10. public List<Entities.MovieCastRow> CastList { get; set; }
  11. [TextAreaEditor(Rows = 8)]
  12. public String Storyline { get; set; }
  13. //...
  14. }
  15. }

通过把 [MovieCastEditor] 特性放在 CastList 属性上,我们指定该属性将由新的 MovieCastEditor 类型所定义的 TypeScript 代码编辑。

我们也可以像这样添加特性 [EditorType(“MovieDB.MovieCast”)],但谁真的喜欢硬编码的字符串呢?我可不喜欢…

现在生成并启动你的应用程序。打开一个影片编辑对话框,你将看到我们的新编辑器:

Movie Cast Editor Initial

OK,这看起来很容易完成该功能,但我实话告诉你,我们连功能的一半都还没完成。

New MovieCast 按钮不能工作,需要为它定义一个对话框;网格显示的列不是我想要的并且字段和按钮的标题很不友好……

此外,我们还有更多的细节需要处理,如在服务器端保存和加载演员列表(我们先用手工方式演示其有多困难,然后再用服务行为(service behavior)来演示其有多简单)

配置演员编辑器(MovieCastEditor)使用编辑演员对话框(MovieCastEditDialog)

MovieCastEditor.ts 所在文件夹中创建文件 MovieCastEditDialog.ts,并对其做如下修改:

  1. /// <reference path="../../Common/Helpers/GridEditorDialog.ts" />
  2. namespace MovieTutorial.MovieDB {
  3. @Serenity.Decorators.registerClass()
  4. export class MovieCastEditDialog extends
  5. Common.GridEditorDialog<MovieCastRow> {
  6. protected getFormKey() { return MovieCastForm.formKey; }
  7. protected getNameProperty() { return MovieCastRow.nameProperty; }
  8. protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
  9. protected form: MovieCastForm;
  10. constructor() {
  11. super();
  12. this.form = new MovieCastForm(this.idPrefix);
  13. }
  14. }
  15. }

我们使用另外一个来自 Serene 的基类 Common.GridEditorDialog,编辑订单详细对话框(OrderDetailEditDialog)也是使用该基类。

再次打开 MovieCastEditor.ts 文件, 添加一个 getDialogType 方法并重写 getAddButtonCaption 方法:

  1. /// <reference path="../../Common/Helpers/GridEditorBase.ts" />
  2. namespace MovieTutorial.MovieDB {
  3. @Serenity.Decorators.registerEditor()
  4. export class MovieCastEditor
  5. extends Common.GridEditorBase<MovieCastRow> {
  6. protected getColumnsKey() { return "MovieDB.MovieCast"; }
  7. protected getDialogType() { return MovieCastEditDialog; }
  8. protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
  9. constructor(container: JQuery) {
  10. super(container);
  11. }
  12. protected getAddButtonCaption() {
  13. return "Add";
  14. }
  15. }
  16. }

我们指定演员编辑器(MovieCastEditor)默认使用编辑演员对话框(MovieCastEditDialog), Add 按钮也使用该对话框。

现在,Add 按钮将显示一个对话框,而不再什么也没做。

Movie Cast Edit Dialog

此对话框需要一些 CSS 美化、电影标题和人员名字段接受数字输入(因为它们实际上是 MovieId 和 PersonId 字段)。

编辑 MovieCastForm.cs 文件

MovieCastEditDialoggetFormKey() 方法返回 MovieCastForm.formKey, 所以它当前使用 Sergen 生成的 MovieCastForm.cs。

在 Serenity 中也有可能一个实体有多个窗体。如果我想要一些其他独立的对话框保存 MovieCastForm,例如 演员对话框(MovieCastDialog,我们已经把它删除了),我更愿意定义一个像 MovieCastEditForm 的新窗体,但在这里我没有这样做。

打开并编辑 MovieCastForm.cs:

  1. namespace MovieTutorial.MovieDB.Forms
  2. {
  3. using Serenity.ComponentModel;
  4. using System;
  5. using System.ComponentModel;
  6. [FormScript("MovieDB.MovieCast")]
  7. [BasedOnRow(typeof(Entities.MovieCastRow))]
  8. public class MovieCastForm
  9. {
  10. public Int32 PersonId { get; set; }
  11. public String Character { get; set; }
  12. }
  13. }

我已经删除了 MovieId,因为这个表单在 编辑演员对话框(MovieCastEditDialog) 中使用,所以在 影片对话框(MovieDialog) 中, 演员(MovieCast) 实体自动带有当前被编辑电影的 MovieId,打开 《魔戒》(Lord of the Rings),并添加《黑客帝国》(the Matrix)的演员列表是没有意义的。

下一步,编辑 MovieCastRow.cs:

  1. [ConnectionKey("Default"), TwoLevelCached]
  2. [DisplayName("Movie Casts"), InstanceName("Cast")]
  3. [ReadPermission("Administration")]
  4. [ModifyPermission("Administration")]
  5. public sealed class MovieCastRow : Row, IIdRow, INameRow
  6. {
  7. //...
  8. [DisplayName("Actor/Actress"), NotNull, ForeignKey("[mov].[Person]", "PersonId")]
  9. [LeftJoin("jPerson"), TextualField("PersonFirstname")]
  10. [LookupEditor(typeof(PersonRow))]
  11. public Int32? PersonId
  12. {
  13. get { return Fields.PersonId[this]; }
  14. set { Fields.PersonId[this] = value; }
  15. }

我为 PersonId 属性设置 LookupEditor 特性,正如我在 PersonRow 添加 LookupScript 特性一样,我可以重用这些信息来设置检索键。

我们也可以写成
[LookupEditor(“MovieDB.Person”)]

PersonId 的显示名称修改为 Actor/Actress

同时修改行的 DisplayNameInstanceName特性来设置对话框标题。

生成解决方案,并启动项目,现在 MovieCastEditDialog 有更好的编辑体验。但对话框宽度和高度仍然太大了。

美化 编辑演员对话框(MovieCastEditDialog)

让我们检查 site.less 来了解为什么我们的 MovieCastEditDialog 不会应用样式。

  1. .s-MovieDB-MovieCastDialog {
  2. > .size { width: 650px; }
  3. .caption { width: 150px; }
  4. }

Site.less 底部 CSS 是 MovieCastDialog,而不是 MovieCastEditDialog,因为该类样式是我们自己定义的,而不是用代码生成器生成。

我们创建了一个名为 MovieCastEditDialog 新对话框,所以现在我们的新对话框只有一个 s-MovieDB-MovieCastEditDialog 样式类,但是代码生成器只生成样式 s-MovieDB-MovieCastDialog

Serenity 将自动为对话框元素分配以”s-“为前缀的 CSS 类名。你可以在开发人员工具中通过该特点检查对话框。MovieCastEditDialog 有 s-MovieCastEditDialogs-MovieDB-MovieCastEditDialog 及一些其它类似 ui-dialog 的 CSS 类。

s-ModuleName-TypeName CSS 类帮助区分两个具有相同名称模块的样式。

由于我们实际上并没有打算使用演员对话框(MovieCastDialog,我们已经把它删除了),让我们在 site.less 重命名一个类:

  1. .s-MovieDB-MovieCastEditDialog {
  2. > .size { width: 450px; }
  3. .caption { width: 120px; }
  4. .s-PropertyGrid .categories { height: 120px; }
  5. }

现在 编辑演员对话框(MovieCastEditDialog) 更为美观了:

Movie Cast Dialog Fixed

调整演员编辑器(MovieCastEditor)的列

演员编辑器(MovieCastEditor) 目前使用在 MovieCastColumns.cs 定义的列(因为它在 getColumnsKey() 方法中返回 “MovieDB.MovieCast” )。

我们这里有 MovieCastId、MovieId、PersonId (显示为 Actor/Actress) 和 Character字段,只显示 Actor/Actress 和 Character 字段会更好。

我们想显示演员的全名而不是整数值(PersonId 是整数),所以我们首先在 MovieCastRow.cs 中定义该属性:

  1. namespace MovieTutorial.MovieDB.Entities
  2. {
  3. //...
  4. public sealed class MovieCastRow : Row, IIdRow, INameRow
  5. {
  6. // ...
  7. [DisplayName("Person Firstname"), Expression("jPerson.Firstname")]
  8. public String PersonFirstname
  9. {
  10. get { return Fields.PersonFirstname[this]; }
  11. set { Fields.PersonFirstname[this] = value; }
  12. }
  13. [DisplayName("Person Lastname"), Expression("jPerson.Lastname")]
  14. public String PersonLastname
  15. {
  16. get { return Fields.PersonLastname[this]; }
  17. set { Fields.PersonLastname[this] = value; }
  18. }
  19. [DisplayName("Actor/Actress"),
  20. Expression("(jPerson.Firstname + ' ' + jPerson.Lastname)")]
  21. public String PersonFullname
  22. {
  23. get { return Fields.PersonFullname[this]; }
  24. set { Fields.PersonFullname[this] = value; }
  25. }
  26. // ...
  27. public class RowFields : RowFieldsBase
  28. {
  29. // ...
  30. public readonly StringField PersonFirstname;
  31. public readonly StringField PersonLastname;
  32. public readonly StringField PersonFullname;
  33. // ...
  34. }
  35. }
  36. }

并修改 MovieCastColumns.cs:

  1. namespace MovieTutorial.MovieDB.Columns
  2. {
  3. using Serenity.ComponentModel;
  4. using System;
  5. [ColumnsScript("MovieDB.MovieCast")]
  6. [BasedOnRow(typeof(Entities.MovieCastRow))]
  7. public class MovieCastColumns
  8. {
  9. [EditLink, Width(220)]
  10. public String PersonFullname { get; set; }
  11. [EditLink, Width(150)]
  12. public String Character { get; set; }
  13. }
  14. }

重新生成项目,现在网格列表有更友好的列:

Movie Cast Dialog Fixed

现在尝试添加演员信息,例如,Keanu Reeves / Neo:

Movie Cast Grid Empty Actor

为什么 Actor/Actress 列是空的?

解决 Actor/Actress 列为空的问题

请记住,我们的编辑是在内存中进行的。这里有没有涉及服务的调用。因此,网格列表显示从对话框传给它的任何实体。

当你点击保存按钮时,对话框生成像这样的实体进行保存:

  1. {
  2. PersonId: 7,
  3. Character: 'Neo'
  4. }

这些字段对应你之前在 MovieCastForm.cs 设置的表单字段:

  1. public class MovieCastForm
  2. {
  3. public Int32 PersonId { get; set; }
  4. public String Character { get; set; }
  5. }

但是在网格列表中,我们显示的是这些列:

  1. public class MovieCastColumns
  2. {
  3. public String PersonFullname { get; set; }
  4. public String Character { get; set; }
  5. }

在该实体中并没有 PersonFullname 属性,所以网格列表不会显示它的值。

我们需要自己设置 PersonFullname 属性。首先,转换 T4 模板以获得我们最近添加的 PersonFullname 属性,然后编辑 MovieCastEditor.ts:

  1. /// <reference path="../../Common/Helpers/GridEditorBase.ts" />
  2. namespace MovieTutorial.MovieDB {
  3. @Serenity.Decorators.registerEditor()
  4. export class MovieCastEditor extends Common.GridEditorBase<MovieCastRow> {
  5. //...
  6. protected validateEntity(row: MovieCastRow, id: number) {
  7. if (!super.validateEntity(row, id))
  8. return false;
  9. row.PersonFullname = PersonRow.getLookup()
  10. .itemById[row.PersonId].Fullname;
  11. return true;
  12. }
  13. }
  14. }

ValidateEntity 是一个 Serene 的 GridEditorBase 类的方法,点击保存按钮,在实体添加到网格列表之前调用该方法对实体进行验证,但是我们需要重写它,让其设置 PersonFullname 属性的值而不是验证。

正如我们之前看到的,我们的实体有 PersonId 和 Character 字段。我们可以使用 PersonId 字段的值来确定角色的全名。

为此,我们需要一个把 PersonId 对应到角色全名的字典。幸运的是,人员检索(lookup)有这样的字典。我们可以通过其 getLookup 方法来访问 PersonRow 的检索(lookup)。

另外一种访问 人员检索(lookup)的方法是使用 Q.getLookup(‘MovieDB.Person’)。PersonRow 中的这个方法是 T4 模板定义的快捷方式。

所有的检索(lookups)都有一个 itemById 字典,以允许你通过其 ID 访问该实体类。

检索(lookups)是一个简单的服务器端与客户端共享数据的方法。但他们只适用于小型数据集。

如果一个表有成千上万条记录,就没有理由为其定义检索(lookup)。在这情况下,我们会用一个服务器请求并通过其 ID 来查询记录。

在 MovieRow 中声明 CastList

当有一个影片对话框打开时,且演员列表(CastList)中没有演员,这时点击保存按钮,你会得到这样的错误:

Cast Save Error

引发该错误的是服务器端的行反序列化器 (JsonRowConverter for JSON.NET)。

我们在 MovieForm 定义 CastList 属性,但是在 MovieRow 没有对应的字段,所以反序列器不知道如何处理从客户端接收的 CastList 值。

如果你使用 F12 打开开发者工具,选择网格选项卡,并在点击保存按钮之后查看 AJAX 请求,你将看到有这样的一个请求:

  1. {
  2. "Entity": {
  3. "Title": "The Matrix",
  4. "Description": "A computer hacker...",
  5. "CastList": [
  6. {
  7. "PersonId":"1",
  8. "Character":"Neo",
  9. "PersonFullname":"Keanu Reeves"
  10. }
  11. ],
  12. "Storyline":"Thomas A. Anderson is a man living two lives...",
  13. "Year":1999,
  14. "ReleaseDate":"1999-03-31",
  15. "Runtime":136,
  16. "GenreId":"",
  17. "Kind":"1",
  18. "MovieId":1
  19. }
  20. }

在这里,CastList 属性不能在服务器端进行反序列化。所以我们要在 MovieRow.cs 中声明:

  1. namespace MovieTutorial.MovieDB.Entities
  2. {
  3. // ...
  4. public sealed class MovieRow : Row, IIdRow, INameRow
  5. {
  6. [DisplayName("Cast List"), SetFieldFlags(FieldFlags.ClientSide)]
  7. public List<MovieCastRow> CastList
  8. {
  9. get { return Fields.CastList[this]; }
  10. set { Fields.CastList[this] = value; }
  11. }
  12. public class RowFields : RowFieldsBase
  13. {
  14. // ...
  15. public readonly RowListField<MovieCastRow> CastList;
  16. // ...
  17. }
  18. }
  19. }

我们定义一个接受列表中 MovieCastRow 对象的 CastList 属性。用于这种行列属性的 Field 类的类型是 RowListField

通过添加 [SetFieldFlags(FieldFlags.ClientSide)] 特性,我们指定此字段直接在数据库表中不可用,因此不能通过简单的 SQL 查询来选择。它在其他 ORM 系统中,类似于未映射的字段。

现在,当你点击保存按钮,将不会有错误发生。

但是,重新打开刚保存的《黑客帝国》(the Matrix) 实体。这里并没有演员记录,尼欧(Neo)发生了什么事?

因为这是一个未映射的字段,所以电影保存服务忽略了 CastList 属性。

如果你还记得在前一节我们的 GenreList 同样也是一个未映射的字段,但不知为何它在那里可以正常工作。这是因为我们在该属性中使用了 LinkedSetRelationBehavior 行为(behavior) 。

我们在这里演示若没有该服务器行为(service behavior)将会发生什么。

处理演员列表(CastList)的保存

打开 MovieRepository.cs, 找到空 MySaveHandler 类,并对其做如下修改:

  1. private class MySaveHandler : SaveRequestHandler<MyRow>
  2. {
  3. protected override void AfterSave()
  4. {
  5. base.AfterSave();
  6. if (Row.CastList != null)
  7. {
  8. var mc = Entities.MovieCastRow.Fields;
  9. var oldList = IsCreate ? null :
  10. Connection.List<Entities.MovieCastRow>(
  11. mc.MovieId == this.Row.MovieId.Value);
  12. new Common.DetailListSaveHandler<Entities.MovieCastRow>(
  13. oldList, Row.CastList,
  14. x => x.MovieId = Row.MovieId.Value).Process(this.UnitOfWork);
  15. }
  16. }
  17. }

MySaveHandler 处理影片行(Movie rows)的添加和修改服务请求。其大部分逻辑由基类 SaveRequestHandler 处理,所以之前该类是空的。

在添加/修改演员列表之前,我们应该先成功添加/修改影片实体。因此,我们通过重写基方法 AfterSave 包含自定义的代码。

如果是添加操作,我们需要在演员(MovieCast)纪录中重用 MovieId 字段的值。由于 MovieId 是一个标识字段,所以它只在添加影片记录之后生效。

由于我们是在内存(客户端)中编辑演员列表信息,所以这将是一个批处理更新。

我们需要比较影片的新旧演员列表记录,并对其进行添加/更新/删除操作。

假设我们数据库中有影片 X,演员有: A、 B、 C、 D。

我们在编辑对话框中对演员列表做了一些修改,现在演员变为:A、 B、 D、 E、 F。

因此我们需要更新 A、 B、 D (角色/演员 发生了改变),删除 C,并添加新的演员记录 E 和 F。

幸运的是,Serene 中定义的 DetailListSaveHandler 类可处理所有这些比较,并自动执行插入/更新/删除操作(通过ID值)。否则我们需要在这里编写大量的代码。

如果这是一个更新影片的操作,为了获取演员列表中的旧记录,我们需要查询数据库。而如果是新增操作,我们就不需要任何演员记录。

我们使用 Connection.List< Entities.MovieCastRow > 扩展方法获取演员列表,这里的 Connection 是 SaveRequestHandler 的属性,该属性返回当前使用的连接。List 选择匹配指定条件(mc.MovieId == this.Row.MovieId.Value)的记录。

this.Row 是指添加/更新当前含有新的字段值的记录(影片记录),因此它包含 MovieId 值(新的或者现有的 ID)。

为了更新演员记录,我们创建了一个 DetailListHandler 对象,该对象含旧的演员列表、新的演员列表及设置演员记录 MovieId 字段值的委托。让对象为新的演员记录与当前电影建立联系。

然后我们使用当前的工作单元(unit of work)调用 DetailListHandler 方法。UnitOfWork 是一个特殊的对象,它封装了当前 连接/事务。

所有的 Serenity 添加/更新/删除 处理都适用于隐式事务(IUnitOfWork)。

处理演员列表的检索

我们还没有完成该功能。当在影片列表中点击影片实体时,对话框调用 Retrieve 服务加载影片记录。就像 演员列表(CastList)字段没有映射的情况,即使我们正确保存了演员,演员也不会加载到对话框。

要解决该问题,我们同样需要在 MovieRepository.cs 文件中编辑 MyRetrieveHandler 类:

  1. private class MyRetrieveHandler : RetrieveRequestHandler<MyRow>
  2. {
  3. protected override void OnReturn()
  4. {
  5. base.OnReturn();
  6. var mc = Entities.MovieCastRow.Fields;
  7. Row.CastList = Connection.List<Entities.MovieCastRow>(q => q
  8. .SelectTableFields()
  9. .Select(mc.PersonFullname)
  10. .Where(mc.MovieId == Row.MovieId.Value));
  11. }
  12. }

在这里,我们重写 OnReturn 方法,以使在检索服务返回之前把 CastList 注入影片的行(Row)对象中。

我使用 Connection.List 的不同扩展,这样我就可以修改 select 查询。

默认情况下,列表选择所有的表字段(除了外键表的字段),但为了显示演员名字,我还需要选择 PersonFullName 字段。

现在生成解决方案,我们终于可以显示/编辑演员了。

处理演员列表的删除

当你尝试删除电影实体时,你将获得一个外键错误。你可以在创建 演员(MovieCast) 表时使用 “级联删除(CASCADE DELETE)” 外键,但是我们选择再次在仓储层(repository level)中处理:

  1. private class MyDeleteHandler : DeleteRequestHandler<MyRow>
  2. {
  3. protected override void OnBeforeDelete()
  4. {
  5. base.OnBeforeDelete();
  6. var mc = Entities.MovieCastRow.Fields;
  7. foreach (var detailID in Connection.Query<Int32>(
  8. new SqlQuery()
  9. .From(mc)
  10. .Select(mc.MovieCastId)
  11. .Where(mc.MovieId == Row.MovieId.Value)))
  12. {
  13. new DeleteRequestHandler<Entities.MovieCastRow>().Process(this.UnitOfWork,
  14. new DeleteRequest
  15. {
  16. EntityId = detailID
  17. });
  18. }
  19. }
  20. }

我们实现这个主/从处理的方式不是很直观,并且在存储层包含了几个手工步骤。请继续阅读,看我们如何通过使用一个集成的功能 (MasterDetailRelationAttribute) 轻松地实现同一逻辑。

在行为(Behavior)中处理 保存/检索/删除

主/从关系是一个综合性的功能(至少在服务器端是),所以我使用 MasterDetailRelation 特性替代手工重写 保存/检索和删除操作。

打开 MovieRow.cs 并修改 CastList 属性:

  1. [MasterDetailRelation(foreignKey: "MovieId", IncludeColumns = "PersonFullname")]
  2. [DisplayName("Cast List"), SetFieldFlags(FieldFlags.ClientSide)]
  3. public List<MovieCastRow> CastList
  4. {
  5. get { return Fields.CastList[this]; }
  6. set { Fields.CastList[this] = value; }
  7. }

我们指定该字段是主/从关系的详细列表(从表内容),并且详细列表的主 ID 字段(外键)是 MovieId

现在我们撤消在 MovieRepository.cs 中做的所有更改:

  1. private class MySaveHandler : SaveRequestHandler<MyRow> { }
  2. private class MyDeleteHandler : DeleteRequestHandler<MyRow> { }
  3. private class MyRetrieveHandler : RetrieveRequestHandler<MyRow> { }

在我们 MasterDetailRelation 特性中,我们指定了一个额外的属性 IncludeColumns

  1. [MasterDetailRelation(foreignKey: "MovieId", IncludeColumns = "PersonFullname")]

这是确保在检索演员列表时包含 PersonFullname 字段。另外,只默认选中表字段的情况下它将不会加载。当你打开一个存在演员信息的影片对话框时,演员的全名将为空。

确保你在网格列中使用到的任何可视字段都添加到 IncludeColumns。使用逗号分隔多个字段名称,例如 IncludeColumns =”FieldA, FieldB, FieldC”。

现在生成项目,你将看到使用更少的代码完成了同样的工作。

MasterDetailRelationAttribute 自动触发一种深层次行为(behavior),MasterDetailRelationBehavior 拦截检索/保存/删除处理并执行我们之前已经重写的方法及其他类似的操作。

所以我们做了同样的事情,但这一次,是以声明的方式,而不是命令式(告诉程序应该做什么,而不是如何去做)。

https://en.wikipedia.org/wiki/Declarative_programming

我们将在下面的章节介绍如何编写你自己的请求处理行为(behaviors)。