Serenity Developer Guide
User Manual:
Open the PDF directly: View PDF .
Page Count: 475 [warning: Documents this large are best viewed by clicking the View PDF Link!]
- Introduction
- Getting Started
- A Tour Of Serene Features
- Tutorials
- Movie Database
- Creating Movie Table
- Generating Code For Movie Table
- Customizing Movie Interface
- Handling Movie Navigation
- Customizing Quick Search
- Adding a Movie Kind Field
- Adding Movie Genres
- Updating Serenity Packages
- Allowing Multiple Genre Selection
- Filtering with Multiple Genre List
- The Cast and Characters They Played
- Listing Movies in Person Dialog
- Adding Primary and Gallery Images
- Multi Tenancy
- Adding Tenants Table and TenantId Field
- Generating Code for Tenants Table
- Tenant Selection in User Dialog
- Filtering Users By TenantId
- Removing Tenant Dropdown From User Form
- Securing Tenant Selection At Server Side
- Setting TenantId For New Users
- Preventing Edits To Users From Other Tenants
- Hiding the Tenant Administration Permission
- Making Roles Multi-Tenant
- Using Serenity Service Behaviors
- Extending Multi-Tenant Behavior To Northwind
- Handling Lookup Scripts
- Meeting Management
- Movie Database
- How To Guides
- How To: Remove Northwind & Other Samples From Serene
- How To: Update Serenity NuGet Packages
- How To: Upgrade to Serenity 2.0 and Enable TypeScript
- How To: Authenticate With Active Directory or LDAP
- How To: Use a SlickGrid Formatter
- How To: Add a Row Selection Column
- How To: Setup Cascaded Editors
- How To: Use Recaptcha
- How To: Register Permissions in Serene
- How To: Use a Third Party Plugin With Serenity
- How To: Enable Script Bundling
- How To: Debugging with Serenity Sources
- Frequently Asked Questions
- Troubleshooting
- Service Locator & Initialization
- Authentication & Authorization
- Configuration System
- Localization
- Caching
- Entities (Row)
- Fluent SQL
- Connections and Transactions
- Services
- Widgets
- Attributes
- Grids
- Code Generator (Sergen)
- Used Tools & Libraries


1.1
1.2
1.2.1
1.2.2
1.2.3
1.2.4
1.3
1.3.1
1.3.2
1.3.3
1.3.4
1.3.5
1.4
1.4.1
1.4.1.1
1.4.1.2
1.4.1.3
1.4.1.4
1.4.1.5
1.4.1.6
1.4.1.7
1.4.1.8
1.4.1.9
1.4.1.10
1.4.1.11
1.4.1.12
1.4.1.13
1.4.2
1.4.2.1
1.4.2.2
TableofContents
Introduction
GettingStarted
InstallingSereneFromVisualStudioMarketplace
InstallingSereneDirectlyFromVisualStudio
InstalingSereneAsp.NetCoreVersionwithSerin
StartingSerene
ATourOfSereneFeatures
Theming
Localization
UserandRoleManagement
ListingPages
EditDialogs
Tutorials
MovieDatabase
CreatingMovieTable
GeneratingCodeForMovieTable
CustomizingMovieInterface
HandlingMovieNavigation
CustomizingQuickSearch
AddingaMovieKindField
AddingMovieGenres
UpdatingSerenityPackages
AllowingMultipleGenreSelection
FilteringwithMultipleGenreList
TheCastandCharactersTheyPlayed
ListingMoviesinPersonDialog
AddingPrimaryandGalleryImages
MultiTenancy
AddingTenantsTableandTenantIdField
GeneratingCodeforTenantsTable
2

1.4.2.3
1.4.2.4
1.4.2.5
1.4.2.6
1.4.2.7
1.4.2.8
1.4.2.9
1.4.2.10
1.4.2.11
1.4.2.12
1.4.2.13
1.4.3
1.4.3.1
1.5
1.5.1
1.5.2
1.5.3
1.5.4
1.5.5
1.5.6
1.5.7
1.5.8
1.5.9
1.5.10
1.5.11
1.5.12
1.6
1.7
1.8
1.8.1
1.8.2
1.8.3
1.8.4
1.8.5
TenantSelectioninUserDialog
FilteringUsersByTenantId
RemovingTenantDropdownFromUserForm
SecuringTenantSelectionAtServerSide
SettingTenantIdForNewUsers
PreventingEditsToUsersFromOtherTenants
HidingtheTenantAdministrationPermission
MakingRolesMulti-Tenant
UsingSerenityServiceBehaviors
ExtendingMulti-TenantBehaviorToNorthwind
HandlingLookupScripts
MeetingManagement
CreatingLookupTables
HowToGuides
HowTo:RemoveNorthwind&OtherSamplesFromSerene
HowTo:UpdateSerenityNuGetPackages
HowTo:UpgradetoSerenity2.0andEnableTypeScript
HowTo:AuthenticateWithActiveDirectoryorLDAP
HowTo:UseaSlickGridFormatter
HowTo:AddaRowSelectionColumn
HowTo:SetupCascadedEditors
HowTo:UseRecaptcha
HowTo:RegisterPermissionsinSerene
HowTo:UseaThirdPartyPluginWithSerenity
HowTo:EnableScriptBundling
HowTo:DebuggingwithSerenitySources
FrequentlyAskedQuestions
Troubleshooting
ServiceLocator&Initialization
DependencyStaticClass
IDependencyResolverInterface
IDependencyRegistrarInterface
MunqContainerClass
CommonInitializationStaticClass
3

1.9
1.9.1
1.9.2
1.9.3
1.9.4
1.9.5
1.9.6
1.10
1.10.1
1.10.2
1.10.3
1.10.4
1.11
1.11.1
1.11.2
1.11.3
1.11.4
1.11.5
1.11.5.1
1.11.6
1.11.6.1
1.11.6.2
1.11.6.3
1.11.6.4
1.12
1.12.1
1.12.1.1
1.12.1.2
1.12.1.3
1.12.2
1.12.2.1
1.12.2.2
1.12.2.3
Authentication&Authorization
IAuthenticationServiceInterface
IAuthorizationServiceInterface
IPermissionServiceInterface
IUserDefinitionInterface
IUserRetrieveServiceInterface
AuthorizationStaticClass
ConfigurationSystem
DefiningConfigurationSettings
IConfigurationRepositoryInterface
AppSettingsJsonConfigRepository
ConfigStaticClass
Localization
LocalTextClass
LanguageIdentifiers
LanguageFallbacks
ILocalTextRegistryInterface
LocalTextRegistryClass
PendingApprovalMode
RegisteringTranslations
ManuallyRegisteringTranslations
NestedLocalTexts
EnumerationTexts
JSONLocalTexts
Caching
LocalCaching
ILocalCacheInterface
LocalCacheStaticClass
UserProfileCachingSample
DistributedCaching
WEBFarmsandCaching
IDistributedCacheInterface
DistributedCacheStaticClass
4

1.12.2.4
1.12.2.5
1.12.2.6
1.12.3
1.12.3.1
1.12.3.2
1.13
1.13.1
1.13.2
1.14
1.14.1
1.14.2
1.15
1.16
1.16.1
1.16.2
1.16.3
1.16.4
1.16.5
1.16.6
1.16.7
1.17
1.17.1
1.17.2
1.18
1.18.1
1.18.2
1.18.3
1.18.4
1.18.5
1.19
1.20
1.20.1
1.20.2
DistributedCacheEmulatorClass
CouchbaseDistributedCacheClass
RedisDistributedCacheClass
TwoLevelCaching
UsingLocalCacheandDistributedCacheInSync
TwoLevelCacheClass
Entities(Row)
MappingAttributes
FieldFlagsEnumeration
FluentSQL
SqlQueryObject
CriteriaObjects
ConnectionsandTransactions
SQLDatabaseTypes
WorkingwithOtherDatabases
SettingConnectionDialect
DialectBasedExpressions
PostgreSQL
MySQL
Sqlite
Oracle
Services
ServiceEndpoints
ListRequestHandler
Widgets
ScriptContextClass
WidgetClass
WidgetWithOptions
TemplatedWidgetClass
TemplatedDialogClass
Attributes
Grids
FormatterTypes
PersistingSettings
5

Introduction
WhatisSerenityPlatform
SerenityisanASP.NETCore/MVC/TypeScriptapplicationplatformwhichhasbeenbuilt
onopensourcetechnologies.
Itaimstomakedevelopmenteasierwhilereducingmaintenancecostsbyavoidingboiler-
platecode,reducingthetimespentonrepetitivetasksandapplyingthebestsoftwaredesign
practices.
Who/WhatThisPlatformIsFor
Serenityisbestsuitedtobusinessapplicationswithmanydataentryformsoradministrative
interfaceofpublicfacingwebsites.It'sfeaturescanbeusefulforotherkindsofweb
applicationsaswell.
WhereToLookForInformation
Afterreadingthisguideanditstutorials,followresourcesbelowformoreinformationabout
Serenity.
HomePage:
http://serenity.is
Blog:
http://serenity.is/blog
GithubRepository:
https://github.com/volkanceylan/Serenity
Issues/Questions
https://github.com/volkanceylan/Serenity/issues
Wiki(FAQ,TroubleshootingandOtherCommunityContent)
https://github.com/volkanceylan/Serenity/wiki
ChangeLog:
https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md
Introduction
7

SereneApplicationTemplate:
https://marketplace.visualstudio.com/items?
itemName=VolkanCeylan.SereneSerenityApplicationTemplate
What'sInTheName
Serenityhasdictionarymeaningsofpeace,comfortandcalmness.
ThisiswhatwearetryingtoachievewithSerenity.Wehopethatafterinstallingandusingit
youwillfeelthiswaytoo...
WhatFeaturesItProvides
Amodular,servicebasedwebapplicationmodel
Codegeneratortoproduceinitialservices/userinterfacecodeforanSQLtable
T4basedcodegenerationonservertoreferencescriptwidgetswithintellisense/
compiletimevalidation
T4basedcodegenerationtoprovidecompiletimetypesafetyandintellisensewhile
callingAJAXservicesfromscriptside.
Anattributebasedformdefinitionsystem(prepareUIinserversidewithasimpleC#
class)
Automaticseamlessdata-bindingthroughformdefinitions(form<->entity<->service).
CachingHelpers(Local/Distributed)
Automaticcachevalidation
ConfigurationSystem(storagemediumindependent.storesettingsindatabase,file,
whatever...)
SimpleLogging
Reporting(reportsjustprovidedata,hasnodependencyonrendering,similartoMVC)
Scriptbundling,minification(makinguseofNode/UglifyJS/CleanCSS)andcontent
versioning(nomoreF5/clearbrowsercache)
FluentSQLBuilder(SELECT/INSERT/UPDATE/DELETE)
MicroORM(alsoDapperisintegrated)
CustomizablehandlersforRESTlikeservicesthatworkbyreusinginformationinentity
classesanddoautomaticvalidation.
Attributebasednavigationmenu
UILocalization(storelocalizedtextsinjsonfiles,embeddedresource,database,in
memoryclass,anywhere)
DataLocalization(usinganextensiontablemechanismhelpstolocalizeevendata
enteredbyusers,likelookuptables)
Introduction
8

Scriptwidgetsystem(inspiredbyjQueryUIbutmoresuitableforC#code)
Clientsideandserversidevalidation(basedonjQueryvalidateplugin,butabstracts
dependency)
Auditlogging(whereCDCisnotavailable)
Systemfordatabasedintegrationtests
Dynamicscripts
Scriptsidetemplates
Background
ThispartwasoriginallywrittenforaCodeProjectarticleasanintroductiontoSerenity.
Thearticlewasrejectedwiththereasonthatitdidn'tcontaincodebutwasanadfor
code.Theywereright,asididputalinktoMovietutorialinthisguide,insteadofcopy
pastingcode.
Youcansafelyskiptonextchapter,ifyoudon'tlikereadinghistory:)
We,developers,areallsolvingthesamesetsofproblemseveryday.Justlikecollege
studentsworkingontheirproblembooks.
Eventhoughweknowthattheyarealreadysolvedandhaveanswerssomewhere,itdoesn't
stopusfromworkingonthem.Actually,ithelpsusimproveourskills,andheyyoucan't
learnwithoutmakingsomemistakes,canyou?Butweshouldlearnwheretodrawaline
betweentrainingandwastingtime.
Whenyoustartanewproject,youhaveseveraldecisionstomakeonplatform,architecture
andsetoflibraries.Todayyouhavesomanychoicesforeverysingletopic.Yes,having
someoptionsisgood,aslongastheyarelimited,asourtimeisnotinfinite.
HereisashorthistoryaboutSerenity,whichaimstohandlecommontasksyoudealwith
businessapplications,andletyouspareyourprecioustimefocusingonfeaturesspecificto
yourapplicationdomain.
Myfirstrealjobinwebtechnologieswasinawebagencydesigningcountry-specificweb
sitesofsomeofbignamesinindustry,e.g.automativecompanies(btw,wearetalkingabout
10+yearspast,timeflowsfast).
AsIhadasoftwarearchitectcareerindesktopapplicationsbeforeIsignedthere,Iwas
askedtodesignaASP.NETWebFormsplatformforthem.Theyexplainedthattheyhave
manysharedmodules,likenews,galleries,navigationateachsite,butasrequirementsare
different,theyhadtocopy/pastethencustomizecodespecifictoeverycustomer.Whenthey
wantedtoaddacommonfeature,theyhadtorepeatitforeverysite.
Introduction
9

Atthattime,thereweren'tsomanyCMSsystemsinmarket,andIdesignedoneforthem,
withoutevenknowingitwascalledaCMS.Forme,itwasn'tperfect,notevengoodenough
asIjusthadafewweekstodesignit.Buttheywereverypleasedwiththeresult,asittook
developmentofnewsitesdowntodays/weeksfrommonths.Alsoresultingcodewasmore
manageablethanbefore.
Learningfromthatexperience,andmistakes,thatpoor-mansCMSbecamesomething
better.Later,thatplatformisevolvedtobeusedbyapplicationsinvaryingdomains,likea
help-desksystem,aCRM,ERP,personnelmanagement,electronicdocumentmanagement,
universitystudentinformationsystemandmore.
Tobecompatiblewithdifferentkindsofapplications,systemsandevenlegacydatabases,it
hadtobeflexibleandwentthroughmanyarchitecturalchanges.
NowittakesustoSerenity.Eventhoughitisanopensourceprojectforabout2years,ithas
amucholderbackground.Butitisalsoyoung,energetic,andisnotafraidofchange.Itcan
adapttonewtechnologiesastheybecamepopularandstable.Thismightmeanbreaking
changesfromtimetotime,butwestrivetokeepthemtoaminimumwithoutbeingparanoid
aboutbackwardscompability.
Introduction
10

GettingStarted
ThebestandfastestwaytogetyourhandsdirtyonSerenityisSERENE,whichisasample
applicationtemplate.
YouhavethreeoptionstoinstallSERENEtemplate:
PleasecheckprerequisitesbelowbeforetryingtoinstallSerene.
InstallingSERENEfromVisualStudioMarketplace(Windows)
InstallingSERENEdirectlyfromVisualStudio(Windows)
InstallingSERENEforAsp.NetCorewithSERIN(Linux,OSX,Windows)
Prerequisites
VisualStudioVersion
Serene.NETFrameworkversion(ASP.NETMVC4)requiresVisualStudio2017orVisual
Studio2015withUpdate3installed.
IfyouhaveVisualStudio2015,pleasemakesurethatyouhaveUpdate3installedby
lookingatHelp=>About
GettingStarted
11

ItmightbepossibletoworkwithVisualStudio2013aswellbutyou'llhavemany
intellisenseerrorsasTypeScript2.5.2can'tbeinstalledinVS2013.
SereneASP.NETCore2.0versiononlyworksinVisualStudio2017,orusingcommandline.
Microsoftrecentlyobsoletedproject.jsonbasedprojectsandreplacedthemwitha
lighterversionofMsBuildbasedCSPROJprojects.Thisnewprojectsystemonlyworks
inVisualStudio2017,soifyouwanttoworkwith.NETCoreversionofSerene,either
youneedtouseVisualStudio2017orgolighterwithVisualStudioCode/Command
Line.
.NETCoreSDK
IfyouaregoingtouseASP.NETCoreversionofSerene,pleaseinstall.NETCore2.0SDK
from:
https://www.microsoft.com/net/download/core
VisualStudioTypeScriptExtension
Asofwriting,therecommendedversionofTypeScriptis2.5.2.
GettingStarted
12

EventhoughSereneusesNodeJSbasedTypeScriptcompiler(tsc)onbuild,VisualStudio
stillusesitsownversionofTypeScriptforintellisenseandrefactoringetc.Ifyouhavean
olderversionofthatextension,you'llbegreetedwithmanyerrorsassoonasyouopena
Sereneproject.
TocheckwhatversionofTypeScriptVisualStudioExtensionyouhave,againseeHelp=>
About:
VisualStudio2017comeswithTypeScript2.1.5bydefault,butVisualStudio2015might
includeolderversions.
Ifyouhavesomethinglowerthan2.3.4there,youmightneedtoinstallTypeScriptforVisual
Studioextension.
TypeScriptversionyouseeinControlPanel/AddRemoveProgramsdoesn'tmatterat
all.WhatmattersistheonethatisenabledinVisualStudio.
TypeScriptversionsafter1.8.6requiresVisualStudio2015Update3tobeinstalledfirst,so
evenifyoutrytoinstalltheextensionitwillraiseanerror,sopleasefirstinstallUpdate3.
YoucangetTypeScriptextensionforyourVisualStudioversionfrom
http://www.typescriptlang.org/#download-links.
GettingStarted
13

HereisthelinkforVisualStudio2015:
https://www.microsoft.com/en-us/download/details.aspx?id=48593
Butdon'tclickthedownloadbuttonrightaway.ExpandDetailssection,andselectthe
exactversionyouneed(e.g.2.5.2):
LatestversionofTypeScriptmightprobablyworkbutkeepinginsyncwiththeversionwe
currentlyuse,canhelpyouavoidcompatibilityproblemsthatcouldcomewiththem.
NodeJS/NPM
SereneusesNodeJS/NPMforthese:
TypeScripttypings(.d.ts)forlibrarieslikejQuery,Bootstrapetc.
GettingStarted
14

TypeScriptcompileritself(tsc)
Lesscompilation(lessjs)
T4CodegenerationbyparsingTypeScriptsources
ItrequiresNodeJSv6.9+andNPM3.10+
Serenewillchecktheirversionsonprojectcreationandaskforconfirmationtodownload
andinstallthem.Anyway,pleasecheckyourversionsmanuallybyopeningacommand
prompt:
>npm-v
3.10.10
>node-v
6.9.4
Ifyougetanerror,theymightnotbeinstalledornotinpath.PleaseinstallLTS(longterm
support)versionsfromhttps://nodejs.org/en/
Currentversionmightalsoworkbutisnottested.
VisualStudioandExternalWebToolPaths
EvenifyouhavecorrectNode/NPMinstalled,VisualStudiomightstillbetryingtouseits
ownintegrated,andolderversionofNodeJS.
ClickTools=>Options,andthenunderProjectsandSolutions=>ExternalWebToolsadd
C:\ProgramFiles\nodejstothetopofthelistbyclickingplusfoldericon,typingC:\Program
Files\nodejsandusingUpArrowtomoveittothestart:
GettingStarted
15

GettingStarted
16

InstallingSereneFromVisualStudio
Marketplace
DownloadingTemplate
OpenURLbelowinyourbrowser:
https://marketplace.visualstudio.com/items?
itemName=VolkanCeylan.SereneSerenityApplicationTemplate
ClickDownloadtotransferVSIXfiletoyourcomputer.
InstallTemplateintoVisualStudio
Afterdownloadisfinished,doubleclickthedownloadedVSIXfiletostartVisualStudio
extensioninstallationdialog
InstallingSereneFromVisualStudioMarketplace
17

IfyouhavebothVisualStudio2017and2015installed,sometimesVisualStudio2015
installermightpickupVSIXfilesoitonlyinstallsinVisualStudio2015.Ifyou
experiencethisissue,rightclickthefile,clickOpenWithandchooseVisualStudio
VersionSelector.
ClickInstallwhenprompted.
NotethatthisapplicationtemplaterequiresVisualStudio2012orhigher.Makeyousure
youhavethelatestVisualStudioupdatesinstalled.ASP.NETMVCCoreversion
requiresVisualStudio2017withUpdate3
CreatingaNewProjectinVisualStudio
InstallingSereneFromVisualStudioMarketplace
18

StartVisualStudio(ifitwasalreadyopen,restartit).ClickFile=>NewProject.Youshould
seeSerenetemplateunderTemplates=>VisualC#section.
WehavetwoversionsofSerenetemplate.OnethatusesclassicASP.NETMVC4
(SERENE)andanotheronethatworksonASP.NETCOREMVC2.0/.NETCORE2.0.
ASP.NETCoreisarecenttechnologyandisplatformindependent(aslongasyoutarget
.NETCoreitalsorunsonLinux/OSX).
ASP.NETMVConlyrunsonWindowsand.NETframeworkbutmoremature(latestversion
isdated2/9/2015).
WecansaybothversionsofSereneisprettystable.
HereisadocumentfromMicrosoftthatmighthelpyouchoosebetweentwoframeworks:
https://docs.microsoft.com/en-us/aspnet/core/choose-aspnet-framework
NameyourapplicationsomethinglikeMyCompany,MyProduct,HelloWorldorleavethe
defaultSerene1.
Pleasedon'tnameitSerenity.ItmayconflictwithotherSerenityassemblies.
PleaseusePascalcasing,e.g.anamethatstartswithaCapitalLetter.Don'tnameyour
projectsomethinglike myProject.
ClickOK.
FeatureSelection
InstallingSereneFromVisualStudioMarketplace
19

Serenewillpromptyoutochoosefeaturesyouwouldliketosee.
Allofthesefeatures/samplesareoptional.Initiallywerecommendyoutoleavethemall
checkedsothatyoumighthavealookathowtheyareimplemented.
AfterhavingsomeexperiencewithSerene,youmightcreateanewapplicationandclearall
thesecheckboxestohaveabareminimumproject.
Choosefeaturesyoulike,clickOKandtakeabreakwhileVisualStudiocreatesthesolution.
InstallingSereneFromVisualStudioMarketplace
20

InstallingSereneDirectlyFromVisual
Studio
StartVisualStudioandClickNew=>Project.
NotethatthisapplicationtemplaterequiresVisualStudio2012orhigher.Makesureyou
havethelatestVisualStudioupdatesinstalled.
IntheNewProjectdialogboxRecent,InstalledandOnlinesectionswillbeshownonleftand
Installedistheactiveone.
ClicktheOnlinesectionandwaitabitwhileRetrievinginformationmessageisonscreen.
Pleasewaitwhileitisloadingresults.
Serenemightbealreadyshowingontopofthelist.Ifitisnot,typeSERENEintoinputbox
withSearchOnlineTemplateslabelandpressENTER.
YouwillseeSerene(SerenityApplicationTemplate):
InstallingSereneDirectlyFromVisualStudio
21

CreatingaNewProjectinVisualStudio2015
andOlder
NameyourapplicationsomethinglikeMyCompany,MyProduct,HelloWorldorleavethe
defaultSerene1.
Pleasedon'tnameitSerenity.ItmayconflictwithotherSerenityassemblies.
Afteryoucreateyourfirstproject,SerenetemplateisinstalledintoVisualStudio,soyou
canusetheInstalledsectioninNewProjectdialogtocreateanotherSerenity
application.
ClickOKtodownloadSereneandcreateyournewproject
YourprojectwilluseASP.NETMVC4versionofSerene,nexttimeyoumaychoose
ASP.NETCoreversionfromNewProjectdialog,Installedsection.
CreatingaNewProjectinVisualStudio2017
UnfortunatelyVisualStudio2017changedtemplateinstallationprocess.Whenyouselect
SerenefromOnlinesectionandclickOK,you'llseethisdialog:
InstallingSereneDirectlyFromVisualStudio
22

WhenyouclickModify,you'llbeaskedtoterminateVisualStudioandotherrelated
processes.
Afterinstallation,VisualStudiowon'trestartitself.You'llhavetomanuallyrelaunchVisual
Studio.
Now,ifyouagaingotoOnlinesectionandclickSerene,you'llgetthiserrormessage:
Unfortunately,thisisaVS2017bug.
PleasecloseOnlinesection,andfindSereneunderNewProject=>Installed=>VisualC#:
InstallingSereneDirectlyFromVisualStudio
23

Afteryoucreateyourfirstproject,SerenetemplateisinstalledintoVisualStudio,soyou
canusetheInstalledsectioninNewProjectdialogtocreateanotherSerenity
application.
NameyourapplicationsomethinglikeMyCompany,MyProduct,HelloWorldorleavethe
defaultSerene1.
Pleasedon'tnameitSerenity.ItmayconflictwithotherSerenityassemblies.
PleaseusePascalcasing,e.g.anamethatstartswithaCapitalLetter.Don'tnameyour
projectsomethinglike myProject.
ClickOK.
ChoosingBetweenASP.NETMVC/ASP.NET
Core
WehavetwoversionsofSerenetemplate.OnethatusesclassicASP.NETMVC4
(SERENE)andanotheronethatworksonASP.NETCORE2.0/.NETCORE2.0.
ASP.NETCoreisarecenttechnologyandisplatformindependent(aslongasyoutarget
.NETCoreitalsorunsonLinux/OSX).
ASP.NETMVConlyrunsonWindowsand.NETframeworkbutmoremature(latestversion
isdated2/9/2015).
WecansaybothversionsofSereneisprettystable.
InstallingSereneDirectlyFromVisualStudio
24

HereisadocumentfromMicrosoftthatmighthelpyouchoosebetweentwoframeworks:
https://docs.microsoft.com/en-us/aspnet/core/choose-aspnet-framework
UnfortunatelyVisualStudio2015doesn'tletyouchoosewhichversiontouseonfirst
install.But,youwillseeASP.NETCoreversionafterinstallationonNewProjectdialog.
FeatureSelection
Serenewillpromptyoutochoosefeaturesyouwouldliketosee.
Allofthesefeatures/samplesareoptional.Initiallywerecommendyoutoleavethemall
checkedsothatyoumighthavealookathowtheyareimplemented.
AfterhavingsomeexperiencewithSerene,youmightcreateanewapplicationandclearall
thesecheckboxestohaveabareminimumproject.
Choosefeaturesyoulike,clickOKandtakeabreakwhileVisualStudiocreatesthesolution.
InstallingSereneDirectlyFromVisualStudio
25

InstallingSereneAsp.NetCoreVersion
withSERIN
Thissectionisforuserswhodoesn'torcan'tuseVisualStudio(inLinux/OSX).
SereneAsp.NetCoreversionsupportsLinuxandOSXinadditiontoWindows.
WerecommendVisualStudioCodeforallplatforms,butitisalsopossibletoworkwith
abasictexteditorlikeNotepad/VIM.Therearealsootherniceoptionse.g.Atom.
Install.NETCore2.0SDK
Pleasegotoaddressbelowandfollowinstructionsforyourspecificplatform:
https://www.microsoft.com/net/download/core
InstallNodeJS
AsTypeScript(andourSEReneprojectINitializer-SERIN)runsonNodeJSyouneedto
installNode/NPMfrom:
https://nodejs.org/en/download/
orusingyourfavoritepackagemanager:
https://nodejs.org/en/download/package-manager/
InstallSERINasaGlobalTool
Installourprojectinitializer,SERINasaglobaltoolusingNPM:
Linux/OSX:
>sudonpminstall-gserin
Windows:
>npminstall-gserin
InstalingSereneAsp.NetCoreVersionwithSerin
26

ThankstoVictor(@vctor)forLinuxscreenshots
CreateFolderforNewProject
CreateanemptyMySerene(oranameyoulike)folder.
Linux/OSX:
>cd~
>mkdirMySerene
>cdMySerene
Windows:
>cdc:\Projects
>mkdirMySerene
>cdMySerene
Serinhastoberunfromacompletelyemptydirectory
RunSerintoCreateaNewProject
Whileinsideanemptydirectory,runserin:
>serin
Typeanapplicationname,e.g.MySereneandpressenter.TakeabreakwhileSerincreates
yourproject,initializesstaticcontentandrestorespackagesetc.
InstalingSereneAsp.NetCoreVersionwithSerin
27

AfterSerincreatesyourproject,youwillhaveaMySerene.Webfolderundercurrent
directory.Enterthatdirectory:
>cdMySerene.Web
RunningSerene
ForOSX/Linux,firstrestorepackages:
>dotnetrestore
MakesureyourunthiscommandunderMySerene.AspNetCorefolder.
Thentype:
>dotnetrun
Nowopenabrowserandnavigateto http://localhost:5000.
Actualportmayvary.You'llseeitonconsoleafterexecutingdotnetrun.
InstalingSereneAsp.NetCoreVersionwithSerin
28

StartingSerene
AfteryourfirstprojectiscreatedinVisualStudiousingSerenetemplate,youwillseea
solutionlikethis:
Asp.NetCoreusersdon'thavetouseVisualStudio,butwe'lluseVisualStudiointhis
guideaswethinkmostofouruserswill.
YoursolutioncontainsSerene1.Webproject,whichisanASP.NETMVC(orASP.NETCore)
application.
ItincludesserversidecodewritteninC#(.cs)andclientsidecodethatiswrittenin
TypeScript(.ts).
Serene.WebhasreferencestoSerenityNuGetpackages,soyoucanupdateitusing
packagemanagerconsoleanytimenecessary.
Asp.NetCoreversioncanalsobeupdatedbyhandediting.CSPROJfile.
SereneautomaticallycreatesitsdatabaseinSQLlocaldbatfirstrun,sojustpressF5and
youarereadytogo.
Whenapplicationlaunchesuse adminuserand serenitypasswordtologin.Youcan
changepasswordorcreatemoreuserslater,usingAdministration/UserManagementpage.
StartingSerene
29

ThesampleapplicationincludesoldandfamousNorthwinddataalongwithservicesand
userinterfacetoeditit,whichismostlyproducedbySerenityCodeGenerator.
TroubleshootingConnectionProblems
IfyouaregettingaconnectionerrorlikethefollowingwhilestartingSereneforfirsttime:
>Anetwork-relatedorinstance-specificerroroccurred
>whileestablishingaconnectiontoSQLServer.
>Theserverwasnotfoundorwasnotaccessible.
>Verifythattheinstancenameiscorrect...
Thiserrormightmeanthatyoudon'thaveSQLServerLocalDB2012installed.Thisserver
comespreinstalledwithVisualStudio2012and2013.
ASP.NETMVC
In web.configfilethereareDefaultandNorthwindconnectionentries:
<connectionStrings>
<addname="Default"connectionString="DataSource=(LocalDb)\v11.0;
InitialCatalog=Serene_Default_v1;IntegratedSecurity=True"
providerName="System.Data.SqlClient"/>
</connectionStrings>
StartingSerene
30

ASP.NETCore
In appsettings.jsonfileyou'llfindDefaultandNorthwindconnectionentries:
"Data":{
"Default":{
"ConnectionString":"Server=(localdb)\\MsSqlLocalDB;Database=Serene2_Default_v1;
IntegratedSecurity=true",
"ProviderName":"System.Data.SqlClient"
},
"Northwind":{
"ConnectionString":"Server=(localdb)\\MsSqlLocalDB;Database=Serene2_Northwind_v
1;IntegratedSecurity=true",
"ProviderName":"System.Data.SqlClient"
}
}
FixingConnectionStrings
(localdb)\v11.0correspondstodefaultSQLServer2012LocalDBinstance,while
(localdb)\MsSqlLocalDBisaninstanceofSQL2014+LocalDB.
Ifyoudon'thaveSQLLocalDB2012,youcaninstallitfrom:
http://www.microsoft.com/en-us/download/details.aspx?id=29062
VisualStudio2015comeswithSQLServer2014LocalDB.It'sdefaultinstancenameis
renamedtoMsSqlLocalDBbydefault.Thus,ifyouhaveVS2015,trychangingconnection
stringsfrom (localdb)\v11.0to (localdb)\MsSqlLocalDB.
<connectionStrings>
<addname="Default"connectionString="DataSource=(LocalDb)\MsSqlLocalDB;
InitialCatalog=Serene_Default_v1;IntegratedSecurity=True"
providerName="System.Data.SqlClient"/>
</connectionStrings>
Ifyoustillhaveanerror,openanadministrativecommandpromptandtype
>sqllocaldbinfo
Thiswilllistlocaldbinstanceslike:
MSSqlLocalDB
test
StartingSerene
31

Ifyoudon'thaveMsSqlLocalDBlisted,youcancreateit:
>sqllocaldbcreateMsSqlLocalDB
IfyouhaveanotherSQLserverinstance,forexampleSQLExpress,changedatasourceto
.\SqlExpress:
<connectionStrings>
<addname="Default"connectionString="DataSource=.\SqlExpress;
InitialCatalog=Serene_Default_v1;IntegratedSecurity=True"
providerName="System.Data.SqlClient"/>
</connectionStrings>
YoucanalsouseanotherSQLserver.Justchangetheconnectionstring.
PerformthesestepsforbothDefaultandNorthwinddatabases.
StartingSerene
32

ATourOfSereneFeatures
AfterloggingtoSerene,youaregreetedwiththedashboardpage.
ThispageistakenasasamplefromfreeAdminLTE
(https://almsaeedstudio.com/themes/AdminLTE/index.html)theme.
Thepagecontent,exceptsomenumberscalculatedfromNorthwindtables,containsrandom
data.
Thereisanaccordionnavigationmenuonleftwhichhasasearchfeaturebyinputaboveit.
We'lltalkabouthowtocustomizeitemsthereinlatersections.
Ontopnavigation,thereissitenameonleft,alongwithadropdowncontainingcurrentuser
nameonright,andasettingsbuttonthroughwhichwechangechangethemeoractive
language.
Theming
Localization
ATourOfSereneFeatures
33

Theming
Sereneinitiallystartswithadark/bluetheme.Ontoprightofthescreen,nexttousername,
clickthesettingsbuttonandchangethemetoanotherone.
ThisfeatureisimplementedbyreplacingabodyCSSclass.
Ifyoulookatthesource,youmayspotaskinclasslikebelowinside <body>tag:
<bodyid="s-DashboardPage"class="fixedsidebar-minihold-transition
skin-bluehas-layout-event">
Whenyouselectthelightyellowskin,itactuallychangestothis:
<bodyid="s-DashboardPage"class="fixedsidebar-minihold-transition
skin-yellow-lighthas-layout-event">
Thisisdoneinmemorysonopagereloadisrequired.
Theming
34

Alsocookie,"ThemePreference""withthecontent"yellow-light"isaddedtoyourbrowser.So
nexttimeyoulaunchSerene,itwillrememberyourpreferenceandstartwithalightyellow
theme.
Theseskinfilesarelocatedunder"Content/adminlte/skins/"oftheSerene.Webproject.If
youlookthereyoucanseefileswithnames:
_all-skins.less
skin.black-light.less
site.blue.less
site.yellow-light.less
site.yellow.less
WeareusingLESSforCSSgenerationsoyoushouldtryeditingLESSfiles,notCSS.Next
timeyoubuildyourproject,LESSfileswillbecompiledtoCSS(usingLess.jscompilerfor
Node).
ThisoperationisconfiguredwithabuildstepinSerene.Web.csprojfile:
...
<TargetName="CompileSiteLess"AfterTargets="AfterBuild">
<ExecCommand=""$(ProjectDir)tools\node\lessc.cmd"
"$(ProjectDir)Content\site\site.less">
"$(ProjectDir)Content\site\site.css"">
</Exec>
</Target>
...
Heresite.lessfileiscompiledtoitscorrespondingcssfileinthesamedirectory.
Seehttp://lesscss.org/formoreinformationonLESScompileranditssyntax.
Theming
35

Localization
Sereneallowsyoutochangetheactivelanguagefromtoprightsettingsmenu.
TrychangingactivelanguagetoSpanish.
Idon'tspeakSpanishandusedmachinetranslation,sosorryforerrors...
Whenyouchangedthelanguage,pageisreloaded,unlikethethemeselectionwhereno
pagereloadisrequired.
Serene,alsoaddedacookie,"LanguagePreference"withcontent"es"toyourbrowser,so
nexttimeyouvisitthesite,itwillrememberyourlastselectionandstartwithSpanish.
WhenyoulaunchedSerenefirsttime,youmighthaveseenthesiteinEnglish,butitisalso
possiblethatitstartedinSpanish,TurkishorRussian(thesearecurrentlyavailablesample
languages)ifyouhaveanoperatingsystemorbrowserofthatlanguage.
Thisiscontrolledbyaweb.configsetting:
<globalizationculture="en-US"uiCulture="auto:en-US"/>
Localization
36

HerewesetUIculturetoautomatic,whileen-USisafallback(ifsystemcan'tdetermineyour
browserlanguage).
Itisalsopossibletosetanotherlanguageasfallback:
<globalizationculture="en-US"uiCulture="auto:tr-TR"/>
Orsetalanguageasdefault,whatevervisitingusersbrowserlanguageis:
<globalizationculture="en-US"uiCulture="es"/>
Ifyoudon'twanttoletuserstochangeUIlanguage,youshouldremovethelanguage
selectiondropdown.
YoumayaddmorelanguagestothelanguageselectiondropdownbyusingLanguages
pageunderAdministrationmenu.
LocalizingUITexts
Sereneincludesabilitytotranslateitstextresourceslive.
ClickAdministrationthenTranslationslinkinnavigation.
Typenavigationintotopleftsearchboxtoseelistoftextsrelatedtonavigationmenu.
ChooseEnglishassourcelanguageandSpanishastargetlanguage.
TypeWelcomePageintolinewithNavigation.Dashboardlocaltextkey.
ClickSaveChanges.
Localization
37

WhenyouswitchtoSpanishlanguage,DashboardmenuitemwillbechangedtoWelcome
PageinsteadofSalpicadero.
Whenyousavedchanges,Serenecreateda user.texts.es.jsonfileinfolder
App_Data/textswithcontentlikebelow:
{
"Navigation.Dashboard":"WelcomePage"
}
Inthe ~/scripts/site/textsfolder,therearealsoothersimilarJSONfileswithdefault
translationsforSereneinterface:
site.texts.es.json
site.texts.invariant.json
site.texts.tr.json
Itisrecommendedtotransferyourtranslationsfromuser.texts.xx.jsonfilesto
site.texts.xx.jsonfilesbeforepublishing.Youcanalsokeepthemunderversioncontrol
thisway,ifApp_Datafolderisignored.
Localization
38

UserandRoleManagement
Serenehasuser,roleandrightsmanagementbuiltin.
ThisfeatureisnotembeddedinSerenityitself.Itisjustasample,soyoucanalways
implementanduseyourusermanagementofchoice.We'lltakealookathowin
followingchapters.
OpenAdministration/RolestocreaterolesAdministratorsandTranslators.
ClickNewRoleandandtypeAdministrators,thenclickSave.
RepeatitforTranslators.
ThenclickroleAdministratorstoopeneditform,andclickEditPermissonsbuttontomodify
itspermissions.Checkallboxestogranteverypermissontothisrole,thenclickOK.
UserandRoleManagement
39

RepeatsamestepsfortheTranslationsrolebutthistimegrantonlytheAdministration:
LanguagesandTranslationspermission.
NavigatetoAdministration/UserManagementpagetoaddmoreusers.
Clickadminusertoedititsdetails.
UserandRoleManagement
40

Hereyoucanchangeadmindetailslikeusername,displayname,email.
Youcanalsochangeitspassword(whichisserenitybydefault)bytypingintoPasswordand
ConfirmPasswordinputsandclickingUpdate.
Youcanalsodeleteitbutthiswouldmakeyoursiteunusableasyouwouldn'tbeableto
login.
adminisaspecialuserinSerene,asithasallpermissionsevenifnoneisexplicitlygranted
tohim.
Letscreateanotheroneandgrantroles/permissionstoit.
Closethisdialog,clicknewuserandtypetranslatorasusername.Fillinotherfieldsasyou'd
like,thenclickUpdate.
UserandRoleManagement
41

YoumayhavenoticedthereisaApplyChangesbuttonwithablackdiskiconwithout
title,nexttoSave.UnlikeSave,whenyouuseit,theformstaysopen,soyoucansee
howyourrecordlookslikeaftersaving,alsoyoucaneditrolesandpermissionsbefore
closingtheform.
NowclickTranslatorroletoopenitseditformandclickEditRoles.GranthimTranslators
roleandclickOK.
UserandRoleManagement
42

Whenyougrantaroletoauser,hegetsallpermissionsgrantedtotherole
automatically.ByclickingEditPermissionsandyoucanalsograntextrapermissions
explicitly.Youcanalsorevokearolepermissionfromauserexplicitly.
NowclosealldialogsandlogoutbyclickingadminontoprightofsiteandclickingLogout.
Trylogginginwithtranslatorandthepasswordyouset.
TranslatoruserwillonlyhaveaccesstoDashboard,ThemeSamples,Languagesand
Translationspages.
UserandRoleManagement
43

UserandRoleManagement
44

ListingPages
SerenehaslistingpagesandeditinginterfaceforNorthwinddatabase.Let'shavealookat
theProductspageunderNorthwindmodule.
Hereweseelistofproductssortedbyproductname(initialsortorder).
GridcomponentisSlickGridwithacustomizedtheme.
https://github.com/mleibman/SlickGrid
Youcanchangeorderbyclickingcolumnheaders.Tosortdescending,clickthesame
columnheaderagain.
Tosortbymultiplecolumns,youcanuseShift+Click.
HereiswhatitlookslikeaftersortingbyCategorythenSuppliercolumns:
ListingPages
45

Whenyouchangedsortorder,gridloadeddatafromaservicewithanAJAXrequest.
Whenyouopenthepagefirsttime,initialrecordswerealsoloadedbyanAJAXcall.
Bydefaultgridloadsrecordsby100pagesize.Onlyrecordsincurrentpageareloadedfrom
server.Inthesampleimage,ichangedpagesizeto20(bottomleftofgrid)toshowpagingin
effect.
Ontopleftofthegrid,youcantypesomethingtodoasimplesearch.
Typecoffeeforexampletoseeproductscontainingitintheirnames.
Itsearchedinproductnamefield.Itisalsopossibletouseanother,ormultiplefieldsfor
quicksearch.We'llseehowinlaterchapters.
ListingPages
46

Ontoprightofthegrid,therearequickfilteringdropdownsforSupplierandCategoryfields.
DropdowncomponentusedisSelect2
https://github.com/select2/select2
ChooseSeafoodasCategoryanditwillshowonlyproductsinthatcategory.
Allsorting,pagingandfilteringisdoneonserversidewithdynamicSQLqueries
generatedbySerenityservicehandlers.
Itisalsopossibletofilterbyanycolumnbyclickingeditfilterlinkatbottomrightofthegrid.
Hereyoucanaddcriteriabyanycolumnbyclickingaddcriteriaandchoosingcolumnname,
choosingcomparisonoperatorfromnextdropdown,andsettingavalue.
ListingPages
47

Somevalueeditorsaresimpletextboxeswhilesomeothersmayhavedropdownsandother
customeditorsdependingoncolumntype.
Itisalsopossibletochangeandtoorbyclickingonit.
Youcanalsogroupcriteriabyclickingparenthesis.Groupswillhaveabitmorespace
betweenthemthanordinarylines.
ListingPages
48

EditDialogs
WhenyouclickaproductnameinProductspage,aneditingdialogforthatrowisdisplayed.
Thisdialogisshownonclientside,thereisnopost-backhappening.Datafortheclicked
entityisloadedfromserversideusinganAJAXrequest(onlydata,notmarkup).Dialogitself
isacustomizedversionofjQueryUIdialog.
Inthisformwehavethreecategoriesforfields:General,PricingandStatus.Byclicking
categorylinksontopbluebaryoucannavigatetostartofthatcategory.
Eachformfieldoccupiesarowwithlabelandeditor.Youmaychoosetoshowmorethan
onefieldinarowifrequired(withCSS).
Fieldsmarkedwith"*"arerequired(cannotbeempty).
Eachfieldhasaspecifictypeofeditortailoredtoitsdatatypelikestring,imageupload,
checkbox,selectetc.
WewouldseesuchanHTMLcodeifwelookedatthesource(simplifiedforbrevity):
EditDialogs
49

<divclass="fieldProductName">
<label>ProductName</label>
<inputtype="text"class="editors-StringEditor"/>
</div>
<divclass="fieldProductImage">
<labelclass="caption">ProductImage</label>
<divclass="editors-ImageUploadEditor">
...
</div>
</div>
...
Everyfieldhasaseparate"div"ofitsownwithclass"field".Insidethisdiv,thereisa"label"
elementandanotherelement(input,select,div)thatchangeswiththeeditortypeforthat
field.
Wecanlookattheclassnamesoftheseelementstoidentifytheireditortypes(e.g.s-
StringEditor,s-ImageUploadEditor)
Inthetoolbarwehaveabuttontosavecurrententityandclosedialog(Update),nexttoita
smalleronethatkeepsdialogopenaftersaveandanotheronetodeletecurrententity
(obviously).
MostSerenityeditingdialogshasthisfamiliarinterface,thoughyoucanalwayscustomize
buttons,fields,addtabs,andotherinterfaceelements.
EditDialogs
50

Tutorial:MovieDatabase
Let'screateeditinginterfaceforsomesitesimilartoIMDBwithSerenity.
Youcanfindsourcecodeforthistutorialat:
https://github.com/volkanceylan/MovieTutorial
CreateanewprojectnamedMovieTutorial
InVisualStudioclickFile->NewProject.MakesureyouchooseSerenetemplate.Type
MovieTutorialasnameandclickOK.
YoumightalsochooseSerene(AspNetCore)version.Afewthingswillbedifferent.
We'lltrytolistthosedifferences.
InSolutionexplorer,youshouldseeoneprojectwithnameMovieTutorial.Web.
MovieTutorial.WebprojectisanASP.NETMVC(orCore)applicationthatcontainsserver
sidecodeplusstaticresourceslikecssfiles,imagesetc.
MovieTutorial.Webalsohasatsconfig.jsonfileatroot,whichspecifiesthatitisalsoa
TypeScriptproject.All.tsfilesunderModules/andScripts/directoriesarecompiledonsave,
andtheiroutputsarecombinedintoascriptfileunderscripts/site/folderwithname
MovieTutorial.Web.js.
PleasemakesurethatyouhaveTypeScript2.5.2+installed.Checkyourversionfrom
VisualStudioExtensionsdialog.
ToinstallTypeScript2.5.2+inVisualStudio,you'llneedtoinstallVisualStudio2015
Update3.
Downloaditslatestversionfromhttp://www.typescriptlang.org/#download-linksforyour
VisualStudio.
AlsocheckprerequisitesinGettingStartedsection.
MovieDatabase
52

CreatingMovieTable
TostorelistofmoviesweneedaMovietable.Wecouldcreatethistableusingold-school
techniqueslikeSQLManagementStudiobutweprefercreatingitasamigrationusing
FluentMigrator:
FluentMigratorisamigrationframeworkfor.NETmuchlikeRubyonRailsMigrations.
Migrationsareastructuredwaytoalteryourdatabaseschemaandareanalternativeto
creatinglotsofsqlscriptsthathavetoberunmanuallybyeverydeveloperinvolved.
Migrationssolvetheproblemofevolvingadatabaseschemaformultipledatabases(for
example,thedeveloper’slocaldatabase,thetestdatabaseandtheproduction
database).DatabaseschemachangesaredescribedinclasseswritteninC#thatcan
becheckedintoaversioncontrolsystem.
Seehttps://github.com/schambers/fluentmigratorformoreinformationon
FluentMigrator.
PleaseNote
AsweareusingFluentMigratorinoursamples,someusersassumeSerenitydoesn'twork
withoutit.That'snotcorrect.Youdon'thavetousemigrations.Serenityhasnodirect
dependencyonmigrations.
Ifyoulike,insteadofusingthesemigrationsyoumaymanuallycreatetablesinSQL
ManagementStudio.
Youcouldalsoworkwithanexistingdatabase.
LocatingMigrationFolder
UsingSolutionExplorernavigatetoMigrations/DefaultDB.
CreatingMovieTable
53

Herewealreadyhaveseveralmigrations.AmigrationislikeaDMLscriptthatmanipulates
databasestructure.
DefaultDB_20141103_140000_Initial.csforexample,containsourinitialmigrationthat
createdNorthwindtablesandUserstable.
Createanewmigrationfileinthesamefolderwithname
DefaultDB_20160519_115100_MovieTable.cs.Youcancopyandchangeoneoftheexisting
migrationfiles,renameitandchangecontents.
Migrationfilename/classnameisactuallynotimportantbutrecommendedfor
consistencyandcorrectordering.
20160519_115100correspondstothetimewearewritingthismigrationin
yyyyMMdd_HHmmssformat.Itwillalsoactasauniquekeyforthismigration.
Ourmigrationfileshouldlooklikebelow:
CreatingMovieTable
54

usingFluentMigrator;
usingSystem;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160519115100)]
publicclassDefaultDB_20160519_115100_MovieTable:Migration
{
publicoverridevoidUp()
{
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();
}
publicoverridevoidDown()
{
}
}
}
MakesureyouusethenamespaceMovieTutorial.Migrations.DefaultDBasSerene
templateappliesmigrationsonlyinthisnamespacetothedefaultdatabase.
InUp()methodwespecifythatthismigration,whenapplied,willcreateaschemanamed
mov.Wewilluseaseparateschemaformovietablestoavoidclasheswithexistingtables.It
willalsocreateatablenamedMoviewith"MovieId,Title,Description..."fieldsonit.
WecouldimplementDown()methodtomakeitpossibletoundothismigration(dropmovie
tableandmovschemaetc),butforthescopeofthissample,letsleaveitempty.
Inabilitytoundoamigrationmightnothurtmuch,butdeletingatablebymistakecould
domoredamage.
OntopofourclassweappliedaMigrationattribute.
[Migration(20160519115100)]
CreatingMovieTable
55

Thisspecifiesauniquekeyforthismigration.Afteramigrationisappliedtoadatabase,its
keyisrecordedinaspecialtablespecifictoFluentMigrator([dbo].[VersionInfo]),sosame
migrationwon'tbeappliedagain.
Migrationkeyshouldbeinsyncwithclassname(forconsistency)butwithout
underscoreasmigrationkeysareInt64numbers.
Migrationsareexecutedinthekeyorder,sousingasortabledatetimepatternlike
yyyyMMddformigrationkeyslookslikeagoodidea.
Pleasemakesureyoualwaysusesamenumberofcharactersformigrationkeyse.g.14
(20160519115100).Otherwiseyourmigrationorderwillgetmessedup,andyouwillhave
migrationerrors,duetomigrationsrunninginunexpectedorder.
RunningMigrations
Bydefault,SerenetemplaterunsallmigrationsinMovieTutorial.Migrations.DefaultDB
namespace.Thishappensonapplicationstartautomatically.
ThecodethatrunsmigrationsareinApp_Start/SiteInitialization.csand
App_Start/SiteInitialization.Migrations.csfiles:
InAsp.NetCoreversion,theyareunderInitialization/Startup.csand
Initialization/DataMigrations.csfilesasthereisnoApp_StartfolderinASP.NETCore.
SiteInitialization.Migrations.cs(orDataMigrations.cs):
namespaceMovieTutorial
{
//...
publicstaticpartialclassSiteInitialization
{
privatestaticstring[]databaseKeys=new[]{"Default","Northwind"};
//...
privatestaticvoidEnsureDatabase(stringdatabaseKey)
{
//...
}
publicstaticboolSkippedMigrations{get;privateset;}
privatestaticvoidRunMigrations(stringdatabaseKey)
{
//...
//safetychecktoensurethatwearenotmodifyingan
CreatingMovieTable
56

//arbitrarydatabase.removethesetwolinesifyouwant
//MovieTutorialmigrationstorunonyourDB.
if(cs.ConnectionString.IndexOf(typeof(SiteInitialization).Namespace+
@"_"+databaseKey+"_v1",
StringComparison.OrdinalIgnoreCase)<0)
{
SkippedMigrations=true;
return;
}
//...
using(varsw=newStringWriter())
{
//...
varrunner=newRunnerContext(announcer)
{
Database=databaseType,
Connection=cs.ConnectionString,
Targets=newstring[]{
typeof(SiteInitialization).Assembly.Location},
Task="migrate:up",
WorkingDirectory=Path.GetDirectoryName(
typeof(SiteInitialization).Assembly.Location),
Namespace="MovieTutorial.Migrations."+databaseKey+"DB"
};
newTaskExecutor(runner).Execute();
}
}
}
}
Thereisasafetycheckondatabasenametoavoidrunningmigrationsonsome
arbitrarydatabaseotherthanthedefaultSerenedatabase(MovieTutorial_Default_v1).
Youcanremovethischeckifyouunderstandtherisks.Forexample,ifyouchange
Northwindconnectioninweb.configtoyourownproductiondatabase,migrationswill
runonitandyouwillhaveNorthwindetctablesevenifyoudidn'tmeanto.
NowpressF5torunyourapplicationandcreateMovietableindefaultdatabase.
VerifyingThattheMigrationisRun
UsingSqlServerManagementStudioorVisualStudio->ConnectionToDatabase,opena
connectiontoMovieTutorial_Default_v1databaseinserver(localdb)\MsSqlLocalDBor
(localdb)\v11.0dependingonversionyouuse.
CreatingMovieTable
57

(localdb)\v11.0isaLocalDBinstancecreatedbySQLServer2012LocalDB.
(localdb)\MsSqlLocalDBisaninstancecreatedbySQL2014+LocalDB.
Ifyoudidn'tinstallLocalDByet,downloaditfromhttps://www.microsoft.com/en-
us/download/details.aspx?id=29062.
IfyouhaveSQLServer2014LocalDB,yourservernamewouldbe
(localdb)\MSSqlLocalDBor(localdb)\v12.0,sochangeconnectionstringinweb.config
file.
YoucouldalsouseanotherSQLserverinstance,justchangetheconnectionstringto
targetserverandremovethemigrationsafetycheck.
Youshouldsee[mov].[Movies]tableinSQLobjectexplorer.
Alsowhenyouviewdatain[dbo].[VersionInfo]table,Versioncolumninthelastrowofthe
tableshouldbe20160519115100.Thisspecifiesthatthemigrationwiththatversionnumber
(migrationkey)isalreadyexecutedonthisdatabase.
So,evenifyouchangemigrationsourcecode,thatmigrationwon'teverrunagainin
thisdatabase.TrytoavoidmodifyingmigrationsaftertheyrunonyourDB.Createa
newmigrationifpossible.
Usually,youdon'thavetodothesechecksaftereverymigration.Hereweshowthese
toexplainwheretolook,incaseyou'llhaveanytroubleinthefuture.
CreatingMovieTable
58

GeneratingCodeForMovieTable
SerenityCodeGenerator(ASP.NETMVC)
ThesestepsappliesonlytoASP.NETMVCversion,notASP.NETCoreversion.Keep
readingtoseehowtorunSergeninASP.NETCoreversion.
Aftermakingsurethatourtableexistsinthedatabase,wewilluseSerenityCodeGenerator
(sergen.exe)togenerateinitialeditinginterface.
InVisualStudio,openPackageManagerConsolebyclickingView=>OtherWindows=>
PackageManagerConsole.
TypesergenandpressEnter.
ResolvingSergenisnotRecognizedIssue
SometimesNuGetpackagemanagercan'tsetPATHcorrectlyandyoumaygetanerrorlike
belowwhiletryingtoexecuteSergen.
Unfortunately,thisisabugofVisualStudio/NuGetandisnotrelatedtoSerenityorSergen
itself.
Mostofthetimes,restartingVisualStudiomightresolvetheissue.
Ifitdoesn't,youmayopenSergen.exefromWindowsExplorer.RightclickonMovieTutorial
solutioninSolutionExplorer,clickOpenInFileExplorer.Sergen.exeisunder
packages\Serenity.CodeGenerator.X.Y.Z\toolsdirectory.
GeneratingCodeForMovieTable
59

SergenUI
SettingProjectLocation
WhenyoufirstrunSergen,WebProjectfieldwillbeprefilledforyouto:
GeneratingCodeForMovieTable
60

..\..\..\MovieTutorial\MovieTutorial.Web\MovieTutorial.Web.csproj
Ifyouchangethisvalueandotheroptions,andgenerateyourfirstpage,youwon'thaveto
setthemagain.AlltheseoptionswillbesavedinSerenity.CodeGenerator.configinyour
solutiondirectory.
Thisvalueisrequired,asSergenwillautomaticallyincludegeneratedfilestoyourWEB
project.
Scriptprojectfieldshouldbeemptyforv2.1+.ThisisforusersofolderSerene,who
mightstillhavecodethatwaswrittenwithSaltarallecompiler,insteadofTypeScript.
RootNamespaceOption
YourrootnamespaceoptionissettotheSolutionnameyouused,e.g.MovieTutorial.Ifyour
projectnameisMyProject.Web,yourrootnamespaceisMyProjectbydefault.
Thisiscriticalsomakesureyoudon'tsetittoanythingdifferent,asbydefault,Serene
templateexpectsallgeneratedcodetobeunderthisrootnamespace.
ItisalsoveryimportanttounderstandthatRootNamespaceiscasesensitiveandmust
exactlymatchyourprojectname,e.g.notmovietutorialormovieTutorialbutMovieTutorial.
Thisoptionisalsosaved,sonexttimeyouwon'thavetofillitin.
ChoosingConnectionString
OnceyousetWebprojectname,Sergenpopulatesconnectiondropdownwithconnection
stringsfromyourweb.configfile.WemighthaveDefaultandNorthwindinthere.Choose
Defaultone.
SelectingTableToGenerateCodeFor
Sergencangeneratecodeformultipletables,butwe'llgenerateforonlyonenow.Oncewe
chooseconnectionstring,tablegridispopulatedwithtablenamesfromthatdatabase.
MarkcheckboxnexttoMovietable.
Identifier
Thisusuallycorrespondstothetablenamebutsometimestablenamesmighthave
underscoresorotherinvalidcharacters,soyoudecidewhattonameyourentityin
generatedcode(avalididentifiername).
GeneratingCodeForMovieTable
61

OurtablenameisMoviesoitisalsoavalidandfineC#identifier,solet'sleaveMovieasthe
entityidentifier.OurentityclasswillbenamedMovieRow.
Thisnameisalsousedinotherclassnames.Forexampleourpagecontrollerwillbenamed
MovieController.
Italsodeterminesthepageurl,inthissampleoureditingpagewillbeatURL
/MovieDB/Movie.
PleaseNote!
IdentifiermustalwaysbeinPascalcase,e.g.somethingthatstartswithaCAPITALletter.
myTable, mycoolTable, aTableareinvalidmodulenames. MyCoolTableisOK.
We'lladdavalidationtoSergenforthissoon.
SettingModuleName
InSerenityterms,amoduleisalogicalgroupofpages,sharingacommonpurpose.
Forexample,inSerenetemplate,allpagesrelatedtoNorthwindsamplebelongsto
Northwindmodule.
Pagesthatarerelatedtogeneralmanagementofsite,likeusers,rolesetc.belongsto
Administrationmodule.
Amoduleusuallycorrespondstoadatabaseschema,orasingledatabasebutthereis
nothingthatpreventsyoufromusingmultiplemodulesinasingledatabase/schema,orthe
opposite,multipledatabasesinonemodule.
Forthistutorial,wewilluseMovieDB(analogoustoIMDB)forallpages.
Modulenameisusedindeterminingnamespaceandurlofgeneratedpages.
Forexample,ournewpagewillbeunderMovieTutorial.MovieDBnamespaceandwilluse
/MovieDBrelativeurl.
PleaseNote!
ModulenamesmustalsobeinPascalcase,e.g.somethingthatstartswithaCAPITALletter.
myModule, mycoolmodule, aModuleareinvalidmodulenames. MyCoolModuleisfine.
PermissionKey
GeneratingCodeForMovieTable
62

InSerenity,accesscontroltoresources(pages,servicesetc.)arecontrolledbypermission
keyswhicharesimplestrings.Usersorrolesaregrantedthesepermissions.
OurMoviepagewillbeonlyusedbyadministrativeusers(ormaybelatercontent
moderators)solet'sleaveitasAdministration:Generalfornow.Bydefault,inSerene
template,onlytheadminuserhasthispermission.
ConnectionKeyParameter
Connectionkeyissettotheconnectionkeyofselectedconnectionstringinweb.configfile.
Youusuallydon'thavetochangeit,justleavedefault.
GeneratingCodeforFirstPage
Aftersettingparametersasshownintheimageabove(youonlyhavetosetModuleName,
otherswereprefilled),clickGenerateCodeforEntitybutton.
SergenwillgenerateseveralfilesandincludetheminMovieTutorial.Weband
MovieTutorial.Scriptprojects.
NowyoucancloseSergen,andreturntoVisualStudio.
SerenityCodeGenerator(ASP.NETCore)
ThesestepsappliesonlytoASP.NETCoreversion,notASP.NETMVCversion.
AsASP.NETCorehascross-platformsupport,.NETCoreversionofSergenalsoneedsto
runinOSX/Linux/Windows.Thus,itsUIiscurrentlyconsolebased.
Wefirstneedtoopenacommandpromptatprojectfolder.RightclickMovieTutorial.Web
projectandclickOpenFolderinFileExplorer.
ClickFilemenuinfileexplorer,andclickOpenWindowsPowershellorOpenCommand
Prompt.
Youmayalsoinstallthisextension(https://marketplace.visualstudio.com/items?
itemName=MadsKristensen.OpenCommandLine)toeasilyopenacommandlinenext
time.Ican'tunderstandwhythereisstillnotsuchanoptioninVisualStudioitself.
Makesureyouareat MovieTutorial.Webdirectory.
Type dotnetsergengtoopenSergencodegenerationUI(console).
GeneratingCodeForMovieTable
63

Ifyoureceiveanerror,type dotnetrestorebeforerunningsergen.
Sergenwilllistconnectionsinappsettings.jsonfile.
YoucanuseTABcompletion,e.g.typeDandpressTABtocompleteDefault.
AfterpressingEnteryou'llgetalistoftablesinthatdatabase:
Clear dbo.usingbackspace,andtype mov.Movieortype manduseTABcompletionto
select mov.Movie,thenpressENTER.
Next,Sergenwillaskforamodulename,enterMovieDB.
Whenprompted,enterMovieasidentifier.
LeavepermissionasAdministration:Generalandpressenteragain.
GeneratingCodeForMovieTable
64

Sergenwillaskyouwhichfilestogenerate,leavedefaultRSUoption(e.g.Row,Serviceand
UserInterface)andpressENTERlasttime.
Nowyoucanquitcommandprompt,andreturnbacktoVisualStudio(orNotepad:)
AfterGeneratingCode
Asprojectismodified,VisualStudiomayaskifyouwanttoreloadchanges,clickReloadAll.
REBUILDtheSolutionandthenpressF5tolaunchapplication.
Useadminasusername,andserenityaspasswordtologin.
WhenyouaregreetedwithDashboardpage,youwillnoticethatthereisanewsection,
MovieDBonthebottomofleftnavigation.
ClicktoexpanditandclickMovietoopenourfirstgeneratedpage.
Nowtryaddinganewmovie,thantryupdatinganddeletingit.
Sergengeneratedcodeforourtable,anditjustworkswithoutwritingasinglelineofcode.
GeneratingCodeForMovieTable
65

Thisdoesn'tmeanidon'tlikewritingcode.Incontrast,iloveit.Actuallyi'mnotafanof
mostdesignersandcodegenerators.Thecodetheyproduceisusuallyunmanagable
mess.
Sergenjusthelpedushereforinitialsetupwhichisrequiredforlayeredarchitecture
andplatformstandards.Wewouldhavetocreateabout10filesforentity,repository,
page,endpoint,grid,formetc.Alsoweneededtodosomesetupinafewotherplaces.
Evenifwedidcopypasteandreplacecodefromsomeotherpage,itwouldbeerror
proneandtakeabout5-10mins.
ThecodefilesSergengenerateshasminimumcodewiththeabsolutebasics.Thisis
thankstothebaseclassesinSerenitythathandlesthemostlogic.Oncewegenerate
codeforsometable,we'llprobablyneveruseSergenagain(forthistable),andmodify
thisgeneratedcodetoourneeds.We'llseehow.
GeneratingCodeForMovieTable
66

CustomizingMovieInterface
CustomizingFieldCaptions
Inourmoviegridandform,wehaveafieldnamedRuntime.Thisfieldexpectsaninteger
numberinminutes,butinitstitlethereisnosignofthis.Let'schangeitstitletoRuntime
(mins).
Thereareseveralwaystodothis.Ouroptionsincludeserversideformdefinition,server
sidecolumnsdefinition,fromscriptgridcodeetc.Butlet'smakethischangeinthecentral
location,theentityitself,soitstitlechangeseverywhere.
WhenSergengeneratedcodeforMovietable,itcreatedaentityclassnamedMovieRow.
YoucanfinditatModules/MovieDB/Movie/MovieRow.cs.
HereisanexcerptfromitssourcewithourRuntimeproperty:
namespaceMovieTutorial.MovieDB.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Movie"),
InstanceName("Movie"),TwoLevelCached]
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
//...
[DisplayName("Runtime")]
publicInt32?Runtime
{
get{returnFields.Runtime[this];}
set{Fields.Runtime[this]=value;}
}
//...
}
}
We'lltalkaboutentities(orrows)later,let'snowfocusonourtargetandchangeits
DisplayNameattributevalueto*Runtime(mins)":
CustomizingMovieInterface
67

namespaceMovieTutorial.MovieDB.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Movie"),InstanceName("Movie"),
TwoLevelCached]
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
//...
[DisplayName("Runtime(mins)")]
publicInt32?Runtime
{
get{returnFields.Runtime[this];}
set{Fields.Runtime[this]=value;}
}
//...
}
}
Nowbuildsolutionandrunapplication.You'llseethatfieldtitleischangedinbothgridand
dialog.
Columntitlehas"..."initascolumnisnotwideenough,thoughitshintshowsthefull
title.We'llseehowtohandlethissoon.
OverridingColumnTitleandWidth
Sofarsogood,whatifwewantedtoshowanothertitleingrid(columns)ordialog(form).
Wecanoverrideitcorrespondingdefinitionfile.
CustomizingMovieInterface
68

Let'sdoitoncolumnsfirst.NexttoMovieRow.cs,youcanfindasourcefilenamed
MovieColumns.cs:
namespaceMovieTutorial.MovieDB.Columns
{
//...
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieColumns
{
[EditLink,DisplayName("Db.Shared.RecordId"),AlignRight]
publicInt32MovieId{get;set;}
//...
publicInt32Runtime{get;set;}
}
}
YoumaynoticethatthiscolumnsdefinitionisbasedontheMovieentity(BasedOnRow
attribute).
Anyattributewrittenherewilloverrideattributesdefinedintheentityclass.
Let'saddaDisplayNameattributetotheRuntimeproperty:
namespaceMovieTutorial.MovieDB.Columns
{
//...
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieColumns
{
[EditLink,DisplayName("Db.Shared.RecordId"),AlignRight]
publicInt32MovieId{get;set;}
//...
[DisplayName("RuntimeinMinutes"),Width(150),AlignRight]
publicInt32Runtime{get;set;}
}
}
Nowwesetcolumncaptionto"RuntimeinMinutes".
Weactuallyaddedtwomoreattributes.
Onetooverridecolumnwidthto150px.
Serenityappliesanautomaticwidthtocolumnsbasedonfieldtypeandcharacter
length,unlessyousetthewidthexplicitly.
CustomizingMovieInterface
69

Andanotheronetoaligncolumntoright(AlignCenter,AlignLeftisalsoavailable).
Let'sbuildandrunagain,thanweget:
Formfieldtitlestayedsame,whilecolumntitlechanged.
Ifwewantedtooverrideformfieldtitleinstead,wewoulddosimilarstepsin
MovieForm.cs
ChangingEditorTypeForDescriptionandStoryline
DescriptionandStorylinefieldscanbeabitlongercomparedtoTitlefield,soletschange
theireditortypestoatextarea.
OpenMovieForm.csinthesamefolderwithMovieColumns.csandMovieRow.cs.
CustomizingMovieInterface
70

namespaceMovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieForm
{
publicStringTitle{get;set;}
publicStringDescription{get;set;}
publicStringStoryline{get;set;}
publicInt32Year{get;set;}
publicDateTimeReleaseDate{get;set;}
publicInt32Runtime{get;set;}
}
}
andaddTextAreaEditorattributestoboth:
namespaceMovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieForm
{
publicStringTitle{get;set;}
[TextAreaEditor(Rows=3)]
publicStringDescription{get;set;}
[TextAreaEditor(Rows=8)]
publicStringStoryline{get;set;}
publicInt32Year{get;set;}
publicDateTimeReleaseDate{get;set;}
publicInt32Runtime{get;set;}
}
}
IleftmoreeditingrowsforStoryline(8)comparedtoDescription(3)asStorylineshouldbe
muchlonger.
Afterrebuildandrun,wehavethis:
CustomizingMovieInterface
71

Serenehasseveraleditortypestochoosefrom.Someareautomaticallypickedbasedon
fielddatatype,whileyouneedtoexplicitlysetothers.
Youcanalsodevelopyourowneditortypes.Youcantakeexistingeditortypesasbase
classes,ordevelopyourownfromstratch.We'llseehowinfollowingchapters.
Aseditorsbecameabithigher,formheightexceededthedefaultSerenityformheight(which
isabout260px)andnowwehaveaverticalscrollbar.Let'sremoveit.
SettingInitialDialogSizeWithCSS(Less)
SergengeneratedsomeCSSforourmoviedialogin
MovieTutorial.Web/Content/site/site.lessfile.
Ifyouopenit,andscrolltobottom,youwillseethis:
/*-------------------------------------------------------------------------*/
/*APPENDEDBYCODEGENERATOR,MOVETOCORRECTPLACEANDREMOVETHISCOMMENT*/
/*-------------------------------------------------------------------------*/
.s-MovieDB-MovieDialog{
>.size{width:650px;}
.caption{width:150px;}
}
CustomizingMovieInterface
72

Youcansafelyremovethe3commentlines(appendedbycodegenerator...).Thisisjusta
reminderforyoutomovethemtoabetterplacelikeasite.movies.lessfilespecifictothis
module(recommended).
Theserulesareappliedtoelementswith.s-MovieDB-MovieDialogclass.OurMoviedialog
hasthisclassbydefault,whichisgeneratedby"s-"+ModuleName+"-"+ClassName.
Inthesecondlineitisspecifiedthatthisdialogis650pxwidebydefault.
Inthirdline,wespecifythatfieldlabelsshouldbe150px(@l:150px).
Let'schangeourinitialdialogheightto500px(indesktopmode),soitwon'trequireavertical
scrollbar:
.s-MovieDialog{
>.size{width:650px);height:500px;}
.caption{width:150px;}
}
Forthischangetobeappliedtoyourdialog,youneedtobuildsolution.Asthis"site.less"file
iscompiledtoa"site.css"fileonbuild.Nowbuildandrefreshthepage.
Whatimeanbydesktopmodeabovewillbecomeclearersoon.Serenitydialogsare
responsivebydefault.Let'sresizeourbrowserwindowtoawidthabout350px.I'lluse
mobilemodeofmyChromebrowsertoswitchtoiPhone6:
CustomizingMovieInterface
73

AndnowaniPadinlandscapemode:
CustomizingMovieInterface
74

So,theheightwesethereisonlymeaningfullfordesktopmode.Dialogwillturnintoa
responsive,devicesizespecificmodeinmobile,withouthavingtomesswithCSS@media
queries.
ChangingPageTitle
OurpagehastitleofMovie.Let'schangeittoMovies.
OpenMovieRow.csagain.
namespaceMovieTutorial.MovieDB.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Movie"),InstanceName("Movie"),
TwoLevelCached]
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
[DisplayName("MovieId"),Identity]
publicInt32?MovieId
ChangeDisplayNameattributevaluetoMovies.Thisisthenamethatisusedwhenthis
tableisreferenced,anditisusuallyapluralname.Thisattributeisusedfordetermining
defaultpagetitle.
ItisalsopossibletooverridethepagetitleinMoviePage.Index.cshtmlfilebutasbefore,
weprefertodoitfromacentrallocationsothatthisinformationcanbereusedinother
places.
InstanceNamecorrespondstosingularnameandisusedinNewRecord(NewMovie)
buttonofthegridandalsodeterminesthedialogtitle(e.g.EditMovie).
namespaceMovieTutorial.MovieDB.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Movies"),InstanceName("Movie"),
TwoLevelCached]
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
[DisplayName("MovieId"),Identity]
publicInt32?MovieId
CustomizingMovieInterface
75

HandlingMovieNavigation
SettingNavigationItemTitleandIcon
WhenSergengeneratedcodeforMovietable,italsocreatedanavigationitementry.In
Serene,navigationitemsarecreatedwithspecialassemblyattributes.
OpenMoviePage.csinthesamefolder,ontopofityou'llfindthisline:
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue,"MovieDB/Movie",
typeof(MovieTutorial.MovieDB.Pages.MovieController))]
namespaceMovieTutorial.MovieDB.Pages
{
//...
Firstargumenttothisattributeisdisplayorderforthisnavigationitem.Asweonlyhaveone
navigationiteminMoviecategoryyet,wedon'thavetomesswithorderingyet.
Secondparameterisnavigationtitlein"SectionTitle/LinkTitle"format.Sectionand
navigationitemsareseperatedwithaslash(/).
LetschangeittoMovieDatabase/Movies.
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue,"MovieDatabase/Movies",
typeof(MovieTutorial.MovieDB.Pages.MovieController),icon:"icon-camrecorder")]
namespaceMovieTutorial.MovieDB.Pages
{
//..
HandlingMovieNavigation
76

Wealsochangednavigationitemicontoicon-camcorder.Serenetemplatehastwosetsof
fonticons,SimpleLineIconsandFontAwesome.Hereweusedaglyphfromsimpleline
iconsset.
Toseelistofsimplelineiconsandtheircssclasses,visitlinkbelow:
http://thesabbir.github.io/simple-line-icons/
FontAwesomeisavailablehere:
https://fortawesome.github.io/Font-Awesome/icons/
ThereisalsoapageinSereneunderThemeSamples/UIElements/Iconscontaining
alistoftheseiconsets.
OrderingNavigationSections
AsourMovieDatabasesectionisautogeneratedlast,itisdisplayedatthebottomof
navigationmenu.
We'llmoveitbeforeNorthwindmenu.
Aswesawrecently,SergencreatedanavigationiteminMoviePage.cs.Ifnavigationitems
arescatteredthroughpageslikethis,itwouldbehardtoseethebigpicture(listofall
navigationitems)andorderthemeasily.
Sowemoveittoourcentrallocationwhichisat
MovieTutorial.Web/Modules/Common/Navigation/NavigationItems.cs.
HandlingMovieNavigation
77

JustcutthebelowlinesfromMoviePage.cs:
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue,"MovieDatabase/Movies",
typeof(MovieTutorial.MovieDB.Pages.MovieController),icon:"icon-camrecorder")]
MoveitintoNavigationItems.csandmodifyitlikethis:
usingSerenity.Navigation;
usingNorthwind=MovieTutorial.Northwind.Pages;
usingAdministration=MovieTutorial.Administration.Pages;
usingMovieDB=MovieTutorial.MovieDB.Pages;
[assembly:NavigationLink(1000,"Dashboard",url:"~/",permission:"",
icon:"icon-speedometer")]
[assembly:NavigationMenu(2000,"MovieDatabase",icon:"icon-film")]
[assembly:NavigationLink(2100,"MovieDatabase/Movies",
typeof(MovieDB.MovieController),icon:"icon-camrecorder")]
[assembly:NavigationMenu(8000,"Northwind",icon:"icon-anchor")]
[assembly:NavigationLink(8200,"Northwind/Customers",
typeof(Northwind.CustomerController),icon:"icon-wallet")]
[assembly:NavigationLink(8300,"Northwind/Products",
typeof(Northwind.ProductController),icon:"icon-present")]
//...
Herewealsodeclaredanavigationmenu(MovieDatabase)withfilmicon.Whenyoudon't
haveanexplicitlydefinednavigationmenu,Serenityimplicitlycreatesone,butinthiscase
youcan'tordermenuyourself,orsetmenuicon.
Weassigneditadisplayorderof2000sothismenuwilldisplayjustafterDashboardlink
(1000)butbeforeNorthwindmenu(8000).
WeassignedourMovieslinkadisplayordervalueof2100butitdoesn'tmatterrightnow,as
wehaveonlyonenavigationitemunderMovieDatabasemenuyet.
Firstlevellinksandnavigationmenusaresortedaccordingtotheirdisplayorderfirst,
thensecondlevellinksamongtheirsiblings.
Hereishowitlookslikeafterthesechanges:
HandlingMovieNavigation
78

TroubleshootingSomeIssueswithVisualStudio
Incaseyoudidn'tnoticealready,VisualStudiodoesn'tletyoumodifycodewhileyoursiteis
running.Alsoyoursitestopswhenyoustopdebugging,soyoucan'tkeepbrowserwindow
openandrefreshafterrebuilding.
Tosolvethisissue,weneedtodisableEditAndContinue(havenoideawhy).
RightClickMovieTutorial.Webproject,clickProperties,intheWebtab,uncheckEnableEdit
AndContinueunderDebuggers.
Unfortunately,thesolutionabovestopsworksinVisualStudio2015Update2.Only
workaroundsofarseemslikestartingwithoutdebugging,e.g.Ctrl+F5insteadofF5.
SolutionaboveonlyappliestoASP.NETMVCversion,notASP.NETCOREversion.
Also,onyoursite,topblueprogressbar(whichisaPace.jsanimation),keepsrunningallthe
timelikeitisstillloadingsomething.ItisthankstotheBrowserLinkfeatureofVisualStudio.
Todisableit,locateitsbuttoninVisualStudiotoolbarthatlookslikearefreshbutton(nextto
playiconwithbrowsernamelikeChrome),clickdropdownanduncheckEnableBrowser
Link.
It'salsopossibletodisableitwithaweb.configsetting
HandlingMovieNavigation
79

<appsettings>
<addkey="vs:EnableBrowserLink"value="false"/>
</appsettings>
Serene1.5.4andlaterhasthisinweb.configbydefault,soyoumightnotexperience
thisissue
I'mnotsureifthereisacorrespondingsettinginappsettings.jsonfileofASP.NETCore
version
HandlingMovieNavigation
80

CustomizingQuickSearch
AddingSeveralMovieEntries
Forthefollowingsections,weneedsomesampledata.Wecancopyandpastesomefrom
IMDB.
Ifyoudon'twanttowasteyourtimeenteringthissampledata,itisavailableasamigration
atthelinkbelow:
https://github.com/volkanceylan/MovieTutorial/blob/master/MovieTutorial/MovieTutorial.
Web/Modules/Common/Migrations/DefaultDB/DefaultDB_20160519_135200_SampleM
ovies.cs
Ifwetypedgointosearchbox,wewouldseetwomoviesarefiltered:TheGood,theBad
andtheUglyandTheGodfather.
IfwetypedGandalfwewouldn'tbeabletofindanything.
Bydefault,Sergendeterminesfirsttextualfieldofatableasthenamefield.Inmoviestable
itisTitle.ThisfieldhasaQuickSearchattributeonitthatspecifiesthattextsearchesshould
beperformedonit.
Thenamefieldalsodeterminesinitialsortingorderandshownineditdialogtitles.
Sometimes,firsttextualcolumnmightnotbethenamefield.Ifyouwantedtochangeitto
anotherfield,youwoulddoitinMovieRow.cs:
CustomizingQuickSearch
81

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
//...
StringFieldINameRow.NameField
{
get{returnFields.Title;}
}
}
Codegeneratordeterminedthatfirsttextual(string)fieldinourtableisTitle.Soitaddeda
INameRowinterfacetoourMoviesrowandimplementeditbyreturningTitlefield.Ifwanted
touseDescriptionasnamefield,wewouldjustreplaceit.
Here,Titleisactuallythenamefield,soweleaveitasis.ButwewantSerenitytosearch
alsoinDescriptionandStorylinefields.Todothis,youneedtoaddQuickSearchattributeto
thesefieldstoo,asshownbelow:
CustomizingQuickSearch
82

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
//...
[DisplayName("Title"),Size(200),NotNull,QuickSearch]
publicStringTitle
{
get{returnFields.Title[this];}
set{Fields.Title[this]=value;}
}
[DisplayName("Description"),Size(1000),QuickSearch]
publicStringDescription
{
get{returnFields.Description[this];}
set{Fields.Description[this]=value;}
}
[DisplayName("Storyline"),QuickSearch]
publicStringStoryline
{
get{returnFields.Storyline[this];}
set{Fields.Storyline[this]=value;}
}
//...
}
}
Now,ifwesearchforGandalf,we'llgetaTheLordoftheRingsentry:
CustomizingQuickSearch
83

QuickSearchattribute,bydefault,searcheswithcontainsfilter.Ithassomeoptionstomake
itmatchbystartswithfilterormatchonlyexactvalues.
Ifwewantedittoshowonlyrowsthatstartswithtypedtext,wewouldchangeattributeto:
[DisplayName("Title"),Size(200),NotNull,QuickSearch(SearchType.StartsWith)]
publicStringTitle
{
get{returnFields.Title[this];}
set{Fields.Title[this]=value;}
}
Herethisquicksearchfeatureisnotveryuseful,butforvalueslikeSSN,serialnumber,
identificationnumber,phonenumberetc,itmightbe.
Ifwewantedtosearchalsoinyearcolumn,butonlyexactintegervalues(1999matchesbut
not19):
[DisplayName("Year"),QuickSearch(SearchType.Equals,numericOnly:1)]
publicInt32?Year
{
get{returnFields.Year[this];}
set{Fields.Year[this]=value;}
}
YoumighthavenoticedthatwearenotwritinganyC#orSQLcodeforthesebasic
featurestowork.Wejustspecifywhatwewant,ratherthanhowtodoit.Thisiswhat
declarativeprogrammingis.
Itisalsopossibletoprovideuserwithabilitytodeterminewhichfieldshewantstosearch
on.
OpenMovieTutorial.Web/Modules/MovieDB/Movie/MovieGrid.tsandmodifyitlike:
CustomizingQuickSearch
84

namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerClass()
exportclassMovieGridextends
Serenity.EntityGrid<MovieRow,any>{
//...
constructor(container:JQuery){
super(container);
}
protectedgetQuickSearchFields():
Serenity.QuickSearchField[]{
return[
{name:"",title:"all"},
{name:"Description",title:"description"},
{name:"Storyline",title:"storyline"},
{name:"Year",title:"year"}
];
}
}
}
Onceyousavethatfile,we'llhaveadropdowninquicksearchinput:
UnlikepriorsampleswherewemodifiedServersidecode,thistimewedidchangesin
clientside,andmodifiedJavascript(TypeScript)code.
RunningT4Templates(.ttfiles,ASP.NETMVCVersion)
CustomizingQuickSearch
85

InpriorsampleweharcodedfieldnameslikeDescription,Storylineetc.Thismayleadto
typingerrorsifweforgotactualpropertynamesortheircasingatserverside(javascriptis
casesensitive).
SerenecontainssomeT4(.tt)filestotransfersuchinformationfromserverside(rowsetcin
C#)toclientside(TypeScript)forintellisensepurposes.
Beforerunningthesetemplates,pleasemakesurethatyoursolutionbuildssuccessfullyas
templatesusesyouroutputDLLfile(MovieTutorial.Web.dll)togeneratecode.
Afterbuildingyoursolution,clickonBuildmenu,thanTransformAllTemplates.
RunningT4Templates(ASP.NETCoreVersion)
Youdon'thavetotransformtemplatesinASP.NETCoreversion.Serenedoesit
automaticallyonbuild.
Actually,thereisn'tanyT4fileinASP.NETCoreversion.
Sofromnowon,whenwesaytransformtemplates,justbuildyourproject(ifyouuse
ASP.NETCoreversion).
Itisalsopossibletoopenacommandpromptinprojectdirectoryandtype dotnetsergent
totransformtemplatesmanually.
IntellisenseonTypeScriptCode(Aftertransforming
templates)
Wecannowuseintellisensetoreplacehardcodedfieldnameswithcompiletimechecked
versions:
CustomizingQuickSearch
86

namespaceMovieTutorial.MovieDB
{
//...
publicclassMovieGridextendsEntityGrid<MovieRow,any>
{
constructor(container:JQuery){
super(container);
}
protectedgetQuickSearchFields():Serenity.QuickSearchField[]
{
letfld=MovieRow.Fields;
return[
{name:"",title:"all"},
{name:fld.Description,title:"description"},
{name:fld.Storyline,title:"storyline"},
{name:fld.Year,title:"year"}
];
}
}
///...
}
Whataboutfieldtitles?Itisnotsocriticalasfieldnames,butcanbeusefulforlocalization
purposes(ifwelaterdecidetotranslateit):
namespaceMovieTutorial.MovieDB
{
//...
publicclassMovieGridextendsEntityGrid<MovieRow,any>
{
constructor(container:JQuery){
super(container);
}
protectedgetQuickSearchFields():Serenity.QuickSearchField[]{
letfld=MovieRow.Fields;
lettxt=(s)=>Q.text("Db."+
MovieRow.localTextPrefix+"."+s).toLowerCase();
return[
{name:"",title:"all"},
{name:fld.Description,title:txt(fld.Description)},
{name:fld.Storyline,title:txt(fld.Storyline)},
{name:fld.Year,title:txt(fld.Year)}
];
}
}
///...
}
CustomizingQuickSearch
87

Wemadeuseofthelocaltextdictionary(translations)availableatclientside.It'ssomething
likethis:
{
//...
"Db.MovieDB.Movie.Description":"Description",
"Db.MovieDB.Movie.Storyline":"Storyline",
"Db.MovieDB.Movie.Year":"Year"
//...
}
Localtextkeysforrowfieldsaregeneratedfrom"Db."+(LocalTextPrefixforRow)+"."+
FieldName.
Theirvaluesaregeneratedfrom[DisplayName]attributesonyourfieldsbybutmightbe
somethingelseinanothercultureiftheyaretranslated.
LocalTextPrefixcorrespondstoModuleName+"."+RowClassNamebydefault,butcanbe
changedinRowfieldsconstructor.
CustomizingQuickSearch
88

AddingaMovieKindField
IfwewantedtoalsokeepTVseriesandminiseriesinourmovietable,wewouldneed
anotherfieldtostoreit:MovieKind.
Aswedidn'tadditwhilecreatingtheMovietable,nowwe'llwriteanothermigrationtoaddit
toourdatabase.
Don'tmodifyexistingmigrations,theywon'trunagain.
CreateanothermigrationfileunderModules/Common/Migrations/DefaultDB/
DefaultDB_20160519_145500_MovieKind.cs:
usingFluentMigrator;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160519145500)]
publicclassDefaultDB_20160519_145500_MovieKind:Migration
{
publicoverridevoidUp()
{
Alter.Table("Movie").InSchema("mov")
.AddColumn("Kind").AsInt32().NotNullable()
.WithDefaultValue(1);
}
publicoverridevoidDown()
{
}
}
}
DeclaringaMovieKindEnumeration
NowasweaddedKindcolumntoMovietable,weneedasetofmoviekindvalues.Let's
defineitasanenumerationatMovieTutorial.Web/Modules/MovieDB/Movie/MovieKind.cs:
AddingaMovieKindField
89

usingSerenity.ComponentModel;
usingSystem.ComponentModel;
namespaceMovieTutorial.MovieDB
{
[EnumKey("MovieDB.MovieKind")]
publicenumMovieKind
{
[Description("Film")]
Film=1,
[Description("TVSeries")]
TvSeries=2,
[Description("MiniSeries")]
MiniSeries=3
}
}
AddingKindFieldtoMovieRowEntity
AswearenotusingSergenanymore,weneedtoaddamappinginourMovieRow.csfor
Kindcolumnmanually.AddfollowingpropertydeclarationinMovieRow.csafterRuntime
property:
[DisplayName("Runtime(mins)")]
publicInt32?Runtime
{
get{returnFields.Runtime[this];}
set{Fields.Runtime[this]=value;}
}
[DisplayName("Kind"),NotNull]
publicMovieKind?Kind
{
get{return(MovieKind?)Fields.Kind[this];}
set{Fields.Kind[this]=(Int32?)value;}
}
WealsoneedtodeclareaInt32FieldobjectwhichisrequiredforSerenityentitysystem.On
thebottomofMovieRow.cslocateRowFieldsclassandmodifyittoaddKindfieldafterthe
Runtimefield:
AddingaMovieKindField
90

publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyInt32FieldRuntime;
publicreadonlyInt32FieldKind;
publicRowFields()
:base("[mov].Movie")
{
LocalTextPrefix="MovieDB.Movie";
}
}
AddingKindSelectionToOurMovieForm
Ifwebuildandrunourprojectnow,we'llseethatthereisnochangeintheMovieform,even
ifweaddedKindfieldmappingtotheMovieRow.Thisisbecause,fieldsshown/editedinthe
formarecontrolledbydeclerationsinMovieForm.cs.
ModifyMovieForm.csasbelow:
namespaceMovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieForm
{
//...
publicMovieKindKind{get;set;}
publicInt32Runtime{get;set;}
}
}
Now,buildyoursolutionandrunit.Whenyoutrytoeditamovieoraddanewone,nothing
willhappen.Thisisanexpectedsituation.Ifyoucheckdevelopertoolsconsoleofyour
browser(F12,inspectelementetc.)you'llseesuchanerror:
YoumightnothavethiserrorwithASP.NETCoreversionasitautotransformsT4
UncaughtCan'tfindMovieTutorial.MovieDB.MovieKindenumtype!
PleaseNote!
AddingaMovieKindField
91

Wheneversuchathinghappens,e.g.somebuttonnotworking,yougotanemptypage,grid
etc,pleasefirstcheckbrowserconsoleforerrors,beforereportingit.
WhyWeHadThisError?
ThiserroriscausedbyMoveKindenumerationnotavailableclientside.Weshouldrunour
T4templatesbeforeexecutingourprogram.
NowinVisualStudio,clickBuild->TransformAllTemplatesagain.
Rebuildyoursolutionandexecuteit.Nowwehaveanicedropdowninourformtoselect
moviekind.
JustbuildprojectforASP.NETCoreversion,asthereisnoT4template
DeclaringaDefaultValueforMovieKind
AsKindisarequiredfield,weneedtofillitinAddMoviedialog,otherwisewe'llgeta
validationerror.
Butmostmovieswe'llstorearefeaturefilms,soitsdefaultshouldbethisvalue.
ToaddadefaultvalueforKindproperty,addaDefaultValueattributelikethis:
AddingaMovieKindField
92

[DisplayName("Kind"),NotNull,DefaultValue(MovieKind.Film)]
publicMovieKind?Kind
{
get{return(MovieKind?)Fields.Kind[this];}
set{Fields.Kind[this]=(Int32?)value;}
}
Now,inAddMoviedialog,KindfieldwillcomeprefilledasFilm.
AddingaMovieKindField
93

AddingMovieGenres
AddingGenreField
ToholdMoviegenresweneedalookuptable.ForKindfieldweusedanenumerationbut
thistimegenresmightnotbethatstatictodeclarethemasanenumeration.
Asusual,westartwithamigration.
Modules/Common/Migrations/DefaultDB/DefaultDB_20160519_154700_GenreTable.cs:
usingFluentMigrator;
usingSystem;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160519154700)]
publicclassDefaultDB_20160519_154700_GenreTable:Migration
{
publicoverridevoidUp()
{
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");
}
publicoverridevoidDown()
{
}
}
}
WealsoaddedaGenreIdfieldtoMovietable.
Actuallyamoviecanhavemultiplegenressoweshouldkeepitinaseparate
MovieGenrestable.Butfornow,wethinkitassingle.We'llseehowtochangeitto
multiplelater.
GeneratingCodeForGenreTable
AddingMovieGenres
94

Firesergen.exeusingPackageManagerConsoleagainandgeneratecodeforGenretable
withtheparametersshownbelow:
Useparametersshownwith dotnetsergengifyouareusingASP.NETCoreversion.
ThisscreenshotbelongstoanolderversionofSergen,justuseparametersshownin
newversion
Rebuildsolutionandrunit.We'llgetanewpagelikethis:
AddingMovieGenres
95

Asyouseeinscreenshot,itisgeneratedunderanewsectionMovieDBinsteadoftheone
werenamedrecently:MovieDatabase.
ThisisbecauseSergenhasnoideaofwhatcustomizationsweperformedonourMovie
page.SoweneedtomovieitunderMovieDatabasemanually.
OpenModules/Movie/GenrePage.cs,cutthenavigationlinkshownbelow:
[assembly:Serenity.Navigation.NavigationLink(int.MaxValue,"MovieDB/Genre",
typeof(MovieTutorial.MovieDB.Pages.GenreController))]
`
AndmoveittoModules/Common/Navigation/NavigationItems.cs:
//...
[assembly:NavigationMenu(2000,"MovieDatabase",icon:"icon-film")]
[assembly:NavigationLink(2100,"MovieDatabase/Movies",
typeof(MovieDB.MovieController),icon:"icon-camcorder")]
[assembly:NavigationLink(2200,"MovieDatabase/Genres",
typeof(MovieDB.GenreController),icon:"icon-pin")]
//...
AddingSeveralGenreDefinitions
Nowlet'saddsomesamplegenres.I'lldoitthroughmigration,tonottorepeatitinanother
PC,butyoumightwanttoaddthemmanuallythroughGenrepage.
AddingMovieGenres
96

usingFluentMigrator;
usingSystem;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160519181800)]
publicclassDefaultDB_20160519_181800_SampleGenres:Migration
{
publicoverridevoidUp()
{
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"
});
}
publicoverridevoidDown()
{
}
}
}
MappingGenreIdFieldinMovieRow
AswedidwithKindfieldbefore,GenreIdfieldneedstobemappedinMovieRow.cs.
AddingMovieGenres
97

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
[DisplayName("Kind"),NotNull,DefaultValue(1)]
publicMovieKind?Kind
{
get{return(MovieKind?)Fields.Kind[this];}
set{Fields.Kind[this]=(Int32?)value;}
}
[DisplayName("Genre"),ForeignKey("[mov].Genre","GenreId"),LeftJoin("g")]
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
[DisplayName("Genre"),Expression("g.Name")]
publicStringGenreName
{
get{returnFields.GenreName[this];}
set{Fields.GenreName[this]=value;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyInt32FieldKind;
publicreadonlyInt32FieldGenreId;
publicreadonlyStringFieldGenreName;
publicRowFields()
:base("[mov].Movie")
{
LocalTextPrefix="MovieDB.Movie";
}
}
}
}
HerewemappedGenreIdfieldandalsodeclaredthatithasaforeignkeyrelationtoGenreId
fieldin[mov].GenretableusingForeignKeyattribute.
IfwedidgeneratecodeforMovietableafterweaddedthisGenretable,Sergenwould
understandthisrelationbycheckingforeignkeydefinitionatdatabaselevel,and
generatesimilarcodeforus.
AddingMovieGenres
98

Wealsoaddedanotherfield,GenreNamethatisnotactuallyafieldinMovietable,butin
Genretable.
SerenityentitiesaremorelikeSQLviews.Youcanbringinfieldsfromothertableswithjoins.
ByaddingLeftJoin("g")attributetoMovieIdproperty,wedeclaredthatwheneverGenretable
needstobejoinedto,itsaliaswillbeg.
SowhenSerenityneedstoselectfromMoviestable,itwillproduceanSQLquerylikethis:
SELECTt0.MovieId,t0.Kind,t0.GenreId,g.NameasGenreName
FROMMoviest0
LEFTJOINGenregont0.GenreId=g.GenreId
ThisjoinwillonlybeperformedifafieldfromGenretablerequestedtobeselected,e.g.
itscolumnisvisibleinadatagrid.
ByaddingExpression("g.Name")ontopofGenreNameproperty,wespecifiedthatthisfield
hasanSQLexpressionofg.Name,thusitisaviewfieldoriginatingfromourgjoin.
AddingGenreSelectionToMovieForm
Let'saddGenreIdfieldtoourforminMovieForm.cs:
namespaceMovieTutorial.MovieDB.Forms
{
//...
[FormScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieForm
{
//...
publicInt32GenreId{get;set;}
publicMovieKindKind{get;set;}
}
}
Nowifwebuildandrunapplication,we'llseethataGenrefieldisaddedtoourform.The
problemis,itacceptsdataentryasaninteger.Wewantittouseadropdown.
It'sclearthatweneedtochangeeditortypeforGenreIdfield.
DeclaringaLookupScriptforGenres
ToshowaneditorforGenrefield,listofgenresinourdatabaseshouldbeavailableatclient
side.
AddingMovieGenres
99

Forenumerationvalues,itwassimple,wejustrunT4templates,andtheycopiedenum
declarationtoscriptside.
Herewecan'tdothesame.Genrelistisadatabasebaseddynamiclist.
Serenityhasnotionofdynamicscriptstomakedynamicdataavailabletoscriptsideinthe
formofruntimegeneratedscripts.
Dynamicscriptsaresimilartowebservices,buttheiroutputsaredynamicjavascript
filesthatcanbecachedonclientside.
Thedynamicherecorrespondstothedatatheycontain,nottheirbehavior.Unlikeweb
services,dynamicscriptscan'tacceptanyparameters.Andtheirdataissharedamong
allusersofyoursite.Theyarelikesingletonsorstaticvariables.
Youshouldn'ttrytowriteadynamicscript(e.g.lookup)thatactslikeawebservice.
TodeclareadynamiclookupscriptforGenretable,openGenreRow.csandmodifyitlike
below:
namespaceMovieTutorial.MovieDB.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Genre"),InstanceName("Genre"),
TwoLevelCached]
[ReadPermission("Administration")]
[ModifyPermission("Administration")]
[JsonConverter(typeof(JsonRowConverter))]
[LookupScript("MovieDB.Genre")]
publicsealedclassGenreRow:Row,IIdRow,INameRow
{
//...
}
Wejustaddedlinewith[LookupScript("MovieDB.Genre")].
Rebuildyourproject,launchit,afterloggingin,opendeveloperconsolebyF12.
TypeQ.getLookup('MovieDB.Genre')
andyouwillgetsomethinglikethis:
AddingMovieGenres
100

HereMovieDB.Genreisthelookupkeyweassignedtothislookupscriptwhendeclaringit
with:
[LookupScript("MovieDB.Genre")]
Thisstepwasjusttoshowhowtocheckifalookupscriptisavailableclientside.
Lookupkey,"MovieDB.Genre"iscasesensitive.Makesureyoutypeexactsamecase
everywhere.
UsingLookupEditorforGenreField
TherearetwoplacestoseteditortypeforGenreIdfield.OneisMovieForm.cs,otheris
MovieRow.cs.
Iusuallypreferthelatter,asitisthecentralplace,butyoumaychoosetosetitonaform,if
thateditortypeisspecifictothatformonly.
Informationdefinedonaformcan'tbereused.Forexample,gridsuseinformationin
XYZColumn.cs/XYZRow.cswhiledialogsuseinformationinXYZForm.cs/
XYZRow.cs.Soit'susuallybettertodefinethingsinXYZRow.cs.
OpenMovieRow.csandaddLookupEditorattributetoGenreIdpropertyasshownbelow:
[DisplayName("Genre"),ForeignKey("[mov].Genre","GenreId"),LeftJoin("g")]
[LookupEditor("MovieDB.Genre")]
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
AddingMovieGenres
101

Afterwebuildandlaunchourproject,we'llnowhaveasearchabledropdown(Select2.js)on
ourGenrefield.
Whiledefining[LookupEditor]wehardcodedthelookupkey.It'salsopossibletoreuse
informationonGenreRow:
[DisplayName("Genre"),ForeignKey("[mov].Genre","GenreId"),LeftJoin("g")]
[LookupEditor(typeof(GenreRow))]
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
Thisisfunctionallyequivalent.I'dpreferlatter.Here,Serenitywilllocatethe[LookupScript]
attributeonGenreRow,andgetlookupkeyinformationfromthere.Ifwehadno
[LookupScript]attributeonGenreRow,you'dgetanerroronapplicationstartup:
ServerErrorin'/'Application.
'MovieTutorial.MovieDB.Entities.GenreRow'typedoesn'thavea
[LookupScript]attribute,soitcan'tbeusedwithaLookupEditor!
Parametername:lookupType
AddingMovieGenres
102

Formsarescannedatapplicationstartup,sothereisnowaytohandlethiserrorwithout
fixingtheissue.
DisplayGenreinMovieGrid
Currently,moviegenrecanbeeditedintheformbutisnotdisplayedinMoviegrid.Edit
MovieColumns.cstoshowGenreName(notGenreId).
namespaceMovieTutorial.MovieDB.Columns
{
//...
publicclassMovieColumns
{
//...
[Width(100)]
publicStringGenreName{get;set;}
[DisplayName("RuntimeinMinutes"),Width(150),AlignRight]
publicInt32Runtime{get;set;}
}
}
NowGenreNameisshowninthegrid.
MakingItPossibleToDefineANewGenreInplace
Whilesettinggenreforoursamplemovies,wenoticethatTheGood,theBadandtheUgly
isWesternbutthereisnosuchgenreinGenredropdownyet(soIhadtochooseDrama).
OneoptionistoopenGenrespage,addit,andcomebacktomovieformagain.Notso
pretty...
AddingMovieGenres
103

Fortunately,Serenityhasintegratedinplaceitemdefinitionabilityforlookupeditors.
OpenMovieRow.csandmodifyLookupEditorattributelikethis:
[DisplayName("Genre"),ForeignKey("[mov].Genre","GenreId"),LeftJoin("g")]
[LookupEditor(typeof(GenreRow),InplaceAdd=true)]
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
NowwecandefineanewGenrebyclickingstar/peniconnexttogenrefield.
Herewealsoseethatwecanuseadialogfromanotherpage(GenreDialog)inthe
moviespage.InSerenityapplications,allclientsideobjects(dialogs,grids,editors,
formattersetc.)areself-containedreusablecomponents(widgets)thatarenotboundto
anypage.
Itisalsopossibletostarttypingingenreeditor,anditwillprovideyouwithanoptiontoadda
newgenre.
AddingMovieGenres
104

HowDidItDetermineWhichDialogTypeToUse
Youprobablydidn'tnoticethisdetail.Ourlookupeditorforgenreselection,automatically
openedanewGenreDialogwhenyouwantedtoaddanewgenreinplace.
Here,ourlookupeditormadeuseofaconvention.Becauseitslookupkeyis
MovieDB.Genre,itsearchedforadialogclasswithfullnamesbelow:
MovieDB.GenreDialog
MovieTutorial.MovieDB.GenreDialog
...
...
Luckily,wehaveaGenreDialog,whichisdefinedinModules/Genre/GenreDialog.tsandits
fullnameisMovieTutorial.MovieDB.GenreDialog.
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
exportclassGenreDialogextendsSerenity.EntityDialog<GenreRow,any>{
protectedgetFormKey(){returnGenreForm.formKey;}
protectedgetIdProperty(){returnGenreRow.idProperty;}
protectedgetLocalTextPrefix(){returnGenreRow.localTextPrefix;}
protectedgetNameProperty(){returnGenreRow.nameProperty;}
protectedgetService(){returnGenreService.baseUrl;}
protectedform=newGenreForm(this.idPrefix);
}
}
If,lookupkeyforGenreRowanditsdialogclassdidn'tmatch,wewouldgetanerrorin
browserconsole,assoonasweclicktheinplaceaddbutton:
UncaughtMovieDB.GenreDialogdialogclassisnotfound!
Butthisisnotthecaseastheymatch.Insuchacase,eitheryou'dhavetouseacompatible
lookupkeylike"ModuleName.RowType",oryou'dneedtospecifydialogtypeexplicitly:
AddingMovieGenres
105

[DisplayName("Genre"),ForeignKey("[mov].Genre","GenreId"),LeftJoin("g")]
[LookupEditor(typeof(GenreRow),InplaceAdd=true,DialogType="MovieDB.Genre")]
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
Youshouldn'tspecifyDialogsuffix,northefullnamespace,e.g.
MovieTutorial.MovieDB.Genre,asSerenityautomaticallysearchesforthem.
AddingQuickFilterforGenreToGrid
Asourlistofmoviesbecomeslarger,wemightneedtofiltermoviesbasedonvaluesof
somefields,besidesthequicksearchfunctionality.
Serenityhasseveralfilteringmethods.OneofthemisQuickFilter,whichwe'lluseonGenre
field.
EditModules/MovieDB/Movie/MovieColumns.cstoadda[QuickFilter]attributeontopof
GenreNamefield:
publicclassMovieColumns
{
//...
publicDateTimeReleaseDate{get;set;}
[Width(100),QuickFilter]
publicStringGenreName{get;set;}
[DisplayName("RuntimeinMinutes"),Width(150),AlignRight]
publicInt32Runtime{get;set;}
}
BuildandnavigatetoMoviespage.You'llaquickfilteringdropdownforgenrefieldis
available:
AddingMovieGenres
106

ThefieldthatisfilteredisactuallyGenreIdnotGenreNamethatweattachedthisattributeto.
Serenityiscleverenoughtounderstandthisrelation,anddeterminededitortypetouseby
lookingatattributesofGenreIdpropertyinGenreRow.cs.
Re-runingT4Templates
Asweaddedanewentitytoourapplication,weshouldrunT4templatesafterbuilding
solution.
AddingMovieGenres
107

UpdatingSerenityPackages(ASP.NET
MVCVersion)
Whenistartedwritingthistutorial,Serenity(NuGetpackagescontainingSerenity
assembliesandstandardscriptslibraries)andSerene(theapplicationtemplate)wasat
version2.1.8.
Whenyoureadthisyouareprobablyusingalaterversion,soyoumightnothavetoupdate
serenityyet.
But,iwanttoshowhowyoucanupdateSerenityNuGetpackages,incaseanotherversion
comesoutinthefuture.
IprefertoworkwithNuGetfromPackageManagerConsoleinsteadofusingNuGetGUI
interfaceasitperformsmuchfaster.
So,clickView->OtherWindows->PackageManagerConsole.
Type:
Update-PackageSerenity.Web
ThiswillalsoupdatefollowingNuGetpackagesinMovieTutorial.Webbecauseof
dependencies:
Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
ToupdateSerenity.CodeGenerator(containgsergen.exe),type:
Update-PackageSerenity.CodeGenerator
Serenity.CodeGeneratorisalsoinstalledinMovieTutorial.Webproject.
Duringupdates,ifNuGetaskstooverridechangesinsomescriptfiles,youcansafely
sayyesunlessyoudidmanualmodificationstoSerenityscriptfiles(whichisuggest
youavoid).
UpdatingSerenityPackages(ASP.NET
UpdatingSerenityPackages
108

CoreVersion)
Theorically,youshouldbeabletoupdateSerenityjustlikeASP.NETMVCversionusing
NuGetpackagemanagerconsole,butitmightnotwork,probablyduetosomeconditionals
inCSPROJfileconfusingNuGet.
Theseconditionalsaretheretosupportswitchingeasilytofull.NETFrameworkifyouhave
to.
Rightclickyourprojectfile,clickEditMySerene.csproj:
<PackageReferenceInclude="Serenity.Web"Version="3.0.5"/>
<PackageReferenceInclude="Serenity.Web.AspNetCore"Version="3.0.5"/>
<DotNetCliToolReferenceInclude="Serenity.CodeGenerator"Version="3.0.5">
FindthreelinesthatincludeSerenity.Web,Serenity.Web.AspNetCoreand
Serenity.CodeGeneratorlikeshownaboveandchangetheirversionstolatestSerenity
version.
Openacommandpromptinyourprojectdirectoryandtypethesetwolines:
dotnetrestore
dotnetsergenrestore
BuildingProject
Nowrebuildyoursolutionanditshouldbuildsuccessfully.
Fromtimetotime,breakingchangesmighthappeninSerenity,buttheyarekeptto
minimum,andyoumighthavetodoafewmanualchangesinyourapplicationcode.
Suchchangesaredocumentedwitha[BREAKINGCHANGE]taginchangelogat:
https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md
Ifyoustillhaveaproblemafterupgrade,feelfreetoopenanissueat:
https://github.com/volkanceylan/Serenity/issues
WhatIsUpdated
UpdatingSerenityNuGetpackages,takesSerenityassembliesuptothelatestversion.
Itmightalsoupdatesomeotherthird-partypackageslikeASP.NETMVC,FluentMigrator,
Select2.js,SlickGridetc.
UpdatingSerenityPackages
109

Pleasedon'tupdateSelect2.jstoaversionafter3.5.1yetasithassomecompability
problemswithjQueryvalidation.
Serenity.Webpackagealsocomeswithsomestaticscriptandcssresourceslikethe
following:
Content/serenity/serenity.css
Scripts/saltarelle/mscorlib.js
Scripts/saltarelle/linq.js
Scripts/serenity/Serenity.CoreLib.js
Scripts/serenity/Serenity.Script.UI.js
So,theseandafewmorearealsoupdatedinMovieApplication.Web.
WhatIsNotUpdated(ORCan'tBeUpdatedAutomatically)
UpdatingSerenitypackages,updatesSerenityassembliesandmoststaticscripts,butnotall
Serenetemplatecontentisupdated.
Wearetryingtokeepupdatingyourapplicationassimpleaspossible,butSereneisjusta
projecttemplate,notastaticpackage.YourapplicationisacustomizablecopyofSerene.
Youmighthavedonemodificationstoapplicationsource,soupdatingaSereneapplication
createdwithanolderversionofSerenetemplate,mightnotbeaseasyasitsounds.
SosometimesyoumighthavetocreateanewSereneapplicationwithup-to-dateSerene
templateversion,andcompareittoyourapplication,andmergefeaturesyouneed.Thisisa
manualprocess.
Usually,updatingSerenitypackagesisenough.UpdatingSereneitselfisnotrequiredunless
youneedsomerecentfeaturesfromlatestSereneversion.
WehavesomeplanstomakepartsofSerenetemplatealsoaNuGetpackage,butitis
stillnottrivialhowtoupdateyourapplicationwithoutoverridingyourchanges,e.g.to
sharedcodelikeNavigationitems.AndwhatifyouremovedNorthwindcode,butour
updatereinstallsit?I'mopentosuggestions...
UpdatingSerenityPackages
110

AllowingMultipleGenreSelection
Ithappens.Requirementschange.Nowwewanttoallowselectingmultiplegenresfora
movie.
Forthis,weneedaM-Nmappingtablethatwillletuslinkanymovietomultiplegenres.
CreatingMovieGenresTable
Asusual,westartwithamigration:
Modules/Common/Migrations/DefaultDB/
DefaultDB_20160528_115400_MovieGenres.cs:
AllowingMultipleGenreSelection
111

usingFluentMigrator;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160528115400)]
publicclassDefaultDB_20160528_115400_MovieGenres:Migration
{
publicoverridevoidUp()
{
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(
@"INSERTINTOmov.MovieGenres(MovieId,GenreId)
SELECTm.MovieId,m.GenreId
FROMmov.Moviem
WHEREm.GenreIdISNOTNULL");
Delete.ForeignKey("FK_Movie_GenreId")
.OnTable("Movie").InSchema("mov");
Delete.Column("GenreId")
.FromTable("Movie").InSchema("mov");
}
publicoverridevoidDown()
{
}
}
}
ItriedtosaveexistingGenredeclarationsonMovietable,bycopyingthemtoournew
MovieGenrestable.ThelineabovewithExecute.Sqldoesthis.
ThenweshouldremoveGenreIdcolumn,byfirstdeletingtheforeignkeydeclaration
FK_Movie_GenreIdthatwedefinedonitpreviously.
DeletingMappingforGenreIdColumn
AssoonasyoubuildandopentheMoviespage,you'llgetthiserror:
AllowingMultipleGenreSelection
112

ThisisbecausewestillhavemappingforGenreIdcolumninourrow.Erroraboveis
receivedfromAJAXcalltoListservicehandlerforMovietable.
RepeatingoferrormessageoriginatesfromSQLserver.MovieIdcolumnnamepasses
severaltimeswithinthegenerateddynamicSQL.
RemoveGenreIdandGenreNamepropertiesandtheirrelatedfieldobjectsfrom
MovieRow.cs:
//removethis
publicInt32?GenreId
{
get{returnFields.GenreId[this];}
set{Fields.GenreId[this]=value;}
}
//removethis
publicStringGenreName
{
get{returnFields.GenreName[this];}
set{Fields.GenreName[this]=value;}
}
publicclassRowFields:RowFieldsBase
{
//andremovethese
publicInt32FieldGenreId;
publicStringFieldGenreName;
}
RemoveGenreNamepropertyfromMovieColumns.cs:
//removethis
[Width(100),QuickFilter]
publicStringGenreName{get;set;}
RemoveGenreIdpropertyfromMovieForm.cs:
AllowingMultipleGenreSelection
113

//removethis
publicInt32GenreId{get;set;}
Afterbuilding,weatleasthaveaworkingMoviespageagain.
GeneratingCodeForMovieGenresTable
FireupsergenandgeneratecodeforMovieGenrestableasusual:
Aswe'renotgoingtoeditmoviegenresfromaseparatepage,youcansafelydeletethe
generatedfilesbelow:
MovieGenresColumns.cs
MovieGenresDialog.ts
MovieGenresEndpoint.cs
MovieGenresForm.cs
MovieGenresGrid.cs
MovieGenresIndex.cshtml
MovieGenresPage.cs
YoucanalsoremoveCSSentriesfors-MovieDB-MovieGenresDialogfromsite.less.
Onlyleavelasttwofiles,MovieGenresRow.csandMovieGenresRepository.cs.
Afterbuilding,runT4templatestobesure,noT4generatedfilesrelatedto
MovieGenresFormetc.isleftbehind.
AllowingMultipleGenreSelection
114

AddingGenreListField
Asonemoviemighthavemultiplegenresnow,insteadofaInt32property,weneedalistof
Int32values,e.g. List<Int32>.AddtheGenreListpropertytoMovieRow.cs:
YoumighthavetoaddSystem.Collections.Generictousings.
//...
[DisplayName("Kind"),NotNull,DefaultValue(MovieKind.Film)]
publicMovieKind?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")]
publicList<Int32>GenreList
{
get{returnFields.GenreList[this];}
set{Fields.GenreList[this]=value;}
}
publicclassRowFields:RowFieldsBase
{
//...
publicInt32FieldKind;
publicListField<Int32>GenreList;
Ourpropertyhas[LookupEditor]attributejustlikeGenreIdpropertyhad,butwithone
difference.Thisoneacceptsmultiplegenreselection.WesetitwithMultiple=true
argument.
ThispropertyalsohasNotMappedflag,whichissomethingsimilartoUnmappedfieldsin
Serenity.Itspecifiesthatthispropertyhasnomatchingdatabasecolumnindatabase.
Wedon'thaveaGenreListcolumninMovietable,soweshouldsetitasanunmappedfield.
Otherwise,SerenitywilltrytoSELECTit,andwe'llgetSQLerrors.
Inthenextline,weuseanothernewattribute,LinkingSetRelation:
[LinkingSetRelation(typeof(MovieGenresRow),"MovieId","GenreId")]
ThisisanattributewhichisspecifictoM-Nreleationsthatlinksarowinthistabletomultiple
rowsfromanothertable.
AllowingMultipleGenreSelection
115

FirstargumentofitisthetypeofM-Nmappingrow,whichisMovieGenresRowhere.
Secondargumentisthepropertynameoffieldinthatrow(MovieGenresRow)thatmatches
thisrow'sIDproperty,e.g.MovieId.
Thirdargumentisthepropertynameoffieldinthatrow(MovieGenresRow)thatlinks
multipleGenresbytheirIDs,e.g.GenreId.
LinkingSetRelationhasarelatedSerenityservicebehavior,named
LinkingSetRelationBehaviorthatisautomaticallyactivatedforallfieldswitha
LinkingSetRelationattribute.
Thisbehavior,willinterceptservicehandlersforCreate,Update,Delete,Retrieveand
ListandinjectcodetopopulateorupdateourGenreListcolumnanditsrelated
MovieGenrestable.
We'lltalkaboutSerenityservicebehaviorsinfollowingchapters.
AddingGenreListToForm
EditMovieForm.csandaddGenreListproperty:
publicclassMovieForm
{
//...
publicList<Int32>GenreList{get;set;}
publicMovieKindKind{get;set;}
publicInt32Runtime{get;set;}
}
NowwecanaddmultiplegenrestoaMovie:
AllowingMultipleGenreSelection
116

ShowingSelectedGenresinaColumn
Previously,whenwehadonlyoneGenreperMovie.Wecouldshowtheselectedgenreina
column,byaddingaviewfieldtoMovieRow.cs.Itisnotgoingtobesosimplethistime.
Let'sstartbyaddingGenreListpropertytoMovieColumns.cs:
publicclassMovieColumns
{
//...
[Width(200)]
publicList<Int32>GenreList{get;set;}
[DisplayName("RuntimeinMinutes"),Width(150),AlignRight]
publicInt32Runtime{get;set;}
}
Thisiswhatwegot:
AllowingMultipleGenreSelection
117

GenreListcolumncontainsalistofInt32values,whichcorrespondstoanarrayin
Javascript.Luckily,Javascript.toString()methodforanarrayreturnsitemsseparatedby
comma,sowegot"1,2"forFightClubmovie.
WewouldprefergenrenamesinsteadofGenreIDs,soit'sclearthatweneedtoformat
thesevalues,byconvertingGenreIdtotheirGenrenameequivalents.
CreatingGenreListFormatterClass
It'stimetowriteaSlickGridcolumnformatter.CreatefileGenreListFormatter.tsnextto
MovieGrid.ts:
AllowingMultipleGenreSelection
118

namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerFormatter()
exportclassGenreListFormatterimplementsSlick.Formatter{
format(ctx:Slick.FormatterContext){
letidList=ctx.valueasnumber[];
if(!idList||!idList.length)
return"";
letbyId=GenreRow.getLookup().itemById;
returnidList.map(x=>{
letg=byId[x];
if(!g)
returnx.toString();
returnQ.htmlEncode(g.Name);
}).join(",");
}
}
}
Herewedefineanewformatter,GenreListFormatterandregisteritwithSerenitytype
system,using@Serenity.Decorators.registerFormatterdecorator.Decoratorsaresimilarto
.NETattributes.
AllformattersshouldimplementSlick.Formatterinterface,whichhasaformatmethodthat
takesactxparameteroftypeSlick.FormatterContext.
ctx,whichistheformattingcontext,isanobjectwithseveralmembers.Oneofthemisvalue
thatcontainsthecolumnvalueforcurrentgridrow/columnbeingformatted.
Asweknowthatwe'llusethisformatteroncolumnwitha List<Int32>value,westartby
castingvaluetonumber[].
ThereisnoInt32typeinJavascript.Int32,Double,Singleetc.correspondstonumber
type.Also,generic List<>typeinC#correspondstoanArrayinJavascript.
Ifthearrayisemptyornull,wecansafelyreturnanemptystring:
letidList=ctx.valueasnumber[];
if(!idList||!idList.length)
return"";
ThenwegetareferencetoGenrelookup,whichhasadictionaryofGenrerowsinits
itemByIdproperty:
AllowingMultipleGenreSelection
119

letbyId=GenreRow.getLookup().itemById;
Next,westartmappingtheseIDvaluesinouridListtotheirGenrenameequivalents,using
Array.mapfunctioninJavascript,whichisprettysimilartoLINQSelectstatement:
returnidList.map(x=>{
WelookupanIDinourGenredictionary.Itshouldbeindictionary,butweplaysafehere,
andreturnitsnumericvalue,ifthegenreisnotfoundindictionary.
letg=byId[x];
if(!g)
returnx.toString();
Ifwecouldfindthegenrerow,correspondingtothisID,wereturnitsNamevalue.We
shouldHTMLencodethegenrename,justincaseitcontainsinvalidHTMLcharacters,like
<, >or &.
returnQ.htmlEncode(g.Name);
Wecouldalsowriteagenericformatterthatworkswithanytypeoflookuplist,butit's
beyondscopeofthistutorial.
AssigningGenreListFormattertoGenreListColumn
Aswedefinedanewformatterclass,weshouldbuildandtransformT4files,sothatwecan
referenceGenreListFormatterinserversidecode.
Afterbuildingandtransforming,openMovieColumns.csandattachthisformatterto
MovieListproperty:
publicclassMovieColumns
{
//...
[Width(200),GenreListFormatter]
publicList<Int32>GenreList{get;set;}
[DisplayName("RuntimeinMinutes"),Width(150),AlignRight]
publicInt32Runtime{get;set;}
}
NowwecanseeGenrenamesinGenrescolumn:
AllowingMultipleGenreSelection
120

AllowingMultipleGenreSelection
121

FilteringwithMultipleGenreList
RememberthatwhenwehadonlyoneGenreperMovie,itwaseasytoquickfilter,by
addinga[QuickFilter]attributetoGenreIdfield.
Let'strytodosimilarinMovieColumns.cs:
[ColumnsScript("MovieDB.Movie")]
[BasedOnRow(typeof(Entities.MovieRow))]
publicclassMovieColumns
{
//...
[Width(200),GenreListFormatter,QuickFilter]
publicList<Int32>GenreList{get;set;}
}
AssoonasyoutypeaGenreintoGenresyou'llhavethiserror:
AsofSerenity2.6.3,LinkingSetRelationwillautomaticallyhandleequalityfilterforits
field,soyouwon'tgetthiserroranditwilljustwork.Anyway,it'sstillrecommendedto
followstepsbelowasitisagoodsamplefordefiningcustomlistrequestsandhandling
themwhenrequired.
ListHandlertriedtofilterbyGenreListfield,butasthereisnosuchcolumnindatabase,we
gotthiserror.
So,nowwehavetohandleitsomehow.
DeclaringMovieListRequestType
FilteringwithMultipleGenreList
122

Aswearegoingtodosomethingnon-standard,e.g.filteringbyvaluesinalinkingsettable,
weneedtopreventListHandlerfromfilteringitselfonGenreListproperty.
WecouldprocesstherequestCriteriaobject(whichissimilartoanexpressiontree)usinga
visitorandhandleGenreListourself,butitwouldbeabitcomplex.Soi'lltakeasimplerroad
fornow.
Let'stakeasubclassofstandardListRequestobjectandaddourGenresfilterparameter
there.AddaMovieListRequest.csfilenexttoMovieRepository.cs:
namespaceMovieTutorial.MovieDB
{
usingSerenity.Services;
usingSystem.Collections.Generic;
publicclassMovieListRequest:ListRequest
{
publicList<int>Genres{get;set;}
}
}
WeaddedaGenrespropertytoourlistrequestobject,whichwillholdtheoptionalGenres
wewantmoviestobefilteredon.
ModifyingRepository/EndpointforNewRequestType
Forourlisthandlerandservicetouseournewlistrequesttype,needtodochangesinafew
places.
StartwithMovieRepository.cs:
publicclassMovieRepository
{
//...
publicListResponse<MyRow>List(IDbConnectionconnection,MovieListRequestrequest
)
{
returnnewMyListHandler().Process(connection,request);
}
//...
privateclassMyListHandler:ListRequestHandler<MyRow,MovieListRequest>{}
}
WechangedListRequesttoMovieListRequestinListmethodandaddedageneric
parametertoMyListHandler,touseournewtypeinsteadofListRequest.
FilteringwithMultipleGenreList
123

AndanotherlittlechangeinMovieEndpoint.cs,whichistheactualwebservice:
publicclassMovieController:ServiceEndpoint
{
//...
publicListResponse<MyRow>List(IDbConnectionconnection,MovieListRequestrequest
)
{
returnnewMyRepository().List(connection,request);
}
}
Nowitstimetobuildandtransformtemplates,soourMovieListRequestobjectandrelated
servicemethodswillbeavailableatclientside.
MovingQuickFiltertoGenresParameter
Westillhavethesameerrorasquickfilterisnotawareoftheparameterwejustaddedtolist
requesttypeandstillusestheCriteriaparameter.
NeedtointerceptquickfilteritemandmovethegenrelisttoGenrespropertyofour
MovieListRequest.
EditMovieGrid.ts:
exportclassMovieGridextendsSerenity.EntityGrid<MovieRow,any>{
//...
protectedgetQuickFilters(){
letitems=super.getQuickFilters();
vargenreListFilter=Q.first(items,x=>
x.field==MovieRow.Fields.GenreList);
genreListFilter.handler=h=>{
varrequest=(h.requestasMovieListRequest);
varvalues=(h.widgetasSerenity.LookupEditor).values;
request.Genres=values.map(x=>parseInt(x,10));
h.handled=true;
};
returnitems;
}
}
getQuickFiltersisamethodthatiscalledtogetalistofquickfilterobjectsforthisgridtype.
FilteringwithMultipleGenreList
124

Bydefaultgridenumeratespropertieswith[QuickFilter]attributesinMovieColumns.csand
createssuitablequickfilterobjectsforthem.
WestartbygettinglistofQuickFilterobjectsfromsuperclass.
letitems=super.getQuickFilters();
ThenlocatethequickfilterobjectforGenreListproperty:
vargenreListFilter=Q.first(items,x=>
x.field==MovieRow.Fields.GenreList);
Actuallythereisonlyonequickfilternow,butwewanttoplaysafe.
Nextstepistosetthehandlermethod.Thisiswhereaquickfilterobjectreadstheeditor
valueandappliesittorequest'sCriteria(ifmultiple)orEqualityFilter(ifsinglevalue)
parameters,justbeforeitssubmittedtolistservice.
genreListFilter.handler=h=>{
ThenwegetareferencetocurrentListRequestbeingprepared:
varrequest=(h.requestasMovieListRequest);
Andreadthecurrentvalueinlookupeditor:
varvalues=(h.widgetasSerenity.LookupEditor).values;
Setitinrequest.Genresproperty:
request.Genres=values.map(x=>parseInt(x,10));
Asvaluesisalistofstring,weneededtoconvertthemtointeger.
Laststepistosethandledtotrue,todisabledefaultbehaviorofquickfilterobject,soitwon't
setCriteriaorEqualityFilteritself:
h.handled=true;
Nowwe'llnolongerhaveInvalidColumnNameGenreListerrorbutGenresfilterisnot
appliedserversideyet.
FilteringwithMultipleGenreList
125

HandlingGenreFilteringInRepository
ModifyMyListHandlerinMovieRepository.cslikebelow:
privateclassMyListHandler:ListRequestHandler<MyRow,MovieListRequest>
{
protectedoverridevoidApplyFilters(SqlQueryquery)
{
base.ApplyFilters(query);
if(!Request.Genres.IsEmptyOrNull())
{
varmg=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()));
}
}
}
ApplyFiltersisamethodthatiscalledtoapplyfiltersspecifiedinlistrequest'sCriteriaand
EqualityFilterparameters.Thisisagoodplacetoapplyourcustomfilter.
WefirstcheckifRequest.Genresisnulloranemptylist.Ifsonofilteringneedstobedone.
Next,wegetareferencetoMovieGenresRow'sfieldswithaliasmg.
varmg=Entities.MovieGenresRow.Fields.As("mg");
Hereitneedssomeexplanation,aswedidn'tcoverSerenityentitysystemyet.
Let'sstartbynotaliasingMovieGenresRow.Fields:
varx=MovieGenresRow.Fields;
newSqlQuery()
.From(x)
.Select(x.MovieId)
.Select(x.GenreId);
Ifwewroteaquerylikeabove,itsSQLoutputwouldbesomethinglikethis:
FilteringwithMultipleGenreList
126

SELECTt0.MovieId,t0.GenreIdFROMMovieGenrest0
Unlesstoldotherwise,Serenityalwaysassignst0toarow'sprimarytable.Evenifwenamed
MovieGenresRow.Fieldsasvariablex,it'saliaswillstillbet0.
Becausewhencompiled,xwon'tbethereandSerenityhasnowaytoknowitsvariable
name.Serenityentitysystemdoesn'tuseanexpressiontreelikeinLINQtoSQLor
EntityFramework.Itmakesuseofverysimplestring/querybuilders.
So,ifwantedittousexasanalias,we'dhavetowriteitexplicitly:
varx=MovieGenresRow.Fields.As("x");
newSqlQuery()
.From(x)
.Select(x.MovieId)
.Select(x.GenreId);
...resultsat:
SELECTx.MovieId,x.GenreIdFROMMovieGenresx
InMyListHandler,whichisforMovieRowentities,t0isalreadyusedforMovieRowfields.So,
topreventclasheswithMovieGenresRowfields(whichisnamedfld),ihadtoassign
MovieGenresRowanalias,mg.
varmg=Entities.MovieGenresRow.Fields.As("mg");
Whati'mtryingtoachieve,isaquerylikethis(justthewaywe'ddothisinbareSQL):
SELECTt0.MovieId,t0.Title,...FROMMoviest0
WHEREEXISTS(
SELECT1
FROMMovieGenresmg
WHERE
mg.MovieId=t0.MovieIdAND
mg.GenreIdIN(1,3,5,7)
)
Soi'maddingaWHEREfiltertomainquerywithWheremethod,usinganEXISTScriteria:
query.Where(Criteria.Exists(
FilteringwithMultipleGenreList
127

Thenstartingtowritethesubquery:
query.SubQuery()
.From(mg)
.Select("1")
Andaddingthewherestatementforsubquery:
.Where(
mg.MovieId==fld.MovieId&&
mg.GenreId.In(Request.Genres))
Herefldactuallycontainsthealiast0forMovieRowfields.
AsCriteria.Existsmethodexpectsasimplestring,ihadtouse.ToString()attheend,to
convertsubquerytoastring:
Yes,ishouldaddoneoverloadthatacceptsasubquery...noted.
.ToString()));
Itmightlookabitalienatstart,butbytimeyou'llunderstandthatSerenityquerysystem
matchesSQLalmost99%.Itcan'tbetheexactSQLaswehavetoworkinadifferent
language,C#.
NowourfilteringforGenreListpropertyworksperfectly...
FilteringwithMultipleGenreList
128

TheCastandCharactersTheyPlayed
Ifwewantedtokeeparecordofactorsandtherolestheyplayedlikethis:
Actor/Actress Character
KeanuReeves Neo
LaurenceFishburne Morpheus
Carrie-AnneMoss Trinity
WeneedatableMovieCastwithcolumnslike:
MovieCastId MovieId PersonId Character
... ... ... ...
11 2(Matrix) 77(KeanuReeves) Neo
12 2(Matrix) 99(LaurenceFisburne) Morpheus
13 2(Matrix) 30(Carrie-AnneMoss) Trinitity
... ... ... ...
It'sclearthatwealsoneedaPersontableaswe'llkeepactors/actressesbytheirID.
It'sbettertocallitPersonasactors/actressesmightbecomedirectors,scenariowriters
andsuchlater.
CreatingPersonandMovieCastTables
Nowitstimetocreateamigrationwithtwotables:
MovieTutorial.Web/Modules/Common/Migrations/DefaultDB/
DefaultDB_20160528_141600_PersonAndMovieCast.cs:
TheCastandCharactersTheyPlayed
129

usingFluentMigrator;
usingSystem;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160528141600)]
publicclassDefaultDB_20160528_141600_PersonAndMovieCast:Migration
{
publicoverridevoidUp()
{
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();
}
publicoverridevoidDown()
{
}
}
}
GeneratingCodeForPersonTable
FirstgeneratecodeforPersontable:
TheCastandCharactersTheyPlayed
130

ChangingGenderToEnumeration
GendercolumninPersontableshouldbeanenumeration.DeclareaGenderenumerationin
Gender.csnexttoPersonRow.cs:
usingSerenity.ComponentModel;
usingSystem.ComponentModel;
namespaceMovieTutorial.MovieDB
{
[EnumKey("MovieDB.Gender")]
publicenumGender
{
[Description("Male")]
Male=1,
[Description("Female")]
Female=2
}
}
ChangeGenderpropertydeclarationinPersonRow.csasbelow:
TheCastandCharactersTheyPlayed
131

//...
[DisplayName("Gender")]
publicGender?Gender
{
get{return(Gender?)Fields.Gender[this];}
set{Fields.Gender[this]=(Int32?)value;}
}
//...
Forconsistency,changetypeofGenderpropertyinPersonForm.csandPersonColumns.cs
fromInt32toGender.
RebuildingT4Templates
Aswedeclaredanewenumerationandusedit,weshouldrebuildsolution,convertT4
templates
Nowafterlaunchingyourproject,youshouldbeabletoenteractors:
DeclaringFullNameField
TheCastandCharactersTheyPlayed
132

Onthetitleofeditdialog,firstnameofthepersonisshown(Carrie-Anne).Itwouldbeniceto
showfullname.Andalsosearchwithfullnameingrid.
Solet'seditourPersonRow.cs:
namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassPersonRow:Row,IIdRow,INameRow
{
//...removeQuickSearchfromFirstName
[DisplayName("FirstName"),Size(50),NotNull]
publicStringFirstname
{
get{returnFields.Firstname[this];}
set{Fields.Firstname[this]=value;}
}
[DisplayName("LastName"),Size(50),NotNull]
publicStringLastname
{
get{returnFields.Lastname[this];}
set{Fields.Lastname[this]=value;}
}
[DisplayName("FullName"),
Expression("(t0.Firstname+''+t0.Lastname)"),QuickSearch]
publicStringFullname
{
get{returnFields.Fullname[this];}
set{Fields.Fullname[this]=value;}
}
//...changeNameFieldtoFullname
StringFieldINameRow.NameField
{
get{returnFields.Fullname;}
}
//...
publicclassRowFields:RowFieldsBase
{
publicreadonlyInt32FieldPersonId;
publicreadonlyStringFieldFirstname;
publicreadonlyStringFieldLastname;
publicreadonlyStringFieldFullname;
//...
}
}
}
TheCastandCharactersTheyPlayed
133

WespecifiedSQLexpressionExpression("(t0.Firstname+''+t0.Lastname)")ontopof
Fullnameproperty.Thus,itisaserversidecalculatedfield.
ByaddingQuickSearchattributetoFullName,insteadofFirstname,gridwillnowsearchby
defaultonFullnamefield.
ButdialogwillstillshowFirstname.Weneedtobuildandtransformtemplatestomakeit
showFullname.
WhyHadtoTransformTemplates?
ThiswillbecomemoreclearafterlookingatPersonDialog.tsfile:
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
exportclassPersonDialogextendsSerenity.EntityDialog<PersonRow,any>{
protectedgetFormKey(){returnPersonForm.formKey;}
protectedgetIdProperty(){returnPersonRow.idProperty;}
protectedgetLocalTextPrefix(){returnPersonRow.localTextPrefix;}
protectedgetNameProperty(){returnPersonRow.nameProperty;}
protectedgetService(){returnPersonService.baseUrl;}
protectedform=newPersonForm(this.idPrefix);
}
}
HereweseethatgetNameProperty()methodreturnsPersonRow.nameProperty.
PersonRowtypinginTypeScriptisinafile(MovieDB.PersonRow.ts)generatedbyourT4
templates.
Thus,unlesswetransformT4templates,thenamepropertychangewedidinPersonRow.cs
won'tbereflectedincorrespondingMovieDB.PersonRow.tsfileunder
*Modules/Common/Imports/ServerTypings/ServerTypings.tt":
TheCastandCharactersTheyPlayed
134

namespaceMovieTutorial.MovieDB{
exportinterfacePersonRow{
PersonId?:number;
Firstname?:string;
Lastname?:string;
Fullname?:string;
//...
}
exportnamespacePersonRow{
exportconstidProperty='PersonId';
exportconstnameProperty='Fullname';
exportconstlocalTextPrefix='MovieDB.Person';
exportnamespaceFields{
exportdeclareconstPersonId:string;
//...
}
//...
}
}
Thismetadata(namepropertyofPersonRow)istransferredtoTypeScriptwithacodefile
(MovieDB.PersonRow.ts)thatisgeneratedbyServerTypings.ttfile.
Similarly,idProperty,localTextPrefix,EnumTypesetc.arealsogeneratedunder
ServerTypings.tt.Thus,whenyoumakeachangethataffectsametadatainthesegenerated
files,youshouldtransformT4templatestotransferthatinformationtoTypeScript.
Youshouldalwaysbuildbeforetransforming,asT4filesusesoutputDLLof
MovieTutorial.Webproject.Otherwiseyou'llbegeneratingcodeforanolderversionof
yourWebproject.
DeclaringPersonRowLookupScript
Whilewearestillhere,let'sdeclareaLookupScriptforPersontableinPersonRow.cs:
namespaceMovieTutorial.MovieDB.Entities
{
//...
[LookupScript("MovieDB.Person")]
publicsealedclassPersonRow:Row,IIdRow,INameRow
//...
We'lluseitforeditingMoviecastlater.
TheCastandCharactersTheyPlayed
135

Buildandtransformtemplatesagain,you'llseethatMovieDB.PersonRow.tsnowhasa
getLookup()methodalongsidewithanewlookupKeyproperty:
namespaceMovieTutorial.MovieDB{
exportinterfacePersonRow{
//...
}
exportnamespacePersonRow{
exportconstidProperty='PersonId';
exportconstnameProperty='Fullname';
exportconstlocalTextPrefix='MovieDB.Person';
exportconstlookupKey='MovieDB.Person';
exportfunctiongetLookup():Q.Lookup<PersonRow>{
returnQ.getLookup<PersonRow>('MovieDB.Person');
}
//...
}
GeneratingCodeForMovieCastTable
GeneratecodeforMovieCasttableusingsergen:
Aftergeneratingcode,aswedon'tneedaseparatepagetoeditmoviecasttable,youmay
deletefileslistedbelow:
TheCastandCharactersTheyPlayed
136

MovieCastIndex.cshtml
MovieCastPage.cs
MovieDialog.ts
MovieGrid.ts
Again,buildandtransformtemplates.
Master/DetailEditingLogicForMovieCastTable
Upuntilnow,wecreatedapageforeachtable,andlistandedititsrecordsinthatpage.This
timewearegoingtouseadifferentstrategy.
We'lllistthecastforamovieintheMoviedialogandallowthemtobeeditedalongwiththe
movie.Also,castwillbesavedtogetherwithmovieentityinonetransaction.
Thus,casteditingwillbeinmemory,andwhenuserpressessavebuttoninMoviedialog,
themovieanditscastwillbesavedtodatabaseinoneshot(onetransaction).
Itwouldbepossibletoeditthecastindependently,herewejustwanttoshowhowitcan
bedone.
Forsometypesofmaster/detailrecordslikeorder/detail,detailsshouldn'tbeallowedto
beeditedindependentlyforconsistencyreasons.Serenealreadyhasasampleforthis
kindofeditinginNorthwind/Orderdialog.
CreatinganEditorForMovieCastList
NexttoMovieCastRow.cs(atMovieTutorial.Web/Modules/MovieDB/MovieCast/),createa
filenamedMovieCastEditor.tswithcontentsbelow:
///<referencepath="../../Common/Helpers/GridEditorBase.ts"/>
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerEditor()
exportclassMovieCastEditor
extendsCommon.GridEditorBase<MovieCastRow>{
protectedgetColumnsKey(){return"MovieDB.MovieCast";}
protectedgetLocalTextPrefix(){returnMovieCastRow.localTextPrefix;}
constructor(container:JQuery){
super(container);
}
}
}
TheCastandCharactersTheyPlayed
137

ThiseditorderivesfromCommon.GridEditorBaseclassinSerene,whichisaspecialgrid
typethatisdesignedforin-memoryediting.ItisalsothebaseclassforOrderDetailseditor
usedinOrderdialog.
The <reference/>lineattopofthefileisimportant.TypeScripthasorderingproblems
withinputfiles.Ifwedidn'tputitthere,TypeScriptwouldsometimesoutput
GridEditorBaseafterourMovieCastEditor,andwe'dgetruntimeerrors.
Asaruleofthumb,ifyouarederivingsomeclassfromanotherinyourproject(not
Serenityclasses),youshouldputareferencetofilecontainingthatbaseclass.
ThishelpsTypeScripttoconvertGridEditorBasetojavascriptbeforeotherclassesthat
mightneedit.
Toreferencethisneweditortypefromserverside,buildandtransformalltemplates.
ThisbaseclassmightbeintegratedtoSerenityinlaterversions.Inthatcase,its
namespacewillbecomeSerenity,insteadofSereneorMovieTutorial.
UsingMovieCastEditorinMovieForm
OpenMovieForm.cs,betweenDescriptionandStorylinefields,addaCastListpropertylike:
namespaceMovieTutorial.MovieDB.Forms
{
//...
publicclassMovieForm
{
publicStringTitle{get;set;}
[TextAreaEditor(Rows=3)]
publicStringDescription{get;set;}
[MovieCastEditor]
publicList<Entities.MovieCastRow>CastList{get;set;}
[TextAreaEditor(Rows=8)]
publicStringStoryline{get;set;}
//...
}
}
Byputting[MovieCastEditor]attributeontopofCastListproperty,wespecifiedthatthis
propertywillbeeditedbyournewMovieCastEditortypewhichisdefinedinTypeScriptcode.
Wecouldalsowrite[EditorType("MovieDB.MovieCast")]butwhoreallylikeshard-coded
strings?Notme...
Nowbuildandlaunchyourapplication.Openamoviedialogandyou'llbegreetedbyour
neweditor:
TheCastandCharactersTheyPlayed
138

OK,itlookedeasy,buti'llbehonest,wearenotevenhalftheway.
ThatNewMovieCastbuttondoesn'twork,needtodefineadialogforit.Thegridcolumns
arenotwhati'dlikethemtobeandthefieldandbuttontitlesarenotsouserfriendly...
Alsowe'llhavetohandleabitmoreplumbinglikeloadingandsavingcastlistonserverside
(we'llshowtheharder-manualwayfirst,thenwe'llseehoweasyitcanbeusingaservice
behavior).
ConfiguringMovieCastEditortoUseMovieCastEditDialog
CreateaMovieCastEditDialog.tsfilenexttoMovieCastEditor.tsandmodifyitlikebelow:
TheCastandCharactersTheyPlayed
139

///<referencepath="../../Common/Helpers/GridEditorDialog.ts"/>
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerClass()
exportclassMovieCastEditDialogextends
Common.GridEditorDialog<MovieCastRow>{
protectedgetFormKey(){returnMovieCastForm.formKey;}
protectedgetNameProperty(){returnMovieCastRow.nameProperty;}
protectedgetLocalTextPrefix(){returnMovieCastRow.localTextPrefix;}
protectedform:MovieCastForm;
constructor(){
super();
this.form=newMovieCastForm(this.idPrefix);
}
}
}
WeareusinganotherbaseclassfromSerene,Common.GridEditorDialogwhichisalsoused
byOrderDetailEditDialog.
OpenMovieCastEditor.tsagain,addagetDialogTypemethodandoverride
getAddButtonCaption:
///<referencepath="../../Common/Helpers/GridEditorBase.ts"/>
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerEditor()
exportclassMovieCastEditor
extendsCommon.GridEditorBase<MovieCastRow>{
protectedgetColumnsKey(){return"MovieDB.MovieCast";}
protectedgetDialogType(){returnMovieCastEditDialog;}
protectedgetLocalTextPrefix(){returnMovieCastRow.localTextPrefix;}
constructor(container:JQuery){
super(container);
}
protectedgetAddButtonCaption(){
return"Add";
}
}
}
WespecifiedthatMovieCastEditorusesaMovieCastEditDialogbydefaultwhichisalso
usedbyAddbutton.
TheCastandCharactersTheyPlayed
140

Now,insteadofdoingnothing,Addbuttonshowsadialog.
ThisdialogneedssomeCSSformatting.Movietitleandpersonnamefieldsacceptsinteger
inputs(astheyareactuallyMovieIdandPersonIdfields).
EditingMovieCastForm.cs
getFormKey()methodofMovieCastEditDialogreturnsMovieCastForm.formKey,soit
currentlyusesMovieCastForm.csgeneratedbySergen.
ItispossibletohavemultipleformsforoneentityinSerenity.Ifiwantedtosave
MovieCastFormforsomeotherstandalonedialog,e.g.MovieCastDialog(whichweactually
deleted),iwouldprefertodefineanewformlikeMovieCastEditForm,butthisisnotthe
case.
OpenMovieCastForm.csandmodifyit:
TheCastandCharactersTheyPlayed
141

namespaceMovieTutorial.MovieDB.Forms
{
usingSerenity.ComponentModel;
usingSystem;
usingSystem.ComponentModel;
[FormScript("MovieDB.MovieCast")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
publicclassMovieCastForm
{
publicInt32PersonId{get;set;}
publicStringCharacter{get;set;}
}
}
IhaveremovedMovieIdasthisformisgoingtobeusedinMovieCastEditDialog,so
MovieCastentitieswillhavetheMovieIdofthemoviecurrentlybeingeditedinthe
MovieDialogautomatically.OpeningLordoftheRingsmovieandaddingacastentryforthe
Matrixwouldbenon-sense.
Next,editMovieCastRow.cs:
[ConnectionKey("Default"),TwoLevelCached]
[DisplayName("MovieCasts"),InstanceName("Cast")]
[ReadPermission("Administration")]
[ModifyPermission("Administration")]
publicsealedclassMovieCastRow:Row,IIdRow,INameRow
{
//...
[DisplayName("Actor/Actress"),NotNull,ForeignKey("[mov].[Person]","PersonId
")]
[LeftJoin("jPerson"),TextualField("PersonFirstname")]
[LookupEditor(typeof(PersonRow))]
publicInt32?PersonId
{
get{returnFields.PersonId[this];}
set{Fields.PersonId[this]=value;}
}
IhaveseteditortypeforPersonIdfieldtoalookupeditorandasihavealreadyaddeda
LookupScriptattributetoPersonRow,icanreusethatinformationforsettingthelookupkey.
Wecouldhavealsowritten[LookupEditor("MovieDB.Person")]
ChangedPersonIddisplaynametoActor/Actress.
AlsochangedDisplayNameandInstanceNameattributesforrowtosetdialogtitle.
TheCastandCharactersTheyPlayed
142

Buildsolution,launchandnowMovieCastEditDialoghasabettereditingexperience.Butstill
toobiginwidthandheight.
FixingtheLookOfMovieCastEditDialog
Let'schecksite.lesstounderstandwhyourMovieCastEditDialogisnotstyled.
.s-MovieDB-MovieCastDialog{
>.size{width:650px;}
.caption{width:150px;}
}
TheCSSatthebottomofsite.lessisfortheMovieCastDialog,notMovieCastEditDialog,
becausewedefinedthisclassourselves,notwithcodegenerator.
WecreatedanewdialogtypeMovieCastEditDialog,sonowournewdialoghasaCSSclass
ofs-MovieDB-MovieCastEditDialog,butcodegeneratoronlygeneratedCSSrulesfors-
MovieDB-MovieCastDialog.
SerenitydialogsautomaticallyassignsCSSclassestodialogelements,byprefixing
typenamewith"s-".Youcanseethisbyinspectingthedialogindevelopertools.
MovieCastEditDialoghasCSSclassesofs-MovieCastEditDialogands-MovieDB-
MovieCastEditDialog,alongwithsomeotherlikeui-dialog.
s-ModuleName-TypeNameCSSclasshelpswithindividualstylingwhentwomodules
hasatypewiththesamename.
AswearenotgonnaactuallyuseMovieCastDialog(wedeletedit),let'srenametheonein
site.less:
.s-MovieDB-MovieCastEditDialog{
>.size{width:450px;}
.caption{width:120px;}
.s-PropertyGrid.categories{height:120px;}
}
NowMovieCastEditDialoghasabetterlook:
TheCastandCharactersTheyPlayed
143

FixingMovieCastEditorColumns
MovieCastEditoriscurrentlyusingcolumnsdefinedinMovieCastColumns.cs(becauseit
returns"MovieDB.MovieCast"ingetColumnsKey()method.
WehaveMovieCastId,MovieId,PersonId(shownasActor/Actress)andCharactercolumns
there.ItisbettertoshowonlyActor/ActressandCharactercolumns.
WewanttoshowactorsfullnameinsteadofPersonId(integervalue),sowe'lldeclarethis
fieldinMovieCastRow.csfirst:
TheCastandCharactersTheyPlayed
144

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieCastRow:Row,IIdRow,INameRow
{
//...
[DisplayName("PersonFirstname"),Expression("jPerson.Firstname")]
publicStringPersonFirstname
{
get{returnFields.PersonFirstname[this];}
set{Fields.PersonFirstname[this]=value;}
}
[DisplayName("PersonLastname"),Expression("jPerson.Lastname")]
publicStringPersonLastname
{
get{returnFields.PersonLastname[this];}
set{Fields.PersonLastname[this]=value;}
}
[DisplayName("Actor/Actress"),
Expression("(jPerson.Firstname+''+jPerson.Lastname)")]
publicStringPersonFullname
{
get{returnFields.PersonFullname[this];}
set{Fields.PersonFullname[this]=value;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyStringFieldPersonFirstname;
publicreadonlyStringFieldPersonLastname;
publicreadonlyStringFieldPersonFullname;
//...
}
}
}
andmodifyMovieCastColumns.cs:
TheCastandCharactersTheyPlayed
145

namespaceMovieTutorial.MovieDB.Columns
{
usingSerenity.ComponentModel;
usingSystem;
[ColumnsScript("MovieDB.MovieCast")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
publicclassMovieCastColumns
{
[EditLink,Width(220)]
publicStringPersonFullname{get;set;}
[EditLink,Width(150)]
publicStringCharacter{get;set;}
}
}
Rebuildandcastgridhasbettercolumns:
Nowtryaddinganactor/actress,forexample,KeanuReeves/Neo:
TheCastandCharactersTheyPlayed
146

WhyActor/Actresscolumnisempty??
ResolvingEmptyActor/ActressColumnProblem
Rememberthatweareeditingin-memory.Thereisnoservicecallinvolvedhere.So,gridis
displayingwhateverentityissentbacktoitfromthedialog.
Whenyouclickthesavebutton,dialogbuildsanentitytosavelikethis:
{
PersonId:7,
Character:'Neo'
}
ThesefieldscorrespondstotheformfieldsyoupreviouslysetinMovieCastForm.cs:
publicclassMovieCastForm
{
publicInt32PersonId{get;set;}
publicStringCharacter{get;set;}
}
TheCastandCharactersTheyPlayed
147

Butingrid,weareshowingthesecolumns:
publicclassMovieCastColumns
{
publicStringPersonFullname{get;set;}
publicStringCharacter{get;set;}
}
ThereisnoPersonFullnamefieldinthisentity,sogridcan'tdisplayitsvalue.
WeneedtosetPersonFullnameourself.Let'sfirsttransformT4templatestohaveaccessto
PersonFullnamefieldthatwerecentlyadded,theneditMovieCastEditor.ts:
///<referencepath="../../Common/Helpers/GridEditorBase.ts"/>
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerEditor()
exportclassMovieCastEditorextendsCommon.GridEditorBase<MovieCastRow>{
//...
protectedvalidateEntity(row:MovieCastRow,id:number){
if(!super.validateEntity(row,id))
returnfalse;
row.PersonFullname=PersonRow.getLookup()
.itemById[row.PersonId].Fullname;
returntrue;
}
}
}
ValidateEntityisamethodfromourGridEditorBaseclassinSerene.Thismethodiscalled
whenSavebuttonisclickedtovalidatetheentity,justbeforeitisgoingtobeaddedtothe
grid.Butweareoverridingithereforanotherpurpose(tosetPersonFullnamefieldvalue)
ratherthanvalidation.
Aswesawbefore,ourentityhasPersonIdandCharacterfieldsfilledin.Wecanusethe
valueofPersonIdfieldtodeterminethepersonfullname.
Forthis,weneedadictionarythatmapsPersonIdtotheirFullnamevalues.Fortunately,
personlookuphassuchadictionary.WecanaccessthelookupforPersonRowthroughits
getLookupmethod.
AnotherwaytoaccesspersonlookupisbyQ.getLookup('MovieDB.Person').Theonein
PersonRowisjustashortcutdefinedbyT4templates.
TheCastandCharactersTheyPlayed
148

AlllookupshasaitemByIddictionarythatallowsyoutoaccessanentityofthattypebyits
ID.
Lookupsareasimplewaytoshareserversidedatawithclientside.Buttheyareonly
suitableforsmallsetsofdata.
Ifatablehashundredsofthousandsofrecords,itwouldn'tbereasonabletodefinea
lookupforit.Inthatcase,wewoulduseaservicerequesttoqueryarecordbyitsID.
DefiningCastListinMovieRow
WhilehavingaMoviedialogopen,andatleastonecastinCastList,clicksavebutton,and
you'llgetsuchanerror:
Thiserrorisraisedfrom->Rowdeserializer(JsonRowConverterforJSON.NET)atserver
side.
WedefinedCastListpropertyinMovieForm,buthavenocorrespondingfielddeclarationin
MovieRow.Sodeserializercan'tfindwheretowriteCastListvaluethatisreceivedfrom
clientside.
IfyouopendevelopertoolswithF12,clickNetworktab,andwatchAJAXrequestafter
clickingSavebutton,you'llseethatithassucharequestpayload:
TheCastandCharactersTheyPlayed
149

{
"Entity":{
"Title":"TheMatrix",
"Description":"Acomputerhacker...",
"CastList":[
{
"PersonId":"1",
"Character":"Neo",
"PersonFullname":"KeanuReeves"
}
],
"Storyline":"ThomasA.Andersonisamanlivingtwolives...",
"Year":1999,
"ReleaseDate":"1999-03-31",
"Runtime":136,
"GenreId":"",
"Kind":"1",
"MovieId":1
}
}
Here,CastListpropertycan'tbedeserializedatserverside.Soweneedtodeclareitin
MovieRow.cs:
namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
[DisplayName("CastList"),NotMapped]
publicList<MovieCastRow>CastList
{
get{returnFields.CastList[this];}
set{Fields.CastList[this]=value;}
}
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyRowListField<MovieCastRow>CastList;
//...
}
}
}
WedefinedaCastListpropertythatwillacceptaListofMovieCastRowobjects.Thetypeof
FieldclassthatisusedforsuchrowlistpropertiesisRowListField.
TheCastandCharactersTheyPlayed
150

Byadding[NotMapped]attribute,wespecifiedthatthisfieldisnotavailabledirectlyin
databasetable,thuscan'tbeselectedthroughsimpleSQLqueries.Itisanalogoustoan
unmappedfieldinotherORMsystems.
Now,whenyouclicktheSavebutton,youwillnotgetanerror.
ButreopentheMatrixentityyoujustsaved.Thereisnocastentrythere.Whathappenedto
Neo?
Asthisisanunmappedfield,somovieSaveservicejustignoredtheCastListproperty.
Ifyourememberthatinpriorsection,ourGenreListalsowasanunmappedfield,but
somehowitworkedthere.Thatsbecausewemadeuseofabehavior,
LinkedSetRelationBehaviorwiththatproperty.
Herewearesamplingwhatwouldhappenifwehadnosuchservicebehavior.
HandlingSaveforCastList
OpenMovieRepository.cs,findtheemptyMySaveHandlerclass,andmodifyitlikebelow:
privateclassMySaveHandler:SaveRequestHandler<MyRow>
{
protectedoverridevoidAfterSave()
{
base.AfterSave();
if(Row.CastList!=null)
{
varmc=Entities.MovieCastRow.Fields;
varoldList=IsCreate?null:
Connection.List<Entities.MovieCastRow>(
mc.MovieId==this.Row.MovieId.Value);
newCommon.DetailListSaveHandler<Entities.MovieCastRow>(
oldList,Row.CastList,
x=>x.MovieId=Row.MovieId.Value).Process(this.UnitOfWork);
}
}
}
MySaveHandler,processesbothCREATE(insert),andUPDATEservicerequestsforMovie
rows.AsmostofitslogicishandledbybaseSaveRequestHandlerclass,itsclassdefinition
wasemptybefore.
TheCastandCharactersTheyPlayed
151

WeshouldfirstwaitforMovieentitytobeinserted/updatedsuccessfully,beforeinserting/
updatingthecastlist.Thus,weareincludingourcustomizedcodebyoverridingthebase
AfterSavemethod.
IfthisisCREATE(insert)operation,weneedtheMovieIdfieldvaluetoreusein
MovieCastrecords.AsMovieIdisanIDENTITYfield,itisonlyavailableafterinserting
themovierecord.
Asweareeditingcastlistinmemory(client-side),thiswillbeabatchupdate.
Weneedtocompareoldlistofthecastrecordsforthismovietothenewlistofcastrecords,
andINSERT/UPDATE/DELETEthem.
Let'ssaywehadcastrecordsA,B,C,DindatabaseformovieX.
Userdidsomemodificationsineditdialogstocastlist,andnowwehaveA,B,D,E,F.
SoweneedtoupdateA,B,D(incasecharacter/actorchanged),deleteC,andinsertnew
recordsEandF.
Fortunately,DetailListSaveHandlerclassthatisdefinedinSerene,handlesallthese
comparisonsandperformsinsert/update/deleteoperationsautomatically(byIDvalues).
Otherwisewewouldhavetowritemuchmorecodehere.
Togetalistofoldrecords,weneedtoquerydatabaseifthisisanUPDATEmovieoperation.
IfthisisaCREATEmovieoperationthereshouldn'tbeanyoldcastrecord.
WeareusingConnection.List<Entities.MovieCastRow>extensionmethod.Connection
hereisapropertyofSaveRequestHandlerthatreturnsthecurrentconnectionused.List
selectsrecordsthatmatchesthespecifiedcriteria(mc.MovieId==this.Row.MovieId.Value).
this.Rowreferstocurrentlyinserted/updatedrecord(movie)withitsnewfieldvalues,soit
containstheMovieIdvalue(neworexisting).
Toupdatecastrecords,wearecreatingaDetailListHandlerobject,witholdcastlist,new
castlist,andadelegatetosettheMovieIdfieldvalueinacastrecord.Thisistolinknew
castrecordswiththecurrentmovie.
ThenwecallDetailListHandler.Processwithcurrentunitofwork.UnitOfWorkisaspecial
objectthatwrapsthecurrentconnection/transaction.
AllSerenityCREATE/UPDATE/DELETEhandlersworkswithimplicittransactions
(IUnitOfWork).
HandlingRetrieveforCastList
TheCastandCharactersTheyPlayed
152

Wearenotdoneyet.WhenaMovieentityisclickedinmoviegrid,moviedialogloadsthe
movierecordbycallingthemovieRetrieveservice.AsCastListisanunmappedfield,evenif
wesavedthemproperly,theywon'tbeloadedintothedialog.
WeneedtoalsoeditMyRetrieveHandlerclassinMovieRepository.cs:
privateclassMyRetrieveHandler:RetrieveRequestHandler<MyRow>
{
protectedoverridevoidOnReturn()
{
base.OnReturn();
varmc=Entities.MovieCastRow.Fields;
Row.CastList=Connection.List<Entities.MovieCastRow>(q=>q
.SelectTableFields()
.Select(mc.PersonFullname)
.Where(mc.MovieId==Row.MovieId.Value));
}
}
Here,weareoverridingOnReturnmethod,toinjectCastListintomovierowjustbefore
returningtheitfromretrieveservice.
IusedadifferentoverloadofConnection.Listextension,whichallowsmetomodifythe
selectquery.
Bydefault,Listselectsalltablefields(notforeignviewfieldscomingfromothertables),but
toshowactorname,ineededtoalsoselectPersonFullNamefield.
Nowbuildthesolution,andwecanfinallylist/editthecast.
HandlingDeleteforCastList
WhenyoutrytodeleteaMovieentity,you'llgetforeignkeyerrors.Youcouldusea
"CASCADEDELETE"foreignkeywhilecreatingMovieCasttable.Butwe'llhandlethisat
repositorylevelagain:
TheCastandCharactersTheyPlayed
153

privateclassMyDeleteHandler:DeleteRequestHandler<MyRow>
{
protectedoverridevoidOnBeforeDelete()
{
base.OnBeforeDelete();
varmc=Entities.MovieCastRow.Fields;
foreach(vardetailIDinConnection.Query<Int32>(
newSqlQuery()
.From(mc)
.Select(mc.MovieCastId)
.Where(mc.MovieId==Row.MovieId.Value)))
{
newDeleteRequestHandler<Entities.MovieCastRow>().Process(this.UnitOfWork,
newDeleteRequest
{
EntityId=detailID
});
}
}
}
Thewayweimplementedthismaster/detailhandlingisnotveryintuitiveandincluded
severalmanualstepsatrepositorylayer.Keeponreadingtoseehoweasilyitcouldbedone
byusinganintegratedfeature(MasterDetailRelationAttribute).
HandlingSave/Retrieve/DeleteWithaBehavior
Master/detailrelationsareanintegratedfeature(atleastonserverside),soinsteadof
manuallyoverridingSave/RetrieveandDeletehandlers,i'lluseanattribute,
MasterDetailRelation.
OpenMovieRow.csandmodifyCastListproperty:
[MasterDetailRelation(foreignKey:"MovieId",IncludeColumns="PersonFullname")]
[DisplayName("CastList"),NotMapped]
publicList<MovieCastRow>CastList
{
get{returnFields.CastList[this];}
set{Fields.CastList[this]=value;}
}
Wespecifiedthatthisfieldisadetaillistofamaster/detailrelationandmasterIDfield
(foreignKey)ofthedetailtableisMovieId.
NowundoallchangeswemadeinMovieRepository.cs:
TheCastandCharactersTheyPlayed
154

privateclassMySaveHandler:SaveRequestHandler<MyRow>{}
privateclassMyDeleteHandler:DeleteRequestHandler<MyRow>{}
privateclassMyRetrieveHandler:RetrieveRequestHandler<MyRow>{}
InourMasterDetailRelationattribute,wespecifiedanextraproperty,IncludeColumns:
[MasterDetailRelation(foreignKey:"MovieId",IncludeColumns="PersonFullname")]
ThisensuresthatPersonFullnamefieldoncastlistisselectedonretrieve.Otherwise,it
wouldn'tbeloadedasonlytablefieldsareselectedbydefault.Whenyouopenamovie
dialogwithexistingcastlist,fullnamewouldbeempty.
MakesureyouaddanyviewfieldyouuseingridcolumnstoIncludeColumns.Put
commabetweennamesofmultiplefields,e.g.IncludeColumns="FieldA,FieldB,
FieldC".
Nowbuildyourprojectandyou'llseethatsamefunctionalityworkswithmuchlesscode.
MasterDetailRelationAttributetriggersaninstrinsic(automatic)behavior,
MasterDetailRelationBehaviorwhichinterceptsRetrieve/Save/Deletehandlersandmethods
wehadoverridenbeforeandperformssimilaroperations.
Sowedidthesamething,butthistimedeclaratively,notimperatively(whatshouldbedone,
insteadofhowtodoit)
https://en.wikipedia.org/wiki/Declarative_programming
We'llseehowtowriteyourownrequesthandlerbehaviorsinfollowingchapters.
TheCastandCharactersTheyPlayed
155

ListingMoviesinPersonDialog
Toshowlistofmoviesapersonactedin,we'lladdatabtoPersonDialog.
Bydefaultallentitydialogs(onesweusedsofar,whichderivefromEntityDialog)uses
EntityDialogtemplateatMovieTutorial.Web/Views/Templates/EntityDialog.Template.html:
<divclass="s-DialogContent">
<divid="~_Toolbar"class="s-DialogToolbar">
</div>
<divclass="s-Form">
<formid="~_Form"action="">
<divclass="fieldsetui-widgetui-widget-contentui-corner-all">
<divid="~_PropertyGrid"></div>
<divclass="clear"></div>
</div>
</form>
</div>
</div>
Thistemplatecontainsatoolbarplaceholder(~_Toolbar),form(~_Form)andPropertyGrid
(*~_PropertyGrid).
~_isaspecialprefixthatisreplacedwithauniquedialogIDatruntime.Thisensures
thatobjectsintwoinstancesofadialogwon'thavethesameIDvalues.
EntityDialogtemplateissharedbyalldialogs,sowearenotgonnamodifyittoaddatabto
PersonDialog.
DefiningaTabbedTemplateforPersonDialog
Createanewfile,MovieDB.PersonDialog.Template.htmlunderModules/MovieDB/Person/
folderwithcontents:
ListingMoviesinPersonDialog
156

<divid="~_Tabs"class="s-DialogContent">
<ul>
<li><ahref="#~_TabInfo"><span>Person</span></a></li>
<li><ahref="#~_TabMovies"><span>Movies</span></a></li>
</ul>
<divid="~_TabInfo"class="tab-panes-TabInfo">
<divid="~_Toolbar"class="s-DialogToolbar">
</div>
<divclass="s-Form">
<formid="~_Form"action="">
<divclass="fieldsetui-widgetui-widget-contentui-corner-all">
<divid="~_PropertyGrid"></div>
<divclass="clear"></div>
</div>
</form>
</div>
</div>
<divid="~_TabMovies"class="tab-panes-TabMovies">
<divid="~_MoviesGrid">
</div>
</div>
</div>
ThesyntaxweusedhereisspecifictojQueryUItabswidget.ItneedsanULelementwith
listoftablinkspointingtotabpanedivs(.tab-pane).
WhenEntityDialogfindsadivwithID~_Tabsinitstemplate,itautomaticallyinitializesatabs
widgetonit.
Namingofthetemplatefileisimportant.Itmustendwith.Template.htmlextension.Allfiles
withthisextensionaremadeavailableatclientsidethroughadynamicscript.
Folderofthetemplatefileisignored,buttemplatesmustbeunderModulesor
Views/Templatedirectories.
Bydefault,alltemplatedwidgets(EntityDialogalsoderivesfromTemplatedWidgetclass),
looksforatemplatewiththeirownclassname.Thus,PersonDialoglooksforatemplatewith
thenameMovieDB.PersonDialog.Template.html,followedbyPersonDialog.Template.html.
MovieDBcomesfromPersonDialognamespacewiththerootnamespace
(MovieTutorial)removed.Youcanalsothinkofitasmodulenamedotclassname.
Ifatemplatewithclassnameisnotfound,searchcontinuestobaseclassesandeventually
afallbacktemplate,EntityDialog.Template.htmlisused.
Now,wehaveatabinPersonDialog:
ListingMoviesinPersonDialog
157

Meanwhile,inoticedPersonlinkisstillunderMovieDBandweforgottoremove
MovieCastlink.I'mfixingthemnow...
CreatingPersonMovieGrid
Movietabisemptyfornow.Weneedtodefineagridwithsuitablecolumnsandplaceitin
thattab.
First,declarethecolumnswe'llusewiththegrid,infilePersonMovieColumns.csnextto
PersonColumns.cs:
ListingMoviesinPersonDialog
158

namespaceMovieTutorial.MovieDB.Columns
{
usingSerenity.ComponentModel;
usingSystem;
[ColumnsScript("MovieDB.PersonMovie")]
[BasedOnRow(typeof(Entities.MovieCastRow))]
publicclassPersonMovieColumns
{
[Width(220)]
publicStringMovieTitle{get;set;}
[Width(100)]
publicInt32MovieYear{get;set;}
[Width(200)]
publicStringCharacter{get;set;}
}
}
NextdefineaPersonMovieGridclass,infilePersonMovieGrid.tsnexttoPersonGrid.ts:
namespaceMovieTutorial.MovieDB{
@Serenity.Decorators.registerClass()
exportclassPersonMovieGridextendsSerenity.EntityGrid<MovieCastRow,any>
{
protectedgetColumnsKey(){return"MovieDB.PersonMovie";}
protectedgetIdProperty(){returnMovieCastRow.idProperty;}
protectedgetLocalTextPrefix(){returnMovieCastRow.localTextPrefix;}
protectedgetService(){returnMovieCastService.baseUrl;}
constructor(container:JQuery){
super(container);
}
}
}
We'llactuallyuseMovieCastservice,tolistmoviesapersonactedin.
LaststepistoinstantiatethisgridinPersonDialog.ts:
ListingMoviesinPersonDialog
159

@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
exportclassPersonDialogextendsSerenity.EntityDialog<PersonRow,any>{
protectedgetFormKey(){returnPersonForm.formKey;}
protectedgetIdProperty(){returnPersonRow.idProperty;}
protectedgetLocalTextPrefix(){returnPersonRow.localTextPrefix;}
protectedgetNameProperty(){returnPersonRow.nameProperty;}
protectedgetService(){returnPersonService.baseUrl;}
protectedform=newPersonForm(this.idPrefix);
privatemoviesGrid:PersonMovieGrid;
constructor(){
super();
this.moviesGrid=newPersonMovieGrid(this.byId("MoviesGrid"));
this.tabs.on('tabsactivate',(e,i)=>{
this.arrange();
});
}
}
Rememberthatinourtemplatewehadadivwithid~_MoviesGridundermoviestabpane.
WecreatedPersonMoviegridonthatdiv.
this.ById("MoviesGrid")isaspecialmethodfortemplatedwidgets.$('#MoviesGrid')
wouldn'tworkhere,asthatdivactuallyhassomeIDlikePersonDialog17_MoviesGrid.
~_intemplatesarereplacedwithauniquecontainerwidgetID.
WealsoattachedtoOnActivateeventofjQueryUItabs,andcalledArrangemethodofthe
dialog.ThisistosolveaproblemwithSlickGrid,whenitisinitiallycreatedininvisibletab.
ArrangetriggersrelayoutforSlickGridtosolvethisproblem.
OK,nowwecanseelistofmoviesinMoviestab,butsomethingisstrange:
ListingMoviesinPersonDialog
160

FilteringMoviesforthePerson
No,Carrie-AnneMossdidn'tactinthreeroles.Thisgridisshowingallmoviecastrecordsfor
now,aswedidn'ttellwhatfilteritshouldapplyyet.
PersonMovieGridshouldknowthepersonitshowsthemoviecastrecordsfor.So,weadda
PersonIDpropertytothisgrid.ThisPersonIDshouldbepassedsomehowtolistservicefor
filtering.
ListingMoviesinPersonDialog
161

namespaceMovieTutorial.MovieDB
{
@Serenity.Decorators.registerClass()
exportclassPersonMovieGridextendsSerenity.EntityGrid<MovieCastRow,any>
{
protectedgetColumnsKey(){return"MovieDB.PersonMovie";}
protectedgetIdProperty(){returnMovieCastRow.idProperty;}
protectedgetLocalTextPrefix(){returnMovieCastRow.localTextPrefix;}
protectedgetService(){returnMovieCastService.baseUrl;}
constructor(container:JQuery){
super(container);
}
protectedgetButtons(){
returnnull;
}
protectedgetInitialTitle(){
returnnull;
}
protectedusePager(){
returnfalse;
}
protectedgetGridCanLoad(){
returnthis.personID!=null;
}
private_personID:number;
getpersonID(){
returnthis._personID;
}
setpersonID(value:number){
if(this._personID!=value){
this._personID=value;
this.setEquality(MovieCastRow.Fields.PersonId,value);
this.refresh();
}
}
}
}
WeareusingES5(EcmaScript5)property(get/set)features.It'sprettysimilartoC#
properties.
ListingMoviesinPersonDialog
162

WestorethepersonIDinaprivatevariable.Whenitchanges,wealsosetaequalityfilterfor
PersonIdfieldusingSetEqualitymethod(whichwillbesenttolistservice),andrefreshto
seechanges.
Equalityfilteristhelistrequestparameterthatisalsousedbyquickfilteritems.
OverridingGetGridCanLoadmethodallowsustocontrolwhengridcancalllistservice.Ifwe
didn'toverrideit,whilecreatinganewPerson,gridwouldloadallmoviecastrecords,as
thereisnotaPersonIDyet(itisnull).
Listhandlerignoresanequalityfilterparameterifitsvalueisnull.Justlikewhenaquick
filterdropdownisempty,allrecordsareshown.
Wealsodidthreecosmeticchanges,byoverridingthreemethods,firsttoremoveallbuttons
fromtoolbar(getButtons),secondtoremovetitlefromthegrid(getInitialTitle)astabtitleis
enough),andthirdtoremovepagingfunctionality(usePager),apersoncan'thaveamillion
moviesright?).
SettingPersonIDofPersonMovieGridinPersonDialog
Ifnobodysetsgrid'sPersonIDproperty,itwillalwaysbenull,andnorecordswillbeloaded.
WeshouldsetitinafterLoadEntitymethodofPersondialog:
namespaceMovieTutorial.MovieDB
{
//...
exportclassPersonDialogextendsSerenity.EntityDialog<PersonRow>
{
//...
protectedafterLoadEntity()
{
super.afterLoadEntity();
this.moviesGrid.personID=this.entityId;
}
}
}
afterLoadEntityiscalledafteranentityoranewentityisloadedintodialog.
Pleasenotethatentityisloadedinalaterphase,soitwon'tbeavailableindialog
constructor.
this.EntityIdreferstotheidentityvalueofthecurrentlyloadedentity.Innewrecordmode,it
isnull.
ListingMoviesinPersonDialog
163

AfterLoadEntityandLoadEntitymightbecalledseveraltimesduringdialoglifetime,so
avoidcreatingsomechildobjectsintheseevents,otherwiseyouwillhavemultiple
instancesofcreatedobjects.Thatswhywecreatedthegridindialogconstructor.
FixingMoviesTabSize
YoumighthavenoticedthatwhenyouswitchtoMoviestab,dialoggetsabitlessinheight.
Thisisbecausedialogissettoautoheightandgridsare200pxbydefault.Whenyouswitch
tomoviestab,formgetshidden,sodialogadjuststomoviesgridheight.
Edits-MovieDB-PersonDialogcssinsite.less:
.s-MovieDB-PersonDialog{
>.size{width:650px;}
.caption{width:150px;}
.s-PersonMovieGrid>.grid-container{height:287px;}
}
ListingMoviesinPersonDialog
164

AddingPrimaryandGalleryImages
ToaddaprimaryimageandmultiplegalleryimagestobothMovieandPersonrecords,need
tostartwithamigration:
usingFluentMigrator;
namespaceMovieTutorial.Migrations.DefaultDB
{
[Migration(20160603205900)]
publicclassDefaultDB_20160603_205900_PersonMovieImages:Migration
{
publicoverridevoidUp()
{
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();
}
publicoverridevoidDown()
{
}
}
}
ThenmodifyMovieRow.csandPersonRow.cs:
AddingPrimaryandGalleryImages
165

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassPersonRow:Row,IIdRow,INameRow
{
[DisplayName("PrimaryImage"),Size(100),
ImageUploadEditor(FilenameFormat="Person/PrimaryImage/~")]
publicstringPrimaryImage
{
get{returnFields.PrimaryImage[this];}
set{Fields.PrimaryImage[this]=value;}
}
[DisplayName("GalleryImages"),
MultipleImageUploadEditor(FilenameFormat="Person/GalleryImages/~")]
publicstringGalleryImages
{
get{returnFields.GalleryImages[this];}
set{Fields.GalleryImages[this]=value;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyStringFieldPrimaryImage;
publicreadonlyStringFieldGalleryImages;
//...
}
}
}
AddingPrimaryandGalleryImages
166

namespaceMovieTutorial.MovieDB.Entities
{
//...
publicsealedclassMovieRow:Row,IIdRow,INameRow
{
[DisplayName("PrimaryImage"),Size(100),
ImageUploadEditor(FilenameFormat="Movie/PrimaryImage/~")]
publicstringPrimaryImage
{
get{returnFields.PrimaryImage[this];}
set{Fields.PrimaryImage[this]=value;}
}
[DisplayName("GalleryImages"),
MultipleImageUploadEditor(FilenameFormat="Movie/GalleryImages/~")]
publicstringGalleryImages
{
get{returnFields.GalleryImages[this];}
set{Fields.GalleryImages[this]=value;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyStringFieldPrimaryImage;
publicreadonlyStringFieldGalleryImages;
//...
}
}
}
HerewespecifythatthesefieldswillbehandledbyImageUploadEditorand
MultipleImageUploadEditortypes.
FilenameFormatspecifiesthenamingofuploadedfiles.Forexample,Personprimaryimage
willbeuploadedtoafolderunderApp_Data/upload/Person/PrimaryImage/.
Youmaychangeuploadroot(App_Data/upload)toanythingyoulikebymodifying
UploadSettingsappSettingskeyinweb.config.
~attheendofFilenameFormatisashortcutfortheautomaticnamingscheme
{1:00000}/{0:00000000}_{2}.
Here,parameter{0}isreplacedwithidentityoftherecord,e.g.PersonID.
Parameter{1}isidentity/1000.Thisisusefultolimitnumberoffilesthatisstoredinone
directory.
AddingPrimaryandGalleryImages
167

Parameter{2}isauniquestringlike6l55nk6v2tiyi,whichisusedtogenerateanewfile
nameoneveryupload.Thishelpstoavoidproblemscausedbycachingonclientside.
Italsoprovidessomesecuritysofilenamescan'tbeknownwithouthavingalink.
Thus,afileweuploadforpersonprimaryimagewillbelocatedatapathlikethis:
>App_Data\upload\Person\PrimaryImage\00000\00000001_6l55nk6v2tiyi.jpg
Youdon'thavetofollowthisnamingscheme.Youcanspecifyyourownformatlike
PersonPrimaryImage_{0}_{2}.
Nextstepistoaddthesefieldstoforms(MovieForm.csandPersonForm.cs):
namespaceMovieTutorial.MovieDB.Forms
{
//...
publicclassPersonForm
{
publicStringFirstname{get;set;}
publicStringLastname{get;set;}
publicStringPrimaryImage{get;set;}
publicStringGalleryImages{get;set;}
publicDateTimeBirthDate{get;set;}
publicStringBirthPlace{get;set;}
publicGenderGender{get;set;}
publicInt32Height{get;set;}
}
}
AddingPrimaryandGalleryImages
168

namespaceMovieTutorial.MovieDB.Forms
{
//...
publicclassMovieForm
{
publicStringTitle{get;set;}
[TextAreaEditor(Rows=3)]
publicStringDescription{get;set;}
[MovieCastEditor]
publicList<Entities.MovieCastRow>CastList{get;set;}
publicStringPrimaryImage{get;set;}
publicStringGalleryImages{get;set;}
[TextAreaEditor(Rows=8)]
publicStringStoryline{get;set;}
publicInt32Year{get;set;}
publicDateTimeReleaseDate{get;set;}
publicInt32Runtime{get;set;}
publicInt32GenreId{get;set;}
publicMovieKindKind{get;set;}
}
}
IalsomodifiedPersondialogcssabittohavemorespace:
.s-MovieDB-PersonDialog{
>.size{width:700px;height:600px;}
.caption{width:150px;}
.s-PersonMovieGrid>.grid-container{height:500px;}
}
Thisiswhatwegetnow:
AddingPrimaryandGalleryImages
169

ImageUploadEditorstoresfilenamedirectlyinastringfield,whileMultipleImageUpload
editorstoresfilenamesinastringfieldwithJSONarrayformat.
RemovingNorthwindandOtherSamples
Asithinkourprojecthasreachedagoodstate,i'mnowgoingtoremoveNorthwindand
othersamplesfromMovieTutorialproject.
Seefollowinghow-totopic:
HowTo:RemovingNorthwindandOtherSamples
AddingPrimaryandGalleryImages
170

MultiTenancy
InthistutorialwearegoingtoturnNorhwindintoamulti-tenantapplication.
Hereisadefinitionofmulti-tenantsofwarefromWikipedia:
SoftwareMultitenancyreferstoasoftwarearchitectureinwhichasingleinstanceofa
softwarerunsonaserverandservesmultipletenants.Atenantisagroupofuserswho
shareacommonaccesswithspecificprivilegestothesoftwareinstance.Witha
multitenantarchitecture,asoftwareapplicationisdesignedtoprovideeverytenanta
dedicatedshareoftheinstanceincludingitsdata,configuration,usermanagement,
tenantindividualfunctionalityandnon-functionalproperties.Multitenancycontrastswith
multi-instancearchitectures,whereseparatesoftwareinstancesoperateonbehalfof
differenttenants.---Wikipedia
We'lladdaTenantIdfieldtoeverytable,includingUsers,andletuserseeandmodifyonly
recordsbelongingtohertenant.So,tenantswillworkinisolation,asiftheyareworkingwith
theirowndatabase.
Multitenantapplicationshassomeadvantageslikereducedcostofmanagement.Butthey
alsohavesomedisadvantages.Forexample,asalltenantdataisinasingledatabase,a
tenantcan'tsimplytakeorbackupherdataalone.Performanceisusuallyreducedasthere
aremorerecordstohandle.
Withincreasingtrendofcloudapplications,decreasedcostofvirtualization,andwith
featureslikemigration,itsnoweasiertosetupmulti-instanceapps.
I'dpersonallyavoidmulti-tenantapplications.It'sbettertohaveonedatabasepercustomer
inmyopinion.
Butsomeusersaskedabouthowtoimplementthisfeature.Thistutorialwillhelpusexplain
someadvancedSerenitytopicsasabonus,alongwithmultitenancy.
Youcanfindsourcecodeforthistutorialat:
https://github.com/volkanceylan/MultiTenancy
CreateanewprojectnamedMultiTenancy
InVisualStudioclickFile->NewProject.MakesureyouchooseSerenetemplate.Type
MultiTenancyasnameandclickOK.
InSolutionexplorer,youshouldseeaprojectwithnameMultiTenancy.Web.
MultiTenancy
171

MultiTenancy
172

AddingTenantsTableandTenantIdField
WeneedtoaddaTenantIdfieldtoalltables,toisolatetenantsfromeachother.
So,wefirstneedaTenantstable.
AsNorthwindtablesalreadyhaverecords,we'lldefineaprimarytenantwithID1,andsetall
existingrecordsTenantIdtoit.
It'stimetowriteamigration,actuallytwomigrations,oneforNorthwindandoneforDefault
database.
DefaultDB_20170430_134800_MultiTenant.cs:
AddingTenantsTableandTenantIdField
173

usingFluentMigrator;
namespaceMultiTenancy.Migrations.DefaultDB
{
[Migration(20170430134800)]
publicclassDefaultDB_20170430_134800_MultiTenant
:AutoReversingMigration
{
publicoverridevoidUp()
{
this.CreateTableWithId32("Tenants","TenantId",s=>s
.WithColumn("TenantName").AsString(100)
.NotNullable());
Insert.IntoTable("Tenants")
.Row(new
{
TenantName="PrimaryTenant"
});
Insert.IntoTable("Tenants")
.Row(new
{
TenantName="SecondTenant"
});
Insert.IntoTable("Tenants")
.Row(new
{
TenantName="ThirdTenant"
});
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);
}
}
}
IhavecreatedTenantstableinDefaultdatabasewhereusertablesare.Hereweadd3
predefinedtenants.WeactuallyonlyneedfirstonewithID1.
AddingTenantsTableandTenantIdField
174

Wedidn'taddTenantIdcolumntotableslikeUserPermissions,UserRoles,RolePermissions
etc,astheyinstrinsiclyhaveTenantIdinformationthroughtheirUserIdorRoleId(asthese
tablesalreadyhaveTenantIdvalue)
Let'swriteanothermigrationforNortwhinddatabasetoaddTenantIdcolumntorequired
tables:
NorthwindDB_20160110_093500_MultiTenant.cs:
AddingTenantsTableandTenantIdField
175

usingFluentMigrator;
namespaceMultiTenancy.Migrations.NorthwindDB
{
[Migration(20170430194100)]
publicclassNorthwindDB_20170430_194100_MultiTenant
:AutoReversingMigration
{
publicoverridevoidUp()
{
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);
}
}
}
AddingTenantsTableandTenantIdField
176

AddingTenantsTableandTenantIdField
177

GeneratingCodeforTenantsTable
LaunchSergenandgeneratecodeforTenantstableinDefaultconnection:
Nextwe'lldefinealookupscriptinTenantRowandsetDisplayNamepropertytoTenants:
namespaceMultiTenancy.Administration.Entities
{
//...
[ConnectionKey("Default"),DisplayName("Tenants"),
InstanceName("Tenant"),TwoLevelCached]
[LookupScript("Administration.Tenant")]
publicsealedclassTenantRow:Row,IIdRow,INameRow
{
[DisplayName("TenantId"),Identity]
publicInt32?TenantId
{
get{returnFields.TenantId[this];}
set{Fields.TenantId[this]=value;}
}
//...
Let'sdefineaAdministration:Tenantspermissionthatonlyadminuserwillhave(in
AdministrationPermissionKeys.cs):
GeneratingCodeforTenantsTable
178

namespaceMultiTenancy.Administration
{
publicclassPermissionKeys
{
publicconststringSecurity="Administration:Security";
publicconststringTranslation="Administration:Translation";
publicconststringTenants="Administration:Tenants";
}
}
AnduseitonTenantRow:
[ConnectionKey("Default"),DisplayNahme("Tenants"),
InstanceName("Tenant"),TwoLevelCached]
[ReadPermission(PermissionKeys.Tenants)]
[ModifyPermission(PermissionKeys.Tenants)]
[LookupScript("Administration.Tenant")]
publicsealedclassTenantRow:Row,IIdRow,INameRow
{
GeneratingCodeforTenantsTable
179

TenantSelectioninUserDialog
WeaddedaTenantIdfieldtoUserstable,butit'snotdefinedinUserRow,andnotvisiblein
userdialog.
Thisfield,shouldonlybeseenandeditedbyadminuser.Otherusers,evenifwegivethem
accesstouserspagetomanagetheirtenantusers,shouldn'tbeabletoseeorchangethis
information.
Let'sfirstaddittoUserRow.cs:
TenantSelectioninUserDialog
180

namespaceMultiTenancy.Administration.Entities
{
//...
publicsealedclassUserRow:LoggingRow,IIdRow,INameRow
{
//...
[DisplayName("LastDirectoryUpdate"),Insertable(false),Updatable(false)]
publicDateTime?LastDirectoryUpdate
{
get{returnFields.LastDirectoryUpdate[this];}
set{Fields.LastDirectoryUpdate[this]=value;}
}
[DisplayName("Tenant"),ForeignKey("Tenants","TenantId"),LeftJoin("tnt")]
[LookupEditor(typeof(TenantRow))]
publicInt32?TenantId
{
get{returnFields.TenantId[this];}
set{Fields.TenantId[this]=value;}
}
[DisplayName("Tenant"),Expression("tnt.TenantName")]
publicStringTenantName
{
get{returnFields.TenantName[this];}
set{Fields.TenantName[this]=value;}
}
//...
publicclassRowFields:LoggingRowFields
{
//...
publicreadonlyDateTimeFieldLastDirectoryUpdate;
publicreadonlyInt32FieldTenantId;
publicreadonlyStringFieldTenantName;
//...
}
}
}
Toeditit,weneedtoaddittoUserForm.cs:
TenantSelectioninUserDialog
181

namespaceMultiTenancy.Administration.Forms
{
usingSerenity;
usingSerenity.ComponentModel;
usingSystem;
usingSystem.ComponentModel;
[FormScript("Administration.User")]
[BasedOnRow(typeof(Entities.UserRow))]
publicclassUserForm
{
publicStringUsername{get;set;}
publicStringDisplayName{get;set;}
[EmailEditor]
publicStringEmail{get;set;}
[PasswordEditor]
publicStringPassword{get;set;}
[PasswordEditor,OneWay]
publicStringPasswordConfirm{get;set;}
[OneWay]
publicstringSource{get;set;}
publicInt32?TenantId{get;set;}
}
}
Needtoalsoincreasesizeofuserdialogabit,insite.administration.lesstomakespacefor
tenantselection:
.s-Administration-UserDialog{
>.size{width:650px;}
.caption{width:150px;}
.s-PropertyGrid.categories{height:470px;}
}
NowopenUserManagementpageandcreateausertenant2thatbelongstoSecond
Tenant.
TenantSelectioninUserDialog
182

Aftercreatingthisuser,edititspermissionsandgranthimUser,RoleManagementand
PermissionspermissionasthiswillbeouradministrativeuserforSecondTenant.
LoggingInWithTenant2
Signoutandloginwithusertenant2.
WhenyouopenUserManagementpage,theremaybetwodifferentcasesyoumay
experience.
Infirstcase,tenant2mightbeabletoopenuserdialogandchangehisandanyotherusers
tenant.Thishappensifyourbrowsercachedthetenantlookup.
Inthesecondcase,you'llseethattenant2can'topenUserdialog.Whenyouclickauser
nothinghappens.
Ifyoucheckbrowserconsole(wheneversuchathingoccurs,youshouldfirstcheckbrowser
consoleforerrors),you'llseeanerrorlikethis:
TenantSelectioninUserDialog
183

Thisisbecause,ourTenantRowhasAdministration:Tenantsreadpermissionwhichis
inheritedbylookupscript.
Wecouldchangereadpermissionfortenantlookupscripttosomethingelsetoresolvethis
error,butinthatcaseTenant2wouldbeabletoseeandchangetenantofhimselfandany
otheruserincludingadmin.
Thisisnotwhatwewanted.
Let'sfirstpreventhimseeingusersofothertenants.
TenantSelectioninUserDialog
184

FilteringUsersByTenantId
WefirstneedtoloadandcacheusertenantinformationinUserDefinition.
OpenUserDefinition.csunderMultitenancy.Web/Modules/Administration/User/
AuthenticationandaddaTenantIdproperty.
namespaceMultiTenancy.Administration
{
usingSerenity;
usingSystem;
[Serializable]
publicclassUserDefinition:IUserDefinition
{
publicstringId{get{returnUserId.ToInvariant();}}
publicstringDisplayName{get;set;}
publicstringEmail{get;set;}
publicshortIsActive{get;set;}
publicintUserId{get;set;}
publicstringUsername{get;set;}
publicstringPasswordHash{get;set;}
publicstringPasswordSalt{get;set;}
publicstringSource{get;set;}
publicDateTime?UpdateDate{get;set;}
publicDateTime?LastDirectoryUpdate{get;set;}
publicintTenantId{get;set;}
}
}
Thisistheclassthatisreturnedwhenyouaskforcurrentuserthrough
Authorization.UserDefinition.
Wealsoneedtomodifythecodewherethisclassisloaded.Inthesamefolder,edit
UserRetrieveService.csandchangeGetFirstmethodlikebelow:
FilteringUsersByTenantId
185

privateUserDefinitionGetFirst(IDbConnectionconnection,BaseCriteriacriteria)
{
varuser=connection.TrySingle<Entities.UserRow>(criteria);
if(user!=null)
returnnewUserDefinition
{
UserId=user.UserId.Value,
Username=user.Username,
Email=user.Email,
DisplayName=user.DisplayName,
IsActive=user.IsActive.Value,
Source=user.Source,
PasswordHash=user.PasswordHash,
PasswordSalt=user.PasswordSalt,
UpdateDate=user.UpdateDate,
LastDirectoryUpdate=user.LastDirectoryUpdate,
TenantId=user.TenantId.Value
};
returnnull;
}
Now,it'stimetofilterlistedusersbyTenantId.OpenUserRepository.cs,locate
MyListHandlerclassandmodifyitlikethis:
privateclassMyListHandler:ListRequestHandler<MyRow>
{
protectedoverridevoidApplyFilters(SqlQueryquery)
{
base.ApplyFilters(query);
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fld.TenantId==user.TenantId);
}
}
Here,wefirstgetareferencetocacheduserdefinitionofcurrentlyloggeduser.
Wecheckifhehastenantadministrationpermission,whichonlyadminwillhaveintheend.
Ifnot,wefilterlistedrecordsbyTenantId.
FilteringUsersByTenantId
186

RemovingTenantDropdownFromUser
Form
Afteryourebuild,andlaunch,nowuserpagewillbelikethis:
Yes,hecan'tseeadminuseranymore,butsomethingiswrong.Whenyouclicktenant2,
nothingwillhappenandyou'llgetanerror"Can'tloadscriptdata:
Lookup.Administration.Tenant"inbrowserconsole:
Thiserrorisnotrelatedtoourrecentfilteringatrepositorylevel.Itcan'tloadthislookup
script,becausecurrentuserhasnopermissiontoTenantstable.Buthowdidheseeitlast
time(inonecase)?
Hecouldseeit,becausewefirstloggedinasadminandwhenweopeneditdialogforuser,
weloadedthislookupscript.Browsercachedit,sowhenweloggedinwithtenant2and
openeditdialog,itloadedtenantsfrombrowsercache.
Butthistime,aswerebuildproject,browsertriedtoloaditfromserver,andwegotthiserror,
astenant2doesn'thavethispermission.It'sok,wedon'twanthimtohavethispermission,
buthowtoavoidthiserror?
RemovingTenantDropdownFromUserForm
187

WeneedtoremoveTenantfieldfromtheuserform.Butweneedthatfieldforadminuser,so
wecan'tsimplydeleteitfromUserForm.cs.Thus,weneedtodoitconditionally.
Buildtheproject,transformalltemplatesandaddmethodbelowtoUserDialog.ts:
protectedgetPropertyItems(){
varitems=super.getPropertyItems();
if(!Q.Authorization.hasPermission("Administration:Tenants"))
items=items.filter(x=>x.name!=UserRow.Fields.TenantId);
returnitems;
}
DialogsgetslistoffieldsitwillshowinitsformbygetPropertyItemsmethod,whichinturn
loadsthemfromserversideformdefinition.
HereweexcludeTenantIdfield,ifcurrentuserdoesn'thavethetenantspermission.
Thisdoesn'tmodifytheoriginaluserform,itjustchangeslistforthisdialoginstance.
Usertenant2cannowopentheuserdialog.
RemovingTenantDropdownFromUserForm
188

SecuringTenantSelectionAtServerSide
Whenyouloginwithtenant2userandopenitseditform,Tenantselectiondropdownisnot
displayed,sohecan'tchangehistenantright?
Wrong!
Ifheisanordinaryuser,hecan't.ButifhehassomeknowledgeofhowSerenityandits
serviceswork,hecould.
Whenyouareworkingwithweb,yougottotakesecuritymuchmoreseriously.
It'sveryeasytocreatesecurityholesinwebapplicationsunlessyouhandlevalidationsboth
atclientsideandserverside.
Let'sdemonstrateit.OpenChromeconsole,whileloggedinwithusertenant2.
Copyandpastethisintoconsole:
Q.serviceCall({
service:'Administration/User/Update',
request:{
EntityId:2,
Entity:{
UserId:2,
TenantId:1
}
}
});
Nowrefreshtheusermanagementpage,you'llseethattenant2canseeadminusernow!
WecalledUserUpdateservicewithjavascript,andchangedtenant2userTenaNntIdto1
(PrimaryTenant).
Let'srevertitbacktoSecondTenant(2)first,thenwe'llfixthissecurityhole:
SecuringTenantSelectionAtServerSide
189

Q.serviceCall({
service:'Administration/User/Update',
request:{
EntityId:2,
Entity:{
UserId:2,
TenantId:2
}
}
});
Luckily,Serenityprovidesfieldlevelpermissions.EditUserRow.cstoletonlyuserswith
Administration:Tenantspermissiontoseeandedittenantinformation.
[LookupEditor(typeof(TenantRow))]
[ReadPermission(PermissionKeys.Tenants)]
publicInt32?TenantId
{
get{returnFields.TenantId[this];}
set{Fields.TenantId[this]=value;}
}
Nowonlyadmincanseeandupdatetenantfieldforusers.
Wedidn'thavetoalsosetModifyPermissionasifauserdoesn'thavetheread
permission,hedoesn'thavethewritepermissionbydefault.
Buildyourproject,thentrytypingthisintoconsoleagain:
Q.serviceCall({
service:'Administration/User/Update',
request:{
EntityId:2,
Entity:{
UserId:2,
TenantId:1
}
}
});
Youwillnowgetthiserror:
Tenantfieldisreadonly!
SecuringTenantSelectionAtServerSide
190

SecuringTenantSelectionAtServerSide
191

SettingTenantIdForNewUsers
WhileloggedinwithTenant2,trytocreateanewuser,User2.
Youwon'tgetanyerrorbutbysuprise,youwon'tseethenewlycreateduserinlist.What
happenedtoUser2?
AswesetdefaultvalueforTenantIdto1inmigrations,nowUser2has1asTenantIdandis
amemberofPrimaryTenant.
WehavetosetnewusersTenantIdtosamevaluewithloggedinuser.
ModifySetInternalFieldsmethodofUserRepositorylikebelow:
protectedoverridevoidSetInternalFields()
{
base.SetInternalFields();
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())
{
stringsalt=null;
Row.PasswordHash=GenerateHash(password,refsalt);
Row.PasswordSalt=salt;
}
}
Here,wesetTenantIdtothesamevaluewithcurrentuser,unlesshehastenant
administrationpermission.
NowtrytocreateanewuserUser2bandthistimeyou'llseehimonthelist.
SettingTenantIdForNewUsers
192

PreventingEditsToUsersFromOther
Tenants
Rememberthatusertenant2couldupdatehisTenantIdwithsomeservicecall,andwehad
tosecureitserverside.
Similartothis,evenifhecan'tseeusersfromothertenantsbydefault,hecanactually
retrieveandupdatethem.
Timetohackagain.
OpenChromeconsoleandtypethis:
newMultiTenancy.Administration.UserDialog().loadByIdAndOpenDialog(1)
What?Hecouldopenuserdialogforadminandupdateit!
MultiTenancy.Administration.UserDialogisthedialogclassthatisopenedwhenyouclicka
usernameinuseradministrationpage.
Wecreatedanewinstanceofit,andaskedtoloadauserentitybyitsID.Adminuserhasan
IDof1.
So,toloadtheentitywithID1,dialogcalledRetrieveserviceofUserRepository.
RememberthatwedidfilteringinListmethodofUserRepository,notRetrieve.So,service
hasnoidea,ifitshouldreturnthisrecordfromanothertenant,ornot.
It'stimetosecureretrieveserviceinUserRepository:
privateclassMyRetrieveHandler:RetrieveRequestHandler<MyRow>
{
protectedoverridevoidPrepareQuery(SqlQueryquery)
{
base.PrepareQuery(query);
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fld.TenantId==user.TenantId);
}
}
WedidsamechangesinMyListHandlerbefore.
PreventingEditsToUsersFromOtherTenants
193

IfyoutrysameJavascriptcodenow,you'llgetanerror:
Recordnotfound.Itmightbedeletedoryoudon'thaverequiredpermissions!
But,wecouldstillupdaterecordcalling Updateservicemanually.So,needtosecure
MySaveHandlertoo.
ChangeitsValidateRequestmethodlikethis:
protectedoverridevoidValidateRequest()
{
base.ValidateRequest();
if(IsUpdate)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(Old.TenantId!=user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
//...
Herewecheckifit'sanupdate,andifTenantIdofrecordbeingupdated(Old.TenantId)is
differentthancurrentlyloggeduser'sTenantId.Ifso,wecall
Authorization.ValidatePermissionmethodtoensurethatuserhastenantadministration
permission.Itwillraiseanerrorifnot.
Authorizationhasbeendeniedforthisrequest!
PreventingToDeleteUsersFromOther
Tenants
TherearedeleteandundeletehandlersinUserRepository,andtheysufferfromsimilar
securityholes.
Usingsimilarmethods,weneedtosecurethemtoo:
PreventingEditsToUsersFromOtherTenants
194

privateclassMyDeleteHandler:DeleteRequestHandler<MyRow>
{
protectedoverridevoidValidateRequest()
{
base.ValidateRequest();
varuser=(UserDefinition)Authorization.UserDefinition;
if(Row.TenantId!=user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
}
}
privateclassMyUndeleteHandler:UndeleteRequestHandler<MyRow>
{
protectedoverridevoidValidateRequest()
{
base.ValidateRequest();
varuser=(UserDefinition)Authorization.UserDefinition;
if(Row.TenantId!=user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
}
}
PreventingEditsToUsersFromOtherTenants
195

HidingtheTenantAdministration
Permission
Wenowhaveonelittleproblem.Usertenant2haspermissionAdministration:Securitysohe
canaccessuserandrolepermissiondialogs.Thus,hecangranthimself
Administration:TenantspermissionusingthepermissionUI.
SerenityscansyourassembliesforattributeslikeReadPermission,WritePermission,
PageAuthorize,ServiceAuthorizeetc.andliststhesepermissionsineditpermissionsdialog.
Weshouldfirstremoveitfromthispre-populatedlist.
Findmethod,ListPermissionKeysinUserPermissionRepository.cs:
HidingtheTenantAdministrationPermission
196

publicListResponse<string>ListPermissionKeys()
{
returnLocalCache.Get("Administration:PermissionKeys",TimeSpan.Zero,()=>
{
//...
result.Remove(Administration.PermissionKeys.Tenants);
result.Remove("*");
result.Remove("?");
//...
Now,thispermissionwon'tbelistedinEditUserPermissionsorEditRolePermissions
dialog.
But,still,hecangrantthispermissiontohimself,bysomelittlehackingthrough
UserPermissionRepository.UpdateorRolePermissionRepository.Updatemethods.
Weshouldaddsomecheckstopreventthis:
publicclassUserPermissionRepository
{
publicSaveResponseUpdate(IUnitOfWorkuow,
UserPermissionUpdateRequestrequest)
{
//...
varnewList=newDictionary<string,bool>(
StringComparer.OrdinalIgnoreCase);
foreach(varpinrequest.Permissions)
newList[p.PermissionKey]=p.Grant??false;
varallowedKeys=ListPermissionKeys()
.Entities.ToDictionary(x=>x);
if(newList.Keys.Any(x=>!allowedKeys.ContainsKey(x)))
thrownewAccessViolationException();
//...
HidingtheTenantAdministrationPermission
197

publicclassRolePermissionRepository
{
publicSaveResponseUpdate(IUnitOfWorkuow,
RolePermissionUpdateRequestrequest)
{
//...
varnewList=newHashSet<string>(
request.Permissions.ToList(),
StringComparer.OrdinalIgnoreCase);
varallowedKeys=newUserPermissionRepository()
.ListPermissionKeys()
.Entities.ToDictionary(x=>x);
if(newList.Any(x=>!allowedKeys.ContainsKey(x)))
thrownewAccessViolationException();
//...
Herewecheckifanyofthenewpermissionkeysthataretriedtobegranted,arenotlisted
inpermissiondialog.Ifso,thisisprobablyahackattempt.
Actuallythischeckshouldbethedefault,evenwithoutmulti-tenantsystems,butusually
wetrustadministrativeusers.Here,administratorswillbeonlymanagingtheirown
tenants,sowecertainlyneedthischeck.
HidingtheTenantAdministrationPermission
198

MakingRolesMulti-Tenant
Sofar,wehavemadeuserspageworkinmulti-tenantstyle.Seemslikewedidtoomany
changestomakeitwork.Butrememberthatwearetryingtoturnasystemthatisnot
designedtobemulti-tenantintosuchone.
Let'sapplysimilarprinciplestotheRolestable.
Again,auserinonetenantshouldn'tseeormodifyrolesinothertenantsandworkin
isolation.
WestartbyaddingTenantIdpropertytoRoleRow.cs:
namespaceMultiTenancy.Administration.Entities
{
//...
publicsealedclassRoleRow:Row,IIdRow,INameRow
{
[Insertable(false),Updatable(false)]
publicInt32?TenantId
{
get{returnFields.TenantId[this];}
set{Fields.TenantId[this]=value;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicInt32FieldTenantId;
//...
}
}
}
Thenwe'lldoseveralchangesinRoleRepository.cs:
privateclassMySaveHandler:SaveRequestHandler<MyRow>
{
protectedoverridevoidSetInternalFields()
{
base.SetInternalFields();
if(IsCreate)
Row.TenantId=((UserDefinition)Authorization.UserDefinition).TenantId;
}
MakingRolesMulti-Tenant
199

protectedoverridevoidValidateRequest()
{
base.ValidateRequest();
if(IsUpdate)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(Old.TenantId!=user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
}
}
}
privateclassMyDeleteHandler:DeleteRequestHandler<MyRow>
{
protectedoverridevoidValidateRequest()
{
base.ValidateRequest();
varuser=(UserDefinition)Authorization.UserDefinition;
if(Row.TenantId!=user.TenantId)
Authorization.ValidatePermission(PermissionKeys.Tenants);
}
}
privateclassMyRetrieveHandler:RetrieveRequestHandler<MyRow>
{
protectedoverridevoidPrepareQuery(SqlQueryquery)
{
base.PrepareQuery(query);
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fld.TenantId==user.TenantId);
}
}
privateclassMyListHandler:ListRequestHandler<MyRow>
{
protectedoverridevoidApplyFilters(SqlQueryquery)
{
base.ApplyFilters(query);
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fld.TenantId==user.TenantId);
}
}
MakingRolesMulti-Tenant
200

MakingRolesMulti-Tenant
201

UsingSerenityServiceBehaviors
Ifwantedtoextendthismulti-tenantsystemtoothertablesinNorthwind,wewouldrepeat
samestepswedidwithRoles.Thoughitdoesn'tlooksohard,it'stoomuchofmanualwork.
Serenityprovidesservicebehaviorsystem,whichallowsyoutointerceptCreate,Update,
Retrieve,List,Deletehandlersandaddcustomcodetothem.
Someoperationsinthesehandlers,likecapturelog,uniqueconstraintvalidationetc.are
alreadyimplementedasservicebehaviors.
Behaviorsmightbeactivatedforallrows,orbasedonsomerule,likehavingaspecific
attributeorinterface.Forexample,CaptureLogBehavioractivatesforrowswith[CaptureLog]
attribute.
We'llfirstdefineaninterfaceIMultiTenantRowthatwilltriggerournewbehavior.Placethis
classinfileIMultiTenantRow.cs,nexttoTenantRow.cs:
usingSerenity.Data;
namespaceMultiTenancy
{
publicinterfaceIMultiTenantRow
{
Int32FieldTenantIdField{get;}
}
}
ThanaddthisbehaviorinfileMultiTenantBehavior.csnexttoit:
usingMultiTenancy.Administration;
usingSerenity;
usingSerenity.Data;
usingSerenity.Services;
namespaceMultiTenancy
{
publicclassMultiTenantBehavior:IImplicitBehavior,
ISaveBehavior,IDeleteBehavior,
IListBehavior,IRetrieveBehavior
{
privateInt32FieldfldTenantId;
publicboolActivateFor(Rowrow)
{
varmt=rowasIMultiTenantRow;
UsingSerenityServiceBehaviors
202

if(mt==null)
returnfalse;
fldTenantId=mt.TenantIdField;
returntrue;
}
publicvoidOnPrepareQuery(IRetrieveRequestHandlerhandler,
SqlQueryquery)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fldTenantId==user.TenantId);
}
publicvoidOnPrepareQuery(IListRequestHandlerhandler,
SqlQueryquery)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(!Authorization.HasPermission(PermissionKeys.Tenants))
query.Where(fldTenantId==user.TenantId);
}
publicvoidOnSetInternalFields(ISaveRequestHandlerhandler)
{
if(handler.IsCreate)
fldTenantId[handler.Row]=
((UserDefinition)Authorization
.UserDefinition).TenantId;
}
publicvoidOnValidateRequest(ISaveRequestHandlerhandler)
{
if(handler.IsUpdate)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(fldTenantId[handler.Old]!=fldTenantId[handler.Row])
Authorization.ValidatePermission(PermissionKeys.Tenants);
}
}
publicvoidOnValidateRequest(IDeleteRequestHandlerhandler)
{
varuser=(UserDefinition)Authorization.UserDefinition;
if(fldTenantId[handler.Row]!=user.TenantId)
Authorization.ValidatePermission(
PermissionKeys.Tenants);
}
publicvoidOnAfterDelete(IDeleteRequestHandlerhandler){}
publicvoidOnAfterExecuteQuery(IRetrieveRequestHandlerhandler){}
publicvoidOnAfterExecuteQuery(IListRequestHandlerhandler){}
publicvoidOnAfterSave(ISaveRequestHandlerhandler){}
UsingSerenityServiceBehaviors
203

publicvoidOnApplyFilters(IListRequestHandlerhandler,SqlQueryquery){}
publicvoidOnAudit(IDeleteRequestHandlerhandler){}
publicvoidOnAudit(ISaveRequestHandlerhandler){}
publicvoidOnBeforeDelete(IDeleteRequestHandlerhandler){}
publicvoidOnBeforeExecuteQuery(IRetrieveRequestHandlerhandler){}
publicvoidOnBeforeExecuteQuery(IListRequestHandlerhandler){}
publicvoidOnBeforeSave(ISaveRequestHandlerhandler){}
publicvoidOnPrepareQuery(IDeleteRequestHandlerhandler,SqlQueryquery){}
publicvoidOnPrepareQuery(ISaveRequestHandlerhandler,SqlQueryquery){}
publicvoidOnReturn(IDeleteRequestHandlerhandler){}
publicvoidOnReturn(IRetrieveRequestHandlerhandler){}
publicvoidOnReturn(IListRequestHandlerhandler){}
publicvoidOnReturn(ISaveRequestHandlerhandler){}
publicvoidOnValidateRequest(IRetrieveRequestHandlerhandler){}
publicvoidOnValidateRequest(IListRequestHandlerhandler){}
}
}
BehaviorclasseswithIImplicitBehaviorinterfacedecideiftheyshouldbeactivatedfora
specificrowtype.
TheydothisbyimplementingActivateFormethod,whichiscalledbyrequesthandlers.
Inthismethod,wecheckifrowtypeimplementsIMultiTenantRowinterface.Ifnotitsimply
returnsfalse.
ThenwegetaprivatereferencetoTenantIdFieldtoreuseitlaterinothermethods.
ActivateForisonlycalledoncepereveryhandlertypeandrow.Ifthismethodreturnstrue,
behaviorinstanceiscachedaggresivelyforperformancereasons,andreusedforany
requestforthisrowandhandlertype.
Thus,everythingyouwriteinothermethodsmustbethread-safe,asoneinstanceisshared
byallrequests.
Abehavior,mightinterceptoneormoreofRetrieve,List,Save,Deletehandlers.Itdoesthis
byimplementingIRetrieveBehavior,IListBehavior,ISaveBehavior,orIDeleteBehavior
interfaces.
Here,weneedtointerceptalloftheseservicecalls,soweimplementallinterfaces.
Weonlyfillinmethodsweareinterestedin,andleaveothersempty.
Themethodsweimplementhere,correspondstomethodsweoverrideinRoleRepository.cs
inprevioussection.Thecodetheycontainisalmostsame,excepthereweneedtobemore
generic,asthisbehaviorwillworkforanyrowtypeimplementingIMultiTenantRow.
UsingSerenityServiceBehaviors
204

ReimplementingRoleRepositoryWithUsing
theBehavior
NowreverteverychangewemadeinRoleRepository.cs:
privateclassMySaveHandler:SaveRequestHandler<MyRow>{}
privateclassMyDeleteHandler:DeleteRequestHandler<MyRow>{}
privateclassMyRetrieveHandler:RetrieveRequestHandler<MyRow>{}
privateclassMyListHandler:ListRequestHandler<MyRow>{}
AndaddIMultiTenantRowinterfacetoRoleRow:
namespaceMultiTenancy.Administration.Entities
{
//...
publicsealedclassRoleRow:Row,IIdRow,INameRow,IMultiTenantRow
{
//...
publicInt32FieldTenantIdField
{
get{returnFields.TenantId;}
}
//...
}
}
Youshouldgetthesameresultwithmuchlesscode.Declarativeprogrammingisalmost
alwaysbetter.
UsingSerenityServiceBehaviors
205

ExtendingMulti-TenantBehaviorTo
Northwind
Asnowwehaveabehaviorhandlingrepositorydetails,wejustneedtoadd
IMultiTenantRowinterfacetorowsandaddTenantIdproperty.
StartwithSupplierRow.cs:
namespaceMultiTenancy.Northwind.Entities
{
//...
publicsealedclassSupplierRow:Row,
IIdRow,INameRow,IMultiTenantRow
{
//...
[Insertable(false),Updatable(false)]
publicInt32?TenantId
{
get{returnFields.TenantId[this];}
set{Fields.TenantId[this]=value;}
}
publicInt32FieldTenantIdField
{
get{returnFields.TenantId;}
}
//...
publicclassRowFields:RowFieldsBase
{
//...
publicreadonlyInt32FieldTenantId;
}
}
}
WhenyouthesechangesinSupplierRowandbuild,you'llseethattenant2can'tsee
suppliersfromothertenantsinsupplierspage.
NowrepeattheseforEmployeeRow,CategoryRow,CustomerRow,ShipperRow,OrderRow,
ProductRow,RegionRowandTerritoryRow.
ExtendingMulti-TenantBehaviorToNorthwind
206

ExtendingMulti-TenantBehaviorToNorthwind
207

HandlingLookupScripts
IfweopenSupplierspagenow,we'llseethattenant2canonlyseesuppliersthatbelongsto
itstenant.Butontoprightofthegrid,incountrydropdown,allcountriesarelisted:
Thisdataisfeedtoscriptsidethroughadynamicscript.Itdoesn'tloadthisdatawithList
serviceswehandledrecently.
ThelookupscriptthatproducesthisdropdownisdefinedinSupplierCountryLookup.cs:
HandlingLookupScripts
208

namespaceMultiTenancy.Northwind.Scripts
{
usingSerenity.ComponentModel;
usingSerenity.Data;
usingSerenity.Web;
[LookupScript("Northwind.SupplierCountry")]
publicclassSupplierCountryLookup:
RowLookupScript<Entities.SupplierRow>
{
publicSupplierCountryLookup()
{
IdField=TextField="Country";
}
protectedoverridevoidPrepareQuery(SqlQueryquery)
{
varfld=Entities.SupplierRow.Fields;
query.Distinct(true)
.Select(fld.Country)
.Where(
newCriteria(fld.Country)!=""&
newCriteria(fld.Country).IsNotNull());
}
protectedoverridevoidApplyOrder(SqlQueryquery)
{
}
}
}
Wecouldn'tuseasimple[LookupScript]attributeonarowclasshere,becausethereis
actuallynocountrytableinNorthwinddatabase.Wearecollectingcountrynamesfrom
existingrecordsinSuppliertableusingdistinct.
Weshouldfilteritsquerybycurrenttenant.
ButthislookupclassderivesfromRowLookupScriptbaseclass.Let'screateanewbase
class,toprepareforotherlookupscriptsthatwe'llhavetohandlelater.
HandlingLookupScripts
209

namespaceMultiTenancy.Northwind.Scripts
{
usingAdministration;
usingSerenity;
usingSerenity.Data;
usingSerenity.Web;
usingSystem;
publicclassMultiTenantRowLookupScript<TRow>:
RowLookupScript<TRow>
whereTRow:Row,IMultiTenantRow,new()
{
publicMultiTenantRowLookupScript()
{
Expiration=TimeSpan.FromDays(-1);
}
protectedoverridevoidPrepareQuery(SqlQueryquery)
{
base.PrepareQuery(query);
AddTenantFilter(query);
}
protectedvoidAddTenantFilter(SqlQueryquery)
{
varr=newTRow();
query.Where(r.TenantIdField==
((UserDefinition)Authorization.UserDefinition).TenantId);
}
publicoverridestringGetScript()
{
returnTwoLevelCache.GetLocalStoreOnly("MultiTenantLookup:"+
this.ScriptName+":"+
((UserDefinition)Authorization.UserDefinition).TenantId,
TimeSpan.FromHours(1),
newTRow().GetFields().GenerationKey,()=>
{
returnbase.GetScript();
});
}
}
}
Thiswillbeourbaseclassformulti-tenantlookupscripts.
Wefirstsetexpirationtoanegativetimespantodisablecaching.Whydowehavetodo
this?Becausedynamicscriptmanagercacheslookupscriptsbytheirkeys.Butwe'llhave
multipleversionsofalookupscriptbasedonTenantIdvalues.
HandlingLookupScripts
210

We'llturnoffcachingatdynamicscriptmanagerlevelandhandlecachingourselfin
GetScriptmethod.InGetScriptmethod,weareusingTwoLevelCache.GetLocalStoreOnlyto
callbasemethod,thatgeneratesourlookupscript,andcacheitsresultwithacachekey
includingTenantId.
SeerelevantsectionformoreinfoaboutTwoLevelCacheclass.
Byoverriding,PrepareQuerymethod,weareaddingafilterbycurrentTenantId,justlikewe
didinlistservicehandlers.
NowitstimetorewriteourSupplierCountryLookupusingthisnewbaseclass:
namespaceMultiTenancy.Northwind.Scripts
{
usingSerenity.ComponentModel;
usingSerenity.Data;
usingSerenity.Web;
[LookupScript("Northwind.SupplierCountry")]
publicclassSupplierCountryLookup:
MultiTenantRowLookupScript<Entities.SupplierRow>
{
publicSupplierCountryLookup()
{
IdField=TextField="Country";
}
protectedoverridevoidPrepareQuery(SqlQueryquery)
{
varfld=Entities.SupplierRow.Fields;
query.Distinct(true)
.Select(fld.Country)
.Where(
newCriteria(fld.Country)!=""&
newCriteria(fld.Country).IsNotNull());
AddTenantFilter(query);
}
protectedoverridevoidApplyOrder(SqlQueryquery)
{
}
}
}
WejustcalledAddTenantFiltermethodmanually,becauseweweren'tcallingbase
PrepareQuerymethodhere(soitwon'tbecalledbybaseclass).
PleasefirstdeleteNorthwind.DynamicScripts.csfile,ifyouhaveit.
HandlingLookupScripts
211

ThereareseveralmoresimilarlookupscriptsinCustomerCountryLookup,
CustomerCityLookup,
OrderShipCityLookup,OrderShipCountryLookup.I'lldosimilarchangesinthem.Change
baseclasstoMultiTenantRowLookupScriptandcallAddTenantFilterinPrepareQuery
method.
LookupScriptDeclarationsOnRows
Wenowhaveonemoreproblemtosolve.IfyouopenOrderspage,you'llseethatShipVia
andEmployeefilterdropdownsstilllistsrecordsfromothertenants.Itisbecausewedefined
theirlookupscriptsbya[LookupScript]attributeontheirrows.
Bydefault,LookupScriptgeneratesalookupinstancebasedon RowLookupScript<>type.
Weneedtochangeitto MultiTenantRowLookupScript<>forthesemulti-tenantrows.
Let'sfixemployeelookupfirst.Replace[LookupScript]attributelikebelowinEmployeeRow.
[LookupScript("Northwind.Employee",
LookupType=typeof(MultiTenantRowLookupScript<>))]
publicsealedclassEmployeeRow:Row,IIdRow,
INameRow,IMultiTenantRow
{
//...
NotethatthisrequiresSerenity2.9.22+
Dosimilar(addLookupType)forShipper,Product,Supplier,Category,RegionandTerritory
rows.
NowNorthwindsupportsmulti-tenancy.
Theremightbesomeglitchesimissed,reportinSerenityGithubrepositoryifany.
HandlingLookupScripts
212

MeetingManagement(InProgress...)
Inthistutorialwearegoingtodevelopameetingmanagementsystemthatwillhelpuskeep
atrackofcorporatemeetings.
We'llfirstplanameeting,withitslocation,time,agendaandattendees,thensendan
invitationtothoseattendeeswithane-mail.
Applicationwillalsoletusstoredecisionstakeninthemeeting,andwillinformattendees
withameetingreporte-mailcontainingthesedecisions.
Codeforthistutorialwillbeavailableat:
https://github.com/volkanceylan/MeetingManagement
CreatingProject
StartbycreatinganewprojectusingSerenetemplate,andnameitMeetingManagement.
RemovingNorthwind
RemoveNorthwindusingthehow-toguide.
MeetingManagement
213

CreatingLookupTables
Let'sstartbycreatinglookuptableswe'llneed.
Hereisalistofthesetables:
MeetingTypes(BoardMeeting,WeeklyAnalytics,SCRUMMeeting,AnnualMeeting,so
on...)
Locations(wheremeetingwillbeheld,roomnumbers,addressetc.)
AgendaTypes(whatsubject(s)anagendaisabout,mightbemultiple)
Units(whichunitisorganizingthemeeting)
Contacts(peoplewhichwouldattendmeetings,reporters,managersetc.)
We'llusedatabaseschemametfortables.
Createanewmigrationunder,Modules/Common/Migrations/DefaultDBwithname
DefaultDB_20160709_232400_MeetingLookups:
CreatingLookupTables
214

usingFluentMigrator;
namespaceMeetingManagement.Migrations.DefaultDB
{
[Migration(20160709232400)]
publicclassDefaultDB_20160709_232400_MeetingLookups
:AutoReversingMigration
{
publicoverridevoidUp()
{
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();
}
}
}
GeneratingCodeforLookupTables
OurmodulenamewillbeMeetings.Weshouldusenon-pluralentityidentifiersforgenerated
code:
CreatingLookupTables
215

AgendaTypes=>AgendaType
Contacts=>Contact
Locations=>Location
MeetingTypes=>MeetingType
Units=>Unit
Generatecodeforthese5tablesusingtheentityidentifiersgivenabove:
Generatedinterfaceforthesetablesisfineenough.Justneedtodoafewcosmetictouches.
CreatingLookupTables
216

MovingNavigationLinkstoNavigationItems.cs
OpenAgendaTypePage.cs,ContactPage.cs,LocationPage.cs,MeetingTypePage.csand
UnitPage.csfilesandmovenavigationlinksattopofthemtoNavigationItems.cs:
usingSerenity.Navigation;
usingAdministration=MeetingManagement.Administration.Pages;
usingMeeting=MeetingManagement.Meeting.Pages;
[assembly:NavigationLink(1000,"Dashboard",
url:"~/",permission:"",icon:"icon-speedometer")]
[assembly:NavigationMenu(2000,"Meeting")]
[assembly:NavigationLink(2500,"Meeting/AgendaTypes",
typeof(Meeting.AgendaTypeController))]
[assembly:NavigationLink(2600,"Meeting/Contacts",
typeof(Meeting.ContactController))]
[assembly:NavigationLink(2700,"Meeting/Locations",
typeof(Meeting.LocationController))]
[assembly:NavigationLink(2800,"Meeting/MeetingTypes",
typeof(Meeting.MeetingTypeController))]
[assembly:NavigationLink(2900,"Meeting/Units",
typeof(Meeting.UnitController))]
SettingDisplayNameandInstanceNameAttributesof
LookupTables
OpenAgendaTypeRow.cs,ContactRow.cs,LocationRow.cs,MeetingTypeRow.csand
UnitRow.csfilesandchangeDisplayNameandInstanceNameattributeslikebelow:
AgendaTypeRow=>"AgendaTypes","AgendaType"
CreatingLookupTables
217

ContactRow=>"Contacts","Contact"
LocationRow=>"Locations","Location"
MeetingTypeRow=>"MeetingTypes","MeetingType"
UnitRow=>"Units","Unit"
[ConnectionKey("Default"),TwoLevelCached,
DisplayName("AgendaTypes"),InstanceName("AgendaType")]
[ReadPermission("Meeting")]
[ModifyPermission("Meeting")]
publicsealedclassAgendaTypeRow:Row,IIdRow,INameRow
{
CreatingLookupTables
218

HowToGuides
HowToGuides
219

HowTo:RemoveNorthwind&Other
SamplesFromSerene
AfteryoutakeNorthwindasasample,anddevelopyourownproject,youwouldwantto
removeNorthwindmodule,andothersampleartifactsfromyourproject.
Hereishowtogetridofthem.
WeassumeyoursolutionnameisMyProject,soyouhaveMyProject.Webprojectinyour
solution.
PerformstepsbelowinVisualStudio:
RemovingProjectFolders
RemoveMyProject.Web/Modules/AdminLTEfolder.Thiswillremoveallserverside
coderelatedtothemesamples.
RemoveMyProject.Web/Modules/BasicSamplesfolder.Thiswillremoveallserverside
coderelatedtobasicsamples.
RemoveMyProject.Web/Modules/Northwindfolder.Thiswillremoveallserversidecode
relatedtoNorthwind.
RemovingNavigationItems
Navigationitemsforthesemodulesaremovedunderrelevantmodulesfolderinv2.5.3.If
youareusinganolderversion,
OpenMyProject.Web/Modules/Common/Navigation/NavigationItems.cs,removeall
lineswithNorthwind,BasicSamplesandThemeSamplesandremovethesetwolines:
usingNorthwind=MovieTutorial.Northwind.Pages;
usingBasic=MovieTutorial.BasicSamples.Pages;
RemovingMigrationScripts
RemovefolderMyProject.Web/Modules/Common/Migrations/NorthwindDB/withallfiles
underit.
HowTo:RemoveNorthwind&OtherSamplesFromSerene
220

Remove"Northwind"fromfollowinglineinMyProject.Web/App_Start/
SiteInitialization.Migrations.cs:
privatestaticstring[]databaseKeys=new[]{"Default","Northwind"};
AlsoremoveNorthwindconnectionstringfromweb.config.
<addname="Northwind"connectionString="DataSource=(LocalDb)\v11.0;
InitialCatalog=MovieTutorial_Northwind_v1;
IntegratedSecurity=True"
providerName="System.Data.SqlClient"/>
RemovingLESSEntries
OpenMyProject.Web/Content/site/site.lessfile,removefollowinglines:
@import"site.basicsamples.less";
@import"site.northwind.less";
RemoveMyProject.Web/Content/site/site.basicsamples.lessfile.
RemoveMyProject.Web/Content/site/site.northwind.lessfile.
OpenMyProject.Web/Content/site/rtl.cssfile,removesectionswithNorthwind.
RemovingLocalizationTexts
OpenMyProject.Web/Modules/Texts.csandremovefollowinglines:
publicstaticLocalTextNorthwindPhone="...";
publicstaticLocalTextNorthwindPhoneMultiple="...";
RemovefolderMyProject.Web/Scripts/site/texts/northwind
RemovefolderMyProject.Web/Scripts/site/texts/samples
RemovingNorthwind/SamplesGeneratedCode
ExpandMyProject.Web/Modules/Common/Imports/ServerTypings/ServerTypings.tt.
SelectfilesstartingwithNorthwind.,BasicSamples.anddeletethem.
RemovingNorthwindNumbersFromDashboard
HowTo:RemoveNorthwind&OtherSamplesFromSerene
221

OpenDashboardPage.cs,removetheseusinglines:
usingNorthwind;
usingNorthwind.Entities;
AsDashboardgetsnumbersfromNorthwindtables,youshouldmodifyIndex()actionlike
this:
[Authorize,HttpGet,Route("~/")]
publicActionResultIndex()
{
varcachedModel=newDashboardPageModel()
{
};
returnView(MVC.Views.Common.Dashboard.DashboardIndex,cachedModel);
}
Youshouldreplacethismodelwithsomethingspecifictoyoursite,andmodify
DashboardIndexaccordingly.
OpenDashboardIndex.cshtml,clearhrefattributescontaining"Northwind"like:
<ahref="~/Northwind/Order?shippingState=1"></a>
<ahref=""></a>
BuildingProjectandRunningT4(.tt)Templates
Nowrebuildyoursolution.
Makesureitisbuiltsuccessfullybeforeexecutingnextstep.
ClickBuildmenuandclickTransformAllTemplates.
Rebuildyoursolutionagain.
SearchforNorthwind,BasicSamplesandThemeSamplesinallsolutionitems.It
shouldfindnoresults.
Runyourproject,nowNorthwindandSamplemenusaregone.
RemovingNorthwindTables
Northwindtablesareinaseparatedatabase,soyoucanjustdropit.
HowTo:RemoveNorthwind&OtherSamplesFromSerene
222

HowTo:RemoveNorthwind&OtherSamplesFromSerene
223

HowTo:UpdateSerenityNuGetPackages
SerenetemplatecontainsreferencestofollowingSerenityNuGetpackages:
Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
Serenity.Web
Serenity.CodeGenerator
ToupdateSerenitypackagestolatestversion,openpackagemanagerconsole(clickView-
>OtherWindows->PackageManagerConsole).
Andtypefollowing:
Update-PackageSerenity.Web
Update-PackageSerenity.CodeGenerator
Updatingthesetwopackageswillalsoupdateothers(becauseofdependencies).
HowTo:UpdateSerenityNuGetPackages
224

HowTo:UpgradetoSerenity2.0and
EnableTypeScript
SerenityhasTypeScriptsupportstartingwithversion2.0.
ThisisamigrationguideforusersthatstartedwithanolderSerenetemplate,andwantsto
useTypeScriptfeatures.
Ifyoudon'tneedTypeScript,justupdateyourSerenitypackagesanditshouldworkas
normal.
Evenifyouwon'tneedTypeScript,it'srecommendedtoperformstepslistedhereto
keepyourprojectuptodate.Thismightalsohelpyouavoidfutureproblemsasthere
hasbeenmanychangesinSereneforTypeScriptsupport.
ShouldISwitchToTypeScript?
TypeScriptsupportinSerenityisstableasofwritingandisstronglyrecommended.
TypeScriptisthefutureforSerenityapplications,asithasastrongerbackingatthemoment
(Microsoftandaveragenumberofusers).
AlsoTypeScriptfeelslikenativeJavascriptwithproperintellisense,refactoringandcompile
timetypechecking.
We'vebeenusingSaltarallewithSerenitysincestartbutitsfutureisabitblurry.Itdidn'tget
anyupdatessinceitisacquiredbyBridge.NET,lastJune(2015).
YouroldcodewritteninSaltarallewillcontinuetowork.Itwillbesupportedaslongas
possiblewithSerenityforbackwardcompability.
IfBridge.NETv2.0(nextSaltaralle)comesout,wemayalsotrytoswitch,unlessitinvolves
toomanychangestohandle.
MigratingYourSereneApplicationtov2.0
Checkthatyoursolutionisbuildingproperly
Firstmakesureyoursolutionisproperlybuilding.
HowTo:UpgradetoSerenity2.0andEnableTypeScript
225

Ifpossible,takeaZIPbackupofsolution,assomestepswe'llperformmightbehardto
revert.
InstallTypeScript
InstallTypeScript1.8+from
https://www.typescriptlang.org/#download-links
foryourVisualStudioversion.
UpdateNuGetPackages
Updateto2.0packagesasyou'dnormallydo:
Update-PackageSerenity.Web
Update-PackageSerenity.CodeGenerator
Update-PackageSerenity.Script
WhileupdatingSerenity.Web,VSmightshowadialogwithtext"YourProjecthasbeen
configuredtosupportTypeScript".ClickYES.
EnsuringPackageUpdatesCausedNoProblems
Rebuildyoursolutionagainandrunit.Opensomepages,dialogsetc.andmakesurethatit
isworkingproperlywith2.0packages.
ConfiguringWebProjectforTypeScript
UnloadMyProject.Webandeditit.
AddlinesbelowafterTypeScriptToolsVersionline:
//...
<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>
HowTo:UpgradetoSerenity2.0andEnableTypeScript
226

ReplaceSerene.Web.jswithyourprojectname.
Intheendofsamefile,you'llseelineslikebelow:
<ImportProject="...Microsoft.CSharp.targets"/>
<ImportProject="...Microsoft.WebApplication.targets"/>
<ImportProject="...Microsoft.TypeScript.targets"/>
MakesurethelinewithTypeScript.targetswithisunderallothertargets.Moveitunder
WebAplpication.targetsifnot.VSputsthembeforeMicrosoft.WebApplication.targetsand
somehowitdoesn'tworkthatway.
Also,atthebottomoffile,you'llfindCompileSiteLessstep,addTSCtoendofit:
<TargetName="CompileSiteLess"AfterTargets="AfterBuild">
<ExecCommand=""$(ProjectDir)tools\node\lessc.cmd"
"$(ProjectDir)Content\site\site.less">
"$(ProjectDir)Content\site\site.css"">
</Exec>
<ExecCommand=""$(TscToolPath)\$(TypeScriptToolsVersion)\
$(TscToolExe)"-project"
$(ProjectDir)tsconfig.json""ContinueOnError="true"/>
</Target>
Savechanges,reloadtheprojectandfollowtonextstep.
Addingtsconfig.jsonFile
Addatsconfig.jsonfiletotherootofyourWebproject(whereweb.configandGlobal.asax
filesare)withcontentlikebelow:
{
"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"
]
}
HowTo:UpgradetoSerenity2.0andEnableTypeScript
227

ReplaceSerene.Webwithyourprojectname.
AddaTestTypeScriptFile
Addadummy.tsfileunderYourProject.Web/scripts/site/dummy.ts.Openitandtype
somethinglikebelow:
namespaceMyProject{
exportclassDummy{
}
}
Whenyousaveit,thereshouldbeaMyProject.Web.jsfiletherewithcontentbelow.Ifyou
can'tseeit,clickShowAllFilesandrefreshfolder.
varMyProject;
(function(MyProject){
varDummy=(function(){
functionDummy(){
}
returnDummy;
}());
MyProject.Dummy=Dummy;
})(MyProject||(MyProject={}));
//#sourceMappingURL=SereneUpgrading.Web.js.map
Rightclickandincludethatfiletoyourproject.Nowyoucandeletedummy.ts.
IfyouareusingaversionbeforeVS2015andcompileonsaveisnotworking,yourTS
fileswillbecompiledatprojectbuild.
IncludingMyProject.Web.jsfilein_LayoutHead.cshtml
EditMyProject.Web/Views/Shared/_LayoutHead.cshtmlandincludeMyProject.Web.jsright
afterMyProject.Script.jsfile:
//...
@Html.Script("~/Scripts/Site/MyProject.Script.js")
@Html.Script("~/Scripts/Site/MyProject.Web.js")
//...
YourprojectisconfiguredforTypeScript.
ChangingLocationforT4Templates
HowTo:UpgradetoSerenity2.0andEnableTypeScript
228

Serenev2.0hasmergedsome.TTtemplatesandcreatednewoneforTypeScriptcode
generation.
PleasemakesureyourprojectisbuildingsuccessfullyandDON'TCLEANitwhile
performingthesesteps,otherwiseyoumayendupwithabrokenproject.
LocatefileYourProject.Web\Modules\Common\Imports\MultipleOutputHelper.ttinclude
MakeacopyofitinsamefolderwithnameCodeGenerationHelpers.ttinclude
GetlatestsourceofCodeGenerationHelpers.ttincludefromaddressbelowandcopypasteit
toCodeGenerationHelpers.ttincludefileyoujustcreated:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/CodeGenerationHelpers.ttinclude
SearchandReplaceSerenewithYourProjectNameinthisfileifany.Thereshouldn'tbe
anySerenewordinthisfileasofwriting.
YoumayalsocreateanewSereneprojectwithlatestversionoftemplatetogetthese
files.
ClientTypes.tt
CreatefolderYourProject.Web\Modules\Common\Imports\ClientTypesandmove
ScriptEditorTypes.tttothere,thenrenameScriptEditorTypes.tttoClientTypes.tt.
GrablatestsourceofClientTypes.ttfilefromaddressbelowandcopypasteitto
ClientTypes.ttfileyoujustmoved:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/ClientTypes/ClientTypes.tt
SearchandReplaceSerenewithYourProjectNameinthisfileifany.
ServerTypings.tt
CreatefolderYourProject.Web\Modules\Common\Imports\ServerTypingsandmove
ScriptFormatterTypes.tttothere,thenrenameScriptFormatterTypes.tttoServerTypings.tt.
GrablatestsourceofServerTypings.ttfilefromaddressbelowandcopypasteitto
ServerTypings.ttfileyoujustmoved:
https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module
s/Common/Imports/ServerTypings/ServerTypings.tt
SearchandReplaceSerenewithYourProjectNameinthisfileifany.
HowTo:UpgradetoSerenity2.0andEnableTypeScript
229

GenerateCode
WhiletheyareopensaveClientTypes.ttandServerTypings.ttfiles,andwaitforthemto
generatecodes.
Saveprojectandrebuild.
ChangingLocationforFormContextsandServiceContracts
T4Templates
Thesetwotemplatesaremergedintoone.
We'llrepeatsimilarstepslikeinWebproject.
LocatefileYourProject.Script\Imports\MultipleOutputHelper.ttinclude
MakeacopyofitinsamefolderwithnameCodeGenerationHelpers.ttinclude
GetlatestsourceofCodeGenerationHelpers.ttincludefromaddressbelow(it'sdifferent!)and
copypasteittoCodeGenerationHelpers.ttincludefileyoujustcreated:
https://raw.githubusercontent.com/volkanceylan/Serene/
b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/
Imports/CodeGenerationHelpers.ttinclude
SearchandReplaceSerenewithYourProjectNameinthisfile.Theremustbeseveral.
ServiceContracts.tt
RenamefolderYourProject.Script\Imports\ServiceContractstoServerImports.Rename
ServiceContracts.tttoServerImports.tt.
GrablatestsourceofServerImports.ttfilefromaddressbelowandcopypasteitto
ServerImports.ttfileyoujustrenamed:
https://raw.githubusercontent.com/volkanceylan/Serene/
b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/
Imports/ServerImports/ServerImports.tt
SearchandReplaceSerenewithYourProjectNameinthisfile.
DeletefolderFormContextswithfileFormContext.ttinit.
SaveServerImports.ttandwaitforittogeneratecode.Itmighttakesometimebecauseof
someslowdownduetoSaltaralle.
Rebuildsolutionandmakesureitbuildsproperlywithoutanyerror.
HowTo:UpgradetoSerenity2.0andEnableTypeScript
230

Congratulations!YourprojectisreadyforTypeScriptandotherfeatures.
WhatareTheseNew.ttFiles
ServerTypings.tt:generatedcodeforTypeScript,containingRow,Form,Column,
ServicedeclarationsimportedfromServer(Web)code.Alsocontainsimportclasses
fromYourProject.Scriptfileifany.
ServerTypes.tt:generatedcodeforSaltaralle,containingRow,Form,Column,Service
declarationsimportedfromServer(Web)code.Thereisnoimportclassesfrom
TypeScriptyet.SoifyouwanttousesomeTypeScriptclassinyourSaltarallecode,you
needtowriteimportclassesmanually.
ClientTypes.tt:generatedcodeforWebproject,containingEditorandFormatter
importsfrombothTypeScriptandSaltaralle.
HowCanIGenerateTypeScriptGrid/DialogCode
ThereissuchanoptioninSerenityCodeGenerator(Sergen)now.Justcheck*Generate
Grid/DialogCodeinTypeScript(insteadofSaltaralle)anditwillgenerateYourDialog.tsand
YourGrid.tsfilesunderYourProject.Web/Modules/YourEntitydirectory,insteadofYourGrid.cs
andYourDialog.csinYourProject.Scriptproject.
Pleasedon'tgeneratecodeforexistingSaltaralledialogsorgridsusingSergen.
Otherwiseyou'llhavedoubleYourGridandYourDialogclassesanditmayleadto
unexpectederrors.
HowTo:UpgradetoSerenity2.0andEnableTypeScript
231

HowTo:AuthenticateWithActive
DirectoryorLDAP
Serene1.8.12+hassomebasicActiveDirectory/LDAPintegrationsamples.
Toenablethem,youhavetofilloneofweb.configsettings.
ForActiveDirectoryaddaappSettingkey ActiveDirectorywithcontentslikebelow:
<addkey="ActiveDirectory"
value="{Domain:'youractivedirectorydomain'}"/>
Ifthisdoesn'tworkforyourActiveDirectoryserveroutofthebox,youmighthaveto
modifyActiveDirectoryServiceclass.
WhenaADusertriestologinfirsttime,Sereneauthenticatesuserwiththisdomain,
retrievesuserdetailsandinsertsauserwithtype directoryintouserstable.
ADpasswordhashanduserinformationiscachedforonehour,soforonehourusercan
loginwithcachedcredentials,withoutevenhittingAD.
Afterthat,userinformationistriedtobeupdatedfromAD.Ifanerroroccurs,userwillbe
allowedtologinwithcachedcredentials.
ThesedetailscanbeseenandmodifiedinAuthenticationServiceclass.
ToenableLDAPauthentication(testedwithOpenLDAP)youneedtoaddaappSettingkey
LDAPtoweb.config:
<addkey="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,therearemanydifferentconfigurationsofLDAPserversoutthere,soifthis
doesn'tworkforyou,youmighthavetomodifyLdapDirectoryServiceclass.
HowTo:AuthenticateWithActiveDirectoryorLDAP
232

HowTo:AuthenticateWithActiveDirectoryorLDAP
233

HowTo:UseaSlickGridFormatter
ThissectionispendingupdateforTypeScript
TouseaSlickGridformatterfunction,likepercentcompletebarformatterat%Complete
columnofSlickGridexample:
http://mleibman.github.io/SlickGrid/examples/example2-formatters.html
IncludingRequiredResources
Firstincludejavascriptfilecontainingtheseformattersinyour_LayoutHead.cshtmlfile
(MyProject.Web/Views/Shared/_LayoutHead.cshtml):
//...
@Html.Script("~/Scripts/jquery.slimscroll.js")
@Html.Script("~/Scripts/SlickGrid/slick.formatters.js")
@Html.Script("~/Scripts/Site/MovieTutorial.Script.js")
//...
YoualsoneedtoincludefollowingCSSfromexample.css(canbeinsertedinsite.less):
.percent-complete-bar{
display:inline-block;
height:6px;
-moz-border-radius:3px;
-webkit-border-radius:3px;
}
DeclaringaSerenityDataGridFormatter
Let'ssaywehaveStudentCourseGridwithaCourseCompletioncolumnthatwewan'ttouse
Slick.Formatters.PercentCompleteBarformatterwith.
publicclassStudentCourseColumns
{
//...
[Width(200)]
publicDecimalCourseCompletion{get;set;}
}
HowTo:UseaSlickGridFormatter
234

ToreferenceaSlickGridformatteratserverside,youneedtodeclareaformattertypefor
Serenitygrids.
InMyApplication.Scriptproject,nexttoStudentCourseGrid.csforexample,defineafile
(PercentCompleteBarFormatter.cs)withcontents:
usingSerenity;
usingSystem;
namespaceMyApplication
{
publicclassPercentCompleteBarFormatter:ISlickFormatter
{
privateSlickColumnFormatterformatter=
Type.GetType("Slick.Formatters.PercentCompleteBar").As<SlickColumnFormatte
r>();
publicstringFormat(SlickFormatterContextctx)
{
returnformatter(ctx.Row,ctx.Cell,ctx.Value,ctx.Column,ctx.Item);
}
}
}
ReplaceMyApplicationwithyourrootnamespace(solutionname).
Nowyoucanreferenceitatserverside:
publicclassStudentCourseColumns
{
//...
[FormatterType("PercentCompleteBar"),Width(200)]
publicDecimalCourseCompletion{get;set;}
}
RebuildyourprojectandyouwillseethatCourseCompletioncolumnhasapercentagebar
justlikeinSlickGridexample.
GettingIntellisenseandCompileTimeCheckingToWork
TogetintellisenseforPercentCompleteBarFormatterserverside(sotoavoidusingmagic
strings),youshouldtransformT4templates(makesuresolutionbuildssuccessfullybefore
transforming).
Afterthisyoucanreferenceitlikethisserverside:
HowTo:UseaSlickGridFormatter
235

publicclassStudentCourseColumns
{
//...
[PercentCompleteBarFormatter,Width(200)]
publicDecimalCourseCompletion{get;set;}
}
AlternateOption(NotRecommended)
ItisalsopossibletosetSlickGridcolumnformatterfunctiondirectlyinscriptsidecode
withoutdefiningaSerenityformatterclass,e.g.inStudentCourseGrid.csbyoverridingits
GetColumnsmethod:
protectedoverrideList<SlickColumn>GetColumns()
{
varcolumns=base.GetColumns();
columns.Single(x=>x.Field=="CourseCompletion").Formatter=
Type.GetType("Slick.Formatters.PercentCompleteBar").As<SlickColumnFormatte
r>();
returncolumns;
}
Thisisnotreusablebutsavesyoufromdefiningaformatterclass.
HowTo:UseaSlickGridFormatter
236

HowTo:AddaRowSelectionColumn
ThissectionispendingupdateforTypeScript
Toaddacolumntoselectindividualrowsorallrows,GridRowSelectionMixincanbeused.
GridRowSelectionMixinisavailableinSerenity1.6.8+
Samplecode:
HowTo:AddaRowSelectionColumn
237

publicclassMyGrid:EntityGrid<MyRow>
{
privateGridRowSelectionMixinrowSelection;
publicMyGrid(jQueryObjectcontainer)
:base(container)
{
rowSelection=newGridRowSelectionMixin(this);
}
protectedoverrideList<SlickColumn>GetColumns()
{
varcolumns=base.GetColumns();
columns.Insert(0,GridRowSelectionMixin.CreateSelectColumn(()=>rowSelection)
);
returncolumns;
}
protectedoverrideList<ToolButton>GetButtons()
{
varbuttons=base.GetButtons();
buttons.Add(newToolButton
{
CssClass="tag-button",
Title="DoSomethingWithSelectedRows",
OnClick=delegate
{
varselectedIDs=rowSelection.GetSelectedKeys();
if(selectedIDs.Count==0)
Q.NotifyWarning("Pleaseselectsomerows");
else
Q.NotifySuccess("Youhaveselected"+selectedIDs.Count+
"row(s)withID(s):"+string.Join(",",selectedIDs));
}
});
returnbuttons;
}
}
HowTo:AddaRowSelectionColumn
238

HowTo:SetupCascadedEditors
Youmightneedmulti-levelcascadededitorslikeCountry=>City,Course=>ClassName=>
Subject.
StartingwithSerenity1.8.2,it'srathersimple.Lookupeditorshavethisintegrated
functionality.
Forversionsbefore1.8.2,itwasalsopossible,andtherewassomesamplesinSerene,
butyouhadtodefinesomeeditorclassestomakeitwork.
Let'ssaywehaveadatabasewiththreetables,Country,City,District:
CountryTable:CountryId,CountryName
CityTable:CityId,CityName,CountryId
DistrictTable:DistrictId,DistrictName,CityId
FirstmakesureyougeneratecodeforallthreetablesusingSergen,andyouhavea
[LookupScript]attributeonallofthem:
[LookupScript("MyModule.Country")]
publicsealedclassCountryRow:Row...
{
[DisplayName("CountryId"),Identity]
publicInt32?CountryId
{
get{returnFields.CountryId[this];}
set{Fields.CountryId[this]=value;}
}
[DisplayName("CountryName"),Size(50),NotNull,QuickSearch]
publicStringCountryName
{
get{returnFields.CountryName[this];}
set{Fields.CountryName[this]=value;}
}
}
HowTo:SetupCascadedEditors
239

[LookupScript("MyModule.City")]
publicsealedclassCityRow:Row...
{
[DisplayName("CityId"),Identity]
publicInt32?CityId
{
get{returnFields.CityId[this];}
set{Fields.CityId[this]=value;}
}
[DisplayName("CityName"),Size(50),NotNull,QuickSearch]
publicStringCityName
{
get{returnFields.CityName[this];}
set{Fields.CityName[this]=value;}
}
[DisplayName("Country"),ForeignKey("Country","CountryId"),LookupInclude]
publicInt32?CountryId
{
get{returnFields.CountryId[this];}
set{Fields.CountryId[this]=value;}
}
}
HowTo:SetupCascadedEditors
240

[LookupScript("MyModule.District")]
publicsealedclassDistrictRow:Row...
{
[DisplayName("DistrictId"),Identity]
publicInt32?DistrictId
{
get{returnFields.DistrictId[this];}
set{Fields.DistrictId[this]=value;}
}
[DisplayName("DistrictName"),Size(50),NotNull,QuickSearch]
publicStringDistrictName
{
get{returnFields.DistrictName[this];}
set{Fields.DistrictName[this]=value;}
}
[DisplayName("City"),ForeignKey("City","CityId"),LookupInclude]
publicInt32?CityId
{
get{returnFields.CityId[this];}
set{Fields.CityId[this]=value;}
}
}
Makesureyouadd LookupIncludeattributetoCityIdfieldofDistrictRow,andCountryIdfield
ofCityRow.Weneedthemtobeavailableatclientside,otherwisetheyarenotincludedby
defaultinlookupscripts.
Ifyouwantedtoeditthesefieldsascascadedlookupeditorsinaform,e.g.CustomerForm,
youwouldsetthemuplikethis:
HowTo:SetupCascadedEditors
241

[FormScript("MyModule.Customer")]
[BasedOnRow(typeof(Entities.CustomerRow))]
publicclassCustomerForm
{
publicStringCustomerID{get;set;}
publicStringCustomeraName{get;set;}
[LookupEditor(typeof(Entities.CountryRow))]
publicInt32?CountryId{get;set;}
[LookupEditor(typeof(Entities.CityRow),
CascadeFrom="CountryId",CascadeField="CountryId")]
publicInt32?CityId{get;set;}
[LookupEditor(typeof(Entities.DistrictRow),
CascadeFrom="CityId",CascadeField="CityId")]
publicInt32?DistrictId{get;set;}
}
YoucouldalsosettheseattributesinCustomerRow
Here,CascadeFromattributetellscityeditor,IDoftheparenteditorthatitwillbindto
(cascade).
Whenthisformisgenerated,CountryIdfieldwillbehandledwithaneditorwithIDCountryId,
sowesetCascadeFromattributeofCityIdlookupeditortothatID.
CascadeFielddeterminesthefieldtofiltercitieson.Thus,whencountryeditorvalue
changes,cityeditoritemswillbefilteredontheirCountryIdpropertieslikethis:
this.Items.Where(x=>x.CountryId==CountryEditorValue)
IfCascadeFromandCascadeFieldattributesaresame,youonlyneedtospecify
CascadeFrom,butiwantedtobeexplicithere.
Ifyouwantedtoaddthesecascadededitorstofilterbarofcustomergrid,in
CreateToolbarExtensionsmethodofCustomerGrid.cs,dothis:
HowTo:SetupCascadedEditors
242

AddEqualityFilter<LookupEditor>("CountryId",
options:newLookupEditorOptions
{
LookupKey="MyModule.Country"
});
AddEqualityFilter<LookupEditor>("CityId",
options:newLookupEditorOptions
{
LookupKey="MyModule.City",
CascadeFrom="CountryId",
CascadeField="CountryId"
});
AddEqualityFilter<LookupEditor>("DistrictId",
options:newLookupEditorOptions
{
LookupKey="MyModule.District",
CascadeFrom="CityId",
CascadeField="CityId"
});
HereisupposeyouhaveCountryId,CityIdandDistrictIdfieldsinCustomerRow.
Nowyouhaveusefulcascadededitorsforbotheditingandfiltering.
HowTo:SetupCascadedEditors
243

HowTo:UseRecaptcha
TouseRecaptchainloginform,followthesesteps:
RequiresSerenity1.8.5+
Youmightalsouseitforanotherform,butthisisjustasampleforlogin.
First,youneedtoregisteranewsiteforRecaptchaat:
https://www.google.com/recaptcha/admin
Onceyouhaveyoursitekey,andsecretkey,entertheminweb.config/appSettingssection:
<addkey="Recaptcha"value="{
SiteKey:'6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
SecretKey:'6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe'}"/>
Thekeyslistedaboveareonlyfortestingpurposes.Neverusetheminproduction.
EditLoginForm.cstoaddaRecaptchaproperty:
publicclassLoginForm
{
[Placeholder("defaultusernameis'admin'")]
publicStringUsername{get;set;}
[PasswordEditor,Placeholder("defaultpasswordforadminuseris'serenity'"),Req
uired(true)]
publicStringPassword{get;set;}
[DisplayName(""),Recaptcha]
publicstringRecaptcha{get;set;}
}
EditLoginRequest.cstoaddaRecaptchaproperty:
publicclassLoginRequest:ServiceRequest
{
publicstringUsername{get;set;}
publicstringPassword{get;set;}
publicstringRecaptcha{get;set;}
}
EditLoginmethodunderAccountPage.cstovalidatethecaptchaserverside:
HowTo:UseRecaptcha
244

[HttpPost,JsonFilter]
publicResult<ServiceResponse>Login(LoginRequestrequest)
{
returnthis.ExecuteMethod(()=>
{
request.CheckNotNull();
if(string.IsNullOrEmpty(request.Username))
thrownewArgumentNullException("username");
varusername=request.Username;
//justaddlinebelow
Serenity.Web.RecaptchaValidation.Validate(request.Recaptcha);
if(WebSecurityHelper.Authenticate(refusername,request.Password,false))
returnnewServiceResponse();
thrownewValidationError("AuthenticationError",
Texts.Validation.AuthenticationError);
});
}
HowTo:UseRecaptcha
245

HowTo:RegisterPermissionsinSerene
Sereneshowsalistofpermissionsinuserandrolepermissiondialogs.Toshowyourown
permissionsthere,youneedtousethesepermissionswithoneoftheattributesbelow:
AttributesthatderivefromPermissionAttributeBase:
ReadPermission
ModifyPermission
InsertPermission
UpdatePermission
DeletePermission
PageandEndpointAccessControlAttributes:
PageAuthorize
ServiceAuthorize
Theseattributescanbeusedwithandlocatedfromoneofthesetypes:
OntopofXYZRow(Read,Write,Insert,Update,Deletepermissions)
OntopofXYZPageandinactionmethods(PageAuthorize)
OntopofXYZEndpointandinserviceactions(ServiceAuthorize)
Whenyouuseapermissionkeywithoneofsuchattributes,Serenewillautomatically
discoverthemusingreflectionatapplicationstart.
ThereisaPermissionKeysclassinSerene.Someusersexpectedthatwhentheywrite
theirpermissionkeysinthisclass,theywillbediscovered.
But,PermissionKeysclassisonlythereforintellisensepurposes,itisignoredby
Serene.
Ifyoudon'tuseapermissionkeywithanyofthembutstillwanttoshowitinpermission
dialogs,youcanuseRegisterPermissionattributeonassembly(writethisanywherein
YourProject.Web):
[assembly:Serenity.ComponentModel.RegisterPermissionKey("MySpecialPermissionKey")]
OrganizingPermissionTree
Tocreatepermissionsintreehierarchy,usecolon(:)asaseparatorinyourpermissionkeys:
HowTo:RegisterPermissionsinSerene
246

MyModule:SubModule:General
MyModule:SubModule:Permission1
MyModule:SubModule:Permission2
ThesekeyswillbeshownunderMyModule/SubModulecategory.Thustheircategorykeys
willbe:
MyModule:SubModule:
Categorykeysendswithcolon.Don'tusepermissionkeysthatendswithcolon.
Pleasedon'tusepermissionkeysthatmatchescategorykeys.Ifyouusesuchkeys,for
exampleMyModule:SubModuleitwon'tbeshownunderMyModule/SubModulecategory
butnexttoitatsamelevel.
Ifyouneedagenericpermissionforsuchacategory,usesomethinglike
MyModule:SubModule:General.
Generalhasnospecialmeaning,youcanuseCommon,Module,View,whateveryou
like.
HandlingCategoryDisplayTexts
Ascategoriesareautomaticallydeterminedfrompermissionkeys,theydon'thaveauser
friendlydisplaytextforthem.
Youneedtoadddisplaytextsforthemusinglocalizationsystem.
Ifyoudon'tneedlocalization,justaddtextstosite.texts.invariant.json
Forexampleinsite.texts.invariant.jsonfile,therearesuchkeys:
"Permission.Administration:":"Administration",
"Permission.Administration:Security":"User,RoleManagementandPermissions",
"Permission.Administration:Translation":"LanguagesandTranslations",
"Permission.Northwind:Customer:":"Customers",
"Permission.Northwind:Customer:View":"View",
"Permission.Northwind:Customer:Delete":"Delete",
"Permission.Northwind:Customer:Modify":"Create/Update",
"Permission.Northwind:General":"[General]"
Thekeysendingwithcolon(:),likeAdministration:andCustomer:correspondstocategories
andthesearetheirdisplaytexts.
Youneedtoaddtextsforcategoriestoinvariantlanguageatminimum.Youmayalsoaddto
otherlanguages,ifyouwantlocalization.
HowTo:RegisterPermissionsinSerene
247

HowTo:RegisterPermissionsinSerene
248

HowTo:UseaThirdPartyPluginWith
Serenity
Touseathirdparty/custompluginwithaSerenityapplicationinvolvesnospecialsteps.
YoumayincludetheirscriptsandCSSin_LayoutHead.cshtml,andfollowtheir
documentation.
EspeciallyifyouareusingTypeScript,therearenospecialstepsinvolved.
IncaseofSaltaralle(whichisbeingdeprecated),youmighthavetowritesomeimport
classesorusedynamicotherwise.
But,ifyouwantthatcomponenttoworkwellamongotherSerenityeditorsindialogs,you
maytrywrappingitintoaSerenitywidget.
Herewe'lltakeBootstrapMultiSelectpluginasasample,andintegrateitasaneditorinto
Serenity,similartoLookupEditor.
Hereisthedocumentationandsamplesforthiscomponent:
http://davidstutz.github.io/bootstrap-multiselect/
GettingScriptandCSSFiles
FirstweshoulddownloaditsscriptandCSSfilesandplacethemincorrectplacesunder
MyProject.Web/scripts/andMyProject.Web/contentfolders.
ThiscomponenthasaNuGetpackagebutunfortunatelyitisnotinastandardfashion(it
doesn'tplacefilesintoyourprojectfolders),sowe'llhavetodownloadfilesmanually.
DownloadthisscriptfileandputitunderMyProject.Web/Scripts:
https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/js/bootstrap-
multiselect.js
DownloadthisCSSfileandputitunderMyProject.Web/Content:
https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/css/bootstrap-
multiselect.css
IncludingScript/CssFilesin_LayoutHead.cshtml
Accordingtoplugindocumentation,weshouldincludethesefiles:
HowTo:UseaThirdPartyPluginWithSerenity
249

<!--Includetheplugin'sCSSandJS:-->
<scripttype="text/javascript"
src="js/bootstrap-multiselect.js">
</script>
<linkrel="stylesheet"type="text/css"/
href="css/bootstrap-multiselect.css"/>
Open_LayoutHead.cshtmlunderMyProject.Web/Views/Sharedandincludethesefiles:
//...
@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")
CreatingBSMultiSelectEditor.ts
NowweneedaTypeScriptsourcefiletoholdourcomponent.Weshouldputitunder
MyProject.Web/ScriptsorMyProject.Web/Modulesdirectories.
I'llcreateitunderMyProject.Web/Modules/Common/Widgets(firstyouneedtocreatefolder
Widgets)
CreatefileBSMultiSelectEditor.tsatthislocation:
HowTo:UseaThirdPartyPluginWithSerenity
250

namespaceMyProject{
@Serenity.Decorators.element("<select/>")
@Serenity.Decorators.registerClass(
[Serenity.IGetEditValue,Serenity.ISetEditValue])
exportclassBSMultiSelectEditor
extendsSerenity.Widget<BSMultiSelectOptions>
implementsSerenity.IGetEditValue,Serenity.ISetEditValue{
constructor(element:JQuery,opt:BSMultiSelectOptions){
super(element,opt);
}
publicsetEditValue(source:any,
property:Serenity.PropertyItem):void{
}
publicgetEditValue(property:Serenity.PropertyItem,
target:any):void{
}
}
exportinterfaceBSMultiSelectOptions{
lookupKey:string;
}
}
Herewedefinedaneweditortype,derivingfromWidget.Ourwidgettakesoptionsoftype
BSMultiSelectOptions,whichcontainsalookupKeyoption,similartoaLookupEditor.Italso
implementsIGetEditValueandISetEditValueTypeScriptinterfaces(thisisdifferentthanC#
interfaces)
@Serenity.Decorators.element("<select/>")
Withaboveline,wespecifiedthatourwidgetworksonaSELECTelement,asthisbootstrap
multiselectpluginrequiresaselectelementtoo.
@Serenity.Decorators.registerClass(
[Serenity.IGetEditValue,Serenity.ISetEditValue])
Above,weregisterourTypeScriptclass,withSaltaralletypesystemandspecifythatour
widgetimplementscustomvaluegetterandsettermethods,correspondingtogetEditValue
andsetEditValuemethods.
HeresyntaxisabitterseaswehavetohandleinteropbetweenSaltaralleand
TypeScript.
OurconstructorandgetEditValue,setEditValuemethodsareyetempty.We'llfillthemin
soon.
HowTo:UseaThirdPartyPluginWithSerenity
251

UsingOurNewEditor
Now,buildyourprojectandtransformtemplates.
OpenCustomerRow.csandlocateRepresentativesproperty:
[LookupEditor(typeof(EmployeeRow),Multiple=true),NotMapped]
[LinkingSetRelation(typeof(CustomerRepresentativesRow),
"CustomerId","EmployeeId")]
publicList<Int32>Representatives
{
get{returnFields.Representatives[this];}
set{Fields.Representatives[this]=value;}
}
HereweseethatRepresentativesusesaLookupEditorwithmultipleoptiontrue.We'll
replaceitwithourbrandneweditor:
[BSMultiSelectEditor(LookupKey="Northwind.Employee"),NotMapped]
[LinkingSetRelation(typeof(CustomerRepresentativesRow),
"CustomerId","EmployeeId")]
publicList<Int32>Representatives
{
get{returnFields.Representatives[this];}
set{Fields.Representatives[this]=value;}
}
PopulatingEditorWithLookupItems
IfyounowbuildyourprojectandopenaCustomerdialog,you'llseeanemptySELECTin
placeofCustomerrepresentativesfield.
Let'sfirstfillitwithdata:
exportclassBSMultiSelectEditor{
constructor(element:JQuery,opt:BSMultiSelectOptions){
super(element,opt);
letlookup=Q.getLookup(this.options.lookupKey)asQ.Lookup<any>;
for(letitemoflookup.get_items()){
letkey=item[lookup.get_idField()];
lettext=item[lookup.get_textField()]||'';
Q.addOption(element,key,text);
}
}
WefirstgetareferencetolookupobjectspecifiedbyourlookupKeyoption.
HowTo:UseaThirdPartyPluginWithSerenity
252

LookupshasidFieldandtextFieldproperties,whichusuallycorrespondstofieldsdetermined
byIIdRowandINameRowinterfacesonyourlookuprow.
Weenumerateallitemsinlookupanddeterminekeyandtextpropertiesofthoseitems,
usingidFieldandtextFieldproperties.
Nowsavefile,andopenCustomerdialogagain.You'llseethatthistimeoptionsarefilled.
BootstrapMultiSelectTypings
Accordingtodocumentationweshouldnowcall".multiselect()"jQueryextensiononour
selectelement.
IwouldcastourSELECTelementto <any>andcall.multiselectonit,butiwanttowritea
TypeScript.d.tsdefinitionfiletoreusemultiselectwithintellisense.
So,underMyProject.Web/Scripts/typings/bsmultiselectfolder,createafile,bsmultiselect.d.ts
interfaceJQuery{
multiselect(options?:BSMultiSelectOptions|string):JQuery;
}
interfaceBSMultiSelectOptions{
multiple?:boolean;
includeSelectAllOption?:boolean;
selectAllText?:string;
selectAllValue?:string|number;
}
Here,ihaveextendedJQueryinterfacewhichbelongstojQueryitselfandisdefinedin
jquery.d.ts.InTypeScriptyoucanextendanyinterfacewithnewmethods,propertiesetc.
IusedplugindocumentationtodefineBSMultiSelectOptions.Thepluginactuallyhasmuch
moreoptions,butfornowikeepitshort.
CreatingBootstrapMultiSelectonOurEditor
Nowi'llgobacktoourconstructorandinitializeamultiselectpluginonit:
HowTo:UseaThirdPartyPluginWithSerenity
253

exportclassBSMultiSelectEditor{
constructor(element:JQuery,opt:BSMultiSelectOptions){
super(element,opt);
element.attr('multiple','multiple')
letlookup=Q.getLookup(this.options.lookupKey)asQ.Lookup<any>;
for(letitemoflookup.get_items()){
letkey=item[lookup.get_idField()];
lettext=item[lookup.get_textField()]||'';
Q.addOption(element,key,text);
}
element
.attr('name',this.uniqueName+"[]")
.multiselect();
}
OpenCustomerDialogandyou'llseethatRepresentativeshasourbootstrapmultiselect
editor.
HandlingGetEditValueandSetEditValueMethod
Ifwedon'thandlethesemethods,Serenitywon'tknowhowtoreadorsetyoureditorvalue,
soevenifyouselectsomerepresentatives,nexttimeyouopenthedialog,you'llhaveempty
selections.
exportclassBSMultiSelectEditor{
//...
publicsetEditValue(source:any,property:Serenity.PropertyItem):void{
this.element.val(source[property.name]||[]).multiselect('refresh');
}
publicgetEditValue(property:Serenity.PropertyItem,target:any):void{
target[property.name]=this.element.val()||[];
}
setEditValueiscalledwheneditorvalueneedstobesetted.Ittakesasourceobject,which
isusuallyyourentitybeingloadedinadialog.
PropertyparameterisaPropertyItemobjectthatcontainsdetailsaboutthefieldbeing
handled,e.g.ourRepresentativesproperty.It'snamefieldcontainsfieldname,e.g.
Representatives.
HowTo:UseaThirdPartyPluginWithSerenity
254

Herewehavetocallmultiselect('refresh')aftersettingselectvalue,asmultiselectplugin
can'tknowwhenselectionsarechanged.
getEditValueisopposite.Itshouldreadeditvalueandsetitintargetentity.
Ok,nowourcustomeditorshouldbeworkingfine.
HowTo:UseaThirdPartyPluginWithSerenity
255

HowTo:EnableScriptBundling
InSerenetemplatethereareabout3MB+ofjavascriptfileswhichareincludedbydefaultin
_LayoutHead.cshtml.
Thismightcausebandwidthandperformanceproblemsforsomesystems,especiallywhen
aSerenitybasedsiteisaccessedfrommobiledevices.
Thereareseveralwaystohandletheseproblems,likeminificationandgzippingtodecrease
scriptsize,bundlingtopackscriptsintofewerfiles,thusreducenumberofrequests.
YoumightprefertousetoolslikeWebpack,Grunt,Gulp,UglifyJSetc,butincaseyouwanta
simplerandeffectivesolutionwithmuchlessmanualsteps,Serenitycomeswithascript
bundlingandminification/compressionsystemoutofthebox.
PleasenotethatthisfeaturerequiresSerenity2.0.13+
ScriptBundles.json
First,youneedaScriptBundles.jsonfileunderMyProject.Web/scripts/sitefolder.
ScriptBundles.jsonconfigureswhichscriptbundlewillcontainwhichfileswhenbundlingis
turnedon.
ThisfileisincludedbydefaultinSerenetemplate2.0.13+andlookslikethis:
Unlessyouwanttoaddsomecustomscriptstobundles,youdon'tneedtomodifythis
file.
HowTo:EnableScriptBundling
256

{
"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"
]
}
Herewedefinetwodistinctbundles,LibsandSite,correspondingtoBundle.Libs.jsand
Bundle.Site.jsdynamicscriptfiles.
Bundle.Site.jsisconfiguredtocontainthesethreeJSfiles(inthelistedorder):
HowTo:EnableScriptBundling
257

"~/Scripts/adminlte/app.js",
"~/Scripts/Site/Serene.Script.js",
"~/Scripts/Site/Serene.Web.js"
WhileBundle.Libs.jscontainsallotherjavascriptfiles.
Hereweused2bundlesbydefault,butitispossibletouseone,threeormoreincase
youneedadifferentconfiguration.Justbecarefulaboutdependencies.
Here,theorderinginsideabundle(package)isveryimportant.Youmustincludescriptsin
theordertheyappearinyour_LayoutHead.cshtml.
Whenyouwilladdanothercustomscript,makesurethatitisplacedafterallits
dependencies.
Forexample,ifyouincludeajquerypluginbeforejqueryisloadeditself,you'llhave
errors.
Alsomakesurethatyoudon'tincludesamefileintwobundles.
EnablingBundling
Youshouldenablebundling(especiallyminification)onlyinproduction.Otherwiseitmight
becomeverydifficulttodebugJavascript.
Toenablebundling,justchangeEnabledpropertyofScriptBundlingapplicationsettingin
yourweb.configtotrue:
<addkey="ScriptBundling"value="
{Enabled:true,Minimize:false,UseMinJS:false}"/>
WhenEnabledisfalse(default)systemwilldonothing,andyou'llworkasusualwithyour
scriptincludes.Andyourpagesourcelookslikethis:
HowTo:EnableScriptBundling
258

<scriptsrc="/Scripts/pace.js?v=..."></script>
<scriptsrc="/Scripts/rsvp.js?v=..."></script>
<scriptsrc="/Scripts/jquery-2.2.3.js?v=..."></script>
<scriptsrc="/Scripts/jquery-ui-1.11.4.js?v=..."></script>
<scriptsrc="/Scripts/jquery-ui-i18n.js?v=..."></script>
...
...
...
<scriptsrc="/Scripts/adminlte/app.js?v=..."></script>
<scriptsrc="/Scripts/Site/Serene.Script.js?v=..."></script>
<scriptsrc="/Scripts/Site/Serene.Web.js?v=..."></script>
...
WhenEnabledistrue,itwillbecomelikethisone:
<scriptsrc="/DynJS.axd/Bundle.Libs.js?v=..."></script>
<scriptsrc="/DynJS.axd/Bundle.Site.js?v=..."></script>
...
Thesetwobundlesaregeneratedinmemoryandcontainsallotherscriptfilesconfiguredin
ScriptBundles.jsonfile.
TheyarealsocompressedwithGZIPandcachedinmemory(ingzippedform),sonowour
scriptswillconsumemuchlessbandwidthandwillcausefewerrequeststoserver.
Nowourscriptfileswillconsume600KB,insteadof3000KBbefore,a%80reduction,not
bad...
EnablingMinification
Afterenablingbundling,youmayalsoenableminificationofscriptswiththesame
web.configsetting.SetMinimizepropertytotrue:
<addkey="ScriptBundling"value="
{Enabled:true,Minimize:true,UseMinJS:false}"/>
PleasenotethatMinimizepropertyonlyworkswhenEnabledistrue,thuswhen
bundlingisenabled.
UglifyJSlibraryisusedforminification.Thiswillbeappliedbeforebundling/gzippingsoour
bundleswillbecomeabout%40smaller,butwillbemuchhardertoread,soenablethisonly
inproduction.
Nowourbundledandminifiedscriptfileswillconsume375KB,insteadof3000KBbefore,a
%87reduction,or1/8theinitialsize.
HowTo:EnableScriptBundling
259

UseMinJSSetting
Minificationmighttakesometime,andfirstrequesttoyoursitemighttakearound5-40
secondsmore,dependingonspeedofyourserver.
Otherrequestswillnotbeaffectedasminificationisonlyperformedonceatapplicationstart.
Anyway,ifyoustillneedmoreperformanceatfirstrequest,youmayaskSerenitytoreuse
alreadyminifiedfilesindisk,iftheyareavailable.
SetUseMinJStotrue:
<addkey="ScriptBundling"value="
{Enabled:true,Minimize:true,UseMinJS:true}"/>
WhenthissettingisON,beforeminifyingafile,let'ssayjquery-ui-1.11.4.js,Serenitywillfirst
checktoseeifajquery-ui-1.11.4.min.jsalreadyexistsindisk.Ifso,insteadofminifiyingwith
UglifyJS,itwillsimplyusethatfile.Otherwise,itwillrunUglifyJS.
Serenecomeswithminifiedversionsofalmostalllibraries,includingSerenityscripts,sothis
settingwillspeedupinitialstarttime.
Thereisalittleriskthatyoushouldbecarefulabout.Ifyoumanuallymodifyalibraryscript,
makesureyouminifyitmanuallyandmodifyits.min.jsfiletoo,otherwisewhenbundlingis
enabledanoldversionofthatscriptmightrunatproduction.
HowSerenityModifiesYour_LayoutHead.cshtmlIncludes?
Ifyoulookatyour_LayoutHead.cshtmlyoumightspotlineslikethese:
@Html.Script("~/Scripts/jquery.cropzoom.js")
@Html.Script("~/Scripts/jquery.fileupload.js")
@Html.Script("~/Scripts/jquery.iframe-transport.js")
Whenbundlingisdisabled,thesestatementsgeneratessuchHTMLcode:
<scriptsrc="/Scripts/jquery.cropzoom.js"></script>
<scriptsrc="/Scripts/jquery.fileupload.js"></script>
<scriptsrc="/Scripts/jquery.iframe-transport.js"></script>
Html.ScriptisanextensionmethodofSerenity,sowhenbundlingisenabled,insteadof
generatingthisHTMLcode,Serenitywillfirstchecktoseeifthisscriptisincludedina
bundle.
HowTo:EnableScriptBundling
260

Forthefirstscriptthatisincludedinabundle,let'ssayBundle.Lib.js,Serenitywillgenerate
codebelow:
<scriptsrc="/DynJS.axd/Bundle.Libs.js?v=..."></script>
But,forotherHtml.Scriptcallsthatisincludedinsamebundle,Serenitywillgenerate
nothing.Thus,eventhoughyoucallHtml.Script50times,you'llgetonlyone <script>
outputinpagecode.
WhatIsv=p53uqJ...InMyScriptTags?
Thisisaversionnumber,orHASHofyourscript.Whetherbundlingisenabledornot,when
youuseHtml.Script,itwilladdthesehashtoyourscriptincludes.Thishashallowsbrowser
tocachescriptuntilitchanges.Whencontentofascriptchanges,itshashwillchangetoo,
sobrowserwon'tcacheanduseanolderversion.
Thisisthereasonyou'llneverhavescriptcachingproblemswithSerenityapps.
HowTo:EnableScriptBundling
261

HowTo:DebuggingwithSerenitySources
Sometimesyoumightwanttodebug(ortraceinto)Serenitysources.Therearetwowaysto
dothis.
ByEnablingSourceServerSupport
SerenityNuGetpackagesalreadycontains.pdbfilesdebugging,whicharemodifiedtouse
GitHubasasymbolsourcebyusingexcellentGitLinkproject:
https://github.com/GitTools/GitLink
Youdon'tneedGitLinktodebug,it'sjustatoolusedbySerenitywhilepublishing
Toenablesourceserversupport,justgotoyourVisualStudiooptions,andunderDebugging
->General,clickEnablesourceserversupport.
YoushouldalsouncheckEnableJustMyCode:
HowTo:DebuggingwithSerenitySources
262

NowputabreakpointonOrderRepository->MyListHandler->ApplyFiltersorsomeother
codeyoulike:
HowTo:DebuggingwithSerenitySources
263

Launchapplicationindebugmode,navigatetoOrderspage,andenjoydebugging:
ByAddingSerenityasaSubModule
ThisoptionisonlyrecommendedforadvanceduserswithagoodknowledgeofGit,
Submodulesand.NETingeneral.You'llalsolosetheabilitytoupdateSerenityandrelated
filessimplywithNuGet.
Idon'trecommendthistonoviceusers.Ifyoudothisandbreakyourproject,sorrybuti
can'thelpyou.
IassumeyouhaveaprojectnamedSereneSample,andhaveaGITrepositoryforitalready.
InGitExtensions,enterRepository->SubModules->Addsubmodule:
UnderPathtosubmoduleenter:
https://github.com/volkanceylan/Serenity.git
EnterSerenityasLocalpath.
ThenclickAddtoaddSerenityasasubmoduletoyourrepository.Thenclosethe
submodulesdialog,andreturntoVisualStudio.
ExpandyourprojectreferencesforSereneSample.Webandremovefollowingreferences:
HowTo:DebuggingwithSerenitySources
264

Serenity.Core
Serenity.Data
Serenity.Data.Entity
Serenity.Services
Serenity.Web
Rightclickyoursolution,clickAdd->ExistingProjectandselectSerenity.Core.csprojunder
Serenityfolder.
RepeatitforSerenity.Data,Serenity.Data.Entity,Serenity.ServicesandSerenity.Web.
Rightclickyourprojectreferences,clickAddReference->Projects->Solutionandcheckall
projectsweaddedabove,thenclickOK.
Nowbuildyoursolution.Thereshouldbenoerrors.
UnloadyourprojectbyrightclickingitandclickingUnload.Thenagainrightclickproject
nameandclickEdit.
AddImportstatementbelow,afterthelastImportProjectstatementinyourcsproj(there
shouldbe4ImportProjectstatements,5afterincludingthisone):
<ImportProject="$(SolutionDir)Serenity\tools\Submodule\Serenity.Submodule.Web.targe
ts"/>
UnderCompileSiteLessincludethis:
<ExecCommand=""$(ProjectDir)tools\node\lessc.cmd"
"$(ProjectDir)..\..\Serenity\Serenity.Web\Style\serenity.less"
>"$(ProjectDir)Content\serenity\serenity.css"">
</Exec>
...
Savefileandreloadproject.
NowyoucanuseSerenityasasubmoduleanddebugnormally.
HowTo:DebuggingwithSerenitySources
265

FrequentlyAskedQuestions
CodeGenerator(Sergen)
ShouldIregeneratecodeafteraddingfieldstomytable:
It'srecommendedtoonlygeneratecodeonce.Youshouldaddnewfieldstorow,column
andformclassesmanually,takingexistingfieldsasasample.
Butifyoumadetoomanychanges,andwanttogeneratecodeagainitispossible.Sergen
willlaunchKdiff3toletyoumergechanges,sothatitwon'toverridethechangesyoumight
havemadetothecodegeneratedbefore.
I'mhavinganerrorinSergenaboutKDiff3.Wheretosetitslocation:
SergenlooksforKDiff3atitsdefaultlocationunderProgramFilesdirectory.Installitifyou
didn'tyet.
IfKdiff3isatanotherlocation,editSerenity.CodeGenerator.configinyoursolutiondirectory.
ThisisaJSONfilecontainingsettingsandpreferencesforSergen.
Permissions
IwanttoseparateINSERTpermissionfromUPDATEpermission:
Insteadof[ModifyPermission]attributeuse[InsertPermission]and[UpdatePermission]
attributesonrows.
Bydefault,forINSERT,savehandlerlooksforthesepermissionsonrowinthisorderon
row:
1)InsertPermission
2)ModifyPermission
3)ReadPermission
Onlythefirstonethatisfoundischecked.
SimilarlyforUPDATE,savehandlerlooksforthesepermissionsinorderonrow:
1)UpdatePermission
FrequentlyAskedQuestions
266

2)ModifyPermission
3)ReadPermission
ForDELETE,deletehandlerlooksforthesepermissionsinorderonrow:
1)DeleteInsertPermission
2)ModifyPermission
3)ReadPermission
ForLIST/RETRIEVE,onlyonepermissionischecked:
1)ReadPermission
PublishingandDeployment
HowcanipublishSerenityapplications:
Serenityapplicationsarex-copydeployable.Youjustneedtosetupconnectionstringsafter
deployment.Youmightexcludesourcefilesfromdeployment.
MakesureyouremovedatabasemigrationsafetycheckfromRunMigrationsmethodin
SiteInitialization.Migrationsfile.
YoucanalsousepublishfeatureinVisualStudio.Makesurebuildactionforallcontentfiles
thatyouusearesettoContent,andnotNone.
YouhavetoonlypublishMyApplication.Web,notscriptproject.
SerenityusesaNuGetversionofASP.NETMVC,sothereisnoneedtoinstallMVCon
server.IfyougetsomeDLLmissingerror,checkthatitsCopyLocaloptionofVSproject
referencesissettoTrue.
FormsandEditors
HowtoallownegativevaluesinDecimalEditor:
InDecimalEditorattributesetMinValueandMaxValueproperties:
[DecimalEditor(MinValue="-999999999.99",MaxValue="999999999.99")]
publicDecimalMyProperty{get;set;}
Makesureyouusesamenumberofdigitsforminandmaxvalue.
Howcanireload/refreshalookupeditordata
FrequentlyAskedQuestions
267

UseQ.ReloadLookup("MyModule.MyLookupKey")toreloadalookupbyitskey.
HowtocreatefiltereditorforanEnum:
AddEqualityFilter<EnumEditor>(SomeRow.Fields.TheEnumField,
options:newEnumEditor{EnumKey="MyModule.MyEnumType"});
Howtosetcurrentdateinadateeditorinnewrecordmode:
Add[DefaultValue("today")]fordate,or[DefaultValue("now")]fordatetimeeditorinform
declaration.
Don'tdothisinrow.Itmaycauseerrors.
Anotheroptionistodothisindialog,overridingAfterLoadEntity:
form.MyDateField.AsDate=JsDate.Today;
FrequentlyAskedQuestions
268

Troubleshooting
InitialSetup
AfteryoucreateanewSereneapplicationandlaunchit,loginscreendoesn'tshow
andyouseeanerrormessageinconsolethatsaysTemplate.LoginPanelisnotfound:
Youprobablyusedaninvalidsolutionname,likeMyProject.Somethingthatcontainsdot(.)
Templatesystemmightnotbeabletolocatetemplateswhenprojectsarenamedthisway.
Pleasedon'tusedotinsolutionname.Youmayrenamesolutionaftercreationifrequired.
CompilationErrors
I'mgettingseveralambiguousreferenceerrorsafteraddingafiletoScriptproject:
RemoveSystem.dllreferencefromscriptproject.VisualStudioaddsthisreferencewhen
youuseAddNewFiledialog.SaltarelleCompilerdoesn'tworkwithsuchreferences,asit
hasacompletelydifferentruntime.
Pleaseusecopy/pastetocreatecodefilesinScriptproject.
Error:System.ComponentModel.DisplayNameattributeexistsinboth
...\Serenity.Script.UI.dlland...\v2.0...\System.dll
Sameasabove,removeSystem.dllreferencefromscriptproject.
RuntimeErrors
I'mgettingNotImplementExceptionwhenuploadingfiles,oraddingnotes:
Suchfeaturesrequiresatablewithintegeridentitycolumn.String/Guidprimarykeysupport
isaddedinrecentSerenityversions,andsomeoldbehaviorsdoesn'tworkwithsuchkeys.
SQLandConnections
Whenichangepageingrid,i'mgettingerror,"Incorrectsyntaxnear'OFFSET'.Invalid
usageoftheoptionNEXTintheFETCHstatement:
Troubleshooting
269

YourSQLserverversionis2008orolder.Bydefault,SQLServerconnectionsuseSQL2012
dialect.DosomethinglikebelowforyourconnectionsinSiteInitialization.csandyourdialect
foralltoSqlServer2005orSqlServer2008:
SqlConnections.GetConnectionString("Default").Dialect=
SqlServer2008Dialect.Instance;
T4TemplateProblems
Myenumisnottransferredtoscriptside,aftertransformingtemplates:
Ifyouuseanenumtypeinaroworservicerequest/responseitwillbetransferred,
otherwiseitwon'tbydefault.Ifyoustillwanttoincludethisenum,add[ScriptInclude]
attributeontopofit.
EditorsandForms
Triedtosetupcascadeddropdownsbutmyseconddropdownisalwaysempty:
MakesureyourCascadeFieldiscorrectanditmatchespropertynameinsecondarylookup
properly.ForexampleCountryIDdoesn'tmatchCountryIdatscriptside.Youmayuse
nameof()operatorlikeCascadeField=nameof(CityRow.CountryId)tobesure.
AsimilarproblemmightoccurifyoufailtocorrectlysetCascadeFromoption.This
correspondstofirstdropdownIDinyourform.Forexample,ifthereareMyCountryIdand
CustomerCityIdpropertiesintheform,CascadeFormshouldbeCustomerCountryId.Again,
youcanusenameof(MyCountryId)tobecertain.
CascadeFromisaneditorIDinform,whileCascadeFieldisafieldpropertynamein
row.
AnotherpossibilityisthatCascadeFieldisnotincludedinlookupdatathatissenttoscript
side.Forexample,ifseconddropdowniscityselection,whichisconnectedtoacountry
dropdownthroughCountryId,makesurethatCountryIdpropertyinCityRowhasa
[LookupInclude]attributeonit.Bydefault,onlyIDandNamepropertiesaresenttoscript
sideforlookups.
Triedtocreatetabsusingadialogtemplate,butmytabisnotshownorempty:
Makesureyoudon'tputatabcontent,insideanotherone,likeDIVinsideanothertabDIV.
Troubleshooting
270

Master/DetailEditing
IcreatedainmemorymasterdetaileditingsimilartooneinMovieTutorialcasteditor,
butwheniupdatearecord,i'mgettingduplicateentries:
Makesureyoudon'thavea[IdProperty]onyourEditDialogclass.Aseditdialogsworkin
memorywithrecordsthatdoesn'tyethaveactualIDs,ifyouuseyouractualIDpropertywith
them,dialogwillthinkthatyouareaddingnewrecordsonupdate(astheiractualIDvalueis
alwaysnull).
Asyouseeincodebelow,GridEditorDialogbaseclassusesafakeID:
[IdProperty("__id")]
publicabstractclassGridEditorDialog<TEntity>:EntityDialog<TEntity>
whereTEntity:class,new()
Sowhenyouput[IdProperty]toyoureditdialog,you'reoverwritingthisfakeIDandcausing
unexpectedbehavior.
I'msuccesfullyaddingdetailsbutlaterwhenopenanexistingrecord,someview
fieldsareempty:
Pleaseput[MinSelectLevel(SelectLevel.List)]onyourviewfieldsinYourDetailRow.cs.By
default,ListhandlersandMasterDetailBehavioronlyloadstablefields(notviewfields)of
detailrows.
Permissions
Mypageisnotshowninnavigation:
PageaccesspermissionsarereadfromPageAuthorizeattributeonIndexactionof
XYZPage.csfile,whichisyourMVCpagecontroller.Makesureyousetthistoapermission
userhas.
IhaveaddedapermissiontoPermissionKeys.csbutitdoesn'tshowinuser
permissionsdialog:
PermissionKeysclassisjustforintellisensepurposes.Seebelowforinformationabout
registeringkeys.
Troubleshooting
271

HowToRegisterPermissionsinSerene
Changedpermissionkeysonrow,buti'mgettinganerrorwheniopenthepage,and
norecordsdisplayed:
YourXYZEndpoint.csalsohasa[ServiceAuthorize("SomePermission")]onit.Thisisto
provideasecondarylevelsecurity.ReplacepermissionkeywiththeoneonRow.
Localization
Mylocalizationslostonlivesiteafterpublishing:
Thetranslationsyoumadeusingtranslationinterfacearesavedtofilesunder~/App_Data
directory.Eithercopythesefilestoliveserver,ormovetextsinthemtorelevantfilesunder
~/Scripts/site/texts.
Ihaveaddedsomecustomlocaltextkeysbutcan'taccessthemfromscriptside:
Notalltranslationsaretransferredtoscriptside.Thereisasettinginweb.configwith
LocalTextPackageskey,thatcontrolstheseprefixes.Ifyoulookthere,youcanseethatonly
textkeysthatarestartingwithDb.,Dialogs.,Forms.etcaretransferredtoclientside.Thisis
tolimitsizeoftextsasnotallofthemareusedinscriptcode.
Eitheraddyourownprefixthere,orchangeyourkeystostartwithoneofdefaultprefixes.
NuGetPackagesandUpdates
IhavesomeerrorsafterupdatingSelect2:
Pleasedon'tupdateSelect2toaversionlaterthan3.5.1.Recentversionshassomeknown
compabilityproblems.
ToreverttoSelect23.5.1,typefollowinginpackagemanagerconsole:
Update-PackageSelect2.js-Version3.5.1
DeploymentandPublishing
Troubleshooting
272

Afterpublishingprojectsomecontentisnotfound,ornotdisplayed:
IfyouareusingVisualStudiopublish,makesurethatcss,imagefilesetcareincludedin
webprojectandtheirbuildactionissettocontent.
AnotherpossibilityisthatIIS_IUSRSusergroupcan'taccessfiles.Checkthatithas
permissionstofilesinpublishedwebfolder.
Tablenotfound(e.g.User)errorsafterpublish:
Serenehasachecktoavoidrunningmigrationsonanarbitrarydatabase.Findthischeck
underRunMigrationsmethodofSiteInitialization.Migrationsfileandremoveit.
FieldAccessExceptionswithmessage"Cannotsetaconstantfield":
Yourhostingproviderhassetyourwebapplicationpooltomediumtrust.Askthemtogrant
hightrust,orifpossiblechangeprovider.
Itmightbepossibletochangetrustlevelinweb.configifyourhostingproviderdidn'tlockit:
<configuration>
<system.web>
<trustlevel="Full"/>
</system.web>
</configuration>
Serenityinitializesfieldobjectswithreflection.Undermediumtrust,itcan'tdothat.Youmay
tryreplacingall*publicreadonly"fielddeclarationswith"publicstatic"in*Row.cs,butnot
sureifthiswillresolveallproblems.
ASP.NEThasmadeMediumtrustobsolete,andtheywon'tfixanyproblemsrelatedto
thisanymore.Seehttp://stackoverflow.com/questions/16849801/is-trying-to-develop-
for-medium-trust-a-lost-causeItisstronglyrecommendedtochangeyourhosting
provider
Troubleshooting
273

ServiceLocator&Initialization
Serenityusestheservicelocatorpatterntoabstractitsdependenciesandmakeitpossible
toworkwithyourchosenlibrariesandserviceproviders.
Forexample,Serenitydoesn'tcareabouthowyoustoreyourusers,butitcanquerycurrent
userthroughanabstraction(IAuthorizationService,IUserRetrieveServiceetc.)
SimilarlyyoumayuseRedis,Couchbase,Memcachedoranyotherasdistributedcachein
yourapplication.Serenitydoesn'thaveadirectdependencyonanyoftheirclientlibraries.
AssoonasyouimplementIDistributedCacheinterfaceandregisteritwiththeservice
locator,SerenitywillstartworkingwithyourNoSQLdatabase.
SomemightarguethatServiceLocatorisananti-patternthatshouldbeavoided.An
alternativetoitwouldbetheDependencyInjectionpattern.Butitseemsunlogical
havingtoknowabouteverydependency(anddependenciesofdependencies...)ofan
objecttojustbeabletouseit(youshouldn'thavetoknowaboutdetailsofwhatyour
mobileoperatorusestosendasimpleSMS).MaybeDIisasampleofover-
engineering.
ServiceLocator&Initialization
274

DependencyStaticClass
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
DependencyclassistheservicelocatorofSerenity.Alldependenciesarequeriedthrough
itsmethods:
publicstaticclassDependency
{
publicstaticTTypeResolve<TType>()whereTType:class;
publicstaticTTypeResolve<TType>(stringname)whereTType:class;
publicstaticTTypeTryResolve<TType>()whereTType:class;
publicstaticTTypeTryResolve<TType>(stringname)whereTType:class;
publicstaticIDependencyResolverSetResolver(IDependencyResolvervalue);
publicstaticIDependencyResolverResolver{get;}
publicstaticboolHasResolver{get;}
}
Inyourapplication'sstartmethod(e.g.inglobal.asax.cs)youshouldinitializeservicelocator
bysettingadependencyresolver(IDependencyResolver)implementation(anIoCcontainer)
withSetResolvermethod.
Dependency.SetResolverMethod
Configuresthedependencyresolverimplementationtouse.
YoucanuseIoCcontainerofyourchoicebutSerenityalreadyincludesonebasedonMunq:
varcontainer=newMunqContainer();
Dependency.SetResolver(container);
SetResolvermethodsreturnpreviouslyconfiguredIDependencyResolverimplementation,or
nullifnoneisconfiguredbefore.
Dependency.ResolverProperty
ReturnscurrentlyconfiguredIDependencyResolverimplementation.
ThrowsaInvalidProgramExceptionifnoneisconfiguredyet.
DependencyStaticClass
275

Depency.HasResolverProperty
ReturnstrueifaIDependencyResolverimplementationisconfiguredthroughSetResolver.
Returnsfalse,ifnot.
Dependency.ResolveMethod
Returnstheregisteredimplementationforrequestedtype.
Ifnoimplementationisregistered,throwsaKeyNotFoundException.
Ifnodependencyresolverisconfiguredyet,throwaInvalidProgramException
SecondoverloadofResolvemethodacceptsanameparameter.Thisshouldbeusedif
differentprovidersareregisteredforaninterfacedependingonscope.
Forexample,SerenityhasaIConfigurationRepositoryinterfacethatcanhavedifferent
providersbasedonsettingscope.SomesettingsmightbeApplicationscoped(shared
betweenallserversforthisapplication),whilesomemightbeServerscoped(eachserver
mightuseadifferentuniqueidentifier).
So,toretrieveaIConfigurationRepositoryproviderforeachofthesescopes,youwouldcall
themethodlike:
varappConfig=Dependency.Resolve<IConfigurationRepository>("Application");
varsrvConfig=Dependency.Resolve<IConfigurationRepository>("Server");
Dependency.TryResolveMethod
ThisisfunctionallyequivalenttoResolvemethodwithonedifference.
Ifaproviderisnotregisteredforrequestedtype,ornodependencyresolverisconfigured
yet,TryResolvedoesn'tthrowanexception,butinsteadreturnsnull.
DependencyStaticClass
276

IDependencyResolverInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
ThisinterfacedefinesthecontractfordependencyresolverswhichareusuallyIoC
containersthathandlesmappingbetweenservicesandproviders.
publicinterfaceIDependencyResolver
{
TServiceResolve<TService>()whereTService:class;
TServiceResolve<TService>(stringname)whereTService:class;
TServiceTryResolve<TService>()whereTService:class;
TServiceTryResolve<TService>(stringname)whereTService:class;
}
AllmethodsarefunctionallyequaltocorrespondingmethodsinDependencystaticclass.
IDependencyResolverInterface
277

IDependencyRegistrarInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
Dependencyresolversshouldimplementthisinterface(IDependencyRegistrar)toregister
dependencies:
publicinterfaceIDependencyRegistrar
{
objectRegisterInstance<TType>(TTypeinstance)whereTType:class;
objectRegisterInstance<TType>(stringname,TTypeinstance)whereTType:class;
objectRegister<TType,TImpl>()whereTType:classwhereTImpl:class,TType;
objectRegister<TType,TImpl>(stringname)whereTType:classwhereTImpl:class
,TType;
voidRemove(objectregistration);
}
MunqContainerandotherIoCcontainersarealsodependencyregistrars(theyimplement
IDependencyRegistrarinterface),soyoujusthavetoqueryforit:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<ILocalTextRegistry>(newLocalTextRegistry());
registrar.RegisterInstance<IAuthenticationService>(...)
IDependencyRegistrar.RegisterInstance
Method
Registersasingletoninstanceofatype(TType,usuallyaninterface)asproviderofthat
type.
objectRegisterInstance<TType>(TTypeinstance)whereTType:class;
Whenyouregisteranobjectinstancewiththisoverload,wheneveranimplementationof
TTypeisrequested,theinstancethatyouregisteredisreturnedfromdependencyresolver.
ThisissimilartoSingletonPattern.
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<ILocalTextRegistry>(newLocalTextRegistry());
IDependencyRegistrarInterface
278

IftherewasalreadyaregistrationforTType,itisoverridden.
Thisoverloadisthemostusedmethodofregisteringdependencies.
Makesuretheproviderwhichyouregisteredisthreadsafe,asallthreadswillbeusing
yourinstanceatsametime!
RegisterInstancehasalesscommonlyusedoverloadwithanameparameter:
objectRegisterInstance<TType>(stringname,TTypeinstance)whereTType:class;
Usingthisoverload,youcanregisterdifferentprovidersforthesameinterface,differentiated
byastringkey.
Forexample,SerenityhasaIConfigurationRepositoryinterfacethatcanhavedifferent
providersbasedonsettingscope.SomesettingsmightbeApplicationscoped(shared
betweenallserversforthisapplication),whilesomemightbeServerscoped(eachserver
mightuseadifferentuniqueidentifier).
So,toregisteraIConfigurationRepositoryproviderforeachofthesescopes,youwouldcall
themethodlike:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<IConfigurationRepository>(
"Application",newMyApplicationConfigurationRepository());
registrar.RegisterInstance<IConfigurationRepository>(
"Server",newMyServerConfigurationRepository());
Andwhenqueryingforthesedependencies:
varappConfig=Dependency.Resolve<IConfigurationRepository>("Application");
//...
varsrvConfig=Dependency.Resolve<IConfigurationRepository>("Server");
//...
IDependencyRegistrar.RegisterMethod
UnlikeRegisterInstance,whenatypeisregisteredwiththismethod,everytimeaprovider
forthattypeisrequested,anewinstancewillbereturned(soeachrequestorgetsaunique
instance).
IDependencyRegistrarInterface
279

varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.Register<ILocalTextRegistry,LocalTextRegistry>();
IDependencyRegistrar.RemoveMethod
AllregistrationmethodsofIDependencyRegistrarinterfacereturnsanobjectwhichyoucan
laterusetoremovethatregistration.
Avoidusingthismethodinordinaryapplicationsasallregistrationsshouldbedonefroma
centrallocationandonceperlifetimeoftheapplication.Butthiscanbeusefulforunittest
purposes.
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
varregistration=registrar.Register<ILocalTextRegistry,LocalTextRegistry>();
//...
registrar.Remove(registration);
Thisisnotanundooperation.IfyouregistertypeCforinterfaceA,whiletypeBwas
alreadyregisteredforthesameinterface,priorregistrationisoverridenandlost.You
can'tgetbacktopriorstatebyremovingregistrationofC.
IDependencyRegistrarInterface
280

MunqContainerClass
[namespace:Serenity,assembly:Serenity.Core]
SerenityincludesaslightlymodifiedversionofMunqIoCcontainer
(http://munq.codeplex.com/).
MunqContainerclassimplementsbothIDependencyResolverandIDependencyRegistrar
interfaces(allcontainersshould).
OnceyousetaMunqContainerinstanceasdependencyresolverlike:
varcontainer=newMunqContainer();
Dependency.SetResolver(container);
YoucanaccessitsregistrationinterfacebyqueryingforIDependencyRegistrarinterface:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
Here,registraristhesameMunqContainerinstance(container)thatwecreatedinprior
sample.
IfyouwouldliketouseanotherIoCcontainer,youjusthavetocreateaclassthat
implementsIDependencyResolverandIDependencyRegistrarinterfacesusingyour
favoriteIoCcontainer.
MunqContainerClass
281

CommonInitializationStaticClass
[namespace:Serenity.Web,assembly:Serenity.Web]
Ifyouaregoingtousedefaultsinawebenvironment,insteadofdoingtheservicelocator
setupandsomeotherconfigurationmanually,youmayjustcallCommonInitialization.Run()
inyourapplicationstartmethod.CommonInitializationregistersdefaultimplementationsfor
someofSerenityabstractions.
CommonInitialization.Run();
Ifthereisalreadyaproviderregisteredforsomeabstraction,CommonInitialization
doesn'toverridethem.
ThismethodcontainscallstosomeothermethodstoinitializeSerenityplatformdefaults:
publicstaticclassCommonInitialization
{
publicstaticvoidRun()
{
InitializeServiceLocator();
InitializeSelfAssemblies();
InitializeConfigurationSystem();
InitializeCaching();
InitializeLocalTexts();
InitializeDynamicScripts();
}
publicstaticvoidInitializeServiceLocator()
{
if(!Dependency.HasResolver)
{
varcontainer=newMunqContainer();
Dependency.SetResolver(container);
}
}
//...
}
CommonInitialization.InitializeServiceLocatorandothermethodsmayalsobeused
individuallyinsteadofcallingCommonInitialization.Run.
InitializeServiceLocator(),registersaMunqContainerinstanceasthedefault
IDependencyResolverimplementation.
CommonInitializationStaticClass
282

CommonInitializationStaticClass
283

Authentication&Authorization
Serenityusessomeabstractionstoworkwithyourapplication'sownuserauthenticationand
authorizationmechanism.
Serenity.Abstractions.IAuthenticationService
Serenity.Abstractions.IAuthorizationService
Serenity.Abstractions.IPermissionService
Serenity.Abstractions.IUserRetrieveService
AsSerenitydoesn'thavedefaultimplementationfortheseabstractions,youshouldprovide
someimplementationforthem,usingdependencyregistrationsystem.
Forexample,SerenityBasicApplicationsampleregisterstheminits
SiteInitialization.ApplicationStartmethodlikebelow:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<IAuthorizationService>(
newAdministration.AuthorizationService());
registrar.RegisterInstance<IAuthenticationService>(
newAdministration.AuthenticationService());
registrar.RegisterInstance<IPermissionService>(
newAdministration.PermissionService());
registrar.RegisterInstance<IUserRetrieveService>(
newAdministration.UserRetrieveService());
Youmightwanttohavealookatthosesampleimplementationsbeforewritingyourown.
Authentication&Authorization
284

IAuthenticationServiceInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
publicinterfaceIAuthenticationService
{
boolValidate(refstringusername,stringpassword);
}
Thisistheservicethatyouusuallycallfromyourloginpagetocheckifenteredcredentials
arecorrect.Yourimplementationshouldreturntrueifusername/passwordpairmatches.
Adummyauthenticationservicecouldbewrittenlikethis:
publicclassDummyAuthenticationService:IAuthenticationService
{
publicboolValidate(refstringusername,stringpassword)
{
returnusername==password;
}
}
Thisservicereturnstrue,ifusernameisequaltospecifiedpassword(justfordemo).
Firstparameterispassedbyreferenceforyoutochangeusernametoitsactual
representationindatabasebeforeloggingin.Forexample,theusermighthaveentered
uppercase JOEinloginform,butactualusernameindatabasecouldbe Joe.Thisisnota
requirement,butifyourdatabaseiscasesensitive,youmighthaveproblemsduringloginor
laterphases.
Youmightregisterthisservicefromglobal.asax.cs/SiteInitialization.ApplicationStartlike:
protectedvoidApplication_Start(objectsender,EventArgse)
{
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance(newDummyAuthenticationService());
}
Anduseitsomewhereinyourloginform:
IAuthenticationServiceInterface
285

voidDoLogin(stringusername,stringpassword)
{
if(Dependency.Resolve<IAuthenticationService>()
.Validate(refusername,password))
{
//FormsAuthentication.SetAuthenticationTicketetc.
}
}
IAuthenticationServiceInterface
286

IAuthorizationServiceInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
ThisistheinterfacethatSerenitychecksthroughtoseeifthereisaloggeduserincurrent
request.
publicinterfaceIAuthorizationService
{
boolIsLoggedIn{get;}
stringUsername{get;}
}
Somebasicimplementationforwebapplicationscouldbe:
usingSerenity;
usingSerenity.Abstractions;
publicclassMyAuthorizationService:IAuthorizationService
{
publicboolIsLoggedIn
{
get{return!string.IsNullOrEmpty(Username);}
}
publicstringUsername
{
get{returnWebSecurityHelper.HttpContextUsername;}
}
}
///...
voidApplication_Start(objectsender,EventArgse)
{
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance(newMyAuthorizationService());
}
IAuthorizationServiceInterface
287

IPermissionServiceInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
Apermissionissomegranttodosomeaction(enteringapage,callingacertainservice).In
Serenitypermissionsaresomestringkeysthatareassignedtoindividualusers(similarto
ASP.NETroles).
Forexample,ifwesaysomeuserhas Administrationpermission,thisusercansee
navigationlinksthatrequiresthispermissionorcallservicesthatrequirethesame.
Youcanalsousecompositepermissionkeyslike ApplicationID:PermissionID(for
example Orders:Create),butSerenitydoesn'tcareaboutapplicationID,nor
permissionID,itonlyusesthecompositepermissionkeyasawhole.
publicinterfaceIPermissionService
{
boolHasPermission(stringpermission);
}
Youmighthaveatablelike...
CREATETABLEUserPermissions(
UserIDint,
Permissionnvarchar(20)
}
andqueryonthistabletoimplementthisinterface.
Asimplersampleforapplicationswherethereisa adminuserwhoistheonlyonethathas
thepermission Administrationcouldbe:
IPermissionServiceInterface
288

usingSerenity;
usingSerenity.Abstractions;
publicclassDummyPermissionService:IPermissionService
{
publicboolHasPermission(stringpermission)
{
if(Authorization.Username=="admin")
returntrue;
if(permission=="Administration")
returnfalse;
returntrue;
}
}
IPermissionServiceInterface
289

IUserDefinitionInterface
[namespace:Serenity,assembly:Serenity.Core]
Mostapplicationsstoresomecommoninformationaboutauser,likeID,displayname(nick/
fullname),emailaddressetc.Serenityprovidesabasicinterfacetoaccessthisinformationin
anapplicationindependentmanner.
publicinterfaceIUserDefinition
{
stringId{get;}
stringUsername{get;}
stringDisplayName{get;}
stringEmail{get;}
Int16IsActive{get;}
}
Yourapplicationshouldprovideaclassthatimplementsthisinterface,butnotallofthis
informationisrequiredbySerenityitself.Id,UsernameandIsActivepropertiesareminimum
required.
Idcanbeaninteger,stringorGUIDthatuniquelyidentifiesauser.
Usernameshouldbeauniqueusername,butyoucanusee-mailaddressesasusername
too.
IsActiveshouldreturn1foractiveusers,-1fordeletedusers(ifyoudon'tdeleteusers
fromdatabase),and0fortemporarilydisabled(accountlocked)users.
DisplayNameand EmailareoptionalandcurrentlynotusedbySerenityitself,thoughyour
applicationmayrequirethem.
IUserDefinitionInterface
290

IUserRetrieveServiceInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
WhenSerenityneedstoaccessIUserDefinitionobjectforagivenusernameoruserID,it
usesthisinterface.
publicinterfaceIUserRetrieveService
{
IUserDefinitionById(stringid);
IUserDefinitionByUsername(stringusername);
}
Inyourimplementation,itisagoodideatocacheuserdefinitionobjects,asacommonWEB
applicationmightusethisinterfacerepeatedlyforsameuser.
SerenityBasicApplicationsamplehasanimplementationlikebelow:
IUserRetrieveServiceInterface
291

publicclassUserRetrieveService:IUserRetrieveService
{
privatestaticMyRow.RowFieldsfld{get{returnMyRow.Fields;}}
privateUserDefinitionGetFirst(IDbConnectionconnection,BaseCriteriacriteria)
{
varuser=connection.TrySingle<Entities.UserRow>(criteria);
if(user!=null)
returnnewUserDefinition
{
UserId=user.UserId.Value,
Username=user.Username,
//...
};
returnnull;
}
publicIUserDefinitionById(stringid)
{
if(id.IsEmptyOrNull())
returnnull;
returnTwoLevelCache.Get<UserDefinition>("UserByID_"+id,CacheExpiration.Nev
er,CacheExpiration.OneDay,fld.GenerationKey,()=>
{
using(varconnection=SqlConnections.NewByKey("Default"))
returnGetFirst(connection,
newCriteria(fld.UserId)==id.TryParseID32().Value);
});
}
publicIUserDefinitionByUsername(stringusername)
{
if(username.IsEmptyOrNull())
returnnull;
returnTwoLevelCache.Get<UserDefinition>("UserByName_"+username,CacheExpira
tion.Never,CacheExpiration.OneDay,fld.GenerationKey,()=>
{
using(varconnection=SqlConnections.NewByKey("Default"))
returnGetFirst(connection,newCriteria(fld.Username)==username);
});
}
}
IUserRetrieveServiceInterface
292

AuthorizationStaticClass
[namespace:Serenity,assembly:Serenity.Core]
Authorizationclassprovidessomeshortcutstoinformationwhichisprovidedbyserviceslike
IAuthorizationService,IPermissionServiceetc.
Forexample,insteadofwriting
Dependency.Resolve<IAuthorizationService>().HasPermission("SomePermission")
youcoulduse
Authorization.HasPermission("SomePermission")
publicstaticclassAuthorization
{
publicstaticboolIsLoggedIn{get;}
publicstaticIUserDefinitionUserDefinition{get;}
publicstaticstringUserId{get;}
publicstaticstringUsername{get;}
publicstaticboolHasPermission(stringpermission);
publicstaticvoidValidateLoggedIn();
publicstaticvoidValidatePermission(stringpermission);
}
IsLoggedIn, UserDefinition, UserId, Usernameand HasPermissionmakeuseof
correspondingserviceforyoutoaccessinformationeasieraboutcurrentuser.
ValidateLoggedInchecksifthereisaloggeduserandifnot,throwsa ValidationException
witherrorcode NotLoggedIn.
ValidatePermissionchecksifloggeduserhasspecifiedpermissionandthrowsa
ValidationExceptionwitherrorcode AccessDeniedotherwise.
AuthorizationStaticClass
293

ConfigurationSystem
.NETapplicationsusuallystorestheirconfigurationinapp.config(desktop)orweb.config
(web)files.
Though,itscommonpracticetostoreconfigurationinsuchfilesforwebapplications,
sometimesitmightberequiredtostoresomeconfigurationinadatabasetabletomakeit
availabletoallserversinawebfarm,andsetitfromonelocation.
JustlikeIsolatedStoragehasscopeslikeApplication,Machine,Useretc,aconfiguration
settingmighthavedifferentscopes:
Application-Sharedbetweenallserversthatrunsawebapplication
Server-Appliestocurrentserveronly
User-Appliestocurrentuseronly
Numberofsamplescanbeincreased.
Ifyouhavejustoneserver,ApplicationandServerscopescanbestoredinweb.configfile,
butinawebfarmsetup,Applicationsettingsshouldbestoredinalocationaccessiblefrom
allservers(databaseorsharedfolder).
UsersettingsareusuallystoredindatabasealongwithaUserID.
Serenityprovidesanextensibleconfigurationsystem.
ConfigurationSystem
294

DefiningConfigurationSettings
InSerenityplatform,configurationsettingsarejustsimpleclasseslike:
[SettingScope("Application"),SettingKey("Logging")]
privateclassLogSettings
{
publicLoggingLevelLevel{get;set;}
publicstringFile{get;set;}
publicintFlushTimeout{get;set;}
}
Ifrequired,defaultsettingscanbesetintheclassconstructor.
SettingScopeAttribute
Ifspecified,thisattributedeterminesthescopeofsettings.
Ifnotspecified,defaultscopeisApplication.
SettingKeyAttribute
Ifspecified,thisattributedeterminesakeyforsettings(e.g.appSettingskeyforweb.config)
class.
Ifnotspecified,classnameisusedasthekey.
DefiningConfigurationSettings
295

IConfigurationRepositoryInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
Allapplicationshavesomekindofconfiguration.Scope,storagemediumandformatfor
thesesettingsaredifferentfromapplicationtoapplication,soSerenityprovides
IConfigurationRepositoryinterfacetoabstractaccesstothisconfiguration.
publicinterfaceIConfigurationRepository
{
objectLoad(TypesettingType);
voidSave(TypesettingType,objectvalue);
}
IConfigurationRepository.LoadMethod
ThismethodreturnsaninstanceofsettingType.ProvidershouldcheckSettingKeyattribute
todeterminekeyforthesettingtype.
Ifsameproviderisregisteredformultiplescopes,providershouldalsocheckfor
SettingScopeattribute.
Providershouldreturnanobjectinstance,evenifsettingisnotfound(anobjectcreatedwith
settingType'sdefaultconstructor).
IConfigurationRepository.SaveMethod
SavesaninstanceofsettingType.ProvidershouldcheckSettingKeyattributetodetermine
keyforthesettingtype.
Ifsameproviderisregisteredformultiplescopes,providershouldalsocheckfor
SettingScopeattribute.
Thismethodisoptionaltoimplement,asyoumaynotwantsettingstobechanged.Inthis
case,justthrowaNotImplementedException.
IConfigurationRepositoryInterface
296

AppSettingsJsonConfigRepository
[namespace:Serenity.Configuration,assembly:Serenity.Data]
Mostwebapplicationsstoreconfigurationsettingsinweb.configfile,underappSettings
section.
SerenityprovidesadefaultimplementationofIConfigurationRepositorythatuses
appSettingsasconfigurationstore.
publicclassAppSettingsJsonConfigRepository:IConfigurationRepository
{
publicvoidSave(TypesettingType,objectvalue)
{
thrownewNotImplementedException();
}
publicobjectLoad(TypesettingType)
{
returnLocalCache.Get("ApplicationSetting:"+settingType.FullName,
TimeSpan.Zero,delegate()
{
varkeyAttr=settingType.GetCustomAttribute<SettingKeyAttribute>();
varkey=keyAttr==null?settingType.Name:keyAttr.Value;
returnJSON.Parse(ConfigurationManager.AppSettings[key].TrimToNull()??
"{}",settingType);
});
}
}
Toregisterthisprovidermanually:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
RegisterInstance<IConfigurationRepository>("Application",
newAppSettingsJsonConfigRepository())
WhenyoucallSerenity.Web.CommonInitialization.Run(),itregistersthisclassasthe
defaultproviderforIConfigurationRepository(inApplicationscope),ifanotheroneisnot
alreadyregistered.
Thisproviderexpectssettingstobedefinedinweb.config/app.configfileinJSONformat:
AppSettingsJsonConfigRepository
297

<appSettings>
<addkey="Logging"value="{File:'~\\App_Data\\Log\\App_{0}_{1}.log',
FlushTimeout:0,Level:'Debug'}"/>
</appSettings>
Outofthebox,Serenitycontainsthisconfigurationprovideronly.Youmaytakeitasa
sample,andwriteanotheroneforyoursetup(loadfromdatabaseetc.).
Itisagoodideatocachereturnedobjectsinyourimplementationtoavoiddeserialization
costseverytimesettingsareread.
AppSettingsJsonConfigRepository
298

ConfigStaticClass
[namespace:Serenity,assembly:Serenity.Core]
Thisisthecentrallocationtoaccessyourconfigurationsettings.Itcontainsshortcut
methodstoregisteredIConfigurationRepositoryprovider.
publicstaticclassConfig
{
publicstaticobjectGet(TypesettingType);
publicstaticTSettingsGet<TSettings>()whereTSettings:class,new();
publicstaticobjectTryGet(TypesettingType);
publicstaticTSettingsTryGet<TSettings>()whereTSettings:class,new();
}
Config.GetMethod
Usedtoreadconfigurationsettingsforspecifiedtype.
Ifnoproviderisregisteredforsettingtype'sscope,aKeyNotFoundExceptionisraised.
Ifsettingisnotfound,providersusuallyreturnadefaultinstance.
Prefergenericoverloadtoavoidhavingtocastthereturnedobject.
if(Config.Get<LogSettings>().LoggingLevel!=LogginLevel.Off)
{
//..
}
Config.TryGetMethod
Usedtoreadconfigurationsettingsforspecifiedtype.
FunctionallyequivalenttoGet,butwhileitthrowsanexceptionifnoconfigurationprovideris
registeredforthesettingscope,TryGetreturnsnull.
ConfigStaticClass
299

if((Config.TryGet<LogSettings>()??newLogSettings()).LoggingLevel!=LogginLevel.Of
f)
{
//..
}
PreferthismethodoverGetonlytoavoidexceptionswhenconfigurationsystemisnot
initializedyet.
Getworksonsafe-side,andistherecommendedmethodtouse.
ConfigStaticClass
300

Localization
Mostwebapplicationsmustsupportavarietyoflanguages.SiteslikeYoutube,Wikipedia,
Facebookworksinmanylanguages.
Firsttimeauservisitssuchasite,alanguageforherisautomaticallychosendependingon
thebrowserlanguage(pre-determinedbyregionalsettings).
Ifautomaticselectionisnotwhattheyexpect,userscansettheirpreferredlanguageand
thisselectionisstoredinaclient-sidecookie(orserversideuserprofiletable).
Oncealanguageischosen,alltextsaredisplayedintheselectedlanguage.
Serenityplatformisdesignedwithlocalizationinmindfromthestart.
IfyouareusingSerenityBasicApplicationSampleyoucanseethisyourselfbysettingyour
browserlanguageorchangingaweb.configsetting:
<system.web>
<globalizationculture="en-US"uiCulture="auto:en-US"/>
</system.web>
Here,UIcultureissettoautomatic,andifautomaticdetectionfails,en-USisusedasa
fallback.
Localization
301

Changethisconfigurationasbelow,refreshyourbrowserandyouwillthesiteinTurkish:
<system.web>
<globalizationculture="en-US"uiCulture="tr"/>
</system.web>
Localization
302

Here,dataisnottranslatedbutitisalsopossibletotranslateuserentereddataby
somemethodslikecultureextensiontables.
Localization
303

LocalTextClass
[namespace:Serenity,assembly:Serenity.Core]
AtthecoreofstringlocalizationisLocalTextclass.
publicclassLocalText
{
publicLocalText(stringkey);
publicstringKey{get;}
publicoverridestringToString();
publicstaticimplicitoperatorstring(LocalTextlocalText);
publicstaticimplicitoperatorLocalText(stringkey);
publicstaticstringGet(stringkey);
publicstaticstringTryGet(stringkey);
publicconststringInvariantLanguageID="";
publicstaticreadonlyLocalTextEmpty;
}
Itsconstructortakesakeyparameter,whichdefinesthelocaltextkeythatitwillcontain.
Someofsamplekeysare:
Enums.Month.January
Enums.Month.December
Db.Northwind.Customer.CustomerName
Dialogs.YesButton
Thoughitisnotarule,itisagoodideatofollowthisnamespacelikedotconventionfor
localtextkeys.
Atruntime,throughToString()function,thelocaltextkeyistranslatedtoitsrepresentationin
theactivelanguage(whichisCultureInfo.CurrentUICulture).
vartext=newLocalText("Dialogs.YesButton");
Console.WriteLine(text.ToString());
>Yes
Ifatranslationisnotfoundinlocaltexttable(wewilltalkaboutthislater),thekeyitselfis
returned.
LocalTextClass
304

vartext=newLocalText("Unknown.Local.Text.Key");
Console.WriteLine(text.ToString());
>Unknown.Local.Text.Key
Thisisbydesign,sothatdevelopercandeterminewhichtranslationsaremissing.
LocalText.KeyProperty
GetsthelocaltextkeythatLocalTextinstancecontains.
ImplicitConversionsFromString
LocalTexthasimplicitconversionfromStringtype.
LocalTextsomeText="Dialogs.YesButton";
HeresomeTextvariablereferencesanewLocalTextinstancewiththekey
Dialogs.YesButton.SoitisjustashortcuttoLocalTextconstructor.
ImplicitConversionsToString
LocalTexthasimplicitconversiontoStringtypetoo,butitreturnstranslationinsteadofthe
key(justlikecallingToString()method):
varlt=newLocalText("Dialogs.NoButton");
stringtext=lt;
Console.WriteLine(text);
>No
LocalText.GetStaticMethod
ToaccessthetranslationforalocaltextkeywithoutcreatingaLocalTextinstance,useGet
method:
LocalTextClass
305

Console.WriteLine(LocalText.Get("Dialogs.YesButton"));
>Yes
ToString()methodinternallycallsGet
LocalText.TryGetStaticMethod
UnlikeGetmethodwhichreturnsthelocaltextkeyifnotranslationisfound,TryGetreturns
null.Thus,coalesceoperatorcanbeusedalongwithTryGetwhererequired:
vartranslation=LocalText.TryGet("Looking.For.This.Key")??"DefaultText";
Console.WriteLine(translation);
>DefaultText
LocalText.EmptyField
SimilartoString.Empty,LocalTextcontainsanemptylocaltextobjectwithemptykey.
LocalText.InvariantLanguageIDConstant
ThisisjustanemptystringforinvariantlanguageIDwhichistheinvariantculturelanguage
identifier(defaultlanguage,usuallyEnglish).
Wewilltalkaboutlanguageidentifiersinthefollowingsection.
LocalTextClass
306

LanguageIdentifiers
AlanguageIDisacodethatassignslettersand/ornumbersasidentifiersorclassifiersfor
languages.
LanguageIDsfollowtheRFC1766standardintheformat <languagecode2>-
<country/regioncode2>,wherelanguagecode2isalowercasetwo-lettercodederivedfrom
ISO639-1andcountry/regioncode2isanuppercasetwo-lettercodederivedfromISO3166.
SomesamplelanguageIDs:
en:English
en-US:EnglishasusedintheUnitedStates(USistheISO3166-1countrycode)
en-GB:EnglishasusedintheUnitedKingdom(GBistheISO3166-1countrycode)
es:Spanish
es-AR:SpanishasusedinArgentina
InvariantLanguage
SimilartoCultureInfo.InvariantCulture,invariantlanguageisthedefaultlanguagewithempty
identifier.
Unlessspecifiedotherwise,embeddedtextsinyourassembliesareconsideredtobewritten
ininvariantlanguage.
ThoughitisusuallyconsideredtobeEnglish,youmayassumeyournaturallanguageasthe
invariantlanguage.
LanguageIdentifiers
307

LanguageFallbacks
NeutralLanguageFallback
Whenatranslationisnotfoundin en-US,itisacceptabletolookforatranslationin en
language,astheyarecloselyrelated.
TwoletterlanguageIDs(neutrallanguages)areimplicitlylanguagefallbacksof4letter
countryspecificcodes.
So esislanguagefallbackof es-ARand enislanguagefallbackof en-USand en-GB.
InvariantLanguageFallback
Invariantlanguagewithemptycodeisthefinalfallbackofalllanguagesimplicitly.
Implementation
LanguagefallbackfunctionalityshouldbeimplementedbytheILocalTextRegistryprovider
(e.g.LocalTextRegistryclass).
Providersmayalsosupportsettinglanguagefallbacksexplicitly,soyoucanset en-USas
languagefallbackof en-UKifneeded.
Thisishowlookingupatranslationforalocaltextkeyworks:
Ifcurrentlanguagehasatranslationforthekey,returnit.
Checkeveryexplicitlydefinedlanguagefallbackforatranslation.
IflanguageIDisa4lettercountryspecificcode,checkneutrallanguagefora
translation.
Checkinvariantlanguageforatranslation.
ReturnthekeyitselfornullforTryGet.
Let'ssayweset en-USaslanguagefallbackof en-UK.
Ifwesearchforatranslationin en-UK,itislookedupinthisorder:
1. en-UK
2. en-US
3. en
LanguageFallbacks
308

4. invariant
LanguageFallbacks
309

ILocalTextRegistryInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
LocalTextclassaccessestranslationsforlocaltextkeysthroughtheproviderforthis
interface.
publicinterfaceILocalTextRegistry
{
stringTryGet(stringlanguageID,stringkey);
voidAdd(stringlanguageID,stringkey,stringtext);
}
ILocalTextRegistry.TryGetMethod
Getstranslationforthespecifiedkeyinrequestedlanguage.
CurrentlanguageisdeterminedbyCultureInfo.CurrentUICulture.
Itisprovidersresponsibilitytochecklanguagefallbacksforthekey,ifatranslationisnot
foundinrequestedlanguage.
Thismethodreturnsnullifnotranslationisfoundinthelanguagehierarchy(fromrequested
languagedowntoinvariantlanguage).
ILocalTextRegistry.AddMethod
Addsatranslationtothelocaltexttablewhichisinternallyholdbythelocaltextregistry.
Thelocaltexttableisanin-memorytable(dictionary)like:
Key LanguageID Text(Translation)
Dialogs.YesButton en Yes
Dialogs.YesButton tr Evet
Dialogs.NoButton en No
Dialogs.NoButton tr Hayır
Thismethoddoesn'tthrowanexceptionifsamekey/languageIDpairisaddedtwice.It
simplyoverridesexistingtranslation.
ILocalTextRegistryInterface
310

ILocalTextRegistryInterface
311

LocalTextRegistryClass
[namespace:Serenity.Localization,assembly:Serenity.Core]
Thisclassistheembedded,defaultimplementationofILocalTextRegistryinterface.
publicclassLocalTextRegistry:ILocalTextRegistry
{
publicvoidAdd(stringlanguageID,stringkey,stringtext);
publicstringTryGet(stringlanguageID,stringkey);
publicvoidSetLanguageFallback(stringlanguageID,stringlanguageFallbackID);
publicvoidAddPending(stringlanguageID,stringkey,stringtext);
publicstringTryGet(stringlanguageID,stringtextKey,boolisApprovalMode);
publicDictionary<string,string>GetAllAvailableTextsInLanguage(
stringlanguageID,boolpending);
}
AddandTryGetimplementscorrespondingmethodsinILocalTextRegistryinterface.
LocalTextRegistry.SetLanguageFallback
Method
Setslanguagefallbackforspecifiedlanguage.
varregistry=(LocalTextRegistry)(Dependency.Resolve<ILocalTextRegistry>());
registry.SetLanguageFallback('en-UK','en-US');
//fromnowonifatranslationisnotfoundin"en-UK"language,
//itwillbelookedupin"en-US"languagefirst,followedby"en".
Moreinformationaboutlanguagefallbackscanbefoundinrelevantsection.
RegisteringLocalTextRegistryasProvider
Thisisusuallydoneinyourapplicationstartmethod:
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<ILocalTextRegistry>(newLocalTextRegistry());
LocalTextRegistryClass
312

CommonInitialization.RunorCommonInitialization.InitializeLocalTextsmethodsalso
registeraLocalTextRegistryinstanceastheILocalTextRegistryprovider,ifnoneis
alreadyregistered.
LocalTextRegistryClass
313

PendingApprovalMode
LocalTextRegistryalsosupportsanoptionalpendingapprovalmode.
Insomesites,translationsmightbeneededtobeapprovedbysomemoderatorsbeforethey
arepublished.
Soyoumayaddtheseunapprovedtextstoyourlocaltextregistrybutwantthemtobe
shownonlytomoderatorsforthemtocheckhowtheywilllookinlivesitewhenapproved.
LocalTextRegistryallowsyoutomarksometextsaspendingapproval,andusethese
translationsforonlyapprovalcontexts(e.g.whenamoderatorisloggedin).
ILocalTextContextInterface
[namespace:Serenity.Localization,assembly:Serenity.Core]
publicinterfaceILocalTextContext
{
boolIsApprovalMode{get;}
}
Implementthisinterfaceandregisteritthroughtheservicelocator(Dependencyclass).
IsApprovalModepropertyisusedtodetermineifcurrentcontextisinapprovalmode(e.g.
usedbyamoderator).
PendingApprovalMode
314

publicclassMyLocalTextContext:ILocalTextContext
{
publicboolIsApprovalMode
{
get
{
//usesomemethodtodetermineifcurrentuserisamoderator
returnAuthorization.HasPermission("Moderation");
}
}
}
voidApplicationStart()
{
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance<ILocalTextContext>(newMyLocalTextContext());
}
LocalTextRegistry.AddPendingMethod
Addsatranslationtolocaltexttableforpendingapprovaltexts.Thesetextsareonlyused
whencurrentcontextisinpendingapprovalmode.
LocalTextRegistry.TryGetOverloadWith
LanguageandPendingArguments
publicstringTryGet(stringlanguageID,stringtextKey,boolisApprovalMode);
Thisoverloadletsyoutogetatranslationinspecifiedlanguageandoptionallyusing
unapprovedtexts(isApprovalMode=true).
OtherTryGetoverloadreturnsunapprovedtextsonlywhenILocalTextContextprovider
returnstrueforIsApprovalModeproperty.
LocalTextRegistry.GetAllAvailableTextsInLangu
ageMethod
Returnsadictionaryofallcurrentlyregisteredtranslationsforalltextkeysinalanguage.
Dictionarykeysarelocaltextkeys,whilevaluesaretranslations.
PendingApprovalMode
315

Italsocontainstextsfoundfromlanguagefallbacksforifatranslationisnotavailablein
requestedlanguage.
PendingApprovalMode
316

RegisteringTranslations
Thereareseveralwaystodefinelocaltextkeysandtranslations,including:
ManuallythroughILocalTextRegistry.AddMethod
Declaringnestedstaticclassescontaininglocaltextobjects
AddingDescriptionattributetoenumerationclasses
JSONfilesinpredeterminedlocations(~/scripts/serenity/texts,~/scripts/site/textsand
~/App_Data/texts)
We'lltalkaboutallthesemethods.
RegisteringTranslations
317

ManuallyRegisteringTranslations
Youcanaddtranslationstolocaltextregistryfromyourapplicationstartmethod.
Sourcesforthesetranslationsmightbeadatabasetable,xmlfile,embeddedresourcesetc.
voidApplication_Start()
{
//...
varregistry=Dependency.Resolve<ILocalTextRegistry>();
registry.Add("es","Dialogs.YesButton","Sí");
registry.Add("fr","Dialogs.YesButton","Oui");
//..
}
ManuallyRegisteringTranslations
318

NestedLocalTexts
SerenityallowsyoutodefinenestedstaticclassescontainingLocalTextobjectstodefine
translationslikebelow:
[NestedLocalTexts]
publicstaticpartialclassTexts
{
publicstaticclassSite
{
publicstaticclassDashboard
{
publicstaticLocalTextWelcomeMessage=
"WelcometoSerenityBasicApplicationhomepage."+
"Usethenavigationonlefttobrowseotherpages...";
}
}
publicstaticclassValidation
{
publicstaticLocalTextDeleteForeignKeyError=
"Can'tdeleterecord.'{0}'tablehasrecordsthatdependsonthisone!";
publicstaticLocalTextSavePrimaryKeyError=
"Can'tsaverecord.Thereisanotherrecordwiththesame{1}value!";
}
}
Thisdefinitionsallowyoutoreferencelocalizedtextswithintellisensesupport,without
havingtomemorizestringkeys.
Theseembeddedtranslationdefinitionsarecommonlyusedtodefinedefaulttranslationsin
invariantlanguage(ultimatefallbacks).
HereisatableoftranslationsthataredefinedwiththisTextsclass:
Key LanguageID Text(Translation)
Site.Dashboard.WelcomeMessage WelcometoSerenityBasicApp...
Validation.DeleteForeignKeyError Can'tdeleterecord...
Validation.SavePrimaryKeyError Can'tsaverecord...
Localtextkeysaregeneratedfromnestedstaticclassnameswithadotinsertedbetween.
Topmoststaticclass(Texts)nameisignoredthoughitisagoodideatonameitsomething
likeTextsforconsistency.
NestedLocalTexts
319

Unlessotherwisestated,languageIDforthesetextsareconsideredtobetheinvariant
language(emptystring).
NestedLocalTextsAttribute
Topmostclass(e.g.Texts)fornestedlocaltextregistrationclassesmusthavethisattribute.
[AttributeUsage(AttributeTargets.Class,AllowMultiple=false)]
publicsealedclassNestedLocalTextsAttribute:Attribute
{
publicNestedLocalTextsAttribute()
{
}
publicstringLanguageID{get;set;}
publicstringPrefix{get;set;}
}
Ithastwooptionalattributes,LanguageIDandPrefix.
LanguageIDallowsyoutodefinewhatlanguagetranslationsarein.
Ifnotspecified,translationsareconsideredtobeintheinvariantlanguage.
Itisagoodideatoregisterdefaulttextsininvariantlanguage,eveniftextsarenotin
English,asinvariantlanguageistheeventuallanguagefallbackforalllanguages.
Ifweuseditlike:
[NestedLocalTexts(LanguageID="en-US")]
publicstaticpartialclassTexts
{
//..
}
LanguageIDcolumnintranslationstablewouldbe"en-US":
Key LanguageID Text(Translation)
Site.Dashboard.WelcomeMessage en-US WelcometoSerenityBasicApp...
Validation.DeleteForeignKeyError en-US Can'tdeleterecord...
Prefixattributevalueisusedasaprefixforlocaltextkeys:
NestedLocalTexts
320

[NestedLocalTexts(LanguageID="en-US",Prefix="APrefix.")]
publicstaticpartialclassTexts
{
//..
}
Key LanguageID Text(Translation)
APrefix.Site.Dashboard.WelcomeMessage en-US WelcometoSerenity
BasicApp...
APrefix.Validation.DeleteForeignKeyError en-US Can'tdeleterecord...
NestedLocalTextRegistrationClass
[namespace:Serenity.Localization,assembly:Serenity.Core]
Fornestedlocaltextdefinitionstoberegistered,youneedtocall
NestedLocalTextRegistration.Initialize()methodinyourapplicationstart:
voidApplication_Start()
{
NestedLocalTextRegistration.Initialize();
}
CommonInitialization.RunandCommonInitialization.InitializeLocalTextsmethodscallit
bydefault.
Onceitisrun,alltranslationswithautogeneratedkeysareaddedtocurrent
ILocalTextRegistryproviderandLocalTextinstancesinnestedstaticclassesarereplaced
withactualLocalTextinstancescontaininggeneratedkeys(theyaresetthroughreflection).
NestedLocalTexts
321

EnumerationTexts
DisplaytextforenumerationvaluescanbespecifiedwithDescriptionattribute.
namespaceMyApplication
{
publicenumSample
{
[Description("FirstValue")]
Value1=1,
[Description("SecondValue")]
Value2=2
}
}
ThisenumerationanditsDescriptionattributesdefinesfollowinglocaltextkeysand
translations:
Key LanguageID Text(Translation)
Enums.MyApplication.Sample.Value1 FirstValue
Enums.MyApplication.Sample.Value2 SecondValue
AlltextsaredefinedforinvariantlanguageIDbydefault.
Youcanusethesekeystoaccesstranslateddescriptionsforenumerationvalues,oruse
extensionmethodGetText()definedforenumerationtypes(importnamespaceSerenityto
makethisextensionmethodavailable).
usingSerenity;
//...
Console.WriteLine(MyApplication.Sample.Value1.GetText());
>FirstValue
EnumKeyAttribute
Enumerationtranslationsusefullnameofenumerationtypeasprefixtogeneratelocaltext
keys.ThisprefixcanbeoverridenwithEnumKeyAttribute:
EnumerationTexts
322

namespaceMyApplication
{
[EnumKey("Something")]
publicenumSample
{
[Description("FirstValue")]
Value1=1,
[Description("SecondValue")]
Value2=2
}
}
Nowdefinedkeysandtranslationsare:
Key LanguageID Text(Translation)
Enums.Something.Value1 FirstValue
Enums.Something.Value2 SecondValue
EnumLocalTextRegistrationClass
[namespace:Serenity.Localization,assembly:Serenity.Core]
Forenumerationlocaltextdefinitionstoberegistered,youneedtocall
EnumLocalTextRegistration.Initialize()methodinyourapplicationstart:
voidApplication_Start()
{
EnumLocalTextRegistration.Initialize(ExtensibilityHelper.SelfAssemblies);
}
Itgetslistofassembliestosearchforenumerationtypes.Youcanpasslistofassemblies
manuallyoruseExtensibilityHelper.SelfAssemblieswhichcontainsallassembliesthat
referenceaSerenityassembly.
CommonInitialization.RunandCommonInitialization.InitializeLocalTextsmethodscallit
bydefault.
EnumerationTexts
323

JSONLocalTexts
SerenitysupportslocaltextregistrationthroughJSONfilescontainingasimplekey/value
dictionary:
{
"Forms.Administration.User.DisplayName":"DisplayName",
"Forms.Administration.User.Email":"E-mail",
"Forms.Administration.User.EntitySingular":"User",
"Forms.Administration.User.EntityPlural":"Users"
}
ToregisteralllocaltextkeysandtranslationsfromJSONfilesinafolder,call
JsonLocalTextRegistration.AddFromFilesInFolderwiththepath:
JsonLocalTextRegistration.AddFromFilesInFolder(@"C:\SomeFolder");
Filenamesinthefoldermustfollowaconvention:
{SomePrefixYouChoose}.{LanguageID}.json
where {LanguageID}istwoorfourletterlanguagecode.Useinvariantaslanguagecodefor
invariantlanguage.
Somesamplefilenamesare:
site.texts.en-US.json
MyCoolTexts.es.json
user.texts.invariant.json
Filesinafolderareparsedandaddedtoregistryintheirfilenameorder.Thusforsamplefile
namesabove,orderwouldbe:
1. MyCoolTexts.es.json
2. site.texts.en-US.json
3. user.texts.invariant.json
Thisorderisimportantasaddingatranslationinsomelanguagewithsamekey
overridespriortranslation.
JSONLocalTexts
324

CommonInitializationandPredetermined
Folders
CommonInitialization.RunandCommonInitialization.InitializeLocalTextscallsthismethodfor
threepredeterminedlocationsunderyourwebsite:
1. ~/Scripts/serenity/texts(serenitytranslations)
2. ~/Scripts/site/texts(yourapplicationspecifictranslations)
3. ~/App_Data/texts(usertranslationsmadethroughtranslationinterface)
PreferusingsecondoneforyourownfilesasfirstoneisforSerenityresources.
Thirdonecontainsusertranslatedtexts.Itisrecommendedtotransfertextsfromthesefiles
toapplicationtranslationfilesunder ~/Scripts/site/textsbeforepublishing.
JSONLocalTexts
325

LocalCaching
Serenityprovidessomecachingabstractionsandutilityfunctionstomakeiteasiertowork
withlocalcache.
Thetermlocalmeansthatcacheditemsareholdinlocalmemory(thusthereisno
serializationinvolved).
Whenyourapplicationisdeployedonawebfarm,localcachingmightnotbeenoughor
sometimessuitable.WewilltalkaboutthisscenarioinDistributedCachingsection.
LocalCaching
327

ILocalCacheInterface
[namespace:Serenity.Abstrations]-[assembly:Serenity.Core]
Definesabasicinterfacetoworkwiththelocalcache.
publicinterfaceILocalCache
{
voidAdd(stringkey,objectvalue,TimeSpanexpiration);
TItemGet<TItem>(stringkey);
objectRemove(stringkey);
voidRemoveAll();
}
AdefaultimplementationofILocalCache( Serenity.Caching.HttpRuntimeCache)that
uses System.Web.Cacheexistsin Serenity.Webassembly.
ILocalCache.AddMethod
Addsavaluetocachewiththespecifiedkey.Ifthekeyalreadyexistsincache,itsvalueis
updated.
Itemsareholdincachefor expirationduration.Youcanspecify TimeSpan.Zeroforitems
thatshouldn'texpireautomatically.
Valuesareaddedtocachewithabsoluteexpiration(thustheyexpireatacertaintime,not
slidingexpiration).
Dependency.Resolve<ILocalCache>.Add("someKey","someValue",TimeSpan.FromMinutes(5));
Thismethod,initsdefaultimplementation,usesHttpRuntime.Cache.Insertmethod.
AvoidHttpRuntime.Cache.Addmethod,asitdoesn'tupdatevalueifthereisalreadya
keywithsamekeyinthecache,anditdoesn'tevenraiseanerrorsoyouwon'tnotice
anything.AmereengineeringgemfromASP.NET)
ILocalCache.Get <TItem>Method
Getsthevaluecorrespondingtothespecifiedkeyinlocalcache.
Ifthereisnosuchkeyincache,anerrormayberaisedonlyifTItemisofvaluetype.For
referencetypesreturnedvalueis null.
ILocalCacheInterface
328

Ifvalueisnotoftype TItem,anexceptionisthrown.
Youmayuse objectas TItemparametertopreventerrorsincaseavaluedoesn'texist,
ornotofrequestedtype.
ILocalCache.RemoveMethod
Removestheitemwithspecifiedkeyfromlocalcacheandreturnsitsvalue.
Noerrorsthrownifthereisnovalueincachewiththespecifiedkey,simply nullis
returned.
Dependency.Resolve<ILocalCache>.Remove("someKey");
ILocalCache.RemoveAllMethod
Removesallitemsfromlocalcache.Avoidusingthisexceptforspecialsituationslikeunit
tests,otherwiseperformancemightsuffer.
ILocalCacheInterface
329

LocalCacheStaticClass
[namespace:Serenity]-[assembly:Serenity.Core]
AstaticclassthatcontainsshortcutstoworkeasierwiththeregisteredILocalCacheprovider.
publicstaticclassLocalCache
{
publicstaticvoidAdd(stringkey,objectvalue,TimeSpanexpiration);
publicstaticTItemGet<TItem>(stringkey,TimeSpanexpiration,
Func<TItem>loader)whereTItem:class;
publicstaticvoidRemove(stringkey);
publicstaticvoidRemoveAll();
}
Add,Remove,andRemoveAllmethodsaresimplyshortcutstocorrespondingmethodsin
ILocalCacheinterface,butGetmethodisabitdifferentthanILocalCache.Get.
LocalCache.Get <TItem>Method
Getsthevaluecorrespondingtothespecifiedkeyinlocalcache.
Ifthereisnosuchkeyincache,usestheloaderfunctiontoproducevalue,andaddsitto
cachewiththespecifiedkey.
IfthevaluethatexistsincacheisDBNull.Value,thannullisreturned.(Thisway,iffor
exampleauserwithanIDdoesn'texistindatabase,repeatedqueryingofdatabasefor
thatIDisprevented)
LocalCacheStaticClass
330

Ifthevalueexists,butofnottypeTItemanexceptionisthrown,otherwisevalueis
returned.
Ifthevaluedidn'texistincache,loaderfunctioniscalledtoproducethevalue(e.g.from
database)and...
Ifthevalueproducedbyloaderfunctionisnull,itisstoredasDBNull.Valuein
cache.
Otherwisetheproducedvalueisaddedtocachewiththespecifiedexpiration
duration.
LocalCacheStaticClass
331

UserProfileCachingSample
Letsassumewehaveaprofilepageinoursitethatisgeneratedusingseveralqueries.We
mighthaveamodelforthispagee.g.UserProfileclassthatcontainsallprofiledatafora
user,andaGetProfilemethodthatproducesthisforaparticularuserid.
publicclassUserProfile
{
publicstringName{get;set;}
publicList<CachedFriend>Friends{get;set;}
publicList<CachedAlbum>Albums{get;set;}
...
}
publicUserProfileGetProfile(intuserID)
{
using(varconnection=newSqlConnection("..."))
{
//loadprofilebyuserIDfromDB
}
}
BymakinguseofLocalCache.Getmethod,wecouldcachethisinformationforonehour
easilyandavoidDBcallseverytimethisinformationisneeded.
publicUserProfileGetProfile(intuserID)
{
returnLocalCache.Get<UserProfile>(
cacheKey:"UserProfile:"+userID,
expiration:TimeSpan.FromHours(1),
loader:delegate{
using(varconnection=newSqlConnection("..."))
{
//loadprofilebyuserIDfromDB
}
}
);
}
UserProfileCachingSample
332

DistributedCaching
Webapplicationsmightrequiretoservehundreds,thousandsorevenmoreusers
simultaneously.Ifyoudidn'ttakerequiredmeasures,undersuchaload,yoursitemight
crashorbecomeunresponsive.
Let'ssayyouareshowingthelast10newsinyourhomepageandinaminute,inaverage
ofathousandusersarevisitingthispage.Foreverypageviewyoumightbequeryingyour
databasetodisplaythisinformation:
SELECTTOP10Title,NewsDate,Subject,BodyFROMNewsORDERBYNewsDateDESC
Evenifwethinkthatourhomepagecontainsonlythisinformation,asite,thatgets10000
visitsaminutewouldrun150SQLqueriespersecond.
Thesequeries,astheirresultdoesn'tdifferfromusertouser(alwaysthelast10news),
mightbecachedinSQLserversideautomatically.
Butqueryresultsconsumessomevaluablenetworkbandwidthwhilebeingtransferredfrom
SQLservertoyourWEBserver.Asthistransfertakessometime(datasize/bandwidth)
andyourconnectioniskeptopenduringthistime,evenifyourSQLserverresponded
instantly,gettingtheresultswouldn'tbesofast.Thetimetotransfermightvarywiththesize
ofthenewscontent.
AlsoasSQLconnectionswhichcanbekeptopensimultaneouslyhasaupperlimit
(connectionpoollimit)andwhenyoureachthatnumber,theconnectionsstarttowaitinthe
queueandblockeachother.
Bytakingintoaccountthatnewsdon'tchangeeverysecond,wecouldcachetheminour
WEBservermemoryfor5minutes.
ThusassoonaswetransfernewslistfromSQLdatabase,storetheminlocalcache.Forthe
next5minutes,foreveryuserthatvisitsthehomepage,newslistisreadfromlocalcache
instantly,withoutevenhittingSQL:
DistributedCaching
333

publicList<News>GetNews()
{
varnews=HttpRuntime.Cache["News"]asList<News>;
if(news==null)
{
using(varconnection=newSqlConnection("......"))
{
news=connection.Query<News>("
SELECTTOP10Title,NewsDate,Subject,Body
FROMNews
ORDERBYNewsDateDESC")
.ToList();
HttpRuntime.Cache.Insert("News",...,
TimeSpan.FromMinutes(5),....);
}
}
returnnews;
}
Thistakesusfrom150queriesperseconddownto1/300queriespersecond(aqueryper
300sec).
AlsothesenewsitemsshouldbeconvertedtoHTMLforeveryvisitor.Bymovingone
stepfurther,wecouldalsocachetheHTMLconvertedstateofthenews.
AllthesecachedinformationisstoredinWEBservermemorywhichisthefastestlocationto
accessthem.
Notethatcachingsomethingdoesn'talwaysmeanthatyourapplicationwillworkfaster.
Howeffectivelyyouusecacheismoreimportantthancachingalone.Itisevenpossible
toslowdownyourapplicationwithcaching,ifnotusedproperly.
DistributedCaching
334

WEBFarmsandCaching
Nowlet'sconsiderwehaveasocialnetworkingsiteandhavemillionsofuserprofiles.Profile
pagesofsomefamoususersmightbegettinghundredsorthousandsofvisitsperminute.
Togenerateausersprofile,wewouldneedmorethanoneSQLquery(friends,album
namesandpicturecounts,profileinformation,laststatusetc.).
Aslongasauserdidn'tupdateherprofile,theinformationthatisshownonherpagewould
bealmoststastic.Thus,asnapshotofprofilepagescouldbecachedfor5minutesor1hour
etc.
Butthismightnotbeenough.Wearetalkingabouthundresofmillionsofprofilesandusers.
Userswouldbedoingmuchmorethanjustlookingatsomeprofilepages.Wewouldneed
morethanoneserverthataredistributedinseveralgeographicallocationsonearth(aWEB
Farm).
Atacertaintime,alltheseserversmighthavecachedaveryimportantpersons(VIP)profile
inlocalcache.WhentheVIPmakesachangeinherprofile,alltheseserversshouldrenew
theirlocalcachedprofile,andthiswouldhappeninafewseconds.Wenowhaveaproblem
ofloadperserverinsteadofloadperuser.
Actually,onceonetheseofserversloadedtheVIPprofilefromSQLdatabaseandcachedit,
otherserverscouldmakeuseofthesameinformationwithouthittingdatabase.But,aseach
serverstorescachedinformationinitsownlocalmemory,itisnottrivialtoaccessthis
informationbyotherservers.
Ifwehadasharedmemorythatallserverscouldaccess:
Informationkey Value
Profile:VeryFamousOne (CachedinformationforVeryFamousOne)
Profile:SomeAnother ...
... ...
... ...
Profile:JohnDoe ...
Let'scallthismemorythedistributedcache.Ifallservershavealookatthiscommon
memorybeforetryingDBwewouldavoidtheloadperserverproblem.
WEBFarmsandCaching
335

publicCachedProfileInformationGetProfile(stringprofileID)
{
varprofile=HttpRuntime.Cache["Profil:"+profileID]
asCachedProfileInformation;
if(profile==null)
{
profile=DistributedCache.Get<CachedProfileInformation>(
"Profil:"+profileID);
if(profile==null)
{
using(varconnection=newSqlConnection("......"))
{
profile=GetProfileFromDBWithSomeSQLQueries(profileID)
profile,TimeSpan.FromMinutes(5));
DistributedCache.Set("Profil:"+profileID,profile,
TimeSpan.FromHours(1));
}
}
}
returnnews;
}
YoucanfindmanyvariationsofdistributedcachesystemsincludingMemcached,
CouchbaseandRedis.TheyarealsocalledNoSQLdatabase.Youcanthinkofthemsimply
asaremotedictionary.Theystorekey/valuepairsintheirmemoryandletyouaccessthem
asfastaspossible.
Warning!Whenitisusedproperly,distributedcachecanimproveperformanceofyour
application,justlikelocalcache.Otherwiseitcanhaveaworseeffectthanlocalcache
asthereisanetworktransferandserializationcostinvolved.Thus"ifwekeepthingsin
distributedcacheoursitewillrunfaster"isamyth.
Whenthecacheddatabecomestoomuch,onecomputermemorymightbenotenoughto
storeallkey/valuepairs.Inthiscaseserverslikememcacheddistributedatabyclustering.
Thiscouldbedonebythefirstletterofkeys.OneservercouldholdpairsstartingwithA,
otherwithBetc.Infact,theyusehashofkeysforthispurpose.
WEBFarmsandCaching
336

IDistributedCacheInterface
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
AllNoSQLservertypesprovideasimilarinterfacelike"storethisvalueforthiskey","give
mevaluecorrespondingtothiskey"etc.
Serenityprovidesitsdistributedcachesupportthroughacommoninterfacetonotdependon
aspecifickindofNoSQLdatabase:
publicinterfaceIDistributedCache
{
longIncrement(stringkey,intamount=1);
TValueGet<TValue>(stringkey);
voidSet<TValue>(stringkey,TValuevalue);
voidSet<TValue>(stringkey,TValuevalue,TimeSpanexpiration);
}
FirstoverloadofSetmethodthattakeskeyandvalueargumentsisusedtostorea
key/valuepairindistributedcache.
IoC.Resolve<IDistributedCache>().Set("someKey","someValue");
LaterwecouldreadbackthisvalueusingGetmethod:
varvalue=IoC.Resolve<IDistrubutedCache().Get<string>("someKey")//someValue
Ifwewantedtokeepsomevalueforapredeterminedduration,wecouldusethesecond
overloadofGetmethod:
IoC.Resolve<IDistributedCache>().Set("someKey","someValue",
TimeSpan.FromMinutes(10));
IDistributedCache.IncrementMethod
Operationondistributedcachesystemsareusuallynotatomicandtheyprovideno
transactionalsystemsatall.
Samekeyvaluecanbechangedbymultipleserversatsametimeandoverrideeachothers
valueinrandomorder.
IDistributedCacheInterface
337

Let'ssayweneededauniquecounter(togenerateanIDforexample)andsynchronizeit
throughdistributedcache(topreventusingsameIDtwice):
intGetTheNextIDValue()
{
varlastID=IoC.Resolve<IDistributedCache>().Get("LastID");
IoC.Resolve<IDistributedCache>().Set("LastID",lastID+1);
returnlastID;
}
Suchacodeblockwon'tfunctionasexpected.Insidethedurationbetweenreading LastID
value(get)andsettingittoincrement LastIDvalue(set),anotherservermighthaveread
thesameLastIDvalue.ThustwoserverscouldusesameIDvalue.
Forthispurpose,youcanuseIncrementmethod:
intGetTheNextIDValue()
{
returnIoC.Resolve<IDistributedCache>().Increment("LastID");
}
IncrementfunctionactsjustlikeInterlocked.Incrementmethodthatisusedinthread
synchronization.Itincreasesanidentityvaluebutblocksotherrequestswhiledoingit,and
returnstheincrementedvalue.SoeveniftwoWEBserversincrementedsamekeyinexact
samemoment,theyendupwithdifferentIDvalues.
IDistributedCacheInterface
338

DistributedCacheStaticClass
[namespace:Serenity,assembly:Serenity.Core]
DistributedCacheclassprovidesshortcutstomethodsforcurrentlyregistered
IDistributedCacheimplementation.Sobelowtwolinesarefunctionallyequal:
IoC.Resolve<IDistributedCache>().Increment("LastID");
DistributedCache.Increment("LastID");
DistributedCacheStaticClass
339

DistributedCacheEmulatorClass
[namespace:Serenity.Abstractions,assembly:Serenity.Core]
Ifyoudon'tneedadistributedcachenow,butyouwantedtowritecodethatwillworkwitha
distributedcacheinthefuture,youcouldusetheDistributedCacheEmulatorclass.
DistributedCacheEmulatorisalsousefulforunittestsanddevelopmentenvironments(so
thatdevelopersdon'tneedtoaccessadistributedcachesystemandworkwithoutaffecting
eachother).
DistributedCacheEmulatoremulatestheIDistributedCacheinterfaceinathread-safemanner
byusingain-memorydictionary.
TouseDistributedCacheEmulator,youneedtoregisteritwiththeSerenityServiceLocator
(IDependencyRegistrar).Wedoitfromsomemethodcalledonapplicationstart
(global.asax.csetc):
privatestaticvoidInitializeDependencies()
{
//...
varregistrar=Dependency.Resolve<IDependencyRegistrar>();
registrar.RegisterInstance<IDistributedCache>(newDistributedCacheEmulator());
//...
}
DistributedCacheEmulatorClass
340

CouchbaseDistributedCacheClass
[namespace:Serenity.Caching,assembly:Serenity.Caching.Couchbase]
CouchbaseisadistributeddatabasethathasMemcachedlikeaccessinterface.
YoucangetSerenityimplementationforthisservertypeinSerenity.Caching.Couchbase
NuGetpackage.
Onceyouregisteritwiththeservicelocator:
Dependency.Resolve<IDependencyRegistrar>()
.RegisterInstance<IDistributedCache>(newCouchbaseDistributedCache())
YoucanconfigureCouchbaseDistributedCacheinapplicationconfigurationfile(withJSON
format):
<appSettings>
<addkey="DistributedCache"value='{
ServerAddress:"http://111.22.111.97:8091/pools",
BucketName:"primary-bucket",
KeyPrefix:""
}'/>
HereServerAddressisCouchbaseserveraddressandBucketNameisthebucketname.
Ifyouwantedtousesameserver/bucketformorethanoneapplicationyoucanput
somethinglike DEV:, TEST:intoKeyPrefixsetting.
CouchbaseDistributedCacheClass
341

RedisDistributedCacheClass
[namespace:Serenity.Caching,assembly:Serenity.Caching.Couchbase]
RedisisanotherinmemorydatabasethatisalsousedbyStackOverflowforitsperformance
andreliability.TheyusejustoneRedisdatabaseforalltheirWEBservers.
YoucangetSerenityimplementationforthisservertypeinSerenity.Caching.RedisNuGet
package.
ItcanberegisteredjustlikeCouchbaseDistributedCacheandconfigurationissimilar(though
thereisnobucketsetting):
<appSettings>
<addkey="DistributedCache"value="{
ServerAddress:'someredisserver:6379',
KeyPrefix:''
}"/>
/>
RedisDistributedCacheClass
342

TwoLevelCaching
Whenyouuselocal(in-memory)caching,oneservercancachesomeinformationand
retrieveitasfastaspossiblebutasotherserverscan'taccessthatcacheddata,theyhave
toqueryforthesameinformationfromdatabase.
Ifyoupreferdistributedcachingtoletotherserversaccesscacheddataasithassome
serialization/deserializationandnetworklatencyoverhead,itmaydegradeperformancein
somecases.
Thereisalsoanotherproblemwithcachingthatneedstobehandled:cacheinvalidation:
ThereareonlytwohardthingsinComputerScience:cacheinvalidationandnaming
things.
--PhilKarlton
Whenyoucachesomeinformation,youhavetomakesurethat,whenthesourcedata
changes,cachedinformationisinvalidated(regeneratedorremovedfromcache).
TwoLevelCaching
343

UsingLocalCacheandDistributedCache
inSync
Wemightenjoythebestofbothworldsbyfollowingasimplealgorithm:
1. Checkforkeyinlocalcache.
2. Ifkeyexistsinlocalcachereturnitsvalue.
3. Ifkeydoesn'texistinlocalcache,trydistributedcache.
4. Ifkeyexistsindistributedcachereturnitsvalueandaddittolocalcachetoo.
5. Ifkeydoesn'texistindistributedcache,produceitfromdatabase,addittobothlocal
cacheanddistributedcache.Returntheproducedvalue.
Thisway,whenaservercachessomeinformationinlocalcache,italsocachesitin
distributedcache,butthistimeotherserverscanre-useinformationindistributedcacheif
theydon'thavealocalcopyinmemory.
Onceallservershavealocalcopy,noneofthemwillneedtoaccessdistributedcache
again,thus,avoidingserializationandlatencyoverhead.
ValidatingLocalCopies
Alllooksfine.Butnowwehaveacacheinvalidationproblem.Whatifinoneoftheservers
cacheddataischanged.Howdowenotifythemofthischange,sothattheycaninvalidate
theirlocallycachedcopy?
Wewouldchangethevalueindistributedcache,butastheydon'tcheckdistributedcache
anymore(shortcutfromstep2inlastalgorithm),theywouldn'tbenoticed.
Onesolutiontothisproblemwouldbetokeeplocalcopiesforacertaintime,e.g.5secs.
Thus,whenaserverchangesacacheddata,otherserverswoulduseout-of-date
informationfor5secondsmostly.
Thismethodwouldhelpwithbatchoperationsthatneedssamecachedinformation
repeatedly.Butevenifnothingchangedindistributedcache,wewouldhavetogetacopy
fromdistributedcachetolocalcacheevery5seconds.Ifcacheddataisbig,thiswould
increasenetworkbandwidthusageanddeserializationcost.
Weneedawaytoknowifthedataindistributedcacheisdifferentfromthelocalcopy.There
areseveralwaysofitthaticanimagine:
Storehashalongsidedatainlocalanddistributedcache(slighthashcalculationcost)
UsingLocalCacheandDistributedCacheInSync
344

Storeanincrementingversionnumberofdata(howtomakesurethattwoservers
doesn'tgeneratesameversionnumbers?)
Storelasttimedataissetindistributedcache(timesyncproblems)
Storearandomnumber(generation)alongsidedata
Serenityusesgenerationnumbers(randomint)asversion.
Sowhenwestoreavalueindistributedcache,let'ssaySomeCachedKey,wealsostorea
randomnumberwithkeySomeCachedKey$GENERATION$.
Nowourprioralgorithmbecomesthis:
1. Checkforkeyinlocalcache.
2. Ifkeyexistsinlocalcache
Compareitsgenerationwithoneindistributedcache
Iftheyareequal,returnlocalcachedvalue
Iftheydon'tmatch,continueto4
3. Ifkeydoesn'texistinlocalcache,trydistributedcache.
4. Ifkeyexistsindistributedcachereturnitsvalueandaddittolocalcachetoo,alongside
itsgeneration.
5. Ifkeydoesn'texistindistributedcache,generateitfromdatabase,addittobothlocal
cacheanddistributedcachewithsomerandomgeneration.Returntheproducedvalue.
ValidatingMultipleCachedItemsInOneShot
Youmighthavecacheddataproducedfromsometable.Theremightbemorethanonekey
indistributedcacheforthistable.
LetssayyouhaveaprofiletableandcachedprofileitemsbytheirUserIDvalues.
Whenauser'sprofileinformationchanges,youmaytrytoremoveitscachedprofilefrom
cache.Butwhatifanotherserverorapplicationyoudon'tknowabout,cachedsome
informationthatisgeneratedfromsameuserprofiledata?Youmaynotknowwhatcached
informationkeysexistindistributedcachethatdependsonsomeuserID.
Mostdistributedcacheimplementationsdon'tprovideawaytofindallkeysthatstartwith
somestringoritiscomputationallyintensive(astheyaredictionarybased).
Sowhenyouwanttoexpireallitemsdependingonsomesetofdata,itmightnotbe
feasible.
Whilecachingitems,Serenityallowsyoutospecifyagroupkey,whichisusedtoexpire
them,whenthedatathatthegroupdependsonchanges.
UsingLocalCacheandDistributedCacheInSync
345

Let'ssayoneapplicationproducedCachedItem17fromauserwithID17'sprofiledataand
weusethisIDasagroupkey(Group17_Generation):
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
Group17_Generation 13579
Here,randomgeneration(version)forthegroupis13579.Alongwithcacheddata
(CachedItem17),westoredwhateverwasthegroupgenerationwhenweproducedthisdata
(CachedItem17_Generation).
Supposethatanotherserver,cachedAnotherItem17fromUser17'sdata:
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
AnotherItem17 uwsdasdas
AnotherItem17_Generation 13579
Group17_Generation 13579
Here,wereusedGroup17_Generation,astherewasalreadyagroupversionnumberin
distributedcache,otherwisewewouldhavetogenerateanewone.
Now,bothitemsincache(CachedItem17andAnotherItem17)arevalid,becausetheir
versionnumbersmatchesthegroupversion.
IfsomebodychangedUser17'sdataandwewantedtoexpireallcacheditemsrelatedto
her,weneedtojustchangethegroupgeneration:
Key Value
CachedItem17 cxyzyxzcasd
CachedItem17_Generation 13579
AnotherItem17 uwsdasdas
AnotherItem17_Generation 13579
Group17_Generation 54237
Nowallcacheditemsareexpired.Eventhoughtheyexistincache,weseethattheir
generationsdon'tmatchthegroupgeneration,sotheyarenotconsideredvalid.
UsingLocalCacheandDistributedCacheInSync
346

Groupkeysweuseareusuallynameofthetablethatdataisproducedfrom.
UsingLocalCacheandDistributedCacheInSync
347

TwoLevelCacheClass
[namespace:Serenity]-[assembly:Serenity.Core]
Outofthebox,TwoLevelCacheprovidesallfunctionalitythatwetalkedaboutsofarand
somemore.
publicstaticclassTwoLevelCache
{
publicstaticTItemGet<TItem>(
stringcacheKey,TimeSpanexpiration,
stringgroupKey,Func<TItem>loader)
whereTItem:class;
publicstaticTItemGet<TItem>(
stringcacheKey,TimeSpanlocalExpiration,TimeSpanremoteExpiration,
stringgroupKey,Func<TItem>loader)
whereTItem:class;
publicstaticTItemGetWithCustomSerializer<TItem,TSerialized>(
stringcacheKey,TimeSpanlocalExpiration,TimeSpanremoteExpiration,
stringgroupKey,Func<TItem>loader,
Func<TItem,TSerialized>serialize,
Func<TSerialized,TItem>deserialize)
whereTItem:class
whereTSerialized:class;
publicstaticTItemGetLocalStoreOnly<TItem>(
stringcacheKey,TimeSpanlocalExpiration,
stringgroupKey,Func<TItem>loader)
whereTItem:class;
publicstaticvoidChangeGlobalGeneration(stringglobalGenerationKey);
publicstaticvoidRemove(stringcacheKey);
}
TwoLevelCache.GetMethod
Triestoreadavaluefromlocalcache.Ifitisnotfoundinthere(orhasanexpired
version),triesthedistributedcache.
Ifneithercontainsthespecifiedkey,producesvaluebycallingaloaderfunctionand
addsthevaluetolocalanddistributedcacheforagivenexpirationtime.
TwoLevelCacheClass
348

TherearetwooverloadsoftheGetmethod.Onethattakesexpirationtimeforlocaland
distributedcachesseparately,andanotherthathasonlyoneexpirationparameterfor
both.
Byusingagroupkey,allitemsonbothcachetypesthataremembersofthisgroupcan
beexpiredatonce(thisisgenerationbasedexpiration,nottime).
Toavoidcheckinggroupgenerationeverytimeanitemthatbelongstogroupis
requested,groupgenerationitselfisalsocachedinlocalcache.Thus,whena
generationnumberchanges,localcacheditemsmightexpireafter5seconds.
Thismeansthat,ifyouusethisstrategyinawebfarmsetup,whenachangeoccursin
oneserver,otherserversmightcontinuetouseoldlocalcacheddatafor5seconds
more.
Ifthisisaproblemforyourconfiguration,youshoulduseDistributedCachemethods
directlyinsteadofdependingonTwoLevelCache.
TwoLevelCacheClass
349

CachedProfileGetCachedProfile(intuserID)
{
TwoLevelCache.Get("CachedProfile:"+userID,TimeSpan.FromDays(1),"SomeGroupKey",
()=>
{
using(varconnection=newSqlConnection("..."))
{
connection.Open();
returnLoadProfileFromDB(connection,userID);
}
});
}
CachedProfileLoadProfileFromDB(IDbConnectionconnection,intuserID)
{
//...
}
TwoLevelCache.GetWithCustomSerializer
Method
TwoLevelCache.Getstorescacheddatainbothlocalcacheanddistributedcache.While
storingcacheditemsinlocalcache,serializationisnotrequired(in-memory).Butbefore
itemsaresenttodistributedcache,somekindofserialization(binary,jsonetc.)mustbe
performed(dependsonprovideranddatatype).
Sometimesthisserialization/deserializationoperationcanbecostly,soyoumightwantto
provideyourownimplementationofthesefunctionsforyourdatatype.
GetWithCustomSerializertakestwoextradelegateargumentstoserializeanddeserialize
values.Youmightreturnastringorbytearrayfromserializationfunction,andin
deserializationtakethisstringorbytearrayandturnitbackintoyouroriginaldatatype.
Mostprovidershandlesimpletypeslikeint,stringorbyte[]effectively,soforsuchdata
typesyoudon'tneedcustomserialization.
TwoLevelCache.GetLocalStoreOnlyMethod
Ifyouonlywanttostoreitemsinlocalcacheandnotdistributedcache,GetLocalStoreOnly
canbeuseful.
Whencacheddatabyoneserverisnothelpfulforothers(changesfromservertoserver),so
bigorslowtoserialize/deserialize,storingsuchdataindistributedcacheisnotmeaningful.
TwoLevelCacheClass
350

So,whyshouldn'tyouuseLocalCachedirectlyinthiscase?
Youcouldbutnotifyouwanttospecifyagroupkey,andexpirelocalcacheditemseasily
whensourcedataofthatgroupchanges(asiftheyarestoredindistributedcache).
TwoLevelCache.ExpireGroupItemsMethod
Thismethodallowsyoutoexpireallitemsthataremembersofonegroupkey.Itsimply
removesgroupkeyfromlocalcacheanddistributedcache,soanotherversionwillbe
generatednexttimeitisqueried.
TwoLevelCache.ExpireGroupItems("SomeGroupKey");
Youshouldcallthisfrommethodsthatchangedata.
IfyourentityclasshasTwoLevelCachedattributeonit,Create,Update,Deleteand
UndeletehandlersdothisautomaticallywithConnectionKey.TableNameasgroupkey.
TwoLevelCache.RemoveMethod
Removesanitemanditsversionfromlocalanddistributedcache.
TwoLevelCacheClass
351

Entities(Row)
Serenityentitysystemisamicro-ormthatisinlovewithSQLjustlikeDapper.
UnlikefullblownORMslikeNHibernate/EntityFramework,Serenityprovidesminimum
featuresrequiredtomapandquerydatabaseswithintellisense,compiletimecheckingand
easyrefactoring.
SerenityentitiesareusuallynamedlikeXYZRow.Theyaresubclassesof
Serenity.Data.Row.
Let'sdefineasimplerowclass:
usingSerenity;
usingSerenity.ComponentModel;
usingSerenity.Data;
publicclassSimpleRow:Row
{
publicstringName
{
get{returnFields.Name[this];}
set{Fields.Name[this]=value;}
}
publicInt32?Age
{
get{returnFields.Age[this];}
set{Fields.Age[this]=value;}
}
publicstaticRowFieldsFields=newRowFields().Init();
publicSimpleRow()
:base(Fields)
{
}
publicclassRowFields:RowFieldsBase
{
publicStringFieldName;
publicInt32FieldAge;
}
}
Entities(Row)
352

Yes,itlooksabitmorecomplicatedthanasimplePOCOclass.Thisisrequiredto
makesomefeaturesworkwithoutusingproxyclasseslikesomeORMsuse(Entity
Framework,NHibernateetc).
Thisstructureallowsustobuildquerieswithzeroreflection,doassignmenttracking,
enableINotifyPropertyChangedwhenrequired.Itmakesitalsopossibletoworkwith
custom,userdefinedfields.
RowsareJSONserializable,sotheycanbereturnedfromserviceswithoutany
problems.Youdon'tneedextraPOCO/DTOclassesunlessyouhaveagoodreasonto
usethem.
Let'sstudypartsofarowdeclaration.
publicclassSimpleRow:Row
HerewedefineanentitynamedSimpleRow,whichprobablymapstoatablenamed Simple
indatabase.
Rowsuffixhereisnotrequired,butcommonpractice,anditpreventsclasheswithother
classnames.
Allentityclassesderivefrom Serenity.Data.Rowbaseclass.
publicstringName
{
get{returnFields.Name[this];}
set{Fields.Name[this]=value;}
}
Nowwedeclareourfirstproperty.Thispropertymapstoadatabasecolumnnamed Namein
the Simpletable.
Itisnotpossibletouseanautopropertyhere(like get;set;).Fieldvaluesmustberead
andsetthroughaspecialobjectcalledField.
FieldobjectsareverysimilartoWPFdependencyproperties.Hereisadependencyproperty
declarationsample:
Entities(Row)
353

publicstaticreadonlyDependencyPropertyMyCustomProperty=
DependencyProperty.Register("MyCustom",typeof(string),typeof(Window1));
publicstringMyCustom
{
get{returnthis.GetValue(MyCustomProperty)asstring;}
set{this.SetValue(MyCustomProperty,value);}
}
Herewedefineastaticdependencypropertyobject(MyCustomProperty),thatcontains
propertymetadataandallowsustosetandgetpropertyvaluethroughitsGetValueand
SetValuemethods.DependencypropertiesallowsWPFtoofferfeatureslikevalidation,data
binding,animation,andmore.
Similartodependencyproperties,Fieldobjectscontainscolumnmetadataandclearsway
forsomefeatureslikeassignmenttracking,buildingquerieswithoutexpressiontrees,
changenotificationetc.
Whiledependencypropertiesaredeclaredasstaticmembersinclasstheyareused,Field
objectsaredeclaredinanestedclassnamedRowFields.Thisallowstogroupandreference
themeasier,withouthavingtoaddFieldorPropertysuffix,andkeepsourentityclearfrom
fielddeclarations.
publicInt32?Age
{
get{returnFields.Age[this];}
set{Fields.Age[this]=value;}
}
Hereisoursecondproperty,named Age,withtype Int32?.
Serenityentitypropertiesarealwaysnullable,evenifdatabasecolumnisnotnullable.
Serenityneverusezeroinplaceofnull.
Thismightseemunlogical,ifyouhaveabackgroundofotherORMs,butconsiderthis:
Isitnotpossibleforanotnullfieldtohaveanullvalue,ifyouqueryitthroughaleft/right
join?Howcanyousay,ifitsretrievedvalueisnullorzerointhatcase?
Referencetypesarealreadynullable,soyoucan'twrite String?.
publicstaticRowFieldsFields=newRowFields().Init();
Entities(Row)
354

WenotedthatfieldobjectsaredeclaredinanestedsubclassnamedRowFields(usually).
Herewearecreatingitssolestaticinstance.Thus,thereisonlyoneRowFieldsinstanceper
rowtype,andonefieldinstanceperrowproperty.
InitisanextensionmethodthatinitializesmembersofRowFields.Itcreatesfieldobjects
thatarenotexplictlyinitialized.
publicSimpleRow()
:base(Fields)
{
}
NowwedefineSimpleRow'sparameterlessconstructor.BaseRowclassrequiresa
RowFieldsinstancetowork,andwepassourstaticFieldsobject.Soallinstancesofarow
type(SimpleRow)shareasingleRowFields(SimpleRow.RowFields)instance.Thismeans
theyshareallthemetadata.
publicclassRowFields:RowFieldsBase
{
publicStringFieldName;
publicInt32FieldAge;
}
Herewedefineournestedclassthatcontainsfieldobjects.Itshouldbederivedfrom
Serenity.Data.RowFieldsBase.RowFieldsBaseisaspecialclasscloselyrelatedtoRowthat
containstablemetadata.
WedeclaredaStringFieldandaInt32Field.Theirtypeisbasedontheirpropertytypes,and
theymustmatchexactly.
Theirnamesmustalsomatchthepropertynames,oryou'llgetaninitializationerror.
Wedidn'tinitializethesefieldobjects,sotheirvaluesareinitiallynull.
Rememberthatwewrote newRowFields().Init()above.Thisiswherefieldobjectsare
automaticallycreated.
It'salsopossibletoinitializetheminRowFieldsconstructormanually,butnot
recommended,exceptforspecialcustomizations.
Entities(Row)
355

MappingAttributes
Serenityprovidessomemappingattributes,tomatchdatabasetable,columnnameswith
rows.
ColumnandTableMappingConventions
Bydefault,arowclassisconsideredtomatchatableindatabasewiththesamename,but
Rowsuffixremoved.
Apropertyisconsideredtomatchacolumnindatabasewiththesamename.
Let'ssaywehavesucharowdefinition:
publicclassCustomerRow:Row
{
publicstringStreetAddress
{
get{returnFields.StreetAddress[this];}
set{Fields.StreetAddress[this]=value;}
}
}
Ifwewroteaquery,selectingStreetAddressfieldfromCustomerRow,itwouldbegenerated
likebelow:
SELECT
T0.StreetAddressAS[StreetAddress]
FROMCustomerT0
CustomerRowmatchestableCustomerbyconvention.Similarly,StreetAddressproperty
matchesacolumnnamedStreetAddress.
T0isaspecialaliasassignedtomaintablebySerenityrows.
As,StreetAddresscolumnbelongstomaintable(Customer),itisselectedwithan
expressionof T0.StreetAddressandwithacolumnaliasof [StreetAddress].
Propertynameisusedasacolumnaliasbydefault
SqlSettings.AutoQuotedIdentifiersFlag
MappingAttributes
356

Insomedatabasesystems,identifiersarecasesensitive.
Forexample,inPostgress,ifyoucreateacolumnwithquotedidentifier "StreetAddress",
youhavetousequoteswhenselectingit,evenifyouwrite SELECTStreetAddress...(same
case)itwon'twork.
Youhavetousetheform SELECT"StreetAddress".
Thus,Postgresusersusuallypreferlowercaseidentifiers.ButFluentMigratoralwaysquotes
identifiers,soweneedaworkaroundtoaddbrackets/quotestoidentifiers.
Serenitydoesn'tquote/bracketcolumnandtablenamesbydefault,butithasasettingfor
compability.
IfSqlSettings.AutoQuotedIdentifiersflagissettotrue,previousquerywouldlooklikethis:
SELECT
T0.[StreetAddress]AS[StreetAddress]
FROM[Customer]T0
ThissettingdefaultstofalseinSerenityforbackwardscompability,butSerene1.8.6+
setsittotrueonapplicationstartup.
AndifweusedPostgressdialect,outputwouldbe:
SELECT
T0."StreetAddress"AS"StreetAddress"
FROM"Customer"T0
ColumnAttribute
[namespace:Serenity.Data.Mapping]-[assembly:Serenity.Data]
YoucanmapapropertytosomeothercolumnnameindatabaseusingColumnattribute:
publicclassCustomerRow:Row
{
[Column("street_address")]
publicstringStreetAddress
{
get{returnFields.StreetAddress[this];}
set{Fields.StreetAddress[this]=value;}
}
}
MappingAttributes
357

Nowthequerybecomes:
SELECT
T0.street_addressAS[StreetAddress]
FROMCustomerT0
Itisalsopossibletomanuallyaddbrackets:
publicclassCustomerRow:Row
{
[Column("[street_address]")]
publicstringStreetAddress
{
get{returnFields.StreetAddress[this];}
set{Fields.StreetAddress[this]=value;}
}
}
SELECT
T0.[street_address]AS[StreetAddress]
FROMCustomerT0
IfSqlSettings.AutoQuotedIdentifiersistrue,bracketsareautomaticallyadded.
UseSqlServerspecificbrackets( [])ifyouneedtoworkwithmultipledatabasetypes.
Thesebracketsareconvertedtodialectspecificquotes(doublequote,backticketc.)
beforerunningqueries.
But,ifyouonlytargetonetypeofdatabase,youmaypreferusingquotesspecificto
thatdatabasetype.
TableNameAttribute
[namespace:Serenity.Data.Mapping]-[assembly:Serenity.Data]
Iftablenameindatabaseisdifferentfromrowclassname,usethisattribute:
MappingAttributes
358

[TableName("TheCustomers")]
publicclassCustomerRow:Row
{
publicstringStreetAddress
{
get{returnFields.StreetAddress[this];}
set{Fields.StreetAddress[this]=value;}
}
}
SELECT
T0.StreetAddressAS[StreetAddress]
FROMTheCustomersT0
Youmayalsousebracketsorquotes:
[TableName("[MyCustomers]")]
publicclassCustomerRow:Row
{
publicstringStreetAddress
{
get{returnFields.StreetAddress[this];}
set{Fields.StreetAddress[this]=value;}
}
}
SELECT
T0.StreetAddressAS[StreetAddress]
FROM[MyCustomers]T0
Again,preferbracketsfordatabasecompability
ExpressionAttribute
[namespace:Serenity.Data.Mapping]-[assembly:Serenity.Data]
Thisattributeisusedtospecifyexpressionofanon-basicfield,e.g.onethatdoesn'tactually
existindatabase.
Therecanbeseveraltypesofsuchfields.
OneexampleisaFullnamefieldwithacalculatedexpressionlike (T0.[Firstname]+''+
T0.[Lastname]).
MappingAttributes
359

publicclassCustomerRow:Row
{
publicstringFirstname
{
get{returnFields.Firstname[this];}
set{Fields.Firstname[this]=value;}
}
publicstringLastname
{
get{returnFields.Lastname[this];}
set{Fields.Lastname[this]=value;}
}
[Expression("(T0.[Firstname]+''+T0.[Lastname])")]
publicstringFullname
{
get{returnFields.Fullname[this];}
set{Fields.Fullname[this]=value;}
}
}
Becarefulwith"+"operatorhereasitisSqlServerspecific.Ifyouwanttotarget
multipledatabases,youshouldwritetheexpressionas:
CONCAT(T0.[Firstname],CONCAT('',T0.[Lastname]))
FirstnameandLastnamearetablefields(actualfieldsinthetable),buteveniftheydon't
haveanexpressionattribute,theyhavebasic,implicitlydefinedexpressions, T0.Firstname
and T0.Lastname(maintableisassigned T0aliasinSerenityqueries).
Inthisdocument,whenwetalkaboutaTableField,itmeansafieldthatactually
correspondstoacolumnindatabasetable.
ViewFieldmeansafieldwithacalculatedexpressionorafieldthatoriginatesfrom
anothertable,likefieldsthatcomesfromjoinsinSQLviews.
WewroteFullnameexpressionusing T0aliasbeforethefieldsthatwereference.
Itwouldprobablyworkwithoutthatprefixtoo.Butitisbettertouseit.Whenyoustartto
addjoins,itispossibletohavemorethanonefieldwithsamenameandexperience
ambiguouscolumnerrors.
ForeignKeyAttribute
[namespace:Serenity.Data.Mapping]-[assembly:Serenity.Data]
MappingAttributes
360

Thisattributeisusedtospecifyforeignkeycolumns,andaddinformationaboutprimary
tableandprimaryfieldthattheyarerelatedto.
publicclassCustomerRow:Row
{
[ForeignKey("Countries","Id")]
publicstringCountryId
{
get{returnFields.Firstname[this];}
set{Fields.Firstname[this]=value;}
}
}
HerewespecifiedthatCountryIdfieldinCustomertablehasaforeignkeytoIdfieldin
Countriestable.
Theforeignkeydoesn'thavetoexistindatabase.Serenitydoesn'tcheckit.
Serenitycanmakeuseofsuchmetainformation,eventhoughitdoesn'taffectgenerated
queriesalone.
ForeignKeyismoremeaningfulwhenusedalongwiththenextattributewe'llsee.
LeftJoinAttribute
Wherewearequeryingdatabase,wetendtomakemanyjoinsbecauseofrelations.Mostof
thesejoinsareLEFTorINNERjoins.
WithSerenityentities,you'llusuallybeusingLEFTJOINs.
Databaseadminspreferstodefineviewstomakeiteasiertoqueryacombinationofmultiple
tables,andtoavoidwritingthesejoinsagainandagain.
SerenityentitiescanbeusedjustlikeSQLviews,soyoucanbringincolumnsfromother
tablestoanentity,andqueryitasiftheyareonebigcombinedtable.
MappingAttributes
361

publicclassCustomerRow:Row
{
[ForeignKey("Cities","Id"),LeftJoin("c")]
publicInt32?CityId
{
get{returnFields.CityId[this];}
set{Fields.CityId[this]=value;}
}
[Expression("c.[Name]")]
publicstringCityName
{
get{returnFields.CityName[this];}
set{Fields.CityName[this]=value;}
}
HerewespecifiedthatCitiestableshouldbeassignedalias cwhenjoined,anditsjoin
typeshouldbe LEFTJOIN.Thejoin ONexpressionisdeterminedas c.[Id]==T0.
[CityId]withsomehelpfromForeignKeyattribute.
LEFTJOINispreferredasitallowstoretrieveallrecordsfromlefttable,Customers,
eveniftheydon'thaveaCityIdset.
CityNameisaviewfield(notactuallyacolumnofCustomertable),whichhasanexpression
c.Name.ItisclearthatCityNameoriginatesfromNamefieldinCitiestable.
Now,ifwewantedtoselectcitynamesofallcustomers,ourquerytextwouldbe:
SELECT
c.NameAS[CityName]
FROMCustomerT0
LEFTJOINCitiescON(c.[Id]=T0.CityId)
Whatifwedon'thaveaCountryIdfieldinCustomertable,butwewanttobringCountry
namesofcitiesthroughCountryIdfieldincitytable?
MappingAttributes
362

publicclassCustomerRow:Row
{
[ForeignKey("Cities","Id"),LeftJoin("c")]
publicInt32?CityId
{
get{returnFields.CityId[this];}
set{Fields.CityId[this]=value;}
}
[Expression("c.[Name]")]
publicstringCityName
{
get{returnFields.CityName[this];}
set{Fields.CityName[this]=value;}
}
[Expression("c.[CountryId]"),ForeignKey("Countries","Id"),LeftJoin("o")]
publicInt32?CountryId
{
get{returnFields.CountryId[this];}
set{Fields.CountryId[this]=value;}
}
[Expression("o.[Name]")]
publicstringCountryName
{
get{returnFields.CountryName[this];}
set{Fields.CountryName[this]=value;}
}
}
ThistimewedidaLEFTJOINonCountryIdfieldinCitiestable.Weassigned oaliasto
Countriestableandbringinthenamefieldfromit.
Youcanassignanytablealiastojoinsaslongastheyarenotreservedwords,andare
uniquebetweenotherjoinsintheentity.SergengeneratesaliaseslikejCountry,butyou
mayrenamethemtoshorterandmorenaturalones.
Let'sselectCityNameandCountryNamefieldsofallCustomers:
SELECT
c.[Name]AS[CityName],
o.[Name]AS[CountryName]
FROMCustomerT0
LEFTJOINCitiescON(c.[Id]=T0.CityId)
LEFTJOINCountriesoON(o.[Id]=c.[CountryId])
We'llseehowtobuildsuchqueriesinFluentSQLchapter.
MappingAttributes
363

Sofar,weusedLeftJoinattributewithpropertiesthathasaForeignKeyattributewiththem.
ItisalsopossibletoattachLeftJoinattributetoentityclasses.Thisisusefulforjoinswithout
acorrespondingfieldinmainentity.
Forexample,let'ssayyouhaveaCustomerDetailsextensiontablethatstoressomeextra
detailsofcustomers(1to1relation).CustomerDetailstablehasaprimarykey,CustomerId,
whichisactuallyaforeignkeytoIdfieldinCustomertable.
[LeftJoin("cd","CustomerDetails","cd.[CustomerId]=T0.[Id]")]
publicclassCustomerRow:Row
{
[Identity,PrimaryKey]
publicInt32?Id
{
get{returnFields.Id[this];}
set{Fields.Id[this]=value;}
}
[Expression("cd.[DeliveryAddress]")]
publicstringDeliveryAddress
{
get{returnFields.DeliveryAddress[this];}
set{Fields.DeliveryAddress[this]=value;}
}
AndherewhatitlookslikewhenyouselectDeliveryAddress:
SELECT
cd.[DeliveryAddress]AS[DeliveryAddress]
FROMCustomerT0
LEFTJOINCustomerDetailscdON(cd.[CustomerId]=T0.[Id])
MappingAttributes
364

FieldFlagsEnumeration
[namespace:Serenity.Data.Mapping]-[assembly:Serenity.Data]
Serenityhasasetoffieldflagsthatcontrolsfieldbehavior.
publicenumFieldFlags
{
None=0,
Insertable=1,
Updatable=2,
NotNull=4,
PrimaryKey=8,
AutoIncrement=16,
Foreign=32,
Calculated=64,
Reflective=128,
NotMapped=256,
Trim=512,
TrimToEmpty=512+1024,
DenyFiltering=2048,
Unique=4096,
Default=Insertable|Updatable|Trim,
Required=Default|NotNull,
Identity=PrimaryKey|AutoIncrement|NotNull
}
AnordinarytablefieldhasInsertable,UpdatableandTrimflagssetbydefaultwhich
correspondstoDefaultcombinationflag.
InsertableFlag
Insertableflagcontrolsifthefieldiseditableinnewrecordmode.Bydefault,allordinary
fieldsareconsideredtobeinsertable.
Somefieldsmightnotbeinsertableindatabasetable,e.g.identitycolumnsshouldn'thave
thisflagsset.
Whenafielddoesn'thavethisflag,itwon'tbeeditableinformsinnewrecordmode.Thisis
alsovalidatedinservicesatrepositorylevel.
Sometimes,theremightbeinternalfieldsthatareperfectlyvalidinSQLINSERTstatements,
butshouldn'tbeeditedinforms.OneexamplemightbeaInsertedByUserIdwhichshouldbe
setonservicelevel,andnotbyenduser.Ifwewouldletendusertoedititinforms,this
FieldFlagsEnumeration
365

wouldbeasecurityhole.Suchfieldsshouldn'thaveInsertableflagsettoo.
Thismeansfieldflagsdon'thavetomatchdatabasetablesettings.
InsertableAttribute
ToturnoffInsertableflagforafield,puta[Insertable(false)]attributeonit:
[Insertable(false)]
publicstringMyField
{
get{returnFields.MyField[this];
set{Fields.MyField[this]=value;
}
UseInsertable(true)toturniton.
Noninsertablefieldsarenothidden.Theyarejustreadonly.Ifyouwanttohidethem,
use[HideOnInsert]attribute(Serenity1.9.8+)orwritesomethinglike
form.MyField.GetGridField().Toggle(IsNew)byoverridingUpdateInterfacemethodof
yourdialog.
UpdatableFlag
ThisflagisjustlikeInsertableflag,butcontrolseditrecordmodeinformsandupdate
operationsinservices.Bydefault,allordinaryfieldsareconsideredtobeupdatable.
UpdatableAttribute
ToturnoffUpdatableflagforafield,puta[Updatable(false)]attributeonit:
[Updatable(false)]
publicstringMyField
{
get{returnFields.MyField[this];
set{Fields.MyField[this]=value;
}
UseUpdatable(true)toturniton.
FieldFlagsEnumeration
366

Nonupdatablefieldsarenothiddenindialogs.Theyarejustreadonly.Ifyouwantto
hidethem,use[HideOnUpdate]attribute(Serenity1.9.8+)orwritesomethinglike
form.MyField.GetGridField().Toggle(!IsNew)byoverridingUpdateInterfacemethodof
yourdialog.
TrimFlag
Thisflagisonlymeaningfulforstringtypedfieldsandcontrolswhethertheirvalueshouldbe
trimmedbeforesave.Allstringfieldshavethisflagonbydefault.
Whenafieldvalueisemptystringorwhitespaceonly,itistrimmedtonull.
TrimToEmptyFlag
Usethisflagifyouprefertotrimstringfieldstoemptystringinsteadofnull.
Whenafieldvalueisnullorwhitespaceonly,itistrimmedtoemptystring.
SetFieldFlagsAttribute
Thisattributecanbeusedonfieldstoincludeorexcludeasetofflags.Ittakesafirst
requiredparametertoincludeflags,andasecondoptionalparametertoexcludeflags.
ToturnonTrimToEmptyflagonafield,weuseitlikethis:
[SetFieldFlags(FieldFlags.TrimToEmpty)]
publicstringMyField
{
get{returnFields.MyField[this];
set{Fields.MyField[this]=value;
}
ToturnoffTrimflag:
[SetFieldFlags(FieldFlags.None,FieldFlags.TrimToEmpty)]
publicstringMyField
{
get{returnFields.MyField[this];
set{Fields.MyField[this]=value;
}
ToincludeTrimToEmptyandUpdatablebutremoveInsertable:
FieldFlagsEnumeration
367

[SetFieldFlags(
FieldFlags.Updatable|FieldFlags.TrimToEmpty,
FieldFlags.Insertable)]
publicstringMyField
{
get{returnFields.MyField[this];
set{Fields.MyField[this]=value;
}
InsertableandUpdatableattributesaresubclassesofSetFieldFlagsattribute.
NotNullFlag
Usethisflagtosetfieldsasnotnullable.Bydefault,thisflagissetforfieldsthatarenot
nullableindatabase,usingNotNullattribute.
Whenafieldisnotnullable,itscorrespondinglabelinformshasaredasteriskandtheyare
requiredtobeentered.
NotNullableAttribute
ThissetstheNotNullatttributeonafieldtoON.Removeattributetoturnitoff.
Youmayalsouse[Required(false)]tomakefieldnotrequiredinforms,evenifitisnot
nullableindatabase.Thisdoesn'tcleartheNotNullflag.
RequiredFlag
ThisisacombinationofDefaultandNotNullableflags.
Ithasnorelationto[Required]attributewhichcontrolsvalidationinforms.
PrimaryKeyFlagandPrimaryKeyAttribute
Setthisforprimarykeyfieldsintable.
PrimarykeyfieldsareselectedonKeycolumnselectionmodeinListandRetrieve
requesthandlers.
[PrimaryKey]attributesetsthisflagON.
FieldFlagsEnumeration
368

AutoIncrementFlagandAutoIncrement
Attribute
Setthisforfieldsthatareautoincrementedonserverside,e.g.identitycolumns,orcolumns
usingagenerator.
IdentityFlagandIdentityAttribute
ThisisacombinationofPrimaryKey,AutoIncrementandNotNullflags,whichiscommonfor
identitycolumns.
ForeignFlag
Thisflagissetforforeignviewfields,thatareoriginatingfromothertablesthroughajoin.
ItisautomaticallysetforfieldswithexpressionscontainingtablealiasesotherthanT0.
Forexample,ifafieldhasanattributelike[Expression("jCountry.CountryName")]itwillhave
thisflag.
ThishasnorelationtoForeignKeyattribute
CalculatedFlag
Ifafieldhasanexpressioninvolvingmorethanonefieldorsomemathematicaloperations,
itwillhavethisflag.
ThiscouldalsobesetforfieldsthatarecalculatedonSQLserverside.
NotMappedFlagandNotMappedAttribute
CorrespondstoanunmappedfieldinSerenityentities.Theydon'thaveacorresponding
fieldindatabasetable.
Thesekindsoffieldscanbeusedfortemporarycalculation,storageandtransferonclient
andservicelayers.
ReflectiveFlag
FieldFlagsEnumeration
369

Thisisusedforanadvancedformofunmappedfields,wheretheydon'thaveastorageof
theirowninrow,butreflectsvalueofanotherfieldinadifferentform.Forexample,afield
thatdisplaysabsolutevalueofaintegerfieldthatcanbenegative.
Thisshouldonlybeusedinrarecasesforsuchunmappedfields.
DenyFilteringFlag
Ifset,deniesfilteringoperationsonasensitivefield.Thiscanbeusefulforsecretfieldslike
PasswordHash,thatshouldn'tbeallowedtobeselectedorfilteredbyclientside.
UniqueFlagandUniqueAttribute
Whenafieldhasthisflag,itsvalueischeckedagainstexistingvaluesindatabasetobe
unique.
YoucanturnonthisflagwithUniqueattributeanddetermineifthisconstraintshouldbe
checkedonservicelevel(beforethecheckindatabaseleveltoavoidcrypticconstraint
errors).
FieldFlagsEnumeration
370

FluentSQL
SerenitycontainsasetofquerybuildersforSELECT,INSERT,UPDATEandDELETE
statements.
ThesebuilderscanbeusedwithsimplestringsorSerenityentity(row)system.
Theiroutputcanbeexecuteddirectly,throughamicro-ormlikeDapper(whichisintegrated
withSerenity),orSerenityextensions.
FluentSQL
371

SqlQueryObject
[namespace:Serenity.Data]-[assembly:Serenity.Data]
SqlQueryisanobjecttocomposedynamicSQLSELECTqueriesthroughafluentinterface.
Advantages
SqlQueryofferssomeadvantagesoverhandcraftedSQL:
UsingIntelliSensefeatureofVisualStudiowhilecomposingSQL
Fluentinterfacewithminimaloverhead
Reducedsyntaxerrorsasthequeryischeckedcompiletime,notexecutiontime.
ClauseslikeSelect,Where,OrderBycanbeusedinanyorder.Theyareplacedat
correctpositionswhenconvertingthequerytostring.Similary,suchclausescanbe
usedmorethanonceandtheyaremergedduringstringconversion.Soyoucan
conditionallybuildSQLdependingoninputparameters.
Noneedtomessupwithparametersandparameternames.Allvaluesusedare
convertedtoautonamedparameters.Youcanalsousemanuallynamedparametersif
required.
Itcangenerateaspecialquerytoperformpagingonservertypesthatdoesn'tsupportit
natively(e.g.SQLServer2000)
Withthedialectsystem,querycanbetargetedatspecificservertypeandversion.
IfitisusedalongwithSerenityentities(itcanalsobeusedwithmicroORMslike
Dapper),helpstoloadqueryresultsfromadatareaderwithzeroreflection.Alsoitdoes
left/rightjoinsautomatically.
HowToTrySamplesHere
IrecommendusingLinqPadtotrysamplesgivenhere.
YoushouldaddreferencetoSerenity.Core,Serenity.DataandSerenity.Data.EntityNuGet
packages.
SqlQueryObject
372

AnotheroptionistolocateandreferencetheseDLLsdirectlyfromaSereneapplication'sbin
orpackagesdirectory.
MakesureyouaddSerenityandSerenity.DatatoAdditionalNamespaceImportsinQuery
Propertiesdialog.
ASimpleSelectQuery
voidMain()
{
varquery=newSqlQuery();
query.Select("Firstname");
query.Select("Surname");
query.From("People");
query.OrderBy("Age");
Console.WriteLine(query.ToString());
}
Thiswillresultinoutput:
SELECT
Firstname,
Surname
FROMPeople
ORDERBYAge
Inthefirstlineofourprogram,wecalledSqlQuerywithitssoleparameterlessconstructor.If
ToString()wascalledatthispoint,theoutputwouldbe:
SELECTFROM
SqlQuerydoesn'tperformanysyntaxvalidation.Itjustconvertsthequeryyoubuildyourself,
bycallingitsmethods.Evenifyoudon'tselectanyfieldsorcallfrommethods,itwill
generatethisbasicSELECTFROMstatement.
SqlQuerycan'tgenerateemptyqueries.
Next,wecalled Selectmethodwithstringparameter "FirstName".Ourqueryisnowlike
this:
SELECTFirstnameFROM
SqlQueryObject
373

When Select("Surname")statementisexecuted,SqlQueryputacommabetween
previouslyselectedfield(Firstname)andthisone:
SELECTFirstname,SurnameFROM
AfterexecutingFromandOrderBymethods,ourfinaloutputis:
SELECTFirstname,SurnameFROMPeopleORDERBYAge
MethodCallOrderandItsEffects
Inprevioussample,outputwouldn'tchangeevenifwereorderedFrom,OrderByandSelect
lines.ItwouldchangeonlyifwechangedorderofSelectstatements...
voidMain()
{
varquery=newSqlQuery();
query.From("People");
query.OrderBy("Age");
query.Select("Surname");
query.Select("Firstname");
Console.WriteLine(query.ToString());
}
...but,onlythecolumnorderinginsidetheSELECTstatementwouldchange:
SELECT
Surname,
Firstname
FROMPeople
ORDERBYAge
YoumightusemethodslikeSelect,From,OrderBy,GroupByinanyorder,andcanalsomix
them(e.g.callSelect,thenOrderBy,thenSelectagain...)
PuttingFROMatstartisrecommended,especiallywhenusedwithSerenityentities,as
ithelpswithautojoinsanddeterminingdatabasedialectetc.
MethodChaining
SqlQueryObject
374

Itisabitverboseandnotsoreadabletostarteveryline query..AlmostallSqlQuery
methodsarechainable,andreturnsthequeryitselfasresult.
Wemayrewritethequerylikethis:
voidMain()
{
varquery=newSqlQuery()
.From("People")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age");
Console.WriteLine(query.ToString());
}
ThisfeatureissimilartojQueryandLINQenumerablemethodchaining.
Wecouldevengetridofthequeryvariable:
voidMain()
{
Console.WriteLine(
newSqlQuery()
.From("People")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age")
.ToString());
}
Itisstronglyrecommendedtoputeverymethodonitsownline,andindentproperlyfor
readabilityandconsistencyreasons.
SelectMethod
publicSqlQuerySelect(stringexpression)
Inthesampleswehadsofar,weusedtheoverloadoftheSelectmethodshownabove(it
hasabout11overloads).
Expressionparametercanbeasimplefieldnameoranexpressionlike "FirstName+''+
LastName"
SqlQueryObject
375

Wheneverthismethodiscalled,theexpressionyousetisaddedtotheSELECTstatement
ofresultingquerywithacommabetween.
ThereisalsoaSelectManymethodtoselectmultiplefieldsinonecall:
publicSqlQuerySelectMany(paramsstring[]expressions)
Forexample:
voidMain()
{
varquery=newSqlQuery()
.From("People")
.SelectMany("Firstname","Surname","Age","Gender")
.ToString();
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Surname,
Age,
Gender
FROMPeople
I'dpersonallyprefercallingSelectmethodmultipletimes.
Youmightbewondering,whymultipleselectionisnotjustanotherSelectoverload.It's
becauseSelecthasamorecommonlyusedoverloadtoselectacolumnwithalias:
publicSqlQuerySelect(stringexpression,stringalias)
voidMain()
{
varquery=newSqlQuery()
.Select("(Firstname+''+Surname)","Fullname")
.From("People")
.ToString();
Console.WriteLine(query.ToString());
}
SqlQueryObject
376

SELECT
(Firstname+''+Surname)AS[Fullname]
FROMPeople
FromMethod
publicSqlQueryFrom(stringtable)
SqlQuery.Frommethodshouldbecalledatleastonce(andusuallyonce).
..anditisrecommendedtobecalledfirst.
Whenyoucallitasecondtime,tablenamewillbeaddedtoFROMstatementwithacomma
between.Thus,itwillbeaCROSSJOIN:
voidMain()
{
varquery=newSqlQuery()
.From("People")
.From("City")
.From("Country")
.Select("Firstname")
.Select("Surname")
.OrderBy("Age");
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Surname
FROMPeople,City,Country
ORDERBYAge
UsingAliasObjectwithSqlQuery
Itiscommontousetablealiaseswhennumberofreferencedtablesincreaseandour
queriesbecomelonger:
SqlQueryObject
377

voidMain()
{
varquery=newSqlQuery()
.From("Personp")
.From("Cityc")
.From("Countryo")
.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.NameAS[CityName],
o.NameAS[CountryName]
FROMPersonp,Cityc,Countryo
ORDERBYp.Age
Althoughitworkslikethis,itisbettertodefine p, c,and oasAliasobjects.
varp=newAlias("Person","p");
Aliasobjectislikeashortnameassignedtoatable.Ithasanindexerandoperator
overloadstogenerateSQLmemberaccessexpressionslike p.Surname.
voidMain()
{
varp=newAlias("Person","p");
Console.WriteLine(p+"Surname");//+operatoroverload
Console.WriteLine(p["Firstname"]);//throughindexer
}
p.Surname
p.Firstname
UnfortunatelyC#memberaccessoperator(.)can'tbeoverridden,sowehadtouse(+).
Aworkaroundcouldbepossiblewithdynamic,butitwouldperformpoorly.
Let'smodifyourquerymakinguseofAliasobjects:
SqlQueryObject
378

voidMain()
{
varp=newAlias("Person","p");
varc=newAlias("City","c");
varo=newAlias("Country","o");
varquery=newSqlQuery()
.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());
}
SELECT
p.Firstname,
p.Surname,
c.NameAS[CityName],
o.NameAS[CountryName]
FROMPersonp,Cityc,Countryo
ORDERBYp.Age
Asseenabove,resultisthesame,butthecodewewroteisabitlonger.Sowhatisthe
advantageofusinganalias?
Ifwehadalistofconstantswithfieldnames…
SqlQueryObject
379

voidMain()
{
conststringFirstname="Firstname";
conststringSurname="Surname";
conststringName="Name";
conststringAge="Age";
varp=newAlias("Person","p");
varc=newAlias("City","c");
varo=newAlias("Country","o");
varquery=newSqlQuery()
.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());
}
…wewouldtakeadvantageofintellisensefeatureandhavesomemorecompiletime
checks.
Obviously,itisnotlogicalandeasytodefinefieldnamesforeveryquery.Thisshouldbeina
centrallocation,orourentitydeclarations.
Let'screateapoormanssimpleORMusingAlias:
SqlQueryObject
380

publicclassPeopleAlias:Alias
{
publicPeopleAlias(stringalias)
:base("People",alias){}
publicstringID{get{returnthis["ID"];}}
publicstringFirstname{get{returnthis["Firstname"];}}
publicstringSurname{get{returnthis["Surname"];}}
publicstringAge{get{returnthis["Age"];}}
}
publicclassCityAlias:Alias
{
publicCityAlias(stringalias)
:base("City",alias){}
publicstringID{get{returnthis["ID"];}}
publicstringCountryID{get{returnthis["CountryID"];}}
publicnewstringName{get{returnthis["Name"];}}
}
publicclassCountryAlias:Alias
{
publicCountryAlias(stringalias)
:base("Country",alias){}
publicstringID{get{returnthis["ID"];}}
publicnewstringName{get{returnthis["Name"];}}
}
voidMain()
{
varp=newPeopleAlias("p");
varc=newCityAlias("c");
varo=newCountryAlias("o");
varquery=newSqlQuery()
.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());
}
Nowwehaveasetoftablealiasclasseswithfieldnamesandtheycanbereusedinall
queries.
SqlQueryObject
381

Thisisjustasampletoexplainaliases.Idon'trecommendwritingsuchclasses.Entities
offersmuchmore.
Insampleabove,weusedSqlQuery.FromoverloadthattakesanAliasparameter:
publicSqlQueryFrom(Aliasalias)
Whenthismethodiscalled,tablenameanditsaliasednameisaddedtoFROMstatement
ofquery.
OrderByMethod
publicSqlQueryOrderBy(stringexpression,booldesc=false)
OrderBycanalsobecalledwithafieldnameorexpressionlikeSelect.
Ifyouassigndescoptionalargumentastrue, DESCkeywordisappendedtothefieldname
orexpression.
Bydefault,OrderByappendsspecifiedexpressionstoendoftheORDERBYstatement.
Sometimes,youmightwanttoinsertanexpression/fieldtostart.
Forexample,youmighthaveaquerywithsomepredefinedorder,butifuserordersbya
columninagrid,nameofthecolumnshouldbeinsertedatindex0.
publicSqlQueryOrderByFirst(stringexpression,booldesc=false)
voidMain()
{
varquery=newSqlQuery()
.Select("Firstname")
.Select("Surname")
.From("Person")
.OrderBy("PersonID");
query.OrderByFirst("Age");
Console.WriteLine(query.ToString());
}
SqlQueryObject
382

SELECT
Firstname,
Surname
FROMPerson
ORDERBYAge,PersonID
DistinctMethod
publicSqlQueryDistinct(booldistinct)
UsethismethodtoprependaDISTINCTkeywordbeforeSELECTstatement.
voidMain()
{
varquery=newSqlQuery()
.Select("Firstname")
.Select("Surname")
.From("Person")
.OrderBy("PersonID")
.Distinct(true);
Console.WriteLine(query.ToString());
}
SELECTDISTINCT
Firstname,
Surname
FROMPerson
ORDERBYPersonID
GroupByMethod
publicSqlQueryGroupBy(stringexpression)
GroupByworkssimilartoOrderBybutitdoesn'thaveaGroupByFirstvariant.
SqlQueryObject
383

SELECT
Firstname,
Lastname,
Count(*)
FROMPerson
GROUPBYFirstname,LastName
SELECT
Firstname,
Lastname,
Count(*)
FROMPerson
GROUPBYFirstname,LastName
HavingMethod
publicSqlQueryHaving(stringexpression)
HavingcanbeusedwithGroupBy(thoughitdoesn'tcheckforGroupBy)andappends
expressiontotheendofHAVINGstatement.
voidMain()
{
varquery=newSqlQuery()
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.GroupBy("Firstname")
.GroupBy("LastName")
.Having("Count(*)>5");
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Lastname,
Count(*)
FROMPerson
GROUPBYFirstname,LastName
HAVINGCount(*)>5
SqlQueryObject
384

PagingOperations(SKIP/TAKE/TOP/LIMIT)
publicSqlQuerySkip(intskipRows)
publicSqlQueryTake(introwCount)
SqlQueryhaspagingmethodssimilartoLINQTakeandSkip.
ThesearemappedtoSQLkeywordsdependingondatabasetype.
AsSqlServerversionsbefore2012doesn'thaveaSKIPequivalent,touseSKIPyour
queryshouldhaveatleastoneORDERBYstatementasROW_NUMBER()willbe
used.ThisisnotrequiredifyouareusingSqlServer2012+dialect.
voidMain()
{
varquery=newSqlQuery()
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.OrderBy("PersonId")
.Skip(100)
.Take(50);
Console.WriteLine(query.ToString());
}
SELECT
Firstname,
Lastname,
Count(*)
FROMPerson
ORDERBYPersonIdOFFSET100ROWSFETCHNEXT50ROWSONLY
InthissampleweareusingthedefaultSQLServer2012dialect.
DatabaseDialectSupport
Inourpagingsample,SqlQueryusedasyntaxthatiscompatiblewithSqlServer2012.
WithDialectmethod,wecanchangetheservertypethatSqlQuerytargets:
SqlQueryObject
385

publicSqlQueryDialect(ISqlDialectdialect)
Asofwriting,thesearethelistofdialecttypessupported:
FirebirdDialect
PostgresDialect
SqliteDialect
SqlServer2000Dialect
SqlServer2005Dialect
SqlServer2012Dialect
IfwewantedtotargetourquerytoSqlServer2005:
voidMain()
{
varquery=newSqlQuery()
.Dialect(SqlServer2005Dialect.Instance)
.From("Person")
.Select("Firstname")
.Select("Lastname")
.Select("Count(*)")
.OrderBy("PersonId")
.Skip(100)
.Take(50);
Console.WriteLine(query.ToString());
}
SELECT*FROM(
SELECTTOP150
Firstname,
Lastname,
Count(*),ROW_NUMBER()OVER(ORDERBYPersonId)AS__num__
FROMPerson)__results__WHERE__num__>100
WithSqliteDialect.Instance,outputwouldbe:
SELECT
Firstname,
Lastname,
Count(*)
FROMPerson
ORDERBYPersonIdLIMIT50OFFSET100
SqlQueryObject
386

Ifyouareusingonlyonetypeofdatabaseserverwithyourapplication,youmayavoid
havingtochooseadialecteverytimeyoustartaquerybysettingthedefaultdialect:
SqlSettings.DefaultDialect=SqliteDialect.Instance;
Writecodeaboveinyourapplicationstartmethod,e.g.global.asax.cs.
SqlQueryObject
387

CriteriaObjects
WhenyouarecreatingdynamicSQLforSELECT,UPDATEorDELETE,youmighthaveto
writecomplexWHEREstatements.
Buildingthesestatementsusingstringconcentanationispossiblebutitistedioustoavoid
syntaxerrorsandopensyourcodetoSQLinjectionattacks.
UsingparametersmightsolveSQLinjectionproblemsbutitinvolvestoomuchmanualwork
toaddparameters.
Luckily,Serenityhasacriteriasystemthathelpsyoubuildparameterizedquerieswitha
syntaxsimilarLINQexpressiontrees.
SerenitycriteriasareimplementedbyutilitizingoperatoroverloadingfeaturesofC#,unlike
LINQwhichusesexpressiontrees.
Let'swriteabasicSQLwherestatementasstringfirst:
newSqlQuery()
.From("MyTable")
.Select("Name")
.Where("Month>5ANDYear<2015ANDNameLIKEN'%a%'")
andsamestatementusingcriteriaobjects:
newSqlQuery()
.From("MyTable")
.Select("Name")
.Where(
newCriteria("Month")>5&
newCriteria("Year")<4&
newCriteria("Name").Contains("a")
Itlooksabitlonger,butitusesparameters
SELECT
Name
FROM
MyTable
WHERE
Month>@p1AND
Year<@p2AND
NameLIKEN'%a%'
CriteriaObjects
388

andyoucouldwriteitwithintellisenseifyouhadanentity:
varm=MyTableRow.Fields;
newSqlQuery()
.From(m)
.Select(m.Name)
.Where(
m.Month>5&
m.Year<4&
m.Name.Contains("a")
Herewedidn'thavetousenewCriteria()becausefieldobjectsalsohasoperator
overloadsthatbuildscriteria.
BaseCriteriaObject
BaseCriteriaisthebaseclassforalltypesofcriteriaobjects.
IthasoverloadsforseveralC#operators,including >, <, &, |thatcanbeusedto
buildcomplexcriteriausingC#expressions.
BaseCriteriadoesn'thaveaconstructorofitselfsoyouneedtocreateoneoftheobjectsthat
derivefromit.Criteriaisthemostcommononethatyoumightuse.
CriteriaObject
CriteriaisasimpleobjectthatcontainsanSQLexpressionasastring,whichisusuallya
fieldname.
newCriteria("MyField")
ItcanalsocontainanSQLexpression(thoughnotrecommendedthisway)
newCriteria("a+b")
Thisparameterisnotsyntaxchecked,soitispossibletobuildacriteriawithinvalid
expression:
newCriteria("Someinvalidexpression()///''^')
CriteriaObjects
389

AND(&)Operator
ItispossibletoANDtwocriteriaobjectswithC# &operator:
newCriteria("Field1>5")&
newCriteria("Field2<4")
`
Pleasenoticethatwearenotusingshortcircuit&&operatorhere.
Thiscreatesanewcriteriaobject(BinaryCriteria)withoperator(AND)andreferenceto
thesetwocriterias.Itdoesn'tmodifyoriginalcriteriaobjects.
BinaryCriteriaissimilartoBinaryExpressioninexpressiontrees
It'sSQLoutputwouldbe:
Field1>5ANDField2<4
ItisalsopossibletouseC#&=operator:
BaseCriteriac=newCriteria("Field1>5)";
c&=newCriteria("Field2<4")
BaseCriteriaisthebaseclassforallcriteriaobjecttypes.IfweusedCriteriac=...in
thefirstline,wewouldhaveacompiletimeerroronsecondlineas&operatorreturnsa
BinaryCriteriaobject,whichisnotassignabletoaCriteriaobject.
OR(|)Operator
ThisissimilartoANDoperator,thoughitusesOR.
newCriteria("Field1>5")|
newCriteria("Field2<4")
`
Field1>5ORField2<4
ParenthesisOperator(~)
CriteriaObjects
390

WhenyouareusingseveralAND/ORstatements,youmightwanttoputparanthesis.
newCriteria("Field1>5")&
(newCriteria("Field2>7")|newCriteria("Field2<3"))
Butthiswon'tworkwithcriteriaobjects,asoutputofabovecriteriawouldbe:
Field1>5ANDField2>7ORField2<3
InformationhereappliestoSerenityversionsbefore1.9.8.AfterthisversionSerenity
putsparanthesisaroundallbinarycriteria(ANDORetc)evenifyoudon'tuse
paranthesis.
Soonlyuse~ifyouwanttoputanexplicitparenthesissomewhere.
Whathappenedtoourparanthesis?Let'stryputtingmoreparanthesis.
newCriteria("Field1>5")&
((((newCriteria("Field2>7")|newCriteria("Field2<3")))))
Still:
Field1>5ANDField2>7ORField2<3
C#doesn'tprovideawaytooverloadparanthesis,itjustusesthemtodeterminecalculation
order,soSerenitycriteriahasnoideaifyouusedthemwithparanthesisornot.
Wehavetouseaspecialoperator, ~(whichisactuallytwo'scomplementinC#):
newCriteria("Field1>5")&
~(newCriteria("Field2>7")|newCriteria("Field2<3"))
NowSQLlookslikewehopedbefore:
Field1>5AND(Field2>7ORField2<3)
AsSerenity1.9.8+autoparanthesisbinarycriteria,aboveexpressionwouldactuallybe:
(Field1>5)AND(((Field2>7)OR(Field2<3)))
CriteriaObjects
391

ComparisonOperators(>,>=,<,<=,==,!=)
ThemostofC#comparisonoperatorsareoverloaded,soyoucanusethemasiswith
criteria.
newCriteria("Field1")==newCriteria("1")&
newCriteria("Field2")!=newCriteria("2")&
newCriteria("Field3")>newCriteria("3")&
newCriteria("Field4")>=newCriteria("4")&
newCriteria("Field5")<newCriteria("5")&
newCriteria("Field6")<=newCriteria("6")
Field1==1AND
Field2<>2AND
Field3>3AND
Field4>=4AND
Field5<5AND
Field6<=6
InlineValues
Whenonesideofacomparisonoperatorisacriteriaandothersideisaninteger,string,
date,guidetc.value,itisconvertedaparametercriteria.
newCriteria("Field1")==1&
newCriteria("Field2")!="ABC"&
newCriteria("Field3")>DateTime.Now&
newCriteria("Field4")>=Guid.NewGuid()&
newCriteria("Field5")<5L
Field1==@p1AND
Field2<>@p2AND
Field3>@p3AND
Field4>=@p4AND
Field5<@p5
Theseparametershascorrespondingvalues,whenaquerycontainingthiscriteriaissentto
SQL.
Automaticparameternumberingstartsfrom1bydefault,butlastnumberisstoredinthe
querythecriteriaisusedwith,sonumbersmightchange.
Let'susethiscriteriainaquery:
CriteriaObjects
392

newSqlQuery()
.From("MyTable")
.Select("Field999")
.Where(newCriteria("FirstOne")>=999)
.Where(newCriteria("SecondOne")>=999)
.Where(
newCriteria("Field1")==1&
newCriteria("Field2")!="ABC"&
newCriteria("Field3")>DateTime.Now&
newCriteria("Field4")>=Guid.NewGuid()&
newCriteria("Field5")<5L
)
SELECT
Field999
FROM
MyTable
WHERE
FirstOne>=@p1AND--@p1=999
SecondOne>=@p2AND--@p2=999
Field1==@p3AND--@p3=1
Field2<>@p4AND--@p4=N'ABC'
Field3>@p5AND--@p5='2016-01-31T01:16:23'
Field4>=@p6AND--@p6='23123-DEFCD-....'
Field5<@p7--@p7=5
Herethesamecriteriathatlistedbefore,usedparameternumbersstartingfrom3,insteadof
1.Becauseprior2numberswhereusedforotherWHEREstatementscomingbeforeit.
Soparameternumberingusesthequeryascontext.Youshouldn'tmakeassumptionsabout
whatparameternamewillbe.
ParamCriteriaandExplicitParamNames
Ifyouwanttousesomeexplicitlynamedparameter,youcanmakeuseofParamCriteria:
newSqlQuery()
.From("SomeTable")
.Select("SomeField")
.Where(newCriteria("SomeField")<=newParamCriteria("@myparam"))
.Where(newCriteria("SomeOtherField")==newParamCriteria("@myparam"))
.SetParam("@myparam",5);
HerewesetparamvalueusingSetParamextensionofSqlQuery.
CriteriaObjects
393

Wecouldalsodeclarethisparambeforehandandreuseit:
varmyParam=newParamCriteria("@myparam");
newSqlQuery()
.From("SomeTable")
.Select("SomeField")
.Where(newCriteria("SomeField")<=myParam)
.Where(newCriteria("SomeOtherField")==myParam)
.SetParam(myParam.Name,5);
ConstantCriteria
Ifyoudon'twanttouseparameterizedqueries,youmayputyourvaluesasConstantCriteria
objects.Theywillnotbeconvertedtoautoparameters.
newSqlQuery()
.From("MyTable")
.Select("MyField")
.Where(
newCriteria("Field1")==newConstantCriteria(1)&
newCriteria("Field2")!=newConstantCriteria("ABC")
)
SELECT
MyField
FROM
MyTable
WHERE
FirstOne>=1
SecondOne>=N'ABC'
NullComparison
InSQL,comparingagainstNULLvaluesusingoperatorslike ==, !=returnsNULL.You
shoulduseISNULLorISNOTNULLforsuchcomparisons.
Criteriaobjectsdon'toverloadcomparisonsagainstnull(orobject),soyoumaygeterrorsif
youtrytowriteexpressionslikebelow:
CriteriaObjects
394

newCriteria("a")==null;//whatistypeofnull?
intb?=null;
newCriteria("c")==b;//nooverloadfornullabletypes
ThesecouldbewrittenusingIsNullandNullable.Valuemethods:
newCriteria("a").IsNull();
newCriteria("a").IsNotNull();
int?b=5;
newCriteria("c")==b.Value;
IfyouaredesperatetowriteField=NULL,youcoulddothis:
newCriteria("Field")==newCriteria("NULL")
LIKEOperators
CriteriahasmethodsLike,NotLike,StartsWith,EndsWith,Contains,NotContainstohelp
withLIKEoperations.
newCriteria("a").Like("__C%")&
newCriteria("b").NotLike("D%")&
newCriteria("c").StartsWith("S")&
newCriteria("d").EndsWith("X")&
newCriteria("e").Contains("This")&
newCriteria("f").NotContains("That")
aLIKE@p1AND--@p1=N'__C%'
bNOTLIKE@p2AND--@p2=N'D%'
cLIKE@p3AND--@p3='S%'
dLIKE@p4AND--@p4=N'%X'
eLIKE@p5AND--@p5=N'%This%'
fNOTLIKE@p6--@p6=N'%That%'
INandNOTINOperators
UseaninlinearraytouseINorNOTIN:
newCriteria("A").In(1,2,3,4,5)
CriteriaObjects
395

AIN(@p1,@p2,@p3,@p4,@p5)
--@p1=1,@p2=2,@p3=3,@p4=4,@p5=5
newCriteria("A").NotIn(1,2,3,4,5)
ANOTIN(@p1,@p2,@p3,@p4,@p5)
--@p1=1,@p2=2,@p3=3,@p4=4,@p5=5
YoumayalsopassanyenumerabletoINmethod:
IEnumerable<int>x=newint[]{1,3,5,7,9};
newCriteria("A").In(x);
AIN(1,3,5,7,9)
--@p1=1,@p2=3,@p3=5,@p4=7,@p5=9
Itisalsopossibletouseasubquery:
varquery=newSqlQuery()
.From("MyTable")
.Select("MyField");
query.Where("SomeID").In(
query.SubQuery()
.From("SomeTable")
.Select("SomeID")
.Where(newCriteria("Balance")<0));
SELECT
MyField
FROM
MyTable
WHERE
SomeIDIN(
SELECT
SomeID
FROM
SomeTable
WHERE
Balance<@p1--@p1=0
)
CriteriaObjects
396

NOTOperator
UseC#!(not)operatortouseNOT:
!(newCriteria("a")>=5)
NOT(a>=@p1)--@p1=5
UsagewithFieldObjects
WehaveusedCriteriaobjectconstructorsofartobuildcriteria.Fieldobjectsalsohassimilar
overloads,sotheycanbeusedinplaceofthem.
Forexample,usingOrder,DetailandCustomerrowsfromNorthwindsample:
varo=OrderRow.Fields.As("o");
varod=OrderDetailRow.Fields.As("od");
varc=CustomerRow.Fields.As("c");
varquery=newSqlQuery()
.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"))&
newCriteria(
query.SubQuery()
.From(od)
.Select(Sql.Sum(od.LineTotal.Expression))
.Where(od.OrderID==o.OrderID))>=1000);
Itsoutputwouldbe:
CriteriaObjects
397

SELECT
o.CustomerIDAS[CustomerID]
FROM
Orderso
LEFTJOIN
Customerso_cON(o_c.CustomerID=o.CustomerID)
WHERE
o_c.[Country]=@p2
AND(CASEWHEN
o.[ShippedDate]ISNULLTHEN0
ELSE1
END)=@p3
ANDo.CustomerIDIN(
SELECT
c.CustomerIDAS[CustomerID]
FROM
Customersc
WHERE
c.Region=@p1)
AND(SELECT
SUM((od.[UnitPrice]*od.[Quantity]-od.[Discount]))
FROM
[OrderDetails]od
WHERE
od.OrderID=o.OrderID)>=@p4
CriteriaObjects
398

ConnectionsandTransactions
SerenityusessimpleADO.NETdataaccessobjects,likeSqlConnection,DbCommandetc.
Itprovidessomebasichelperstocreateaconnection,addparameters,executequeriesetc.
SqlConnectionsClass
[namespace:Serenity.Data,assembly:Serenity.Data]
Thisclasscontainsstaticfunctionstocreateaconnection,andcontrolitinadatabase
agnosticway.
SqlConnections.NewByKeymethod
publicstaticIDbConnectionNewByKey(stringconnectionKey)
UsethismethodtogetanewIDbConnectionforaconnectionstringdefinedinapplication
configurationfile(e.g.app.configorweb.config).
using(varconnection=SqlConnections.NewByKey("Default"))
{
//...
}
Trytoalwayswrapconnectionsinausingblock...
Thisreadsconnectionstringwith"Default"keyfromweb.config,andcreatesanew
connectionusingProviderNameinformationthatisalsospecifiedinconnectionsetting.For
example,ifProviderNameis"System.Data.SqlClient"thiscreatesanewSqlConnection
object.
Youusuallydon'thavetoopenconnectionsexplicitlyastheyareautomaticallyopened
whenneeded(aslongasyouuseSerenityextensions).
SqlConnections.NewFor<TClass>method
Ifyoudon'twanttomemorizeconnectionstringkeys,butinsteadreuseinformationonarow
(informofaConnectionKeyattribute),youmaypreferthisvariant.
ConnectionsandTransactions
399

LookingontopofaRowclass,youmayspotConnectionKeyattributegeneratedbySergen:
[ConnectionKey("Northwind")]
publicsealedclassCustomerRow:Row,IIdRow,INameRow
{
}
Whenyouaregoingtoqueryforcustomers,insteadofhardcoding"Northwind",youmay
reusethisinformationfromaCustomerRow:
using(varconnection=SqlConnections.NewFor<CustomerRow>())
{
returnconnection.List<CustomerRow>();
}
ThiscorrespondstoSqlConnections.NewByKey("Northwind").
Herewedidn'thavetoopentheconnection,asListextensionmethodopensit
automatically.
Theclassusedwiththismethoddoesn'thavetobeaRow,anyclasswithaConnectionKey
attributewouldwork,eventhoughitwouldbearowmostofthetime.
SqlConnections.Newmethod
publicstaticIDbConnectionNew(stringconnectionString,stringproviderName)
Youmaysometimeswanttocreateaconnectionthatdoesn'texistinyourconfigurationfile.
using(varconnection=SqlConnections.New(
"DataSource=(localdb)\v11.0;InitialCatalog=Northwind;
IntegratedSecurity=true","System.Data.SqlClient"))
{
//...
}
Herewehavetospecifyconnectionstringandtheprovidernamelike
"System.Data.SqlClient".
Youmightbeaskingyourself"whythismethodinsteadofsimplytypingnewSqlClient()?",
seenexttopicforadvantagesofthese.
WrappedConnection
ConnectionsandTransactions
400

AllmethodswesawsofarreturnsanIDbConnectionobject.You'dexpectittobea
SqlConnection,FirebirdConnectionetc,butthatsnotexactlytrue.
TheIDbConnectionobjectyoureceiveisaSerenityspecificWrappedConnectionobjectthat
actuallycontainsanunderlyingSqlConnectionorFirebirdConnectionetc.
ThishelpsSerenityprovidesomefeatureslikeautoopen,dialectsupport,default
transactions,unitofworkpattern,overridingconnectionsfortestabilityetc.
YoumaynotnoticethesedetailswhileworkingwithreturnedIDbConnectioninstances,
they'llactjustliketheunderlyingconnections,butyoushouldpreferSqlConnections
methodstocreateconnections,otherwiseyoumightlosesomeoftheselistedfeatures.
UnitOfWorkandIUnitOfWork
UnitOfWorkisasimpleobjectthatjustcontainsatransactionreference.Ithastwoextra
eventsthatwecanattachtoOnCommitandOnRollback.
Let'ssaywearecreatingtasks,andsomee-mailsshouldbesentincasethesetasksare
savedtodatabasesuccesfully.
Ifwehurryandsendthesee-mailsbeforetransactioniscommitted,wemightendupwithe-
mailsthataresentfornon-existenttasksincasetransactionfails.Soweshouldonlysende-
mailiftransactioniscommittedsuccessfully,e.g.inOnCommitevent.
YoumightsaythenCommittransactionfirstandsende-mailsrightafter,butwhatifour
CreateTaskservicecallisjustastepofalargeroperation,sowearenotcontrollingthe
transactionanditshouldbecommittedafterallstepsaresuccess.
Anotherscenarioisaboutuploadingfiles.ThistimeweareupdatingsomeFileentity,and
let'ssaywereplaceanoldfilewithuploadednewfile.Ifweagainhurryanddeleteoldfile
beforetransactionoutcomeisclear,andtransactionfailseventually,we'llendupwithafile
entitywithoutanactualoldfileindisk.So,weshouldactuallydeletefileandreplaceitwith
thenewfileinOnCommitevent,andremoveuploadedfileinOnRollbackevent.
ConnectionsandTransactions
401

voidSomeBatchOperation()
{
using(varconnection=SqlConnections.NewByKey("Default"))
using(varuow=newUnitOfWork(connection))
{
//hereweareinatransactioncontext
//createseveraltasksintransaction
CreateATask(newTaskRow{...});
CreateATask(newTaskRow{...});
//...
//committhetransaction
//ifanyexceptionoccurshereoratprior
//linestransactionwillrollback
//andnoe-mailswillbesent
uow.Commit();
}
}
voidCreateATask(IUnitOfWorkuow,TaskRowtask)
{
//inserttaskusingconnectionwrappedinsideIUnitOfWork
//thiswillautomaticallyrunintransactioncontext
uow.Connection.Insert(task);
uow.OnCommit+=()=>{
//sende-mailforthistasknow,thismethodwillonly
//becallediftransactioncommitssuccessfully
};
uow.OnRollback+=()=>{
//optional,dosomethingelseifitfails
};
}
ConnectionsandTransactions
402

WorkingWithOtherDatabaseTypes
SerenityhasadialectsystemforworkingwithdatabasetypesotherthanSqlServer.
Ifyouneedtosupportmultipledatabasetypes,justbychangingconnectionstringsin
web.config,youshouldbecarefulaboutnotusingdatabasespecificfunctionsinexpressions
andavoidusingreservedwords.
WarningAboutCONCATandOtherSimilarExpressionsIn
Rows
Serenehastosupportavarietyofdatabaseengines,includingMySQL,Postgressetc.
Thesedatabasesdon'thaveastringplus(+)operatorlikeMsSqlServer.Thus,inNorthwind,
CONCATfunctionisusedinplaceof'+'operator:
[Expression("CONCAT(T0.[FirstName],CONCAT('',T0.[LastName]))")]
publicStringFullName
{
get{returnFields.FullName[this];}
set{Fields.FullName[this]=value;}
}
CONCATisavailableafterSqlServer2012.Soifyouaregoingtouseanolderversionof
SQLserver,e.g.2005or2008,replacetheseexpressionswithsuch:
[Expression("T0.[FirstName]+''+T0.[LastName]")]
publicStringFullName
{
get{returnFields.FullName[this];}
set{Fields.FullName[this]=value;}
}
WorkingwithOtherDatabases
403

SetDatabaseDialectforConnections
SerenityautodetectsdialectforaconnectionbyusingtheproviderNameinweb.config.
Sometimes,automaticdialectdetectionusingproviderNamemaynotworkyou,oryoumight
wanttouseSqlServer2000orSqlServer2005dialectforsomeconnections.
Eventhoughitispossibletosetadefaultglobaldialect,thisdoesn'toverrideautomatic
detection):
SqlSettings.DefaultDialect=SqlServer2005Dialect.Instance;
Asprovidernamefor"Northwind"and"Default"connectionsis"System.Data.SqlClient",
SerenitywillautomaticallysettheirdialectstoSqlServer2012,evenifyouoverrideglobal
dialect.
Butitispossibletochangedialectonconnectionkeybasis:
publicstaticpartialclassSiteInitialization
{
publicstaticvoidApplicationStart()
{
try
{
SqlConnections.GetConnectionString("Default").Dialect=
SqlServer2005Dialect.Instance;
SqlConnections.GetConnectionString("Northwind").Dialect=
SqlServer2005Dialect.Instance;
Itisalsopossibletosetthisthroughanapplicationconfigurationentry(recommended):
<configuration>
<appSettings>
<addkey="ConnectionSettings"value="{
Default:{
Dialect:'SqlServer2005'
},
Northwind:{
Dialect:'Postgres'
}
"/>
SettingConnectionDialect
404

SettingConnectionDialect
405

DialectBasedExpressions
Sometimesitmightnotbepossibletouseacommonexpression.Forexample,Sqlitehas
noCONCAToperator.
Serenity2.8.1+supportsdialectbasedexpressions,e.g.
[DisplayName("FullName"),QuickSearch]
[Expression("CONCAT(T0.[FirstName],CONCAT('',T0.[LastName]))")]
[Expression("(T0.FirstName||''||T0.LastName)",Dialect="Sqlite")]
publicStringFullName
{
get{returnFields.FullName[this];}
set{Fields.FullName[this]=value;}
}
Here,asthefirstExpressionhasnodialect,itwillbeusedforanydatabasetype,unlessthe
connectioncorrespondingtothisrowhasdialectofSqlite,e.g.itisaSystem.Data.Sqlite
connection.
HowDialectforRowisDetermined
Todeterminedialecttypeforarow,theConnectionKeyattributeonrowisused(ifany),
otherwisethedefaultdialect(SqlSettings.DefaultDialect)isused.
Expressionforafieldisdetermined(fixed)atapplicationstart,soitisnotpossibletoswitch
expressionsbyswitchingconnectionsordialects.
Itisalsopossibletospecifymultipledialects:
[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")]
publicStringFullName
{
get{returnFields.FullName[this];}
set{Fields.FullName[this]=value;}
}
DialectMatching
DialectBasedExpressions
406

ISqlDialectinterfacehasaServerTypeproperty.ItisPostgresforPostgresDialect,SqlServer
forSqlServer2012Dialect,SqlServer2008DialectandSqlServer2005Dialect.
Foranexpressiondialecttomatchaconnectiondialect,itshouldstartwiththeServerType
and/ortheclassnameoftheconnectiondialect(e.g.SqlServer2012Dialect).
Ifmultipledialecttypesmatchatargetedexpression,theonewiththelongestname
matches.
Let'ssaywewrotethesetwoexpressions:
[Expression("CONCAT(T0.[FirstName],T0.[LastName])",Dialect="SqlServer")]
[Expression("T0.[FirstName]+T0.[LastName]",Dialect="SqlServer200")]
IfconnectiondialectisSqlServer2008,bothexpressionswouldmatch,butasSqlServer200
isalongermatchthanSqlServer,secondexpressionwillbeused.
IfconnectiondialectisSqlServer2012,onlythefirstexpressionwouldmatch.
DialectBasedExpressions
407

PostgreSQL
RegisteringNpgsqlProvider
PostgreSQLhasa.NETprovidernamedNpgsql.Youneedtofirstinstallitin
MyProject.Web:
Install-PackageNpgsql-ProjectMyProject.Web
Ifyoudidn'tinstallthisproviderinGAC/machine.configbefore,ordon'twanttoinstallit
there,youneedtoregisteritinweb.configfile:
<configuration>
//...
<system.data>
<DbProviderFactories>
<removeinvariant="Npgsql"/>
<addname="NpgsqlDataProvider"
invariant="Npgsql"
description=".NetDataProviderforPostgreSQL"
type="Npgsql.NpgsqlFactory,Npgsql,Culture=neutral,
PublicKeyToken=5d8b90d52f46fda7"
support="FF"/>
</DbProviderFactories>
</system.data>
//...
SettingConnectionStrings
NextstepistoreplaceconnectionstringsfordatabasesyouwanttousewithPostgres:
Makesureyoureplaceconnectionstringparameterswithvaluesforyourserver
<connectionStrings>
<addname="Default"connectionString="
Server=127.0.0.1;Database=serene_default_v1;
UserId=postgres;Password=yourpassword;"
providerName="Npgsql"/>
<addname="Northwind"connectionString="
Server=127.0.0.1;Database=serene_northwind_v1;
UserId=postgres;Password=yourpassword;"
providerName="Npgsql"/>
</connectionStrings>
PostgreSQL
408

SettingConnectionStrings-.NetCoreappsettings.json
"Data":{
"Default":{
"ConnectionString":"Server=127.0.0.1;Database=serene_default_v1;UserId=postgre
s;Password=yourpassword;",
"ProviderName":"Npgsql"
},
"Northwind":{
"ConnectionString":"Server=127.0.0.1;Database=serene_northwind_v1;UserId=postg
res;Password=yourpassword;",
"ProviderName":"Npgsql"
}
},
Pleaseuselowercasedatabasenameslike serene_default_v1asPostgreswillalways
convertittolowercase.
Providernamemustbe NpgsqlforSerenitytoauto-detectdialect.
NotesAboutIdentifierCaseSensitivy
PostgreSQLiscasesensitiveforidentifiers.
FluentMigratorautomaticallyquotesallidentifiers,sotablesandcolumnnamesindatabase
willbequotedandcasesensitive.Thismightcauseproblemswhentables/columnsaretried
tobeselectedwithoutquotedidentifiers.
Oneoptionistoalwaysuselowercaseidentifiersinmigrations,butsuchnamingscheme
won'tlooksoniceforotherdatabasetypes,thuswedidn'tpreferthisway.
TopreventsuchproblemswithPostgres,Serenityhasanautomaticquotingfeature,to
resolvecompabilitywithPostgres/FluentMigrator,whichshouldbeenabledinapplication
startmethodofSiteInitialization.cs:
publicstaticvoidApplicationStart()
{
try
{
SqlSettings.AutoQuotedIdentifiers=true;
Serenity.Web.CommonInitialization.Run();
MakesureitisbeforeCommonInitialization.Runline
Thissettingautomaticallyquotescolumnnamesinentities,butnotmanuallywritten
expressions(withExpressionattributeforexample).
PostgreSQL
409

Usebrackets []foridentifiersinexpressionsifyouwanttosupportmultipledatabase
types.Serenitywillautomaticallyconvertbracketstodatabasespecificquotetypebefore
runningqueries.
Youmightalsoprefertousedoublequotesinexpressions,butitmightnotbecompatible
withotherdatabaseslikeMySQL.
RegisteringPostgreSQLDbProviderFactory
Openthe Startup.csfileunder{SerenityProject}/Initialization/anduncommentthelastline,
asshownbelow.
publicstaticvoidRegisterDataProviders()
{
//DbProviderFactories.RegisterFactory("System.Data.SqlClient",
//SqlClientFactory.Instance);
//DbProviderFactories.RegisterFactory("Microsoft.Data.Sqlite",
//Microsoft.Data.Sqlite.SqliteFactory.Instance);
//toenableFIREBIRD:addFirebirdSql.Data.FirebirdClientreference,setconnectio
ns,anduncommentlinebelow
//DbProviderFactories.RegisterFactory("FirebirdSql.Data.FirebirdClient",
//FirebirdSql.Data.FirebirdClient.FirebirdClientFactory.Instance);
//toenableMYSQL:addMySql.Datareference,setconnections,anduncommentlineb
elow
//DbProviderFactories.RegisterFactory("MySql.Data.MySqlClient",
//MySql.Data.MySqlClient.MySqlClientFactory.Instance);
//toenablePOSTGRES:addNpgsqlreference,setconnections,anduncommentlinebe
low
DbProviderFactories.RegisterFactory("Npgsql",Npgsql.NpgsqlFactory.Instance);
}
SettingDefaultDialect
Thisstepisoptional.
Serenityautomaticallydetermineswhichdialecttouse,bylookingatproviderNameof
connectionstrings.
Itcanevenworkwithmultipledatabasetypesatthesametime.
Forexample,NorthwindmightstayinSqlServer,whileDefaultdatabaseusesPostgreSQL.
But,ifyouaregoingtouseonlyonedatabasetypepersite,youcanregisterwhichyouare
goingtousebydefaultinSiteInitialization:
PostgreSQL
410

publicstaticvoidApplicationStart()
{
try
{
SqlSettings.DefaultDialect=PostgresDialect.Instance;
SqlSettings.AutoQuotedIdentifiers=true;
Serenity.Web.CommonInitialization.Run();
Defaultdialectisusedwhenthedialectforaconnection/entityetc.couldn'tbeauto
determined.
Thissettingdoesn'toverrideautomaticdetection,itisjustusedasfallback.
LaunchingApplication
Nowlaunchyourapplication,itshouldautomaticallycreatedatabases,iftheyarenot
createdmanuallybefore.
ConfiguringCodeGenerator
Sergendoesn'thavereferencetoPostgreSQLprovider,soifyouwanttouseittogenerate
code,youmustalsoregisterthisproviderwithit.
Sergen.exeisanexefile,soyoucan'taddaNuGetreferencetoit.Weneedtoregisterthis
providerinapplicationconfigfile.
ItisalsopossibletoregistertheproviderinGAC/machine.configandskipthisstep
completely.
LocateSergen.exe,whichisunderafolderlike
packages/Serenity.CodeGenerator.1.8.6/toolsandcreateafilenamed Sergen.exe.config
nexttoitwithcontentsbelow:
PostgreSQL
411

<?xmlversion="1.0"encoding="utf-8"?>
<configuration>
<system.data>
<DbProviderFactories>
<removeinvariant="Npgsql"/>
<addname="NpgsqlDataProvider"
invariant="Npgsql"
description=".NetDataProviderforPostgreSQL"
type="Npgsql.NpgsqlFactory,Npgsql,Culture=neutral,
PublicKeyToken=5d8b90d52f46fda7"
support="FF"/>
</DbProviderFactories>
</system.data>
<appSettings>
<addkey="LoadProviderDLLs"value="Npgsql.dll"/>
</appSettings>
</configuration>
AlsocopyNpgsql.dlltosamefolderwhereSergen.exeresides.NowSergenwillbeableto
generatecodeforyourPostgrestables.
Youmightwanttoremove [public].prefixfordefaultschemafromtablename/column
expressionsingeneratedrowsifyouwanttobeabletoworkwithmultipledatabases.
PostgreSQL
412

MySql
.NETFramework
RegisteringMySqlProvider
MySQLhasa.NETprovidernamedMySql.Data.YouneedtofirstinstallitinMyProject.Web:
Install-PackageMySql.Data-ProjectMyProject.Web
Ifyoudidn'tinstallthisproviderinGAC/machine.configbefore,ordon'twanttoinstallit
there,youneedtoregisteritinweb.configfile(MySql.DataNuGetpackagealreadydoesthis
oninstall):
<configuration>
//...
<system.data>
<DbProviderFactories>
<removeinvariant="MySql.Data.MySqlClient"/>
<addname="MySQLDataProvider"
invariant="MySql.Data.MySqlClient"
description=".NetFrameworkDataProviderforMySQL"
type="MySql.Data.MySqlClient.MySqlClientFactory,
MySql.Data,Culture=neutral,
PublicKeyToken=c5687fc88969c44d"/>
</DbProviderFactories>
</system.data>
//...
SettingConnectionStrings
NextstepistoreplaceconnectionstringsfordatabasesyouwanttousewithMySql:
Makesureyoureplaceconnectionstringparameterswithvaluesforyourserver
MySQL
413

<connectionStrings>
<addname="Default"connectionString="
Server=localhost;Port=3306;Database=Serene_Default_v1;
Uid=root;Pwd=yourpass"
providerName="MySql.Data.MySqlClient"/>
<addname="Northwind"connectionString="
Server=localhost;Port=3306;Database=Serene_Northwind_v1;
Uid=root;Pwd=yourpass"
providerName="MySql.Data.MySqlClient"/>
</connectionStrings>
Providernamemustbe MySql.Data.MySqlClientforSerenitytoauto-detectdialect.
Readnotesabovetooverridedefaultdialect.
MySqluseslowercasedatabase(schema)andtablenames,butdoesn'thavethecase
sensitivityproblemwetalkedaboutPostgres.
ConfiguringCodeGenerator
Sergendoesn'thavereferencetoMySqlprovider,soifyouwanttouseittogeneratecode,
youmustalsoregisterthisproviderwithit.
Sergen.exeisanexefile,soyoucan'taddaNuGetreferencetoit.Weneedtoregisterthis
providerinapplicationconfigfile.
ItisalsopossibletoregistertheproviderinGAC/machine.configandskipthisstep
completely.
LocateSergen.exe,whichisunderafolderlike
packages/Serenity.CodeGenerator.1.8.6/toolsandcreateafilenamed Sergen.exe.config
nexttoitwithcontentsbelow:
MySQL
414

<?xmlversion="1.0"encoding="utf-8"?>
<configuration>
<system.data>
<DbProviderFactories>
<removeinvariant="MySql.Data.MySqlClient"/>
<addname="MySQLDataProvider"
invariant="MySql.Data.MySqlClient"
description=".NetFrameworkDataProviderforMySQL"
type="MySql.Data.MySqlClient.MySqlClientFactory,
MySql.Data,Culture=neutral,
PublicKeyToken=c5687fc88969c44d"/>
</DbProviderFactories>
</system.data>
<appSettings>
<addkey="LoadProviderDLLs"value="MySql.Data.dll"/>
</appSettings>
</configuration>
AlsocopyMySql.Data.dlltosamefolderwhereSergen.exeresides.NowSergenwillbeable
togeneratecodeforyourMySqltables.
.NETCore
RegisteringMySqlProvider
MySQLhasa.NETprovidernamedMySql.Data.Youneedtofirstinstallitin
MyProject.AspNetCore:
Openproject.jsonandaddpackageasfollows:
{
"dependencies":{
//...
"Serenity.FluentMigrator.Runner":"1.6.903",
"MySql.Data":"7.0.6-IR31"
},
MakesureyouhaveSerenity.FluentMigrator.Runner1.6.903+
OpenInitialization/Startup.csfile,registerthisfactoryinSerenity:
MySQL
415

DbProviderFactories.RegisterFactory(
"System.Data.SqlClient",SqlClientFactory.Instance);
DbProviderFactories.RegisterFactory(
"MySql.Data.MySqlClient",
MySql.Data.MySqlClient.MySqlClientFactory.Instance);
ConfiguringCodeGenerator
Asofwritingdotnet-sergendoesn'tyetsupportanydatabasesotherthanSqlServer.
MySQL
416

Sqlite
RegisteringSqliteProvider
Sqlitehasa.NETprovidernamedSystem.Data.Sqlite.Youneedtofirstinstallitin
MyProject.Web:
Install-PackageSystem.Data.SQLite.Core-ProjectMyProject.Web
Ifyoudidn'tinstallthisproviderinGAC/machine.configbefore,ordon'twanttoinstallit
there,youneedtoregisteritinweb.configfile:
<configuration>
//...
<system.data>
<DbProviderFactories>
<removeinvariant="System.Data.SQLite"/>
<addname="SQLiteDataProvider"
invariant="System.Data.SQLite"
description=".NetFrameworkDataProviderforSQLite"
type="System.Data.SQLite.SQLiteFactory,System.Data.SQLite"/>
</DbProviderFactories>
</system.data>
//...
SettingConnectionStrings
NextstepistoreplaceconnectionstringsfordatabasesyouwanttousewithSqlite:
<connectionStrings>
<addname="Default"connectionString=
"DataSource=|DataDirectory|Serene_Default_v1.sqlite;"
providerName="System.Data.Sqlite"/>
<addname="Northwind"connectionString=
"DataSource=|DataDirectory|Serene_Northwind_v1.sqlite;"
providerName="System.Data.Sqlite"/>
</connectionStrings>
ApplyingSqliteChangestoSerene
Sqliteproviderhasbeenaddedrecently,soifyoualreadyhaveanapplication,you'llneedto
getlatestversionofSiteInitialization.Migrations.csfromlatesttemplate/githubrepositoryto
getSqlitesupport.
Sqlite
417

Providernamemustbe System.Data.SqliteforSerenitytoauto-detectdialect.Read
notesabovetooverridedefaultdialect.
I'mnotsurewhy,butwhileFluentMigratorcreatesNorthwinddatabaseforSqlitefirst
time,ittakessometime.
ConfiguringCodeGenerator
Sergendoesn'thavereferencetoSqliteprovider,soifyouwanttouseittogeneratecode,
youmustalsoregisterthisproviderwithit.
Sergen.exeisanexefile,soyoucan'taddaNuGetreferencetoit.Weneedtoregisterthis
providerinapplicationconfigfile.
ItisalsopossibletoregistertheproviderinGAC/machine.configandskipthisstep
completely.
LocateSergen.exe,whichisunderafolderlike
packages/Serenity.CodeGenerator.1.8.6/toolsandcreateafilenamed Sergen.exe.config
nexttoitwithcontentsbelow:
<?xmlversion="1.0"encoding="utf-8"?>
<configuration>
<system.data>
<DbProviderFactories>
<removeinvariant="System.Data.SQLite"/>
<addname="SQLiteDataProvider"
invariant="System.Data.SQLite"
description=".NetFrameworkDataProviderforSQLite"
type="System.Data.SQLite.SQLiteFactory,System.Data.SQLite"/>
</DbProviderFactories>
</system.data>
<appSettings>
<addkey="LoadProviderDLLs"value="Sqlite.Data.dll"/>
</appSettings>
</configuration>
AlsocopySystem.Data.Sqlite.dllanditsx86andx64foldersunderbindirectorytosame
folderwhereSergen.exeresides.NowSergenwillbeabletogeneratecodeforyourSqlite
tables.
Sqlite
418

Oracle
OraclesupportisavailableforSerene2.2.2+
RegisteringOracleProvider
Oraclehasamanaged.NETprovidernamedOracle.ManagedDataAccess.Youneedtofirst
installitinMyProject.Web:
Install-PackageOracle.ManagedDataAccess-ProjectMyProject.Web
Ifyoudidn'tinstallthisproviderinGAC/machine.configbefore,ordon'twanttoinstallit
there,youneedtoregisteritinweb.configfile(Oracle.ManagedDataAccessNuGetpackage
alreadydoesthisoninstall):
<configuration>
//...
<system.data>
<DbProviderFactories>
<removeinvariant="Oracle.ManagedDataAccess.Client"/>
<addname="ODP.NET,ManagedDriver"
invariant="Oracle.ManagedDataAccess.Client"
description="OracleDataProviderfor.NET,ManagedDriver"
type="Oracle.ManagedDataAccess.Client.OracleClientFactory,
Oracle.ManagedDataAccess,Version=4.121.2.0,Culture=neutral,
PublicKeyToken=89b483f429c47342"/>
</DbProviderFactories>
</system.data>
//...
CreatingDatabases
Serenecan'tautocreatedatabase(tablespace)forOracle.Youmightcreatethemyourself,
oruseascriptlikebelow(iusedthisforXE):
Oracle
419

CREATETABLESPACESerene_Default_v1_TABS
DATAFILE'Serene_Default_v1_TABS.dat'SIZE10MAUTOEXTENDON;
CREATETEMPORARYTABLESPACESerene_Default_v1_TEMP
TEMPFILE'Serene_Default_v1_TEMP.dat'SIZE5MAUTOEXTENDON;
CREATEUSERSerene_Default_v1
IDENTIFIEDBYsomepassword
DEFAULTTABLESPACESerene_Default_v1_TABS
TEMPORARYTABLESPACESerene_Default_v1_TEMP;
GRANTCREATESESSIONTOSerene_Default_v1;
GRANTCREATETABLETOSerene_Default_v1;
GRANTCREATESEQUENCETOSerene_Default_v1;
GRANTCREATETRIGGERTOSerene_Default_v1;
GRANTUNLIMITEDTABLESPACETOSerene_Default_v1;
CREATETABLESPACESerene_Northwind_v1_TABS
DATAFILE'Serene_Northwind_v1_TABS.dat'SIZE10MAUTOEXTENDON;
CREATETEMPORARYTABLESPACESerene_Northwind_v1_TEMP
TEMPFILE'Serene_Northwind_v1_TEMP.dat'SIZE5MAUTOEXTENDON;
CREATEUSERSerene_Northwind_v1
IDENTIFIEDBYsomepassword
DEFAULTTABLESPACESerene_Northwind_v1_TABS
TEMPORARYTABLESPACESerene_Northwind_v1_TEMP;
GRANTCREATESESSIONTOSerene_Northwind_v1;
GRANTCREATETABLETOSerene_Northwind_v1;
GRANTCREATESEQUENCETOSerene_Northwind_v1;
GRANTCREATETRIGGERTOSerene_Northwind_v1;
GRANTUNLIMITEDTABLESPACETOSerene_Northwind_v1;
SettingConnectionStrings
YoumightwanttoconfigureyourdatasourcesforORACLE.IusedExpressEdition(XE)
here:
<configuration>
<oracle.manageddataaccess.client>
<versionnumber="*">
<dataSources>
<dataSourcealias="XE"
descriptor="
(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)
(HOST=localhost)(PORT=1521))
(CONNECT_DATA=(SERVICE_NAME=XE)))"/>
</dataSources>
</version>
</oracle.manageddataaccess.client>
</configuration>
NextstepistoreplaceconnectionstringsfordatabasesyouwanttousewithOracle:
Oracle
420

Makesureyoureplaceconnectionstringparameterswithvaluesforyourserver
<connectionStrings>
<addname="Default"connectionString="
DataSource=XE;UserId=Serene_Default_v1;Password=somepassword;"
providerName="Oracle.ManagedDataAccess.Client"/>
<addname="Northwind"connectionString="
DataSource=XE;UserId=Serene_Northwind_v1;Password=somepassword;"
providerName="Oracle.ManagedDataAccess.Client"/>
</connectionStrings>
Providernamemustbe Oracle.ManagedDataAccess.ClientforSerenitytoauto-detect
dialect.Readnotesabovetooverridedefaultdialect.
ConfiguringCodeGenerator
Sergendoesn'thavesupportforOracleyet,hopefullycomingsoon...
Oracle
421

Services
422

ServiceEndpoints
InSerenity,ServiceEndpointsareasubclassofASP.NETMVCcontrollers.
HereisanexcerptfromNorthwindOrderEndpoint:
namespaceSerene.Northwind.Endpoints
{
[RoutePrefix("Services/Northwind/Order"),Route("{action}")]
[ConnectionKey("Northwind"),ServiceAuthorize(Northwind.PermissionKeys.General)]
publicclassOrderController:ServiceEndpoint
{
[HttpPost]
publicSaveResponseCreate(IUnitOfWorkuow,SaveRequest<MyRow>request)
{
returnnewMyRepository().Create(uow,request);
}
publicListResponse<MyRow>List(IDbConnectionconnection,ListRequestrequest)
{
returnnewMyRepository().List(connection,request);
}
}
}
ControllerNamingandNamespace
OurclasshasnameOrderController,eventhoughitsfileisnamedOrderEndpoint.cs.Thisis
duetoaASP.NETMVClimitation(whichidon'tfindsological)thatallcontrollersmustend
withControllersuffix.
Ifyoudon'tendyourcontrollerclassnamewiththissuffix,youractionswillsimplywon't
work.Sobeverycarefulwiththis.
Ialsodidthismistakeseveraltimesanditcostmehours.
Namespaceofthisclass(Serene.Northwind.Endpoints)isnotimportantatall,thoughwe
usuallyputendpointsunderMyProject.Module.Endpointsnamespaceforconsistency.
OurOrderControllerderivesfromServiceEndpoint(andshould),whichprovidesthisMVC
controllerwithnotsousualfeaturesthatwe'llseeshortly.
RoutingAttributes
ServiceEndpoints
423

[RoutePrefix("Services/Northwind/Order"),Route("{action}")]
Routingattributesabove,whichbelongstoASP.NETattributerouting,configuresbase
addressforourserviceendpoint.Ouractionswillbeavailableunder
"mysite.com/Services/Northwind/Order".
PleaseavoidclassicASP.NETMVCrouting,whereyouconfiguredallroutesin
ApplicationStartmethodwithroutes.AddRouteetc.Itwasreallyhardtomanage.
AllSerenityserviceendpointsuses/Services/Module/Entityaddressingschemebydefault.
Againeventhoughyou'dbeabletouseanotheraddressscheme,thisisrecommendedfor
consistencyandbasicconventions.
ConnectionKeyAttribute
Thisattributespecificieswhichconnectionkeyinyourapplicationconfigurationfile(e.g.
web.config)shouldbeusedtocreateaconnectionwhenneeded.
Let'sseewhenandhowthisautocreatedconnectionisused:
publicListResponse<MyRow>List(IDbConnectionconnection,ListRequestrequest)
{
returnnewMyRepository().List(connection,request);
}
HereweseethatthisactiontakesaIDbConnectionparameter.Youcan'tsenda
IDbConnectiontoanMVCactionfromclientside.Sowhocreatesthisconnection?
RememberthatourcontrollerderivesfromServiceEndpoint?SoServiceEndpoint
understandsthatouractionrequiresaconnection.Itchecks[ConnectionKey]attributeon
topofcontrollerclasstodetermineconnectionkey,createsaconnectionusing
SqlConnections.NewByKey(),executesouractionwiththisconnection,andwhenaction
endsexecuting,closestheconnection.
You'dbeabletoremovethisconnectionparameterfromtheactionandcreateitmanually:
publicListResponse<MyRow>List(ListRequestrequest)
{
using(varconnection=SqlConnections.NewByKey("Northwind"))
{
returnnewMyRepository().List(connection,request);
}
}
ServiceEndpoints
424

ActuallythisiswhatServiceEndpointdoesbehindthescenes.
Whynotusethisfeaturewhileplatformhandlesthisdetailautomatically?Onereasonwould
bewhenyouneedtoopenacustomconnectionthatisnotlistedintheconfigfile,oropena
dynamiconedependingonsomeconditions.
WehaveanothermethodthattakesIUnitOfWork(transaction),insteadofIDbConnection
parameter:
publicSaveResponseCreate(IUnitOfWorkuow,SaveRequest<MyRow>request)
{
returnnewMyRepository().Create(uow,request);
}
Heresituationissimilar.ServiceEndpointagaincreatesaconnection,butthistimestartsa
transactiononit(IUnitOfWork),callsouractionmethod,andwhenitreturnswillcommit
transactionautomatically.Again,ifitfails,wouldrollbackit.
Hereisthemanualversionofthesamething:
publicSaveResponseCreate(SaveRequest<MyRow>request)
{
using(varconnection=SqlConnections.NewByKey("Northwind"))
using(varuow=newUnitOfWork(connection))
{
varresult=newMyRepository().Create(uow,request);
uow.Commit();
returnresult;
}
}
So,ServiceEndpointhandlessomethingthattakes8linesin1lineofcode.
WhenToUseIUnitOfWork/IDbConnection
Byconvention,Serenityactionmethodsthatmodifysomestate(CREATE,UPDATEetc.)
shouldruninsideatransaction,thustakeanIUnitOfWorkparameter,andonesthatareread
onlyoperations(LIST,RETRIEVE)shoulduseIDbConnection.
IfyourservicemethodtakesaIUnitOfWorkparameter,thisisasignaturethatyourmethod
mightmodifysomedata.
About[HttpPost]Attribute
ServiceEndpoints
425

YoumayhavenoticedthatCreate,Update,DeleteetcmethodshasthisattributewhileList,
Retrieveetc.not.
ThisattributelimitsCreate,Update,DeleteactionstoHTTPPOSTonly.Itdoesn'tallowthem
tobecalledbyHTTPGET.
Thisisbecause,thesemethodsareonesthatmodifysomestate,e.g.insert,update,delete
somerecordsfromDB,sotheyshouldn'tbeallowedtobecalledunintentionally,andtheir
resultsshouldn'tbeallowedtobecached.
Thisalsohassomesecurityimplications.ActionswithGETmethodmightbesubjectto
someattacks.
List,Retrievedoesn'tmodifyanything,sotheyareallowedtobecalledwithGET,e.g.typing
inabrowseraddressbar.
Eventhough,List,RetrievecanbecalledbyGET,Serenityalwayscallsservicesusing
HTTPPOSTwhenyouuseitsmethods,e.g.Q.CallService,andwillturnofcachingtoavoid
unexpectedresults.
ServiceAuthorizeAttribute
OurcontrollerclasshasServiceAuthorizeattribute:
ServiceAuthorize(Northwind.PermissionKeys.General)
ThisattributeissimilartoASP.NETMVC[Authorize]attributebutitchecksonlythatuseris
loggedin,andthrowsanexceptionotherwise.
Ifusedwithnoparameters,e.g.[ServiceAuthorize()]thisattributealsochecksthatuseris
loggedin.
Whenyoupassitapermissionkeystring,itwillcheckthatuserisloggedinandalsohas
thatpermission.
ServiceAuthorize("SomePermission")
Ifuserisnotgranted"SomePermission",thiswillpreventhimfromexecutinganyendpoint
method.
Thereisalso[PageAuthorize]attributethatworkssimilar,butyoushouldprefer
[ServiceAuthorize]withserviceendpoints,becauseitserrorhandlingismoresuitablefor
services.
ServiceEndpoints
426

While[PageAuthorize]redirectsusertotheLoginpage,ifuserdoesn'thavethepermission,
ServiceAuthorizereturnsamoresuitableNotAuthorizedserviceerror.
It'salsopossibletouse[ServiceAuthorize]attributeonactions,insteadofcontroller:
[ServiceAuthorize("SomePermissionThatIsRequiredForCreate")]
publicSaveResponseCreate(SaveRequest<MyRow>request)
AboutRequestandResponseObjects
ExceptthespeciallyhandledIUnitOfWorkandIDbConnectionparameters,allSerenity
serviceactionstakesasinglerequestparameterandreturnsasingleresult.
publicSaveResponseCreate(IUnitOfWorkuow,SaveRequest<MyRow>request)
Let'sstartwiththeresult.IfyouhavesomebackgroundonASP.NETMVC,you'dknowthat
controllerscan'treturnarbitraryobjects.Theymustreturnobjectsthatderivefrom
ActionResult.
ButourSaveResponsederivesfromServiceResponsewhichisjustanordinaryobject:
publicclassSaveResponse:ServiceResponse
{
publicobjectEntityId;
}
publicclassServiceResponse
{
publicServiceErrorError{get;set;}
}
Howthisispossible?AgainServiceEndpointhandlesthisdetailbehindthescenes.It
transformsourSaveResponsetoaspecialactionresultthatreturnsJSONdata.
Wedon'thavetoworryaboutthisdetailaslongasourresponseobjectderivesfrom
ServiceResponseandisJSONserializable.
Again,ourrequestobjectisalsoanordinaryclassthatderivesfromabasicServiceRequest:
ServiceEndpoints
427

publicclassSaveRequest<TEntity>:ServiceRequest,ISaveRequest
{
publicobjectEntityId{get;set;}
publicTEntityEntity{get;set;}
}
publicclassServiceRequest
{
}
ServiceEndpointtakestheHTTPrequestcontentwhichisusuallyJSON,deserializesitinto
ourrequestparameter,usingaspecialMVCactionfilter(JsonFilter).
Ifyouwanttousesomecustomactions,yourmethodsshouldalsofollowthisphilosophy,
e.g.takejustonerequest(derivingfromServiceRequest)andreturnoneresponse(deriving
fromServiceResponse).
Let'saddaservicemethodthatreturnscountofallordersgreaterthansomeamount:
publicclassMyOrderCountRequest:ServiceRequest
{
publicdecimalMinAmount{get;set;}
}
publicclassMyOrderCountResponse:ServiceResponse
{
publicintCount{get;set;}
}
publicclassOrderController:ServiceEndpoint
{
publicMyOrderCountResponseMyOrderCount(IDbConnectionconnection,
MyOrderCountRequestrequest)
{
varfld=OrderRow.Fields;
returnnewMyOrderCountResponse
{
Count=connection.Count<OrderRow>(fld.TotalAmount>=request.MinAmount);
};
}
}
Pleasefollowthispatternandtrynottoaddmoreparameterstoactionmethods.Serenity
followsmessagebasedpattern,withonlyonerequestobject,thatcanbeextendedlaterby
addingmoreproperties.
Don'tdothis(whichiscalledRPC-Remoteprocedurecallstyle):
ServiceEndpoints
428

publicclassOrderController:ServiceEndpoint
{
publicdecimalMyOrderCount(IDbConnectionconnection,
decimalminAmount,decimalmaxAmount,....)
{
//...
}
}
Preferthis(messagebasedservices):
publicclassMyOrderCountRequest:ServiceRequest
{
publicdecimalMinAmount{get;set;}
publicdecimalMaxAmount{get;set;}
}
publicclassOrderController:ServiceEndpoint
{
publicMyOrderCountResponseMyOrderCount(IDbConnectionconnection,
MyOrderCountRequestrequest)
{
//...
}
}
Thiswillavoidhavingtorememberparameterorder,willmakeyourrequestobjects
extensiblewithoutbreakingbackwardscompability,andhavemanymoreadvantagesthat
youmaynoticelater.
WhyEndpointMethodsAreAlmostEmpty
Weusuallydelegateactualworktoourrepositorylayer:
publicListResponse<MyRow>List(IDbConnectionconnection,ListRequestrequest)
{
returnnewMyRepository().List(connection,request);
}
RememberthatServiceEndpointhasadirectdependencytoASP.NETMVC.Thismeans
thatanycodeyouwriteinsideaserviceendpointwillhaveadependencytoASP.NETMVC,
andthuswebenvironment.
ServiceEndpoints
429

Youmaynotbeabletoreuseanycodeyouwrotehere,fromlet'ssayadesktopapplication,
orwon'tbeabletoisolatethiscodeintoaDLLthatdoesn'thaveareferencetoWEB
libraries.
Butifyoureallydon'thavesucharequirement,youcanremoverepositoriesalltogetherand
writeallyourcodeinsidetheendpoint.
Somepeoplemightarguethatentities,repositories,businessrules,endpointsetc.shouldall
beintheirownisolatedassemblies.Intheory,andforsomescenariosthismightbevalid,
butsome(ormost)usersdon'tneedsomuchisolation,andmayfallintoYAGNI(youaren't
gonnaneedit)category.
ServiceEndpoints
430

ListRequestHandler
ThisisthebaseclassthathandlesListrequestsoriginatingfromclientside,e.g.fromgrids.
Let'sfirstsamplewhenandhowthisclasshandleslistrequests:
1. Firstalistrequestmustbetriggeredfromclientside.Possibleoptionsare:
a)Youopenalistpagethatcontainsagrid.Rightafteryourgridobjectiscreatedit
buildsupaListRequestobject,basedoncurrentlyvisiblecolumns,initialsortorder,
filtersetc.andsubmitsittoserverside.
b)Userclicksonacolumnheadertosort,clickspagingbuttonsorrefreshbuttonto
triggersameeventsinoptionA.
c)YoumightmanuallycallalistserviceusingXYZService.Listmethod.
2. Aservicerequest(AJAX)toMVCXYZController(infileXYZEndpoint.cs)arrivesat
server.RequestparametersaredeserializedfromJSONintoaListRequestobject.
3. XYZEndpointcallsXYZRepository.ListmethodwithretrievedListRequestobject.
4. XYZRepository.ListmethodcreatesasubclassofListRequestHandler
(XYZRepository.MyListHandler)andinvokesitsProcessmethodwiththeListRequest.
5. ListRequestHandler.ProcessmethodbuildsupadynamicSQLquery,basedonthe
ListRequest,metadatainitsentitytype(Row)andotherinformationandexecutesit.
6. ListRequestHandler.ProcessreturnsaListResponsewithEntitiesmemberthatcontains
rowstobereturned.
7. XYZEndpointreceivesthisListResponse,returnsitfromaction.
8. ListResponseisserializedtoJSON,sentbacktoclient
9. Gridreceivesentities,updatesitsdisplayedrowsandotherpartslikepagingstatus.
We'llcoverhowgridsbuildandsubmitalistrequestinanotherchapter.Let'sfocuson
ListRequestHandlerfornow.
ListRequestObject
FirstweshouldhavealookatwhatmembersaListRequestobjecthave:
ListRequestHandler
431

publicclassListRequest:ServiceRequest,IIncludeExcludeColumns
{
publicintSkip{get;set;}
publicintTake{get;set;}
publicSortBy[]Sort{get;set;}
publicstringContainsText{get;set;}
publicstringContainsField{get;set;}
publicDictionary<string,object>EqualityFilter{get;set;}
[JsonConverter(typeof(JsonSafeCriteriaConverter))]
publicBaseCriteriaCriteria{get;set;}
publicboolIncludeDeleted{get;set;}
publicboolExcludeTotalCount{get;set;}
publicColumnSelectionColumnSelection{get;set;}
[JsonConverter(typeof(JsonStringHashSetConverter))]
publicHashSet<string>IncludeColumns{get;set;}
[JsonConverter(typeof(JsonStringHashSetConverter))]
publicHashSet<string>ExcludeColumns{get;set;}
}
ListRequest.SkipandListRequest.TakeParameters
TheseoptionsareusedforpagingandsimilartoSkipandPageextensionsinLINQ.
ThereisonelittledifferenceaboutTake.IfyouTake(0),LINQwillreturnyouzerorecords,
whileSerenitywillreturnALLrecords.ThereisnopointincallingaLISTserviceand
requesting0records.
So,SKIPandTAKEhasdefaultvaluesof0,andtheyaresimplyignoredwhen0/undefined.
//returnsallcustomersasSkipandTakeare0bydefault
CustomerService.List(newListRequest
{
},response=>{});
Ifyouhaveagridthathaspagesize50andswitchtopagenumber4,SKIPwillbe200while
TAKEis50.
//returnscustomersbetweenrownumbers201and250(insomedefaultorder)
CustomerService.List(newListRequest
{
Skip=200,
Take=50
},response=>{});
TheseparametersareconvertedtorelevantSQLpagingstatementsbasedonSQLdialect.
ListRequestHandler
432

ListRequest.SortParameter
Thisparametertakesanarraytosortresultson.SortingisperformedbygeneratingSQL.
SortByparameterexpectsalistofSortByobjects:
[JsonConverter(typeof(JsonSortByConverter))]
publicclassSortBy
{
publicSortBy()
{
}
publicSortBy(stringfield)
{
Field=field;
}
publicSortBy(stringfield,booldescending)
{
Field=field;
Descending=descending;
}
publicstringField{get;set;}
publicboolDescending{get;set;}
}
WhencallingaListmethodofXYZRepositoryserversidetosortbyCountrythenCity
descending,youmightdoitlikethis:
newCustomerRepository().List(connection,newListRequest
{
SortBy=new[]{
newSortBy("Country"),
newSortBy("City",descending:true)
}
});
SortByclasshasacustomJsonConvertersowhenbuildingalistrequestclientside,you
shoulduseasimplestringarray:
ListRequestHandler
433

//CustomerEndpointandthusCustomerRepositoryisaccessedfrom
//clientside(YourProject.Script)throughCustomerServiceclassstaticmethods
//whichisgeneratedbyServiceContracts.tt
CustomerService.List(connection,newListRequest
{
SortBy=new[]{"Country","CityDESC"}
},response=>{});
ThisisbecauseListRequestclassdefinitionatclientsidehasabitdifferentstructure:
[Imported,Serializable,PreserveMemberCase]
publicclassListRequest:ServiceRequest
{
publicintSkip{get;set;}
publicintTake{get;set;}
publicstring[]Sort{get;set;}
//...
}
ColumnnamesusedhereshouldbePropertynamesofcorrespondingfields.Expressions
arenotaccepted.E.g.thiswon'twork!:
CustomerService.List(connection,newListRequest
{
SortBy=new[]{"t0.FirstName+''+t0.LastName"}
},response=>{});
ListRequest.ContainsTextandListRequest.ContainsField
Parameters
Theseparametersareusedbyquicksearchfuntionalitywhichissearchinputontopleftof
grids.
WhenonlyContainsTextisspecifiedandContainsFieldisempty,searchingisperformedon
allfieldswith[QuickSearch]attributeonthem.
Itispossibletodefinesomespecificfieldlisttoperformsearchesongridclientside,by
overridingGetQuickSearchField()methods.Sowhensuchafieldisselectedinquicksearch
input,searchisonlyperformedonthatcolumn.
IfyousetContainsFieldtoafieldnamethatdoesn'thaveQuickSearchattributeonit,system
willraiseanexception.Thisisforsecuritypurposes.
Asusual,searchingisdonewithdynamicSQLbyLIKEstatements.
ListRequestHandler
434

CustomerService.List(connection,newListRequest
{
ContainsText="the",
ContainsField="CompanyName"
},response=>{});
SELECT...FROMCustomerst0WHEREt0.CompanyNameLIKE'%the%'
IfContainsTextisnulloremptystringitissimplyignored.
ListRequest.EqualityFilterParameter
EqualityFilterisadictionarythatallowsquickequalityfilteringbysomefields.Itisusedby
quickfilterdropdownsongrids(onesthataredefinedwithAddEqualityFilterhelper).
CustomerService.List(connection,newListRequest
{
EqualityFilter=newJsDictionary<string,object>{
{"Country","Germany"}
}
},response=>{});
SELECT*FROMCustomerst0WHEREt0.Country="Germany"
Again,youshouldusepropertynamesasequalityfieldkeys,notexpressions.Serenity
doesn'tallowanyarbitrarySQLexpressionsfromclientside,topreventSQLinjections.
Pleasenotethatnullandemptystringvaluesaresimplyignored,similartoContainsText,so
it'snotpossibletofilterforemptyornullvalueswithEqualityFilter.Sucharequestwould
returnallrecords:
CustomerService.List(connection,newListRequest
{
EqualityFilter=newJsDictionary<string,object>{
{"Country",""},//won'twork,emptystringisignored
{"City",null},//won'twork,nullisignored
}
},response=>{});
UseCriteriaparameterifyouintenttofiltercustomerswithemptycountries.
ListRequest.Criteria
ListRequestHandler
435

ThisparameteracceptscriteriaobjectssimilartoserversideCriteriaobjectswetalkedabout
inFluentSQLchapter.Onlydifferenceis,asthesecriteriaobjectsaresentfromclientside,
theyhavetobevalidatedandcan'tcontainanyarbitrarySQLexpressions.
Servicerequestbelowwillonlyreturncustomerswithemptycountryornullcityvalues
CustomerService.List(connection,newListRequest
{
Criteria=newCriteria("Country")==""|
newCriteria("City").IsNull()
},response=>{});
YoucouldsetCriteriaparameterofgeneratedListRequestthatisabouttobesubmittedin
yourXYZGrid.cslikebelow:
protectedoverrideboolOnViewSubmit()
{
//onlycontinueifbaseclassdidn'tcancelrequest
if(!base.OnViewSubmit())
returnfalse;
//viewobjectisthedatasourceforgrid(SlickRemoteView)
//thisisanEntityGridsoview.ParamsisaListRequest
varrequest=(ListRequest)view.Params;
//weuse"&="herebecauseotherwisewemightclear
//filtersetbyaneditfilterdialogifany.
request.Criteria&=
newCriteria(ProductRow.Fields.UnitsInStock)>10&
newCriteria(ProductRow.Fields.CategoryName)!="Condiments"&
newCriteria(ProductRow.Fields.Discontinued)==0;
returntrue;
}
YoucouldalsosetotherparametersofListRequestinyourgridssimilarly.
ListRequest.IncludeDeleted
ThisparameterisonlyusefulwithrowsthatimplementsIIsActiveDeletedRowinterface.If
rowhassuchaninterface,listhandlerbydefaultonlyreturnsrowsthatarenotdeleted
(IsActive!=-1).Itisawaytonotdeleterowsactuallybutmarkthemasdeleted.
IfthisparameterisTrue,listhandlerwillreturnallrowswithoutlookingatIsActivecolumn.
ListRequestHandler
436

Somegridsforsuchrowshavealittleerasericonontoprighttotogglethisflag,thus
showdeletedrecordsorhidethem(default).
ListRequest.ColumnSelectionParameter
SerenitytrieshardtoloadonlyrequiredcolumnsofyourentitiesfromSQLservertolimit
networktraffictominimumbetweenSQLServer<->WEBServerandthuskeepdatasize
transferredtoclientaslowaspossible.
ListRequesthasaColumnSelectionparameterforyoutocontrolthesetofcolumnsloaded
fromSQL.
ColumnSelectionenumerationhasfollowingvaluesdefined:
publicenumColumnSelection
{
List=0,
KeyOnly=1,
Details=2,
}
BydefaultgridrequestsrecordsfromListservicein"ColumnSelection.List"mode(canbe
changed).Thus,itslistrequestlookslikethis:
newListRequest
{
ColumnSelection=ColumnSelection.List
}
InColumnSelection.Listmode,ListRequestHandlerreturnstablefields,thusfieldsthat
actuallybelongtothetable,notviewfieldsthatareoriginatingfromjoinedtables.
Oneexceptionisexpressionfieldsthatonlycontainsreferencetotablefields,e.g.
(t0.FirstName+''+t0.LastName).ListRequestHandleralsoloadssuchfields.
ColumnSelection.KeyOnlyonlyincludesID/primarykeyfields.
ColumnSelection.Detailsincludesallfields,includingviewones,unlessafieldisexplicitly
excludedormarkedas"sensitive",e.g.apasswordfield.
DialogsloadseditedrecordsinDetailsmode,thustheyalsoincludeviewfields.
ListRequest.IncludeColumnsParameter
ListRequestHandler
437

WetoldthatgridrequestsrecordsinListmode,soloadsonlytablefields,thenhowitcan
showcolumnsthatoriginatefromothertables?
GridsendslistofvisiblecolumnstoListservicewithIncludeColumns,sothesecolumnsare
includedinselectioneveniftheyareviewfields.
Inmemorygridscan'tdothis.Astheydon'tcallservicesdirectly,youhavetoput
[MinSelectLevel(SelectLevel.List)]toviewfieldsthatyouwan'ttoloadforinmemory
detailgrids.
IfyouhaveaProductGridthatshowsSupplierNamecolumnitsactualListRequestlookslike
this:
newListRequest
{
ColumnSelection=ColumnSelection.List,
IncludeColumns=newList<string>{
"ProductID",
"ProductName",
"SupplierName",
"..."
}
}
Thus,theseextraviewfieldsarealsoincludedinselection.
Ifyouhaveagridthatshouldonlyloadvisiblecolumnsforperformancereasons,
overrideitsColumnSelectionleveltoKeyOnly.Notethatnon-visibletablefieldswon't
beavailableinclientsiderow.
ListRequest.ExcludeColumnsParameter
OppositeofIncludeColumnsisExcludeColumns.Let'ssayyouhaveanvarchar(max)Notes
fieldonyourrowthatisnevershowninthegrid.Tolowernetworktraffic,youmaychooseto
NOTloadthisfieldinproductgrid:
ListRequestHandler
438

newListRequest
{
ColumnSelection=ColumnSelection.List,
IncludeColumns=newList<string>{
"ProductID",
"ProductName",
"SupplierName",
"..."
},
ExcludeColumns=newList<string>{
"Notes"
}
}
OnViewSubmitisagoodplacetosetthisparameter(andsomeothers):
protectedoverrideboolOnViewSubmit()
{
if(!base.OnViewSubmit())
returnfalse;
varrequest=(ListRequest)view.Params;
request.ExcludeColumns=newList<string>{"Notes"}
returntrue;
}
ControllingLoadingAtServerSide
YoumightwanttoexcludesomefieldslikeNotesfromColumnSelection.List,without
excludingitexplicitlyingrid.ThisispossiblewithMinSelectLevelattribute:
[MinSelectLevel(SelectLevel.Details)]
publicStringNote
{
get{returnFields.Note[this];}
set{Fields.Note[this]=value;}
}
ThereisaSelectLevelenumerationthatcontrolswhenafieldisloadedfordifferent
ColumnSelectionlevels:
ListRequestHandler
439

publicenumSelectLevel
{
Default=0,
Always=1,
Lookup=2,
List=3,
Details=4,
Explicit=5,
Never=6
}
SelectLevel.Default,whichisthedefaultvalue,correspondstoSelectLevel.Listfortable
fieldsandSelectLevel.Detailsforviewfields.
Bydefault,tablefieldshaveaselectlevelofSelectLevel.Listwhileviewfieldshave
SelectLevel.Details.
SelectLevel.Alwaysmeanssuchafieldisselectedforanycolumnselectionmode,evenifit
isexplicitlyexcludedusingExcludeColumns.
SelectLevel.Lookupisobsolete,avoidusingit.Lookupcolumnsaredeterminedwith
[LookupInclude]attribute.
SelectLevel.ListmeanssuchafieldisselectedforColumnSelection.Listand
ColumnSelection.DetailsmodesorifitisexplicitlyincludedwithIncludeColumnsparameter.
SelectLevel.DetailsmeanssuchafieldisselectedforColumnSelection.Detailsmode,orifit
isexplicitlyincludedwithIncludeColumnsparameter.
SelectLevel.Explicitmeanssuchafieldshouldn'tbeselectedinanymode,unlessitis
explicitlyincludedwithIncludeColumnsparameter.Usethisforfieldsthatarenotmeaningful
forgridsoreditdialogs.
SelectLevel.Nevermeansneverloadthisfield!Useitforfieldsthatshouldn'tbesenttoclient
side,likeapasswordhash.
ListRequestHandler
440

Widgets
SerenityScriptUIlayer'scomponentclasses(control)arebasedonasystemthatissimilar
tojQueryUI'sWidgetFactory,butredesignedforC#.
YoucanfindmoreinformationaboutjQueryUIwidgetsystemhere:
http://learn.jquery.com/jquery-ui/widget-factory/
http://msdn.microsoft.com/en-us/library/hh404085.aspx
Widget,isanobjectthatisattachedtoanHTMLelementandextendsitwithsome
behaviour.
Forexample,IntegerEditorwidget,whenattachedtoanINPUTelement,makesiteasierto
enternumbersintheinputandvalidatesthattheenterednumberisacorrectinteger.
Similarly,aToolbarwidget,whenattachedtoaDIVelement,turnsitintoatoolbarwithtool
buttons(inthiscase,DIVactsasaplaceholder).
Widgets
441

ScriptContextClass
C#,doesn'tsupportglobalmethods,sojQuery's $functioncan'tbeusedassimplyin
SaltarelleasitisinJavascript.
Asimpleexpressionlike $('#SomeElementId)inJavascriptcorrespondstoSaltarelleC#
code jQuery.Select("#SomeElementId").
Asaworkaround,ScriptContextclasscanbeused:
publicclassScriptContext
{
[InlineCode("$({p})")]
protectedstaticjQueryObjectJ(objectp);
[InlineCode("$({p},{context})")]
protectedstaticjQueryObjectJ(objectp,objectcontext);
}
As $isnotavalidmethodnameinC#, Jischoseninstead.Insubclassesof
ScriptContext,jQuery.Select()functioncanbecalledbrieflyas J().
publicclassSampleClass:ScriptContext
{
publicvoidSomeMethod()
{
J("#SomeElementId").AddClass("abc");
}
}
ScriptContextClass
442

WidgetClass
WidgetClassDiagram
AsampleWidget
Let'sbuildawidget,thatincreasesaDIV'sfontsizeeverytimeitisclicked:
WidgetClass
443

namespaceMySamples
{
publicclassMyCoolWidget:Widget
{
privateintfontSize=10;
publicMyCoolWidget(jQueryObjectdiv)
:base(div)
{
div.Click(e=>{
fontSize++;
this.Element.Css("font-size",fontSize+"pt");
});
}
}
}
<divid="SomeDiv">SampleText</div>
WecancreatethiswidgetonanHTMLelement,like:
vardiv=jQuery.Select("#SomeDiv");
newMyCoolWidget(div);
WidgetClassMembers
publicabstractclassWidget:ScriptContext
{
privatestaticintNextWidgetNumber=0;
protectedWidget(jQueryObjectelement);
publicvirtualvoidDestroy();
protectedvirtualvoidOnInit();
protectedvirtualvoidAddCssClass();
publicjQueryObjectElement{get;}
publicstringWidgetName{get;}
publicstringUniqueName{get;}
}
Widget.ElementProperty
WidgetClass
444

ClassesderivedfromWidgetcangettheelement,onwhichtheyarecreated,bythe
Elementproperty.
publicjQueryObjectElement{get;}
ThispropertyhastypeofjQueryObjectandreturnstheelement,whichisusedwhenthe
widgetiscreated.Inoursample,containerDIVelementisreferencedas this.Elementin
theclickhandler.
HTMLElementandWidgetCSSClass
WhenawidgetiscreatedonanHTMLelement,itdoessomemodificationstotheelement.
First,theHTMLelementgetsaCSSclass,basedonthetypeofthewidget.
Inoursample, .s-MyCoolWidgetclassisaddedtothe DIVwithID #SomeDiv.
Thus,afterwidgetcreation,theDIVlookssimilartothis:
<divid="SomeDiv"class="s-MyCoolWidget">SampleText</div>
ThisCSSclassisgeneratedbyputtinga s-prefixinfrontofthewidgetclassname(itcan
bechangedbyoverridingWidget.AddCssClassmethod).
StylingtheHTMLElementWithWidgetCSSClass
WidgetCSSclasscanbeusedtostyletheHTMLelementthatthewidgetiscreatedon.
.s-MyCoolWidget{
background-color:red;
}
GettingaWidgetReferenceFromanHTMLElementwith
thejQuery.DataFunction
AlongwithaddingaCSSclass,anotherinformationaboutthewidgetisaddedtotheHTML
element,thoughitisnotobviousonmarkup.Thisinformationcanbeseenbytyping
followinginChromeconsole:
>$('#SomeDiv').data()
>Object{MySamples_MyCoolWidget:$MySamples_MyCoolWidget}
WidgetClass
445

Thus,itispossibletogetareferencetoawidgetthatisattachedtoanHTMLelement,using
$.datafunction.InC#thiscanbewrittenas:
varmyWidget=(MyCoolWidget)(J("#SomeDiv").GetDataValue('MySamples_MyCoolWidget'));
WidgetExtensions.GetWidgetExtensionMethod
Insteadofthepriorlinethatlooksabitlongandcomplex,aSerenityshortcutcanbeused:
varmyWidget=J("#SomeDiv").GetWidget<MyCoolWidget>();
ThispieceofcodereturnsthewidgetifitexistsonHTMLelement,otherwisethrowsan
exception:
Elementhasnowidgetoftype'MySamples_MyCoolWidget'!
WidgetExtensions.TryGetWidgetExtensionMethod
TryGetWidgetcanbeusedtocheckifthewidgetexists,simplyreturning nullifitdoesn't:
varmyWidget=$('#SomeDiv').TryGetWidget<MyCoolWidget>();
CreatingMultipleWidgetsonanHTMLElement
OnlyonewidgetofthesameclasscanbeattachedtoanHTMLelement.
Anattempttocreateasecondarywidgetofthesameclassonaelementthrowsthe
followingerror:
Theelementalreadyhaswidget'MySamples_MyCoolWidget'.
Anynumberofwidgetsfromdifferentclassescanbeattachedtoasingleelementaslongas
theirbehaviourdoesn'taffecteachother.
Widget.UniqueNameProperty
Everywidgetinstancegetsauniquenamelike MySamples_MyCoolWidget3automatically,
whichcanbeaccessedby this.UniqueNameproperty.
WidgetClass
446

ThisuniquenameisusefulasaIDprefixfortheHTMLelementanditsdescendant
elementswhichmaybegeneratedbythewidgetitself.
Itcanalsobeusedasaneventclassfor $.bindand`$.unbind'methodstoattach/detach
eventhandlerswithoutaffectingotherhandlers,whichmightbeattachedtotheelement:
jQuery("body").Bind("click."+this.UniqueName,delegate{...});
...
jQUery("body").Unbind("click."+this.UniqueName);
Widget.DestroyMethod
SometimesreleasinganattachedwidgetmightberequiredwithoutremovingtheHTML
elementitself.
Widgetclassprovides Destroymethodforthepurpose.
IndefaultimplementationoftheDestroymethod,eventhandlerswhichareassignedbythe
widgetitselfarecleaned(byusingUniqueNameeventclass)anditsCSSclass( .s-
WidgetClass)isremovedfromtheHTMLelement.
CustomwidgetclassesmightneedtooverrideDestroymethodtoundochangesonHTML
elementandreleaseresources(though,noneedtodetachhandlersthatareattached
previouslywithUniqueNameclass)
DestroymethodiscalledautomaticallywhentheHTMLelementisdetachedfromtheDOM.
Itcanalsobecalledmanually.
Ifdestoryoperationisnotperformedcorrectly,memoryleaksmayoccurinsomebrowsers.
WidgetClass
447

Widget<TOptions>GenericClass
Ifawidgetrequiressomeadditionalinitializationoptions,itmightbederivedfromthe
Widget<TOptions>class.
Theoptionspassedtotheconstructorcanbeaccessedinclassmethodsthroughthe
protectedfield options.
publicabstractclassWidget<TOptions>:Widget
whereTOptions:class,new()
{
protectedWidget(jQueryObjectelement,TOptionsopt=null){...}
protectedreadonlyTOptionsoptions;
}
WidgetWithOptions
448

TemplatedWidgetClass
AwidgetthatgeneratesacomplicatedHTMLmarkupinitsconstructororothermethods
mightleadtoaclasswithmuchspaghetticodethatishardtomaintain.Besides,asmarkup
liesinprogramcode,itmightbedifficulttocustomizeoutput.
publicclassMyComplexWidget:Widget
{
publicMyComplexWidget(jQueryObjectdiv)
:base(div)
{
vartoolbar=J("<div>")
.Attribute("id",this.UniqueName+"_MyToolbar")
.AppendTo(div);
vartable=J("<table>")
.AddClass("myTable")
.Attribute("id",this.UniqueName+"_MyTable")
.AppendTo(div);
varheader=J("<thead/>").AppendTo(table);
varbody=J("<tbody/>").AppendTo(table);
...
...
...
}
}
SuchproblemscanbeavoidedbyusingHTMLtemplates.Forexample,letsaddthe
followingtemplateintotheHTMLpage:
<scriptid="Template_MyComplexWidget"type="text/html">
<divid="~_MyToolbar">
</div>
<tableid="~_MyTable">
<thead><tr><th>Name</th><th>Surname</th>...</tr></thead>
<tbody>...</tbody>
</table>
</script>
Here,a SCRIPTtagisused,butbyspecifyingitstypeas "text/html",browserwon't
recognizeitasarealscripttoexecute.
BymakinguseofTemplatedWidget,letsrewritepreviousspaghetticodeblock:
TemplatedWidgetClass
449

publicclassMyComplexWidget:TemplatedWidget
{
publicMyComplexWidget(jQueryObjectdiv)
:base(div)
{
}
}
WhenthiswidgetiscreatedonanHTMLelementlikefollowing:
<divid="SampleElement">
</div>
You'llendupwithsuchanHTMLmarkup:
<divid="SampleElement">
<divid="MySamples_MyComplexWidget1_MyToolbar">
</div>
<tableid="MySamples_MyComplexWidget1_MyTable">
<thead><tr><th>Name</th><th>Surname</th>...</tr></thead>
<tbody>...</tbody>
</table>
</div>
TemplatedWidgetautomaticallylocatesthetemplateforyourclassandappliesittothe
HTMLelement.
TemplatedWidgetIDGeneration
Ifyouwatchcarefully,inourtemplatewespecifiedIDfordescendantelementsas
~_MyToolbarand ~_MyTable.
ButwhenthistemplateisappliedtotheHTMLelement,resultingmarkupcontainedID'sof
MySamples_MyComplexWidget1_MyToolbarand MySamples_MyComplexWidget1_MyTable
instead.
TemplatedWidgetreplacesprefixeslike ~_withthewidget's UniqueNameandunderscore
("_")( this.idPrefixcontainsthecombinedprefix).
Usingthisstrategy,evenifthesamewidgettemplateisusedinapageformorethanone
HTMLelement,theirID'swon'tconflictwitheachotherastheywillhaveuniqueID's.
TemplatedWidget.ByIDMethod
TemplatedWidgetClass
450

AsTemplateWidgetappendsauniquenametothem,theIDattributesinawidgettemplate
can'tbeusedtoaccesselementsafterwidgetcreation.
Widget'suniquenameandanunderscoreshouldbeprependedtotheoriginalIDattributein
thetemplatetofindanelement:
publicclassMyComplexWidget:TemplatedWidget
{
publicMyComplexWidget(jQueryObjectdiv)
:base(div)
{
J(this.uniqueName+"_"+"Toolbar").AddClass("some-class");
}
}
TemplatedWidget'sByIDmethodcanbeusedinstead:
publicclassMyComplexWidget
{
publicMyComplexWidget(jQueryObjectdiv)
:base(div)
{
ByID("Toolbar").AddClass("some-class");
}
}
TemplatedWidget.GetTemplateNameMethod
Inthelastsample MyComplexWidgetlocateditstemplateautomatically.
TemplatedWidgetmakesuseofaconventiontofinditstemplate(conventionbased
programming).Itinserts Template_prefixbeforetheclassnameandsearchesfora
SCRIPTelementwiththisIDattribute( Template_MyComplexWidget)andusesitsHTML
contentasatemplate.
IfwewantedtouseanotherIDlikefollowing:
<scriptid="TheMyComplexWidgetTemplate"type="text/html">
...
</script>
Anerrorlikethiswouldbeseeninthebrowserconsole:
TemplatedWidgetClass
451

Can'tlocatetemplateforwidget'MyComplexWidget'withname'Template_MyComplexWidget
'!
WemightfixourtemplateIDoraskthewidgettouseourcustomID:
publicclassMyComplexWidget
{
protectedoverridestringGetTemplateName()
{
return"TheMyComplexWidgetTemplate";
}
}
TemplatedWidget.GetTemplateMethod
GetTemplatemethodmightbeoverridentoprovideatemplatefromanothersourceor
specifyitmanually:
publicclassMyCompleWidget
{
protectedoverridestringGetTemplate()
{
return$('#TheMyComplexWidgetTemplate').GetHtml();
}
}
Q.GetTemplateMethodandServerSide
Templates
Defaultimplementationfor TemplatedWidget.GetTemplatemethodcalls GetTemplateNameand
searchesfora SCRIPTelementwiththatID.
IfnosuchSCRIPTelementisfound, Q.GetTemplateiscalledwiththesameID.
Anerroristhrownifneitherreturnsaresult.
Q.GetTemplatemethodprovidesaccesstotemplatesdefinedontheserverside.These
templatesarecompiledfromfileswith .template.cshtmlextensionin ~/Views/Templateor
~/Modulesfoldersortheirsubfolders.
Forexample,wecouldcreateatemplateforMyComplexWidgetinaserversidefilelike
~/Views/Template/SomeFolder/MyComplexWidget.template.cshtmlwiththefollowingcontent:
TemplatedWidgetClass
452

<divid="~_MyToolbar">
</div>
<tableid="~_MyTable">
<thead><tr><th>Name</th><th>Surname</th>...</tr></thead>
<tbody>...</tbody>
</table>
Templatefilenameandextensionisimportantwhileitsfolderissimplyignored.
Byusingthisstrategytherewouldbenoneedtoinsertwidgettemplatesintothepage
markup.
Also,assuchserversidetemplatesareloadedonthefirstuse(lazyloading)andcachedin
thebrowserandtheserver,pagemarkupdoesn'tgetpollutedwithtemplatesforwidgetsthat
wemightneveruseinaspecificpage.Thus,serversidetemplatesarefavoredoverinline
SCRIPTtemplates.
TemplatedWidgetClass
453

TemplatedDialogClass
TemplatedWidget'ssubclassTemplatedDialogmakesuseofjQueryUIDialogtocreatein-
pagemodaldialogs.
UnlikeotherwidgettypesTemplatedDialogcreatesitsownHTMLelement,whichitwillbe
attechedto.
TemplatedDialogClass
454

Attributes
VisibleAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Controlsvisibilityofacolumnorformfield.
Itisalsopossibletohideafieldbypassingfalseasitsvalue,but[Hidden]attributeis
recommended.
publicclassSomeColumns
{
[Visible]
publicstringExplicitlyVisible{get;set;}
[Visible(false)]
publicstringExplicitlyHidden{get;set;}
}
Usermightstillshowthecolumnbyusingthecolumnpickerifany.
HiddenAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Hidesacolumnorformfield.
ThisisjustasubclassofVisibleAttributewithfalsevalue.
publicclassSomeColumns
{
[Hidden]
publicstringHiddenColumn{get;set;}
}
Usermightstillshowthecolumnbyusingthecolumnpickerifany.
HideOnInsertAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Attributes
455

Controlswhetherafieldisvisibleonnewrecordmode.
Thisonlyworkswithforms,notcolumns.
publicclassSomeColumns
{
[HideOnInsert]
publicstringHideMeOnInsert{get;set;}
[HideOnInsert(false)]
publicstringDontHideMeOnInsert{get;set;}
}
HideOnUpdateAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Controlswhetherafieldisvisibleoneditrecordmode.
Thisonlyworkswithforms,notcolumns.
publicclassSomeColumns
{
[HideOnUpdate]
publicstringHideMeOnUpdate{get;set;}
[HideOnUpdate(false)]
publicstringDontHideMeOnUpdate{get;set;}
}
InsertableAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Controlsifapropertyiseditableinnewrecordmode.
Whenusedonrowfields,turnsonorofftheInsertableflag.
Ithasnoeffectoncolumns
publicclassSomeForm
{
[Insertable(false)]
publicstringReadOnlyOnInsert{get;set;}
}
Attributes
456

UpdatableAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Controlsifapropertyiseditableineditrecordmode.
Whenusedonrowfields,turnsonorofftheUpdatableflag.
Ithasnoeffectoncolumns
publicclassSomeForm
{
[Updatable(false)]
publicstringReadOnlyOnUpdate{get;set;}
}
DisplayNameAttribute
namespace:System.ComponentModel,assembly:System
Determinesdefaulttitleforgridcolumnsorformfields.
publicclassSomeForm
{
[DisplayName("TitleforSomeField")]
publicstringSomeField{get;set;}
}
DisplayNameattributecannotbeusedonEnummembers,soyouhavetouse
Descriptionattribute
Titlessetwiththisattributeisconsideredtobeininvariantlanguage.
ThisisnotaSerenityattribute,itresidesin.NETSystemassembly.
DescriptionAttribute
namespace:System.ComponentModel,assembly:System
Determinesdefaulttitleforenummembers.
Attributes
457

publicclassSomeEnum
{
[Description("TitleforValue1")]
Value1=1,
[Description("Value2")]
Value2=2
}
Titlessetwiththisattributeisconsideredtobeininvariantlanguage.
ThisisnotaSerenityattribute,itresidesin.NETSystemassembly.
DisplayFormatAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Setsthedisplayformatforacolumn.
Thishasnoeffectoneditors!ItisonlyforDisplay,NOTEditing.Forediting,youhave
tochangecultureinweb.config(notUIculture).
Displayformatstringsarespecifictocolumndataandformattertype.
IfcolumnisaDateorDateTimecolumn,itsdefaultformatteracceptscustomDateTime
formatstringslikedd/MM/yyyy.
Wedon'tsuggestsettingDisplayFormatfordatesexplicitly,useculturesetting(notUI
culture)inweb.configunlessacolumnhastodisplaydate/timeinadifferentorderthan
thedefault.
Youmayalsousefollowingstandardformatstrings:
"d": dd/MM/yyyywhereDMYorderchangesbasedoncurrentculture.
"g": dd/MM/yyyyHH:mmwhereDMYorderchangesbasedoncurrentculture.
"G": dd/MM/yyyyHH:mm:sswhereDMYorderchangesbasedoncurrentculture.
"s": yyydd-MM-ddTHH:mm:ssISOsortabledatetimeformat.
"u": yyydd-MM-ddTHH:mm:ss.fffZISO8601UTC.
Ifcolumnisaninteger,doubleordecimalitaccepts.NETcustomnumericformat
strings.
Attributes
458

publicclassSomeColumns
{
[DisplayFormat("d")]
publicDateTimeDateWithCultureDMYOrder{get;set;}
[DisplayFormat("dd/MM/yyyy")]
publicDateTimeDateWithConstantDMYOrder{get;set;}
[DisplayFormat("g")]
publicDateTimeDateTimeToMinWithCultureDMYOrder{get;set;}
[DisplayFormat("dd/MM/yyyyHH:mm")]
publicDateTimeDateTimeToMinConstantDMYOrder{get;set;}
[DisplayFormat("G")]
publicDateTimeDateTimeToSecWithCultureDMYOrder{get;set;}
[DisplayFormat("dd/MM/yyyyHH:mm:ss")]
publicDateTimeDateTimeToSecWithConstantDMYOrder{get;set;}
[DisplayFormat("s")]
publicDateTimeSortableDateTime{get;set;}
[DisplayFormat("u")]
publicDateTimeISO8601UTC{get;set;}
[DisplayFormat("#,##0.00")]
publicDecimalShowTwoZerosAfterDecimalWithGrouping{get;set;}
[DisplayFormat("0.00")]
publicDecimalShowTwoZerosAfterDecimalNoGrouping{get;set;}
}
PlaceholderAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Setsaplaceholderforaformfield.
Placeholderisshowninsidetheeditorwithgraycolorwheneditorvalueisempty.
OnlybasicinputbasededitorsandSelect2supportsthis.Itisignoredbyothereditor
typeslikeCheckbox,Grid,FileUploadEditoretc.
publicclassSomeForm
{
[Placeholder("Showthisinsidetheeditorwhenitisempty")]
publicstringFieldWithPlaceHolder{get;set;}
}
HintAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Setsahintforaformfield.
Attributes
459

Hintisshownwhenfieldlabelishovered.
Thishasnoeffectoncolumns.
publicclassSomeForm
{
[Hint("Showthiswhenmycaptionishovered")]
publicstringFieldWithHint{get;set;}
}
CssClassAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
SetsCSSclassforgridcolumnsandformfields.
Informs,classisaddedtocontainerdivwith.fieldclassthatcontainsbothlabeland
editor.
Forcolumns,itsetscssClasspropertyofSlickColumn,whichaddsthisclasstoslick
cellsforallrows.
Slickcolumnheadersarenotaffectedbythisattribute,use [HeaderCssClass]forthat.
publicclassSomeForm
{
[CssClass("extra-class")]
publicstringFieldWithExtraClass{get;set;}
}
publicclassSomeColumn
{
[CssClass("extra-class")]
publicstringCellWithExtraClass{get;set;}
}
HeaderCssClassAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
SetsCSSclassforgridcolumnheaders.
Thishasnoeffectforforms.
Attributes
460

ItsetsheaderCsspropertyofSlickColumn,whichaddsthisclasstoslickheaderforthat
column.
publicclassSomeColumn
{
[HeaderCssClass("extra-class")]
publicstringFieldWithExtraHeaderClass{get;set;}
}
AlignCenterAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Centerstexthorizontally.
Usedtocontroltextalignmentingridsbyadding align-centerCSSclassto
correspondingSlickGridcolumn.
Columnheadersarenotaffectedbythisattribute.Youmayuse
[HeaderCssClass("align-center")]forthat.
Notethatithasnoeffectoneditorsorforms.
AlignRightAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Rightalignstexthorizontally.
Usedtocontroltextalignmentingridsbyadding align-rightCSSclassto
correspondingSlickGridcolumn.
Columnheadersarenotaffectedbythisattribute.Youmayuse
[HeaderCssClass("align-right")]forthat.
Notethatithasnoeffectoneditorsorforms.
IgnoreAttribute
namespace:Serenity.ComponentModel,assembly:Serenity.Core
Skipsapropertywhilegeneratinggridcolumnorformfieldlist.
Attributes
461

UsethistoignoreapropertyforUI,butstilluseitforotherpurposeslikeJSON
serialization.
ThismightbeusefulwhenatypeisusedasaServiceRequestandFormDeclarationat
thesametime.
publicclassSomeColumns
{
[Ignore]
publicstringDontGenerateAColumnForMe{get;set;}
}
Attributes
462

Grids
Grids
463

FormatterTypes
URLFormatter
ThisformatterletsyouputalinkwithaURLtoagridcolumn.
Ittakesoptionalargumentsbelow:
OptionName Description
UrlFormat
ThisistheformatofURL.Asamplewouldbe
"http://www.site.com/{0}"where{0}istheUrlPropertyvalue.
Ifnoformatisspecified,linkwillbethevalueofUrlPropertyasis.
IfyourURLformatstartswith"~/",itwillberesolvedtoapplication
root.Forexample,ifformatis"~/upload/{0}"andyourapplication
runsat"localhost:3045/mysite",resultingURLwillbe
"/mysite/upload/xyz.png".
UrlProperty
ThisisnameofthepropertythatwillbeusedtodeterminelinkURL.
Ifnotspecified,itisthenameofthecolumnthatthisformatteris
placedon.
IfUrlPropertyvaluestartswith"~/"itwillberesolvedlikeUrlFormat.
DisplayFormat
Thisisthedisplaytextformatoflink.Asamplewouldbe"clickto
open{0}"where{0}istheDisplayPropertyvalue.
Ifnoformatisspecified,linkwillbethevalueofDisplayPropertyas
is.
DisplayProperty
Thisisnameofthepropertythatwillbeusedtodeterminelinktext.
Ifnotspecified,itisthenameofthecolumnthatthisformatteris
placedon.
Target Thisisthetargetofthelink.Use"_blank"toopenlinksinanewtab.
FormatterTypes
464

PersistingSettings
Serenity2.1.5introducedabilitytopersistgridsettingsincludingthesedetails:
VisibleColumnsandDisplayOrder
ColumnWidths
SortedColumns
AdvancedFilters(onescreatedwithEditFilterlinkonbottomright)
QuickFilters(asofwriting,notyetavailable)
StateofIncludeDeletedToggle
Bydefault,gridsdoesn'tautomaticallypersistanything.
Thus,ifyouhidesomecolumnsandnavigateawayfromOrderspage,whenyoucome
back,you'llseethatthosehiddencolumnsbecamevisibleagain.
Youneedtoturnonpersistanceforallgrids,orforindividualonesthatyouwantthemto
remembertheirsettings.
TurningOnPersistancebyDefault
DataGridhasastaticconfigurationparameterwithnameDefaultPersistanceStorage.This
parametercontrolswheregridssavetheirsettingsautomaticallybydefault.Itisinitiallynull.
InScriptInitialization.ts,youmightturnonpersistanceforallgridsbydefaultlikebelow:
namespaceSerene.ScriptInitialization{
Q.Config.responsiveDialogs=true;
Q.Config.rootNamespaces.push('Serene');
Serenity.DataGrid.defaultPersistanceStorage=window.sessionStorage;
}
Thissavessettingstobrowsersessionstorage,whichisakey/valuedictionarythat
preservesdatawhileanybrowserwindowstaysopen.Whenuserclosesallbrowser
windows,allsettingswillbelost.
Anotheroptionistousebrowserlocalstorage.Whichpreservessettingsbetweenbrowser
restarts.
Serenity.DataGrid.defaultPersistanceStorage=window.localStorage;
PersistingSettings
465

Usinganyofthetwooptions,gridswillstarttoremembertheirsettings,betweenpage
reloads.
HandlingaBrowserthatisSharedbyMultipleUsers
BothsessionStorageandlocalStorageisbrowserscoped,soifabrowserissharedby
multipleusers,they'llhavesamesetofsettings.
Ifoneuserchangessomesettings,andlogsout,andotheronelogsin,seconduserwill
startwithsettingsofthefirstuser(unlessyouclearlocalStorageonsignout)
Ifthisisaproblemforyourapplication,youmaytrywritingacustomprovider:
namespaceSerene{
exportclassUserLocalStorageimplementsSerenity.SettingStorage{
getItem(key:string):string{
returnwindow.localStorage.getItem(
Authorization.userDefinition.Username+":"+key);
}
setItem(key:string,value:string):void{
window.localStorage.setItem(
Authorization.userDefinition.Username+":"+key,value);
}
}
}
//...
Serenity.DataGrid.defaultPersistanceStorage=newUserLocalStorage();
Pleasenotethatthisdoesn'tprovideanysecurity.Itjustletsusershaveseparate
settings.
SettingPersistanceStoragePerGridType
Toturnonpersistance,orchangetargetstorageforaparticulargrid,override
getPersistanceStoragemethod:
PersistingSettings
466

namespaceSerene.Northwind{
//...
exportclassOrderGridextendsSerenity.EntityGrid<OrderRow,any>{
//...
protectedgetPersistanceStorage():Serenity.SettingStorage{
returnwindow.localStorage;
}
}
}
Youmayalsoturnoffpersistanceforagridclassbyreturningnullfromthismethod.
DeterminingWhichSettingTypesAreSaved
Bydefault,allsettingsnotedatstartaresaved,likevisiblecolumns,widths,filtersetc.You
maychoosetonotpersist/restorespecificsettings.Thisiscontrolledby
getPersistanceFlagsmethod:
namespaceSerene.Northwind{
//...
exportclassOrderGridextendsSerenity.EntityGrid<OrderRow,any>{
//...
protectedgetPersistanceFlags():GridPersistanceFlags{
return{
columnWidths:false//dontpersistcolumnwidths;
}
}
}
}
Hereisthesetofcompleteflags:
interfaceGridPersistanceFlags{
columnWidths?:boolean;
columnVisibility?:boolean;
sortColumns?:boolean;
filterItems?:boolean;
quickFilters?:boolean;
includeDeleted?:boolean;
}
WhenSettingsAreSaved/Restored
Settingsareautomaticallysavedwhenyouchangesomethingwithagridlike:
PersistingSettings
467

ChoosingvisiblecolumnswithColumnPickerdialog
Resizingacolumnmanually
Editingadvancedfilter
Draggingacolumn,changingposition
Changingsortedcolumns
Settingsarerestoredonfirstpageload,justaftergridcreation.
PersistingSettingstoDatabase(UserPreferencesTable)
Serene2.1.5comeswithaUserPreferencestablethatyoumayuseasapersistance
storage.Tousethisstorage,youjustneedtosetitasstoragesimilartootherstoragetypes.
///<referencepath="../Common/UserPreference/UserPreferenceStorage.ts"/>
Serenity.DataGrid.defaultPersistanceStorage=newCommon.UserPreferenceStorage();
Don'tforgettoaddreferencestatement,oryou'llhaveruntimeerrors,asTypeScripthas
problemswithorderingotherwise.
OR
namespaceSerene.Northwind{
//...
exportclassOrderGridextendsSerenity.EntityGrid<OrderRow,any>{
//...
protectedgetPersistanceStorage():Serenity.SettingStorage{
returnnewCommon.UserPreferenceStorage();
}
}
}
ManuallySaving/RestoringSettings
Ifyouneedtosave/restoresettingsmanually,youmayusemethodsbelow:
protectedgetCurrentSettings(flags?:GridPersistanceFlags):PersistedGridSettings;
protectedrestoreSettings(settings?:PersistedGridSettings,flags?:GridPersistanceFla
gs):void;
TheseareprotectedmethodsofDataGrid,socanonlybecalledfromsubclasses.
PersistingSettings
468

PersistingSettings
469

CodeGenerator(Sergen)
Sergenhassomeextraoptionsthatyoumaysetthroughitsconfigurationfile
(Serenity.CodeGenerator.config)inyoursolutiondirectory.
Hereisthefullsetofoptions:
publicclassGeneratorConfig
{
publicList<Connection>Connections{get;set;}
publicstringKDiff3Path{get;set;}
publicstringTFPath{get;set;}
publicstringTSCPath{get;set;}
publicboolTFSIntegration{get;set;}
publicstringWebProjectFile{get;set;}
publicstringScriptProjectFile{get;set;}
publicstringRootNamespace{get;set;}
publicList<BaseRowClass>BaseRowClasses{get;set;}
publicList<string>RemoveForeignFields{get;set;}
publicboolGenerateSSImports{get;set;}
publicboolGenerateTSTypings{get;set;}
publicboolGenerateTSCode{get;set;}
}
Connections,RootNamespace,WebProjectFile,ScriptProjectFile,GenerateSSImports,
GenerateSSTypingsandGenerateTSCodeoptionsareallavailableinuserinterface,sowe'll
focusonotheroptions.
KDiff3Path
SergentriestolaunchKDiff3whenitneedstomergechangestoanexistingfile.Thismight
happenwhenyoutrytogeneratecodeforanentityagain.Insteadofoverridingtargetfiles,
SergenwillexecuteKDiff3.
SergenlooksforKDiff3atitsdefaultlocationunderC:\ProgramFiles\Kdiff3,butyoumay
overridethispathwiththisoption,ifyouinstalledKdiff3toanotherlocation.
TFSIntegrationandTFPath
ForusersthatworkwithTFS,Sergenprovidesthisoptionstomakeitpossibletocheckout
existingfilesandaddnewonestosourcecontrol.SetTFSIntegrationtotrue,ifyourproject
isversionedinTFS,andsetTFPathiftf.exeisnotunderitsdefaultlocationatC:\Program
Files\VisualStudio\x.y\Common7\ide\
CodeGenerator(Sergen)
470

{
//...
"TFSIntegration":true,
"TFPath":"C:\ProgramFiles\....\tf.exe"
}
RemoveForeignFields
Bydefault,Sergenexaminesyourtableforeignkeys,andwhengeneratingarowclass,it
willbringallfieldsfromallreferencedforeigntables.
Sometimes,youmighthavesomefieldsinforeigntables,e.g.someloggingfieldslike
InsertUserId,UpdateDateetc.thatwouldn'tbeusefulinanotherrow.
You'dbeabletoremovethemmanuallyaftercodegenerationtoo,butusingthisoptionit
mightbeeasier.Listfieldsyouwanttoremovefromgeneratedrowsasanarrayofstring:
{
//...
"RemoveForeignFields":["InsertUserId","UpdateUserId",
"InsertDate","UpdateDate"]
}
Notethatthisdoesn'tremovethisfieldsfromtablerowitself,itonlyremovestheseview
fieldsfromforeignjoins.
BaseRowClasses
Ifyouareusingsomebaserowclass,e.g.somethinglikeLoggingRowinSerene,youmight
wantSergentogenerateyourrowsderivingfromthesebaseclasses.
Forthistowork,listyourbaseclasses,andthefieldstheyhave.
{
//...
"BaseRowClasses":[{
"ClassName":"Serene.Administration.LoggingRow",
"Fields":["InsertUserId","UpdateUserId",
"InsertDate","UpdateDate"]
}]
}
CodeGenerator(Sergen)
471

IfSergendeterminesthatatablehasallfieldslistedin"Fields"array,itwillsetitsbaseclass
as"ClassName",andwillnotgeneratethesefieldsexplicityinrow,astheyarealready
definedinbaserowclass.
Itispossibletodefinemorethanonebaserowclass.Sergenwillchoosethebaserowclass
withmostmatchingfields,ifarow'sfieldsmatchesmorethanonebaseclass.
CodeGenerator(Sergen)
472

UsedToolsandLibraries
Serenityplatformmakesuseofsomevaluableopensourcetoolsandlibrariesthatarelisted
below(inalphabeticorder)
Thislistmightseemabitlong,butnotallofthemaredirectdependenciesfora
SerenityApplication.
SomeofthemareonlyusedduringdevelopmentofSerenityplatformitself,whilesome
aredependenciesforoptionalfeatures.
Wetriedtoreuseopensourcelibraries,wherethereisaqualityoneavailabletoavoid
reinventingthewheel.
Autonumeric(https://github.com/BobKnothe/autoNumeric)
BlockUI(https://github.com/malsup/blockui/)
Bootstrap(https://github.com/twbs/bootstrap)
CakeBuild(https://github.com/cake-build/cake)
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)
UsedTools&Libraries
473

JSON2(https://github.com/douglascrockford/JSON-js)
JSRender(https://github.com/BorisMoore/jsrender)
jQuery(https://github.com/jquery/jquery)
jQueryCookie(https://github.com/carhartl/jquery-cookie)
jQueryValidation(https://github.com/jzaefferer/jquery-
validation)
jQueryUI(https://github.com/jquery/jquery-ui)
jQuery.event.drag
(http://threedubmedia.com/code/event/drag)
Less.JS(Node)(https://github.com/less/less.js)
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/)
SaltarelleCompiler(https://github.com/erik-
kallen/SaltarelleCompiler)
UsedTools&Libraries
474