Extending the PersonalMoney Application
Part 6
This exercise revisits binary filing to put in versioning
that should have gone in before, addressing multiple versions of the
application. A simple import and export
facility is built to transfer data using the Quicken Interchange Format
(qif). The editable combo box is
expanded to search for partial user-typed entries.
In Part 4,
as part of implementing bi-directional sorting, we looked at versioning
instances of ListViewColumn. We changed
the class definition, adding an instance variable, then overrode two class-side
methods, #stbVersion and #stbConvertFrom:, so that instances previously filed
out could be reloaded with the binary filer (STBFiler). While that's well and good, we did not do
the same for PersonalMoney's various model classes which have gone through
internal structural changes since the original Object Arts Tutorial.
In Part 1
we added 'accountTypes' to PersonalMoney and 'accountType' to
PersonalAccount. In Part 5 we added
'categories' to PersonalAccount and 'category' to
PersonalAccountTransactions. This means
that if you had created some data in the original PersonalMoney application and
then filed it out, you could not read it into the current version of
PersonalMoney. Likewise, if you had
created some data in the version following the Part 1 changes, you could not
read it in now. In the case of
PersonalMoney data this might not be a big thing, but if this had been a deployed
application, you might be getting calls from different people saying "What
gives ... and where's the fix?" and in fact you'd need to be fixing each
and every versioning problem.
There's a
third versioning problem and that's for users who created data files with
Dolphin version 2.1. Although this
tutorial is geared for version 3 and above, it's possible that someone with
version 2.1 created data, saved it, and now wants to read that into the latest
PersonalMoney application. Again, for a
deployed application, this is a reasonable scenario and something you'd have to
deal with. Here are the instance
variables for class PersonalMoney for the various stages.
PersonalMoney, instance
variables at various stages
|
||||||
Tutorial Version
|
Dolphin Version |
class #stbVersion |
Slot 1 |
Slot 2 |
Slot 3 |
Slot 4 |
|
Original |
2.1 |
0 |
owner |
accounts |
|
|
|
Original |
3 |
1 |
events |
owner |
accounts |
|
|
Part
1 |
3 |
2 |
events |
owner |
accounts |
accountTypes |
This shows
that in the original tutorial, PersonalMoney had two instance variables --
owner and accounts. Dolphin version 3
added 'events' in class Model, so that is inherited by PersonalMoney -- in slot
1. Then in Part 1 of this tutorial we
added 'accountTypes'. These different
versions of the model are indicated by the column 'class #stbVersion', though
we haven't implemented version 2 yet.
#stbVersion 0 is the default and that is what's found in Dolphin 2.1. Dolphin 3 sets stbVersion to 1 and this is
what is inherited by PersonalMoney so far.
Let's say
we have a data file from Dolphin version 2.1.
If we just read it back into Dolphin 2.1, the version is zero and no
conversion takes place. The binary filer
reads in the instance, finds two values, creates a PersonalMoney instance and
assigns the two values to the two instance variables. As for reading the same file into Dolphin verion 3, take a quick
look at the conversion method in class Model, Dolphin 3:
Model, class
side:
stbConvertFrom: anSTBClassFormat
"Convert from earlier
version models.
1: Added 'events' instance variable to
Model."
^[:data | | newInst |
newInst := self basicNew.
data keysAndValuesDo:
[:i :v | newInst instVarAt: i+1 put: v].
newInst]
In this
case, the filer reads in two values, but the new instance will have three
slots. The method above essentially
rightshifts the values. 'data' is an
array that contains the values read in by the binary filer. The first value goes into slot 2 (owner) and
the second value goes into slot 3 (accounts).
'events' is left at nil, which is a valid value (for events), so no
initialization is needed.
The key
things to remember when overriding #stbConvertFrom: are that a block is returned. The block will be passed an argument,
'data', an array containing the values in the binary file, which may or may not
represent an old class version. The
code in the block needs to create a new blank instance of the receiver (the
model), copy the data values from 'data' into the new instance, initialize or
change values in the new instance as needed, and then return the new instance.
So what
changes do we want to make for the next version, version 2. We want to use the existing code so that
version 0 data will be rearranged and copied (from data to the newInst) and so
that version 1 data will be copied from data to the newInst. Then, since the 'data' array will not have
any values for 'accountTypes', but newInst does have a slot for it, we need to
initialize the value in slot 4. This
looks like so:
In
PersonalMoney, class side:
stbVersion
"Answer the current binary
filer version number for instances of the receiver."
^2
stbConvertFrom: anSTBClassFormat
"Convert from earlier
version models.
1: Added 'events' instance variable to
Model.
2: Added 'accountTypes' instance
variable."
^[:data | | newInst ver offset |
newInst := self basicNew.
ver := anSTBClassFormat
version.
offset := ver < 1 ifTrue: [1] ifFalse: [0].
1 to: data size do: [:i | newInst
instVarAt: i+offset put: (data at: i)].
"Perhaps use lazy
initialization instead of the next statement."
newInst accountTypes isNil
ifTrue: [
newInst instVarAt: 4 put:
(ListModel
with: OrderedCollection new searchPolicy:
SearchPolicy equality)].
newInst]
A few
notes: We add a line to the comment,
indicating the change(s) for this version.
We extract the version ('ver') from anSTBClassFormat, which is passed in
as a parameter. We set up an 'offset'
local variable which we use to shift the data values into the new instance
variable, if needed. If version is
zero, data will be shifted, otherwise it was already shifted before writing out
to file. Then we copy the values from
the data array into the appropriate slots in newInst. At this point we test to see if newInst
accountTypes isNil, which it will be if we're reading in data filed from
versions 0 or 1, so we initialize it here.
Note that we need to use the method #instVarAt:put: since there's no
public method in PersonalMoney to assign accountTypes.
Note also
the comment on Lazy Initialization. My
first thoughts were to leave accountTypes uninitialized, simplifying this
method. Then move the initialization
code for accountTypes from PersonalMoney>>#initialize to the accountTypes
accessor method. However, following the
suggestion and reasoning in the Pattern for Lazy Initialization, I put the code
in here, duplicating what is in #initialize.
I do still wonder about this -- I know there are those who swear by Lazy
Initialization.
Now we look
at PersonalAccountTransaction, which is very similar to PersonalMoney.
PersonalAccountTransaction,
instance variables at various stages
|
||||||||
Tutorial Version
|
Dolphin Version |
class #stbVersion |
Slot 1 |
Slot 2 |
Slot 3 |
Slot 4 |
Slot 5 |
Slot 6 |
|
Original |
2.1 |
0 |
date |
description |
amount |
isDebit |
|
|
|
Original |
3 |
1 |
events |
date |
description |
amount |
isDebit |
|
|
Part
5 |
3 |
2 |
events |
date |
description |
amount |
isDebit |
category |
Version 0 had 4 instance variables. Version 1 (Dolphin verion 3) added
an 'events' instance variable and verion 2 (tutorial Part 5) added a 'category'
instance variable. So we essentially do
the same thing here that we did in PersonalMoney class>>#stbConvertFrom:
except that since category can be nil, we don't even bother initializing
it. Here are the new methods:
In
PersonalAccountTransaction, class side:
stbVersion
"Answer the current binary
filer version number for instances of the receiver."
^2
stbConvertFrom: anSTBClassFormat
"Convert from earlier
version models.
1: Added 'events' instance variable to
Model.
2: Added 'category' instance
variable."
^[:data | | newInst ver offset |
newInst := self basicNew.
ver := anSTBClassFormat
version.
offset := ver < 1 ifTrue: [1] ifFalse: [0].
1 to: data size do: [:i | newInst
instVarAt: i+offset put: (data at: i)].
ver < 2 ifTrue: ["leave 'category' nilled."].
newInst]
Finally, we look at PersonalAccount, which is similar to the previous two
model classes, though a bit more complex.
PersonalAccount, instance
variables at various stages
|
||||||||||
Tutorial Version
|
Dolphin Version |
#stb Version |
Slot 1 |
Slot 2 |
Slot 3 |
Slot 4 |
Slot 5 |
Slot 6 |
Slot
7
|
Slot
8
|
|
Original |
2.1 |
0 |
name |
account Number |
initial Balance |
transactions |
current Balance |
|
|
|
|
Original |
3 |
1 |
events |
name |
account Number |
initial Balance |
transactions |
current Balance |
|
|
|
Part
1 |
3 |
2 |
events |
name |
account Number |
account Type |
initial Balance |
transactions |
current Balance |
|
|
Part
5 |
3 |
3 |
events |
name |
account Number |
account Type |
initial Balance |
transactions |
current Balance |
categories |
PersonalAccount
started out with 5 instance variables in version 0 (Dolphin 2.1). Version 1 saw the addition of 'events', and
then 'accountType' was added in Tutorial Part 1 and 'categories' was added in
Tutorial Part 5. Note that
'accountType' was added in the middle of the list of instance variables (slot
3), rather than at the beginning or the end -- this complicates things a bit
but it's not too bad.
In
PersonalAccount, class side:
stbVersion
"Answer the current binary
filer version number for instances of the receiver."
^3
stbConvertFrom: anSTBClassFormat
"Convert from earlier
version models.
1: Added 'events' instance variable to
Model.
2. Added 'accountType' instance
variable.
3: Added 'categories' instance
variable."
^[:data | | newInst ver offset |
newInst := self basicNew.
ver := anSTBClassFormat
version.
"Copy values from
data to newInst. Shift by 1, for
events, if ver = 0."
offset := ver < 1 ifTrue: [1] ifFalse: [0].
1 to: data size do: [:i | newInst
instVarAt: i+offset put: (data at: i)].
(ver < 2 and: [(newInst instVarAt: 5) isLiteral not]) ifTrue:
[
"shift
data and nil out 'accountType' in 4th slot."
self instSize-1 to: 4 by: -1 do: [:i | newInst instVarAt: i+1 put: (newInst instVarAt: i)].
newInst instVarAt: 4 put: nil].
ver < 3 ifTrue: ["initialize categories."
newInst instVarAt: 8 put:
(ListModel
with: SortedCollection new searchPolicy:
SearchPolicy equality)].
newInst]
Most of
this is pretty much the same as in the previous two model classes. The tricky part is the section starting with
"(ver < 2 and: [(newInst instVarAt: 5) isLiteral not])". If someone had saved data in Dolphin 2.1 or
Dolphin 3 (before the changes in Tutorial Part 1) then we want to shift
initialBalance, transactions, and currentBalance one slot to the right to make
room for accountType. However, if they
had saved data after making the changes in Tutorial Part 1, the saved file
would have a version of 1, but it would have accountType data in the proper
slot. #isLiteral returns false if
transactions data (a ListModel) is in the fifth slot, in which case we do need
to shift the columns to the right, then nil out the old data in the fourth slot
(accountType). Note that we go
backwards, using #to:by:do:, decrementing by 1 (by: -1) to perform the
right-shift.
At this
point, you can read in almost any file that was created in a prior version of
PersonalMoney. The exception is those
files with sorted data that were created prior to the fix to #stbSaveOn: (in
Part 3). By this I mean the case where
a listview column is clicked (which sorts the data) and then the data is saved
to file. These files, as far as I can
see, are not readable. I won't even go
into what I've tried -- if you look (using a hex editor or even notepad) at a
binary file with the data sorted, you'll see a whole shipload of stuff, I
assume brought in by a block closure or two.
The fix in Part 3 prevents the problem but does not offer a
solution. It seems like somewhere it
would be necessary to bypass all the gunk, picking out the data and making it
an OrderedCollection, but I couldn't figure out the where or how.
The bottom
line AND the big lesson on all of this is that if you're using the binary
filer, make sure you update the #stbVersion and #stbConvertFrom: class-side
methods whenever changes are made to the class definition. Also, make sure you use #stbSaveOn: to
convert any sorted collections back to ordered collections before filing them
out.
In brief, a
stream (usually a ReadStream, WriteStream or FileStream) provides a simple and
consistent protocol that allows you to access an underlying sequenceable
collection (string, file, or byte array).
An excellent primer on Smalltalk streams can be found in the IBM
Smalltalk Tutorial, Chapter 8. The
Dolphin hierarchy of stream and filestream classes differs from that of IBM,
but there is effectively little difference -- most of the methods and usage are
the same.
The minimum
requirement for qif file transfer (in and out of PersonalMoney) is to be able
to write an account's transactions to file so that it can be read into Quicken
and also to be able to read an account's transactions that were written to file
from within Quicken.
Below is a
file (22 lines) that was generated by Quicken.
The account has an opening balance of $500 and two transactions. The first line is a Quicken 'export header'
and identifies the account type, in this case a Bank account. This is followed by three transactions,
including the opening balance. Each
transaction consists of a number of lines ending with a circumflex (^). Each line starts with a single character,
identifying the type of information, and then the information itself.
!Type:Bank
D8/11/99
U500.00
T500.00
CX
POpening Balance
L[Wells Fargo]
^
D3/ 3' 0
U-80.00
T-80.00
N101
PBell Market
LGroceries
^
D9/15' 0
U100.00
T100.00
NATM
PDeposit
LReimbursement
^
The single
character codes have the following meanings:
'D' stands for date, 'U' and 'T' stand for amount (I'm not sure what the
difference is, the Quicken documentation is unclear), 'C' stands for
transaction cleared, 'P' stands for payee or description, 'L' stands for
transaction category, and 'N' stands for the check number or reference.
For now we
can ignore some of these lines and just process the ones we're interested
in. Specifically, we look for date,
amount, description, category, and end of item. These correspond to codes D, T, P, L, and ^. These are the items we'll read in to the
model as well as write out. Export
headers, check numbers, 'U' amounts, and other coded information we'll make use
of at a later date.
Starting
with the export side of the business, look at #fileSave and #fileSaveAs in
DocumentShell for some ideas on how to prompt for a filename, then save data to
file. These methods use FileSaveDialog
to prompt for the filename -- if it's not nil then a stream is opened on the
file, another method is called to write the data out, and the file is closed. An exception is raised if there is a problem
with the file. Combining these for our
purposes, we get:
In
PersonalAccountShell:
accountExport
"Prompts for a filename,
then exports the receiver to file."
| filename stream |
filename := (FileSaveDialog on:
self model name)
fileTypes: #( ('Quicken Files (*.qif)' '*.qif') ); defaultExtension: 'qif';
caption:
'Export Document As'; showModal.
[filename notNil ifTrue: [
stream := FileStream write:
filename.
[self streamOutQIF:
stream] ensure: [stream
close]]
] on: FileException do: [:e |
MessageBox errorMsg: 'Unable to export file ',
e file name
printString caption: 'Error - ', e messageText]
This method
creates a FileStream on the user-supplied filename, then tells itself to stream
out qif data to that stream, ensuring that the stream is closed (upon success
or failure). The first cut at
implementing #streamOutQIF: is simple -- just have each transaction stream
itself to the filestream:
In
PersonalAccountShell:
streamOutQIF: aStream
"Write receiver's
transactions out to aStream."
self transactions do: [:trans
| trans streamOutQIF: aStream]
In
PersonalAccountTransaction:
streamOutQIF: aStream
"Write receiver's data out
to aStream in Quicken (QIF) format."
aStream
nextPut: $D; nextPutAll: self date
displayString; cr;