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