Okay, it's time for part two:)
We'll continue where we left off with our library, I've attached the end result to this blog entry again. If you open it you will see I've made a few changes to part one that go outside of our tutorial.
I've put some of my classes into folders. Folders are a great way to organise your library. Before you know it your library will consist of dozens of classes and you'll find yourself getting lost in them.
I've also removed our test window and test schema and table and replaced them with some schema and table classes we'll be using.
Finally I've altered my table base class slightly. The $sqlerror has been changed to retrieve all errors and we have a method called $newRecord. On hindsight I named $newRecord a little bit wrong because I originally implemented it for row variables but it works just the same for list values. It clears the values of the current row but its meant to be a little more intelligent. Right now it simply sets the value based on the column type, but in most of my applications I enhance this by conventions such as setting a column called "createdBy" to the user name or "createdOn" to the current date. It saves me the trouble of writing that logic time and time again.
Note: Please note the $$ prefix I use in the $$setFieldState method. This is a personal convention I use meaning this is a protected method. A protected method can be called from any subclass of this class but it is not allowed to call the method from outside. Omnis does not enforce this, from Omnis' perspective, this is a public method.
Attached to this blog is a small SQL file with some SQL code that will create the needed tables on the database. I like working with scripts like these to install databases because they allow me much greater control in creating constraints and indexs but they do tent to be platform dependent. You can off course also drag the schema classes in the library to the SQL browser (the reverse of what we did in the first section) and Omnis will create the tables for you. I'll show you some time later how you can write some code that will create all the tables in an empty database using the schema classes in the library.
A little information about this script. It creates 3 tables, Counter, ContactType and Contact. Counter is a special table as it allows us to keep a number of counters for auto numbers. Many SQL databases offer great native support for this, Sybase and Microsoft have identity fields, MySQL has the autonumber default property, Oracle has sequence objects, etc. If you commit to a certain platform I suggest you use this functionality. But since I can not assume anything about your platform choice, I'll show a solution that is cross platform that uses a counter table.
The other two tables, ContactType and Contact are my two data tables. All my data tables have something in common. They all start with an ID field. This is a field I generally do not show to the end user but that for my application is vital. Its a unique number that identifies my record and that I use as the primary key. This would be the field I would apply the auto numbering to. In our example I've used an integer field, which is fine for a small demonstration app like this, but for larger apps I'd suggest using a larger field type such as a numeric(15) field (maximum for a number 0dp field, at least when using Sybase).
My ContactType table and Contact table are related to each other through the field FkTypeID in the Contact table. In my script I also create a constraint that informs my database about this relation. This serves a number of functions:
- It makes sure that my database gives an error if I violate this relationship
- it documents this relation meaning someone looking at my database that is not formiliar with it, immediately knows this column points to the ContactType table
- I can query this information and build logic on it enabling me to automate things, though this is harder to do cross platform
I started by creating a new table base class called tBaseCounted. We set the $superclass of tBaseCounter to tBase. There are several methods in this class but I want to hilite a few of them:
tBaseCounted.$getNextValue(pCounterName)
----
Do $cinst.$sessionobject.$begin() ;; Begin a transaction, very important!
Begin statement (Carriage return,Linefeed)
Sta: if not exists (select 1 from Counter where Name = '[pCounterName]') begin
Sta: insert into Counter (Name, LastID) values ('[pCounterName]', 1)
Sta: end else begin
Sta: update Counter set LastID = LastID + 1 where Name = '[pCounterName]'
Sta: end
Sta: select LastID from Counter where Name = '[pCounterName]'
End statement
If not($cinst.$statementobject.$execdirect())
Do $cinst.$sqlerror('$getNextValue',$cinst.$statementobject.$nativeerrorcode,$cinst.$statementobject.$nativeerrortext)
Do $cinst.$sessionobject.$rollback()
Quit method -1 ;; Something went wrong!
End If
Do $cinst.$statementobject.$fetch(tmpResult,kFetchAll)
If tmpResult.$linecount=0
Do $cinst.$sessionobject.$rollback()
Calculate iLastError as "Couldn't retrieve counter value for an unknown reason!"
Quit method -2 ;; No value?
Else
Do $cinst.$sessionobject.$commit() ;; We're good
Quit method tmpResult.1.LastID ;; We should have only 1 result!
End If
This does a couple of things. First it starts a transaction, now not all platforms will support this, and there is an issue with nesting that we'll re-address much much later, but its very important we do this if we're building a multiuser application because of the second thing we do.We execute a query batch, through means of a statement block, that in one go either inserts a new counter or updates the existing counter and then returns the new value. Because we are doing this in a transaction the insert or update action will lock the record we're after. That means that by the time we select our value it is impossible for another user to do the same, they will be locked by us until we're done selecting our value and committing our transaction. This ensures we do not give out the same number twice.
There is a tiny tiny tiny theoretical risk that the first time a counter is used two users try to create the same counter but this will lead into one user encountering a primary key constraint error. In real life however you'll probably end up creating all counters from the get go and users will only be updating counters.
One more important thing I want to note. I'm using $cinst.$sessionobject and $cinst.$statementobject. During the $construct of my table class my global gDatabase was assigned to the $sessionobject property of my list and at that moment a statementobject was created. I am using these here however by accessing them this way I am making my code safe in case I later decide to use this object using a different database connection. My global database connection has become my default, but not my only means of using my code!
tBaseCounted.$insert()
----
Calculate tmpNewID as $cinst.$getNextValue($cinst.$servertablenames) ;; Get a counter value based on our server table name
If tmpNewID<0
Quit method kFalse
End If
Calculate $cinst.C1 as tmpNewID ;; Assume the first column in our table (and thus in our list) is the primary key!
; Do the original insert!
Do default Returns tmpResult
If not(tmpResult)
Calculate $cinst.C1 as 0
End If
Quit method tmpResult
Here we've overridden the standard Omnis $insert method. Whenever we insert a record we now first get a new value from our counter table and assign it to the primary key. Since I've made a rule for myself that the first column of my table is always my auto numbering primary key I can make life easy and select the first column for this (you can use C1, C2, C3, etc to access columns regardless of their name).
tBaseCounted.$validateRecord
----
; Override this method to do any validation on the data of the current record
Quit method kTrue
Ok, this is important, this method does absolutely nothing. Its there because I want to make sure my developers are aware that this method needs to exist in subclasses of this base class. As validation rules can be very different per table, I need to override this logic per subclass. Also by implementing this method in my base class I can build logic that assumes this method exists even though when I build the logic, I know nothing about how it will work. Also if after months I decide I do implement some generic validation logic, I have already created a place to put it.There are also a number of other methods added to tBaseCounted that work together and that are specifically meant to be used for row variables:
- $loadRecord, loads a record with a certain ID
- $loadFirst, loads the first record in our database
- $loadLast, loads the last record in our database
- $loadPrev, loads the record previous to the current record
- $loadNext, loads the record next to the current record
Now we create our schema and table classes for our ContactType and Contact tables by dragging them from our SQL browser into our library. We don't need a schema and table class for our Counter table as we won't access that directly.
Tip: Click on SQL Browser, select Options, go to the drag and drop tab pane and select the "create table and schema class" option to create both schema and table classes when you drag a table into your library. Note that Omnis creates the table classes with a T_ prefix, I tent to use t as a prefix, pick a standard you like but I do advise you to use a naming convention.
We assign tBaseCounted to the $superclass property of our two new table classes. In our two new table classes we only override the $validateRecord method, you can check the code but for now, it's really simple.
Now its time for some windows. We're going to make a couple of really simple maintenance windows for our two tables. Really straight forward. Our windows will be standard insert/edit/delete windows with a save/undo button and some browse buttons to allow us to browse through the records. The idea will be to place as much logic as we can in a base class and keep our final windows as clean as possible. Once we're finished creating another maintenance window for a new table in our database will be a matter of minutes. This is our payback on our investment.
I started with creating a window class called wBase. This will be a window class that will form the basis of all windows I'll be making. I've implemented only some basic logic within this class but it is very important logic. I've implemented what we call a state engine. This current state engine currently recognises 5 states:
- View state, no record selected
- View state, record selected
- Edit state, new record
- Edit state, existing record
- Direct editing
The state engine will call the $getEnabled() method of an object if it exists and expects that method to return if that field should be enabled in the current state.
I base my visual settings fully on #STYLES. Go to the system classes folder and double click on #STYLES to look at them. Omnis creates most of the default styles, you will see that I have created copies of some of the styles suffixed with "Disabled". I toggle between the style and its disabled version to visually inform the user that a field is disabled.
(RD if you're reading this, GREAT ENHANCEMENT IDEA, a single style that lets me configure how a field should look when its enabled, and what it should look like when its disabled!).
More about styles some other day, needless to say: Use them, they are great!
I also have a toolbar base class called tbBase, again, this purely implements the state engine. Using this base class I've created two toolbar classes, tbBrowse and tbInsertEditDelete.
tbBrowse is a toolbar that will allow the user to browse through the records in a table:
When we look at the first button on this toolbar we see the following methods:
tbBrowse.FirstBtn.$event()
----
On evClick
Do $cwind.$evFirst()
tbBrowse.FirstBtn.$getEnabled()
----
Quit method pick(iState,kTrue,kTrue,kFalse,kFalse,kTrue)
Our $event method simply handles a click event that sends a message to the window this toolbar is on. The actual logic thus is present in the window. This way this toolbar can be used for any sort of window that has any sort of browse logic.Our $getEnabled() method is used by the state engine and we can see how this button reacts to our 5 states. The button is enabled in the two view states and in the direct edit state, but disabled in the two edit states.
The tbInsertEditDelete toolbar is a toolbar that gives us a nice insert, edit and delete button with a save and undo button to confirm our change. Sorry I couldn't find a nice delete icon and didn't feel artistic enough to make one :D
Now that we have our toolbars we can create the window where it all starts to come together. wBaseRecordBrowser is a base class that puts all the pieces of the puzzle together and its become really simple. I've added our two toolbars to this window but there are no fields on this window. There is a new instance variable called iRecordRow but we do not initialise it in our base class yet.
When we look at the code we see that except for one additional method, this class only implements the logic for the calls that the toolbars make to this window. Owh we also override our $construct but all we do here is call our $evFirst method, the same method the first button on our browse toolbar calls. This will ensure that when a subclass of this window opens, the first record of the table is shown.
The additional method is called $updateDetail and there is no logic in there. This method gets called after a new record is loaded into the screen and allows any subclass of our window to load additional details into the window or perform additional logic that is required. We may or may not need it.
When we look at the methods of our first toolbar, our browse toolbar we see that the logic follows a simple pattern:
- call the appropriate method of our row variable to get the first/last/previous/next record
- if successful call $updateDetail, set the correct state depending on whether a record was found and redraw the window
- if we fail, give an error
- For the insert button we first clear the record and then change the state.
- For the edit button we simply change the state (note that the edit button is only enabled in a state where a record is available that we can edit!)
- For the delete button we ask the user if he/she is sure, and the call the $delete method of our row variable and act according to the result
- For the save we either call the $insert or $update method of our row variable and act according to the result, finally we change the state back to view.
- Last but not least, for the undo we simply reload the current record and change the state, off course after asking the user if he/she is sure
Now remember we've already dragged our tables from the SQL browser into our library, in doing so created the schema class and the table classes, we've subclassed our new table classes from tBaseCounted and last but not least, we've implemented the $validateRecord method for our new table classes. These are roughly the steps you would have to repeat for each new table that you want to introduce to your application. Piece of cake right?
Creating our first window is just as easy. Create a subclass of wBaseRecordBrowser and call it wContactType.
Now override the $construct and change the code to this:
wContactType.$construct
----
; First define our record row for the correct type!
Do iRecordRow.$definefromsqlclass('tContactType')
; Then do the default construct!
Do inherited
All we do here is define our iRecordRow (remember, it was created in our base class) so it uses our table class (which was subclassed from tBaseCounted and thus contains all that cool logic we implemented).Now we need to create fields on our window for our table so our window looks like this:
We have 3 fields on this. A background field which is just a label. An entry field where we set the $dataname property to iRecordRow.Description and a checkbox field where we set the $dataname property to iRecordRow.IsPerson and we change the $text property.
Visually we don't need to do anything else.
We do need to implement a $getEnabled() method for the entry field and the checkbox. Both contain a single line of code:
Quit method pick(iState,kFalse,kFalse,kTrue,kTrue,kTrue)
And thats it.Really.
Thats all. Press CTRL-T and you'll see...
Lets do another one.
Create a subclass of wBaseRecordBrowser and call it wContact. Change the construct so that iRecordRow uses tContact. Add fields to this window so you can edit the record, add a $getEnabled() to each of these fields, and it should work.
Now if you look at the version I made, you can see I made a dropdown list for the column FkTypeID since this is a foreign key to our contact type table. I have to add a little more code for that:
wContact.$updateDetail
----
Calculate tmpFKID as iRecordRow.FkTypeID
Do iContactTypeList.$search($ref.ID=tmpFKID,kTrue,kFalse,kFalse,kFalse)
$UpdateDetail simply sets our dropdown list to the correct line for the current record. If the line can't be found the list will have $line = 0 which is fine by me.
wContact.iContactTypeList.$construct
----
; Init our contact list
Do iContactTypeList.$definefromsqlclass('tContactType')
If iContactTypeList.$select('order by upper(Description)')
Do iContactTypeList.$fetch(kFetchAll)
Else
OK message Error {[iContactTypeList.$getLastError()]}
End If
This is a $construct on our dropdown list. $construct methods on fields get automatically called when a window is opened and are run before the $construct of the window runs. Here we do nothing more then load the values from our contact type table into our list so that our dropdown list is populated.
wContact.iContactTypeList.$event
----
On evClick ;; Event Parameters - pRow ( Itemreference )
Calculate iRecordRow.FkTypeID as iContactTypeList.ID
Do $cinst.$$setFieldState($cinst.$objs.CustomerName) ;; Changing this dropdownlist could influence the state of this field!
Do $cinst.$redraw(kTrue,kTrue)
Finally we have to react when the user selects a line from our dropdown list. We update our current record with this information but we also reapply the field state for our customer name field. This is because this field is not only dependent on the state, but also on the value of ContactType.IsPerson.All that's left now is opening our windows from a menu. We can test our windows by pressing CTRL-T because we are still using a single library and a single task but that's not very use full for a user.
If you look at mMain you will see I'm now opening these windows.
Ok, there are two things missing in this tutorial that I've left out a little bit on purpose. There is enough in this tutorial for you to add this logic yourself, at least for the most part.
First, the user is now still forced to enter a customer number manually. It would be nicer if the system generates a new number for him/her. We can simply disable the customer number field by removing the $getEnabled method and disabling the field. But what to do about auto numbering this field? Hmmm.. where would be the best place? In the window? No that's not it... In the $validateRecord? No that's not it either.....
Second, the user can't search for a record. Our database is still small so there is no problem here, but wouldn't it be nice if the user could search? In one of my next tutorials we'll be building a separate search window but there are other options. Here is a suggestion, add a search button to the browse toolbar and introduce a new state (the search state). When the user presses the search button the window should go into a search state meaning that he/she can no longer browse, he/she can no longer insert/edit/delete/save/undo, he/she can fill in all the information on the screen and finally he/she can press the search button again (or another button) that will cause the system to look for a record in the table that matches what the user has entered.
Let me know where you would put what logic, how you would do this, surprise me :)
I will add a solution myself in the near future but since this is not my preferred way of designing windows I won't max it out.
I hope you enjoyed this part of the tutorial, took me a lot more time then I expected.
----
Disclaimer: I'm sure some of the listers will recognise their ideas in this tutorial in one form or another. Some ideas are truly mine, some are inspired by what I've learned in over 10 years of conferencing and reading the list. It would be an impossible task for me to list the many many people who I owe thanks to. I'm sure you know who you are my friends. I take only credit for taking the time to put this series together. Use the code you find in this tutorials and the library/libraries attached freely and as you see fit. I take no responsibility and will not be liable for any damages resulting directly or indirectly from using the information I present here.
No comments:
Post a Comment