Thursday, September 29, 2016

Develop a keen eye for unnecessary code

We've been offering quizzes on the PL/SQL Challenge (and now the new Oracle Dev Gym - still in an "early adaptor" user testing phase) since April 2010. We've covered hundreds of topics in PL/SQL, and hundreds more in SQL. Most quizzes are multiple choice, and one of my favorite question style is to ask: what code in my program unit is unnecessary?

By "unnecessary" we mean that the code can be removed without affecting the behavior of the program unit.

There can be two reasons, roughly, for a chunk of code to be unnecessary:

1. The code "reinforces" or explicitly defines default behavior. If you remove it, the default comes into play. So no harm done, but it is often beneficial to be explicit.

2. You misunderstand how the language works and therefore write code that should not be there at all, and is likely to cause maintenance issues later (and maybe even lead to bugs).

I offer an exercise below in identifying unnecessary code. See if you can figure out what can be removed, before looking at the explanations below the procedure definition.

You can also check out the quiz behind this post.

So here goes: what code can be removed from this procedure definition without change the behavior of running this block:

BEGIN 
   plch_busy_proc; 
END;
/
CREATE OR REPLACE PROCEDURE plch_busy_proc (
      n_in NUMBER DEFAULT NULL) 
   AUTHID DEFINER
IS
   PRAGMA AUTONOMOUS_TRANSACTION;

   CURSOR objects_cur
   IS
        SELECT *
          FROM all_objects
         WHERE ROWNUM < 5
      ORDER BY object_name;

   object_rec objects_cur%ROWTYPE;
BEGIN
   FOR object_rec IN objects_cur
   LOOP
      DBMS_OUTPUT.put_line (object_rec.object_name);
   END LOOP;

   IF object_rec.object_name IS NOT NULL
   THEN
      DBMS_OUTPUT.put_line (object_rec.object_name);
   END IF;
EXCEPTION
   WHEN OTHERS
   THEN
      IF objects_cur%ISOPEN
      THEN
         CLOSE objects_cur;
      END IF;

      DBMS_OUTPUT.put_line (STANDARD.SQLERRM);
END;
/

OK, time to pause.

Examine the code. above

Do not look below for the answers.

Try to figure it out yourself first.

Enhancing your skill at analyzing code greatly improves your ability to write it in the first place

What Can Be Removed?

We'll move from the top on down to the bottom.

(n_in NUMBER DEFAULT NULL) 

Yes, it is true: the entire parameter list can be removed. Why? The only test case you are measuring this against is the anonymous block shown above, which calls the procedure without any arguments. It does not need to pass a value for n_in, because it has a default value of NULL.

Should you therefore remove the parameter list? Very likely not! :-)

Almost certainly, other program units will call the procedure with a non-null value.

So while it is good to understand how PL/SQL takes advantage of default values for parameters, it is not a motivation to remove parameters!

AUTHID DEFINER

Yep, this clause can be removed, because DEFINER is the default setting for AUTHID. The AUTHID clause determines if your program unit runs under one of the following paradigms:

1. Definer Rights (AUTHID DEFINER): when you invoke the program unit, it executes with the privileges of the defining (owning) schema. So even if your user does not have, for the above procedure SELECT or READ privileges on ALL_OBJECTS, the procedure will still execute, because the owner of the procedure did have the ability to select from ALL_OBJECTS.

2. Invoker Rights (AUTHID CURRENT_USER): when you invoke the program unit, it executes with your privileges (the current user). This means that the PL/SQL runtime engine resolves all references to database objects before executing the code.

Check out the doc for more information on these two approaches.

PRAGMA AUTONOMOUS_TRANSACTION;

This pragma (a command to the compiler) can be removed because the procedure contains no non-query DML (insert, update, delete, merge). Since it does not change any data in tables, defining the procedure as an autonomous transaction is irrelevant.

An autonomous transaction program unit is one in which all changes made within the unit can and must be committed or rolled back, without affecting other un-committed changes in your session. It is most commonly used in error logging routines, since you want to save the error information to your table, but you certainly do not want to commit changes in your most likely "broken" transaction (you do, after all, have an error). And when you rollback the transaction, you don't want to lose your error log insertion. Autonomous transaction to the rescue!

Check out the doc on this pragma.

object_rec objects_cur%ROWTYPE;

What? I can remove the whole declaration of this record? But how will the procedure even compile, then? Don't I use it here, for example?

    DBMS_OUTPUT.put_line (object_rec.object_name);

Welllllll, yes and no. Yes, you do reference a record with the name "OBJECT_REC". No, you do not reference the record which was explicitly defined in the declaration section.

Instead, you are referencing this record, declared implicitly by PL/SQL for use within the cursor FOR loop:

    FOR object_rec IN objects_cur

I have seen developers declare their own variable as a sort of "programmer's insurance." The thinking seems to go like this: "I will declare a variable with that name, just in case."

"Nooooooooo!" (that's me howling in dismay)

Never write code "just in case". Instead, make sure you know how the technology works and use it as directed, and as necessary.

In this case, because I declared that unnecessary variable, I could write even more, more confusing code, namely:

IF object_rec.object_name IS NOT NULL
THEN
   DBMS_OUTPUT.put_line (object_rec.object_name);
END IF;

Yes, that's right. The entire IF statement can - and should - be removed.

The object_rec.object_name field (of the object_rec variable explicitly declared) will always be NULL. That's because all the fields in the variable are defaulted to NULL initially, and those values are never changed.

"But, but...what about in the cursor FOR loop?" you might be asking yourself.

Oh yes, the value of object_rec.object_name is set  with each new row fetched, but remember: the object_rec modified inside the cursor FOR loop is not the same object_rec that is declared and referenced outside the loop.

This is a great example of how buggy, confusing code can creep into your programs if you do not thoroughly understand the programming language you are using.

IF objects_cur%ISOPEN
THEN
   CLOSE objects_cur;
END IF;

The objects_cur cursor is only open inside the cursor FOR loop. If an error was raised from within the loop (and it is quite difficult to see how that could happen), the PL/SQL engine would automatically close the cursor. So it will never be open in the exception handler, and this code is both unnecessary and misleading.

It should be removed.

STANDARD.

Really? I can remove the name of the package in which SQLERRM is defined? Yes, because STANDARD is one of two very special packages in PL/SQL (the other being DBMS_STANDARD).

As the doc explains, "The package specification declares public types, variables, exceptions, subprograms, which are available automatically to PL/SQL programs...The contents of package STANDARD are directly visible to applications. You need not qualify references to its contents by prefixing the package name."

Sure, you could include the "STANDARD." prefix, but I suggest you do not. It will mostly just raise questions from other developers on your team. It is a fundamental aspect of the way PL/SQL works and is not going to change. So don't clutter up your code.

So did I miss anything? Are there any other chunks of code you believe could and/or should be removed from this program unit?

Monday, September 26, 2016

Looking for stories about using Hibernate with Oracle Database

I recently (well, OK, not all that recently) received this request:
Our Java developers think that the work can be done faster with Hibernate by basically eliminating the "middle-men" and "middle-women" (ie. the database developers) for what they think is the simpler database access tasks for basic forms and reports.  It is true that Java developer out number PL/SQL developers by about 10:1 at my company - so we do/would have a lot of work to do.  
But I've talked with the other PL/SQL developers and while we are all rather busy. there are almost no cases of PL/SQL tasks needing to rollover from one sprint to the next (meaning, or course, that a PL/SQL task didn't get done on time). 
We want to make sure that our company continues to use PL/SQL for the database access layer in our application. Can you help?
Hibernate offers an ORM (Object Relational Mapping) tool: "Hibernate ORM enables developers to more easily write applications whose data outlives the application process. As an Object/Relational Mapping (ORM) framework, Hibernate is concerned with data persistence as it applies to relational databases (via JDBC)."

I have never used Hibernate or any other ORMs, as I live within the database. The idea of an ORM is quite appealing: rather than write the SQL to perform the necessary SQL to overcome the "impedance" mismatch between objects and tables, Hibernate generates it for you.

Which is great, until the SQL that is generated doesn't perform well. Then you have to start customizing or going around your ORM, and so on. I have, over the years, heard from lots of frustrated Oracle Database developers who are sick of hearing them (or Oracle Database) being blamed for bad application performance, while they are simultaneously not able to (easily, at least) do what it takes to use Oracle Database properly.

So I have a request: do you have experience with Hibernate, or another ORM? Please share your experience, both positive and negative, below in the Comments section. Others, including the fellow who wrote the above message, can then use this information to help their company make the most informed decision.

Thanks!


Tuesday, September 20, 2016

PL/SQL 101: Three ways to get error message/stack in PL/SQL

The PL/SQL Challenge quiz for 10 September - 16 September 2016 explored the different ways you can obtain the error message / stack in PL/SQL. Note: an error stack is a sequence of multiple error messages that can occur when an exception is propagated and re-raised through several layers of nested blocks.

The three ways are:
  1. SQLERRM - The original, traditional and (oddly enough) not currently recommended function to get the current error message. Not recommended because the next two options avoid a problem which you are unlikely to run into: the error stack will be truncated at 512 bytes, and you might lose some error information.
  2. DBMS_UTILITY.FORMAT_ERROR_STACK - Returns the error message / stack, and will not truncate your string like SQLERRM will.
  3. UTL_CALL_STACK API - Added in Oracle Database 12c, the UTL_CALL_STACK package offers a comprehensive API into the execution call stack, the error stack and the error backtrace. 
Note: check out this LiveSQL script if you'd like to explore more of the nuances between SQLERRM and DBMS_UTILITY.FORMAT_ERROR_STACK.

Other helpful resources regarding error management in PL/SQL:

Nine Good to Knows for PL/SQL Error Management
PL/SQL Error Handling (doc)

Finally, here's the code from our quiz that you can copy/paste into your editor to check out these alternatives.
CREATE OR REPLACE PROCEDURE plch_check_balance (
   balance_in IN NUMBER)
   AUTHID DEFINER
IS
BEGIN
   IF balance_in < 0
   THEN
      RAISE VALUE_ERROR;
   END IF;
EXCEPTION
   WHEN OTHERS
   THEN
      DBMS_OUTPUT.put_line (SQLERRM);
END;
/

BEGIN
   plch_check_balance (-1);
END;
/

CREATE OR REPLACE PROCEDURE plch_check_balance (
   balance_in IN NUMBER)
   AUTHID DEFINER
IS
BEGIN
   IF balance_in < 0
   THEN
      RAISE VALUE_ERROR;
   END IF;
EXCEPTION
   WHEN OTHERS
   THEN
      DBMS_OUTPUT.put_line (DBMS_UTILITY.format_error_stack);
END;
/

BEGIN
   plch_check_balance (-1);
END;
/

CREATE OR REPLACE PROCEDURE plch_check_balance (
   balance_in IN NUMBER)
   AUTHID DEFINER
IS
BEGIN
   IF balance_in < 0
   THEN
      RAISE VALUE_ERROR;
   END IF;
EXCEPTION
   WHEN OTHERS
   THEN
      DBMS_OUTPUT.put_line (   'ORA-'
         || TO_CHAR (UTL_CALL_STACK.error_number (1), 'fm00000')
         || ': '
         || UTL_CALL_STACK.error_msg (1));
END;
/

BEGIN
   plch_check_balance (-1);
END;
/

DROP PROCEDURE plch_check_balance
/

Friday, September 16, 2016

How to get compiler settings for PL/SQL program units

This question was posted today on the OTN SQL-PL/SQL Forum:
Is there a way, after compilation, to detect what level of optimization was used? Perhaps you have something already in production, and you just want to find some candidates for recompile (but not necessarily any changes in the source) with increased optimization level.
The answer is: Yes! Just run a query against the ALL_PLSQL_OBJECT_SETTINGS data dictionary view (or the USER_PLSQL_OBJECT_SETTINGS, to see information only about program units you own).

For a given schema and object name, the following information is provided through this view:



So suppose you need to identify any program units that may have been accidentally compiled with an optimization level below 2 (the default, aggressive optimization, which can only be improved upon by level 3, which turns on inlining of subprograms globally).

No problem!

SELECT *
  FROM user_plsql_object_settings p
 WHERE p.plsql_optimize_level < 2

Or how about: for which program units has identifier information been gathered for PL/Sscope analysis?

SELECT *
  FROM user_plsql_object_settings p
 WHERE plscope_settings = 'IDENTIFIERS:ALL'

I offer a LiveSQL script to help you take full, quick advantage of this data dictionary view.

Thursday, September 15, 2016

Maintaining transaction integrity with FORALL and multiple DML statements

FORALL is used to avoid row-by-row execution of the same DML statement (differing only in the values bound into it), thereby reducing context switching between the PL/SQL and SQL engines.

I will assume in this post that you have a basic working knowledge of BULK COLLECT and FORALL. If not, check out:

A Checklist for Conversion to Bulk Processing

As noted in the checklist, you need to document your current transaction behavior, and then make sure that the same behavior manifests in your bulk implementation.

I published a video that takes you through the key steps in the conversion from row-by-row to bulk. The code for this video is available on LiveSQL.

A viewer asked:
Hi Steven - @minute 21.56 in the video, function update_employee. in case of any update failures, you are handling an exception "bulk_error" but how do you ensure that the corresponding insert gets rolled back? You showed a way to communicate the failed employee records from insert_history function to update_employee, but it needs to be done the other way round too ? to ensure "INSERT+ADJUST+UPDATE" is one transaction like in the case of cursor for loop.
I decided to answer this question by writing a post on my blog - this one. So let's dive in. The original code looked like this:

CREATE OR REPLACE PROCEDURE upd_for_dept (
   dept_in     IN employees.department_id%TYPE
 , newsal_in   IN employees.salary%TYPE)
IS
   CURSOR emp_cur
   IS
      SELECT employee_id, salary, hire_date
        FROM employees
       WHERE department_id = dept_in
         FOR UPDATE;
BEGIN
   FOR rec IN emp_cur
   LOOP
      BEGIN
         INSERT INTO employee_history (employee_id, salary, hire_date)
              VALUES (rec.employee_id, rec.salary, rec.hire_date);

         rec.salary := newsal_in;


         adjust_compensation (rec.employee_id, rec.salary);


         UPDATE employees

            SET salary = rec.salary
          WHERE employee_id = rec.employee_id;
      EXCEPTION
         WHEN OTHERS
         THEN
            log_error;
      END;
   END LOOP;
END upd_for_dept;

[Note: I have added the "FOR UPDATE" clause in bold above; that was not in the video and original code, but it should be to ensure that while I am doing my updates, no one else can come along and modify a row in my "target" dataset. thanks to Martin Rose for pointing this out in his acerbic comments on my video. :-) ]

And I noted in the video that if an insert fails, the update does not take place, so we need to avoid this in the bulk processing. I did so by removing the employee ID of any failed insert from the bind array that is used in the second FORALL for the update:


PROCEDURE insert_history
IS
BEGIN
  FORALL indx IN 1 .. l_employees.COUNT SAVE EXCEPTIONS
     INSERT
       INTO employee_history (employee_id
                            ,  salary
                            ,  hire_date)
     VALUES (
               l_employees (indx).employee_id
             ,  l_employees (indx).salary
             ,  l_employees (indx).hire_date);
EXCEPTION
  WHEN bulk_errors
  THEN
     FOR indx IN 1 .. SQL%BULK_EXCEPTIONS.COUNT
     LOOP
        /* Log the error then ... Communicate this failure to update:
        Delete this row so that the update will not take place.
        */
        l_employees.delete (
           SQL%BULK_EXCEPTIONS (indx).ERROR_INDEX);
     END LOOP;


END insert_history;

But Jinay correctly points out that in my bulkified code, if the update fails, I do not reverse the insert. 

Well, I can justify that omission easily: it is not an omission. The original code has this same flaw in it!

So really what Jinay has identified is a possible/likely bug in my original code. This is a very common experience when developers perform code reviews and is a primary motivator for pair programming (kill those bugs before/as they are written).

To achieve the "effect" described by Jinay, I can add SAVEPOINTs:

BEGIN
   FOR rec IN emp_cur
   LOOP
      BEGIN
         SAVEPOINT before_insert;

         INSERT
           INTO employee_history (employee_id, salary, hire_date)
         VALUES (rec.employee_id, rec.salary, rec.hire_date);

         rec.salary := newsal_in;

         adjust_compensation (rec.employee_id, rec.salary);

         UPDATE employees
            SET salary = rec.salary
          WHERE employee_id = rec.employee_id;
      EXCEPTION
         WHEN OTHERS
         THEN
            ROLLBACK TO before_insert;
            log_error;
      END;
   END LOOP;

END upd_for_dept;

Now if the insert succeeds and the update fails, the insert will be rolled back.

OK, so then the question is: how can I achieve the same effect when I convert to bulk processing? Easily...with more bulk processing and the helpful RETURNING clause.

PROCEDURE insert_history
IS
BEGIN
  FORALL indx IN 1 .. l_employees.COUNT SAVE EXCEPTIONS
     INSERT
       INTO employee_history (employee_id
                            ,  salary
                            ,  hire_date)
     VALUES (
               l_employees (indx).employee_id
             ,  l_employees (indx).salary
             ,  l_employees (indx).hire_date)
     RETURNING id, employee_id BULK COLLECT INTO l_inserted;
EXCEPTION
  WHEN bulk_errors
  THEN
     FOR indx IN 1 .. SQL%BULK_EXCEPTIONS.COUNT
     LOOP
        /* Log the error then ... Communicate this failure to update:
        Delete this row so that the update will not take place.
        */
        l_employees.delete (
           SQL%BULK_EXCEPTIONS (indx).ERROR_INDEX);
     END LOOP;


END insert_history;

where l_inserted is declared as follows:

   TYPE inserted_rt IS RECORD
   (
      id            employee_history.id%TYPE,
      employee_id   employee_history.employee_id%TYPE
   );


   TYPE inserted_t IS TABLE OF inserted_rt;

   l_inserted inserted_t := inserted_t();

I can then modify the exception handler in the update "phase" of this bulk-ified procedure as follows:

First, add another nested subprogram:

   PROCEDURE remove_history_row (
      employee_id_in   IN employees.employee_id%TYPE)
   IS
      l_found_index   INTEGER;
      l_index         INTEGER := l_inserted.FIRST;
   BEGIN
      /* Find matching element in l_inserted, and remove */

      WHILE l_found_index IS NULL AND l_index IS NOT NULL
      LOOP
         IF l_inserted (l_index).employee_id = employee_id_in
         THEN
            l_found_index := l_index;
         ELSE
            l_index := l_inserted.NEXT (l_index);
         END IF;
      END LOOP;

      IF l_found_index IS NOT NULL
      THEN
         DELETE FROM employee_history
               WHERE id = l_inserted (l_found_index).id;
      END IF;
   END;

Then invoke the subprogram inside the exception section of the update procedure:

   EXCEPTION
      WHEN bulk_errors
      THEN
         FOR indx IN 1 .. SQL%BULK_EXCEPTIONS.COUNT
         LOOP
            remove_history_row (
               l_employees (
                  SQL%BULK_EXCEPTIONS (indx).ERROR_INDEX).employee_id);

            log_error (
                  'Unable to update salary for employee '
               || l_employees (
                     SQL%BULK_EXCEPTIONS (indx).ERROR_INDEX).employee_id,
               SQL%BULK_EXCEPTIONS (indx).ERROR_CODE);
         END LOOP;
   END update_employees;

I have now carried over the SAVEPOINT-based behavior to my bulk-ified version. Note that I am performing row-by-row deletes in remove_history_row. Perhaps you, my dear reader, would like to take on a little exercise of enhancing the solution I offer above to use FORALL to delete all the inserted rows for which the update failed!

Monday, September 12, 2016

PL/SQL 101: Save your source code to files

PL/SQL is a database programming language. This means that your source code is compiled into and executed from within the Oracle Database. There are many fantastic consequences of this fact, many of which are explored in Bryn Llewellyn's Why Use PL/SQL? whitepaper.

But this also can mean that developers see the database as the natural repository for the original source code, and this is a bad mistake to make. It's not the sort of mistake any JavaScript or Java or php developer would ever make, because that code is not compiled into the database (well, you can compile Java into the database, but that's not where 99.99% of all Java code lives).

But it's a mistake that apparently too many Oracle Database developers make.

So here's the bottom line:
Store each PL/SQL program unit in its own file. Use a source code control system to manage those files. Compile them into the database as needed for development and testing.
In other words: you should never keep the original version of your code in the database, especially if you are part of a development team, with multiple people needing to work on a given program unit, sometimes at the same time. Otherwise, chaos might ensure, resulting in "lost updates" to your code.

Hmm, that might sound kind of odd to some of you.
"Steven, who works for Oracle Corporation, who thinks that Oracle Database is a really great database, is telling us to avoid the database?"
Well, I do think Oracle Database is a really great database. And I do think that you can trust it with your data, your applications, the care and feeding of your users. :-)

But it was not designed to be a development environment per se. The requirements developers have for writing code is certainly very different from requirements users have for accessing and changing their data.

So Do This

Suppose I need to create a package. I type the following in my worksheet in SQL Developer (a free, powerful IDE for Oracle Database):


Then the very next thing action I take, because I hate to lose any of my work, is to save this text to a file:


Notice I used the ".pks" extension for my package specification. You could just use the default ".sql" for all your database code files, but I prefer to use specialized prefixes for my program units, views and more.





Then I compile my code, and start working on it. And any time I need to go back and work on the package specification some more, I open the file.


I never, never edit a program unit directly from the database. Sure, you can do that. It is "allowed." It's just not recommended. Seriously. Not. Recommended.

Lost Updates - Lost CODE Updates, That Is

You can double-click on your program unit name in the Schema Browser. That code will appear in an editor. You can make changes and compile it back into the database.

But what if another developer on your team is doing the same thing? A minute after you double-clicked on the program name, Sandra did, too. You make changes, which Sandra doesn't see. Sandra makes changes, which you can't see.

You compile your changes into the database. You give yourself a pat on the back: Job well done.


And then you go to lunch.


At which point, Sandra compiles her changes into the database. She eats lunch at her desk, editing and compiling changes to ten more program units. A busy, productive programmer.

And when you come back from lunch? Your changes are gone.


There are several ways in which database programming is really different from Java, JavaScript, etc. programming. Some of these ways are super cool, such as: there is no need for a make utility or process. The database takes care of all of that for you.

But in other ways, writing PL/SQL program units is - or should be - no different from writing a Java class or a JavaScript...um...script. 

You figure out the algorithm you need to implement the requirements.

You translate that algorithm into code. You type that code in an editor.

You save it to a file, and then you compile that file into the database.

You check in that file to your source code control system.

Need to make more changes? Check it out (or not, depending on how your tool works), open the file, and work on that file. And so on, and so forth.

By taking this approach, there is a much smaller chance that code changes will be lost. 

And a much greater chance that a whole team of developers can work together efficiently to developer a highly secure, very efficient, easy to maintain base of code in PL/SQL that can be utilized by UI developers.

Note: Yalim Gerger (@yalimgerger) of Formspider offers a very interesting tool, Gitora, the offers a PL/SQL API to manage your database objects directly in Git. So if you really, really don't like my advice and really, really want to work on code directly in the database and avoid files entirely, check out Gitora.


Friday, September 9, 2016

Fine-tuning the Quiz-taking Experience on the Oracle Dev Gym

Back in June 2016, we announced "early adaptor" access to the Oracle Dev Gym, a new skin on top of the PL/SQL Challenge quiz platform.

Since then, we've gotten lots of great feedback, and lots of usage on the site. Players have set up 543 monthly goals, with over 2,200 workout exercises (with 1,200+ actually completed :-) ). Plus more than 4,500 quizzes have been answered at the Dev Gym, vs. the "traditional" PL/SQL Challenge.

All good news.

When I held our first live Dev Gym workout with the Chicago Oracle User Group two weeks ago, however, I did get some very critical reviews of the quiz-taking experience.

Basically, we are using too much real estate for players to easily see the code in the question and the various multiple choices, some of which can be very long in and of themselves.

So we went back to the drawing board and made a number of changes, which we rolled into production today.

To summarize:
  • The left sidebar is closed by default to minimize distraction and usage of page real estate.
  • Each choice now uses up less space. We've reduced white space and padding wherever possible.
  • There is now a "scratchpad" in the right sidebar, so that you can write notes to yourself (comparisons between choices, for example) that remain visible even as you scroll up and down the question.
  • Choices can be expanded or compressed - individually or as a group. With just a click or two of your mouse, you can zoom in on a single choice, or two, allowing you to focus attention more tightly.
Note: there is one known issue with the collapsing process. When you collapse all the direction icon on the left is not changing. So you need to click twice on it to expand a particular choice. We should have that fixed soon.

We explored other possibilities, like using sliders to allow you to have the question remain on the left, while you scroll up and down through the choices. We decided not to pursue this approach because it does not work well on mobile devices, and we are aiming for a smooth, responsive website regardless of your device.

I offer some screenshots below to give you an idea of what we've done. Please do take a quiz on the Dev Gym (accessed through the PL/SQL Challenge) and let us know what think.

Oh, and that's not all: we've also added a dashboard on the home page.

And if you have other suggestions for improving the quiz-taking experience or the dashboard or anything else, don't hesitate to click on the Feedback link on the top right of the site!

Scratchpad

See something on Choice 1 you want to remember when looking at the other choices? Jot it down!



Collapsed Choices

You can Expand All or Collapse All and then fine-tune from there.













Just One Choice Uncollapsed

Open up one or more choices selectively. The banner on each choice displays green if you've selected it as correct. This is visible even when collapsed.












Dashboard

Let us know if you'd like to see different information or see this data differently (visually).