Serenity Guide
Serenity Guide
Serenity Guide
of Contents
Introduction 1.1
Getting Started 1.2
Installing Serene From Visual Studio Gallery 1.2.1
Installing Serene Directly From Visual Studio 1.2.2
Starting Serene 1.2.3
A Tour Of Serene Features 1.3
Theming 1.3.1
Localization 1.3.2
User and Role Management 1.3.3
Listing Pages 1.3.4
Edit Dialogs 1.3.5
Tutorials 1.4
Movie Database 1.4.1
Creating Movie Table 1.4.1.1
Generating Code For Movie Table 1.4.1.2
Customizing Movie Interface 1.4.1.3
Handling Movie Navigation 1.4.1.4
Customizing Quick Search 1.4.1.5
Adding a Movie Kind Field 1.4.1.6
Adding Movie Genres 1.4.1.7
Updating Serenity Packages 1.4.1.8
Allowing Multiple Genre Selection 1.4.1.9
Filtering with Multiple Genre List 1.4.1.10
The Cast and Characters They Played 1.4.1.11
Listing Movies in Person Dialog 1.4.1.12
Adding Primary and Gallery Images 1.4.1.13
Multi Tenancy 1.4.2
Adding Tenants Table and TenantId Field 1.4.2.1
Generating Code for Tenants Table 1.4.2.2
Tenant Selection in User Dialog 1.4.2.3
2
Filtering Users By TenantId 1.4.2.4
Removing Tenant Dropdown From User Form 1.4.2.5
Securing Tenant Selection At Server Side 1.4.2.6
Setting TenantId For New Users 1.4.2.7
Preventing Edits To Users From Other Tenants 1.4.2.8
Hiding the Tenant Administration Permission 1.4.2.9
Making Roles Multi-Tenant 1.4.2.10
Using Serenity Service Behaviors 1.4.2.11
Extending Multi-Tenant Behavior To Northwind 1.4.2.12
Handling Lookup Scripts 1.4.2.13
Meeting Management 1.4.3
Creating Lookup Tables 1.4.3.1
How To Guides 1.5
How To: Remove Northwind & Other Samples From Serene 1.5.1
How To: Update Serenity NuGet Packages 1.5.2
How To: Upgrade to Serenity 2.0 and Enable TypeScript 1.5.3
How To: Authenticate With Active Directory or LDAP 1.5.4
How To: Use a SlickGrid Formatter 1.5.5
How To: Add a Row Selection Column 1.5.6
How To: Setup Cascaded Editors 1.5.7
How To: Use Recaptcha 1.5.8
How To: Register Permissions in Serene 1.5.9
How To: Use a Third Party Plugin With Serenity 1.5.10
How To: Enable Script Bundling 1.5.11
How To: Debugging with Serenity Sources 1.5.12
Frequently Asked Questions 1.6
Troubleshooting 1.7
Service Locator & Initialization 1.8
Dependency Static Class 1.8.1
IDependencyResolver Interface 1.8.2
IDependencyRegistrar Interface 1.8.3
MunqContainer Class 1.8.4
CommonInitialization Static Class 1.8.5
Authentication & Authorization 1.9
3
IAuthenticationService Interface 1.9.1
IAuthorizationService Interface 1.9.2
IPermissionService Interface 1.9.3
IUserDefinition Interface 1.9.4
IUserRetrieveService Interface 1.9.5
Authorization Static Class 1.9.6
Configuration System 1.10
Defining Configuration Settings 1.10.1
IConfigurationRepository Interface 1.10.2
AppSettingsJsonConfigRepository 1.10.3
Config Static Class 1.10.4
Localization 1.11
LocalText Class 1.11.1
Language Identifiers 1.11.2
Language Fallbacks 1.11.3
ILocalTextRegistry Interface 1.11.4
LocalTextRegistry Class 1.11.5
Pending Approval Mode 1.11.5.1
Registering Translations 1.11.6
Manually Registering Translations 1.11.6.1
Nested Local Texts 1.11.6.2
Enumeration Texts 1.11.6.3
JSON Local Texts 1.11.6.4
Caching 1.12
Local Caching 1.12.1
ILocalCache Interface 1.12.1.1
LocalCache Static Class 1.12.1.2
User Profile Caching Sample 1.12.1.3
Distributed Caching 1.12.2
WEB Farms and Caching 1.12.2.1
IDistributedCache Interface 1.12.2.2
Distributed Cache Static Class 1.12.2.3
DistributedCacheEmulator Class 1.12.2.4
4
CouchbaseDistributedCache Class 1.12.2.5
RedisDistributedCache Class 1.12.2.6
Two Level Caching 1.12.3
Using Local Cache and Distributed Cache In Sync 1.12.3.1
TwoLevelCache Class 1.12.3.2
Entities (Row) 1.13
Mapping Attributes 1.13.1
FieldFlags Enumeration 1.13.2
Fluent SQL 1.14
SqlQuery Object 1.14.1
Criteria Objects 1.14.2
Connections and Transactions 1.15
SQL Database Types 1.16
Working with Other Databases 1.16.1
Setting Connection Dialect 1.16.2
Dialect Based Expressions 1.16.3
PostgreSQL 1.16.4
MySQL 1.16.5
Sqlite 1.16.6
Oracle 1.16.7
Services 1.17
Service Endpoints 1.17.1
List Request Handler 1.17.2
Widgets 1.18
ScriptContext Class 1.18.1
Widget Class 1.18.2
Widget With Options 1.18.3
TemplatedWidget Class 1.18.4
TemplatedDialog Class 1.18.5
Attributes 1.19
Grids 1.20
Formatter Types 1.20.1
Persisting Settings 1.20.2
Code Generator (Sergen) 1.21
5
Used Tools & Libraries 1.22
6
Introduction
Introduction
It aims to make development easier while reducing maintenance costs by avoiding boiler-
plate code, reducing the time spent on repetitive tasks and applying the best software design
practices.
Github Repository:
https://github.com/volkanceylan/Serenity
Issues / Questions
https://github.com/volkanceylan/Serenity/issues
Change Log:
https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md
7
Introduction
This is what we are trying to achieve with Serenity. We hope that after installing and using it
you will feel this way too...
8
Introduction
Background
This part was originally written for a CodeProject article as an introduction to Serenity.
The article was rejected with the reason that it didn't contain code but was an ad for
code. They were right, as i did put a link to Movie tutorial in this guide, instead of copy
pasting code.
You can safely skip to next chapter, if you don't like reading history :)
We, developers, are all solving the same sets of problems everyday. Just like college
students working on their problem books.
Even though we know that they are already solved and have answers somewhere, it doesn't
stop us from working on them. Actually, it helps us improve our skills, and hey you can't
learn without making some mistakes, can you? But we should learn where to draw a line
between training and wasting time.
When you start a new project, you have several decisions to make on platform, architecture
and set of libraries. Today you have so many choices for every single topic. Yes, having
some options is good, as long as they are limited, as our time is not infinite.
Here is a short history about Serenity, which aims to handle common tasks you deal with
business applications, and let you spare your precious time focusing on features specific to
your application domain.
My first real job in web technologies was in a web agency designing country-specific web
sites of some of big names in industry, e.g. automative companies (btw, we are talking about
10+ years past, time flows fast).
As I had a software architect career in desktop applications before I signed there, I was
asked to design a ASP.NET WebForms platform for them. They explained that they have
many shared modules, like news, galleries, navigation at each site, but as requirements are
different, they had to copy/paste then customize code specific to every customer. When they
wanted to add a common feature, they had to repeat it for every site.
At that time, there weren't so many CMS systems in market, and I designed one for them,
without even knowing it was called a CMS. For me, it wasn't perfect, not even good enough
as I just had a few weeks to design it. But they were very pleased with the result, as it took
development of new sites down to days/weeks from months. Also resulting code was more
manageable than before.
Learning from that experience, and mistakes, that poor-mans CMS became something
better. Later, that platform is evolved to be used by applications in varying domains, like a
help-desk system, a CRM, ERP, personnel management, electronic document management,
9
Introduction
To be compatible with different kinds of applications, systems and even legacy databases, it
had to be flexible and went through many architectural changes.
Now it takes us to Serenity. Even though it is an open source project for about 2 years, it has
a much older background. But it is also young, energetic, and is not afraid of change. It can
adapt to new technologies as they became popular and stable. This might mean breaking
changes from time to time, but we strive to keep them to a minimum without being paranoid
about backwards compability.
10
Getting Started
Getting Started
The best and fastest way to get your hands dirty on Serenity is SERENE, which is a sample
application template.
You have two options to install SERENE template into your Visual Studio:
You can add Serenity libraries to any .NET project with NuGet.
It is also possible to add Serenity as a GIT submodule to your project and keep it
always up to date with source codes.
11
Installing Serene From Visual Studio Gallery
Type Serene into the search input with Search the Visual Studio Gallery label and press
ENTER.
You will see Serene (Serenity Application Template), click to open it.
12
Installing Serene From Visual Studio Gallery
After download is finished, click the downloaded VSIX file to start Visual Studio extension
installation dialog and click Install.
Note that this application template requires Visual Studio 2012 or higher. Make you sure
you have the latest Visual Studio updates installed.
Start Visual Studio (if it was already open, restart it). Click File => New Project. You should
see Serenity template under Templates => Visual C# section.
13
Installing Serene From Visual Studio Gallery
Name your application something like MyCompany, MyProduct, HelloWorld or leave the
default Serene1.
Please don't name it Serenity. It may conflict with other Serenity assemblies.
Click OK and take a break while Visual Studio creates the solution.
14
Installing Serene Directly From Visual Studio
Note that this application template requires Visual Studio 2012 or higher. Make sure you
have the latest Visual Studio updates installed.
In the New Project dialog box Recent, Installed and Online sections will be shown on left and
Installed is the active one.
Click the Online section and wait a bit while Retrieving information message is on screen.
Type SERENE into input box with Search Online Templates label and press ENTER.
15
Installing Serene Directly From Visual Studio
Name your application something like MyCompany, MyProduct, HelloWorld or leave the
default Serene1.
Please don't name it Serenity. It may conflict with other Serenity assemblies.
Click OK and take a break while Visual Studio downloads the template and creates the
solution.
After you create your first project, Serene template is installed into Visual Studio, so you
can use the Installed section in New Project dialog to create another Serenity
application.
16
Starting Serene
Starting Serene
After your first project is created in Visual Studio using Serene template, you will see a
solution like this:
It includes server side code written in C# (.cs) and client side code that is written in
TypeScript (.ts).
Serene.Web has references to Serenity NuGet packages, so you can update it using
package manager console anytime necessary.
The screenshot above, belongs to a version < 2.1, which had a Serene1.Script project
that was using Saltarelle Compiler for client side code. Since 2.1 we switched to
TypeScript, and client side code (.ts files) also resides in Web project.
Serene automatically creates its database in SQL local db at first run, so just press F5 and
you are ready to go.
17
Starting Serene
When application launches use admin user and serenity password to login. You can
change password or create more users later, using Administration / User Management page.
The sample application includes old and famous Northwind data along with services and
user interface to edit it, which is mostly produced by Serenity Code Generator.
This error might mean that you don't have SQL Server Local DB 2012 installed. This server
comes preinstalled with Visual Studio 2012 and 2013.
<connectionStrings>
<add name="Default" connectionString="Data Source=(LocalDb)\v11.0;
Initial Catalog=Serene_Default_v1; Integrated Security=True"
providerName="System.Data.SqlClient" />
</connectionStrings>
18
Starting Serene
If you don't have SQL LocalDB 2012, you can install it from:
http://www.microsoft.com/en-us/download/details.aspx?id=29062
Visual Studio 2015 comes with SQL Server 2014 LocalDB. It's default instance name is
renamed to MsSqlLocalDB by default. Thus, if you have VS2015, try changing connection
strings from (localdb)\v11.0 to (localdb)\MsSqlLocalDB .
<connectionStrings>
<add name="Default" connectionString="Data Source=(LocalDb)\MsSqlLocalDB;
Initial Catalog=Serene_Default_v1; Integrated Security=True"
providerName="System.Data.SqlClient" />
</connectionStrings>
If you still have an error, open an administrative command prompt and type
MSSqlLocalDB
test
If you have another SQL server instance, for example SQL Express, change data source to
.\SqlExpress :
<connectionStrings>
<add name="Default" connectionString="Data Source=.\SqlExpress;
Initial Catalog=Serene_Default_v1; Integrated Security=True"
providerName="System.Data.SqlClient" />
</connectionStrings>
You can also use another SQL server. Just change the connection string.
Perform these steps for both Default and Northwind databases. Serene 1.6.4.3+
creates two databases.
19
Starting Serene
20
A Tour Of Serene Features
The page content, except some numbers calculated from Northwind tables, contains random
data.
There is an accordion navigation menu on left which has a search feature by input above it.
We'll talk about how to customize items there in later sections.
On top navigation, there is site name on left, along with a dropdown containing current user
name on right, and a settings button through which we change change theme or active
language.
Theming
Localization
21
A Tour Of Serene Features
22
Theming
Theming
Serene initially starts with a dark/blue theme. On top right of the screen, next to username,
click the settings button and change theme to another one.
If you look at the source, you may spot a skin class like below inside <body> tag:
When you select the light yellow skin, it actually changes to this:
23
Theming
Also cookie, "ThemePreference"" with the content "yellow-light" is added to your browser. So
next time you launch Serene, it will remember your preference and start with a light yellow
theme.
These skin files are located under "Content/adminlte/skins/" of the Serene.Web project. If
you look there you can see files with names:
_all-skins.less
skin.black-light.less
site.blue.less
site.yellow-light.less
site.yellow.less
We are using LESS for CSS generation so you should try editing LESS files, not CSS. Next
time you build your project, LESS files will be compiled to CSS (using Less.js compiler for
Node).
...
<Target Name="CompileSiteLess" AfterTargets="AfterBuild">
<Exec Command=""$(ProjectDir)tools\node\lessc.cmd"
"$(ProjectDir)Content\site\site.less" >
"$(ProjectDir)Content\site\site.css"">
</Exec>
</Target>
...
Here site.less file is compiled to its corresponding css file in the same directory.
See http://lesscss.org/ for more information on LESS compiler and its syntax.
24
Localization
Localization
Serene allows you to change the active language from top right settings menu .
I don't speak Spanish and used machine translation, so sorry for errors...
When you changed the language, page is reloaded, unlike the theme selection where no
page reload is required.
Serene, also added a cookie, "LanguagePreference" with content "es" to your browser, so
next time you visit the site, it will remember your last selection and start with Spanish.
When you launched Serene first time, you might have seen the site in English, but it is also
possible that it started in Spanish, Turkish or Russian (these are currently available sample
languages) if you have an operating system or browser of that language.
25
Localization
Here we set UI culture to automatic, while en-US is a fallback (if system can't determine your
browser language).
If you don't want to let users to change UI language, you should remove the language
selection dropdown.
You may add more languages to the language selection dropdown by using Languages
page under Administration menu.
Localizing UI Texts
Serene includes ability to translate its text resources live.
Type navigation into top left search box to see list of texts related to navigation menu.
Choose English as source language and Spanish as target language.
Type Welcome Page into line with Navigation.Dashboard local text key.
26
Localization
When you switch to Spanish language, Dashboard menu item will be changed to Welcome
Page instead of Salpicadero.
{
"Navigation.Dashboard": "Welcome Page"
}
In the ~/scripts/site/texts folder, there are also other similar JSON files with default
translations for Serene interface:
site.texts.es.json
site.texts.invariant.json
site.texts.tr.json
27
User and Role Management
This feature is not embedded in Serenity itself. It is just a sample, so you can always
implement and use your user management of choice. We'll take a look at how in
following chapters.
Click New Role and and type Administrators, then click Save.
Then click role Administrators to open edit form, and click Edit Permissons button to modify
its permissions. Check all boxes to grant every permisson to this role, then click OK.
28
User and Role Management
Repeat same steps for the Translations role but this time grant only the Administration:
Languages and Translations permission.
29
User and Role Management
Here you can change admin details like username, display name, email.
You can also change its password (which is serenity by default) by typing into Password and
Confirm Password inputs and clicking Update.
You can also delete it but this would make your site unusable as you wouldn't be able to
login.
admin is a special user in Serene, as it has all permissions even if none is explicitly granted
to him.
Close this dialog, click new user and type translator as username. Fill in other fields as you'd
like, then click Update.
30
User and Role Management
You may have noticed there is a Apply Changes button with a black disk icon without
title, next to Save. Unlike Save, when you use it, the form stays open, so you can see
how your record looks like after saving, also you can edit roles and permissions before
closing the form.
Now click Translator role to open its edit form and click Edit Roles. Grant him Translators
role and click OK.
31
User and Role Management
When you grant a role to a user, he gets all permissions granted to the role
automatically. By clicking Edit Permissions and you can also grant extra permissions
explicitly. You can also revoke a role permission from a user explicitly.
Now close all dialogs and logout by clicking admin on top right of site and clicking Logout.
Translator user will only have access to Dashboard, Theme Samples, Languages and
Translations pages.
32
User and Role Management
33
Listing Pages
Listing Pages
Serene has listing pages and editing interface for Northwind database. Let's have a look at
the Products page under Northwind module.
Here we see list of products sorted by product name (initial sort order).
https://github.com/mleibman/SlickGrid
You can change order by clicking column headers. To sort descending, click the same
column header again.
Here is what it looks like after sorting by Category then Supplier columns:
34
Listing Pages
When you changed sort order, grid loaded data from a service with an AJAX request.
When you open the page first time, initial records were also loaded by an AJAX call.
By default grid loads records by 100 page size. Only records in current page are loaded from
server. In the sample image, i changed page size to 20 (bottom left of grid) to show paging in
effect.
On top left of the grid, you can type something to do a simple search.
It searched in product name field. It is also possible to use another, or multiple fields for
quick search. We'll see how in later chapters.
35
Listing Pages
On top right of the grid, there are quick filtering dropdowns for Supplier and Category fields.
https://github.com/select2/select2
Choose Seafood as Category and it will show only products in that category.
All sorting, paging and filtering is done on server side with dynamic SQL queries
generated by Serenity service handlers.
It is also possible to filter by any column by clicking edit filter link at bottom right of the grid.
Here you can add criteria by any column by clicking add criteria and choosing column name,
choosing comparison operator from next dropdown, and setting a value.
36
Listing Pages
Some value editors are simple textboxes while some others may have dropdowns and other
custom editors depending on column type.
You can also group criteria by clicking parenthesis. Groups will have a bit more space
between them than ordinary lines.
37
Edit Dialogs
Edit Dialogs
When you click a product name in Products page, an editing dialog for that row is displayed.
This dialog is shown on client side, there is no post-back happening. Data for the clicked
entity is loaded from server side using an AJAX request (only data, not markup). Dialog itself
is a customized version of jQuery UI dialog.
In this form we have three categories for fields: General, Pricing and Status. By clicking
category links on top blue bar you can navigate to start of that category.
Each form field occupies a row with label and editor. You may choose to show more than
one field in a row if required (with CSS).
Each field has a specific type of editor tailored to its data type like string, image upload,
checkbox, select etc.
We would see such an HTML code if we looked at the source (simplified for brevity):
38
Edit Dialogs
...
Every field has a separate "div" of its own with class "field". Inside this div, there is a "label"
element and another element (input, select, div) that changes with the editor type for that
field.
We can look at the class names of these elements to identify their editor types (e.g. s-
StringEditor, s-ImageUploadEditor)
In the toolbar we have a button to save current entity and close dialog (Update), next to it a
smaller one that keeps dialog open after save and another one to delete current entity
(obviously).
Most Serenity editing dialogs has this familiar interface, though you can always customize
buttons, fields, add tabs, and other interface elements.
39
Tutorials
Tutorials
Movie Database (similar to IMDB)
Multi Tenancy
40
Movie Database
https://github.com/volkanceylan/MovieTutorial
In Solution explorer, you should see one project with name MovieTutorial.Web.
MovieTutorial.Web project is an ASP.NET MVC application that contains server side code
plus static resources like css files, images etc.
MovieTutorial.Web also has a tsconfig.json file at root, which specifies that it is also a
TypeScript project. All .ts files under Modules/ and Scripts/ directories are compiled on save,
and their outputs are combined into a script file under scripts/site/ folder with name
MovieTutorial.Web.js.
41
Creating Movie Table
Fluent Migrator is a migration framework for .NET much like Ruby on Rails Migrations.
Migrations are a structured way to alter your database schema and are an alternative to
creating lots of sql scripts that have to be run manually by every developer involved.
Migrations solve the problem of evolving a database schema for multiple databases (for
example, the developers local database, the test database and the production
database). Database schema changes are described in classes written in C# that can
be checked into a version control system.
Here we already have several migrations. A migration is like a DML script that manipulates
database structure.
42
Creating Movie Table
Migration file name / class name is actually not important but recommended for
consistency and correct ordering.
using FluentMigrator;
using System;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160519115100)]
public class DefaultDB_20160519_115100_MovieTable : Migration
{
public override void Up()
{
Create.Schema("mov");
Create.Table("Movie").InSchema("mov")
.WithColumn("MovieId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Title").AsString(200).NotNullable()
.WithColumn("Description").AsString(1000).Nullable()
.WithColumn("Storyline").AsString(Int32.MaxValue).Nullable()
.WithColumn("Year").AsInt32().Nullable()
.WithColumn("ReleaseDate").AsDateTime().Nullable()
.WithColumn("Runtime").AsInt32().Nullable();
}
43
Creating Movie Table
In Up() method we specify that this migration, when applied, will create a schema named
mov. We will use a separate schema for movie tables to avoid clashes with existing tables. It
will also create a table named Movie with "MovieId, Title, Description..." fields on it.
We could implement Down() method to make it possible to undo this migration (drop movie
table and mov schema etc), but for the scope of this sample, lets leave it empty.
Inability to undo a migration might not hurt much, but deleting a table by mistake could
do more damage.
[Migration(20160519115100)]
This specifies a unique key for this migration. After a migration is applied to a database, its
key is recorded in a special table specific to FluentMigrator ([dbo].[VersionInfo]), so same
migration won't be applied again.
Migration key should be in sync with class name (for consistency) but without
underscore as migration keys are Int64 numbers.
Migrations are executed in the key order, so using a sortable datetime pattern like
yyyyMMdd for migration keys looks like a good idea.
Please make sure you always use same number of characters for migration keys e.g. 14
(20160519115100). Otherwise your migration order will get messed up, and you will have
migration errors, due to migrations running in unexpected order.
Running Migrations
By default, Serene template runs all migrations in MovieTutorial.Migrations.DefaultDB
namespace. This happens on application start automatically. The code that runs migrations
are in App_Start/SiteInitialization.cs and App_Start/SiteInitialization.Migrations.cs files:
SiteInitialization.Migrations.cs:
namespace MovieTutorial
{
//...
//...
44
Creating Movie Table
// ...
new TaskExecutor(runner).Execute();
}
}
}
}
Now press F5 to run your application and create Movie table in default database.
45
Creating Movie Table
If you have SQL Server 2014 LocalDB, your server name would be
(localdb)\MSSqlLocalDB or (localdb)\v12.0, so change connection string in web.config
file.
You could also use another SQL server instance, just change the connection string to
target server and remove the migration safety check.
Also when you view data in [dbo].[VersionInfo] table, Version column in the last row of the
table should be 20160519115100. This specifies that the migration with that version number
(migration key) is already executed on this database.
Usually, you don't have to do these checks after every migration. Here we show these
to explain where to look, in case you'll have any trouble in the future.
46
Generating Code For Movie Table
In Visual Studio, open Package Manager Console by clicking View => Other Windows =>
Package Manager Console.
Sometimes package manager console can't set path correctly and you may get an error
executing Sergen. Restarting Visual Studio might resolve the issue.
..\..\..\MovieTutorial\MovieTutorial.Web\MovieTutorial.Web.csproj
47
Generating Code For Movie Table
If you change this value and other options, and generate your first page, you won't have to
set them again. All these options will be saved in Serenity.CodeGenerator.config in your
solution directory.
This value is required, as Sergen will automatically include generated files to your WEB
project.
Script project field should be empty for v2.1+. This is for users of older Serene, who
might still have code that was written with Saltaralle compiler, instead of TypeScript.
This is critical so make sure you don't set it to anything different, as by default, Serene
template expects all generated code to be under this root namespace.
This option is also saved, so next time you won't have to fill it in.
For example, in Serene template, all pages related to Northwind sample belongs to
Northwind module.
Pages that are related to general management of site, like users, roles etc. belongs to
Administration module.
48
Generating Code For Movie Table
For this tutorial, we will use MovieDB (analogous to IMDB) for all pages.
For example, our new page will be under MovieTutorial.MovieDB namespace and will use
/MovieDB relative url.
ConnectionKey Parameter
Connection key is set to the connection key of selected connection string in web.config file.
You usually don't have to change it, just leave default.
Entity Identifier
This usually corresponds to the table name but sometimes table names might have
underscores or other invalid characters, so you decide what to name your entity in
generated code (a valid identifier name).
Our table name is Movie so it is also a valid and fine C# identifier, so let's leave Movie as the
entity identifier. Our entity class will be named MovieRow.
This name is also used in other class names. For example our page controller will be named
MovieController.
It also determines the page url, in this sample our editing page will be at URL
/MovieDB/Movie.
Permission Key
In Serenity, access control to resources (pages, services etc.) are controlled by permission
keys which are simple strings. Users or roles are granted these permissions.
Our Movie page will be only used by administrative users (or maybe later content
moderators) so let's leave it as Administration for now. By default, in Serene template, only
the admin user has this permission.
49
Generating Code For Movie Table
Sergen will generate several files and include them in MovieTutorial.Web and
MovieTutorial.Script projects.
As project is modified, Visual Studio will ask if you want to reload changes, click Reload All.
When you are greeted with Dashboard page, you will notice that there is a new section,
MovieDB on the bottom of left navigation.
Click to expand it and click Movie to open our first generated page.
Now try adding a new movie, than try updating and deleting it.
Sergen generated code for our table, and it just works without writing a single line of code.
50
Generating Code For Movie Table
This doesn't mean i don't like writing code. In contrast, i love it. Actually i'm not a fan of
most designers and code generators. The code they produce is usually unmanagable
mess.
Sergen just helped us here for initial setup which is required for layered architecture
and platform standards. We would have to create about 10 files for entity, repository,
page, endpoint, grid, form etc. Also we needed to do some setup in a few other places.
Even if we did copy paste and replace code from some other page, it would be error
prone and take about 5-10 mins.
The code files Sergen generates has minimum code with the absolute basics. This is
thanks to the base classes in Serenity that handles the most logic. Once we generate
code for some table, we'll probably never use Sergen again (for this table), and modify
this generated code to our needs. We'll see how.
51
Customizing Movie Interface
There are several ways to do this. Our options include server side form definition, server
side columns definition, from script grid code etc. But let's make this change in the central
location, the entity itself, so its title changes everywhere.
When Sergen generated code for Movie table, it created a entity class named MovieRow.
You can find it at Modules/MovieDB/Movie/MovieRow.cs.
namespace MovieTutorial.MovieDB.Entities
{
// ...
[ConnectionKey("Default"), DisplayName("Movie"),
InstanceName("Movie"), TwoLevelCached]
public sealed class MovieRow : Row, IIdRow, INameRow
{
// ...
[DisplayName("Runtime")]
public Int32? Runtime
{
get { return Fields.Runtime[this]; }
set { Fields.Runtime[this] = value; }
}
//...
}
}
We'll talk about entities (or rows) later, let's now focus on our target and change its
DisplayName attribute value to *Runtime (mins)":
52
Customizing Movie Interface
namespace MovieTutorial.MovieDB.Entities
{
// ...
[ConnectionKey("Default"), DisplayName("Movie"), InstanceName("Movie"),
TwoLevelCached]
public sealed class MovieRow : Row, IIdRow, INameRow
{
// ...
[DisplayName("Runtime (mins)")]
public Int32? Runtime
{
get { return Fields.Runtime[this]; }
set { Fields.Runtime[this] = value; }
}
//...
}
}
Now build solution and run application. You'll see that field title is changed in both grid and
dialog.
Column title has "..." in it as column is not wide enough, though its hint shows the full
title. We'll see how to handle this soon.
53
Customizing Movie Interface
Let's do it on columns first. Next to MovieRow.cs, you can find a source file named
MovieColumns.cs:
namespace MovieTutorial.MovieDB.Columns
{
// ...
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieColumns
{
[EditLink, DisplayName("Db.Shared.RecordId"), AlignRight]
public Int32 MovieId { get; set; }
//...
public Int32 Runtime { get; set; }
}
}
You may notice that this columns definition is based on the Movie entity (BasedOnRow
attribute).
Any attribute written here will override attributes defined in the entity class.
namespace MovieTutorial.MovieDB.Columns
{
// ...
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieColumns
{
[EditLink, DisplayName("Db.Shared.RecordId"), AlignRight]
public Int32 MovieId { get; set; }
//...
[DisplayName("Runtime in Minutes"), Width(150), AlignRight]
public Int32 Runtime { get; set; }
}
}
Serenity applies an automatic width to columns based on field type and character
length, unless you set the width explicitly.
54
Customizing Movie Interface
And another one to align column to right (AlignCenter, AlignLeft is also available).
55
Customizing Movie Interface
namespace MovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieForm
{
public String Title { get; set; }
public String Description { get; set; }
public String Storyline { get; set; }
public Int32 Year { get; set; }
public DateTime ReleaseDate { get; set; }
public Int32 Runtime { get; set; }
}
}
namespace MovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieForm
{
public String Title { get; set; }
[TextAreaEditor(Rows = 3)]
public String Description { get; set; }
[TextAreaEditor(Rows = 8)]
public String Storyline { get; set; }
public Int32 Year { get; set; }
public DateTime ReleaseDate { get; set; }
public Int32 Runtime { get; set; }
}
}
I left more editing rows for Storyline (8) compared to Description (3) as Storyline should be
much longer.
56
Customizing Movie Interface
Serene has several editor types to choose from. Some are automatically picked based on
field data type, while you need to explicitly set others.
You can also develop your own editor types. You can take existing editor types as base
classes, or develop your own from stratch. We'll see how in following chapters.
As editors became a bit higher, form height exceeded the default Serenity form height (which
is about 260px) and now we have a vertical scroll bar. Let's remove it.
If you open it, and scroll to bottom, you will see this:
/* ------------------------------------------------------------------------- */
/* APPENDED BY CODE GENERATOR, MOVE TO CORRECT PLACE AND REMOVE THIS COMMENT */
/* ------------------------------------------------------------------------- */
.s-MovieDB-MovieDialog {
> .size { width: 650px; }
.caption { width: 150px; }
}
57
Customizing Movie Interface
You can safely remove the 3 comment lines (appended by code generator...). This is just a
reminder for you to move them to a better place like a site.movies.less file specific to this
module (recommended).
These rules are applied to elements with .s-MovieDB-MovieDialog class. Our Movie dialog
has this class by default, which is generated by "s-" + ModuleName + "-" + ClassName.
In the second line it is specified that this dialog is 650px wide by default.
In third line, we specify that field labels should be 150px (@l: 150px).
Let's change our initial dialog height to 500px (in desktop mode), so it won't require a vertical
scroll bar:
.s-MovieDialog {
> .size { width: 650px); height: 500px; }
.caption { width: 150px; }
}
For this change to be applied to your dialog, you need to build solution. As this "site.less" file
is compiled to a "site.css" file on build. Now build and refresh the page.
What i mean by desktop mode above will become clearer soon. Serenity dialogs are
responsive by default. Let's resize our browser window to a width about 350px. I'll use
mobile mode of my Chrome browser to switch to iPhone 6:
58
Customizing Movie Interface
59
Customizing Movie Interface
So, the height we set here is only meaningfull for desktop mode. Dialog will turn into a
responsive, device size specific mode in mobile, without having to mess with CSS @media
queries.
namespace MovieTutorial.MovieDB.Entities
{
// ...
[ConnectionKey("Default"), DisplayName("Movie"), InstanceName("Movie"),
TwoLevelCached]
public sealed class MovieRow : Row, IIdRow, INameRow
{
[DisplayName("Movie Id"), Identity]
public Int32? MovieId
Change DisplayName attribute value to Movies. This is the name that is used when this
table is referenced, and it is usually a plural name. This attribute is used for determining
default page title.
It is also possible to override the page title in MoviePage.Index.cshtml file but as before,
we prefer to do it from a central location so that this information can be reused in other
places.
InstanceName corresponds to singular name and is used in New Record (New Movie)
button of the grid and also determines the dialog title (e.g. Edit Movie).
namespace MovieTutorial.MovieDB.Entities
{
// ...
[ConnectionKey("Default"), DisplayName("Movies"), InstanceName("Movie"),
TwoLevelCached]
public sealed class MovieRow : Row, IIdRow, INameRow
{
[DisplayName("Movie Id"), Identity]
public Int32? MovieId
60
Handling Movie Navigation
Open MoviePage.cs in the same folder, on top of it you'll find this line:
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "MovieDB/Movie",
typeof(MovieTutorial.MovieDB.Pages.MovieController))]
namespace MovieTutorial.MovieDB.Pages
{
//...
First argument to this attribute is display order for this navigation item. As we only have one
navigation item in Movie category yet, we don't have to mess with ordering yet.
Second parameter is navigation title in "Section Title/Link Title" format. Section and
navigation items are seperated with a slash (/).
namespace MovieTutorial.MovieDB.Pages
{
//..
61
Handling Movie Navigation
We also changed navigation item icon to icon-camcorder. Serene template has two sets of
font icons, Simple Line Icons and Font Awesome. Here we used a glyph from simple line
icons set.
To see list of simple line icons and their css classes, visit link below:
http://thesabbir.github.io/simple-line-icons/
https://fortawesome.github.io/Font-Awesome/icons/
There is also a page in Serene under Theme Samples / UI Elements / Icons containing
a list of these icon sets.
62
Handling Movie Navigation
using Serenity.Navigation;
using Northwind = MovieTutorial.Northwind.Pages;
using Administration = MovieTutorial.Administration.Pages;
using MovieDB = MovieTutorial.MovieDB.Pages;
Here we also declared a navigation menu (Movie Database) with film icon. When you don't
have an explicitly defined navigation menu, Serenity implicitly creates one, but in this case
you can't order menu yourself, or set menu icon.
We assigned it a display order of 2000 so this menu will display just after Dashboard link
(1000) but before Northwind menu (8000).
We assigned our Movies link a display order value of 2100 but it doesn't matter right now, as
we have only one navigation item under Movie Database menu yet.
First level links and navigation menus are sorted according to their display order first,
then second level links among their siblings.
63
Handling Movie Navigation
To solve this issue, we need to disable Edit And Continue (have no idea why).
Right Click MovieTutorial.Web project, click Properties, in the Web tab, uncheck Enable Edit
And Continue under Debuggers.
Unfortunately, the solution above stops works in Visual Studio 2015 Update 2. Only
workaround so far seems like starting without debugging, e.g. Ctrl+F5 instead of F5.
Also, on your site, top blue progress bar (which is a Pace.js animation), keeps running all the
time like it is still loading something. It is thanks to the Browser Link feature of Visual Studio.
To disable it, locate its button in Visual Studio toolbar that looks like a refresh button (next to
play icon with browser name like Chrome), click dropdown and uncheck Enable Browser
Link.
<appsettings>
<add key="vs:EnableBrowserLink" value="false" />
</appsettings>
64
Handling Movie Navigation
Serene 1.5.4 and later has this in web.config by default, so you might not experience
this issue
65
Customizing Quick Search
If you don't want to waste your time entering this sample data, it is available as a migration
at the link below:
https://github.com/volkanceylan/MovieTutorial/blob/master/MovieTutorial/MovieTutorial.
Web/Modules/Common/Migrations/DefaultDB/DefaultDB_20160519_135200_SampleM
ovies.cs
If we typed go into search box, we would see two movies are filtered: The Good, the Bad
and the Ugly and The Godfather.
By default, Sergen determines first textual field of a table as the name field. In movies table
it is Title. This field has a QuickSearch attribute on it that specifies that text searches should
be performed on it.
The name field also determines initial sorting order and shown in edit dialog titles.
Sometimes, first textual column might not be the name field. If you wanted to change it to
another field, you would do it in MovieRow.cs:
66
Customizing Quick Search
namespace MovieTutorial.MovieDB.Entities
{
//...
public sealed class MovieRow : Row, IIdRow, INameRow
{
//...
StringField INameRow.NameField
{
get { return Fields.Title; }
}
}
Code generator determined that first textual (string) field in our table is Title. So it added a
INameRow interface to our Movies row and implemented it by returning Title field. If wanted
to use Description as name field, we would just replace it.
Here, Title is actually the name field, so we leave it as is. But we want Serenity to search
also in Description and Storyline fields. To do this, you need to add QuickSearch attribute to
these fields too, as shown below:
67
Customizing Quick Search
namespace MovieTutorial.MovieDB.Entities
{
//...
public sealed class MovieRow : Row, IIdRow, INameRow
{
//...
[DisplayName("Title"), Size(200), NotNull, QuickSearch]
public String Title
{
get { return Fields.Title[this]; }
set { Fields.Title[this] = value; }
}
[DisplayName("Storyline"), QuickSearch]
public String Storyline
{
get { return Fields.Storyline[this]; }
set { Fields.Storyline[this] = value; }
}
//...
}
}
Now, if we search for Gandalf, we'll get a The Lord of the Rings entry:
68
Customizing Quick Search
QuickSearch attribute, by default, searches with contains filter. It has some options to make
it match by starts with filter or match only exact values.
If we wanted it to show only rows that starts with typed text, we would change attribute to:
Here this quick search feature is not very useful, but for values like SSN, serial number,
identification number, phone number etc, it might be.
If we wanted to search also in year column, but only exact integer values (1999 matches but
not 19):
You might have noticed that we are not writing any C# or SQL code for these basic
features to work. We just specify what we want, rather than how to do it. This is what
declarative programming is.
It is also possible to provide user with ability to determine which field she wants to search
on.
69
Customizing Quick Search
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerClass()
export class MovieGrid extends
Serenity.EntityGrid<MovieRow, any> {
//...
constructor(container: JQuery) {
super(container);
}
protected getQuickSearchFields():
Serenity.QuickSearchField[] {
return [
{ name: "", title: "all" },
{ name: "Description", title: "description" },
{ name: "Storyline", title: "storyline" },
{ name: "Year", title: "year" }
];
}
}
}
Once you save that file, we'll have a dropdown in quick search input:
Unlike prior samples where we modified Server side code, this time we did changes in
client side, and modified Javascript (TypeScript) code.
70
Customizing Quick Search
In prior sample we harcoded field names like Description, Storyline etc. This may lead to
typing errors if we forgot actual property names or their casing at server side (javascript is
case sensitive).
Serene contains some T4 (.tt) files to transfer such information from server side (rows etc in
C#) to client side (TypeScript) for intellisense purposes.
Before running these templates, please make sure that your solution builds successfully as
templates uses your output DLL file (MovieTutorial.Web.dll) to generate code.
After building your solution, click on Build menu, than Transform All Templates.
We can now use intellisense to replace hardcoded field names with compile time checked
versions:
namespace MovieTutorial.MovieDB
{
//...
public class MovieGrid extends EntityGrid<MovieRow, any>
{
constructor(container: JQuery) {
super(container);
}
What about field titles? It is not so critical as field names, but can be useful for localization
purposes (if we later decide to translate it):
71
Customizing Quick Search
namespace MovieTutorial.MovieDB
{
//...
public class MovieGrid extends EntityGrid<MovieRow, any>
{
constructor(container: JQuery) {
super(container);
}
We made use of the local text dictionary (translations) available at client side. It's something
like this:
{
// ...
"Db.MovieDB.Movie.Description": "Description",
"Db.MovieDB.Movie.Storyline": "Storyline",
"Db.MovieDB.Movie.Year": "Year"
// ...
}
Local text keys for row fields are generated from "Db." + (LocalTextPrefix for Row) + "." +
FieldName.
Their values are generated from [DisplayName] attributes on your fields by but might be
something else in another culture if they are translated.
72
Adding a Movie Kind Field
As we didn't add it while creating the Movie table, now we'll write another migration to add it
to our database.
using FluentMigrator;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160519145500)]
public class DefaultDB_20160519_145500_MovieKind : Migration
{
public override void Up()
{
Alter.Table("Movie").InSchema("mov")
.AddColumn("Kind").AsInt32().NotNullable()
.WithDefaultValue(1);
}
73
Adding a Movie Kind Field
using Serenity.ComponentModel;
using System.ComponentModel;
namespace MovieTutorial.MovieDB
{
[EnumKey("MovieDB.MovieKind")]
public enum MovieKind
{
[Description("Film")]
Film = 1,
[Description("TV Series")]
TvSeries = 2,
[Description("Mini Series")]
MiniSeries = 3
}
}
[DisplayName("Runtime (mins)")]
public Int32? Runtime
{
get { return Fields.Runtime[this]; }
set { Fields.Runtime[this] = value; }
}
[DisplayName("Kind"), NotNull]
public MovieKind? Kind
{
get { return (MovieKind?)Fields.Kind[this]; }
set { Fields.Kind[this] = (Int32?)value; }
}
We also need to declare a Int32Field object which is required for Serenity entity system. On
the bottom of MovieRow.cs locate RowFields class and modify it to add Kind field after the
Runtime field:
74
Adding a Movie Kind Field
public RowFields()
: base("[mov].Movie")
{
LocalTextPrefix = "MovieDB.Movie";
}
}
namespace MovieTutorial.MovieDB.Forms
{
// ...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieForm
{
// ...
public MovieKind Kind { get; set; }
public Int32 Runtime { get; set; }
}
}
Now, build your solution and run it. When you try to edit a movie or add a new one, nothing
will happen. This is an expected situation. If you check developer tools console of your
browser (F12, inspect element etc.) you'll see such an error:
Whenever such a thing happens, please first check browser console for errors, before
reporting it.
This error is caused by MoveKind enumeration not available client side. We should run our
T4 templates before executing our program.
75
Adding a Movie Kind Field
Now in Visual Studio, click Build -> Transform All Templates again.
Rebuild your solution and execute it. Now we have a nice dropdown in our form to select
movie kind.
But most movies we'll store are feature films, so its default should be this value.
To add a default value for Kind property, add a DefaultValue attribute like this:
Now, in Add Movie dialog, Kind field will come prefilled as Film.
76
Adding Movie Genres
Modules/Common/Migrations/DefaultDB/ DefaultDB_20160519_154700_GenreTable.cs:
using FluentMigrator;
using System;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160519154700)]
public class DefaultDB_20160519_154700_GenreTable : Migration
{
public override void Up()
{
Create.Table("Genre").InSchema("mov")
.WithColumn("GenreId").AsInt32().NotNullable()
.PrimaryKey().Identity()
.WithColumn("Name").AsString(100).NotNullable();
Alter.Table("Movie").InSchema("mov")
.AddColumn("GenreId").AsInt32().Nullable()
.ForeignKey("FK_Movie_GenreId", "mov", "Genre", "GenreId");
}
77
Adding Movie Genres
Fire sergen.exe using Package Manager Console again and generate code for Genre table
with the parameters shown below:
Rebuild solution and run it. We'll get a new page like this:
As you see in screenshot, it is generated under a new section MovieDB instead of the one
we renamed recently: Movie Database.
78
Adding Movie Genres
This is because Sergen has no idea of what customizations we performed on our Movie
page. So we need to movie it under Movie Database manually.
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "MovieDB/Genre",
typeof(MovieTutorial.MovieDB.Pages.GenreController))]
//...
[assembly: NavigationMenu(2000, "Movie Database", icon: "icon-film")]
[assembly: NavigationLink(2100, "Movie Database/Movies",
typeof(MovieDB.MovieController), icon: "icon-camcorder")]
[assembly: NavigationLink(2200, "Movie Database/Genres",
typeof(MovieDB.GenreController), icon: "icon-pin")]
//...
79
Adding Movie Genres
using FluentMigrator;
using System;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160519181800)]
public class DefaultDB_20160519_181800_SampleGenres : Migration
{
public override void Up()
{
Insert.IntoTable("Genre").InSchema("mov")
.Row(new
{
Name = "Action"
})
.Row(new
{
Name = "Drama"
})
.Row(new
{
Name = "Comedy"
})
.Row(new
{
Name = "Sci-fi"
})
.Row(new
{
Name = "Fantasy"
})
.Row(new
{
Name = "Documentary"
});
}
80
Adding Movie Genres
namespace MovieTutorial.MovieDB.Entities
{
// ...
public sealed class MovieRow : Row, IIdRow, INameRow
{
[DisplayName("Kind"), NotNull, DefaultValue(1)]
public MovieKind? Kind
{
get { return (MovieKind?)Fields.Kind[this]; }
set { Fields.Kind[this] = (Int32?)value; }
}
[DisplayName("Genre"), Expression("g.Name")]
public String GenreName
{
get { return Fields.GenreName[this]; }
set { Fields.GenreName[this] = value; }
}
// ...
public RowFields()
: base("[mov].Movie")
{
LocalTextPrefix = "MovieDB.Movie";
}
}
}
}
Here we mapped GenreId field and also declared that it has a foreign key relation to GenreId
field in [mov].Genre table using ForeignKey attribute.
If we did generate code for Movie table after we added this Genre table, Sergen would
understand this relation by checking foreign key definition at database level, and
generate similar code for us.
81
Adding Movie Genres
We also added another field, GenreName that is not actually a field in Movie table, but in
Genre table.
Serenity entities are more like SQL views. You can bring in fields from other tables with joins.
By adding LeftJoin("g") attribute to MovieId property, we declared that whenever Genre table
needs to be joined to, its alias will be g.
So when Serenity needs to select from Movies table, it will produce an SQL query like this:
This join will only be performed if a field from Genre table requested to be selected, e.g.
its column is visible in a data grid.
namespace MovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieForm
{
//...
public Int32 GenreId { get; set; }
public MovieKind Kind { get; set; }
}
}
Now if we build and run application, we'll see that a Genre field is added to our form. The
problem is, it accepts data entry as an integer. We want it to use a dropdown.
It's clear that we need to change editor type for GenreId field.
82
Adding Movie Genres
For enumeration values, it was simple, we just run T4 templates, and they copied enum
declaration to script side.
Here we can't do the same. Genre list is a database based dynamic list.
Serenity has notion of dynamic scripts to make dynamic data available to script side in the
form of runtime generated scripts.
Dynamic scripts are similar to web services, but their outputs are dynamic javascript
files that can be cached on client side.
The dynamic here corresponds to the data they contain, not their behavior. Unlike web
services, dynamic scripts can't accept any parameters. And their data is shared among
all users of your site. They are like singletons or static variables.
You shouldn't try to write a dynamic script (e.g. lookup) that acts like a web service.
To declare a dynamic lookup script for Genre table, open GenreRow.cs and modify it like
below:
namespace MovieTutorial.MovieDB.Entities
{
// ...
Rebuild your project, launch it, after logging in, open developer console by F12.
Type Q.getLookup('MovieDB.Genre')
83
Adding Movie Genres
Here MovieDB.Genre is the lookup key we assigned to this lookup script when declaring it
with:
[LookupScript("MovieDB.Genre")]
This step was just to show how to check if a lookup script is available client side.
Lookup key, "MovieDB.Genre" is case sensitive. Make sure you type exact same case
everywhere.
I usually prefer the latter, as it is the central place, but you may choose to set it on a form, if
that editor type is specific to that form only.
Information defined on a form can't be reused. For example, grids use information in
XYZColumn.cs / XYZRow.cs while dialogs use information in XYZForm.cs /
XYZRow.cs. So it's usually better to define things in XYZRow.cs.
Open MovieRow.cs and add LookupEditor attribute to GenreId property as shown below:
84
Adding Movie Genres
After we build and launch our project, we'll now have a searchable dropdown (Select2.js) on
our Genre field.
While defining [LookupEditor] we hardcoded the lookup key. It's also possible to reuse
information on GenreRow:
This is functionally equivalent. I'd prefer latter. Here, Serenity will locate the [LookupScript]
attribute on GenreRow, and get lookup key information from there. If we had no
[LookupScript] attribute on GenreRow, you'd get an error on application startup:
85
Adding Movie Genres
Forms are scanned at application startup, so there is no way to handle this error without
fixing the issue.
namespace MovieTutorial.MovieDB.Columns
{
// ...
public class MovieColumns
{
//...
[Width(100)]
public String GenreName { get; set; }
[DisplayName("Runtime in Minutes"), Width(150), AlignRight]
public Int32 Runtime { get; set; }
}
}
One option is to open Genres page, add it, and come back to movie form again. Not so
pretty...
86
Adding Movie Genres
Fortunately, Serenity has integrated inplace item definition ability for lookup editors.
Now we can define a new Genre by clicking star/pen icon next to genre field.
Here we also see that we can use a dialog from another page (GenreDialog) in the
movies page. In Serenity applications, all client side objects (dialogs, grids, editors,
formatters etc.) are self-contained reusable components (widgets) that are not bound to
any page.
It is also possible to start typing in genre editor, and it will provide you with an option to add a
new genre.
87
Adding Movie Genres
Here, our lookup editor made use of a convention. Because its lookup key is
MovieDB.Genre, it searched for a dialog class with full names below:
MovieDB.GenreDialog
MovieTutorial.MovieDB.GenreDialog
...
...
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
export class GenreDialog extends Serenity.EntityDialog<GenreRow, any> {
protected getFormKey() { return GenreForm.formKey; }
protected getIdProperty() { return GenreRow.idProperty; }
protected getLocalTextPrefix() { return GenreRow.localTextPrefix; }
protected getNameProperty() { return GenreRow.nameProperty; }
protected getService() { return GenreService.baseUrl; }
If, lookup key for GenreRow and its dialog class didn't match, we would get an error in
browser console, as soon as we click the inplace add button:
But this is not the case as they match. In such a case, either you'd have to use a compatible
lookup key like "ModuleName.RowType", or you'd need to specify dialog type explicitly:
88
Adding Movie Genres
You shouldn't specify Dialog suffix, nor the full namespace, e.g.
MovieTutorial.MovieDB.Genre, as Serenity automatically searches for them.
Serenity has several filtering methods. One of them is Quick Filter, which we'll use on Genre
field.
Build and navigate to Movies page. You'll a quick filtering dropdown for genre field is
available:
89
Adding Movie Genres
The field that is filtered is actually GenreId not GenreName that we attached this attribute to.
Serenity is clever enough to understand this relation, and determined editor type to use by
looking at attributes of GenreId property in GenreRow.cs.
Re-runing T4 Templates
As we added a new entity to our application, we should run T4 templates after building
solution.
90
Updating Serenity Packages
When you read this you are probably using a later version, so you might not have to update
serenity yet.
But, i want to show how you can update Serenity NuGet packages, in case another version
comes out in the future.
I prefer to work with NuGet from Package Manager Console instead of using NuGet GUI
interface as it performs much faster.
So, click View -> Other Windows -> Package Manager Console.
Type:
Update-Package Serenity.Web
Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
Update-Package Serenity.CodeGenerator
During updates, if NuGet asks to override changes in some script files, you can safely
say yes unless you did manual modifications to Serenity script files (which i suggest
you avoid).
91
Updating Serenity Packages
From time to time, breaking changes might happen in Serenity, but they are kept to
minimum, and you might have to do a few manual changes in your application code.
Such changes are documented with a [BREAKING CHANGE] tag in change log at:
https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md
If you still have a problem after upgrade, feel free to open an issue at:
https://github.com/volkanceylan/Serenity/issues
What Is Updated
Updating Serenity NuGet packages, takes Serenity assemblies up to the latest version.
It might also update some other third-party packages like ASP.NET MVC, FluentMigrator,
Select2.js, SlickGrid etc.
Please don't update Select2.js to a version after 3.5.1 yet as it has some compability
problems with jQuery validation.
Serenity.Web package also comes with some static script and css resources like the
following:
Content/serenity/serenity.css
Scripts/saltarelle/mscorlib.js
Scripts/saltarelle/linq.js
Scripts/serenity/Serenity.CoreLib.js
Scripts/serenity/Serenity.Script.UI.js
We are trying to keep updating your application as simple as possible, but Serene is just a
project template, not a static package. Your application is a customizable copy of Serene.
You might have done modifications to application source, so updating a Serene application
created with an older version of Serene template, might not be as easy as it sounds.
So sometimes you might have to create a new Serene application with up-to-date Serene
template version, and compare it to your application, and merge features you need. This is a
manual process.
92
Updating Serenity Packages
Usually, updating Serenity packages is enough. Updating Serene itself is not required unless
you need some recent features from latest Serene version.
We have some plans to make parts of Serene template also a NuGet package, but it is
still not trivial how to update your application without overriding your changes, e.g. to
shared code like Navigation items. And what if you removed Northwind code, but our
update reinstalls it? I'm open to suggestions...
93
Allowing Multiple Genre Selection
For this, we need a M-N mapping table that will let us link any movie to multiple genres.
Modules/Common/Migrations/DefaultDB/
DefaultDB_20160528_115400_MovieGenres.cs:
94
Allowing Multiple Genre Selection
using FluentMigrator;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160528115400)]
public class DefaultDB_20160528_115400_MovieGenres : Migration
{
public override void Up()
{
Create.Table("MovieGenres").InSchema("mov")
.WithColumn("MovieGenreId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("MovieId").AsInt32().NotNullable()
.ForeignKey("FK_MovieGenres_MovieId",
"mov", "Movie", "MovieId")
.WithColumn("GenreId").AsInt32().NotNullable()
.ForeignKey("FK_MovieGenres_GenreId",
"mov", "Genre", "GenreId");
Execute.Sql(
@"INSERT INTO mov.MovieGenres (MovieId, GenreId)
SELECT m.MovieId, m.GenreId
FROM mov.Movie m
WHERE m.GenreId IS NOT NULL");
Delete.ForeignKey("FK_Movie_GenreId")
.OnTable("Movie").InSchema("mov");
Delete.Column("GenreId")
.FromTable("Movie").InSchema("mov");
}
I tried to save existing Genre declarations on Movie table, by copying them to our new
MovieGenres table. The line above with Execute.Sql does this.
Then we should remove GenreId column, by first deleting the foreign key declaration
FK_Movie_GenreId that we defined on it previously.
95
Allowing Multiple Genre Selection
This is because we still have mapping for GenreId column in our row. Error above is
received from AJAX call to List service handler for Movie table.
Repeating of error message originates from SQL server. MovieId column name passes
several times within the generated dynamic SQL.
Remove GenreId and GenreName properties and their related field objects from
MovieRow.cs:
// remove this
public Int32? GenreId
{
get { return Fields.GenreId[this]; }
set { Fields.GenreId[this] = value; }
}
// remove this
public String GenreName
{
get { return Fields.GenreName[this]; }
set { Fields.GenreName[this] = value; }
}
// remove this
[Width(100), QuickFilter]
public String GenreName { get; set; }
96
Allowing Multiple Genre Selection
// remove this
public Int32 GenreId { get; set; }
As we're not going to edit movie genres from a separate page, you can safely delete the
generated files below:
MovieGenresColumns.cs
MovieGenresDialog.ts
MovieGenresEndpoint.cs
MovieGenresForm.cs
MovieGenresGrid.cs
MovieGenresIndex.cshtml
MovieGenresPage.cs
You can also remove CSS entries for s-MovieDB-MovieGenresDialog from site.less.
97
Allowing Multiple Genre Selection
//...
[DisplayName("Kind"), NotNull, DefaultValue(MovieKind.Film)]
public MovieKind? Kind
{
get { return (MovieKind?)Fields.Kind[this]; }
set { Fields.Kind[this] = (Int32?)value; }
}
[DisplayName("Genres")]
[LookupEditor(typeof(GenreRow), Multiple = true), NotMapped]
[LinkingSetRelation(typeof(MovieGenresRow), "MovieId", "GenreId")]
public List<Int32> GenreList
{
get { return Fields.GenreList[this]; }
set { Fields.GenreList[this] = value; }
}
Our property has [LookupEditor] attribute just like GenreId property had, but with one
difference. This one accepts multiple genre selection. We set it with Multiple = true
argument.
This property also has NotMapped flag, which is something similar to Unmapped fields in
Serenity. It specifies that this property has no matching database column in database.
We don't have a GenreList column in Movie table, so we should set it as an unmapped field.
Otherwise, Serenity will try to SELECT it, and we'll get SQL errors.
This is an attribute which is specific to M-N releations that links a row in this table to multiple
rows from another table.
98
Allowing Multiple Genre Selection
First argument of it is the type of M-N mapping row, which is MovieGenresRow here.
Second argument is the property name of field in that row (MovieGenresRow) that matches
this row's ID property, e.g. MovieId.
Third argument is the property name of field in that row (MovieGenresRow) that links
multiple Genres by their IDs, e.g. GenreId.
This behavior, will intercept service handlers for Create, Update, Delete, Retrieve and
List and inject code to populate or update our GenreList column and its related
MovieGenres table.
99
Allowing Multiple Genre Selection
100
Allowing Multiple Genre Selection
We would prefer genre names instead of Genre IDs, so it's clear that we need to format
these values, by converting GenreId to their Genre name equivalents.
101
Allowing Multiple Genre Selection
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerFormatter()
export class GenreListFormatter implements Slick.Formatter {
format(ctx: Slick.FormatterContext) {
let idList = ctx.value as number[];
if (!idList || !idList.length)
return "";
return Q.htmlEncode(g.Name);
}).join(", ");
}
}
}
Here we define a new formatter, GenreListFormatter and register it with Serenity type
system, using @Serenity.Decorators.registerFormatter decorator. Decorators are similar to
.NET attributes.
All formatters should implement Slick.Formatter interface, which has a format method that
takes a ctx parameter of type Slick.FormatterContext.
ctx, which is the formatting context, is an object with several members. One of them is value
that contains the column value for current grid row/column being formatted.
As we know that we'll use this formatter on column with a List<Int32> value, we start by
casting value to number[].
There is no Int32 type in Javascript. Int32, Double, Single etc. corresponds to number
type. Also, generic List<> type in C# corresponds to an Array in Javascript.
Then we get a reference to Genre lookup, which has a dictionary of Genre rows in its
itemById property:
102
Allowing Multiple Genre Selection
Next, we start mapping these ID values in our idList to their Genre name equivalents, using
Array.map function in Javascript, which is pretty similar to LINQ Select statement:
We lookup an ID in our Genre dictionary. It should be in dictionary, but we play safe here,
and return its numeric value, if the genre is not found in dictionary.
let g = byId[x];
if (!g)
return x.toString();
If we could find the genre row, corresponding to this ID, we return its Name value. We
should HTML encode the genre name, just in case it contains invalid HTML characters, like
< , > or & .
return Q.htmlEncode(g.Name);
We could also write a generic formatter that works with any type of lookup list, but it's
beyond scope of this tutorial.
After building and transforming, open MovieColumns.cs and attach this formatter to
MovieList property:
103
Allowing Multiple Genre Selection
104
Filtering with Multiple Genre List
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
public class MovieColumns
{
//...
[Width(200), GenreListFormatter, QuickFilter]
public List<Int32> GenreList { get; set; }
}
As soon as you type a Genre into Genres you'll have this error:
As of Serenity 2.6.3, LinkingSetRelation will automatically handle equality filter for its
field, so you won't get this error and it will just work. Anyway, it's still recommended to
follow steps below as it is a good sample for defining custom list requests and handling
them when required.
ListHandler tried to filter by GenreList field, but as there is no such column in database, we
got this error.
105
Filtering with Multiple Genre List
As we are going to do something non-standard, e.g. filtering by values in a linking set table,
we need to prevent ListHandler from filtering itself on GenreList property.
We could process the request Criteria object (which is similar to an expression tree) using a
visitor and handle GenreList ourself, but it would be a bit complex. So i'll take a simpler road
for now.
Let's take a subclass of standard ListRequest object and add our Genres filter parameter
there. Add a MovieListRequest.cs file next to MovieRepository.cs:
namespace MovieTutorial.MovieDB
{
using Serenity.Services;
using System.Collections.Generic;
We added a Genres property to our list request object, which will hold the optional Genres
we want movies to be filtered on.
//...
private class MyListHandler : ListRequestHandler<MyRow, MovieListRequest> { }
}
106
Filtering with Multiple Genre List
And another little change in MovieEndpoint.cs, which is the actual web service:
Now its time to build and transform templates, so our MovieListRequest object and related
service methods will be available at client side.
Need to intercept quick filter item and move the genre list to Genres property of our
MovieListRequest.
Edit MovieGrid.ts:
//...
protected getQuickFilters() {
let items = super.getQuickFilters();
genreListFilter.handler = h => {
var request = (h.request as MovieListRequest);
var values = (h.widget as Serenity.LookupEditor).values;
request.Genres = values.map(x => parseInt(x, 10));
h.handled = true;
};
return items;
}
}
getQuickFilters is a method that is called to get a list of quick filter objects for this grid type.
107
Filtering with Multiple Genre List
Actually there is only one quick filter now, but we want to play safe.
Next step is to set the handler method. This is where a quick filter object reads the editor
value and applies it to request's Criteria (if multiple) or EqualityFilter (if single value)
parameters, just before its submitted to list service.
genreListFilter.handler = h => {
Last step is to set handled to true, to disable default behavior of quick filter object, so it won't
set Criteria or EqualityFilter itself:
h.handled = true;
Now we'll no longer have Invalid Column Name GenreList error but Genres filter is not
applied server side yet.
108
Filtering with Multiple Genre List
if (!Request.Genres.IsEmptyOrNull())
{
var mg = Entities.MovieGenresRow.Fields.As("mg");
query.Where(Criteria.Exists(
query.SubQuery()
.From(mg)
.Select("1")
.Where(
mg.MovieId == fld.MovieId &&
mg.GenreId.In(Request.Genres))
.ToString()));
}
}
}
ApplyFilters is a method that is called to apply filters specified in list request's Criteria and
EqualityFilter parameters. This is a good place to apply our custom filter.
var mg = Entities.MovieGenresRow.Fields.As("mg");
Here it needs some explanation, as we didn't cover Serenity entity system yet.
var x = MovieGenresRow.Fields;
new SqlQuery()
.From(x)
.Select(x.MovieId)
.Select(x.GenreId);
If we wrote a query like above, its SQL output would be something like this:
109
Filtering with Multiple Genre List
Unless told otherwise, Serenity always assigns t0 to a row's primary table. Even if we named
MovieGenresRow.Fields as variable x, it's alias will still be t0.
Because when compiled, x won't be there and Serenity has no way to know its variable
name. Serenity entity system doesn't use an expression tree like in LINQ to SQL or
Entity Framework. It makes use of very simple string / query builders.
var x = MovieGenresRow.Fields.As("x");
new SqlQuery()
.From(x)
.Select(x.MovieId)
.Select(x.GenreId);
...results at:
In MyListHandler, which is for MovieRow entities, t0 is already used for MovieRow fields. So,
to prevent clashes with MovieGenresRow fields (which is named fld), i had to assign
MovieGenresRow an alias, mg.
var mg = Entities.MovieGenresRow.Fields.As("mg");
What i'm trying to achieve, is a query like this (just the way we'd do this in bare SQL):
So i'm adding a WHERE filter to main query with Where method, using an EXISTS criteria:
query.Where(Criteria.Exists(
110
Filtering with Multiple Genre List
query.SubQuery()
.From(mg)
.Select("1")
.Where(
mg.MovieId == fld.MovieId &&
mg.GenreId.In(Request.Genres))
As Criteria.Exists method expects a simple string, i had to use .ToString() at the end, to
convert subquery to a string:
.ToString()));
It might look a bit alien at start, but by time you'll understand that Serenity query system
matches SQL almost 99%. It can't be the exact SQL as we have to work in a different
language, C#.
111
The Cast and Characters They Played
Actor/Actress Character
Keanu Reeves Neo
Laurence Fishburne Morpheus
It's clear that we also need a Person table as we'll keep actors/actresses by their ID.
It's better to call it Person as actors/actresses might become directors, scenario writers
and such later.
MovieTutorial.Web/Modules/Common/Migrations/DefaultDB/
DefaultDB_20160528_141600_PersonAndMovieCast.cs:
112
The Cast and Characters They Played
using FluentMigrator;
using System;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160528141600)]
public class DefaultDB_20160528_141600_PersonAndMovieCast : Migration
{
public override void Up()
{
Create.Table("Person").InSchema("mov")
.WithColumn("PersonId").AsInt32().Identity()
.PrimaryKey().NotNullable()
.WithColumn("Firstname").AsString(50).NotNullable()
.WithColumn("Lastname").AsString(50).NotNullable()
.WithColumn("BirthDate").AsDateTime().Nullable()
.WithColumn("BirthPlace").AsString(100).Nullable()
.WithColumn("Gender").AsInt32().Nullable()
.WithColumn("Height").AsInt32().Nullable();
Create.Table("MovieCast").InSchema("mov")
.WithColumn("MovieCastId").AsInt32().Identity()
.PrimaryKey().NotNullable()
.WithColumn("MovieId").AsInt32().NotNullable()
.ForeignKey("FK_MovieCast_MovieId", "mov", "Movie", "MovieId")
.WithColumn("PersonId").AsInt32().NotNullable()
.ForeignKey("FK_MovieCast_PersonId", "mov", "Person", "PersonId")
.WithColumn("Character").AsString(50).Nullable();
}
113
The Cast and Characters They Played
using Serenity.ComponentModel;
using System.ComponentModel;
namespace MovieTutorial.MovieDB
{
[EnumKey("MovieDB.Gender")]
public enum Gender
{
[Description("Male")]
Male = 1,
[Description("Female")]
Female = 2
}
}
114
The Cast and Characters They Played
//...
[DisplayName("Gender")]
public Gender? Gender
{
get { return (Gender?)Fields.Gender[this]; }
set { Fields.Gender[this] = (Int32?)value; }
}
//...
Rebuilding T4 Templates
As we declared a new enumeration and used it, we should rebuild solution, convert T4
templates
Now after launching your project, you should be able to enter actors:
115
The Cast and Characters They Played
On the title of edit dialog, first name of the person is shown (Carrie-Anne). It would be nice to
show full name. And also search with full name in grid.
namespace MovieTutorial.MovieDB.Entities
{
//...
public sealed class PersonRow : Row, IIdRow, INameRow
{
//... remove QuickSearch from FirstName
[DisplayName("First Name"), Size(50), NotNull]
public String Firstname
{
get { return Fields.Firstname[this]; }
set { Fields.Firstname[this] = value; }
}
[DisplayName("Full Name"),
Expression("(t0.Firstname + ' ' + t0.Lastname)"), QuickSearch]
public String Fullname
{
get { return Fields.Fullname[this]; }
set { Fields.Fullname[this] = value; }
}
//...
116
The Cast and Characters They Played
By adding QuickSearch attribute to FullName, instead of Firstname, grid will now search by
default on Fullname field.
But dialog will still show Firstname. We need to build and transform templates to make it
show Fullname.
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
export class PersonDialog extends Serenity.EntityDialog<PersonRow, any> {
protected getFormKey() { return PersonForm.formKey; }
protected getIdProperty() { return PersonRow.idProperty; }
protected getLocalTextPrefix() { return PersonRow.localTextPrefix; }
protected getNameProperty() { return PersonRow.nameProperty; }
protected getService() { return PersonService.baseUrl; }
Thus, unless we transform T4 templates, the name property change we did in PersonRow.cs
won't be reflected in corresponding MovieDB.PersonRow.ts file under
*Modules/Common/Imports/ServerTypings/ ServerTypings.tt":
117
The Cast and Characters They Played
namespace MovieTutorial.MovieDB {
export interface PersonRow {
PersonId?: number;
Firstname?: string;
Lastname?: string;
Fullname?: string;
//...
}
This metadata (name property of PersonRow) is transferred to TypeScript with a code file
(MovieDB.PersonRow.ts) that is generated by ServerTypings.tt file.
Similarly, idProperty, localTextPrefix, Enum Types etc. are also generated under
ServerTypings.tt. Thus, when you make a change that affects a metadata in these generated
files, you should transform T4 templates to transfer that information to TypeScript.
You should always build before transforming, as T4 files uses output DLL of
MovieTutorial.Web project. Otherwise you'll be generating code for an older version of
your Web project.
namespace MovieTutorial.MovieDB.Entities
{
//...
[LookupScript("MovieDB.Person")]
public sealed class PersonRow : Row, IIdRow, INameRow
//...
118
The Cast and Characters They Played
Build and transform templates again, you'll see that MovieDB.PersonRow.ts now has a
getLookup() method alongside with a new lookupKey property:
namespace MovieTutorial.MovieDB {
export interface PersonRow {
//...
}
//...
}
After generating code, as we don't need a separate page to edit movie cast table, you may
delete files listed below:
119
The Cast and Characters They Played
MovieCastIndex.cshtml
MovieCastPage.cs
MovieDialog.ts
MovieGrid.ts
We'll list the cast for a movie in the Movie dialog and allow them to be edited along with the
movie. Also, cast will be saved together with movie entity in one transaction.
Thus, cast editing will be in memory, and when user presses save button in Movie dialog,
the movie and its cast will be saved to database in one shot (one transaction).
It would be possible to edit the cast independently, here we just want to show how it can
be done.
For some types of master/detail records like order/detail, details shouldn't be allowed to
be edited independently for consistency reasons. Serene already has a sample for this
kind of editing in Northwind/Order dialog.
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerEditor()
export class MovieCastEditor
extends Common.GridEditorBase<MovieCastRow> {
protected getColumnsKey() { return "MovieDB.MovieCast"; }
protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
constructor(container: JQuery) {
super(container);
}
}
}
120
The Cast and Characters They Played
This editor derives from Common.GridEditorBase class in Serene, which is a special grid
type that is designed for in-memory editing. It is also the base class for Order Details editor
used in Order dialog.
The <reference /> line at top of the file is important. TypeScript has ordering problems
with input files. If we didn't put it there, TypeScript would sometimes output
GridEditorBase after our MovieCastEditor, and we'd get runtime errors.
As a rule of thumb, if you are deriving some class from another in your project (not
Serenity classes), you should put a reference to file containing that base class.
This helps TypeScript to convert GridEditorBase to javascript before other classes that
might need it.
To reference this new editor type from server side, build and transform all templates.
This base class might be integrated to Serenity in later versions. In that case, its
namespace will become Serenity, instead of Serene or MovieTutorial.
namespace MovieTutorial.MovieDB.Forms
{
//...
public class MovieForm
{
public String Title { get; set; }
[TextAreaEditor(Rows = 3)]
public String Description { get; set; }
[MovieCastEditor]
public List<Entities.MovieCastRow> CastList { get; set; }
[TextAreaEditor(Rows = 8)]
public String Storyline { get; set; }
//...
}
}
Now build and launch your application. Open a movie dialog and you'll be greeted by our
new editor:
121
The Cast and Characters They Played
OK, it looked easy, but i'll be honest, we are not even half the way.
That New MovieCast button doesn't work, need to define a dialog for it. The grid columns
are not what i'd like them to be and the field and button titles are not so user friendly...
Also we'll have to handle a bit more plumbing like loading and saving cast list on server side
(we'll show the harder - manual way first, then we'll see how easy it can be using a service
behavior).
122
The Cast and Characters They Played
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerClass()
export class MovieCastEditDialog extends
Common.GridEditorDialog<MovieCastRow> {
protected getFormKey() { return MovieCastForm.formKey; }
protected getNameProperty() { return MovieCastRow.nameProperty; }
protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
constructor() {
super();
this.form = new MovieCastForm(this.idPrefix);
}
}
}
We are using another base class from Serene, Common.GridEditorDialog which is also used
by OrderDetailEditDialog.
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerEditor()
export class MovieCastEditor
extends Common.GridEditorBase<MovieCastRow> {
protected getColumnsKey() { return "MovieDB.MovieCast"; }
protected getDialogType() { return MovieCastEditDialog; }
protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
constructor(container: JQuery) {
super(container);
}
protected getAddButtonCaption() {
return "Add";
}
}
}
123
The Cast and Characters They Played
This dialog needs some CSS formatting. Movie title and person name fields accepts integer
inputs (as they are actually MovieId and PersonId fields).
Editing MovieCastForm.cs
getFormKey() method of MovieCastEditDialog returns MovieCastForm.formKey, so it
currently uses MovieCastForm.cs generated by Sergen.
It is possible to have multiple forms for one entity in Serenity. If i wanted to save
MovieCastForm for some other standalone dialog, e.g. MovieCastDialog (which we actually
deleted), i would prefer to define a new form like MovieCastEditForm, but this is not the
case.
124
The Cast and Characters They Played
namespace MovieTutorial.MovieDB.Forms
{
using Serenity.ComponentModel;
using System;
using System.ComponentModel;
[FormScript("MovieDB.MovieCast")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
public class MovieCastForm
{
public Int32 PersonId { get; set; }
public String Character { get; set; }
}
}
[ConnectionKey("Default"), TwoLevelCached]
[DisplayName("Movie Casts"), InstanceName("Cast")]
[ReadPermission("Administration")]
[ModifyPermission("Administration")]
public sealed class MovieCastRow : Row, IIdRow, INameRow
{
//...
[DisplayName("Actor/Actress"), NotNull, ForeignKey("[mov].[Person]", "PersonId
")]
[LeftJoin("jPerson"), TextualField("PersonFirstname")]
[LookupEditor(typeof(PersonRow))]
public Int32? PersonId
{
get { return Fields.PersonId[this]; }
set { Fields.PersonId[this] = value; }
}
I have set editor type for PersonId field to a lookup editor and as i have already added a
LookupScript attribute to PersonRow, i can reuse that information for setting the lookup key.
Also changed DisplayName and InstanceName attributes for row to set dialog title.
125
The Cast and Characters They Played
Build solution, launch and now MovieCastEditDialog has a better editing experience. But still
too big in width and height.
.s-MovieDB-MovieCastDialog {
> .size { width: 650px; }
.caption { width: 150px; }
}
The CSS at the bottom of site.less is for the MovieCastDialog, not MovieCastEditDialog,
because we defined this class ourselves, not with code generator.
We created a new dialog type MovieCastEditDialog, so now our new dialog has a CSS class
of s-MovieDB-MovieCastEditDialog, but code generator only generated CSS rules for s-
MovieDB-MovieCastDialog.
s-ModuleName-TypeName CSS class helps with individual styling when two modules
has a type with the same name.
As we are not gonna actually use MovieCastDialog (we deleted it), let's rename the one in
site.less:
.s-MovieDB-MovieCastEditDialog {
> .size { width: 450px; }
.caption { width: 120px; }
.s-PropertyGrid .categories { height: 120px; }
}
126
The Cast and Characters They Played
We want to show actors fullname instead of PersonId (integer value), so we'll declare this
field in MovieCastRow.cs first:
127
The Cast and Characters They Played
namespace MovieTutorial.MovieDB.Entities
{
//...
public sealed class MovieCastRow : Row, IIdRow, INameRow
{
// ...
[DisplayName("Actor/Actress"),
Expression("(jPerson.Firstname + ' ' + jPerson.Lastname)")]
public String PersonFullname
{
get { return Fields.PersonFullname[this]; }
set { Fields.PersonFullname[this] = value; }
}
// ...
128
The Cast and Characters They Played
namespace MovieTutorial.MovieDB.Columns
{
using Serenity.ComponentModel;
using System;
[ColumnsScript("MovieDB.MovieCast")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
public class MovieCastColumns
{
[EditLink, Width(220)]
public String PersonFullname { get; set; }
[EditLink, Width(150)]
public String Character { get; set; }
}
}
129
The Cast and Characters They Played
When you click the save button, dialog builds an entity to save like this:
{
PersonId: 7,
Character: 'Neo'
}
These fields corresponds to the form fields you previously set in MovieCastForm.cs:
130
The Cast and Characters They Played
There is no PersonFullname field in this entity, so grid can't display its value.
We need to set PersonFullname ourself. Let's first transform T4 templates to have access to
PersonFullname field that we recently added, then edit MovieCastEditor.ts:
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerEditor()
export class MovieCastEditor extends Common.GridEditorBase<MovieCastRow> {
//...
row.PersonFullname = PersonRow.getLookup()
.itemById[row.PersonId].Fullname;
return true;
}
}
}
ValidateEntity is a method from our GridEditorBase class in Serene. This method is called
when Save button is clicked to validate the entity, just before it is going to be added to the
grid. But we are overriding it here for another purpose (to set PersonFullname field value)
rather than validation.
As we saw before, our entity has PersonId and Character fields filled in. We can use the
value of PersonId field to determine the person fullname.
For this, we need a dictionary that maps PersonId to their Fullname values. Fortunately,
person lookup has such a dictionary. We can access the lookup for PersonRow through its
getLookup method.
131
The Cast and Characters They Played
All lookups has a itemById dictionary that allows you to access an entity of that type by its
ID.
Lookups are a simple way to share server side data with client side. But they are only
suitable for small sets of data.
This error is raised from -> Row deserializer (JsonRowConverter for JSON.NET) at server
side.
If you open developer tools with F12, click Network tab, and watch AJAX request after
clicking Save button, you'll see that it has such a request payload:
132
The Cast and Characters They Played
{
"Entity": {
"Title": "The Matrix",
"Description": "A computer hacker...",
"CastList": [
{
"PersonId":"1",
"Character":"Neo",
"PersonFullname":"Keanu Reeves"
}
],
"Storyline":"Thomas A. Anderson is a man living two lives...",
"Year":1999,
"ReleaseDate":"1999-03-31",
"Runtime":136,
"GenreId":"",
"Kind":"1",
"MovieId":1
}
}
namespace MovieTutorial.MovieDB.Entities
{
// ...
public sealed class MovieRow : Row, IIdRow, INameRow
{
[DisplayName("Cast List"), NotMapped]
public List<MovieCastRow> CastList
{
get { return Fields.CastList[this]; }
set { Fields.CastList[this] = value; }
}
We defined a CastList property that will accept a List of MovieCastRow objects. The type of
Field class that is used for such row list properties is RowListField.
133
The Cast and Characters They Played
By adding [NotMapped] attribute, we specified that this field is not available directly in
database table, thus can't be selected through simple SQL queries. It is analogous to an
unmapped field in other ORM systems.
Now, when you click the Save button, you will not get an error.
But reopen the Matrix entity you just saved. There is no cast entry there. What happened to
Neo?
As this is an unmapped field, so movie Save service just ignored the CastList property.
If you remember that in prior section, our GenreList also was an unmapped field, but
somehow it worked there. Thats because we made use of a behavior,
LinkedSetRelationBehavior with that property.
Here we are sampling what would happen if we had no such service behavior.
if (Row.CastList != null)
{
var mc = Entities.MovieCastRow.Fields;
var oldList = IsCreate ? null :
Connection.List<Entities.MovieCastRow>(
mc.MovieId == this.Row.MovieId.Value);
new Common.DetailListSaveHandler<Entities.MovieCastRow>(
oldList, Row.CastList,
x => x.MovieId = Row.MovieId.Value).Process(this.UnitOfWork);
}
}
}
MySaveHandler, processes both CREATE (insert), and UPDATE service requests for Movie
rows. As most of its logic is handled by base SaveRequestHandler class, its class definition
was empty before.
134
The Cast and Characters They Played
We should first wait for Movie entity to be inserted / updated successfully, before inserting /
updating the cast list. Thus, we are including our customized code by overriding the base
AfterSave method.
If this is CREATE (insert) operation, we need the MovieId field value to reuse in
MovieCast records. As MovieId is an IDENTITY field, it is only available after inserting
the movie record.
As we are editing cast list in memory (client-side), this will be a batch update.
We need to compare old list of the cast records for this movie to the new list of cast records,
and INSERT/UPDATE/DELETE them.
User did some modifications in edit dialogs to cast list, and now we have A, B, D, E, F.
So we need to update A, B, D (in case character / actor changed), delete C, and insert new
records E and F.
To get a list of old records, we need to query database if this is an UPDATE movie operation.
If this is a CREATE movie operation there shouldn't be any old cast record.
this.Row refers to currently inserted / updated record (movie) with its new field values, so it
contains the MovieId value (new or existing).
To update cast records, we are creating a DetailListHandler object, with old cast list, new
cast list, and a delegate to set the MovieId field value in a cast record. This is to link new
cast records with the current movie.
135
The Cast and Characters They Played
We are not done yet. When a Movie entity is clicked in movie grid, movie dialog loads the
movie record by calling the movie Retrieve service. As CastList is an unmapped field, even if
we saved them properly, they won't be loaded into the dialog.
var mc = Entities.MovieCastRow.Fields;
Row.CastList = Connection.List<Entities.MovieCastRow>(q => q
.SelectTableFields()
.Select(mc.PersonFullname)
.Where(mc.MovieId == Row.MovieId.Value));
}
}
Here, we are overriding OnReturn method, to inject CastList into movie row just before
returning the it from retrieve service.
By default, List selects all table fields (not foreign view fields coming from other tables), but
to show actor name, i needed to also select PersonFullName field.
Now build the solution, and we can finally list / edit the cast.
136
The Cast and Characters They Played
var mc = Entities.MovieCastRow.Fields;
foreach (var detailID in Connection.Query<Int32>(
new SqlQuery()
.From(mc)
.Select(mc.MovieCastId)
.Where(mc.MovieId == Row.MovieId.Value)))
{
new DeleteRequestHandler<Entities.MovieCastRow>().Process(this.UnitOfWork,
new DeleteRequest
{
EntityId = detailID
});
}
}
}
The way we implemented this master/detail handling is not very intuitive and included
several manual steps at repository layer. Keep on reading to see how easily it could be done
by using an integrated feature (MasterDetailRelationAttribute).
We specified that this field is a detail list of a master/detail relation and master ID field
(foreignKey) of the detail table is MovieId.
137
The Cast and Characters They Played
This ensures that PersonFullname field on cast list is selected on retrieve. Otherwise, it
wouldn't be loaded as only table fields are selected by default. When you open a movie
dialog with existing cast list, full name would be empty.
Make sure you add any view field you use in grid columns to IncludeColumns. Put
comma between names of multiple fields, e.g. IncludeColumns = "FieldA, FieldB,
FieldC".
Now build your project and you'll see that same functionality works with much less code.
So we did the same thing, but this time declaratively, not imperatively (what should be done,
instead of how to do it)
https://en.wikipedia.org/wiki/Declarative_programming
We'll see how to write your own request handler behaviors in following chapters.
138
Listing Movies in Person Dialog
By default all entity dialogs (ones we used so far, which derive from EntityDialog) uses
EntityDialog template at MovieTutorial.Web/Views/Templates/EntityDialog.Template.html:
<div class="s-DialogContent">
<div id="~_Toolbar" class="s-DialogToolbar">
</div>
<div class="s-Form">
<form id="~_Form" action="">
<div class="fieldset ui-widget ui-widget-content ui-corner-all">
<div id="~_PropertyGrid"></div>
<div class="clear"></div>
</div>
</form>
</div>
</div>
This template contains a toolbar placeholder (~_Toolbar), form (~_Form) and PropertyGrid
(*~_PropertyGrid).
~_ is a special prefix that is replaced with a unique dialog ID at runtime. This ensures
that objects in two instances of a dialog won't have the same ID values.
EntityDialog template is shared by all dialogs, so we are not gonna modify it to add a tab to
PersonDialog.
139
Listing Movies in Person Dialog
</div>
</div>
</div>
The syntax we used here is specific to jQuery UI tabs widget. It needs an UL element with
list of tab links pointing to tab pane divs (.tab-pane).
When EntityDialog finds a div with ID ~_Tabs in its template, it automatically initializes a tabs
widget on it.
Naming of the template file is important. It must end with .Template.html extension. All files
with this extension are made available at client side through a dynamic script.
Folder of the template file is ignored, but templates must be under Modules or
Views/Template directories.
By default, all templated widgets (EntityDialog also derives from TemplatedWidget class),
looks for a template with their own classname. Thus, PersonDialog looks for a template with
the name MovieDB.PersonDialog.Template.html, followed by PersonDialog.Template.html.
If a template with class name is not found, search continues to base classes and eventually
a fallback template, EntityDialog.Template.html is used.
140
Listing Movies in Person Dialog
Meanwhile, i noticed Person link is still under MovieDB and we forgot to remove
MovieCast link. I'm fixing them now...
Creating PersonMovieGrid
Movie tab is empty for now. We need to define a grid with suitable columns and place it in
that tab.
First, declare the columns we'll use with the grid, in file PersonMovieColumns.cs next to
PersonColumns.cs:
141
Listing Movies in Person Dialog
namespace MovieTutorial.MovieDB.Columns
{
using Serenity.ComponentModel;
using System;
[ColumnsScript("MovieDB.PersonMovie")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
public class PersonMovieColumns
{
[Width(220)]
public String MovieTitle { get; set; }
[Width(100)]
public Int32 MovieYear { get; set; }
[Width(200)]
public String Character { get; set; }
}
}
namespace MovieTutorial.MovieDB {
@Serenity.Decorators.registerClass()
export class PersonMovieGrid extends Serenity.EntityGrid<MovieCastRow, any>
{
protected getColumnsKey() { return "MovieDB.PersonMovie"; }
protected getIdProperty() { return MovieCastRow.idProperty; }
protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
protected getService() { return MovieCastService.baseUrl; }
constructor(container: JQuery) {
super(container);
}
}
}
We'll actually use MovieCast service, to list movies a person acted in.
142
Listing Movies in Person Dialog
@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
export class PersonDialog extends Serenity.EntityDialog<PersonRow, any> {
protected getFormKey() { return PersonForm.formKey; }
protected getIdProperty() { return PersonRow.idProperty; }
protected getLocalTextPrefix() { return PersonRow.localTextPrefix; }
protected getNameProperty() { return PersonRow.nameProperty; }
protected getService() { return PersonService.baseUrl; }
constructor() {
super();
Remember that in our template we had a div with id ~_MoviesGrid under movies tab pane.
We created PersonMovie grid on that div.
We also attached to OnActivate event of jQuery UI tabs, and called Arrange method of the
dialog. This is to solve a problem with SlickGrid, when it is initially created in invisible tab.
Arrange triggers relayout for SlickGrid to solve this problem.
OK, now we can see list of movies in Movies tab, but something is strange:
143
Listing Movies in Person Dialog
PersonMovieGrid should know the person it shows the movie cast records for. So, we add a
PersonID property to this grid. This PersonID should be passed somehow to list service for
filtering.
144
Listing Movies in Person Dialog
namespace MovieTutorial.MovieDB
{
@Serenity.Decorators.registerClass()
export class PersonMovieGrid extends Serenity.EntityGrid<MovieCastRow, any>
{
protected getColumnsKey() { return "MovieDB.PersonMovie"; }
protected getIdProperty() { return MovieCastRow.idProperty; }
protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }
protected getService() { return MovieCastService.baseUrl; }
constructor(container: JQuery) {
super(container);
}
protected getButtons() {
return null;
}
protected getInitialTitle() {
return null;
}
protected usePager() {
return false;
}
protected getGridCanLoad() {
return this.personID != null;
}
get personID() {
return this._personID;
}
We are using ES5 (EcmaScript 5) property (get/set) features. It's pretty similar to C#
properties.
145
Listing Movies in Person Dialog
We store the person ID in a private variable. When it changes, we also set a equality filter for
PersonId field using SetEquality method (which will be sent to list service), and refresh to
see changes.
Equality filter is the list request parameter that is also used by quick filter items.
Overriding GetGridCanLoad method allows us to control when grid can call list service. If we
didn't override it, while creating a new Person, grid would load all movie cast records, as
there is not a PersonID yet (it is null).
List handler ignores an equality filter parameter if its value is null. Just like when a quick
filter dropdown is empty, all records are shown.
We also did three cosmetic changes, by overriding three methods, first to remove all buttons
from toolbar (getButtons), second to remove title from the grid (getInitialTitle) as tab title is
enough), and third to remove paging functionality (usePager), a person can't have a million
movies right?).
namespace MovieTutorial.MovieDB
{
// ...
export class PersonDialog extends Serenity.EntityDialog<PersonRow>
{
// ...
protected afterLoadEntity()
{
super.afterLoadEntity();
this.moviesGrid.personID = this.entityId;
}
}
}
Please note that entity is loaded in a later phase, so it won't be available in dialog
constructor.
this.EntityId refers to the identity value of the currently loaded entity. In new record mode, it
is null.
146
Listing Movies in Person Dialog
AfterLoadEntity and LoadEntity might be called several times during dialog lifetime, so
avoid creating some child objects in these events, otherwise you will have multiple
instances of created objects. Thats why we created the grid in dialog constructor.
.s-MovieDB-PersonDialog {
> .size { width: 650px; }
.caption { width: 150px; }
.s-PersonMovieGrid > .grid-container { height: 287px; }
}
147
Adding Primary and Gallery Images
using FluentMigrator;
namespace MovieTutorial.Migrations.DefaultDB
{
[Migration(20160603205900)]
public class DefaultDB_20160603_205900_PersonMovieImages : Migration
{
public override void Up()
{
Alter.Table("Person").InSchema("mov")
.AddColumn("PrimaryImage").AsString(100).Nullable()
.AddColumn("GalleryImages").AsString(int.MaxValue).Nullable();
Alter.Table("Movie").InSchema("mov")
.AddColumn("PrimaryImage").AsString(100).Nullable()
.AddColumn("GalleryImages").AsString(int.MaxValue).Nullable();
}
148
Adding Primary and Gallery Images
namespace MovieTutorial.MovieDB.Entities
{
// ...
public sealed class PersonRow : Row, IIdRow, INameRow
{
[DisplayName("Gallery Images"),
MultipleImageUploadEditor(FilenameFormat = "Person/GalleryImages/~")]
public string GalleryImages
{
get { return Fields.GalleryImages[this]; }
set { Fields.GalleryImages[this] = value; }
}
// ...
149
Adding Primary and Gallery Images
namespace MovieTutorial.MovieDB.Entities
{
// ...
public sealed class MovieRow : Row, IIdRow, INameRow
{
[DisplayName("Primary Image"), Size(100),
ImageUploadEditor(FilenameFormat = "Movie/PrimaryImage/~")]
public string PrimaryImage
{
get { return Fields.PrimaryImage[this]; }
set { Fields.PrimaryImage[this] = value; }
}
[DisplayName("Gallery Images"),
MultipleImageUploadEditor(FilenameFormat = "Movie/GalleryImages/~")]
public string GalleryImages
{
get { return Fields.GalleryImages[this]; }
set { Fields.GalleryImages[this] = value; }
}
// ...
public class RowFields : RowFieldsBase
{
// ...
public readonly StringField PrimaryImage;
public readonly StringField GalleryImages;
// ...
}
}
}
FilenameFormat specifies the naming of uploaded files. For example, Person primary image
will be uploaded to a folder under App_Data/upload/Person/PrimaryImage/.
You may change upload root (App_Data/upload) to anything you like by modifying
UploadSettings appSettings key in web.config.
{1:00000}/{0:00000000}_{2} .
Here, parameter {0} is replaced with identity of the record, e.g. PersonID.
Parameter {1} is identity / 1000. This is useful to limit number of files that is stored in one
directory.
150
Adding Primary and Gallery Images
Parameter {2} is a unique string like 6l55nk6v2tiyi, which is used to generate a new file
name on every upload. This helps to avoid problems caused by caching on client side.
It also provides some security so file names can't be known without having a link.
Thus, a file we upload for person primary image will be located at a path like this:
> App_Data\upload\Person\PrimaryImage\00000\00000001_6l55nk6v2tiyi.jpg
You don't have to follow this naming scheme. You can specify your own format like
PersonPrimaryImage_{0}_{2} .
namespace MovieTutorial.MovieDB.Forms
{
//...
public class PersonForm
{
public String Firstname { get; set; }
public String Lastname { get; set; }
public String PrimaryImage { get; set; }
public String GalleryImages { get; set; }
public DateTime BirthDate { get; set; }
public String BirthPlace { get; set; }
public Gender Gender { get; set; }
public Int32 Height { get; set; }
}
}
151
Adding Primary and Gallery Images
namespace MovieTutorial.MovieDB.Forms
{
//...
public class MovieForm
{
public String Title { get; set; }
[TextAreaEditor(Rows = 3)]
public String Description { get; set; }
[MovieCastEditor]
public List<Entities.MovieCastRow> CastList { get; set; }
public String PrimaryImage { get; set; }
public String GalleryImages { get; set; }
[TextAreaEditor(Rows = 8)]
public String Storyline { get; set; }
public Int32 Year { get; set; }
public DateTime ReleaseDate { get; set; }
public Int32 Runtime { get; set; }
public Int32 GenreId { get; set; }
public MovieKind Kind { get; set; }
}
}
.s-MovieDB-PersonDialog {
> .size { width: 700px; height: 600px; }
.caption { width: 150px; }
.s-PersonMovieGrid > .grid-container { height: 500px; }
}
152
Adding Primary and Gallery Images
153
Multi Tenancy
Multi Tenancy
In this tutorial we are going to turn Norhwind into a multi-tenant application.
We'll add a TenantId field to every table, including Users, and let user see and modify only
records belonging to her tenant. So, tenants will work in isolation, as if they are working with
their own database.
Multi tenant applications has some advantages like reduced cost of management. But they
also have some disadvantages. For example, as all tenant data is in a single database, a
tenant can't simply take or backup her data alone. Performance is usually reduced as there
are more records to handle.
With increasing trend of cloud applications, decreased cost of virtualization, and with
features like migration, its now easier to setup multi-instance apps.
I'd personally avoid multi-tenant applications. It's better to have one database per customer
in my opinion.
But some users asked about how to implement this feature. This tutorial will help us explain
some advanced Serenity topics as a bonus, along with multi tenancy.
https://github.com/volkanceylan/Serenity-Tutorials/tree/master/MultiTenancy
154
Multi Tenancy
In Solution explorer, you should see two projects with names MultiTenancy.Web and
MultiTenancy.Script.
Make sure MultiTenancy.Web is the startup project (it should be bold), if not right click
on project name and click Set As Startup Project.
This is controlled by a setting under Visual Studio Options -> Projects and Solutions ->
Build And Run -> "Only build startup projects and dependencies on Run". It is not
recommended to change it.
To make Script project also build when Web project is run, right click MultiTenancy.Web
project, click Build Dependencies -> Project Dependencies and check MultiTenancy.Script
under Dependencies tab.
155
Adding Tenants Table and TenantId Field
As Northwind tables already have records, we'll define a primary tenant with ID 1, and set all
existing records TenantId to it.
It's time to write a migration, actually two migrations, one for Northwind and one for Default
database.
DefaultDB_20160110_092200_MultiTenant.cs:
156
Adding Tenants Table and TenantId Field
using FluentMigrator;
namespace MultiTenancy.Migrations.DefaultDB
{
[Migration(20160110092200)]
public class DefaultDB_20160110_092200_MultiTenant
: AutoReversingMigration
{
public override void Up()
{
Create.Table("Tenants")
.WithColumn("TenantId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("TenantName").AsString(100)
.NotNullable();
Insert.IntoTable("Tenants")
.Row(new
{
TenantName = "Primary Tenant"
});
Insert.IntoTable("Tenants")
.Row(new
{
TenantName = "Second Tenant"
});
Insert.IntoTable("Tenants")
.Row(new
{
TenantName = "Third Tenant"
});
Alter.Table("Users")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Roles")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Languages")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
}
}
}
I have created Tenants table in Default database where user tables are. Here we add 3
predefined tenants. We actually only need first one with ID 1.
157
Adding Tenants Table and TenantId Field
NorthwindDB_20160110_093500_MultiTenant.cs:
158
Adding Tenants Table and TenantId Field
using FluentMigrator;
namespace MultiTenancy.Migrations.NorthwindDB
{
[Migration(20160110093500)]
public class NorthwindDB_20160110_093500_MultiTenant
: AutoReversingMigration
{
public override void Up()
{
Alter.Table("Employees")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Categories")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Customers")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Shippers")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Suppliers")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Orders")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Products")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Region")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
Alter.Table("Territories")
.AddColumn("TenantId").AsInt32()
.NotNullable().WithDefaultValue(1);
}
}
}
159
Adding Tenants Table and TenantId Field
160
Generating Code for Tenants Table
Next we'll define a lookup script in TenantRow and set InstanceName property to Tenant:
namespace MultiTenancy.Administration.Entities
{
//...
[ConnectionKey("Default"), DisplayName("Tenants"),
InstanceName("Tenant"), TwoLevelCached]
[LookupScript("Administration.Tenant")]
public sealed class TenantRow : Row, IIdRow, INameRow
{
[DisplayName("Tenant Id"), Identity]
public Int32? TenantId
{
get { return Fields.TenantId[this]; }
set { Fields.TenantId[this] = value; }
}
//...
Let's define a Administration:Tenants permission that only admin user will have:
161
Generating Code for Tenants Table
namespace MultiTenancy.Administration
{
public class PermissionKeys
{
public const string Security = "Administration:Security";
public const string Translation = "Administration:Translation";
public const string Tenants = "Administration:Tenants";
}
}
162
Tenant Selection in User Dialog
This field, should only be seen and edited by admin user. Other users, even if we give them
access to users page to manage their tenant users, shouldn't be able to see or change this
information.
163
Tenant Selection in User Dialog
namespace MultiTenancy.Administration.Entities
{
//...
public sealed class UserRow : LoggingRow, IIdRow, INameRow
{
//...
[DisplayName("Last Directory Update"), Insertable(false), Updatable(false)]
public DateTime? LastDirectoryUpdate
{
get { return Fields.LastDirectoryUpdate[this]; }
set { Fields.LastDirectoryUpdate[this] = value; }
}
[DisplayName("Tenant"), Expression("tnt.TenantName")]
public String TenantName
{
get { return Fields.TenantName[this]; }
set { Fields.TenantName[this] = value; }
}
//...
public class RowFields : LoggingRowFields
{
//...
public readonly DateTimeField LastDirectoryUpdate;
public readonly Int32Field TenantId;
public readonly StringField TenantName;
//...
}
}
}
164
Tenant Selection in User Dialog
namespace MultiTenancy.Administration.Forms
{
using Serenity;
using Serenity.ComponentModel;
using System;
using System.ComponentModel;
[FormScript("Administration.User")]
[BasedOnRow(typeof(Entities.UserRow))]
public class UserForm
{
public String Username { get; set; }
public String DisplayName { get; set; }
[EmailEditor]
public String Email { get; set; }
[PasswordEditor]
public String Password { get; set; }
[PasswordEditor, OneWay]
public String PasswordConfirm { get; set; }
[OneWay]
public string Source { get; set; }
public Int32? TenantId { get; set; }
}
}
Need to also increase size of user dialog a bit, in site.administration.less to make space for
tenant selection:
.s-UserDialog {
> .size { .widthAndMin(650px); }
.dialog-styles(@h: auto, @l: 150px, @e: 400px);
.categories { height: 300px; }
}
Now open User Management page and create a user tenant2 that belongs to Second
Tenant.
165
Tenant Selection in User Dialog
After creating this user, edit its permissions and grant him User, Role Management and
Permissions permission as this will be our administrative user for Second Tenant.
When you open User Management page, you'll see that user can see and edit admin user,
in addition to his own tenant2 user. He can even see and edit his tenant in user dialog.
166
Tenant Selection in User Dialog
167
Filtering Users By TenantId
namespace MultiTenancy.Administration
{
using Serenity;
using System;
[Serializable]
public class UserDefinition : IUserDefinition
{
public string Id { get { return UserId.ToInvariant(); } }
public string DisplayName { get; set; }
public string Email { get; set; }
public short IsActive { get; set; }
public int UserId { get; set; }
public string Username { get; set; }
public string PasswordHash { get; set; }
public string PasswordSalt { get; set; }
public string Source { get; set; }
public DateTime? UpdateDate { get; set; }
public DateTime? LastDirectoryUpdate { get; set; }
public int TenantId { get; set; }
}
}
This is the class that is returned when you ask for current user through
Authorization.UserDefinition.
We also need to modify the code where this class is loaded. In the same folder, edit
UserRetrieveService.cs and change GetFirst method like below:
168
Filtering Users By TenantId
return null;
}
Now, it's time to filter listed users by TenantId. Open UserRepository.cs, locate
MyListHandler class and modify it like this:
Here, we first get a reference to cached user definition of currently logged user.
We check if he has tenant administration permission, which only admin will have in the end.
If not, we filter listed records by TenantId.
169
Removing Tenant Dropdown From User Form
Yes, he can't see admin user anymore, but something is wrong. When you click tenant2,
nothing will happen and you'll get an error "Can't load script data:
Lookup.Administration.Tenant":
This error is not related to our recent filtering at repository level. It can't load this lookup
script, because current user has no permission to Tenants table. But how did he see it last
time?
He could see it, because we first logged in as admin and when we open edit dialog for user,
we loaded this lookup script. Browser cached it, so when we logged in with tenant2 and
open edit dialog, it loaded tenants from browser cache.
But this time, as we rebuild project, browser tried to load it from server, and we got this error,
as tenant2 doesn't have this permission. It's ok, we don't want him to have this permission,
but how to avoid this error?
170
Removing Tenant Dropdown From User Form
We need to remove Tenant field from the user form. But we need that field for admin user, so
we can't simply delete it from UserForm.cs. Thus, we need to do it conditionally.
namespace Serene.Administration {
@Serenity.Decorators.registerClass()
export class UserDialog extends Serenity.EntityDialog<UserRow, any> {
//...
protected getPropertyItems() {
let items = super.getPropertyItems();
if (!Authorization.hasPermission("Administration:Tenants"))
items = items.filter(x => x.name != UserRow.Fields.TenantId);
return items;
}
}
For Serenity < 2.0: Transform all T4 files, then open UserDialog.cs and override
GetPropertyItems method like below:
namespace MultiTenancy.Administration
{
using jQueryApi;
using Serenity;
using System.Collections.Generic;
using System.Linq;
//...
public class UserDialog : EntityDialog<UserRow>
{
//...
protected override List<PropertyItem> GetPropertyItems()
{
var items = base.GetPropertyItems();
if (!Authorization.HasPermission("Administration:Tenants"))
items = items.Where(x =>
x.Name != UserRow.Fields.TenantId).ToList();
return items;
}
}
}
171
Removing Tenant Dropdown From User Form
GetPropertyItems is the method, dialog gets its list of form fields, from server side form
definition. Here fields are read from UserForm we defined server side.
If user doesn't have tenant administration permission, we remove the TenantId field from
form definition at client side.
This doesn't modify the actual form definition, it just removes TenantId field for this dialog
instance.
Some users report that this also removes tenant selection for admin user. Make sure
your HasPermission method in Authorization.cs of MultiTenancy.Script project is like
below:
172
Securing Tenant Selection At Server Side
Wrong!
If he is an ordinary user, he can't. But if he has some knowledge of how Serenity and its
services work, he could.
When you are working with web, you got to take security much more seriously.
It's very easy to create security holes in web applications unless you handle validations both
at client side and server side.
Let's demonstrate it. Open Chrome console, while logged in with user tenant2.
Q.serviceCall({
service: 'Administration/User/Update',
request: {
EntityId: 2,
Entity: {
UserId: 2,
TenantId: 1
}
}
});
Now refresh the user management page, you'll see that tenant2 can see admin user now!
We called User Update service with javascript, and changed tenant2 user TenaNntId to 1
(Primary Tenant).
Let's revert it back to Second Tenant (2) first, then we'll fix this security hole:
173
Securing Tenant Selection At Server Side
Q.serviceCall({
service: 'Administration/User/Update',
request: {
EntityId: 2,
Entity: {
UserId: 2,
TenantId: 2
}
}
});
if (!Authorization.HasPermission(Administration.PermissionKeys.Security))
{
editable.Remove(fld.Source);
editable.Remove(fld.IsActive);
}
if (!Authorization.HasPermission(Administration.PermissionKeys.Tenants))
{
editable.Remove(fld.TenantId);
}
}
Build your project, then try typing this into console again:
Q.serviceCall({
service: 'Administration/User/Update',
request: {
EntityId: 2,
Entity: {
UserId: 2,
TenantId: 1
}
}
});
174
Securing Tenant Selection At Server Side
If user doesn't have tenant administration permission, we remove TenantId from the list of
auto-determined editable fields.
175
Setting TenantId For New Users
You won't get any error but by suprise, you won't see the newly created user in list. What
happened to User2?
As we set default value for TenantId to 1 in migrations, now User2 has 1 as TenantId and is
a member of Primary Tenant.
We have to set new users TenantId to same value with logged in user.
if (IsCreate)
{
Row.Source = "site";
Row.IsActive = Row.IsActive ?? 1;
if (!Authorization.HasPermission(Administration.PermissionKeys.Tenants) ||
Row.TenantId == null)
{
Row.TenantId = ((UserDefinition)Authorization.UserDefinition)
.TenantId;
}
}
if (IsCreate || !Row.Password.IsEmptyOrNull())
{
string salt = null;
Row.PasswordHash = GenerateHash(password, ref salt);
Row.PasswordSalt = salt;
}
}
Here, we set TenantId to the same value with current user, unless he has tenant
administration permission.
Now try to create a new user User2b and this time you'll see him on the list.
176
Preventing Edits To Users From Other Tenants
Similar to this, even if he can't see users from other tenants by default, he can actually
retrieve and update them.
new MultiTenancy.Administration.UserDialog().loadByIdAndOpenDialog(1)
What? He could open user dialog for admin and update it!
We created a new instance of it, and asked to load a user entity by its ID. Admin user has an
ID of 1.
So, to load the entity with ID 1, dialog called Retrieve service of UserRepository.
Remember that we did filtering in List method of UserRepository, not Retrieve. So, service
has no idea, if it should return this record from another tenant, or not.
177
Preventing Edits To Users From Other Tenants
Record not found. It might be deleted or you don't have required permissions!
But, we could still update record calling Update service manually. So, need to secure
MySaveHandler too.
if (IsUpdate)
{
var user = (UserDefinition)Authorization.UserDefinition;
if (Old.TenantId != user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
// ...
Here we check if it's an update, and if TenantId of record being updated (Old.TenantId) is
different than currently logged user's TenantId. If so, we call
Authorization.ValidatePermission method to ensure that user has tenant administration
permission. It will raise an error if not.
178
Preventing Edits To Users From Other Tenants
179
Hiding the Tenant Administration Permission
180
Hiding the Tenant Administration Permission
result.Remove(Administration.PermissionKeys.Tenants);
result.Remove("*");
result.Remove("?");
//...
Now, this permission won't be listed in Edit User Permissions or Edit Role Permissions
dialog.
But, still, he can grant this permission to himself, by some little hacking through
UserPermissionRepository.Update or RolePermissionRepository.Update methods.
181
Hiding the Tenant Administration Permission
Here we check if any of the new permission keys that are tried to be granted, are not listed
in permission dialog. If so, this is probably a hack attempt.
Actually this check should be the default, even without multi-tenant systems, but usually
we trust administrative users. Here, administrators will be only managing their own
tenants, so we certainly need this check.
182
Making Roles Multi-Tenant
Again, a user in one tenant shouldn't see or modify roles in other tenants and work in
isolation.
namespace MultiTenancy.Administration.Entities
{
//...
public sealed class RoleRow : Row, IIdRow, INameRow
{
[Insertable(false), Updatable(false)]
public Int32? TenantId
{
get { return Fields.TenantId[this]; }
set { Fields.TenantId[this] = value; }
}
//...
183
Making Roles Multi-Tenant
if (IsCreate)
Row.TenantId = ((UserDefinition)Authorization.UserDefinition).TenantId;
}
}
184
Using Serenity Service Behaviors
Serenity provides service behavior system, which allows you to intercept Create, Update,
Retrieve, List, Delete handlers and add custom code to them.
Some operations in these handlers, like capture log, unique constraint validation etc. are
already implemented as service behaviors.
Behaviors might be activated for all rows, or based on some rule, like having a specific
attribute or interface. For example, CaptureLogBehavior activates for rows with [CaptureLog]
attribute.
We'll first define an interface IMultiTenantRow that will trigger our new behavior. Place this
class in file IMultiTenantRow.cs, next to TenantRow.cs:
using Serenity.Data;
namespace MultiTenancy
{
public interface IMultiTenantRow
{
Int32Field TenantIdField { get; }
}
}
using MultiTenancy.Administration;
using Serenity;
using Serenity.Data;
using Serenity.Services;
namespace MultiTenancy
{
public class MultiTenantBehavior : IImplicitBehavior,
ISaveBehavior, IDeleteBehavior,
IListBehavior, IRetrieveBehavior
{
private Int32Field fldTenantId;
185
Using Serenity Service Behaviors
if (mt == null)
return false;
fldTenantId = mt.TenantIdField;
return true;
}
186
Using Serenity Service Behaviors
Behavior classes with IImplicitBehavior interface decide if they should be activated for a
specific row type.
In this method, we check if row type implements IMultiTenantRow interface. If not it simply
returns false.
ActivateFor is only called once per every handler type and row. If this method returns true,
behavior instance is cached aggresively for performance reasons, and reused for any
request for this row and handler type.
Thus, everything you write in other methods must be thread-safe, as one instance is shared
by all requests.
A behavior, might intercept one or more of Retrieve, List, Save, Delete handlers. It does this
by implementing IRetrieveBehavior, IListBehavior, ISaveBehavior, or IDeleteBehavior
interfaces.
Here, we need to intercept all of these service calls, so we implement all interfaces.
We only fill in methods we are interested in, and leave others empty.
187
Using Serenity Service Behaviors
namespace MultiTenancy.Administration.Entities
{
//...
public sealed class RoleRow : Row, IIdRow, INameRow, IMultiTenantRow
{
//...
public Int32Field TenantIdField
{
get { return Fields.TenantId; }
}
//...
}
}
You should get the same result with much less code. Declarative programming is almost
always better.
188
Extending Multi-Tenant Behavior To Northwind
namespace MultiTenancy.Northwind.Entities
{
//...
public sealed class SupplierRow : Row,
IIdRow, INameRow, IMultiTenantRow
{
//...
[Insertable(false), Updatable(false)]
public Int32? TenantId
{
get { return Fields.TenantId[this]; }
set { Fields.TenantId[this] = value; }
}
//...
When you these changes in SupplierRow and build, you'll see that tenant2 can't see
suppliers from other tenants in suppliers page.
189
Extending Multi-Tenant Behavior To Northwind
190
Handling Lookup Scripts
This data is feed to script side through a dynamic script. It doesn't load this data with List
services we handled recently.
191
Handling Lookup Scripts
namespace MultiTenancy.Northwind.Scripts
{
using Serenity.ComponentModel;
using Serenity.Data;
using Serenity.Web;
[LookupScript("Northwind.SupplierCountry")]
public class SupplierCountryLookup :
RowLookupScript<Entities.SupplierRow>
{
public SupplierCountryLookup()
{
IdField = TextField = "Country";
}
We couldn't use a simple [LookupScript] attribute on a row class here, because there is
actually no country table in Northwind database. We are collecting country names from
existing records in Supplier table using distinct.
But this lookup class derives from RowLookupScript base class. Let's create a new base
class, to prepare for other lookup scripts that we'll have to handle later.
192
Handling Lookup Scripts
namespace MultiTenancy.Northwind.Scripts
{
using Administration;
using Serenity;
using Serenity.Data;
using Serenity.Web;
using System;
193
Handling Lookup Scripts
We'll turn off caching at dynamic script manager level and handle caching ourself in
GetScript method. In GetScript method, we are using TwoLevelCache.GetLocalStoreOnly to
call base method, that generates our lookup script, and cache its result with a cache key
including TenantId.
By overriding, PrepareQuery method, we are adding a filter by current TenantId, just like we
did in list service handlers.
Now its time to rewrite our SupplierCountryLookup using this new base class:
namespace MultiTenancy.Northwind.Scripts
{
using Serenity.ComponentModel;
using Serenity.Data;
using Serenity.Web;
[LookupScript("Northwind.SupplierCountry")]
public class SupplierCountryLookup :
MultiTenantRowLookupScript<Entities.SupplierRow>
{
public SupplierCountryLookup()
{
IdField = TextField = "Country";
}
AddTenantFilter(query);
}
194
Handling Lookup Scripts
We now have one more problem to solve. If you open Orders page, you'll see that Ship Via
and Employee filter dropdowns still lists records from other tenants. It is because we defined
their lookup scripts by a [LookupScript] attribute on their rows.
Let's fix employee lookup first. Remove [LookupScript] attribute from EmployeeRow.
namespace MultiTenancy.Northwind.Scripts
{
using Entities;
using Serenity.ComponentModel;
using Serenity.Web;
[LookupScript("Northwind.Employee")]
public class EmployeeLookup :
MultiTenantRowLookupScript<EmployeeRow>
{
}
}
We don't have to override anything, as base class will handle everything for us. By default,
LookupScript attribute on rows, defines a new automatic lookup script class by using
RowLookupScript as base class.
As there is no way to override this base class per row, we defined our lookup script class
explicitly, and used MultiTenantRowLookupScript as base class.
195
Handling Lookup Scripts
This is because we don't have a [LookupScript] attribute on top of our row class, but in some
places like forms, we used [LookupEditor(typeof(EmployeeRow))].
Open OrderRow.cs and you'll see this attribute on top of EmployeeID property. Change it to
[LookupEditor("Northwind.Employee")].
We'll do similar for ShipperRow. Remove LookupScript attribute and define class below:
namespace MultiTenancy.Northwind.Scripts
{
using Entities;
using Serenity.ComponentModel;
using Serenity.Web;
[LookupScript("Northwind.Shipper")]
public class ShipperLookup :
MultiTenantRowLookupScript<ShipperRow>
{
}
}
And on top of ShipVia property of OrderRow you'll find another similar LookupEditor
attribute. Change it to [LookupEditor("Northwind.Shipper")].
namespace MultiTenancy.Northwind.Scripts
{
using Entities;
using Serenity.ComponentModel;
using Serenity.Web;
[LookupScript("Northwind.Product")]
public class ProductLookup : MultiTenantRowLookupScript<ProductRow>
{
}
}
And on top of ProductID property of OrderDetailRow you'll find another similar LookupEditor
attribute. Change it to [LookupEditor("Northwind.Product")].
Supplier, Category, Region, Territory are next ones we should handle similarly. See commit
log of Serenity tutorials github repository.
196
Handling Lookup Scripts
There might be some glitches i missed, report in Serenity Github repository if any.
This feature might be integrated into Serenity, if there will be enough interest.
197
Meeting Management
We'll first plan a meeting, with its location, time, agenda and attendees, then send an
invitation to those attendees with an e-mail.
Application will also let us store decisions taken in the meeting, and will inform attendees
with a meeting report e-mail containing these decisions.
https://github.com/volkanceylan/MeetingManagement
Creating Project
Start by creating a new project using Serene template, and name it MeetingManagement.
Removing Northwind
Remove Northwind using the how-to guide.
198
Creating Lookup Tables
Meeting Types (Board Meeting, Weekly Analytics, SCRUM Meeting, Annual Meeting, so
on...)
Locations (where meeting will be held, room numbers, address etc.)
Agenda Types (what subject(s) an agenda is about, might be multiple)
Units (which unit is organizing the meeting)
Contacts (people which would attend meetings, reporters, managers etc.)
199
Creating Lookup Tables
using FluentMigrator;
namespace MeetingManagement.Migrations.DefaultDB
{
[Migration(20160709232400)]
public class DefaultDB_20160709_232400_MeetingLookups
: AutoReversingMigration
{
public override void Up()
{
Create.Schema("met");
Create.Table("AgendaTypes").InSchema("met")
.WithColumn("AgendaTypeId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Name").AsString(100).NotNullable();
Create.Table("Contacts").InSchema("met")
.WithColumn("ContactId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Title").AsString(30).Nullable()
.WithColumn("FirstName").AsString(50).NotNullable()
.WithColumn("LastName").AsString(50).NotNullable()
.WithColumn("Email").AsString(100).NotNullable();
Create.Table("Locations").InSchema("met")
.WithColumn("LocationId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Name").AsString(100).NotNullable()
.WithColumn("Address").AsString(300).Nullable()
.WithColumn("Latitude").AsDouble()
.WithColumn("Longitude").AsDouble();
Create.Table("MeetingTypes").InSchema("met")
.WithColumn("MeetingTypeId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Name").AsString(100).NotNullable();
Create.Table("Units").InSchema("met")
.WithColumn("UnitId").AsInt32()
.Identity().PrimaryKey().NotNullable()
.WithColumn("Name").AsString(100).NotNullable();
}
}
}
200
Creating Lookup Tables
Generate code for these 5 tables using the entity identifiers given above:
Generated interface for these tables is fine enough. Just need to do a few cosmetic touches.
201
Creating Lookup Tables
using Serenity.Navigation;
using Administration = MeetingManagement.Administration.Pages;
using Meeting = MeetingManagement.Meeting.Pages;
202
Creating Lookup Tables
[ConnectionKey("Default"), TwoLevelCached,
DisplayName("Agenda Types"), InstanceName("Agenda Type")]
[ReadPermission("Meeting")]
[ModifyPermission("Meeting")]
public sealed class AgendaTypeRow : Row, IIdRow, INameRow
{
203
How To Guides
How To Guides
204
How To: Remove Northwind & Other Samples From Serene
We assume your solution name is MyProject, so you have MyProject.Web project in your
solution.
Remove MyProject.Web/Modules/Northwind folder. This will remove all server side code
related to Northwind.
205
How To: Remove Northwind & Other Samples From Serene
@import "site.basicsamples.less";
@import "site.northwind.less";
206
How To: Remove Northwind & Other Samples From Serene
using Northwind;
using Northwind.Entities;
As Dashboard gets numbers from Northwind tables, you should modify Index() action like
this:
You should replace this model with something specific to your site, and modify
DashboardIndex accordingly.
<a href="~/Northwind/Order?shippingState=1"></a>
<a href=""></a>
Search for Northwind, Basic Samples and Theme Samples in all solution items. It
should find no results.
Run your project, now Northwind and Sample menus are gone.
207
How To: Remove Northwind & Other Samples From Serene
208
How To: Update Serenity NuGet Packages
Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
Serenity.Web
Serenity.CodeGenerator
To update Serenity packages to latest version, open package manager console (click View -
> Other Windows -> Package Manager Console).
Update-Package Serenity.Web
Update-Package Serenity.CodeGenerator
Updating these two packages will also update others (because of dependencies).
209
How To: Upgrade to Serenity 2.0 and Enable TypeScript
This is a migration guide for users that started with an older Serene template, and wants to
use TypeScript features.
If you don't need TypeScript, just update your Serenity packages and it should work as
normal.
Even if you won't need TypeScript, it's recommended to perform steps listed here to
keep your project up to date. This might also help you avoid future problems as there
has been many changes in Serene for TypeScript support.
Also TypeScript feels like native Javascript with proper intellisense, refactoring and compile
time type checking.
We've been using Saltaralle with Serenity since start but its future is a bit blurry. It didn't get
any updates since it is acquired by Bridge.NET, last June (2015).
Your old code written in Saltaralle will continue to work. It will be supported as long as
possible with Serenity for backward compability.
If Bridge.NET v2.0 (next Saltaralle) comes out, we may also try to switch, unless it involves
too many changes to handle.
210
How To: Upgrade to Serenity 2.0 and Enable TypeScript
If possible, take a ZIP backup of solution, as some steps we'll perform might be hard to
revert.
Install TypeScript
Install TypeScript 1.8+ from
https://www.typescriptlang.org/#download-links
Update-Package Serenity.Web
Update-Package Serenity.CodeGenerator
Update-Package Serenity.Script
While updating Serenity.Web, VS might show a dialog with text "Your Project has been
configured to support TypeScript". Click YES.
// ...
<TypeScriptToolsVersion>1.8</TypeScriptToolsVersion>
<TypeScriptCompileBlocked>True</TypeScriptCompileBlocked>
</PropertyGroup>
<PropertyGroup>
<TypeScriptCharset>utf-8</TypeScriptCharset>
<TypeScriptEmitBOM>True</TypeScriptEmitBOM>
<TypeScriptGeneratesDeclarations>False</TypeScriptGeneratesDeclarations>
<TypeScriptExperimentalDecorators>True</TypeScriptExperimentalDecorators>
<TypeScriptOutFile>Scripts\site\Serene.Web.js</TypeScriptOutFile>
<TypeScriptCompileOnSaveEnabled>False</TypeScriptCompileOnSaveEnabled>
</PropertyGroup>
211
How To: Upgrade to Serenity 2.0 and Enable TypeScript
Make sure the line with TypeScript.targets with is under all other targets. Move it under
WebAplpication.targets if not. VS puts them before Microsoft.WebApplication.targets and
somehow it doesn't work that way.
Also, at the bottom of file, you'll find CompileSiteLess step, add TSC to end of it:
{
"compileOnSave": true,
"compilerOptions": {
"preserveConstEnums": true,
"experimentalDecorators": true,
"declaration": true,
"emitBOM": true,
"sourceMap": true,
"target": "ES5",
"outFile": "Scripts/site/Serene.Web.js"
},
"exclude": [
"Scripts/site/Serene.Web.d.ts"
]
}
212
How To: Upgrade to Serenity 2.0 and Enable TypeScript
namespace MyProject {
export class Dummy {
}
}
When you save it, there should be a MyProject.Web.js file there with content below. If you
can't see it, click Show All Files and refresh folder.
var MyProject;
(function (MyProject) {
var Dummy = (function () {
function Dummy() {
}
return Dummy;
}());
MyProject.Dummy = Dummy;
})(MyProject || (MyProject = {}));
//# sourceMappingURL=SereneUpgrading.Web.js.map
Right click and include that file to your project. Now you can delete dummy.ts.
If you are using a version before VS2015 and compile on save is not working, your TS
files will be compiled at project build.
// ...
@Html.Script("~/Scripts/Site/MyProject.Script.js")
@Html.Script("~/Scripts/Site/MyProject.Web.js")
// ...
213
How To: Upgrade to Serenity 2.0 and Enable TypeScript
Serene v2.0 has merged some .TT templates and created new one for TypeScript code
generation.
Please make sure your project is building successfully and DON'T CLEAN it while
performing these steps, otherwise you may end up with a broken project.
Get latest source of CodeGenerationHelpers.ttinclude from address below and copy paste it
to CodeGenerationHelpers.ttinclude file you just created:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/CodeGenerationHelpers.ttinclude
Search and Replace Serene with YourProjectName in this file if any. There shouldn't be
any Serene word in this file as of writing.
You may also create a new Serene project with latest version of template to get these
files.
ClientTypes.tt
Create folder YourProject.Web\Modules\Common\Imports\ClientTypes and move
ScriptEditorTypes.tt to there, then rename ScriptEditorTypes.tt to ClientTypes.tt.
Grab latest source of ClientTypes.tt file from address below and copy paste it to
ClientTypes.tt file you just moved:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/ClientTypes/ClientTypes.tt
ServerTypings.tt
Create folder YourProject.Web\Modules\Common\Imports\ServerTypings and move
ScriptFormatterTypes.tt to there, then rename ScriptFormatterTypes.tt to ServerTypings.tt.
Grab latest source of ServerTypings.tt file from address below and copy paste it to
ServerTypings.tt file you just moved:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/ServerTypings/ServerTypings.tt
214
How To: Upgrade to Serenity 2.0 and Enable TypeScript
Generate Code
While they are open save ClientTypes.tt and ServerTypings.tt files, and wait for them to
generate codes.
Get latest source of CodeGenerationHelpers.ttinclude from address below (it's different!) and
copy paste it to CodeGenerationHelpers.ttinclude file you just created:
https://raw.githubusercontent.com/volkanceylan/Serene/
b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/
Imports/CodeGenerationHelpers.ttinclude
Search and Replace Serene with YourProjectName in this file. There must be several.
ServiceContracts.tt
Rename folder YourProject.Script\Imports\ServiceContracts to ServerImports. Rename
ServiceContracts.tt to ServerImports.tt.
Grab latest source of ServerImports.tt file from address below and copy paste it to
ServerImports.tt file you just renamed:
https://raw.githubusercontent.com/volkanceylan/Serene/
b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/
Imports/ServerImports/ServerImports.tt
Save ServerImports.tt and wait for it to generate code. It might take some time because of
some slow down due to Saltaralle.
Rebuild solution and make sure it builds properly without any error.
215
How To: Upgrade to Serenity 2.0 and Enable TypeScript
ServerTypes.tt: generated code for Saltaralle, containing Row, Form, Column, Service
declarations imported from Server (Web) code. There is no import classes from
TypeScript yet. So if you want to use some TypeScript class in your Saltaralle code, you
need to write import classes manually.
ClientTypes.tt: generated code for Web project, containing Editor and Formatter
imports from both TypeScript and Saltaralle.
Please don't generate code for existing Saltaralle dialogs or grids using Sergen.
Otherwise you'll have double YourGrid and YourDialog classes and it may lead to
unexpected errors.
216
How To: Authenticate With Active Directory or LDAP
For ActiveDirectory add a appSetting key ActiveDirectory with contents like below:
<add key="ActiveDirectory"
value="{ Domain: 'youractivedirectorydomain' }" />
If this doesn't work for your Active Directory server out of the box, you might have to
modify ActiveDirectoryService class.
When a AD user tries to login first time, Serene authenticates user with this domain,
retrieves user details and inserts a user with type directory into users table.
AD password hash and user information is cached for one hour, so for one hour user can
login with cached credentials, without even hitting AD.
After that, user information is tried to be updated from AD. If an error occurs, user will be
allowed to login with cached credentials.
To enable LDAP authentication (tested with OpenLDAP) you need to add a appSetting key
LDAP to web.config:
<add key="LDAP"
value="{
Host: '123.124.125.126',
Port: 389,
DistinguishedName: 'dc=yourdomain, dc=com',
Username: 'cn=someuserthatcanreadldap,ou=groupofthatuser,
dc=yourdomain,dc=com',
Password: 'passwordofthatuser'
}"
/>
Again, there are many different configurations of LDAP servers out there, so if this
doesn't work for you, you might have to modify LdapDirectoryService class.
217
How To: Authenticate With Active Directory or LDAP
218
How To: Use a SlickGrid Formatter
To use a SlickGrid formatter function, like percent complete bar formatter at %Complete
column of SlickGrid example:
http://mleibman.github.io/SlickGrid/examples/example2-formatters.html
//...
@Html.Script("~/Scripts/jquery.slimscroll.js")
@Html.Script("~/Scripts/SlickGrid/slick.formatters.js")
@Html.Script("~/Scripts/Site/MovieTutorial.Script.js")
//...
You also need to include following CSS from example.css (can be inserted in site.less):
.percent-complete-bar {
display: inline-block;
height: 6px;
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
}
219
How To: Use a SlickGrid Formatter
To reference a SlickGrid formatter at server side, you need to declare a formatter type for
Serenity grids.
using Serenity;
using System;
namespace MyApplication
{
public class PercentCompleteBarFormatter : ISlickFormatter
{
private SlickColumnFormatter formatter =
Type.GetType("Slick.Formatters.PercentCompleteBar").As<SlickColumnFormatte
r>();
Rebuild your project and you will see that CourseCompletion column has a percentage bar
just like in SlickGrid example.
220
How To: Use a SlickGrid Formatter
This is not reusable but saves you from defining a formatter class.
221
How To: Add a Row Selection Column
To add a column to select individual rows or all rows, GridRowSelectionMixin can be used.
Sample code:
222
How To: Add a Row Selection Column
buttons.Add(new ToolButton
{
CssClass = "tag-button",
Title = "Do Something With Selected Rows",
OnClick = delegate
{
var selectedIDs = rowSelection.GetSelectedKeys();
if (selectedIDs.Count == 0)
Q.NotifyWarning("Please select some rows");
else
Q.NotifySuccess("You have selected " + selectedIDs.Count +
" row(s) with ID(s): " + string.Join(", ", selectedIDs));
}
});
return buttons;
}
223
How To: Setup Cascaded Editors
Starting with Serenity 1.8.2, it's rather simple. Lookup editors have this integrated
functionality.
For versions before 1.8.2, it was also possible, and there was some samples in Serene,
but you had to define some editor classes to make it work.
Let's say we have a database with three tables, Country, City, District:
First make sure you generate code for all three tables using Sergen, and you have a
[LookupScript] attribute on all of them:
[LookupScript("MyModule.Country")]
public sealed class CountryRow : Row...
{
[DisplayName("Country Id"), Identity]
public Int32? CountryId
{
get { return Fields.CountryId[this]; }
set { Fields.CountryId[this] = value; }
}
224
How To: Setup Cascaded Editors
[LookupScript("MyModule.City")]
public sealed class CityRow : Row...
{
[DisplayName("City Id"), Identity]
public Int32? CityId
{
get { return Fields.CityId[this]; }
set { Fields.CityId[this] = value; }
}
225
How To: Setup Cascaded Editors
[LookupScript("MyModule.District")]
public sealed class DistrictRow : Row...
{
[DisplayName("District Id"), Identity]
public Int32? DistrictId
{
get { return Fields.DistrictId[this]; }
set { Fields.DistrictId[this] = value; }
}
Make sure you add LookupInclude attribute to CityId field of DistrictRow, and CountryId field
of CityRow. We need them to be available at client side, otherwise they are not included by
default in lookup scripts.
If you wanted to edit these fields as cascaded lookup editors in a form, e.g. CustomerForm,
you would set them up like this:
226
How To: Setup Cascaded Editors
[FormScript("MyModule.Customer")]
[BasedOnRow(typeof(Entities.CustomerRow))]
public class CustomerForm
{
public String CustomerID { get; set; }
public String CustomeraName { get; set; }
[LookupEditor(typeof(Entities.CountryRow))]
public Int32? CountryId { get; set; }
[LookupEditor(typeof(Entities.CityRow),
CascadeFrom = "CountryId", CascadeField = "CountryId")]
public Int32? CityId { get; set; }
[LookupEditor(typeof(Entities.DistrictRow),
CascadeFrom = "CityId", CascadeField = "CityId")]
public Int32? DistrictId { get; set; }
}
Here, CascadeFrom attribute tells city editor, ID of the parent editor that it will bind to
(cascade).
When this form is generated, CountryId field will be handled with an editor with ID CountryId,
so we set CascadeFrom attribute of CityId lookup editor to that ID.
CascadeField determines the field to filter cities on. Thus, when country editor value
changes, city editor items will be filtered on their CountryId properties like this:
If CascadeFrom and CascadeField attributes are same, you only need to specify
CascadeFrom, but i wanted to be explicit here.
If you wanted to add these cascaded editors to filter bar of customer grid, in
CreateToolbarExtensions method of CustomerGrid.cs, do this:
227
How To: Setup Cascaded Editors
AddEqualityFilter<LookupEditor>("CountryId",
options: new LookupEditorOptions
{
LookupKey = "MyModule.Country"
});
AddEqualityFilter<LookupEditor>("CityId",
options: new LookupEditorOptions
{
LookupKey = "MyModule.City",
CascadeFrom = "CountryId",
CascadeField = "CountryId"
});
AddEqualityFilter<LookupEditor>("DistrictId",
options: new LookupEditorOptions
{
LookupKey = "MyModule.District",
CascadeFrom = "CityId",
CascadeField = "CityId"
});
Here i suppose you have CountryId, CityId and DistrictId fields in CustomerRow.
Now you have useful cascaded editors for both editing and filtering.
228
How To: Use Recaptcha
You might also use it for another form, but this is just a sample for login.
https://www.google.com/recaptcha/admin
Once you have your site key, and secret key, enter them in web.config/appSettings section:
The keys listed above are only for testing purposes. Never use them in production.
Edit Login method under AccountPage.cs to validate the captcha server side:
229
How To: Use Recaptcha
[HttpPost, JsonFilter]
public Result<ServiceResponse> Login(LoginRequest request)
{
return this.ExecuteMethod(() =>
{
request.CheckNotNull();
if (string.IsNullOrEmpty(request.Username))
throw new ArgumentNullException("username");
230
How To: Register Permissions in Serene
These attributes can be used with and located from one of these types:
When you use a permission key with one of such attributes, Serene will automatically
discover them using reflection at application start.
There is a PermissionKeys class in Serene. Some users expected that when they write
their permission keys in this class, they will be discovered.
If you don't use a permission key with any of them but still want to show it in permission
dialogs, you can use RegisterPermission attribute on assembly (write this anywhere in
YourProject.Web):
[assembly: Serenity.ComponentModel.RegisterPermissionKey("MySpecialPermissionKey")]
231
How To: Register Permissions in Serene
MyModule:SubModule:General
MyModule:SubModule:Permission1
MyModule:SubModule:Permission2
These keys will be shown under MyModule / SubModule category. Thus their category keys
will be:
MyModule:SubModule:
Category keys ends with colon. Don't use permission keys that ends with colon.
Please don't use permission keys that matches category keys. If you use such keys, for
example MyModule:SubModule it won't be shown under MyModule / SubModule category
but next to it at same level.
If you need a generic permission for such a category, use something like
MyModule:SubModule:General.
General has no special meaning, you can use Common, Module, View, whatever you
like.
You need to add display texts for them using localization system.
"Permission.Administration:": "Administration",
"Permission.Administration:Security": "User, Role Management and Permissions",
"Permission.Administration:Translation": "Languages and Translations",
"Permission.Northwind:Customer:": "Customers",
"Permission.Northwind:Customer:View": "View",
"Permission.Northwind:Customer:Delete": "Delete",
"Permission.Northwind:Customer:Modify": "Create/Update",
"Permission.Northwind:General": "[General]"
The keys ending with colon (:), like Administration: and Customer: corresponds to categories
and these are their display texts.
You need to add texts for categories to invariant language at minimum. You may also add to
other languages, if you want localization.
232
How To: Register Permissions in Serene
233
How To: Use a Third Party Plugin With Serenity
Especially if you are using TypeScript, there are no special steps involved.
In case of Saltaralle (which is being deprecated), you might have to write some import
classes or use dynamic otherwise.
But, if you want that component to work well among other Serenity editors in dialogs, you
may try wrapping it into a Serenity widget.
Here we'll take Bootstrap MultiSelect plugin as a sample, and integrate it as an editor into
Serenity, similar to LookupEditor.
http://davidstutz.github.io/bootstrap-multiselect/
This component has a NuGet package but unfortunately it is not in a standard fashion (it
doesn't place files into your project folders), so we'll have to download files manually.
https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/js/bootstrap-
multiselect.js
https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/css/bootstrap-
multiselect.css
234
How To: Use a Third Party Plugin With Serenity
// ...
@Html.Stylesheet("~/Content/bootstrap-multiselect.css")
@Html.Stylesheet("~/Content/serenity/serenity.css")
@Html.Stylesheet("~/Content/site/site.css")
// ...
@Html.Script("~/Scripts/bootstrap-multiselect.js")
@Html.Script("~/Scripts/Site/Serene.Script.js")
@Html.Script("~/Scripts/Site/Serene.Web.js")
Creating BSMultiSelectEditor.ts
Now we need a TypeScript source file to hold our component. We should put it under
MyProject.Web/Scripts or MyProject.Web/Modules directories.
235
How To: Use a Third Party Plugin With Serenity
namespace MyProject {
@Serenity.Decorators.element("<select/>")
@Serenity.Decorators.registerClass(
[Serenity.IGetEditValue, Serenity.ISetEditValue])
export class BSMultiSelectEditor
extends Serenity.Widget<BSMultiSelectOptions>
implements Serenity.IGetEditValue, Serenity.ISetEditValue {
Here we defined a new editor type, deriving from Widget. Our widget takes options of type
BSMultiSelectOptions, which contains a lookupKey option, similar to a LookupEditor. It also
implements IGetEditValue and ISetEditValue TypeScript interfaces (this is different than C#
interfaces)
@Serenity.Decorators.element("<select/>")
With above line, we specified that our widget works on a SELECT element, as this bootstrap
multiselect plugin requires a select element too.
@Serenity.Decorators.registerClass(
[Serenity.IGetEditValue, Serenity.ISetEditValue])
Above, we register our TypeScript class, with Saltaralle type system and specify that our
widget implements custom value getter and setter methods, corresponding to getEditValue
and setEditValue methods.
Here syntax is a bit terse as we have to handle interop between Saltaralle and
TypeScript.
Our constructor and getEditValue, setEditValue methods are yet empty. We'll fill them in
soon.
236
How To: Use a Third Party Plugin With Serenity
Here we see that Representatives uses a LookupEditor with multiple option true. We'll
replace it with our brand new editor:
237
How To: Use a Third Party Plugin With Serenity
Lookups has idField and textField properties, which usually corresponds to fields determined
by IIdRow and INameRow interfaces on your lookup row.
We enumerate all items in lookup and determine key and text properties of those items,
using idField and textField properties.
Now save file, and open Customer dialog again. You'll see that this time options are filled.
I would cast our SELECT element to <any> and call .multiselect on it, but i want to write a
TypeScript .d.ts definition file to reuse multiselect with intellisense.
interface JQuery {
multiselect(options?: BSMultiSelectOptions | string): JQuery;
}
interface BSMultiSelectOptions {
multiple?: boolean;
includeSelectAllOption?: boolean;
selectAllText?: string;
selectAllValue?: string | number;
}
Here, i have extended JQuery interface which belongs to jQuery itself and is defined in
jquery.d.ts. In TypeScript you can extend any interface with new methods, properties etc.
I used plugin documentation to define BSMultiSelectOptions. The plugin actually has much
more options, but for now i keep it short.
238
How To: Use a Third Party Plugin With Serenity
element.attr('multiple', 'multiple')
element
.attr('name', this.uniqueName + "[]")
.multiselect();
}
Open CustomerDialog and you'll see that Representatives has our bootstrap multi select
editor.
setEditValue is called when editor value needs to be setted. It takes a source object, which
is usually your entity being loaded in a dialog.
Property parameter is a PropertyItem object that contains details about the field being
handled, e.g. our Representatives property. It's name field contains field name, e.g.
Representatives.
239
How To: Use a Third Party Plugin With Serenity
Here we have to call multiselect('refresh') after setting select value, as multiselect plugin
can't know when selections are changed.
getEditValue is opposite. It should read edit value and set it in target entity.
240
How To: Enable Script Bundling
This might cause bandwidth and performance problems for some systems, especially when
a Serenity based site is accessed from mobile devices.
There are several ways to handle these problems, like minification and gzipping to decrease
script size, bundling to pack scripts into fewer files, thus reduce number of requests.
You might prefer to use tools like Webpack, Grunt, Gulp, UglifyJS etc, but in case you want a
simpler and effective solution with much less manual steps, Serenity comes with a script
bundling and minification / compression system out of the box.
ScriptBundles.json
First, you need a ScriptBundles.json file under MyProject.Web/scripts/site folder.
ScriptBundles.json configures which script bundle will contain which files when bundling is
turned on.
This file is included by default in Serene template 2.0.13+ and looks like this:
Unless you want to add some custom scripts to bundles, you don't need to modify this
file.
241
How To: Enable Script Bundling
{
"Libs": [
"~/Scripts/pace.js",
"~/Scripts/rsvp.js",
"~/Scripts/jquery-{version}.js",
"~/Scripts/jquery-ui-{version}.js",
"~/Scripts/jquery-ui-i18n.js",
"~/Scripts/jquery.validate.js",
"~/Scripts/jquery.blockUI.js",
"~/Scripts/jquery.cookie.js",
"~/Scripts/jquery.json.js",
"~/Scripts/jquery.autoNumeric.js",
"~/Scripts/jquery.colorbox.js",
"~/Scripts/jquery.dialogextendQ.js",
"~/Scripts/jquery.event.drag.js",
"~/Scripts/jquery.scrollintoview.js",
"~/Scripts/jsrender.js",
"~/Scripts/select2.js",
"~/Scripts/toastr.js",
"~/Scripts/SlickGrid/slick.core.js",
"~/Scripts/SlickGrid/slick.grid.js",
"~/Scripts/SlickGrid/slick.groupitemmetadataprovider.js",
"~/Scripts/SlickGrid/Plugins/slick.autotooltips.js",
"~/Scripts/SlickGrid/Plugins/slick.headerbuttons.js",
"~/Scripts/SlickGrid/Plugins/slick.rowselectionmodel.js",
"~/Scripts/SlickGrid/Plugins/slick.rowmovemanager.js",
"~/Scripts/bootstrap.js",
"~/Scripts/Saltarelle/mscorlib.js",
"~/Scripts/Saltarelle/linq.js",
"~/Scripts/Serenity/Serenity.CoreLib.js",
"~/Scripts/Serenity/Serenity.Script.UI.js",
"~/Scripts/Serenity/Serenity.Externals.js",
"~/Scripts/Serenity/Serenity.Externals.Slick.js",
"~/Scripts/jquery.cropzoom.js",
"~/Scripts/jquery.fileupload.js",
"~/Scripts/jquery.iframe-transport.js",
"~/Scripts/jquery.slimscroll.js",
"~/Scripts/mousetrap.js",
"~/Scripts/fastclick/fastclick.js"
],
"Site": [
"~/Scripts/adminlte/app.js",
"~/Scripts/Site/Serene.Script.js",
"~/Scripts/Site/Serene.Web.js"
]
}
Here we define two distinct bundles, Libs and Site, corresponding to Bundle.Libs.js and
Bundle.Site.js dynamic script files.
Bundle.Site.js is configured to contain these three JS files (in the listed order):
242
How To: Enable Script Bundling
"~/Scripts/adminlte/app.js",
"~/Scripts/Site/Serene.Script.js",
"~/Scripts/Site/Serene.Web.js"
Here we used 2 bundles by default, but it is possible to use one, three or more in case
you need a different configuration. Just be careful about dependencies.
Here, the ordering inside a bundle (package) is very important. You must include scripts in
the order they appear in your _LayoutHead.cshtml.
When you will add another custom script, make sure that it is placed after all its
dependencies.
For example, if you include a jquery plugin before jquery is loaded itself, you'll have
errors.
Also make sure that you don't include same file in two bundles.
Enabling Bundling
You should enable bundling (especially minification) only in production. Otherwise it might
become very difficult to debug Javascript.
When Enabled is false (default) system will do nothing, and you'll work as usual with your
script includes. And your page source looks like this:
243
How To: Enable Script Bundling
<script src="/Scripts/pace.js?v=..."></script>
<script src="/Scripts/rsvp.js?v=..."></script>
<script src="/Scripts/jquery-2.2.3.js?v=..."></script>
<script src="/Scripts/jquery-ui-1.11.4.js?v=..."></script>
<script src="/Scripts/jquery-ui-i18n.js?v=..."></script>
...
...
...
<script src="/Scripts/adminlte/app.js?v=..."></script>
<script src="/Scripts/Site/Serene.Script.js?v=..."></script>
<script src="/Scripts/Site/Serene.Web.js?v=..."></script>
...
<script src="/DynJS.axd/Bundle.Libs.js?v=..."></script>
<script src="/DynJS.axd/Bundle.Site.js?v=..."></script>
...
These two bundles are generated in memory and contains all other script files configured in
ScriptBundles.json file.
They are also compressed with GZIP and cached in memory (in gzipped form), so now our
scripts will consume much less bandwidth and will cause fewer requests to server.
Now our script files will consume 600KB, instead of 3000KB before, a %80 reduction, not
bad...
Enabling Minification
After enabling bundling, you may also enable minification of scripts with the same
web.config setting. Set Minimize property to true:
Please note that Minimize property only works when Enabled is true, thus when
bundling is enabled.
UglifyJS library is used for minification. This will be applied before bundling / gzipping so our
bundles will become about %40 smaller, but will be much harder to read, so enable this only
in production.
Now our bundled and minified script files will consume 375KB, instead of 3000KB before, a
%87 reduction, or 1/8 the initial size.
244
How To: Enable Script Bundling
UseMinJS Setting
Minification might take some time, and first request to your site might take around 5-40
seconds more, depending on speed of your server.
Other requests will not be affected as minification is only performed once at application start.
Anyway, if you still need more performance at first request, you may ask Serenity to reuse
already minified files in disk, if they are available.
When this setting is ON, before minifying a file, let's say jquery-ui-1.11.4.js, Serenity will first
check to see if a jquery-ui-1.11.4.min.js already exists in disk. If so, instead of minifiying with
UglifyJS, it will simply use that file. Otherwise, it will run UglifyJS.
Serene comes with minified versions of almost all libraries, including Serenity scripts, so this
setting will speed up initial start time.
There is a little risk that you should be careful about. If you manually modify a library script,
make sure you minify it manually and modify its .min.js file too, otherwise when bundling is
enabled an old version of that script might run at production.
@Html.Script("~/Scripts/jquery.cropzoom.js")
@Html.Script("~/Scripts/jquery.fileupload.js")
@Html.Script("~/Scripts/jquery.iframe-transport.js")
<script src="/Scripts/jquery.cropzoom.js"></script>
<script src="/Scripts/jquery.fileupload.js"></script>
<script src="/Scripts/jquery.iframe-transport.js"></script>
245
How To: Enable Script Bundling
For the first script that is included in a bundle, let's say Bundle.Lib.js, Serenity will generate
code below:
<script src="/DynJS.axd/Bundle.Libs.js?v=..."></script>
But, for other Html.Script calls that is included in same bundle, Serenity will generate
nothing. Thus, even though you call Html.Script 50 times, you'll get only one <script>
output in page code.
This is the reason you'll never have script caching problems with Serenity apps.
246
How To: Debugging with Serenity Sources
https://github.com/GitTools/GitLink
You don't need GitLink to debug, it's just a tool used by Serenity while publishing
To enable source server support, just go to your Visual Studio options, and under Debugging
-> General, click Enable source server support.
247
How To: Debugging with Serenity Sources
Now put a breakpoint on OrderRepository -> MyListHandler -> ApplyFilters or some other
code you like:
248
How To: Debugging with Serenity Sources
Launch application in debug mode, navigate to Orders page, and enjoy debugging:
I don't recommend this to novice users. If you do this and break your project, sorry but i
can't help you.
I assume you have a project named SereneSample, and have a GIT repository for it already.
https://github.com/volkanceylan/Serenity.git
Then click Add to add Serenity as a submodule to your repository. Then close the
submodules dialog, and return to Visual Studio.
Expand your project references for SereneSample.Web and remove following references:
249
How To: Debugging with Serenity Sources
Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
Serenity.Web
Right click your solution, click Add -> Existing Project and select Serenity.Core.csproj under
Serenity folder.
Right click your project references, click Add Reference -> Projects -> Solution and check all
projects we added above, then click OK.
Unload your project by right clicking it and clicking Unload. Then again right click project
name and click Edit.
Add Import statement below, after the last Import Project statement in your csproj (there
should be 4 Import Project statements, 5 after including this one):
<Import Project="$(SolutionDir)Serenity\tools\Submodule\Serenity.Submodule.Web.targe
ts" />
<Exec Command=""$(ProjectDir)tools\node\lessc.cmd"
"$(ProjectDir)..\..\Serenity\Serenity.Web\Style\serenity.less"
> "$(ProjectDir)Content\serenity\serenity.css"">
</Exec>
...
250
Frequently Asked Questions
It's recommended to only generate code once. You should add new fields to row, column
and form classes manually, taking existing fields as a sample.
But if you made too many changes, and want to generate code again it is possible. Sergen
will launch Kdiff3 to let you merge changes, so that it won't override the changes you might
have made to the code generated before.
I'm having an error in Sergen about KDiff3. Where to set its location:
Sergen looks for KDiff3 at its default location under Program Files directory. Install it if you
didn't yet.
Permissions
I want to separate INSERT permission from UPDATE permission :
By default, for INSERT, save handler looks for these permissions on row in this order on
row:
1) InsertPermission
2) ModifyPermission
3) ReadPermission
Similarly for UPDATE, save handler looks for these permissions in order on row:
1) UpdatePermission
251
Frequently Asked Questions
2) ModifyPermission
3) ReadPermission
For DELETE, delete handler looks for these permissions in order on row:
1) DeleteInsertPermission
2) ModifyPermission
3) ReadPermission
1) ReadPermission
Serenity applications are x-copy deployable. You just need to setup connection strings after
deployment. You might exclude source files from deployment.
Make sure you remove database migration safety check from RunMigrations method in
SiteInitialization.Migrations file.
You can also use publish feature in Visual Studio. Make sure build action for all content files
that you use are set to Content, and not None.
Serenity uses a NuGet version of ASP.NET MVC, so there is no need to install MVC on
server. If you get some DLL missing error, check that its Copy Local option of VS project
references is set to True.
Make sure you use same number of digits for min and max value.
252
Frequently Asked Questions
AddEqualityFilter<EnumEditor>(SomeRow.Fields.TheEnumField,
options: new EnumEditor { EnumKey = "MyModule.MyEnumType" });
Add [DefaultValue("today")] for date, or [DefaultValue("now")] for date time editor in form
declaration.
form.MyDateField.AsDate = JsDate.Today;
253
Troubleshooting
Troubleshooting
Initial Setup
After you create a new Serene application and launch it, login screen doesn't show
and you see an error message in console that says Template.LoginPanel is not found:
You probably used an invalid solution name, like MyProject.Something that contains dot (.)
Template system might not be able to locate templates when projects are named this way.
Please don't use dot in solution name. You may rename solution after creation if required.
Compilation Errors
I'm getting several ambiguous reference errors after adding a file to Script project :
Remove System.dll reference from script project. Visual Studio adds this reference when
you use Add New File dialog. Saltarelle Compiler doesn't work with such references, as it
has a completely different runtime.
Runtime Errors
I'm getting NotImplementException when uploading files, or adding notes:
Such features requires a table with integer identity column. String/Guid primary key support
is added in recent Serenity versions, and some old behaviors doesn't work with such keys.
254
Troubleshooting
Your SQL server version is 2008 or older. By default, SQL Server connections use SQL2012
dialect. Do something like below for your connections in SiteInitialization.cs and your dialect
for all to SqlServer2005 or SqlServer2008:
SqlConnections.GetConnectionString("Default").Dialect =
SqlServer2008Dialect.Instance;
T4 Template Problems
My enum is not transferred to script side, after transforming templates:
If you use an enum type in a row or service request / response it will be transferred,
otherwise it won't by default. If you still want to include this enum, add [ScriptInclude]
attribute on top of it.
Make sure your CascadeField is correct and it matches property name in secondary lookup
properly. For example CountryID doesn't match CountryId at script side. You may use
nameof() operator like CascadeField = nameof(CityRow.CountryId) to be sure.
A similar problem might occur if you fail to correctly set CascadeFrom option. This
corresponds to first dropdown ID in your form. For example, if there are MyCountryId and
CustomerCityId properties in the form, CascadeForm should be CustomerCountryId. Again,
you can use nameof(MyCountryId) to be certain.
Another possibility is that CascadeField is not included in lookup data that is sent to script
side. For example, if second dropdown is city selection, which is connected to a country
dropdown through CountryId, make sure that CountryId property in CityRow has a
[LookupInclude] attribute on it. By default, only ID and Name properties are sent to script
side for lookups.
Tried to create tabs using a dialog template, but my tab is not shown or empty:
Make sure you don't put a tab content, inside another one, like DIV inside another tab DIV.
255
Troubleshooting
Master/Detail Editing
I created a in memory master detail editing similar to one in Movie Tutorial cast editor,
but when i update a record, i'm getting duplicate entries:
Make sure you don't have a [IdProperty] on your EditDialog class. As edit dialogs work in
memory with records that doesn't yet have actual IDs, if you use your actual ID property with
them, dialog will think that you are adding new records on update (as their actual ID value is
always null).
As you see in code below, GridEditorDialog base class uses a fake ID:
[IdProperty("__id")]
public abstract class GridEditorDialog<TEntity> : EntityDialog<TEntity>
where TEntity : class, new()
So when you put [IdProperty] to your edit dialog, you're overwriting this fake ID and causing
unexpected behavior.
I'm succesfully adding details but later when open an existing record, some view
fields are empty:
Permissions
My page is not shown in navigation:
Page access permissions are read from PageAuthorize attribute on Index action of
XYZPage.cs file, which is your MVC page controller. Make sure you set this to a permission
user has.
PermissionKeys class is just for intellisense purposes. See below for information about
registering keys.
256
Troubleshooting
Changed permission keys on row, but i'm getting an error when i open the page, and
no records displayed:
Localization
My localizations lost on live site after publishing:
The translations you made using translation interface are saved to files under ~/App_Data
directory. Either copy these files to live server, or move texts in them to relevant files under
~/Scripts/site/texts.
I have added some custom local text keys but can't access them from script side:
Not all translations are transferred to script side. There is a setting in web.config with
LocalTextPackages key, that controls these prefixes. If you look there, you can see that only
text keys that are starting with Db., Dialogs., Forms. etc are transferred to client side. This is
to limit size of texts as not all of them are used in script code.
Either add your own prefix there, or change your keys to start with one of default prefixes.
Please don't update Select2 to a version later than 3.5.1. Recent versions has some known
compability problems.
257
Troubleshooting
If you are using Visual Studio publish, make sure that css, image files etc are included in
web project and their build action is set to content.
Another possibility is that IIS_IUSRS user group can't access files. Check that it has
permissions to files in published web folder.
Serene has a check to avoid running migrations on an arbitrary database. Find this check
under RunMigrations method of SiteInitialization.Migrations file and remove it.
Your hosting provider has set your web application pool to medium trust. Ask them to grant
high trust, or if possible change provider.
It might be possible to change trust level in web.config if your hosting provider didn't lock it:
<configuration>
<system.web>
<trust level="Full" />
</system.web>
</configuration>
Serenity initializes field objects with reflection. Under medium trust, it can't do that. You may
try replacing all *public readonly" field declarations with "public static" in *Row.cs, but not
sure if this will resolve all problems.
ASP.NET has made Medium trust obsolete, and they won't fix any problems related to
this anymore. See http://stackoverflow.com/questions/16849801/is-trying-to-develop-
for-medium-trust-a-lost-cause It is strongly recommended to change your hosting
provider
258
Service Locator & Initialization
For example, Serenity doesn't care about how you store your users, but it can query current
user through an abstraction (IAuthorizationService, IUserRetrieveService etc.)
Similarly you may use Redis, Couchbase, Memcached or any other as distributed cache in
your application. Serenity doesn't have a direct dependency on any of their client libraries.
As soon as you implement IDistributedCache interface and register it with the service
locator, Serenity will start working with your NoSQL database.
Some might argue that Service Locator is an anti-pattern that should be avoided. An
alternative to it would be the Dependency Injection pattern. But it seems unlogical
having to know about every dependency (and dependencies of dependencies...) of an
object to just be able to use it (you shouldn't have to know about details of what your
mobile operator uses to send a simple SMS). Maybe DI is a sample of over-
engineering.
259
Dependency Static Class
Dependency class is the service locator of Serenity. All dependencies are queried through
its methods:
In your application's start method (e.g. in global.asax.cs) you should initialize service locator
by setting a dependency resolver (IDependencyResolver) implementation (an IoC container)
with SetResolver method.
Dependency.SetResolver Method
Configures the dependency resolver implementation to use.
You can use IoC container of your choice but Serenity already includes one based on Munq:
Dependency.Resolver Property
Returns currently configured IDependencyResolver implementation.
260
Dependency Static Class
Depency.HasResolver Property
Returns true if a IDependencyResolver implementation is configured through SetResolver.
Returns false, if not.
Dependency.Resolve Method
Returns the registered implementation for requested type.
Second overload of Resolve method accepts a name parameter. This should be used if
different providers are registered for an interface depending on scope.
For example, Serenity has a IConfigurationRepository interface that can have different
providers based on setting scope. Some settings might be Application scoped (shared
between all servers for this application), while some might be Server scoped (each server
might use a different unique identifier).
So, to retrieve a IConfigurationRepository provider for each of these scopes, you would call
the method like:
Dependency.TryResolve Method
This is functionally equivalent to Resolve method with one difference.
261
IDependencyResolver Interface
IDependencyResolver Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
This interface defines the contract for dependency resolvers which are usually IoC
containers that handles mapping between services and providers.
All methods are functionally equal to corresponding methods in Dependency static class.
262
IDependencyRegistrar Interface
IDependencyRegistrar Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
MunqContainer and other IoC containers are also dependency registrars (they implement
IDependencyRegistrar interface), so you just have to query for it:
IDependencyRegistrar.RegisterInstance
Method
Registers a singleton instance of a type (TType, usually an interface) as provider of that
type.
When you register an object instance with this overload, whenever an implementation of
TType is requested, the instance that you registered is returned from dependency resolver.
263
IDependencyRegistrar Interface
Make sure the provider which you registered is thread safe, as all threads will be using
your instance at same time!
Using this overload, you can register different providers for the same interface, differentiated
by a string key.
For example, Serenity has a IConfigurationRepository interface that can have different
providers based on setting scope. Some settings might be Application scoped (shared
between all servers for this application), while some might be Server scoped (each server
might use a different unique identifier).
So, to register a IConfigurationRepository provider for each of these scopes, you would call
the method like:
registrar.RegisterInstance<IConfigurationRepository>(
"Application", new MyApplicationConfigurationRepository());
registrar.RegisterInstance<IConfigurationRepository>(
"Server", new MyServerConfigurationRepository());
IDependencyRegistrar.Register Method
Unlike RegisterInstance, when a type is registered with this method, every time a provider
for that type is requested, a new instance will be returned (so each requestor gets a unique
instance).
264
IDependencyRegistrar Interface
IDependencyRegistrar.Remove Method
All registration methods of IDependencyRegistrar interface returns an object which you can
later use to remove that registration.
Avoid using this method in ordinary applications as all registrations should be done from a
central location and once per lifetime of the application. But this can be useful for unit test
purposes.
This is not an undo operation. If you register type C for interface A, while type B was
already registered for the same interface, prior registration is overriden and lost. You
can't get back to prior state by removing registration of C.
265
MunqContainer Class
MunqContainer Class
[namespace: Serenity, assembly: Serenity.Core]
You can access its registration interface by querying for IDependencyRegistrar interface:
Here, registrar is the same MunqContainer instance (container) that we created in prior
sample.
If you would like to use another IoC container, you just have to create a class that
implements IDependencyResolver and IDependencyRegistrar interfaces using your
favorite IoC container.
266
CommonInitialization Static Class
If you are going to use defaults in a web environment, instead of doing the service locator
setup and some other configuration manually, you may just call CommonInitialization.Run()
in your application start method. CommonInitialization registers default implementations for
some of Serenity abstractions.
CommonInitialization.Run();
This method contains calls to some other methods to initialize Serenity platform defaults:
//...
}
267
CommonInitialization Static Class
268
Authentication & Authorization
Serenity.Abstractions.IAuthenticationService
Serenity.Abstractions.IAuthorizationService
Serenity.Abstractions.IPermissionService
Serenity.Abstractions.IUserRetrieveService
As Serenity doesn't have default implementation for these abstractions, you should provide
some implementation for them, using dependency registration system.
registrar.RegisterInstance<IAuthorizationService>(
new Administration.AuthorizationService());
registrar.RegisterInstance<IAuthenticationService>(
new Administration.AuthenticationService());
registrar.RegisterInstance<IPermissionService>(
new Administration.PermissionService());
registrar.RegisterInstance<IUserRetrieveService>(
new Administration.UserRetrieveService());
You might want to have a look at those sample implementations before writing your own.
269
IAuthenticationService Interface
IAuthenticationService Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
This is the service that you usually call from your login page to check if entered credentials
are correct. Your implementation should return true if username/password pair matches.
This service returns true, if username is equal to specified password (just for demo).
First parameter is passed by reference for you to change username to its actual
representation in database before logging in. For example, the user might have entered
uppercase JOE in login form, but actual username in database could be Joe . This is not a
requirement, but if your database is case sensitive, you might have problems during login or
later phases.
270
IAuthenticationService Interface
271
IAuthorizationService Interface
IAuthorizationService Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
This is the interface that Serenity checks through to see if there is a logged user in current
request.
using Serenity;
using Serenity.Abstractions;
/// ...
272
IPermissionService Interface
IPermissionService Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
A permission is some grant to do some action (entering a page, calling a certain service). In
Serenity permissions are some string keys that are assigned to individual users (similar to
ASP.NET roles).
For example, if we say some user has Administration permission, this user can see
navigation links that requires this permission or call services that require the same.
You can also use composite permission keys like ApplicationID:PermissionID (for
example Orders:Create ), but Serenity doesn't care about application ID, nor
permission ID, it only uses the composite permission key as a whole.
A simpler sample for applications where there is a admin user who is the only one that has
the permission Administration could be:
273
IPermissionService Interface
using Serenity;
using Serenity.Abstractions;
if (permission == "Administration")
return false;
return true;
}
}
274
IUserDefinition Interface
IUserDefinition Interface
[namespace: Serenity, assembly: Serenity.Core]
Most applications store some common information about a user, like ID, display name (nick /
fullname), email address etc. Serenity provides a basic interface to access this information in
an application independent manner.
Your application should provide a class that implements this interface, but not all of this
information is required by Serenity itself. Id, Username and IsActive properties are minimum
required.
Username should be a unique username, but you can use e-mail addresses as username
too.
IsActive should return 1 for active users, -1 for deleted users (if you don't delete users
DisplayName and Email are optional and currently not used by Serenity itself, though your
275
IUserRetrieveService Interface
IUserRetrieveService Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
When Serenity needs to access IUserDefinition object for a given user name or user ID, it
uses this interface.
In your implementation, it is a good idea to cache user definition objects, as a common WEB
application might use this interface repeatedly for same user.
276
IUserRetrieveService Interface
return null;
}
277
Authorization Static Class
Authorization class provides some shortcuts to information which is provided by services like
IAuthorizationService, IPermissionService etc.
Dependency.Resolve<IAuthorizationService>().HasPermission("SomePermission")
Authorization.HasPermission("SomePermission")
corresponding service for you to access information easier about current user.
278
Configuration System
Configuration System
.NET applications usually stores their configuration in app.config (desktop) or web.config
(web) files.
Though, its common practice to store configuration in such files for web applications,
sometimes it might be required to store some configuration in a database table to make it
available to all servers in a web farm, and set it from one location.
Just like IsolatedStorage has scopes like Application, Machine, User etc, a configuration
setting might have different scopes:
If you have just one server, Application and Server scopes can be stored in web.config file,
but in a web farm setup, Application settings should be stored in a location accessible from
all servers (database or shared folder).
User settings are usually stored in database along with a User ID.
279
Defining Configuration Settings
[SettingScope("Application"), SettingKey("Logging")]
private class LogSettings
{
public LoggingLevel Level { get; set; }
public string File { get; set; }
public int FlushTimeout { get; set; }
}
SettingScope Attribute
If specified, this attribute determines the scope of settings.
SettingKey Attribute
If specified, this attribute determines a key for settings (e.g. appSettings key for web.config)
class.
280
IConfigurationRepository Interface
IConfigurationRepository Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
All applications have some kind of configuration. Scope, storage medium and format for
these settings are different from application to application, so Serenity provides
IConfigurationRepository interface to abstract access to this configuration.
IConfigurationRepository.Load Method
This method returns an instance of settingType. Provider should check SettingKey attribute
to determine key for the setting type.
If same provider is registered for multiple scopes, provider should also check for
SettingScope attribute.
Provider should return an object instance, even if setting is not found (an object created with
settingType's default constructor).
IConfigurationRepository.Save Method
Saves an instance of settingType. Provider should check SettingKey attribute to determine
key for the setting type.
If same provider is registered for multiple scopes, provider should also check for
SettingScope attribute.
This method is optional to implement, as you may not want settings to be changed. In this
case, just throw a NotImplementedException.
281
AppSettingsJsonConfigRepository
AppSettingsJsonConfigRepository
[namespace: Serenity.Configuration, assembly: Serenity.Data]
Most web applications store configuration settings in web.config file, under appSettings
section.
This provider expects settings to be defined in web.config / app.config file in JSON format:
282
AppSettingsJsonConfigRepository
<appSettings>
<add key="Logging" value="{ File: '~\\App_Data\\Log\\App_{0}_{1}.log',
FlushTimeout: 0, Level: 'Debug' }" />
</appSettings>
Out of the box, Serenity contains this configuration provider only. You may take it as a
sample, and write another one for your setup (load from database etc.).
283
Config Static Class
This is the central location to access your configuration settings. It contains shortcut
methods to registered IConfigurationRepository provider.
Config.Get Method
Used to read configuration settings for specified type.
if (Config.Get<LogSettings>().LoggingLevel != LogginLevel.Off)
{
// ..
}
Config.TryGet Method
Used to read configuration settings for specified type.
284
Config Static Class
Prefer this method over Get only to avoid exceptions when configuration system is not
initialized yet.
285
Localization
Localization
Most web applications must support a variety of languages. Sites like Youtube, Wikipedia,
Facebook works in many languages.
First time a user visits such a site, a language for her is automatically chosen depending on
the browser language (pre-determined by regional settings).
If automatic selection is not what they expect, users can set their preferred language and
this selection is stored in a client-side cookie (or server side user profile table).
Once a language is chosen, all texts are displayed in the selected language.
If you are using Serenity Basic Application Sample you can see this yourself by setting your
browser language or changing a web.config setting:
<system.web>
<globalization culture="en-US" uiCulture="auto:en-US" />
</system.web>
Here, UI culture is set to automatic, and if automatic detection fails, en-US is used as a
fallback.
286
Localization
Change this configuration as below, refresh your browser and you will the site in Turkish:
<system.web>
<globalization culture="en-US" uiCulture="tr" />
</system.web>
287
Localization
Here, data is not translated but it is also possible to translate user entered data by
some methods like culture extension tables.
288
LocalText Class
LocalText Class
[namespace: Serenity, assembly: Serenity.Core]
Its constructor takes a key parameter, which defines the local text key that it will contain.
Some of sample keys are:
Enums.Month.January
Enums.Month.December
Db.Northwind.Customer.CustomerName
Dialogs.YesButton
Though it is not a rule, it is a good idea to follow this namespace like dot convention for
local text keys.
At runtime, through ToString() function, the local text key is translated to its representation in
the active language (which is CultureInfo.CurrentUICulture).
> Yes
If a translation is not found in local text table (we will talk about this later), the key itself is
returned.
289
LocalText Class
> Unknown.Local.Text.Key
This is by design, so that developer can determine which translations are missing.
LocalText.Key Property
Gets the local text key that LocalText instance contains.
Here someText variable references a new LocalText instance with the key
Dialogs.YesButton. So it is just a shortcut to LocalText constructor.
> No
290
LocalText Class
Console.WriteLine(LocalText.Get("Dialogs.YesButton"));
> Yes
LocalText.Empty Field
Similar to String.Empty, LocalText contains an empty local text object with empty key.
LocalText.InvariantLanguageID Constant
This is just an empty string for invariant language ID which is the invariant culture language
identifier (default language, usually English).
291
Language Identifiers
Language Identifiers
A language ID is a code that assigns letters and/or numbers as identifiers or classifiers for
languages.
Language IDs follow the RFC 1766 standard in the format <languagecode2>-
<country/regioncode2> , where languagecode2 is a lowercase two-letter code derived from
ISO 639-1 and country/regioncode2 is an uppercase two-letter code derived from ISO 3166.
en : English
en-US : English as used in the United States (US is the ISO 3166-1 country code)
en-GB : English as used in the United Kingdom (GB is the ISO 3166-1 country code)
es : Spanish
Invariant Language
Similar to CultureInfo.InvariantCulture, invariant language is the default language with empty
identifier.
Unless specified otherwise, embedded texts in your assemblies are considered to be written
in invariant language.
Though it is usually considered to be English, you may assume your natural language as the
invariant language.
292
Language Fallbacks
Language Fallbacks
Two letter language IDs (neutral languages) are implicitly language fallbacks of 4 letter
country specific codes.
Implementation
Language fallback functionality should be implemented by the ILocalTextRegistry provider
(e.g. LocalTextRegistry class).
Providers may also support setting language fallbacks explicitly, so you can set en-US as
language fallback of en-UK if needed.
1. en-UK
2. en-US
3. en
293
Language Fallbacks
4. invariant
294
ILocalTextRegistry Interface
ILocalTextRegistry Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
LocalText class accesses translations for local text keys through the provider for this
interface.
ILocalTextRegistry.TryGet Method
Gets translation for the specified key in requested language.
It is providers responsibility to check language fallbacks for the key, if a translation is not
found in requested language.
This method returns null if no translation is found in the language hierarchy (from requested
language down to invariant language).
ILocalTextRegistry.Add Method
Adds a translation to the local text table which is internally hold by the local text registry.
Dialogs.YesButton tr Evet
Dialogs.NoButton en No
Dialogs.NoButton tr Hayr
This method doesn't throw an exception if same key/language ID pair is added twice. It
simply overrides existing translation.
295
ILocalTextRegistry Interface
296
LocalTextRegistry Class
LocalTextRegistry Class
[namespace: Serenity.Localization, assembly: Serenity.Core]
LocalTextRegistry.SetLanguageFallback
Method
Sets language fallback for specified language.
297
LocalTextRegistry Class
298
Pending Approval Mode
In some sites, translations might be needed to be approved by some moderators before they
are published.
So you may add these unapproved texts to your local text registry but want them to be
shown only to moderators for them to check how they will look in live site when approved.
LocalTextRegistry allows you to mark some texts as pending approval, and use these
translations for only approval contexts (e.g. when a moderator is logged in).
ILocalTextContext Interface
[namespace: Serenity.Localization, assembly: Serenity.Core]
Implement this interface and register it through the service locator (Dependency class).
299
Pending Approval Mode
void ApplicationStart()
{
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance<ILocalTextContext>(new MyLocalTextContext());
}
LocalTextRegistry.AddPending Method
Adds a translation to local text table for pending approval texts. These texts are only used
when current context is in pending approval mode.
This overload lets you to get a translation in specified language and optionally using
unapproved texts (isApprovalMode = true).
Other TryGet overload returns unapproved texts only when ILocalTextContext provider
returns true for IsApprovalMode property.
LocalTextRegistry.GetAllAvailableTextsInLangu
age Method
Returns a dictionary of all currently registered translations for all text keys in a language.
Dictionary keys are local text keys, while values are translations.
300
Pending Approval Mode
It also contains texts found from language fallbacks for if a translation is not available in
requested language.
301
Registering Translations
Registering Translations
There are several ways to define local text keys and translations, including:
302
Manually Registering Translations
Sources for these translations might be a database table, xml file, embedded resources etc.
void Application_Start()
{
// ...
var registry = Dependency.Resolve<ILocalTextRegistry>();
registry.Add("es", "Dialogs.YesButton", "S");
registry.Add("fr", "Dialogs.YesButton", "Oui");
// ..
}
303
Nested Local Texts
[NestedLocalTexts]
public static partial class Texts
{
public static class Site
{
public static class Dashboard
{
public static LocalText WelcomeMessage =
"Welcome to Serenity BasicApplication home page. " +
"Use the navigation on left to browse other pages...";
}
}
This definitions allow you to reference localized texts with intellisense support, without
having to memorize string keys.
These embedded translation definitions are commonly used to define default translations in
invariant language (ultimate fallbacks).
Here is a table of translations that are defined with this Texts class:
Local text keys are generated from nested static class names with a dot inserted between.
Topmost static class (Texts) name is ignored though it is a good idea to name it something
like Texts for consistency.
304
Nested Local Texts
Unless otherwise stated, language ID for these texts are considered to be the invariant
language (empty string).
NestedLocalTexts Attribute
Topmost class (e.g. Texts) for nested local text registration classes must have this attribute.
[AttributeUsage(AttributeTargets.Class, AllowMultiple=false)]
public sealed class NestedLocalTextsAttribute : Attribute
{
public NestedLocalTextsAttribute()
{
}
It is a good idea to register default texts in invariant language, even if texts are not in
English, as invariant language is the eventual language fallback for all languages.
If we used it like:
[NestedLocalTexts(LanguageID = "en-US")]
public static partial class Texts
{
// ..
}
305
Nested Local Texts
NestedLocalTextRegistration Class
[namespace: Serenity.Localization, assembly: Serenity.Core]
void Application_Start()
{
NestedLocalTextRegistration.Initialize();
}
Once it is run, all translations with auto generated keys are added to current
ILocalTextRegistry provider and LocalText instances in nested static classes are replaced
with actual LocalText instances containing generated keys (they are set through reflection).
306
Enumeration Texts
Enumeration Texts
Display text for enumeration values can be specified with Description attribute.
namespace MyApplication
{
public enum Sample
{
[Description("First Value")]
Value1 = 1,
[Description("Second Value")]
Value2 = 2
}
}
This enumeration and its Description attributes defines following local text keys and
translations:
You can use these keys to access translated descriptions for enumeration values, or use
extension method GetText() defined for enumeration types (import namespace Serenity to
make this extension method available).
using Serenity;
//...
Console.WriteLine(MyApplication.Sample.Value1.GetText());
EnumKey Attribute
Enumeration translations use full name of enumeration type as prefix to generate local text
keys. This prefix can be overriden with EnumKeyAttribute:
307
Enumeration Texts
namespace MyApplication
{
[EnumKey("Something")]
public enum Sample
{
[Description("First Value")]
Value1 = 1,
[Description("Second Value")]
Value2 = 2
}
}
EnumLocalTextRegistration Class
[namespace: Serenity.Localization, assembly: Serenity.Core]
void Application_Start()
{
EnumLocalTextRegistration.Initialize(ExtensibilityHelper.SelfAssemblies);
}
It gets list of assemblies to search for enumeration types. You can pass list of assemblies
manually or use ExtensibilityHelper.SelfAssemblies which contains all assemblies that
reference a Serenity assembly.
308
JSON Local Texts
{
"Forms.Administration.User.DisplayName": "Display Name",
"Forms.Administration.User.Email": "E-mail",
"Forms.Administration.User.EntitySingular": "User",
"Forms.Administration.User.EntityPlural": "Users"
}
To register all local text keys and translations from JSON files in a folder , call
JsonLocalTextRegistration.AddFromFilesInFolder with the path:
JsonLocalTextRegistration.AddFromFilesInFolder(@"C:\SomeFolder");
where {LanguageID} is two or four letter language code. Use invariant as language code for
invariant language.
site.texts.en-US.json
MyCoolTexts.es.json
user.texts.invariant.json
Files in a folder are parsed and added to registry in their file name order. Thus for sample file
names above, order would be:
1. MyCoolTexts.es.json
2. site.texts.en-US.json
3. user.texts.invariant.json
This order is important as adding a translation in some language with same key
overrides prior translation.
309
JSON Local Texts
Prefer using second one for your own files as first one is for Serenity resources.
Third one contains user translated texts. It is recommended to transfer texts from these files
to application translation files under ~/Scripts/site/texts before publishing.
310
Caching
Caching
Caching is an important part of modern, heavy-traffic applications. Even if your web
application isn't getting so much traffic now, it might later and it is a good idea to design it
with caching in mind from the start.
Local Caching
Distributed Caching
Two Level Cache
311
Local Caching
Local Caching
Serenity provides some caching abstractions and utility functions to make it easier to work
with local cache.
The term local means that cached items are hold in local memory (thus there is no
serialization involved).
When your application is deployed on a web farm, local caching might not be enough or
sometimes suitable. We will talk about this scenario in Distributed Caching section.
312
ILocalCache Interface
ILocalCache Interface
[namespace: Serenity.Abstrations] - [assembly: Serenity.Core]
ILocalCache.Add Method
Adds a value to cache with the specified key. If the key already exists in cache, its value is
updated.
Items are hold in cache for expiration duration. You can specify TimeSpan.Zero for items
that shouldn't expire automatically.
Values are added to cache with absolute expiration (thus they expire at a certain time, not
sliding expiration).
If there is no such key in cache, an error may be raised only if TItem is of value type. For
reference types returned value is null .
313
ILocalCache Interface
You may use object as TItem parameter to prevent errors in case a value doesn't exist,
or not of requested type.
ILocalCache.Remove Method
Removes the item with specified key from local cache and returns its value.
No errors thrown if there is no value in cache with the specified key, simply null is
returned.
Dependency.Resolve<ILocalCache>.Remove("someKey");
ILocalCache.RemoveAll Method
Removes all items from local cache. Avoid using this except for special situations like unit
tests, otherwise performance might suffer.
314
LocalCache Static Class
A static class that contains shortcuts to work easier with the registered ILocalCache provider.
Add, Remove, and RemoveAll methods are simply shortcuts to corresponding methods in
ILocalCache interface, but Get method is a bit different than ILocalCache.Get.
If there is no such key in cache, uses the loader function to produce value, and adds it to
cache with the specified key.
If the value that exists in cache is DBNull.Value, than null is returned. (This way, if for
example a user with an ID doesn't exist in database, repeated querying of database for
that ID is prevented)
315
LocalCache Static Class
If the value exists, but of not type TItem an exception is thrown, otherwise value is
returned.
If the value didn't exist in cache, loader function is called to produce the value (e.g. from
database) and...
316
User Profile Caching Sample
By making use of LocalCache.Get method, we could cache this information for one hour
easily and avoid DB calls every time this information is needed.
317
Distributed Caching
Distributed Caching
Web applications might require to serve hundreds, thousands or even more users
simultaneously. If you didn't take required measures, under such a load, your site might
crash or become unresponsive.
Let's say you are showing the last 10 news in your home page and in a minute, in average
of a thousand users are visiting this page. For every page view you might be querying your
database to display this information:
SELECT TOP 10 Title, NewsDate, Subject, Body FROM News ORDER BY NewsDate DESC
Even if we think that our home page contains only this information, a site, that gets 10000
visits a minute would run 150 SQL queries per second.
These queries, as their result doesn't differ from user to user (always the last 10 news),
might be cached in SQL server side automatically.
But query results consumes some valuable network bandwidth while being transferred from
SQL server to your WEB server. As this transfer takes some time (data size / bandwidth)
and your connection is kept open during this time, even if your SQL server responded
instantly, getting the results wouldn't be so fast. The time to transfer might vary with the size
of the news content.
Also as SQL connections which can be kept open simultaneously has a upper limit
(connection pool limit) and when you reach that number, the connections start to wait in the
queue and block each other.
By taking into account that news don't change every second, we could cache them in our
WEB server memory for 5 minutes.
Thus as soon as we transfer news list from SQL database, store them in local cache. For the
next 5 minutes, for every user that visits the home page, news list is read from local cache
instantly, without even hitting SQL:
318
Distributed Caching
HttpRuntime.Cache.Insert("News", ...,
TimeSpan.FromMinutes(5), ....);
}
}
return news;
}
This takes us from 150 queries per second down to 1/300 queries per second (a query per
300 sec).
Also these news items should be converted to HTML for every visitor. By moving one
step further, we could also cache the HTML converted state of the news.
All these cached information is stored in WEB server memory which is the fastest location to
access them.
Note that caching something doesn't always mean that your application will work faster.
How effectively you use cache is more important than caching alone. It is even possible
to slow down your application with caching, if not used properly.
319
WEB Farms and Caching
To generate a users profile, we would need more than one SQL query (friends, album
names and picture counts, profile information, last status etc.).
As long as a user didn't update her profile, the information that is shown on her page would
be almost stastic. Thus, a snapshot of profile pages could be cached for 5 minutes or 1 hour
etc.
But this might not be enough. We are talking about hundres of millions of profiles and users.
Users would be doing much more than just looking at some profile pages. We would need
more than one server that are distributed in several geographical locations on earth (a WEB
Farm).
At a certain time, all these servers might have cached a very important persons (VIP) profile
in local cache. When the VIP makes a change in her profile, all these servers should renew
their local cached profile, and this would happen in a few seconds. We now have a problem
of load per server instead of load per user.
Actually, once one these of servers loaded the VIP profile from SQL database and cached it,
other servers could make use of the same information without hitting database. But, as each
server stores cached information in its own local memory, it is not trivial to access this
information by other servers.
Profile:SomeAnother ...
... ...
... ...
Profile:JohnDoe ...
Let's call this memory the distributed cache. If all servers have a look at this common
memory before trying DB we would avoid the load per server problem.
320
WEB Farms and Caching
if (profile == null)
{
profile = DistributedCache.Get<CachedProfileInformation>(
"Profil:" + profileID);
if (profile == null)
{
using (var connection = new SqlConnection("......"))
{
profile = GetProfileFromDBWithSomeSQLQueries(profileID)
profile, TimeSpan.FromMinutes(5));
return news;
}
You can find many variations of distributed cache systems including Memcached,
Couchbase and Redis. They are also called NoSQL database. You can think of them simply
as a remote dictionary. They store key/value pairs in their memory and let you access them
as fast as possible.
Warning! When it is used properly, distributed cache can improve performance of your
application, just like local cache. Otherwise it can have a worse effect than local cache
as there is a network transfer and serialization cost involved. Thus "if we keep things in
distributed cache our site will run faster" is a myth.
When the cached data becomes too much, one computer memory might be not enough to
store all key/value pairs. In this case servers like memcached distribute data by clustering.
This could be done by the first letter of keys. One server could hold pairs starting with A,
other with B etc. In fact, they use hash of keys for this purpose.
321
IDistributedCache Interface
IDistributedCache Interface
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
All NoSQL server types provide a similar interface like "store this value for this key", "give
me value corresponding to this key" etc.
Serenity provides its distributed cache support through a common interface to not depend on
a specific kind of NoSQL database:
First overload of Set method that takes key and value arguments is used to store a
key/value pair in distributed cache.
IoC.Resolve<IDistributedCache>().Set("someKey", "someValue");
If we wanted to keep some value for a predetermined duration, we could use the second
overload of Get method:
IoC.Resolve<IDistributedCache>().Set("someKey", "someValue",
TimeSpan.FromMinutes(10));
IDistributedCache.Increment Method
Operation on distributed cache systems are usually not atomic and they provide no
transactional systems at all.
Same key value can be changed by multiple servers at same time and override each others
value in random order.
322
IDistributedCache Interface
Let's say we needed a unique counter (to generate an ID for example) and synchronize it
through distributed cache (to prevent using same ID twice):
int GetTheNextIDValue()
{
var lastID = IoC.Resolve<IDistributedCache>().Get("LastID");
IoC.Resolve<IDistributedCache>().Set("LastID", lastID + 1);
return lastID;
}
Such a code block won't function as expected. Inside the duration between reading LastID
value (get) and setting it to increment LastID value (set), another server might have read
the same LastID value. Thus two servers could use same ID value.
int GetTheNextIDValue()
{
return IoC.Resolve<IDistributedCache>().Increment("LastID");
}
Increment function acts just like Interlocked.Increment method that is used in thread
synchronization. It increases an identity value but blocks other requests while doing it, and
returns the incremented value. So even if two WEB servers incremented same key in exact
same moment, they end up with different ID values.
323
Distributed Cache Static Class
IoC.Resolve<IDistributedCache>().Increment("LastID");
DistributedCache.Increment("LastID");
324
DistributedCacheEmulator Class
DistributedCacheEmulator Class
[namespace: Serenity.Abstractions, assembly: Serenity.Core]
If you don't need a distributed cache now, but you wanted to write code that will work with a
distributed cache in the future, you could use the DistributedCacheEmulator class.
DistributedCacheEmulator is also useful for unit tests and development environments (so
that developers don't need to access a distributed cache system and work without affecting
each other).
To use DistributedCacheEmulator, you need to register it with the Serenity Service Locator
(IDependencyRegistrar). We do it from some method called on application start
(global.asax.cs etc):
325
CouchbaseDistributedCache Class
CouchbaseDistributedCache Class
[namespace: Serenity.Caching, assembly: Serenity.Caching.Couchbase]
You can get Serenity implementation for this server type in Serenity.Caching.Couchbase
NuGet package.
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance<IDistributedCache>(new CouchbaseDistributedCache())
<appSettings>
<add key="DistributedCache" value='{
ServerAddress: "http://111.22.111.97:8091/pools",
BucketName: "primary-bucket",
KeyPrefix: ""
}' />
Here ServerAddress is Couchbase server address and BucketName is the bucket name.
If you wanted to use same server / bucket for more than one application you can put
something like DEV: , TEST: into KeyPrefix setting.
326
RedisDistributedCache Class
RedisDistributedCache Class
[namespace: Serenity.Caching, assembly: Serenity.Caching.Couchbase]
Redis is another in memory database that is also used by StackOverflow for its performance
and reliability. They use just one Redis database for all their WEB servers.
You can get Serenity implementation for this server type in Serenity.Caching.Redis NuGet
package.
<appSettings>
<add key="DistributedCache" value="{
ServerAddress: 'someredisserver:6379',
KeyPrefix: ''
}"/>
/>
327
Two Level Caching
If you prefer distributed caching to let other servers access cached data as it has some
serialization / deserialization and network latency overhead, it may degrade performance in
some cases.
There is also another problem with caching that needs to be handled: cache invalidation:
There are only two hard things in Computer Science: cache invalidation and naming
things.
-- Phil Karlton
When you cache some information, you have to make sure that, when the source data
changes, cached information is invalidated (regenerated or removed from cache).
328
Using Local Cache and Distributed Cache In Sync
This way, when a server caches some information in local cache, it also caches it in
distributed cache, but this time other servers can re-use information in distributed cache if
they don't have a local copy in memory.
Once all servers have a local copy, none of them will need to access distributed cache
again, thus, avoiding serialization and latency overhead.
We would change the value in distributed cache, but as they don't check distributed cache
anymore (shortcut from step 2 in last algorithm), they wouldn't be noticed.
One solution to this problem would be to keep local copies for a certain time, e.g. 5 secs.
Thus, when a server changes a cached data, other servers would use out-of-date
information for 5 seconds mostly.
This method would help with batch operations that needs same cached information
repeatedly. But even if nothing changed in distributed cache, we would have to get a copy
from distributed cache to local cache every 5 seconds. If cached data is big, this would
increase network bandwidth usage and deserialization cost.
We need a way to know if the data in distributed cache is different from the local copy. There
are several ways of it that i can imagine:
Store hash alongside data in local and distributed cache (slight hash calculation cost)
329
Using Local Cache and Distributed Cache In Sync
Store an incrementing version number of data (how to make sure that two servers
doesn't generate same version numbers?)
Store last time data is set in distributed cache (time sync problems)
Store a random number (generation) alongside data
So when we store a value in distributed cache, let's say SomeCachedKey, we also store a
random number with key SomeCachedKey$GENERATION$.
Lets say you have a profile table and cached profile items by their User ID values.
When a user's profile information changes, you may try to remove its cached profile from
cache. But what if another server or application you don't know about, cached some
information that is generated from same user profile data? You may not know what cached
information keys exist in distributed cache that depends on some user ID.
Most distributed cache implementations don't provide a way to find all keys that start with
some string or it is computationally intensive (as they are dictionary based).
So when you want to expire all items depending on some set of data, it might not be
feasible.
While caching items, Serenity allows you to specify a group key, which is used to expire
them, when the data that the group depends on changes.
330
Using Local Cache and Distributed Cache In Sync
Let's say one application produced CachedItem17 from a user with ID 17's profile data and
we use this ID as a group key (Group17_Generation):
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
Group17_Generation 13579
Here, random generation (version) for the group is 13579. Along with cached data
(CachedItem17), we stored whatever was the group generation when we produced this data
(CachedItem17_Generation).
Suppose that another server, cached AnotherItem17 from User 17's data:
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
AnotherItem17 uwsdasdas
AnotherItem17_Generation 13579
Group17_Generation 13579
Now, both items in cache (CachedItem17 and AnotherItem17) are valid, because their
version numbers matches the group version.
If somebody changed User 17's data and we wanted to expire all cached items related to
her, we need to just change the group generation:
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
AnotherItem17 uwsdasdas
AnotherItem17_Generation 13579
Group17_Generation 54237
Now all cached items are expired. Even though they exist in cache, we see that their
generations don't match the group generation, so they are not considered valid.
331
Using Local Cache and Distributed Cache In Sync
Group keys we use are usually name of the table that data is produced from.
332
TwoLevelCache Class
TwoLevelCache Class
[namespace: Serenity] - [assembly: Serenity.Core]
Out of the box, TwoLevelCache provides all functionality that we talked about so far and
some more.
TwoLevelCache.Get Method
Tries to read a value from local cache. If it is not found in there (or has an expired
version), tries the distributed cache.
If neither contains the specified key, produces value by calling a loader function and
adds the value to local and distributed cache for a given expiration time.
333
TwoLevelCache Class
There are two overloads of the Get method. One that takes expiration time for local and
distributed caches separately, and another that has only one expiration parameter for
both.
By using a group key, all items on both cache types that are members of this group can
be expired at once (this is generation based expiration, not time).
To avoid checking group generation every time an item that belongs to group is
requested, group generation itself is also cached in local cache. Thus, when a
generation number changes, local cached items might expire after 5 seconds.
This means that, if you use this strategy in a web farm setup, when a change occurs in
one server, other servers might continue to use old local cached data for 5 seconds
more.
If this is a problem for your configuration, you should use DistributedCache methods
directly instead of depending on TwoLevelCache.
334
TwoLevelCache Class
TwoLevelCache.GetWithCustomSerializer
Method
TwoLevelCache.Get stores cached data in both local cache and distributed cache. While
storing cached items in local cache, serialization is not required (in-memory). But before
items are sent to distributed cache, some kind of serialization (binary, json etc.) must be
performed (depends on provider and data type).
Sometimes this serialization / deserialization operation can be costly, so you might want to
provide your own implementation of these functions for your data type.
Most providers handle simple types like int, string or byte[] effectively, so for such data
types you don't need custom serialization.
TwoLevelCache.GetLocalStoreOnly Method
If you only want to store items in local cache and not distributed cache, GetLocalStoreOnly
can be useful.
When cached data by one server is not helpful for others (changes from server to server), so
big or slow to serialize / deserialize, storing such data in distributed cache is not meaningful.
335
TwoLevelCache Class
You could but not if you want to specify a group key, and expire local cached items easily
when source data of that group changes (as if they are stored in distributed cache).
TwoLevelCache.ExpireGroupItems Method
This method allows you to expire all items that are members of one group key. It simply
removes group key from local cache and distributed cache, so another version will be
generated next time it is queried.
TwoLevelCache.ExpireGroupItems("SomeGroupKey");
If your entity class has TwoLevelCached attribute on it, Create, Update, Delete and
Undelete handlers do this automatically with ConnectionKey.TableName as group key.
TwoLevelCache.Remove Method
Removes an item and its version from local and distributed cache.
336
Entities (Row)
Entities (Row)
Serenity entity system is a micro-orm that is in love with SQL just like Dapper.
Unlike full blown ORMs like NHibernate / Entity Framework, Serenity provides minimum
features required to map and query databases with intellisense, compile time checking and
easy refactoring.
Serenity entities are usually named like XYZRow. They are subclasses of
Serenity.Data.Row.
using Serenity;
using Serenity.ComponentModel;
using Serenity.Data;
public SimpleRow()
: base(Fields)
{
}
337
Entities (Row)
Yes, it looks a bit more complicated than a simple POCO class. This is required to
make some features work without using proxy classes like some ORMs use (Entity
Framework, NHibernate etc).
This structure allows us to build queries with zero reflection, do assignment tracking,
enable INotifyPropertyChanged when required. It makes it also possible to work with
custom, user defined fields.
Rows are JSON serializable, so they can be returned from services without any
problems. You don't need extra POCO/DTO classes unless you have a good reason to
use them.
Here we define an entity named SimpleRow, which probably maps to a table named Simple
in database.
Row suffix here is not required, but common practice, and it prevents clashes with other
class names.
Now we declare our first property. This property maps to a database column named Name in
the Simple table.
It is not possible to use an auto property here (like get; set; ). Field values must be read
and set through a special object called Field.
Field objects are very similar to WPF dependency properties. Here is a dependency property
declaration sample:
338
Entities (Row)
Similar to dependency properties, Field objects contains column metadata and clears way
for some features like assignment tracking, building queries without expression trees,
change notification etc.
While dependency properties are declared as static members in class they are used, Field
objects are declared in a nested class named RowFields. This allows to group and reference
them easier, without having to add Field or Property suffix, and keeps our entity clear from
field declarations.
Serenity entity properties are always nullable, even if database column is not nullable.
This might seem unlogical, if you have a background of other ORMs, but consider this:
Is it not possible for a not null field to have a null value, if you query it through a left/right
join? How can you say, if its retrieved value is null or zero in that case?
339
Entities (Row)
We noted that field objects are declared in a nested subclass named RowFields (usually).
Here we are creating its sole static instance. Thus, there is only one RowFields instance per
row type, and one field instance per row property.
Init is an extension method that initializes members of RowFields. It creates field objects
public SimpleRow()
: base(Fields)
{
}
Here we define our nested class that contains field objects. It should be derived from
Serenity.Data.RowFieldsBase . RowFieldsBase is a special class closely related to Row that
We declared a StringField and a Int32Field. Their type is based on their property types, and
they must match exactly.
Their names must also match the property names, or you'll get an initialization error.
We didn't initialize these field objects, so their values are initially null.
Remember that we wrote new RowFields().Init() above. This is where field objects are
automatically created.
It's also possible to initialize them in RowFields constructor manually, but not
recommended, except for special customizations.
340
Mapping Attributes
Mapping Attributes
Serenity provides some mapping attributes, to match database table, column names with
rows.
SELECT
T0.StreetAddress AS [StreetAddress]
FROM Customer T0
SqlSettings.AutoQuotedIdentifiers Flag
341
Mapping Attributes
For example, in Postgress, if you create a column with quoted identifier "StreetAddress" ,
you have to use quotes when selecting it, even if you write SELECT StreetAddress ... (same
case) it won't work.
Thus, Postgres users usually prefer lowercase identifiers. But FluentMigrator always quotes
identifiers, so we need a workaround to add brackets/quotes to identifiers.
Serenity doesn't quote/bracket column and table names by default, but it has a setting for
compability.
If SqlSettings.AutoQuotedIdentifiers flag is set to true, previous query would look like this:
SELECT
T0.[StreetAddress] AS [StreetAddress]
FROM [Customer] T0
This setting defaults to false in Serenity for backwards compability, but Serene 1.8.6+
sets it to true on application startup.
SELECT
T0."StreetAddress" AS "StreetAddress"
FROM "Customer" T0
Column Attribute
[namespace: Serenity.Data.Mapping] - [assembly: Serenity.Data]
You can map a property to some other column name in database using Column attribute:
342
Mapping Attributes
SELECT
T0.street_address AS [StreetAddress]
FROM Customer T0
SELECT
T0.[street_address] AS [StreetAddress]
FROM Customer T0
Use SqlServer specific brackets ( [] ) if you need to work with multiple database types.
These brackets are converted to dialect specific quotes (double quote, backtick etc.)
before running queries.
But, if you only target one type of database, you may prefer using quotes specific to
that database type.
TableName Attribute
[namespace: Serenity.Data.Mapping] - [assembly: Serenity.Data]
If table name in database is different from row class name, use this attribute:
343
Mapping Attributes
[TableName("TheCustomers")]
public class CustomerRow : Row
{
public string StreetAddress
{
get { return Fields.StreetAddress[this]; }
set { Fields.StreetAddress[this] = value; }
}
}
SELECT
T0.StreetAddress AS [StreetAddress]
FROM TheCustomers T0
[TableName("[My Customers]")]
public class CustomerRow : Row
{
public string StreetAddress
{
get { return Fields.StreetAddress[this]; }
set { Fields.StreetAddress[this] = value; }
}
}
SELECT
T0.StreetAddress AS [StreetAddress]
FROM [My Customers] T0
Expression Attribute
[namespace: Serenity.Data.Mapping] - [assembly: Serenity.Data]
This attribute is used to specify expression of a non-basic field, e.g. one that doesn't actually
exist in database.
One example is a Fullname field with a calculated expression like (T0.[Firstname] + ' ' +
T0.[Lastname]) .
344
Mapping Attributes
Be careful with "+" operator here as it is Sql Server specific. If you want to target
multiple databases, you should write the expression as:
CONCAT(T0.[Firstname], CONCAT(' ', T0.[Lastname]))
Firstname and Lastname are table fields (actual fields in the table), but even if they don't
have an expression attribute, they have basic, implicitly defined expressions, T0.Firstname
and T0.Lastname (main table is assigned T0 alias in Serenity queries).
In this document, when we talk about a Table Field, it means a field that actually
corresponds to a column in database table.
View Field means a field with a calculated expression or a field that originates from
another table, like fields that comes from joins in SQL views.
We wrote Fullname expression using T0 alias before the fields that we reference.
It would probably work without that prefix too. But it is better to use it. When you start to
add joins, it is possible to have more than one field with same name and experience
ambiguous column errors.
ForeignKey Attribute
[namespace: Serenity.Data.Mapping] - [assembly: Serenity.Data]
345
Mapping Attributes
This attribute is used to specify foreign key columns, and add information about primary
table and primary field that they are related to.
Here we specified that CountryId field in Customer table has a foreign key to Id field in
Countries table.
The foreign key doesn't have to exist in database. Serenity doesn't check it.
Serenity can make use of such meta information, even though it doesn't affect generated
queries alone.
ForeignKey is more meaningful when used along with the next attribute we'll see.
LeftJoin Attribute
Where we are querying database, we tend to make many joins because of relations. Most of
these joins are LEFT or INNER joins.
Database admins prefers to define views to make it easier to query a combination of multiple
tables, and to avoid writing these joins again and again.
Serenity entities can be used just like SQL views, so you can bring in columns from other
tables to an entity, and query it as if they are one big combined table.
346
Mapping Attributes
[Expression("c.[Name]")]
public string CityName
{
get { return Fields.CityName[this]; }
set { Fields.CityName[this] = value; }
}
Here we specified that Cities table should be assigned alias c when joined, and its join
type should be LEFT JOIN . The join ON expression is determined as c.[Id] == T0.
[CityId] with some help from ForeignKey attribute.
LEFT JOIN is preferred as it allows to retrieve all records from left table, Customers,
even if they don't have a CityId set.
CityName is a view field (not actually a column of Customer table), which has an expression
c.Name. It is clear that CityName originates from Name field in Cities table.
Now, if we wanted to select city names of all customers, our query text would be:
SELECT
c.Name AS [CityName]
FROM Customer T0
LEFT JOIN Cities c ON (c.[Id] = T0.CityId)
What if we don't have a CountryId field in Customer table, but we want to bring Country
names of cities through CountryId field in city table?
347
Mapping Attributes
[Expression("c.[Name]")]
public string CityName
{
get { return Fields.CityName[this]; }
set { Fields.CityName[this] = value; }
}
[Expression("o.[Name]")]
public string CountryName
{
get { return Fields.CountryName[this]; }
set { Fields.CountryName[this] = value; }
}
}
This time we did a LEFT JOIN on CountryId field in Cities table. We assigned o alias to
Countries table and bring in the name field from it.
You can assign any table alias to joins as long as they are not reserved words, and are
unique between other joins in the entity. Sergen generates aliases like jCountry, but you
may rename them to shorter and more natural ones.
SELECT
c.[Name] AS [CityName],
o.[Name] AS [CountryName]
FROM Customer T0
LEFT JOIN Cities c ON (c.[Id] = T0.CityId)
LEFT JOIN Countries o ON (o.[Id] = c.[CountryId])
348
Mapping Attributes
So far, we used LeftJoin attribute with properties that has a ForeignKey attribute with them.
It is also possible to attach LeftJoin attribute to entity classes. This is useful for joins without
a corresponding field in main entity.
For example, let's say you have a CustomerDetails extension table that stores some extra
details of customers (1 to 1 relation). CustomerDetails table has a primary key, CustomerId,
which is actually a foreign key to Id field in Customer table.
[Expression("cd.[DeliveryAddress]")]
public string DeliveryAddress
{
get { return Fields.DeliveryAddress[this]; }
set { Fields.DeliveryAddress[this] = value; }
}
SELECT
cd.[DeliveryAddress] AS [DeliveryAddress]
FROM Customer T0
LEFT JOIN CustomerDetails cd ON (cd.[CustomerId] = T0.[Id])
349
FieldFlags Enumeration
FieldFlags Enumeration
[namespace: Serenity.Data.Mapping] - [assembly: Serenity.Data]
An ordinary table field has Insertable, Updatable and Trim flags set by default which
corresponds to Default combination flag.
Insertable Flag
Insertable flag controls if the field is editable in new record mode. By default, all ordinary
fields are considered to be insertable.
Some fields might not be insertable in database table, e.g. identity columns shouldn't have
this flags set.
When a field doesn't have this flag, it won't be editable in forms in new record mode. This is
also validated in services at repository level.
Sometimes, there might be internal fields that are perfectly valid in SQL INSERT statements,
but shouldn't be edited in forms. One example might be a InsertedByUserId which should be
set on service level, and not by end user. If we would let end user to edit it in forms, this
350
FieldFlags Enumeration
would be a security hole. Such fields shouldn't have Insertable flag set too.
This means field flags don't have to match database table settings.
Insertable Attribute
To turn off Insertable flag for a field, put a [Insertable(false)] attribute on it:
[Insertable(false)]
public string MyField
{
get { return Fields.MyField[this];
set { Fields.MyField[this] = value;
}
Non insertable fields are not hidden. They are just readonly. If you want to hide them,
use [HideOnInsert] attribute (Serenity 1.9.8+) or write something like
form.MyField.GetGridField().Toggle(IsNew) by overriding UpdateInterface method of
your dialog.
Updatable Flag
This flag is just like Insertable flag, but controls edit record mode in forms and update
operations in services. By default, all ordinary fields are considered to be updatable.
Updatable Attribute
To turn off Updatable flag for a field, put a [Updatable(false)] attribute on it:
[Updatable(false)]
public string MyField
{
get { return Fields.MyField[this];
set { Fields.MyField[this] = value;
}
351
FieldFlags Enumeration
Non updatable fields are not hidden in dialogs. They are just readonly. If you want to
hide them, use [HideOnUpdate] attribute (Serenity 1.9.8+) or write something like
form.MyField.GetGridField().Toggle(!IsNew) by overriding UpdateInterface method of
your dialog.
Trim Flag
This flag is only meaningful for string typed fields and controls whether their value should be
trimmed before save. All string fields have this flag on by default.
TrimToEmpty Flag
Use this flag if you prefer to trim string fields to empty string instead of null.
SetFieldFlags Attribute
This attribute can be used on fields to include or exclude a set of flags. It takes a first
required parameter to include flags, and a second optional parameter to exclude flags.
[SetFieldFlags(FieldFlags.TrimToEmpty)]
public string MyField
{
get { return Fields.MyField[this];
set { Fields.MyField[this] = value;
}
[SetFieldFlags(FieldFlags.None, FieldFlags.TrimToEmpty)]
public string MyField
{
get { return Fields.MyField[this];
set { Fields.MyField[this] = value;
}
352
FieldFlags Enumeration
[SetFieldFlags(
FieldFlags.Updatable | FieldFlags.TrimToEmpty,
FieldFlags.Insertable)]
public string MyField
{
get { return Fields.MyField[this];
set { Fields.MyField[this] = value;
}
NotNull Flag
Use this flag to set fields as not nullable. By default, this flag is set for fields that are not
nullable in database, using NotNull attribute.
When a field is not nullable, its corresponding label in forms has a red asterisk and they are
required to be entered.
NotNullable Attribute
This sets the NotNull atttribute on a field to ON. Remove attribute to turn it off.
You may also use [Required(false)] to make field not required in forms, even if it is not
nullable in database. This doesn't clear the NotNull flag.
Required Flag
This is a combination of Default and NotNullable flags.
Primary key fields are selected on Key column selection mode in List and Retrieve
request handlers.
353
FieldFlags Enumeration
Foreign Flag
This flag is set for foreign view fields, that are originating from other tables through a join.
It is automatically set for fields with expressions containing table aliases other than T0.
Calculated Flag
If a field has an expression involving more than one field or some mathematical operations,
it will have this flag.
This could also be set for fields that are calculated on SQL server side.
These kinds of fields can be used for temporary calculation, storage and transfer on client
and service layers.
Reflective Flag
354
FieldFlags Enumeration
This is used for an advanced form of unmapped fields, where they don't have a storage of
their own in row, but reflects value of another field in a different form. For example, a field
that displays absolute value of a integer field that can be negative.
This should only be used in rare cases for such unmapped fields.
DenyFiltering Flag
If set, denies filtering operations on a sensitive field. This can be useful for secret fields like
PasswordHash, that shouldn't be allowed to be selected or filtered by client side.
You can turn on this flag with Unique attribute and determine if this constraint should be
checked on service level (before the check in database level to avoid cryptic constraint
errors).
355
Fluent SQL
Fluent SQL
Serenity contains a set of query builders for SELECT, INSERT, UPDATE and DELETE
statements.
These builders can be used with simple strings or Serenity entity (row) system.
Their output can be executed directly, through a micro-orm like Dapper (which is integrated
with Serenity), or Serenity extensions.
356
SqlQuery Object
SqlQuery Object
[namespace: Serenity.Data] - [assembly: Serenity.Data]
SqlQuery is an object to compose dynamic SQL SELECT queries through a fluent interface.
Advantages
SqlQuery offers some advantages over hand crafted SQL:
Reduced syntax errors as the query is checked compile time, not execution time.
Clauses like Select, Where, Order By can be used in any order. They are placed at
correct positions when converting the query to string. Similary, such clauses can be
used more than once and they are merged during string conversion. So you can
conditionally build SQL depending on input parameters.
No need to mess up with parameters and parameter names. All values used are
converted to auto named parameters. You can also use manually named parameters if
required.
It can generate a special query to perform paging on server types that doesn't support it
natively (e.g. SQL Server 2000)
With the dialect system, query can be targeted at specific server type and version.
If it is used along with Serenity entities (it can also be used with micro ORMs like
Dapper), helps to load query results from a data reader with zero reflection. Also it does
left/right joins automatically.
357
SqlQuery Object
Another option is to locate and reference these DLLs directly from a Serene application's bin
or packages directory.
Make sure you add Serenity and Serenity.Data to Additional Namespace Imports in Query
Properties dialog.
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Surname
FROM People
ORDER BY Age
In the first line of our program, we called SqlQuery with its sole parameterless constructor. If
ToString() was called at this point, the output would be:
SELECT FROM
SqlQuery doesn't perform any syntax validation. It just converts the query you build yourself,
by calling its methods. Even if you don't select any fields or call from methods, it will
generate this basic SELECT FROM statement.
Next, we called Select method with string parameter "FirstName" . Our query is now like
this:
358
SqlQuery Object
After executing From and OrderBy methods, our final output is:
void Main()
{
var query = new SqlQuery();
query.From("People");
query.OrderBy("Age");
query.Select("Surname");
query.Select("Firstname");
Console.WriteLine(query.ToString());
}
...but, only the column ordering inside the SELECT statement would change:
SELECT
Surname,
Firstname
FROM People
ORDER BY Age
You might use methods like Select, From, OrderBy, GroupBy in any order, and can also mix
them (e.g. call Select, then OrderBy, then Select again...)
Putting FROM at start is recommended, especially when used with Serenity entities, as
it helps with auto joins and determining database dialect etc.
Method Chaining
359
SqlQuery Object
It is a bit verbose and not so readable to start every line query. . Almost all SqlQuery
methods are chainable, and returns the query itself as result.
void Main()
{
var query = new SqlQuery()
.From("People")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age");
Console.WriteLine(query.ToString());
}
void Main()
{
Console.WriteLine(
new SqlQuery()
.From("People")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age")
.ToString());
}
It is strongly recommended to put every method on its own line, and indent properly for
readability and consistency reasons.
Select Method
public SqlQuery Select(string expression)
In the samples we had so far, we used the overload of the Select method shown above (it
has about 11 overloads).
Expression parameter can be a simple field name or an expression like "FirstName + ' ' +
LastName"
360
SqlQuery Object
Whenever this method is called, the expression you set is added to the SELECT statement
of resulting query with a comma between.
For example:
void Main()
{
var query = new SqlQuery()
.From("People")
.SelectMany("Firstname", "Surname", "Age", "Gender")
.ToString();
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Surname,
Age,
Gender
FROM People
You might be wondering, why multiple selection is not just another Select overload. It's
because Select has a more commonly used overload to select a column with alias:
void Main()
{
var query = new SqlQuery()
.Select("(Firstname + ' ' + Surname)", "Fullname")
.From("People")
.ToString();
Console.WriteLine(query.ToString());
}
361
SqlQuery Object
SELECT
(Firstname + ' ' + Surname) AS [Fullname]
FROM People
From Method
public SqlQuery From(string table)
When you call it a second time, table name will be added to FROM statement with a comma
between. Thus, it will be a CROSS JOIN:
void Main()
{
var query = new SqlQuery()
.From("People")
.From("City")
.From("Country")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age");
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Surname
FROM People, City, Country
ORDER BY Age
362
SqlQuery Object
void Main()
{
var query = new SqlQuery()
.From("Person p")
.From("City c")
.From("Country o")
.Select("p.Firstname")
.Select("p.Surname")
.Select("c.Name", "CityName")
.Select("o.Name", "CountryName")
.OrderBy("p.Age")
.ToString();
Console.WriteLine(query.ToString());
}
SELECT
p.Firstname,
p.Surname,
c.Name AS [CityName],
o.Name AS [CountryName]
FROM Person p, City c, Country o
ORDER BY p.Age
Alias object is like a short name assigned to a table. It has an indexer and operator
overloads to generate SQL member access expressions like p.Surname .
void Main()
{
var p = new Alias("Person", "p");
Console.WriteLine(p + "Surname"); // + operator overload
Console.WriteLine(p["Firstname"]); // through indexer
}
p.Surname
p.Firstname
Unfortunately C# member access operator (.) can't be overridden, so we had to use (+).
A workaround could be possible with dynamic, but it would perform poorly.
363
SqlQuery Object
void Main()
{
var p = new Alias("Person", "p");
var c = new Alias("City", "c");
var o = new Alias("Country", "o");
Console.WriteLine(query.ToString());
}
SELECT
p.Firstname,
p.Surname,
c.Name AS [CityName],
o.Name AS [CountryName]
FROM Person p, City c, Country o
ORDER BY p.Age
As seen above, result is the same, but the code we wrote is a bit longer. So what is the
advantage of using an alias?
364
SqlQuery Object
void Main()
{
const string Firstname = "Firstname";
const string Surname = "Surname";
const string Name = "Name";
const string Age = "Age";
Console.WriteLine(query.ToString());
}
we would take advantage of intellisense feature and have some more compile time
checks.
Obviously, it is not logical and easy to define field names for every query. This should be in a
central location, or our entity declarations.
365
SqlQuery Object
void Main()
{
var p = new PeopleAlias("p");
var c = new CityAlias("c");
var o = new CountryAlias("o");
var query = new SqlQuery()
.From(p)
.From(c)
.From(o)
.Select(p.Firstname)
.Select(p.Surname)
.Select(c.Name, "CityName")
.Select(o.Name, "CountryName")
.OrderBy(p.Age)
.ToString();
Console.WriteLine(query.ToString());
}
Now we have a set of table alias classes with field names and they can be reused in all
queries.
366
SqlQuery Object
This is just a sample to explain aliases. I don't recommend writing such classes. Entities
offers much more.
When this method is called, table name and its aliased name is added to FROM statement
of query.
OrderBy Method
public SqlQuery OrderBy(string expression, bool desc = false)
OrderBy can also be called with a field name or expression like Select.
If you assign desc optional argument as true, DESC keyword is appended to the field name
or expression.
For example, you might have a query with some predefined order, but if user orders by a
column in a grid, name of the column should be inserted at index 0.
void Main()
{
var query = new SqlQuery()
.Select("Firstname")
.Select("Surname")
.From("Person")
.OrderBy("PersonID");
query.OrderByFirst("Age");
Console.WriteLine(query.ToString());
}
367
SqlQuery Object
SELECT
Firstname,
Surname
FROM Person
ORDER BY Age, PersonID
Distinct Method
public SqlQuery Distinct(bool distinct)
void Main()
{
var query = new SqlQuery()
.Select("Firstname")
.Select("Surname")
.From("Person")
.OrderBy("PersonID")
.Distinct(true);
Console.WriteLine(query.ToString());
}
SELECT DISTINCT
Firstname,
Surname
FROM Person
ORDER BY PersonID
GroupBy Method
public SqlQuery GroupBy(string expression)
368
SqlQuery Object
SELECT
Firstname,
Lastname,
Count(*)
FROM Person
GROUP BY Firstname, LastName
SELECT
Firstname,
Lastname,
Count(*)
FROM Person
GROUP BY Firstname, LastName
Having Method
public SqlQuery Having(string expression)
Having can be used with GroupBy (though it doesn't check for GroupBy) and appends
expression to the end of HAVING statement.
void Main()
{
var query = new SqlQuery()
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.GroupBy("Firstname")
.GroupBy("LastName")
.Having("Count(*) > 5");
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Lastname,
Count(*)
FROM Person
GROUP BY Firstname, LastName
HAVING Count(*) > 5
369
SqlQuery Object
As SqlServer versions before 2012 doesn't have a SKIP equivalent, to use SKIP your
query should have at least one ORDER BY statement as ROW_NUMBER() will be
used. This is not required if you are using SqlServer 2012+ dialect.
void Main()
{
var query = new SqlQuery()
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.OrderBy("PersonId")
.Skip(100)
.Take(50);
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Lastname,
Count(*)
FROM Person
ORDER BY PersonId OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY
With Dialect method, we can change the server type that SqlQuery targets:
370
SqlQuery Object
FirebirdDialect
PostgresDialect
SqliteDialect
SqlServer2000Dialect
SqlServer2005Dialect
SqlServer2012Dialect
void Main()
{
var query = new SqlQuery()
.Dialect(SqlServer2005Dialect.Instance)
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.OrderBy("PersonId")
.Skip(100)
.Take(50);
Console.WriteLine(query.ToString());
}
SELECT * FROM (
SELECT TOP 150
Firstname,
Lastname,
Count(*), ROW_NUMBER() OVER (ORDER BY PersonId) AS __num__
FROM Person) __results__ WHERE __num__ > 100
SELECT
Firstname,
Lastname,
Count(*)
FROM Person
ORDER BY PersonId LIMIT 50 OFFSET 100
371
SqlQuery Object
If you are using only one type of database server with your application, you may avoid
having to choose a dialect every time you start a query by setting the default dialect:
SqlSettings.DefaultDialect = SqliteDialect.Instance;
372
Criteria Objects
Criteria Objects
When you are creating dynamic SQL for SELECT, UPDATE or DELETE, you might have to
write complex WHERE statements.
Building these statements using string concentanation is possible but it is tedious to avoid
syntax errors and opens your code to SQL injection attacks.
Using parameters might solve SQL injection problems but it involves too much manual work
to add parameters.
Luckily, Serenity has a criteria system that helps you build parameterized queries with a
syntax similar LINQ expression trees.
Serenity criterias are implemented by utilitizing operator overloading features of C#, unlike
LINQ which uses expression trees.
new SqlQuery()
.From("MyTable")
.Select("Name")
.Where("Month > 5 AND Year < 2015 AND Name LIKE N'%a%'")
new SqlQuery()
.From("MyTable")
.Select("Name")
.Where(
new Criteria("Month") > 5 &
new Criteria("Year") < 4 &
new Criteria("Name").Contains("a")
SELECT
Name
FROM
MyTable
WHERE
Month > @p1 AND
Year < @p2 AND
Name LIKE N'%a%'
373
Criteria Objects
var m = MyTableRow.Fields;
new SqlQuery()
.From(m)
.Select(m.Name)
.Where(
m.Month > 5 &
m.Year < 4 &
m.Name.Contains("a")
Here we didn't have to use new Criteria() because field objects also has operator
overloads that builds criteria.
BaseCriteria Object
BaseCriteria is the base class for all types of criteria objects.
It has overloads for several C# operators, including > , < , & , | that can be used to
build complex criteria using C# expressions.
BaseCriteria doesn't have a constructor of itself so you need to create one of the objects that
derive from it. Criteria is the most common one that you might use.
Criteria Object
Criteria is a simple object that contains an SQL expression as a string, which is usually a
field name.
new Criteria("MyField")
It can also contain an SQL expression (though not recommended this way)
This parameter is not syntax checked, so it is possible to build a criteria with invalid
expression:
374
Criteria Objects
Please notice that we are not using shortcircuit && operator here.
This creates a new criteria object (BinaryCriteria) with operator (AND) and reference to
these two criterias. It doesn't modify original criteria objects.
BaseCriteria is the base class for all criteria object types. If we used Criteria c = ... in
the first line, we would have a compile time error on second line as & operator returns a
BinaryCriteria object, which is not assignable to a Criteria object.
OR (|) Operator
This is similar to AND operator, though it uses OR.
375
Criteria Objects
When you are using several AND/OR statements, you might want to put paranthesis.
But this won't work with criteria objects, as output of above criteria would be:
Information here applies to Serenity versions before 1.9.8. After this version Serenity
puts paranthesis around all binary criteria (AND OR etc) even if you don't use
paranthesis.
Still:
C# doesn't provide a way to overload paranthesis, it just uses them to determine calculation
order, so Serenity criteria has no idea if you used them with paranthesis or not.
As Serenity 1.9.8+ auto paranthesis binary criteria, above expression would actually be:
376
Criteria Objects
Field1 == 1 AND
Field2 <> 2 AND
Field3 > 3 AND
Field4 >= 4 AND
Field5 < 5 AND
Field6 <= 6
Inline Values
When one side of a comparison operator is a criteria and other side is an integer, string,
date, guid etc. value, it is converted a parameter criteria.
These parameters has corresponding values, when a query containing this criteria is sent to
SQL.
Automatic parameter numbering starts from 1 by default, but last number is stored in the
query the criteria is used with, so numbers might change.
377
Criteria Objects
new SqlQuery()
.From("MyTable")
.Select("Field999")
.Where(new Criteria("FirstOne") >= 999)
.Where(new Criteria("SecondOne") >= 999)
.Where(
new Criteria("Field1") == 1 &
new Criteria("Field2") != "ABC" &
new Criteria("Field3") > DateTime.Now &
new Criteria("Field4") >= Guid.NewGuid() &
new Criteria("Field5") < 5L
)
SELECT
Field999
FROM
MyTable
WHERE
FirstOne >= @p1 AND -- @p1 = 999
SecondOne >= @p2 AND -- @p2 = 999
Field1 == @p3 AND -- @p3 = 1
Field2 <> @p4 AND -- @p4 = N'ABC'
Field3 > @p5 AND -- @p5 = '2016-01-31T01:16:23'
Field4 >= @p6 AND -- @p6 = '23123-DEFCD-....'
Field5 < @p7 -- @p7 = 5
Here the same criteria that listed before, used parameter numbers starting from 3, instead of
1. Because prior 2 numbers where used for other WHERE statements coming before it.
So parameter numbering uses the query as context. You shouldn't make assumptions about
what parameter name will be.
new SqlQuery()
.From("SomeTable")
.Select("SomeField")
.Where(new Criteria("SomeField") <= new ParamCriteria("@myparam"))
.Where(new Criteria("SomeOtherField") == new ParamCriteria("@myparam"))
.SetParam("@myparam", 5);
378
Criteria Objects
new SqlQuery()
.From("SomeTable")
.Select("SomeField")
.Where(new Criteria("SomeField") <= myParam)
.Where(new Criteria("SomeOtherField") == myParam)
.SetParam(myParam.Name, 5);
ConstantCriteria
If you don't want to use parameterized queries, you may put your values as ConstantCriteria
objects. They will not be converted to auto parameters.
new SqlQuery()
.From("MyTable")
.Select("MyField")
.Where(
new Criteria("Field1") == new ConstantCriteria(1) &
new Criteria("Field2") != new ConstantCriteria("ABC")
)
SELECT
MyField
FROM
MyTable
WHERE
FirstOne >= 1
SecondOne >= N'ABC'
Null Comparison
In SQL, comparing against NULL values using operators like == , != returns NULL. You
should use IS NULL or IS NOT NULL for such comparisons.
Criteria objects don't overload comparisons against null (or object), so you may get errors if
you try to write expressions like below:
379
Criteria Objects
int b? = null;
new Criteria("c") == b; // no overload for nullable types
new Criteria("a").IsNull();
new Criteria("a").IsNotNull();
int? b = 5;
new Criteria("c") == b.Value;
LIKE Operators
Criteria has methods Like, NotLike, StartsWith, EndsWith, Contains, NotContains to help
with LIKE operations.
new Criteria("A").In(1, 2, 3, 4, 5)
380
Criteria Objects
new Criteria("A").NotIn(1, 2, 3, 4, 5)
A IN (1, 3, 5, 7, 9)
-- @p1 = 1, @p2 = 3, @p3 = 5, @p4 = 7, @p5 = 9
query.Where("SomeID").In(
query.SubQuery()
.From("SomeTable")
.Select("SomeID")
.Where(new Criteria("Balance") < 0));
SELECT
MyField
FROM
MyTable
WHERE
SomeID IN (
SELECT
SomeID
FROM
SomeTable
WHERE
Balance < @p1 -- @p1 = 0
)
381
Criteria Objects
NOT Operator
Use C# ! (not) operator to use NOT:
For example, using Order, Detail and Customer rows from Northwind sample:
var o = OrderRow.Fields.As("o");
var od = OrderDetailRow.Fields.As("od");
var c = CustomerRow.Fields.As("c");
var query = new SqlQuery()
.From(o)
.Select(o.CustomerID);
query.Where(
o.CustomerCountry == "France" &
o.ShippingState == 1 &
o.CustomerID.In(
query.SubQuery()
.From(c)
.Select(c.CustomerID)
.Where(c.Region == "North")) &
new Criteria(
query.SubQuery()
.From(od)
.Select(Sql.Sum(od.LineTotal.Expression))
.Where(od.OrderID == o.OrderID)) >= 1000);
382
Criteria Objects
SELECT
o.CustomerID AS [CustomerID]
FROM
Orders o
LEFT JOIN
Customers o_c ON (o_c.CustomerID = o.CustomerID)
WHERE
o_c.[Country] = @p2
AND (CASE WHEN
o.[ShippedDate] IS NULL THEN 0
ELSE 1
END) = @p3
AND o.CustomerID IN (
SELECT
c.CustomerID AS [CustomerID]
FROM
Customers c
WHERE
c.Region = @p1)
AND (SELECT
SUM((od.[UnitPrice] * od.[Quantity] - od.[Discount]))
FROM
[Order Details] od
WHERE
od.OrderID = o.OrderID) >= @p4
383
Connections and Transactions
It provides some basic helpers to create a connection, add parameters, execute queries etc.
SqlConnections Class
[namespace: Serenity.Data, assembly: Serenity.Data]
This class contains static functions to create a connection, and control it in a database
agnostic way.
SqlConnections.NewByKey method
Use this method to get a new IDbConnection for a connection string defined in application
configuration file (e.g. app.config or web.config).
This reads connection string with "Default" key from web.config, and creates a new
connection using ProviderName information that is also specified in connection setting. For
example, if ProviderName is "System.Data.SqlClient" this creates a new SqlConnection
object.
You usually don't have to open connections explicitly as they are automatically opened
when needed (as long as you use Serenity extensions).
384
Connections and Transactions
Looking on top of a Row class, you may spot ConnectionKey attribute generated by Sergen:
[ConnectionKey("Northwind")]
public sealed class CustomerRow : Row, IIdRow, INameRow
{
}
When you are going to query for customers, instead of hardcoding "Northwind", you may
reuse this information from a CustomerRow:
Here we didn't have to open the connection, as List extension method opens it
automatically.
The class used with this method doesn't have to be a Row, any class with a ConnectionKey
attribute would work, even though it would be a row most of the time.
SqlConnections.New method
You may sometimes want to create a connection that doesn't exist in your configuration file.
Here we have to specify connection string and the provider name like
"System.Data.SqlClient".
You might be asking yourself "why this method instead of simply typing new SqlClient()?",
see next topic for advantages of these.
WrappedConnection
385
Connections and Transactions
The IDbConnection object you receive is a Serenity specific WrappedConnection object that
actually contains an underlying SqlConnection or FirebirdConnection etc.
This helps Serenity provide some features like auto open, dialect support, default
transactions, unit of work pattern, overriding connections for testability etc.
You may not notice these details while working with returned IDbConnection instances,
they'll act just like the underlying connections, but you should prefer SqlConnections
methods to create connections, otherwise you might lose some of these listed features.
Let's say we are creating tasks, and some e-mails should be sent in case these tasks are
saved to database succesfully.
If we hurry and send these e-mails before transaction is committed, we might end up with e-
mails that are sent for non-existent tasks in case transaction fails. So we should only send e-
mail if transaction is committed successfully, e.g. in OnCommit event.
You might say then Commit transaction first and send e-mails right after, but what if our
Create Task service call is just a step of a larger operation, so we are not controlling the
transaction and it should be committed after all steps are success.
Another scenario is about uploading files. This time we are updating some File entity, and
let's say we replace an old file with uploaded new file. If we again hurry and delete old file
before transaction outcome is clear, and transaction fails eventually, we'll end up with a file
entity without an actual old file in disk. So, we should actually delete file and replace it with
the new file in OnCommit event, and remove uploaded file in OnRollback event.
386
Connections and Transactions
void SomeBatchOperation()
{
using (var connection = SqlConnections.NewByKey("Default"))
using (var uow = new UnitOfWork(connection))
{
// here we are in a transaction context
// create several tasks in transaction
CreateATask(new TaskRow { ... });
CreateATask(new TaskRow { ... });
//...
uow.OnCommit += () => {
// send e-mail for this task now, this method will only
// be called if transaction commits successfully
};
uow.OnRollback += () => {
// optional, do something else if it fails
};
}
387
Working with Other Databases
If you need to support multiple database types, just by changing connection strings in
web.config, you should be careful about not using database specific functions in expressions
and avoid using reserved words.
CONCAT is available after Sql Server 2012. So if you are going to use an older version of
SQL server, e.g. 2005 or 2008, replace these expressions with such:
388
Setting Connection Dialect
Sometimes, automatic dialect detection using providerName may not work you, or you might
want to use SqlServer2000 or SqlServer2005 dialect for some connections.
Even though it is possible to set a default global dialect, this doesn't override automatic
detection):
SqlSettings.DefaultDialect = SqlServer2005Dialect.Instance;
SqlConnections.GetConnectionString("Northwind").Dialect =
SqlServer2005Dialect.Instance;
<configuration>
<appSettings>
<add key="ConnectionSettings" value="{
Default: {
Dialect: 'SqlServer2005'
},
Northwind: {
Dialect: 'Postgres'
}
"/>
389
Setting Connection Dialect
390
Dialect Based Expressions
[DisplayName("FullName"), QuickSearch]
[Expression("CONCAT(T0.[FirstName], CONCAT(' ', T0.[LastName]))")]
[Expression("(T0.FirstName || ' ' || T0.LastName)", Dialect = "Sqlite")]
public String FullName
{
get { return Fields.FullName[this]; }
set { Fields.FullName[this] = value; }
}
Here, as the first Expression has no dialect, it will be used for any database type, unless the
connection corresponding to this row has dialect of Sqlite, e.g. it is a System.Data.Sqlite
connection.
Expression for a field is determined (fixed) at application start, so it is not possible to switch
expressions by switching connections or dialects.
[DisplayName("FullName"), QuickSearch]
[Expression("CONCAT(T0.[FirstName], CONCAT(' ', T0.[LastName]))")]
[Expression("T0.[FirstName] + ' ' + T0.[LastName]",
Dialect = "SqlServer2000,SqlServer2005")]
[Expression("(T0.FirstName || ' ' || T0.LastName)",
Dialect = "Sqlite,MySql,Postgres")]
public String FullName
{
get { return Fields.FullName[this]; }
set { Fields.FullName[this] = value; }
}
Dialect Matching
391
Dialect Based Expressions
For an expression dialect to match a connection dialect, it should start with the ServerType
and/or the class name of the connection dialect (e.g. SqlServer2012Dialect).
If multiple dialect types match a targeted expression, the one with the longest name
matches.
392
PostgreSQL
PostgreSQL
Registering Npgsql Provider
PostgreSQL has a .NET provider named Npgsql. You need to first install it in
MyProject.Web:
If you didn't install this provider in GAC/machine.config before, or don't want to install it
there, you need to register it in web.config file:
<configuration>
// ...
<system.data>
<DbProviderFactories>
<remove invariant="Npgsql"/>
<add name="Npgsql Data Provider"
invariant="Npgsql"
description=".Net Data Provider for PostgreSQL"
type="Npgsql.NpgsqlFactory, Npgsql, Culture=neutral,
PublicKeyToken=5d8b90d52f46fda7"
support="FF" />
</DbProviderFactories>
</system.data>
// ...
Make sure you replace connection string parameters with values for your server
<connectionStrings>
<add name="Default" connectionString="
Server=127.0.0.1;Database=serene_default_v1;
User Id=postgres;Password=yourpassword;"
providerName="Npgsql" />
393
PostgreSQL
Please use lowercase database names like serene_default_v1 as Postgres will always
convert it to lowercase.
FluentMigrator automatically quotes all identifiers, so tables and column names in database
will be quoted and case sensitive. This might cause problems when tables/columns are tried
to be selected without quoted identifiers.
One option is to always use lowercase identifiers in migrations, but such naming scheme
won't look so nice for other database types, thus we didn't prefer this way.
To prevent such problems with Postgres, Serenity has an automatic quoting feature, to
resolve compability with Postgres/FluentMigrator, which should be enabled in application
start method of SiteInitialization.cs:
This setting automatically quotes column names in entities, but not manually written
expressions (with Expression attribute for example).
Use brackets [] for identifiers in expressions if you want to support multiple database
types. Serenity will automatically convert brackets to database specific quote type before
running queries.
You might also prefer to use double quotes in expressions, but it might not be compatible
with other databases like MySQL.
394
PostgreSQL
It can even work with multiple database types at the same time.
For example, Northwind might stay in Sql Server, while Default database uses PostgreSQL.
But, if you are going to use only one database type per site, you can register which you are
going to use by default in SiteInitialization:
Default dialect is used when the dialect for a connection / entity etc. couldn't be auto
determined.
Launching Application
Now launch your application, it should automatically create databases, if they are not
created manually before.
Sergen.exe is an exe file, so you can't add a NuGet reference to it. We need to register this
provider in application config file.
It is also possible to register the provider in GAC/machine.config and skip this step
completely.
395
PostgreSQL
Also copy Npgsql.dll to same folder where Sergen.exe resides. Now Sergen will be able to
generate code for your Postgres tables.
You might want to remove [public]. prefix for default schema from tablename/column
expressions in generated rows if you want to be able to work with multiple databases.
396
MySQL
MySql
.NET Framework
Registering MySql Provider
MySQL has a .NET provider named MySql.Data. You need to first install it in MyProject.Web:
If you didn't install this provider in GAC/machine.config before, or don't want to install it
there, you need to register it in web.config file (MySql.Data NuGet package already does this
on install):
<configuration>
// ...
<system.data>
<DbProviderFactories>
<remove invariant="MySql.Data.MySqlClient"/>
<add name="MySQL Data Provider"
invariant="MySql.Data.MySqlClient"
description=".Net Framework Data Provider for MySQL"
type="MySql.Data.MySqlClient.MySqlClientFactory,
MySql.Data, Culture=neutral,
PublicKeyToken=c5687fc88969c44d" />
</DbProviderFactories>
</system.data>
// ...
Make sure you replace connection string parameters with values for your server
397
MySQL
<connectionStrings>
<add name="Default" connectionString="
Server=localhost; Port=3306; Database=Serene_Default_v1;
Uid=root; Pwd=yourpass"
providerName="MySql.Data.MySqlClient" />
MySql uses lowercase database (schema) and table names, but doesn't have the case
sensitivity problem we talked about Postgres.
Sergen.exe is an exe file, so you can't add a NuGet reference to it. We need to register this
provider in application config file.
It is also possible to register the provider in GAC/machine.config and skip this step
completely.
398
MySQL
Also copy MySql.Data.dll to same folder where Sergen.exe resides. Now Sergen will be able
to generate code for your MySql tables.
.NET Core
Registering MySql Provider
MySQL has a .NET provider named MySql.Data. You need to first install it in
MyProject.AspNetCore:
{
"dependencies": {
// ...
"Serenity.FluentMigrator.Runner": "1.6.903",
"MySql.Data": "7.0.6-IR31"
},
399
MySQL
DbProviderFactories.RegisterFactory(
"System.Data.SqlClient", SqlClientFactory.Instance);
DbProviderFactories.RegisterFactory(
"MySql.Data.MySqlClient",
MySql.Data.MySqlClient.MySqlClientFactory.Instance);
400
Sqlite
Sqlite
Registering Sqlite Provider
MySQL has a .NET provider named System.Data.Sqlite. You need to first install it in
MyProject.Web:
If you didn't install this provider in GAC/machine.config before, or don't want to install it
there, you need to register it in web.config file:
<configuration>
// ...
<system.data>
<DbProviderFactories>
<remove invariant="System.Data.SQLite"/>
<add name="SQLite Data Provider"
invariant="System.Data.SQLite"
description=".Net Framework Data Provider for SQLite"
type="System.Data.SQLite.SQLiteFactory, System.Data.SQLite"/>
</DbProviderFactories>
</system.data>
// ...
<connectionStrings>
<add name="Default" connectionString=
"Data Source=|DataDirectory|Serene_Default_v1.sqlite;"
providerName="System.Data.Sqlite" />
<add name="Northwind" connectionString=
"Data Source=|DataDirectory|Serene_Northwind_v1.sqlite;"
providerName="System.Data.Sqlite" />
</connectionStrings>
401
Sqlite
I'm not sure why, but while FluentMigrator creates Northwind database for Sqlite first
time, it takes some time.
Sergen.exe is an exe file, so you can't add a NuGet reference to it. We need to register this
provider in application config file.
It is also possible to register the provider in GAC/machine.config and skip this step
completely.
Also copy System.Data.Sqlite.dll and its x86 and x64 folders under bin directory to same
folder where Sergen.exe resides. Now Sergen will be able to generate code for your MySql
tables.
402
Oracle
Oracle
Oracle support is available for Serene 2.2.2+
If you didn't install this provider in GAC/machine.config before, or don't want to install it
there, you need to register it in web.config file (Oracle.ManagedDataAccess NuGet package
already does this on install):
<configuration>
// ...
<system.data>
<DbProviderFactories>
<remove invariant="Oracle.ManagedDataAccess.Client"/>
<add name="ODP.NET, Managed Driver"
invariant="Oracle.ManagedDataAccess.Client"
description="Oracle Data Provider for .NET, Managed Driver"
type="Oracle.ManagedDataAccess.Client.OracleClientFactory,
Oracle.ManagedDataAccess, Version=4.121.2.0, Culture=neutral,
PublicKeyToken=89b483f429c47342"/>
</DbProviderFactories>
</system.data>
// ...
Creating Databases
Serene can't autocreate database (tablespace) for Oracle. You might create them yourself,
or use a script like below (i used this for XE):
403
Oracle
<configuration>
<oracle.manageddataaccess.client>
<version number="*">
<dataSources>
<dataSource alias="XE"
descriptor="
(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)
(HOST=localhost)(PORT=1521))
(CONNECT_DATA=(SERVICE_NAME=XE))) "/>
</dataSources>
</version>
</oracle.manageddataaccess.client>
</configuration>
Next step is to replace connection strings for databases you want to use with Oracle:
404
Oracle
Make sure you replace connection string parameters with values for your server
<connectionStrings>
<add name="Default" connectionString="
Data Source=XE;User Id=Serene_Default_v1;Password=somepassword;"
providerName="Oracle.ManagedDataAccess.Client"/>
<add name="Northwind" connectionString="
Data Source=XE;User Id=Serene_Northwind_v1;Password=somepassword;"
providerName="Oracle.ManagedDataAccess.Client"/>
</connectionStrings>
405
Services
406
Service Endpoints
Service Endpoints
In Serenity, Service Endpoints are a subclass of ASP.NET MVC controllers.
namespace Serene.Northwind.Endpoints
{
[RoutePrefix("Services/Northwind/Order"), Route("{action}")]
[ConnectionKey("Northwind"), ServiceAuthorize(Northwind.PermissionKeys.General)]
public class OrderController : ServiceEndpoint
{
[HttpPost]
public SaveResponse Create(IUnitOfWork uow, SaveRequest<MyRow> request)
{
return new MyRepository().Create(uow, request);
}
If you don't end your controller class name with this suffix, your actions will simply won't
work. So be very careful with this.
Our OrderController derives from ServiceEndpoint (and should), which provides this MVC
controller with not so usual features that we'll see shortly.
Routing Attributes
407
Service Endpoints
[RoutePrefix("Services/Northwind/Order"), Route("{action}")]
Routing attributes above, which belongs to ASP.NET attribute routing, configures base
address for our service endpoint. Our actions will be available under
"mysite.com/Services/Northwind/Order".
Please avoid classic ASP.NET MVC routing, where you configured all routes in
ApplicationStart method with routes.AddRoute etc. It was really hard to manage.
ConnectionKey Attribute
This attribute specificies which connection key in your application configuration file (e.g.
web.config) should be used to create a connection when needed.
Let's see when and how this auto created connection is used:
Here we see that this action takes a IDbConnection parameter. You can't send a
IDbConnection to an MVC action from client side. So who creates this connection?
You'd be able to remove this connection parameter from the action and create it manually:
408
Service Endpoints
Why not use this feature while platform handles this detail automatically? One reason would
be when you need to open a custom connection that is not listed in the config file, or open a
dynamic one depending on some conditions.
Here situation is similar. ServiceEndpoint again creates a connection, but this time starts a
transaction on it (IUnitOfWork), calls our action method, and when it returns will commit
transaction automatically. Again, if it fails, would rollback it.
If your service method takes a IUnitOfWork parameter, this is a signature that your method
might modify some data.
409
Service Endpoints
You may have noticed that Create, Update, Delete etc methods has this attribute while List,
Retrieve etc. not.
This attribute limits Create, Update, Delete actions to HTTP POST only. It doesn't allow them
to be called by HTTP GET.
This is because, these methods are ones that modify some state, e.g. insert, update, delete
some records from DB, so they shouldn't be allowed to be called unintentionally, and their
results shouldn't be allowed to be cached.
This also has some security implications. Actions with GET method might be subject to
some attacks.
List, Retrieve doesn't modify anything, so they are allowed to be called with GET, e.g. typing
in a browser address bar.
Even though, List, Retrieve can be called by GET, Serenity always calls services using
HTTP POST when you use its methods, e.g. Q.CallService, and will turn of caching to avoid
unexpected results.
ServiceAuthorize Attribute
Our controller class has ServiceAuthorize attribute:
ServiceAuthorize(Northwind.PermissionKeys.General)
This attribute is similar to ASP.NET MVC [Authorize] attribute but it checks only that user is
logged in, and throws an exception otherwise.
If used with no parameters, e.g. [ServiceAuthorize()] this attribute also checks that user is
logged in.
When you pass it a permission key string, it will check that user is logged in and also has
that permission.
ServiceAuthorize("SomePermission")
If user is not granted "SomePermission", this will prevent him from executing any endpoint
method.
There is also [PageAuthorize] attribute that works similar, but you should prefer
[ServiceAuthorize] with service endpoints, because its error handling is more suitable for
services.
410
Service Endpoints
While [PageAuthorize] redirects user to the Login page, if user doesn't have the permission,
ServiceAuthorize returns a more suitable NotAuthorized service error.
[ServiceAuthorize("SomePermissionThatIsRequiredForCreate")]
public SaveResponse Create(SaveRequest<MyRow> request)
Let's start with the result. If you have some background on ASP.NET MVC, you'd know that
controllers can't return arbitrary objects. They must return objects that derive from
ActionResult.
But our SaveResponse derives from ServiceResponse which is just an ordinary object:
How this is possible? Again ServiceEndpoint handles this detail behind the scenes. It
transforms our SaveResponse to a special action result that returns JSON data.
We don't have to worry about this detail as long as our response object derives from
ServiceResponse and is JSON serializable.
Again, our request object is also an ordinary class that derives from a basic ServiceRequest:
411
Service Endpoints
ServiceEndpoint takes the HTTP request content which is usually JSON, deserializes it into
our request parameter, using a special MVC action filter (JsonFilter).
If you want to use some custom actions, your methods should also follow this philosophy,
e.g. take just one request (deriving from ServiceRequest) and return one response (deriving
from ServiceResponse).
Let's add a service method that returns count of all orders greater than some amount:
Please follow this pattern and try not to add more parameters to action methods. Serenity
follows message based pattern, with only one request object, that can be extended later by
adding more properties.
412
Service Endpoints
This will avoid having to remember parameter order, will make your request objects
extensible without breaking backwards compability, and have many more advantages that
you may notice later.
Remember that ServiceEndpoint has a direct dependency to ASP.NET MVC. This means
that any code you write inside a service endpoint will have a dependency to ASP.NET MVC,
and thus web environment.
413
Service Endpoints
You may not be able to reuse any code you wrote here, from let's say a desktop application,
or won't be able to isolate this code into a DLL that doesn't have a reference to WEB
libraries.
But if you really don't have such a requirement, you can remove repositories all together and
write all your code inside the endpoint.
Some people might argue that entities, repositories, business rules, endpoints etc. should all
be in their own isolated assemblies. In theory, and for some scenarios this might be valid,
but some (or most) users don't need so much isolation, and may fall into YAGNI (you aren't
gonna need it) category.
414
List Request Handler
ListRequestHandler
This is the base class that handles List requests originating from client side, e.g. from grids.
Let's first sample when and how this class handles list requests:
1. First a list request must be triggered from client side. Possible options are:
a) You open a list page that contains a grid. Right after your grid object is created it
builds up a ListRequest object, based on currently visible columns, initial sort order,
filters etc. and submits it to server side.
b) User clicks on a column header to sort, clicks paging buttons or refresh button to
trigger same events in option A.
We'll cover how grids build and submit a list request in another chapter. Let's focus on
ListRequestHandler for now.
415
List Request Handler
There is one little difference about Take. If you Take(0), LINQ will return you zero records,
while Serenity will return ALL records. There is no point in calling a LIST service and
requesting 0 records.
So, SKIP and TAKE has default values of 0, and they are simply ignored when 0 / undefined.
If you have a grid that has page size 50 and switch to page number 4, SKIP will be 200 while
TAKE is 50.
// returns customers between row numbers 201 and 250 (in some default order)
CustomerService.List(new ListRequest
{
Skip = 200,
Take = 50
}, response => {});
These parameters are converted to relevant SQL paging statements based on SQL dialect.
416
List Request Handler
ListRequest.Sort Parameter
This parameter takes an array to sort results on. Sorting is performed by generating SQL.
[JsonConverter(typeof(JsonSortByConverter))]
public class SortBy
{
public SortBy()
{
}
When calling a List method of XYZRepository server side to sort by Country then City
descending, you might do it like this:
SortBy class has a custom JsonConverter so when building a list request client side, you
should use a simple string array:
417
List Request Handler
This is because ListRequest class definition at client side has a bit different structure:
Column names used here should be Property names of corresponding fields. Expressions
are not accepted. E.g. this won't work!:
It is possible to define some specific field list to perform searches on grid client side, by
overriding GetQuickSearchField() methods. So when such a field is selected in quick search
input, search is only performed on that column.
If you set ContainsField to a field name that doesn't have QuickSearch attribute on it, system
will raise an exception. This is for security purposes.
418
List Request Handler
ListRequest.EqualityFilter Parameter
EqualityFilter is a dictionary that allows quick equality filtering by some fields. It is used by
quick filter dropdowns on grids (ones that are defined with AddEqualityFilter helper).
Again, you should use property names as equality field keys, not expressions. Serenity
doesn't allow any arbitrary SQL expressions from client side, to prevent SQL injections.
Please note that null and empty string values are simply ignored, similar to ContainsText, so
it's not possible to filter for empty or null values with EqualityFilter. Such a request would
return all records:
Use Criteria parameter if you intent to filter customers with empty countries.
ListRequest.Criteria
419
List Request Handler
This parameter accepts criteria objects similar to server side Criteria objects we talked about
in Fluent SQL chapter. Only difference is, as these criteria objects are sent from client side,
they have to be validated and can't contain any arbitrary SQL expressions.
Service request below will only return customers with empty country or null city values
You could set Criteria parameter of generated ListRequest that is about to be submitted in
your XYZGrid.cs like below:
request.Criteria &=
new Criteria(ProductRow.Fields.UnitsInStock) > 10 &
new Criteria(ProductRow.Fields.CategoryName) != "Condiments" &
new Criteria(ProductRow.Fields.Discontinued) == 0;
return true;
}
You could also set other parameters of ListRequest in your grids similarly.
ListRequest.IncludeDeleted
This parameter is only useful with rows that implements IIsActiveDeletedRow interface. If
row has such an interface, list handler by default only returns rows that are not deleted
(IsActive != -1). It is a way to not delete rows actually but mark them as deleted.
If this parameter is True, list handler will return all rows without looking at IsActive column.
420
List Request Handler
Some grids for such rows have a little eraser icon on top right to toggle this flag, thus
show deleted records or hide them (default).
ListRequest.ColumnSelection Parameter
Serenity tries hard to load only required columns of your entities from SQL server to limit
network traffic to minimum between SQL Server < - > WEB Server and thus keep data size
transferred to client as low as possible.
ListRequest has a ColumnSelection parameter for you to control the set of columns loaded
from SQL.
By default grid requests records from List service in "ColumnSelection.List" mode (can be
changed). Thus, its list request looks like this:
new ListRequest
{
ColumnSelection = ColumnSelection.List
}
One exception is expression fields that only contains reference to table fields, e.g.
(t0.FirstName + ' ' + t0.LastName). ListRequestHandler also loads such fields.
ColumnSelection.Details includes all fields, including view ones, unless a field is explicitly
excluded or marked as "sensitive", e.g. a password field.
Dialogs loads edited records in Details mode, thus they also include view fields.
ListRequest.IncludeColumns Parameter
421
List Request Handler
We told that grid requests records in List mode, so loads only table fields, then how it can
show columns that originate from other tables?
Grid sends list of visible columns to List service with IncludeColumns, so these columns are
included in selection even if they are view fields.
In memory grids can't do this. As they don't call services directly, you have to put
[MinSelectLevel(SelectLevel.List)] to view fields that you wan't to load for in memory
detail grids.
If you have a ProductGrid that shows SupplierName column its actual ListRequest looks like
this:
new ListRequest
{
ColumnSelection = ColumnSelection.List,
IncludeColumns = new List<string> {
"ProductID",
"ProductName",
"SupplierName",
"..."
}
}
If you have a grid that should only load visible columns for performance reasons,
override its ColumnSelection level to KeyOnly. Note that non-visible table fields won't
be available in client side row.
ListRequest.ExcludeColumns Parameter
Opposite of IncludeColumns is ExcludeColumns. Let's say you have a nvarchar(max) Notes
field on your row that is never shown in the grid. To lower network traffic, you may choose to
NOT load this field in product grid:
422
List Request Handler
new ListRequest
{
ColumnSelection = ColumnSelection.List,
IncludeColumns = new List<string> {
"ProductID",
"ProductName",
"SupplierName",
"..."
},
ExcludeColumns = new List<string> {
"Notes"
}
}
[MinSelectLevel(SelectLevel.Details)]
public String Note
{
get { return Fields.Note[this]; }
set { Fields.Note[this] = value; }
}
There is a SelectLevel enumeration that controls when a field is loaded for different
ColumnSelection levels:
423
List Request Handler
By default, table fields have a select level of SelectLevel.List while view fields have
SelectLevel.Details.
SelectLevel.Always means such a field is selected for any column selection mode, even if it
is explicitly excluded using ExcludeColumns.
SelectLevel.Lookup is obsolete, avoid using it. Lookup columns are determined with
[LookupInclude] attribute.
SelectLevel.Never means never load this field! Use it for fields that shouldn't be sent to client
side, like a password hash.
424
Widgets
Widgets
Serenity Script UI layer's component classes (control) are based on a system that is similar
to jQuery UI's Widget Factory, but redesigned for C#.
You can find more information about jQuery UI widget system here:
http://learn.jquery.com/jquery-ui/widget-factory/
http://msdn.microsoft.com/en-us/library/hh404085.aspx
Widget, is an object that is attached to an HTML element and extends it with some
behaviour.
For example, IntegerEditor widget, when attached to an INPUT element, makes it easier to
enter numbers in the input and validates that the entered number is a correct integer.
Similarly, a Toolbar widget, when attached to a DIV element, turns it into a toolbar with tool
buttons (in this case, DIV acts as a placeholder).
425
ScriptContext Class
ScriptContext Class
C#, doesn't support global methods, so jQuery's $ function can't be used as simply in
Saltarelle as it is in Javascript.
426
Widget Class
Widget Class
A sample Widget
Let's build a widget, that increases a DIV's font size everytime it is clicked:
427
Widget Class
namespace MySamples
{
public class MyCoolWidget : Widget
{
private int fontSize = 10;
Widget.Element Property
428
Widget Class
Classes derived from Widget can get the element, on which they are created, by the
Element property.
This property has type of jQueryObject and returns the element, which is used when the
widget is created. In our sample, container DIV element is referenced as this.Element in
the click handler.
First, the HTML element gets a CSS class, based on the type of the widget.
This CSS class is generated by putting a s- prefix in front of the widget class name (it can
be changed by overriding Widget.AddCssClass method).
.s-MyCoolWidget {
background-color: red;
}
> $('#SomeDiv').data()
429
Widget Class
Thus, it is possible to get a reference to a widget that is attached to an HTML element, using
$.data function. In C# this can be written as:
This piece of code returns the widget if it exists on HTML element, otherwise throws an
exception:
An attempt to create a secondary widget of the same class on a element throws the
following error:
Any number of widgets from different classes can be attached to a single element as long as
their behaviour doesn't affect each other.
Widget.UniqueName Property
Every widget instance gets a unique name like MySamples_MyCoolWidget3 automatically,
which can be accessed by this.UniqueName property.
430
Widget Class
This unique name is useful as a ID prefix for the HTML element and its descendant
elements which may be generated by the widget itself.
It can also be used as an event class for $.bind and `$.unbind' methods to attach / detach
event handlers without affecting other handlers, which might be attached to the element:
jQUery("body").Unbind("click." + this.UniqueName);
Widget.Destroy Method
Sometimes releasing an attached widget might be required without removing the HTML
element itself.
In default implementation of the Destroy method, event handlers which are assigned by the
widget itself are cleaned (by using UniqueName event class) and its CSS class ( .s-
WidgetClass ) is removed from the HTML element.
Custom widget classes might need to override Destroy method to undo changes on HTML
element and release resources (though, no need to detach handlers that are attached
previously with UniqueName class)
Destroy method is called automatically when the HTML element is detached from the DOM.
It can also be called manually.
If destory operation is not performed correctly, memory leaks may occur in some browsers.
431
Widget With Options
The options passed to the constructor can be accessed in class methods through the
protected field options .
432
TemplatedWidget Class
TemplatedWidget Class
A widget that generates a complicated HTML markup in its constructor or other methods
might lead to a class with much spaghetti code that is hard to maintain. Besides, as markup
lies in program code, it might be difficult to customize output.
Such problems can be avoided by using HTML templates. For example, lets add the
following template into the HTML page:
Here, a SCRIPT tag is used, but by specifying its type as "text/html" , browser won't
recognize it as a real scriptto execute.
433
TemplatedWidget Class
<div id="SampleElement">
</div>
<div id="SampleElement">
<div id="MySamples_MyComplexWidget1_MyToolbar">
</div>
<table id="MySamples_MyComplexWidget1_MyTable">
<thead><tr><th>Name</th><th>Surname</th>...</tr></thead>
<tbody>...</tbody>
</table>
</div>
TemplatedWidget automatically locates the template for your class and applies it to the
HTML element.
TemplatedWidget ID Generation
If you watch carefully, in our template we specified ID for descendant elements as
~_MyToolbar and ~_MyTable .
But when this template is applied to the HTML element, resulting markup contained ID's of
MySamples_MyComplexWidget1_MyToolbar and MySamples_MyComplexWidget1_MyTable
instead.
TemplatedWidget replaces prefixes like ~_ with the widget's UniqueName and underscore
("_") ( this.idPrefix contains the combined prefix).
Using this strategy, even if the same widget template is used in a page for more than one
HTML element, their ID's won't conflict with each other as they will have unique ID's.
TemplatedWidget.ByID Method
434
TemplatedWidget Class
Widget's unique name and an underscore should be prepended to the original ID attribute in
the template to find an element:
TemplatedWidget.GetTemplateName Method
In the last sample MyComplexWidget located its template automatically.
content as a template.
435
TemplatedWidget Class
We might fix our template ID or ask the widget to use our custom ID:
TemplatedWidget.GetTemplate Method
GetTemplate method might be overriden to provide a template from another source or
specify it manually:
If no such SCRIPT element is found, Q.GetTemplate is called with the same ID.
Q.GetTemplate method provides access to templates defined on the server side. These
For example, we could create a template for MyComplexWidget in a server side file like
~/Views/Template/SomeFolder/MyComplexWidget.template.cshtml with the following content:
436
TemplatedWidget Class
<div id="~_MyToolbar">
</div>
<table id="~_MyTable">
<thead><tr><th>Name</th><th>Surname</th>...</tr></thead>
<tbody>...</tbody>
</table>
Template file name and extension is important while its folder is simply ignored.
By using this strategy there would be no need to insert widget templates into the page
markup.
Also, as such server side templates are loaded on the first use (lazy loading) and cached in
the browser and the server, page markup doesn't get polluted with templates for widgets that
we might never use in a specific page. Thus, server side templates are favored over inline
SCRIPT templates.
437
TemplatedDialog Class
TemplatedDialog Class
TemplatedWidget's subclass TemplatedDialog makes use of jQuery UI Dialog to create in-
page modal dialogs.
Unlike other widget types TemplatedDialog creates its own HTML element, which it will be
atteched to.
438
Attributes
Attributes
Visible Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
It is also possible to hide a field by passing false as its value, but [Hidden] attribute is
recommended.
User might still show the column by using the column picker if any.
Hidden Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
User might still show the column by using the column picker if any.
HideOnInsert Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
439
Attributes
HideOnUpdate Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
Insertable Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
440
Attributes
Updatable Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
DisplayName Attribute
namespace: System.ComponentModel, assembly: System
Description Attribute
namespace: System.ComponentModel, assembly: System
441
Attributes
DisplayFormat Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
This has no effect on editors! It is only for Display, NOT Editing. For editing, you have
to change culture in web.config (not UI culture).
Display format strings are specific to column data and formatter type.
If column is a Date or DateTime column, its default formatter accepts custom DateTime
format strings like dd/MM/yyyy.
We don't suggest setting DisplayFormat for dates explicitly, use culture setting (not UI
culture) in web.config unless a column has to display date/time in a different order than
the default.
442
Attributes
Placeholder Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
Placeholder is shown inside the editor with gray color when editor value is empty.
Only basic input based editors and Select2 supports this. It is ignored by other editor
types like Checkbox, Grid, FileUploadEditor etc.
Hint Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
443
Attributes
CssClass Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
In forms, class is added to container div with .field class that contains both label and
editor.
For columns, it sets cssClass property of SlickColumn, which adds this class to slick
cells for all rows.
Slick column headers are not affected by this attribute, use [HeaderCssClass] for that.
HeaderCssClass Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
444
Attributes
It sets headerCss property of SlickColumn, which adds this class to slick header for that
column.
AlignCenter Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
Column headers are not affected by this attribute. You may use
[HeaderCssClass("align-center")] for that.
AlignRight Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
Column headers are not affected by this attribute. You may use
[HeaderCssClass("align-right")] for that.
Ignore Attribute
namespace: Serenity.ComponentModel, assembly: Serenity.Core
445
Attributes
Use this to ignore a property for UI, but still use it for other purposes like JSON
serialization.
This might be useful when a type is used as a Service Request and Form Declaration at
the same time.
446
Grids
Grids
447
Formatter Types
Formatter Types
URLFormatter
This formatter lets you put a link with a URL to a grid column.
This is name of the property that will be used to determine link URL.
If not specified, it is the name of the column that this formatter is
UrlProperty placed on.
If UrlProperty value starts with "~/" it will be resolved like UrlFormat.
This is name of the property that will be used to determine link text.
DisplayProperty If not specified, it is the name of the column that this formatter is
placed on.
Target This is the target of the link. Use "_blank" to open links in a new tab.
448
Persisting Settings
Persisting Settings
Serenity 2.1.5 introduced ability to persist grid settings including these details:
Thus, if you hide some columns and navigate away from Orders page, when you come
back, you'll see that those hidden columns became visible again.
You need to turn on persistance for all grids, or for individual ones that you want them to
remember their settings.
In ScriptInitialization.ts, you might turn on persistance for all grids by default like below:
namespace Serene.ScriptInitialization {
Q.Config.responsiveDialogs = true;
Q.Config.rootNamespaces.push('Serene');
Serenity.DataGrid.defaultPersistanceStorage = window.sessionStorage;
}
This saves settings to browser session storage, which is a key/value dictionary that
preserves data while any browser window stays open. When user closes all browser
windows, all settings will be lost.
Another option is to use browser local storage. Which preserves settings between browser
restarts.
Serenity.DataGrid.defaultPersistanceStorage = window.localStorage;
449
Persisting Settings
Using any of the two options, grids will start to remember their settings, between page
reloads.
If one user changes some settings, and logs out, and other one logs in, second user will
start with settings of the first user (unless you clear localStorage on signout)
If this is a problem for your application, you may try writing a custom provider:
namespace Serene {
export class UserLocalStorage implements Serenity.SettingStorage {
getItem(key: string): string {
return window.localStorage.getItem(
Authorization.userDefinition.Username + ":" + key);
}
//...
Serenity.DataGrid.defaultPersistanceStorage = new UserLocalStorage();
Please note that this doesn't provide any security. It just lets users have separate
settings.
450
Persisting Settings
namespace Serene.Northwind {
//...
export class OrderGrid extends Serenity.EntityGrid<OrderRow, any> {
//...
You may also turn off persistance for a grid class by returning null from this method.
namespace Serene.Northwind {
//...
export class OrderGrid extends Serenity.EntityGrid<OrderRow, any> {
//...
interface GridPersistanceFlags {
columnWidths?: boolean;
columnVisibility?: boolean;
sortColumns?: boolean;
filterItems?: boolean;
quickFilters?: boolean;
includeDeleted?: boolean;
}
451
Persisting Settings
Settings are restored on first page load, just after grid creation.
Don't forget to add reference statement, or you'll have runtime errors, as TypeScript has
problems with ordering otherwise.
OR
namespace Serene.Northwind {
//...
export class OrderGrid extends Serenity.EntityGrid<OrderRow, any> {
//...
These are protected methods of DataGrid, so can only be called from subclasses.
452
Persisting Settings
453
Code Generator (Sergen)
KDiff3 Path
Sergen tries to launch KDiff3 when it needs to merge changes to an existing file. This might
happen when you try to generate code for an entity again. Instead of overriding target files,
Sergen will execute KDiff3.
Sergen looks for KDiff3 at its default location under C:\Program Files\Kdiff3, but you may
override this path with this option, if you installed Kdiff3 to another location.
454
Code Generator (Sergen)
{
// ...
"TFSIntegration": true,
"TFPath": "C:\Program Files\....\tf.exe"
}
RemoveForeignFields
By default, Sergen examines your table foreign keys, and when generating a row class, it
will bring all fields from all referenced foreign tables.
Sometimes, you might have some fields in foreign tables, e.g. some logging fields like
InsertUserId, UpdateDate etc. that wouldn't be useful in another row.
You'd be able to remove them manually after code generation too, but using this option it
might be easier. List fields you want to remove from generated rows as an array of string:
{
// ...
"RemoveForeignFields": ["InsertUserId", "UpdateUserId",
"InsertDate", "UpdateDate"]
}
Note that this doesn't remove this fields from table row itself, it only removes these view
fields from foreign joins.
BaseRowClasses
If you are using some base row class, e.g. something like LoggingRow in Serene, you might
want Sergen to generate your rows deriving from these base classes.
For this to work, list your base classes, and the fields they have.
{
// ...
"BaseRowClasses": [{
"ClassName": "Serene.Administration.LoggingRow",
"Fields": ["InsertUserId", "UpdateUserId",
"InsertDate", "UpdateDate"]
}]
}
455
Code Generator (Sergen)
If Sergen determines that a table has all fields listed in "Fields" array, it will set its base class
as "ClassName", and will not generate these fields explicity in row, as they are already
defined in base row class.
It is possible to define more than one base row class. Sergen will choose the base row class
with most matching fields, if a row's fields matches more than one base class.
456
Used Tools & Libraries
This list might seem a bit long, but not all of them are direct dependencies for a
Serenity Application.
Some of them are only used during development of Serenity platform itself, while some
are dependencies for optional features.
We tried to reuse open source libraries, where there is a quality one available to avoid
reinventing the wheel.
Autonumeric (https://github.com/BobKnothe/autoNumeric)
BlockUI (https://github.com/malsup/blockui/)
Bootstrap (https://github.com/twbs/bootstrap)
Cecil (https://github.com/jbevain/cecil)
Clean-CSS [Node]
(https://github.com/jakubpawlowicz/clean-css)
Colorbox (https://github.com/jackmoore/colorbox)
Dapper (https://github.com/StackExchange/dapper-dot-net)
DialogExtend (https://github.com/ROMB/jquery-
dialogextend)
jLayout (https://github.com/bramstein/jlayout)
Json.NET (https://github.com/JamesNK/Newtonsoft.Json)
457
Used Tools & Libraries
JSON2 (https://github.com/douglascrockford/JSON-js)
JSRender (https://github.com/BorisMoore/jsrender)
jQuery (https://github.com/jquery/jquery)
jQuery UI (https://github.com/jquery/jquery-ui)
jQuery.event.drag
(http://threedubmedia.com/code/event/drag)
Linq.js (http://linqjs.codeplex.com/)
metisMenu (https://github.com/onokumus/metisMenu)
Munq (https://munq.codeplex.com/)
NodeJS (https://github.com/joyent/node)
Pace (https://github.com/HubSpot/pace)
PhantomJS (https://github.com/ariya/phantomjs)
RazorGenerator (https://razorgenerator.codeplex.com/)
RSVP (https://github.com/tildeio/rsvp.js/)
458
Used Tools & Libraries
Select2 (https://github.com/ivaynberg/select2)
SlickGrid (https://github.com/mleibman/SlickGrid)
Toastr (https://github.com/CodeSeven/toastr)
XUnit (https://github.com/xunit/xunit)
459