Professional Documents
Culture Documents
Steven Feuerstein
steven@stevenfeuerstein.com www.oracleplsqlprogramming.com www.qnxo.com www.unit-test.com
The Mechanics
Lectures followed by lots of flex-time exercises.
Tell me your status: green = ready, red=help needed
Use Toad (or your own alternative) to build, edit, run exercises. Login: qnxo/qnxo Qnxo contains your exercise repository.
Or work from c:\hoc\hoc_exercises.html or c:\hoc\hoc_exercises_no_sol.html Generally, all files found in c:\hoc unless indicated otherwise.
You can download all my training materials and demonstration scripts from:
http://oracleplsqlprogramming.com/resources.html
Toad: thanks to Quest for giving me permission! Qnxo: repository of exercises and useful PL/SQL code MasterMind and Set: let's have fun while keeping our brains tuned up. Qute: the Quick Unit Test Engine
plsql_ides.txt
Introduction to Toad
You all have installed and available Toad 8.6 to help you write and run your exercises.
Username: qnxo Password: qnxo
Let's do a quick review of the main features of Toad you will be using...
Introduction to Qnxo
I used Qnxo to define a repository of all the exercises and solutions for the class.
Find the "Hands-on Training on PL/SQL Collections" root script in the Script Browser.
Drill down to the current chapter, and then into the exercises for that chapter.
The corresponding "s" script (CH-Ns) offers a solution that you can use to help you solve the exercise.
Copyright 2006 Steven Feuerstein - Page 7
Your schema has defined within it the full set of standard HR objects, such as employees, departments, etc. We have also added a department_denorms table with collection columns. If at any point you want to refresh these tables...
Copy the code from the "Script to create tables referenced in the exercises" Paste into the SQL Editor and run it.
Copyright 2006 Steven Feuerstein - Page 10
A. Collection utilities and applications The A(ppendix) challenges you to construct commonly-needed functionality when working with collections.
Since PL/SQL is a strongly-typed language, it is very difficult to write programs that work for all types of collections. If you find yourself waiting for others to finish, you can work on these!
PL/SQL Collections
Collections are single-dimensioned lists of information, similar to 3GL arrays. They are an invaluable data structure; all PL/SQL developers should be familiar with them -- and use them a lot. They take some getting used to, especially when you want to leverage the latest features, such as multi-level collections.
What is a collection?
1 abc 2 def 3 sf 4 q
...
22 rrr
23 swq
A collection is an "ordered group of elements, all of the same type." (PL/SQL User Guide and Reference)
That's a very general definition; lists, sets, arrays and similar data structures are all types of collections. Each element of a collection may be addressed by a unique subscript, usually an integer but in some cases also a string. Collections are single-dimensional, but you can create collections of collections to emulate multi-dimensional structures.
Copyright 2006 Steven Feuerstein - Page 13
Parallelize execution of PL/SQL functions inside SQL statements. With table functions.... Dramatically improve multi-row querying, inserting, updating and deleting the contents of tables.
Combined with BULK COLLECT and FORALL....
Library cache
Update emp Set sal=...
Large Pool
calc_totals
show_emps
upd_salaries
Session 1
Session 2
Sparse
Data does not have to be stored in consecutive rows, as is required in traditional 3GL arrays and VARRAYs.
Part of object model, requiring initialization. Is always dense initially, but can become sparse after deletes.
Can be defined as a schema level type and used as a relational table column type.
nested_table_example.sql Copyright 2006 Steven Feuerstein - Page 18
About Varrays
Has a maximum size, associated with its type.
Can adjust the size at runtime in Oracle10g R2.
Part of object model, requiring initialization. Is always dense; you can only remove elements from the end of a varray with TRIM. Can be defined as a schema level type and used as a relational table column type.
varray_example.sql Copyright 2006 Steven Feuerstein - Page 19
Don't always fill collections sequentially. Think about how you need to manipulate the contents. Oracle raises NO_DATA_FOUND if you try to read an index that does not exist.
Copyright 2006 Steven Feuerstein - Page 21
mysess.pkg sess2.sql
Exercises for "01. Declare and define types, instances, and elements"
Time to get comfortable with declaring types and variables based on those types!
02. Introduction to BULK COLLECT / FORALL; high performance SQL Oracle8i and Oracle9i offer groundbreaking new syntax to improve the performance of both DML and queries. In Oracle8, updating from a collection (or, in general, performing multi-row DML) meant writing code like this:
CREATE TYPE dlist_t AS TABLE OF INTEGER; / PROCEDURE remove_emps_by_dept (deptlist dlist_t) IS BEGIN FOR aDept IN deptlist.FIRST..deptlist.LAST LOOP DELETE emp WHERE deptno = deptlist(aDept); END LOOP; END;
Copyright 2006 Steven Feuerstein - Page 23
Conventional Bind
Oracle server
PL/SQL Runtime Engine
PL/SQL block
SQL Engine
FOR aDept IN deptlist.FIRST.. deptlist.LAST LOOP DELETE emp WHERE deptno = deptlist(aDept); END LOOP;
SQL Engine
bulkcoll.sql
Let's get you comfortable with the basics of BULK COLLECT and FORALL.
Later we will drill down into more nuances of these great constructs.
Copyright 2006 Steven Feuerstein - Page 28
When you are done with this section, choosing the right kind of loop should be automatic for you.
Example of passing collections as arguments - 1 Define a schema-level collection type and then use that to pass an argument to a procedure.
CREATE OR REPLACE TYPE numbers_ntt IS TABLE OF NUMBER; / CREATE OR REPLACE PROCEDURE process_numbers ( numbers_in IN numbers_ntt ) IS BEGIN -- Assumes densely-filled collection! FOR indx IN 1 .. numbers_in.COUNT LOOP DBMS_OUTPUT.put_line ( numbers_in ( indx )); END LOOP; END process_numbers; /
Example of passing collections as arguments - 2 Define a collection type in a package specification and then use that to pass an argument to a procedure.
CREATE OR REPLACE PACKAGE types_pkg IS TYPE numbers_ntt IS TABLE OF NUMBER; END types_pkg; / CREATE OR REPLACE PROCEDURE process_numbers ( numbers_in IN types_pkg.numbers_ntt ) IS BEGIN -- Assumes densely-filled collection! FOR indx IN 1 .. numbers_in.COUNT LOOP DBMS_OUTPUT.put_line ( numbers_in ( indx )); END LOOP; END process_numbers; /
Copyright 2006 Steven Feuerstein - Page 33
Example of passing collections as arguments - 3 Define a collection type in a local block and then use that to pass an argument to a procedure.
DECLARE TYPE numbers_ntt IS TABLE OF NUMBER; l_numbers numbers_ntt; PROCEDURE process_numbers ( numbers_in IN types_pkg.numbers_ntt ) IS BEGIN -- Assumes densely-filled collection! FOR indx IN 1 .. numbers_in.COUNT LOOP DBMS_OUTPUT.put_line ( numbers_in ( indx )); END LOOP; END process_numbers; BEGIN process_numbers ( l_numbers ); END;
Copyright 2006 Steven Feuerstein - Page 34
Can lead to excessive overhead and memory for large structures and particularly collections and objects.
Enter the NOCOPY compiler hint.
Copyright 2006 Steven Feuerstein - Page 35
Under some circumstances, the compiler will ignore your request to not copy, including:
Actual parameter is element of assoc. array Actual parameter is constrained (eg, NOT NULL)
And if it does as requested and an exception is raised, your data may be corrupted.
The exception will not cause a rollback of changes to a parameter that was passed by reference.
nocopy*.* Copyright 2006 Steven Feuerstein - Page 36
Let's make sure you are comfortable with all the variations, and familiar with NOCOPY.
TRIM removes elements from the end a nested table or VARRAY only.
delete.sql extend.sql trim.sql Copyright 2006 Steven Feuerstein - Page 38
Go ahead, see if you can mess up those collections by deleting, trimming, extending!
Oracle9i Release 2
Prior to Oracle9iR2, you could only index by BINARY_INTEGER. You can now define the index on your associative array to be:
Any sub-type derived from BINARY_INTEGER VARCHAR2(n), where n is between 1 and 32767 %TYPE against a database column that is consistent with the above rules A SUBTYPE against any of the above.
This means that you can now index on string values! (and concatenated indexes and...)
Copyright 2006 Steven Feuerstein - Page 40
INDEX BY BINARY_INTEGER; INDEX BY PLS_INTEGER; INDEX BY POSITIVE; INDEX BY NATURAL; INDEX BY VARCHAR2(64); INDEX BY VARCHAR2(32767); INDEX BY employee.last_name%TYPE; TYPE array_t8 IS TABLE OF NUMBER INDEX BY types_pkg.subtype_t;
IS IS IS IS IS IS IS
OF OF OF OF OF OF OF
Specifying a row via a string takes some getting used to, but if offers some very powerful advantages.
Copyright 2006 Steven Feuerstein - Page 42
Oracle9i Release 2
One of the most powerful applications of this features is to construct very fast pathways to static data from within PL/SQL programs.
If you are repeatedly querying the same data from the database, why not cache it in your PGA inside collections?
Emulate the various indexing mechanisms (primary key, unique indexes) with collections.
Demonstration package: assoc_array5.sql Generate a caching package: genaa.sql genaa.tst Comparison of performance of different approaches: vocab*.*
Fun with strings! On the one hand, there is no substantive difference in the way you work with string-indexed collections. Other the other hand, you can do some really cool things very easily....
Copyright 2006 Steven Feuerstein - Page 44
Oracle9i
Oracle9i allows you to create collections of collections, or collections of records that contain collections, or... Applies to all three types of collections. Two scenarios to be aware of:
Named sub-collections Anonymous sub-collections
Oracle9i
When a collection is based on a record or object that in turn contains a collection, that collection has a name.
CREATE TYPE vet_visit_t IS OBJECT ( visit_date DATE, reason VARCHAR2 (100)); / CREATE TYPE vet_visits_t IS TABLE OF vet_visit_t / CREATE TYPE pet_t IS OBJECT ( Collection nested inside tag_no INTEGER, object type NAME VARCHAR2 (60), petcare vet_visits_t, MEMBER FUNCTION set_tag_no (new_tag_no IN INTEGER) RETURN pet_t); /
multilevel_collections.sql Copyright 2006 Steven Feuerstein - Page 46
Continued...
Oracle9i
DECLARE TYPE bunch_of_pets_t IS TABLE my_pets bunch_of_pets_t; BEGIN my_pets (1) := pet_t ( 100, 'Mercury', vet_visits_t ( vet_visit_t ( '01-Jan-2001', vet_visit_t ( '01-Apr-2002', ) ); DBMS_OUTPUT.put_line (my_pets END;
Outer collection
Inner collection
'Clip wings'), 'Check cholesterol')
(1).petcare (2).reason);
Oracle9i
If your nested collections do not rely on "intermediate" records or objects, you simply string together index subscripts.
To demonstrate this syntax, let's take a look at how to emulate a three-dimensional array using nested collections.
First, we cannot directly reference or populate an individual cell, as one would do in a 3GL.
Instead we have to build an interface between the underlying arrays and the user of the "three dimensional array."
Can't do this...
BEGIN gps_info (1, 45, 605) := l_value; Copyright 2006 Steven Feuerstein - Page 48
Oracle9i
TYPE dim2_t IS TABLE OF dim1_t INDEX BY BINARY_INTEGER; TYPE dim3_t IS TABLE OF dim2_t INDEX BY BINARY_INTEGER; PROCEDURE setcell ( array_in IN OUT dim3_t, dim1_in PLS_INTEGER, dim2_in PLS_INTEGER, dim3_in PLS_INTEGER, value_in IN VARCHAR2 ); FUNCTION getcell ( array_in IN dim1_in dim2_in dim3_in ) RETURN VARCHAR2; END multdim;
Oracle9i
So I will build a program to identify such ambiguous overloadings. But how can I do this?
PACKAGE salespkg IS PROCEDURE calc_total ( dept_in IN VARCHAR2); PROCEDURE calc_total ( dept_in IN CHAR); END salespkg; BEGIN salespkg.calc_total ('ABC'); END; /
Load it up!
Then what? Write lots of code to interpret the contents... Which programs are overloaded? Where does one overloading end and another start?
l_last_program all_arguments.object_name%TYPE; l_is_new_program BOOLEAN := FALSE; l_last_overload PLS_INTEGER := -1; IF l_arguments (indx).overload BEGIN != l_last_overload FOR indx IN l_arguments.FIRST .. OR l_last_overload = -1 l_arguments.LAST THEN LOOP IF l_is_new_program IF l_arguments (indx).object_name != THEN l_last_program do_first_overloading_stuff; OR l_last_program IS NULL ELSE THEN do_new_overloading_stuff; l_last_program := END IF; l_arguments (indx).object_name; END IF; l_is_new_program := TRUE; END LOOP; do_new_program_stuff; END; END IF; ... Copyright 2006 Steven Feuerstein - Page 55
Overloading
Overloading 1 Overloading 2
Breakout Argument
Argument 1 Argument 2 Argument 3 Argument 4 Argument 5 Breakout 1 Breakout 2 Breakout 3 Breakout 1
Each program has zero or more overloadings, each overloading has N arguments, and each argument can have multiple "breakouts" (my term - applies to nonscalar parameters, such as records or object types).
Copyright 2006 Steven Feuerstein - Page 56
What if I reflect this hierarchy in a collection of collections? Have to build from the bottom up:
1. Set of rows from ALL_ARGUMENTS 2. All the "breakout" info for a single argument 3. All the argument info for a single overloading 4. All the overloadings for a distinct program name TYPE breakouts_t IS TABLE OF all_arguments%ROWTYPE INDEX BY BINARY_INTEGER; TYPE arguments_t IS TABLE OF breakouts_t INDEX BY BINARY_INTEGER; TYPE overloadings_t IS TABLE OF arguments_t INDEX BY BINARY_INTEGER; TYPE programs_t IS TABLE OF overloadings_t INDEX BY all_arguments.object_name%type;
String-based index
Copyright 2006 Steven Feuerstein - Page 57
But I will now also add the multi-level load in single assignment
show_all_arguments.sp show_all_arguments.tst
What is the datatype of the RETURN clause of the 2nd overloading of TOP_SALES?
And, of course, I know the beginning and end points of each program, overloading, and argument. I just use the FIRST and LAST methods on those collections!
Oracle10g
These structures can get very tough to follow, very quickly. Take it step by step and encapsulate the structures to hide the details and avoid confusion.
08. Working with collections in SQL statements When you define a collection as the column of a table, you can manipulate its contents with SQL statements. You can also query the contents of a PL/SQL variable collection (nested table or varray).
Which means that you sort the contents.
BOLZ
BOND
BOLZ
...
Copyright 2006 Steven Feuerstein - Page 63
Make sure you are comfortable with applying various kinds of SQL statements to collections. Advanced: 08-4...populate the denormalization table.
Let's take a look at that structure....
Copyright 2006 Steven Feuerstein - Page 67
Let's go back to BULK COLLECT and talk a bit more about using it in "real life." BULK COLLECT is fast, but can also cause large consumption of memory. Use the LIMIT clause to manage memory and still achieve high performance.
Use the LIMIT clause with the INTO to manage the amount of memory used with the BULK COLLECT operation.
WARNING! BULK COLLECT will not raise NO_DATA_FOUND if no rows are found. Best to check contents of collection to confirm that something was retrieved.
bulklimit.sql
Oracle9i
Now you can even avoid the OPEN FOR and just grab your rows in a single pass!
CREATE OR REPLACE PROCEDURE fetch_by_loc (loc_in IN VARCHAR2) IS TYPE numlist_t IS TABLE OF NUMBER; TYPE namelist_t IS TABLE OF employee.name%TYPE; TYPE employee_t IS TABLE OF employee%ROWTYPE;
emp_cv
sys_refcursor;
empnos numlist_t; enames namelist_t; l_employees employee_t; BEGIN OPEN emp_cv FOR 'SELECT empno, ename FROM emp_' || loc_in; FETCH emp_cv BULK COLLECT INTO empnos, enames; CLOSE emp_cv; EXECUTE IMMEDIATE 'SELECT * FROM emp_' || loc_in BULK COLLECT INTO l_employees; END; Copyright 2006 Steven Feuerstein - Page 70
Tips and Fine Points Use bulk queries whenever you need to execute individual row queries within a PL/SQL loop.
Can be used with implicit and explicit cursors
Fills collection sequentially, starting at row 1 Avoid "Snapshot too old" errors...
Caused: a cursor is held open too long and Oracle can no longer maintain the snapshot information. Solution: open-close cursor, or use BULK COLLECT to retrieve information more rapidly bulktiming.sql
Copyright 2006 Steven Feuerstein - Page 71 emplu.pkg cfl_to_bulk*.sql
Why would you ever use a cursor FOR loop (or other LOOP) now that you can perform a BULK COLLECT?
If you want to do complex processing on each row as it is queried and possibly halt further fetching. You are retrieving many rows and cannot afford to use up the memory (large numbers of users).
Let's now return to FORALL and fill out our understanding of this powerful construct:
Use with dynamic SQL SAVE EXCEPTIONS for improved error handling Using FORALL with sparsely-filled collections
Oracle9i
This example shows the use of bulk binding and collecting, plus application of the RETURNING clause.
CREATE TYPE NumList IS TABLE OF NUMBER; CREATE TYPE NameList IS TABLE OF VARCHAR2(15); PROCEDURE update_emps ( col_in IN VARCHAR2, empnos_in IN numList) IS enames NameList; BEGIN FORALL indx IN empnos_in.FIRST .. empnos_in.LAST EXECUTE IMMEDIATE 'UPDATE emp SET ' || col_in || ' = ' || col_in || ' * 1.1 WHERE empno = :1 RETURNING ename INTO :2' USING empnos_in (indx ) Notice that empnos_in is RETURNING BULK COLLECT INTO enames; indexed, but enames is not. ... END;
Copyright 2006 Steven Feuerstein - Page 74
Oracle9i
Allows you to continue past errors and obtain error information for each individual operation (for dynamic and static SQL).
CREATE OR REPLACE PROCEDURE load_books (books_in IN book_obj_list_t) IS bulk_errors EXCEPTION; PRAGMA EXCEPTION_INIT ( bulk_errors, -24381 ); BEGIN FORALL indx IN books_in.FIRST..books_in.LAST Allows processing of all SAVE EXCEPTIONS rows, even after an INSERT INTO book values (books_in(indx)); error occurs. EXCEPTION WHEN BULK_ERRORS THEN FOR indx in 1..SQL%BULK_EXCEPTIONS.COUNT New cursor LOOP attribute, a pseudolog_error (SQL%BULK_EXCEPTIONS(indx)); END LOOP; collection END;
bulkexc.sql
Oracle10g
In Oracle10g, the FORALL driving array no longer needs to be processed sequentially. Use the INDICES OF clause to use only the row numbers defined in another array. Use the VALUES OF clause to use only the values defined in another array.
Oracle10g
Using INDICES OF
DECLARE TYPE employee_aat IS TABLE OF employee.employee_id%TYPE INDEX BY PLS_INTEGER; l_employees employee_aat; TYPE boolean_aat IS TABLE OF BOOLEAN INDEX BY PLS_INTEGER; l_employee_indices boolean_aat; BEGIN l_employees (1) := 7839; l_employees (100) := 7654; l_employees (500) := 7950; -l_employee_indices (1) := TRUE; l_employee_indices (500) := TRUE; l_employee_indices (799) := TRUE -FORALL l_index IN INDICES OF l_employee_indices BETWEEN 1 AND 500 UPDATE employee SET salary = 10000 WHERE employee_id = l_employees (l_index); END; 10g_indices_of.sql 10g_indices_of2.sql
It only processes the rows with row numbers matching the defined rows of the driving array.
Oracle10g
Using VALUES OF
DECLARE TYPE employee_aat IS TABLE OF employee.employee_id%TYPE INDEX BY PLS_INTEGER; l_employees employee_aat; TYPE values_aat IS TABLE OF PLS_INTEGER INDEX BY PLS_INTEGER; l_employee_values values_aat; BEGIN l_employees (-77) := 7820; l_employees (13067) := 7799; l_employees (99999999) := 7369; -l_employee_values (100) := -77; l_employee_values (200) := 99999999; -FORALL l_index IN VALUES OF l_employee_values UPDATE employee SET salary = 10000 WHERE employee_id = l_employees (l_index); END;
It only processes the rows with row numbers matching the content of a row in the driving array.
10g_values_of.sql
forall_incr_commit.sql
Combined with REF CURSORs, you can now more easily transfer data from within PL/SQL to host environments.
Java, for example, works very smoothly with cursor variables
Copyright 2006 Steven Feuerstein - Page 81
Simple table function example Return a list of names as a nested table, and then call that function in the FROM clause.
CREATE OR REPLACE FUNCTION lotsa_names ( base_name_in IN VARCHAR2, count_in IN INTEGER ) RETURN names_nt IS retval names_nt := names_nt (); BEGIN retval.EXTEND (count_in); FOR indx IN 1 .. count_in LOOP retval (indx) := base_name_in || ' ' || indx; END LOOP; RETURN retval; END lotsa_names; Copyright 2006 Steven Feuerstein - Page 83
SELECT column_value FROM TABLE ( lotsa_names ('Steven' , 100)) names; COLUMN_VALUE -----------Steven 1 ... Steven 100
tabfunc_scalar.sql
Pipelined functions allow you to return data iteratively, asynchronous to termination of the function.
As data is produced within the function, it is passed back to the calling process/query.
RETURN...nothing at all!
13. Multiset operations on nested tables Oracle10g introduces high-level set operations on nested tables (only).
Nested tables are multisets, meaning that theoretically there is no order to their elements. This makes set operations of critical importance for manipulating nested tables.
the same number of elements, then if at least one element is NULL, the operators return NULL.
Oracle10g
END;
Oracle10g
Use the SET operator to work with distinct values, and determine if you have a set of distinct values.
DECLARE keep_it_simple strings_nt := strings_nt (); BEGIN keep_it_simple := SET (favorites_pkg.my_favorites); favorites_pkg.show_favorites ('FULL SET', favorites_pkg.my_favorites);
p.l (favorites_pkg.my_favorites IS A SET, 'My favorites distinct?'); p.l (favorites_pkg.my_favorites IS NOT A SET, 'My favorites NOT distinct?');
favorites_pkg.show_favorites ( 'DISTINCT SET', keep_it_simple); p.l (keep_it_simple IS A SET, 'Keep_it_simple distinct?'); p.l (keep_it_simple IS NOT A SET, 'Keep_it_simple NOT distinct?'); END; 10g_set.sql 10g_favorites.pkg Copyright 2006 Steven Feuerstein - Page 95
Oracle10g
Use the SUBMULTISET operator to determine if a nested table contains only elements that are in another nested table.
BEGIN p.l (favorites_pkg.my_favorites SUBMULTISET OF favorites_pkg.eli_favorites , 'Father follows son?'); p.l (favorites_pkg.eli_favorites SUBMULTISET OF favorites_pkg.my_favorites , 'Son follows father?'); p.l (favorites_pkg.my_favorites NOT SUBMULTISET OF favorites_pkg.eli_favorites , 'Father doesn''t follow son?'); p.l (favorites_pkg.eli_favorites NOT SUBMULTISET OF favorites_pkg.my_favorites , 'Son doesn''t follow father?'); END; Copyright 2006 Steven Feuerstein - Page 96
10g_submultiset.sql 10g_favorites.pkg
A mutating table error occurs when a rowlevel trigger attempts to query or change the table from which it fired. Collections offer a very nice way to work around these errors. Use exercise A-5 to apply the information provided in the next two pages.
Row level triggers cannot query from or change the contents of the table to which it is attached; it is "mutating". But statement level triggers do not have this restriction. So what are you supposed to do when a rowlevel operation needs to "touch" that table?
Copyright 2006 Steven Feuerstein - Page 99
mutating.sql
Note: in Oracle8i, you can use autonomous transactions to relax restrictions associated with queries.
Writes to list
mutating_trigger.pkg ranking.pkg
Statement Trigger
A guide to my mentors/resources
A Timeless Way of Building a beautiful and deeply spiritual book on architecture that changed the way many developers approach writing software. Peopleware a classic text on the human element behind writing software. Refactoring formalized techniques for improving the internals of one's code without affect its behavior. Code Complete another classic programming book covering many aspects of code construction. The Cult of Information thought-provoking analysis of some of the downsides of our information age. Patterns of Software a book that wrestles with the realities and problems with code reuse and design patterns. Extreme Programming Explained excellent introduction to XP. Code and Other Laws of Cyberspace a groundbreaking book that recasts the role of software developers as law-writers, and questions the direction that software is today taking us.
So Much to Learn...
Don't panic -- but don't stick your head in the sand, either.
You won't thrive as an Oracle7, Oracle8 or Oracle8i developer!
You can do so much more from within PL/SQL than you ever could before.
Familiarity with new features will greatly ease the challenges you face.
Copyright 2006 Steven Feuerstein - Page 103