Saturday, July 28, 2018

Simple Web Client, the Sequel

In an earlier post, A Simple Web Client, I demonstrated a simple program for pulling data from a download website to an Oracle table using the htp package.  A few years later, I needed to do a similar data pull to a SQL Server table.  In this post, I'll demonstrate a simple way of pulling data from an URL into a SQL Server table.

In an ideal world, languages and packages would be standard enough that one could readily port a stored procedure from one vendor's database and reuse the code without too much trouble in another vendor's database.    While most SQL is pretty transportable, the stored procedure languages are highly proprietary and do not port at all from one vendor to another ( one exception: IBM's support of Oracle's PL/SQL language).

The  SQL Server solution takes a different approach from the  Oracle PL/SQL solution.   In the latter,  an Oracle PL/SQL procedure used built-in Oracle  packages to open and manage an HTTP connection, to read data from the HTTP socket, and to write the data to the console.  In practice, instead of writing the data to the console, we might have processed the data  directly or loaded the data to a staging table for later processing.   The SQL Server solution is different; rather than use T-SQL to manage the HTTP connection, this solution uses an external program to read the data from the web into a file on a Windows Server before bulk importing the data  into a SQL Server staging table.  After we load the data to a staging table, we can process the data as needed by our application.

Let's look at the SQL Server solution.  First, we'll use the data from the Federal Reserve from the previous post again.  Put the URL https://www.federalreserve.gov/datadownload/Output.aspx?rel=H10&series=122e3bcb627e8e53f1bf72a1a09cfb81&lastObs=10&from=&to=&filetype=csv&label=include&layout=seriescolumn&type=package into your browser.   When your browser opens the URL, you'll get a CSV file like the following:

Downloaded data opened in a spreadsheet

We have a four-column spreadsheet, with some meta-data in rows 1 through 5, the column headers in line 6, and the data starts in row 7.   We'll create a four-column table  to store the results.   In a SQL Server Management Studio (SSMS) window, create the following table:

use MyDatabase
go

create table stageFedRates (
 timePeriod date,
 jrxwtfb_nb dec(15,8),
 jrxwtfn_nb dec(15,8),
 jrxwtfo_nb dec(15,8)
 )
 ;

go

We have a couple of choices of programs to pull data from the web into an external file.  One option is wget, another is curl.   Curl is already installed on my Windows 10 computer and my Windows 2008 Server, so we'll use curl.   The syntax is pretty simple, just type curl and the URL enclosed in double quotes.  Don't forget the double quotes -- the URL is full of special characters that command console will try to interpret if not quoted.  The output goes to STDOUT by default.  Let's try it in a command shell window:

C:\> curl "https://www.federalreserve.gov/datadownload/Output.aspx?rel=H10&series=122e3bcb627e8e53f1bf72a1a09cfb81&lastObs=10&from=&to=&filetype=csv&label=include&layout=seriescolumn&type=package"

"Series Description","Nominal Broad Dollar Index ","Nominal Major Currencies Dollar Index ","Nominal Other Important Trading Partners Dollar Index "
"Unit:","Index:_1973_Mar_100","Index:_1973_Mar_100","Index:_1997_Jan_100"
"Multiplier:","1","1","1"
"Currency:","NA","NA","NA"
"Unique Identifier: ","H10/H10/JRXWTFB_N.B","H10/H10/JRXWTFN_N.B","H10/H10/JRXWTFO_N.B"
"Time Period","JRXWTFB_N.B","JRXWTFN_N.B","JRXWTFO_N.B"
2018-07-09,123.4946,89.6613,160.2890
2018-07-10,123.4637,89.7541,160.0923
2018-07-11,123.8276,89.8616,160.7776
2018-07-12,123.7232,90.1620,160.1318
2018-07-13,124.0710,90.2872,160.7554
2018-07-16,123.8317,90.0232,160.5674
2018-07-17,124.1553,90.4107,160.7806
2018-07-18,124.3573,90.5535,161.0480
2018-07-19,125.0530,90.9072,162.1562
2018-07-20,125.0484,90.9072,162.1456
c:\>

This is the same data we saw in the Excel spreadsheet above. Two useful options of the curl command are the --stderr option and the --output option.   Those two options specify files to store STDERR and the output file:

C:\>curl --stderr curlErrors.txt --output curlData.csv "https://www.federalreserve.gov/datadownload/Output.aspx?rel=H10&series=122e3bcb627e8e53f1bf72a1a09cfb81&lastObs=10&from=&to=&filetype=csv&label=include&layout=seriescolumn&type=package"

C:\>dir curl*.*
 Volume in drive C has no label.
 Volume Serial Number is 1836-25CF

 Directory of C:\

2018-07-28  03:38 PM               804 curlData.csv
2018-07-28  03:38 PM               399 curlErrors.txt
               2 File(s)          1,203 bytes
               0 Dir(s)  352,925,888,512 bytes free

C:\> type curlErrors.txt
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   804    0   804    0     0    804      0 --:--:-- --:--:-- --:--:--  2233



Now that we can pull data from our source, let's look at loading into a SQL Server table.  From a new SSMS window,  use the bulk import command to load the table:

use MyDatabase
go

Bulk Insert stageFedRates 
   from  'C:\curlData.csv ' 
   with ( fieldterminator = ',',   
          rowTerminator = '\n',  
          firstRow = 7,
          maxErrors = 999999999 )


The fieldTerminator and rowTerminator options specify that the input is a CSV with rows delimited by new line characters and the columns by commas. We know there is some metadata in the download, so we specify firstRow=7 to start loading data. Finally, maxErrors specifies how many errors the import will tolerate before aborting the load. This will depend on the data; if the data is very clean, specify a small number of errors, but if not, specify a large number so all the data loads into the table for post-load cleanup.

In a SSMS window, select all the data and see what we imported into the SS table:

Data loaded into SQL Server


Now we're able to pull data from the web into a file and load that data into a table. Next, we'll create a stored procedure to automate this process. We've demonstrated all the commands we need to do this, but there's one more hurdle to leap: working within the context of the Windows file system permissions. The directories that we can read/write are not necessarily the directories that SQL Server service can read/write.

To work around this, we will query SQL Server's TMP environmental variable, and we will use that directory to write to and read from. The following stored procedure will do the trick. The comments will explain how it works along way. It's worth leaving the PRINT statements in the code for debugging; getting all the quotes and double-quotes correct can be quite tricky.

use MyDatabase
go

create procedure [dbo].[loadFedReserve] AS
begin
    -- Get SQL Server's tmp directory.    
    declare @destinationDirectory varchar(256);
    declare @cmd varchar(256) = 'echo %TMP%'; 

    -- Create a temporary table 
    create table #tmpResult (
 envVar varchar(256)
 )
    -- Store the results of executing "echo %TMP%" on the Windows server
    -- into the temporary table
    insert into #tmpResult exec xp_cmdshell @cmd 

    -- Now get the TMP directory from the table
    set @destinationDirectory = ( select top 1 envVar from #tmpResult);
 
    -- Build the command line, including the output and stderr file locations.
    -- We will reuse the @destination variable when we import the file to SQL Server.
    -- Be sure to use the double quotes around the URL  
    declare @curlCommand varchar(512) = 'curl ';
    declare @destination varchar(128) =  @destinationDirectory +'\curlData.csv ';
    declare @errors varchar(128) = @destinationDirectory + '\curlErrors.txt" ';
    set @curlCommand = @curlCommand + '--stderr "' + @errors
    set @curlCommand = @curlCommand + ' --output ' + @destination 
    set @curlCommand = @curlCommand + '"https://www.federalreserve.gov/datadownload/Output.aspx?' +  
       'rel=H10&series=122e3bcb627e8e53f1bf72a1a09cfb81&lastObs=10' + 
       '&from=&to=&filetype=csv&label=include&layout=seriescolumn&type=package"'
  
    -- Little bit of debugging, then execute the command
    print  @curlCommand; 
    exec xp_cmdshell @curlCommand
 
    -- The table is usually a staging table that we reuse, so delete any data in it.
    delete from stageFedRates; 

    -- Now build a sql statement with the bulk import command and the file locations
    -- that we determened before
    declare @sqlStmt nvarchar(256) = 'Bulk Insert stageFedRates from  ''' + 
  @destination + ''' with ( fieldterminator = '','', rowTerminator = ''\n'',  firstRow = 7,maxErrors = 999999999 )'
    
    -- A little more debugging, then execute the load.  Building the SQL command and 
    -- executing it with sp_executeSQL let's us change the imported file programmatically. 
    print @sqlStmt;
    exec sp_executeSQL @sqlStmt
 
    select count(*) RowsImported from fedRates
END

GO

Depending on how your SQL Server instance is configured, you may need to enable the use of xp_cmdshell. I can execute the following on the instance of SQL Server on my PC; the help of a DBA is probably required for an enterprise server.

exec sp_configure 'show advanced options', 1;  
go  
-- Refresh the configuration 
reconfigure ;
go 
  
-- Enable xp_cmdshell   
exec sp_configure 'xp_cmdshell', 1;  
go
  
-- Refresh the configuration 
reconfigure ;
go 
  

After compiling the store procedure, open a new SSMS window, execute the stored procedure, and select the results of the load:

exec loadFedReserve

select * 
from stageFedRates
 

There should be 10 rows, like below, and it will take SQL Server less than a second to pull the data and load the table:

Data returned from a SQL Select in SQL Server Management Studio
Using the curl command and a bit of code in a stored procedure, we can pull external data from the web and load it into SQL Server. After loading the data into the staging table,  we can manipulate the data as needed by our business requirements.   The load is fast enough to be useful for interactive processing with small amounts of data, or we can use the SQL Server Scheduler to batch load large amounts of data.



Saturday, April 7, 2018

Lubuntu 17.04 to 17.10 upgrade

Introduction


I'm a bit tardy upgrading my Linux Lubuntu 17.04 distribution.  I have two computers running 17.04, an old HP dv8210us with a single-core, 2GHz AMD processor, and a newer Asus netbook with a dual-core, 1GHz AMD processor.

Everything was working nicely until I tried installing Notepadqq, an open-source version of the popular Notepad++ for Windows computer.  For my next snafu, I tried installing libdvd to play DVDs in my HP.  Unfortunately,  Lubuntu 17.04 is no longer supported, and I couldn't load the pre-requisite packages for either libdvd or Notepaddqq.   Also, security updates are no longer available.

These are my notes and learnings as I worked thru the upgrade process from 17.04 to 17.10.     The notes are my collection of how-tos for the next time I do this.   The learnings are little bits of wisdom I picked up along the way.

First learning:   if you have a choice, install an LTS version of Ubuntu or Lubuntu.  Long Term Support (LTS)  versions are supported for 5 years (Ubuntu) or 3 years (Lubuntu).   This is the second time I've done an upgrade, first from 12.04 to 14.04, and now from 17.04 to 17.10.   The process is not as smooth as one would hope.  My systems are old, things will certainly work better or differently or at least more quickly on your own hardware.

Second learning:  take copious notes of everything you customized and how you customized it.   One problem area on the HP is the Broadcom wireless card.  If you know how to handle it, it's pretty simple.  But the first time thru was painful; following my notes in Google Keep made a messy process easy.

First Note:  Installation media


This is pretty simple,  I created a bootable USB for the netbook (no optical disk player) and a bootable DVD for the HP (no USB boot).  First download the iso image from Lubuntu.net.

To create the bootable USB, use the Startup Disk Creator (System Tools -> Startup Disk Creator). Select the iso image as the source,  a 2GB USB drive as the destination, then click the Make Startup Disk button.

To create the bootable DVD, use xfburn ( Sound and Video > xfburn).   The Burn Image button burns an ISO image to a disc.  A DVD  is required to hold the whole image; a CD is not quite big enough.

Third learning:  procrastination is not your friend.   I upgraded Ubuntu 12.04 to 14.04 using the software center over the network;    four months ago, I could have repeated the process and skipped creating bootable media.

Second Note:  Broadcomm wireless adapter


My old HP laptop has a Broadcomm wireless adapter.  The following notes will help you get this working.   If you don't have a Broadcomm wireless adapter, skip this section.

First, do the install while connected to the Internet with an Ethernet cable. If the system tries to connect to the internet using the existing driver, the system will hang.   You will need to download the correct driver from the internet.   So, plug in.

After installation is complete, remain connected to the Internet via the ethernet cable.  Start a terminal window, and execute the following four commands.
 
$ sudo apt-get update
$ sudo apt-get install firmware-b43-installer
$ sudo apt-get remove bcmwl-kernel-source
$ sudo reboot


Third note:  Consistency


Working on a Windows 10 PC all day at work, I'm accustomed to hitting Windows-L to quickly lock my screen.  I don't have a strong preference for Windows-L on my PC verses Ctrl-Alt-L on my Linux systems, but it's convenient when all the simple things work the same way.

To add this to Lubuntu, edit the lubuntu-rc.xml configuration file in your favorite text editor.  Search for the string "lxsession-default lock".  When you find the string, copy the entire <keybind> thru </keybind>, and paste it below the existing lock command.  Add a comment that the sequence Windows + L locks the screen, and change the "key=C-A-l" to "key=W-l":

  
  <!-- Lock the screen on Windows + l-->
  <keybind key="W-l">
    <action name="Execute">
      <command>lxsession-default lock</command>
    </action>
  </keybind>


Save the file, and from a terminal session refresh your openbox configuration:

openbox --reconfigure

Assuming you didn't get any errors, both Ctrl-Alt-L and Windows-L sequences will lock your screen.

Fourth note:   Two Monitors


I have a second monitor sitting to the left of my laptop screen.   If you know Lubuntu, you know  where this is going...  the left monitor is always the main monitor.  And if you're really "clever", you can swap the monitor positions but find that you don't have a taskbar with the laptop monitor alone.

I've googled this problem, and it's a common question with no satisfactory answer.  After a lot of trial and error, the best solution that I've found is to configure the menu on both monitors identically, and to use xrandr to set the monitors so the second monitor is to the left of the laptop screen.    This gives me a configuration that works whether the laptop has a second monitor or not, and my mouse moves left to go to the left monitor.  With the taskbar on both monitors, I will also have a taskbar on the laptop when only the laptop screen is in use.

Here's scoop:

First, right-click the task bar and open the panel settings.  Configure the panel settings to your personal preferences and close the window.

Next, open a terminal window, and change your working directory to .config/lxpanel/Lubuntu/panels.   Edit the panel file in the panels directory using a text editor like vi or leafpad.  In the global section, add one line

monitor=0

and save the file.   Now change the monitor=0 line to monitor=1, and using a Save as... dialog, save the panel as panel1.

Execute lxpanelctl restart, and you will have taskbars on both of your monitors.

Fifth Note:  Network and Volume Indicators


After installing 17.10, I noticed problems with the network and volume controls on the task bar:  The network menu collapsed so quickly it was almost unusable, and the volume control didn't work at all. After a lot of googling,  I found that this is only a problem if the autohide option on the taskbar is selected.  I like the autohide feature, so I worked around this problem in two ways.   I rarely use the network menu; if I need it, I turn off the autohide feature while I use the network menu.

I use the volume control frequently; so turning off autohide every time I want to mute an advertisement is not ideal.   Instead, I disabled the Sound Menu on the indicator menu and added the Volume Control plug in to the panel menu:
  • Right click on the task bar, and select Panel Settings
  • Click on the Panel Applets tab, select Indicator applets, and click the Preferences button
  • Uncheck the Sound Menu and close the Indicator applets menu
  • Back on the Panel Preferences menu, click the Add button
  • On the Add plugin to panel menu, scroll down the list, find the Volume Control, highlight it, and click the Add button
  • The Volume Control is added to the right of the power control.  Use the Up and Down buttons to move the control where you like.
  • Close the panel preferences

Sixth Note:  More on Network Interfaces


I have two wireless network interfaces with my Asus netbook.  The built-in Qualcomm Atheros AR9485 worked fine out of the box.   In addition, I have a Realtek RTL8192cu USB dongle.   When booting into Windows 10, I found the Realtek interface is much faster than the Qualcomm interface.   In Lubuntu, the Realtek interface would stop working every two or three minutes until I disabled/enabled the interface. Then it would work again for another two or three minutes.

Googling the issue turned up several different answers to the problem; the references below all agree that it's a driver issue.  In short, the driver in kernel 4.4 is supposed to work well, but Lubuntu 17.10 is using kernel 4.13, and the driver for the Realtek interface is buggy.  Here's how to fix it.

sudo apt-get install linux-headers-$(uname -r) build-essential dkms git
git clone https://github.com/pvaret/rtl8192cu-fixes.git
sudo dkms add ./rtl8192cu-fixes
# NB:  The step above reports a version.   
#      As of 2018-04-12, the version is 1.11, not 1.10.
#      Use the correct version in the next step.
sudo dkms install 8192cu/1.11
sudo depmod -a
sudo cp ./rtl8192cu-fixes/blacklist-native-rtl8192.conf /etc/modprobe.d/
sudo cp ./rtl8192cu-fixes/8192cu-disable-power-management.conf /etc/modprobe.d/
reboot


Seventh Note:  Printers


I use two printers at home, a old Lexmark Optra S 1255, and a wireless Epson NX420.  Configuring the Lexmark is easy; it's a postscript printer, and Linux has always supported postscript printers well.   Open System Tools -> Printers -> Add.  I have a network card in the printer, so I expand the Network Printer options and choose AppSocket/HP JetDirect.  Enter the IP of the printer, port 9100 is already filled in, and click Forward.  Select the manufacturer from the list, then the model,  describe the printer, click Apply and print the test page.

The default driver for the Epson NX420 does not work well at all; the colors do not align properly, but instead look like shadows.   This is another problem that's a pain to fix the first time, but it's not too bad the second time.  The NX420 needs a driver from http://www.openprinting.org/printer/Epson/Epson-NX420_Series.  Choose the 64-bit deb driver, download it, and then open the downloaded file.   Lubuntu will open the driver in the Software Center.   Click the Install button to install the driver.

Now add the printer ( System Tools -> Printers -> Add).   Expand the network printers, select the NX420, and click the Forward button.  The system searches for print drivers for a minute or so, then displays the Describe Printer dialog.  Click Apply, and print a test page to verify that the driver works properly.  The test page should identify the driver as EPSON.PPD, and the test page should look perfect.   If the print driver is STP01629.PPD and the images at the top of the test page don't align, then you have the bad print driver.

With these notes, I'm ready for the next upgrade.    With Lubuntu installed on my 12-year old laptop and my under-powered netbook,  I have two pretty responsive computers.   The laptop is perfect for my desk:  if the cat walks across it, who cares? It's a twelve year old computer.  And the one and half pound netbook is great for traveling.


References





Tuesday, January 23, 2018

Snipping in Lubuntu, the sequel

In my last post, I added screen snipping to my Lubuntu system using scrot and mtpaint.   The result is similar to using the Snipping Tool in Windows 7, where the resulting clipped image is left in the Snipping Tool viewer.    There are two drawbacks to the approach:  first, leaving the clipped image in mtpaint is not the quickest thing to do for simple copy and paste applications, and second, not every program will accept the resulting image.

In this post, I'll demonstrate adding a new meta-key combination, Windows-Shift-S to select and copy part of a screen, format the data so any program can use it, and copy the image to the system clipboard.  The end result will be the same meta-key that Windows 10 uses to capture screens, and the result will be ready to paste -- no intermediate step leaving the result in mtpaint.

Capturing the image in a format that was usable by all my programs turned out to be trickier than I thought.   At first, I thought I could send the output of scrot to xclip.   This works for LibreOffice Writer and Gmail, but it didn't work for Blogger.  Hence,it was time to take a deeper dive into this process.  The 'net being the 'net, there's often someone who's run into the same problem.   In this case, artfulrobot (their handle) on askubuntu.com provided a solution that I'm going to work with here.

In this post, I'll discuss using scrot, base64, and xclip to add Snipping Tool functionality to Lubuntu.  We'll start by making sure we have all three programs installed, then we'll test invoking them from the command line, and finally we'll map them to meta-keys in the Openbox window manager.

First, make sure these the tools are installed on your system.   Base64 should be installedd on Lubuntu, but you may need to install scrot and xclip yourself.  Open a terminal window, and try executing xclip and scrot; if they load, everything is good.   If you get a message like:

The program 'xclip' is currently not installed. You can install it by typing: 
sudo apt install xclip 


If you get that message, go ahead and install the tool using apt:

sudo apt install xclip


Now you should have all three tools installed.  There are a lot of moving parts, so let's start with the solution and work backwards.  If you haven't read the previous post, I suggest you browse it now.

First, edit the openbox configuration file using vi or your favorite text editor:

tom@dv8000:~$ cd .config/openbox/ 
tom@dv8000:~/.config/openbox$ ls -l 
total 36 
-rw-r--r-- 1 tom tom 33058 Jan 23 14:06 lubuntu-rc.xml 

tom@dv8000:~/.config/openbox$ cp lubuntu-rc.xml lubuntu-rc.xml.safe
tom@dv8000:~/.config/openbox$ vi lubuntu-rc.xml 


 In our previous post, we added the Windows-s meta-key to copy a screen shot to mtpaint in lines 384 through 389.  The command to do this on line 387 was pretty simple, and we included it right in the configuration file.

The command we need to execute this time is a bit too complicated to include directly in the openbox configuration file, so we'll wrap it in a simple shell script on line 394.  The easiest place to put the shell script is in your home directory until you're satisfied with it.  In my case, that's /home/tom; I'll name the shell script ScrotIt, a meshing of SnipIt and scrot.  Using your editor, add lines 390 through 397, substituting your home directory for /home/tom:


    384     <!--  Windows-s key to select screen and copy to mtpaint -->
    385     <keybind key="W-s">
    386       <action name="Execute">
    387         <command>  scrot -s -e 'mtpaint $f ' /tmp/ScrotSave.png </command>
    388       </action>
    389     </keybind>
    390     <!-- Windows-Shift-s to select screen and copy to xclip -->
    391     <keybind key="W-S-s">
    392       <action name="Execute">
    393         <command>
    394             /home/tom/ScrotIt
    395        </command>
    396      </action>
    397     </keybind>



After you added the new keybinding, save the file and refresh openbox with your changes:

openbox --reconfigure

Next, edit the script ScrotIt using your favorite editor, and add the following lines:

      1 #!/bin/bash
      2 scrot -s /tmp/scrotSave.png && \ 
      3    echo "<img src='data:image/png;base64,"\ 
      4         $(base64 -w0 /tmp/scrotSave.png)\
      5         "' />" | 
      6    xclip -selection clipboard -t text/html 


Let's walk through the code and see what's happening:


  • Line 2 runs the scrot command, saving the output in /tmp/scrotSave
  • Lines 3 thru 5 build an ASCII string with the image
  • Line 3 starts an <img> tag, and the source is a data URI.   The MIME type is image/png, and it will be base64-encoded.
  • Line 4 runs the base64 command, encoding the image data as an ASCII character string.  The bash $( ) construction executes base64 and returns the output as a string to the script running the command
  • Line 5 closes the <img> tag we started in line 3.  The shell concatenates the string in line 3, the output of base64 in line 4, and the closing tag string in line 5 into one long string.
  • Finally, on line 5 the output of the echo command that started on line 3 is piped to xclip on line 6.  xclip takes the output and leaves it in the system clipboard, ready for pasting.

The last step is to make the script executable and test it.  After typing in the ScrotIt command on the second line and pressing enter, ScrotIt will wait until you select an area of your screen.

 

tom@dv8000:~$ chmod +x ScrotIt 
tom@dv8000:~$ ./ScrotIt 


Select an area of your screen and paste it into an application that supports images, like your word processor or mail program (but not a text editor like vi). I copied and pasted the following image into blogger:

Linux terminal screenshot showing Scrotit running

If all went well, now try it with the Windows-Shift-S meta-key combination that you defined for openbox.   After selecting an area of your screen with your mouse, you should be able to paste it into your document.

Using these tools, you can select, copy, and paste from your screen into documents as easily with Linux as you can with Windows 10.



References:

In addition to the Unix man pages for scrot, base64, and xclip, I found the following web pages to be useful.

https://wiki.archlinux.org/index.php/openbox#Keybinds

https://askubuntu.com/questions/759651/how-to-copy-an-image-to-the-clipboard-from-a-file-using-command-line

https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs

Saturday, January 13, 2018

Snipping in Lubuntu

One feature that I find very useful in Windows 10 is the Snipping Tool.   The Snipping Tool is mapped to Win-Shift-s key combination.   After pressing that meta-key combination, the screen grays out, and you can select an area of the screen to copy to the system clipboard.    This is really useful for splatting images quickly into an email (or other document) with a minimum of effort.   Print Screen and Alt-Print Screen were useful for grabbing images of a whole screen or of a whole window.   But many times we only want a small portion of the screen, and the Snipping Tool is a useful way to grab exactly what you need.

In my last post, my old laptop got a second lease on life with a Lubuntu installation and an upgrade to 2GB of memory.  I am spoiled  by the Snipping Tool on my Windows 10 computers, so I'm looking for a way to get that functionality with Lubuntu.

As with many things in life, "da google" is your friend.   Googling "screen capture" or "screen image" returns lots of hits showing screen images from Lubuntu, but not a lot of information describing how to grab that screen image.    Eventually, I found some information about a screen capture utility called scrot (short for SCReen shOT).  In this post, I'll discuss using scrot and mtpaint to add Snipping Tool functionality to Lubuntu.  Mtpaint is installed as part of the Lubuntu distribution, but you may need to install scrot  yourself. Finally we'll map them to meta-keys in the Openbox window manager.

First, make sure scrot and mtpaint are installed on your system.     Open a terminal window, and try executing scrot -v.  If the tool loads and displays a version number, everything is good.   If it doesn't load, you'll see a message like:

The program 'scrot' is currently not installed. You can install it by typing: 
sudo apt install scrot 


If you get that message, go ahead and install the tool using apt:

sudo apt install scrot


Now you should have both tools installed.  I'll start by running them from the command line.  First, try
scrot --select

You'll notice that your terminal window pauses, waiting for you do do something.  With your mouse, click and drag over any section of your screen, creating a rectangle.  Release the mouse button, then in your terminal window type
ls -l *png

and your output will look something like the following.   Scrot creates files with the datetime and images size in the filename:

tom@dv8000:~$ ls -l *png
-rw-rw-r-- 1 tom tom 131103 Jan 13 11:48 2018-01-13-114835_1440x875_scrot.png
-rw-rw-r-- 1 tom tom    510 Jan 13 11:48 2018-01-13-114856_184x172_scrot.png
tom@dv8000:~$


Our next step is to get the the output into mtpaint.  Scrot has a couple of options to facilitate sending the captured image to another program.   The -exec option tells scrot to run another program, and the $f will pass the name of the saved file.   Try the next command; scrot will save the output in /tmp/ScrotSave.png and then execute mtpaint with that filename as the argument.

scrot --select --exec 'mtpaint $f ' /tmp/ScrotSave.png

Now we have all the facilities of mtpaint at our disposal to edit the image.  Using mtpaint, we can save that image back to disk, or just copy it to the system clipboard  using Ctrl-C, then Edit -> Export Clipboard to System from the drop-down menu.

Finally, we'll map this command to a meta-key combination in Lubuntu's openbox window manager.   From your home directory, navigate to the .config directory, then to the openbox directory, and look for the XML configuration file.   In my system, it's lbubuntu-rc.xml:

tom@dv8000:~$ cd .config 
tom@dv8000:~/.config$ cd openbox  
tom@dv8000:~/.config/openbox$ ls -l 
total 36 
-rw-r--r-- 1 tom tom 33422 Jan 13 15:10 lubuntu-rc.xml 
tom@dv8000:~/.config/openbox$ 


There are lots of things that we can configure here, but we're only interested in the keybindings. Before you begin, copy the xml file to a safe place! If you make an error, you don't want to leave the configuration file a mess, and you can restore it using your saved copy.  After creating a backup copy, edit lubuntu-rc.xml with your favorite text editor (leafpad and vi are both installed on Lubuntu, don't use a document editor like Abiword, OpenOffice, or LibreOffice). 

Find the line where the Lubuntu specific keybinding start, and add the XML starting with the <keybind> tag through the closing </keybind> tag:

    382     <!--  Lubuntu specific : Keybindings -->
    383     <!--  Lubuntu specific : Keybindings -->
    384     <!--  Windows-s key to select screen and copy to mtpaint -->
    385     <keybind key="W-s">
    386       <action name="Execute">
    387         <command>  scrot -s -e 'mtpaint $f ' /tmp/ScrotSave.png 
    388      </action>
    389     </keybind>
 

Let's look at what we've done:   Line 384 is a comment describing our change. In line 385, we start a new keybinding, capturing the meta-character combination Windows and the "s" key.  The action will be to Execute the command within the command tags on line 387, which is the scrot command that we executed in the terminal prompt above.

Next, save the file, and refresh the window manager with the new configuration information:

openbox --reconfigure

If all went well, openbox will not display any output.   If there is an error, openbox pops up a message describing the error and its location in the XML file.   After openbox has reloaded the XML file, test your keymap by pressing the Windows and S, then select part of your screen using the mouse.   After you release the mouse key, mtpaint will open with a copy of the image you selected. 

Now you have a quick and easy way to grab a selected part of your screen and manipulate it for further use.  Save the image as a file, or copy it to the system clipboard in mtpaint.




References:

In addition to the Unix man pages for scrot and mtpaint,  the following web pages should be useful for readers desiring a deeper look into the keybinding and mtpaint.

https://wiki.archlinux.org/index.php/openbox#Keybinds

http://mtpaint.sourceforge.net/handbook/en_GB/chap_04.html#SEC6







Saturday, December 30, 2017

He's dead, Jim

Except for Scotty's warning that "the dilithium crystals are going to blow", Dr. McCoy's pronouncement that "He's dead, Jim" may be the most famous Star Trek quote in popular lexicon.

And so was my nine-year old laptop:  it's dead, Tom. With a hard disk that had just failed,  the laptop was "really and truly dead".  Dual-booting into Windows XP or Ubuntu,  the laptop was a top-of-line beast when it was new, and it still performed well enough for most daily tasks nine years later.

A search on eBay and ten dollars later, my laptop was ready to go... except for an operating system.   Restoring Windows XP was not an option; I'm the second owner, and I don't have the XP install disks.   I was running Ubuntu 14.04 LTS at the time the old hard disk failed, so Ubuntu seemed like a good choice this time.

With a single-core AMD 64-bit processor running just shy of 2GHz and  1GB of memory, I noticed that the laptop was finding the latest browsers a handful to run.   This was especially true when Mozilla dropped support for flash video.   HTML 5 video requires much more resources than flash did.  So, I decided to look for a lightweight Linux distribution that required less computing power than the full-blown Ubuntu distribution I was using.  Anything the operating system wasn't using would be available for applications to use.

Google "lightweight Linux distributions", and you'll get dozens and dozens of results to browse through.   My advice:  don't over-think it.   A lightweight distribution is a lightweight distribution, and the 600 pound gorilla is not the Linux distribution you choose (nor is it Windows nor macOS), it's the applications.  Applications are stuffed features that you'll never use, and they get bigger and badder every release.  Browsers like Firefox and Chrome are often victims of massive amounts of JavaScript and advertisements running video automatically.  So, the distribution is the least of your problems, just pick a distribution and install it.

I choose Lubuntu.   It's reasonably lightweight, it has access to the Ubuntu software libraries, it includes enough applications to get you started, and it's well-supported by the Ubuntu community.   I installed Lubuntu 17.04 LTS, which is supported by security updates for the next several years. 

I used Mozilla's Firefox browser until the most recent update that dropped support for flash.  The latest Firefox browser consumed so much memory that the laptop took a deep dive into the swap space and performance really suffered.  I found Opera to work much better.  As a bonus, Opera includes an ad blocker that tames most ads without crippling a website's functionality.

Lubuntu includes Abiword for word processing and gnumeric for a spreadsheet.  They're both lightweight applications, but if you need compatibility with Microsoft's office suite, I recommend installing either LibreOffice or OpenOffice.  LibreOffice and OpenOffice share a common ancestry; either one is a good choice, and compatibility with Microsoft's file types is very good.  Again, don't over-think it! Lubuntu's software catalog includes LibreOffice, so that's the one I choose.     From the Lubuntu start menu, choose System Tools -> Software.   If your computer is old (like mine), be patient while the software catalog loads.   Then, click the Productivity button and scroll down to find LibreOffice.  Installing LibreOffice loads the base product and the word processor; choose Base, Calc, Draw, or Impress to load the other features.

Finally, I found 1GB of memory to be just barely enough to satisfy the memory requirements of the most recent browser upgrades.  If your system has enough memory that it is not using much swap space, you'll find computing to be much more enjoyable.   If your computer's hard disk is buzzing away while it swaps programs and data in and out of memory, it's time for a memory upgrade.   I found 1GB of memory on eBay for $14 that lets my system hum along nicely while barely touching the swap space.    Memory upgrades can be tricky, I had good luck with a-techcomponents, a vendor I found on eBay.

Happy Computing, and Happy New Year!




Thursday, March 24, 2016

SQL Server Listagg and SQL Recursion, Part Three

I've had many occasions to import and export data from one database to another, and I've found SQL recursion to be very useful when I need to string a set of values into one column.   Recently, I had a little hiccup:  a query that I expected to take a few seconds was still running after five minutes.    Clearly, something was wrong.  After convincing myself that the query was correct, I began to examine the data and some of the intermediate query results.

Most of the keys had perhaps five or six values concatenated into a column.  However there were a few keys that had 30 to 40 values to concatenate.    I've reviewed some of the intermediate results before, and I knew the algorithm expanded out about half the possible combinations and then selected the one row with the complete answer.  When there were only three or four values to concatenate to a key, this was not a problem.  But with 30 to 40 values to concatenate to one key, I had a very long running query.

The solution to the performance problem is to reduce the amount of work the query does.   And the key to that is in SQL Server Listagg, Part Two.  In part two, I discussed the problem of concatenating values together when one of the values repeats.  I introduced the use of a surrogate key to control the recursion. In part one, I used the values to control recursion, starting with the smallest value, and repeatedly concatenating larger values to the result.   In part two, I assigned surrogate keys to the values, started the recursion with the smallest surrogate key, and repeatedly concatenated values with larger surrogate keys.   The queries in part one and part two are very similar, and they create a lot of intermediate results that are later discarded.

The new query is much more efficient, and it gains efficiency in two places.  Say we have a set of values that we want to concatenate,  {A, B, G, L}. Now suppose we have a set of surrogates {1, 2, 3, 4} assigned to {A, B, G, L}.   In the first two posts, we use a correlated subquery to find the smallest value to start.  But if we have assigned surrogate values to each of the target values, we do not need the correlated subquery; the starting surrogate value will always be 1.  This makes the query simpler and faster.

In the algorithm in part one, we start with "A", then look for a larger value to concatenate.  That value could be B, G, or L.  Our part one algorithm will iterate thru and create rows with all those combinations: {A},  {A, B}, {A,G}, and {A,L}.   And the iterations continue: {A,B,G}, {A,B,L}, {A,G,L}.  And, finally, {A,B,G,L}.   But we really only want {A,B,G,L}.     Now, if we start with surrogate 1, the next surrogate must be 2, the one after that 3, and  finally 4.    Our intermediate results are {A}, {A,B}, {A,B,G}, and finally {A,B,G,L}.  In part one, we had an algorithm where the work increased exponentially with the number of values to concatenate.  Now, we have an algorithm where the work increases linearly with the number of values to concatenate.   This algorithm is no more complicated that the one we saw in part two, but it's far more efficient.  This is a bigger improvement than eliminating the correlated subquery above.

Here's the code, notice the comments after "from add_surrogate" and "inner join rq":

-- Start with a new common table expression and create a unique 
-- surrogate value for each value in the VALUE column.  The OLAP  
--  row_number() function will do that easily.
with add_surogate (id, value, surogate_value) as (
    select  id, 
            value,       
            row_number() over (partition by id order by value)     
    from sample
    )

-- Add the surrogate_value to the rq CTE
,rq ( id, value, surogate_value,  concat_value, depth) as (

   select a.id,          
          a.value, 
          surrogate_value,
          cast( a.value as varchar(256)), 
          1
   from add_surrogate a
   -- Using surrogate values, we do not need the correlated sub-query
   -- to find the smallest initial value.  The smallest surrogate 
   -- value will always be 1
   where surrogate_value = 1  

   union all 

   select c.id,     
          c.value, 
          surrogate_value, 
          -- No changes, concatenate values as before 
          cast( rq.concat_value + ',' + c.value as varchar(256)), 
          rq.depth + 1
   from add_surrogate c
   inner join rq 
      on c.id = rq.id
         -- Now control the progress of the recursion using the
         -- surrogate value. In part two, the condition was   
         -- the new surrogate > the old surrogate.  That works,
         -- but here's where we can do better:  The next surrogate
         -- will always be the previous surrogate value plus 1.  
        and surrogate_value = surrogate_value + 1
     
)

,all_rows as (
    select id, value, concat_value, depth,
           max(depth) over (partition by id) max_depth
    from rq
)

select id, concat_value
from all_rows
where depth = max_depth -- Select final answers


Moore's Law often works in our favor:  if performance doubles every two years, then we can solve bigger and more difficult problems than we could before. But sometimes the problem we're trying to solve outstrips Moore's Law,  and then we need a better way of solving the problem. In this case, a small tweak in the code delivers a huge performance boost.

Wednesday, April 15, 2015

Maple Syrup - Liquid Gold

Today, I became a New Englander.

I could not claim that before.  I am, as the Maine expression says, "from away", a transplanted Marylander rooting for the Baltimore Orioles in the spring and the New England Patriots in the fall.  But today, I made my first batch of maple syrup.  Starting with two quarts of fresh maple sap from our friends' sugar maple trees and some advice on sugar content, I boiled two quarts of sap down to about two ounces of maple syrup.  I feel like I belong.

Maple syruping has a long tradition pre-dating European settlers.  It is a pretty simple process that has become more mechanized over the ages, with long lines of tubing replacing traditional taps and buckets and horses and sleds.  Anyone living in New England owes themselves a visit to one of the many sugar houses dotting the New England countryside to watch the pros in action. In this post, I'll describe a simple way of making a few ounces of home-made maple syrup.

Four quart pot boiling two quarts of maple sap
Four quart pot to boil two quarts of sap.

I started with two quarts of maple sap in a four-quart pan. Leave plenty of room to prevent the sap from boiling over into the stove.


Piece of paper showing my reduction calculations
Calculating reduction



The syrup is usually boiled to a 30:1 to 40:1 reduction.   Our friends and a nearby sugar house both said the sap was very heavy with sugar this year, so I guessed that a 30:1 reduction would work. Professionals use the syrup's boiling point to indicate the syrup is ready.  I started with 60mm of syrup in the four-quart pan.  A 30:1 reduction leaves should leave about 2mm of syrup in the pan.


Smaller pot for the final reduction
Maple sap boiling in a smaller container

Boiling down to a depth of 2mm didn't seem practical, so at 10mm in the four-quart pan I transferred the sap to a smaller container.  I had boiled to a 6:1 reduction, now I just needed a 5:1 reduction in the smaller pan.   There was 49mm of syrup in the small pot, so a 5:1 reduction should give me about 10mm of syrup.


Small maple syrup bottle with home-made funnel
Maple syrup jug with tin foil funnel

After boiling down to the 10mm mark, it's time to bottle.  I have a small syrup container complete with a hand-made aluminum foil funnel and bowl to catch any stray syrup. 



Small bottle of maple syrup
Liquid Gold!


Finally, the finished product: a little over two ounces of maple syrup.
 
Today, I am a New Englander.  Tomorrow, I eat pancakes and maple syrup!


Saturday, March 28, 2015

SQL Server Listagg, Part Two

Last week's post examined using common table expressions and recursive SQL to concatenate strings grouped by an id value in SQL Server.  This is an easy operation to perform in DB2 and Oracle; both databases support the LISTAGG function.   But SQL Server does not support the LISTAGG function, so we needed to take a different approach to getting the same results.

Our query works well, but sometimes our data may have values that repeat within an id.   Because our query controls the recursion with the values that we're concatenating, we might not get the result back that we wanted.

Let's start with last week's sample table, and add a row to it:

    insert into sample values(3,'G');

Run the last week's final query, and our results look this:

IdConcat_value
1A,B
2C,D
3E,F,G
3E,F,G
4H


This is probably not the result we hoped for.  So, first question:  what did we hope to see?   If the correct result is four rows, with the value of "E,F,G" for Id = 3 , then we just need to add the distinct function to the final query.

But maybe the correct answer is four rows, and the concat_value for Id = 3 is "E,F,G,G".  In that case, we need to do a little work to our query.   As originally written, the query uses the VALUE column to control the recursion.   If the values repeat, then we need to introduce a surrogate value to the query.  Let's examine the query and let the code comments do the talking:


-- Start with a new common table expression(CTE) that creates a unique surrogate
-- value for each value in the VALUE column.  The OLAP row_number() function will 
-- do that easily.
with add_surogate (id, value, surogate_value) as (
    select  id, 
     value,       
            row_number() over (partition by id order by value)     
    from sample
    )

-- Add the surrogate_value to the rq CTE
,rq ( id, value, surogate_value,  concat_value, depth) as (

   select a.id,          
          a.value, 
   a.surogate_value,
          cast( a.value as varchar(256)), 
          1
   from add_surogate a
   where a.surogate_value = (
         select min(surogate_value)   -- Start the recursion using the surrogate value
         from add_surogate b
         where a.id = b.id
         )

   union all 

   select c.id,     
          c.value, 
          c.surogate_value, 
          -- No changes, concatenate values as before 
          cast( rq.concat_value + ',' + c.value as varchar(256)), 
          rq.depth + 1
   from add_surogate c
   inner join rq 
      on c.id = rq.id
         -- And control the progress of the recursion using the
         -- surrogate value
        and c.surogate_value > rq.surogate_value
        and rq.depth < 10
)

,all_rows as (
    select id, value, concat_value, depth,
           max(depth) over (partition by id) max_depth
    from rq
)

select id, concat_value
from all_rows
where depth = max_depth


The changes from last week's query to this week's query are pretty simple:
  • Add a new common table expression to create a surrogate value
  • Add the surrogate value to the recursive common table expression
  • Use the surrogate value to control the recursion
  • Continue to build the result string as before
With these simple changes, we get the desired results:

ID Concat_value
1 A,B
2 C,D
3 E,F,G,G
4 H


More Reading:

The vendors' online documentation have good examples:

Friday, March 20, 2015

SQL Server ListAgg Function, Part One

Lately, I've been straddling the Oracle database world and the Microsoft SQL Server world, moving data from Oracle tables to SQL Server tables.  One of the things I needed to do was concatenate string values grouped by an id value.

This is a pretty easy thing to do in the Oracle world.   Let's create a table and add some data:

create table sample (
    id number,
    value varchar2(16)
);
insert into sample values (1, 'A');
insert into sample values (1, 'B');
insert into sample values (2, 'C');
insert into sample values (2, 'D');
insert into sample values (3, 'E');
insert into sample values (3, 'F');
insert into sample values (3, 'G');
insert into sample values (4, 'H');
commit; 

Next, using the LISTAGG function available in DB2 and Oracle, our query will return an id and the concatenation of the values for each id:

select id, 
       listagg(value,',') within group (order by value) concat_value
from sample
group by id

ID CONCAT_VALUE
1A,B
2C,D
3E,F,G
4H

That was easy on an Oracle database!  Now, search the SQL Server documentation, and you won't find a LISTAGG function. Next, google "SQL Server listagg", and you'll find lots of discussion. Stackoverflow contributors offer many ways of solving this problem, usually using stored procedures or functions. In this post, I will suggest a SQL solution to the problem using SQL recursion and common table expressions.

First, grab the create table and the insert SQL above.  Change the varchar2 datatype to varchar (or nvarchar) and create the sample table on your SQL Server database.  Next, construct the recursive query.

with rq ( id, value, concat_value, depth) as (

   select a.id, 
          a.value, 
          cast( a.value as varchar(256)), 
          1
   from sample a
   where a.value = (
         select min(value) 
         from sample b
         where a.id = b.id
         )

   union all 

   select c.id, 
          c.value,  
          cast( rq.concat_value + ',' + c.value as varchar(256)), 
          rq.depth + 1
   from sample c
   inner join rq 
      on c.id = rq.id
        and c.value > rq.value
        and rq.depth < 10
)

select rq.* 
from rq
order by id, depth

Before we execute the query, let's examine it:
  • the recursion returns 4 columns:  the id, the current value, the result of concatenating the values, and the recursive depth
  • we CAST the concatenated value to a varchar large enough to hold the result
  • we use a correlated query to return the smallest value for each id.  This gives us a starting point for the recursion. 
  • Tracking the depth is useful while developing a query.  The test for rq.depth < 10 will keep the query from spinning away if there's an error.
  • The condition c.value > rq.value performs the same function as "order by value" in the Oracle query.   
Execute the query, and we get the following result table:

IDValueConcat_valuedepth
1AA 1
1BA,B 2
2CC 1
2DC,D 2
3EE 1
3FE,F 2
3GE,G 2
3GE,F,G 3
4HH 1

The answer we want is in the result table above, we just need to filter out the intermediate rows.   The rows we want have have the maximum depth value by ID.  We will use the max function by id to find the maximum depth in a second common table expression, and then filter the results in our final query:

with rq ( id, value, concat_value, depth) as (

   select a.id, 
          a.value, 
          cast( a.value as varchar(256)), 
          1
   from sample a
   where a.value = (
         select min(value) 
         from sample b
         where a.id = b.id
         )

   union all 

   select c.id, 
          c.value,  
          cast( rq.concat_value + ',' + c.value as varchar(256)), 
          rq.depth + 1
   from sample c
   inner join rq 
      on c.id = rq.id
        and c.value > rq.value
        and rq.depth < 10
)

,all_rows as (
    select id, value, concat_value, depth,
  max(depth) over (partition by id) max_depth
    from rq
)

select id, concat_value
from all_rows
where depth = max_depth


When we execute this query, we get the same results as our original query using the LISTAGG function in Oracle:

ID Concat_value
1 A,B
2 C,D
3 E,F,G
4 H

Using common table expressions and recursion, we now have a way to aggregate strings together in SQL Server.  But this is just the start; using the method for string aggregation as a model, we can develop other aggregations as well.  Next week, we talk about handling cases where values are repeated within an id.



Thursday, November 20, 2014

Directory Lookups

Every organization has an employee directory, and building search functions to find someone in the directory is a common problem.  How do you look someone up?  By their last name? By their first name?  And what do we mean by last and first names?  In some countries (China, Hungary), the family name precedes the given name. Or by their full name, including a middle initial?

If an organization is small, it's easy to load all the names into a select list and let the user pick one.  For large organizations, searching the company directory is more challenging.   Is Marguerite in the directory as Marguerite, or Margaret, or maybe even Gretchen, or maybe just Marge?  And how many John Smiths and Jane Does do we have in the directory?

In fact, searching text is a challenging problem.  Consider product searches.   Go to www.ebay.com, enter a product, and you will likely get tens, perhaps hundreds of good matches.  In my experience, the eBay search engine works very well.  Other vendors do not do as well -- a search that turns up hundreds of poor matches is not very useful. 

In general, we want a search strategy that returns lots of good matches and very few poor matches.   In this post, we'll use the company directory as an example of how to solve this problem.

So, suppose someone sits at a web page, types in a name, and presses Enter.  How do we select a list of likely names to present?   Most organizations have tried a few approaches.  Soundex looks promising, but in practice tends to return many false positives.  Many organizations home-grow a solution:  only look at the first few characters of each name, perhaps drop all the vowels and squish everything together, or maybe swap first and last names.

Most home-grown solutions suffer from not returning enough likely matches and returning too many bad matches  --- the worst of all situations.  Worse, many organizations invest much time and effort home-growing the poor-performing solution. There are a lot of solutions out there, but rather than re-invent the wheel, let's examine a solution using Oracle's text indexes and fuzzy matching.  We can get good results by using the out-of-box tools provided in the Oracle database.

Let's start with a simple table and some data.  First we'll create a table to hold the names, split into a first name, last name, nickname, and finally all the names concatenated together. More about the all_names column later.

create table names (
    first_name varchar2(50),
    last_name varchar2(50),
    nick_name varchar2(50),
    all_names varchar2(160)
);

The first_name (John), last_name (Smith), and nick_name (Johnny) are entered as part of normal table maintenance.  The all_names columns is a concatenation of the three names, and we'll use a trigger to maintain the all_names column:

create or replace trigger names_before_iu
before insert or update on names
for each row
begin
   :new.all_names := :new.first_name || ' ' ||
                     :new.last_name || ' ' ||
                     :new.nick_name ;
end;

Next, let's load some test data. We'll use the system catalog to get some "names", with the object's name acting as a first_name and the object's type acting as a last_name. In addition, we'll use the Apex demo_customers table as a source of names:

insert into names (first_name, last_name)
select object_name, object_type
from user_objects;

insert into names (first_name, last_name)
select cust_first_name, cust_last_name
from demo_customers;

Our next step is creating an index on the table. We will index the all_names column using a ctxsys.context type of index:

create index names_ctx
    on names ( all_names)
    indextype is ctxsys.context
    parameters('sync(on commit)');

We will use this context index to do fuzzy matching. Our queries will use the contains function in the where clause, and we need to construct a fuzzy argument to pass to the contains function.  The fuzzy function expands the search to include similarly spelled words.  The fuzzy argument takes three parameters:
  • a search string that is at least 3 characters long
  • a number between 1 and  80 specifying the lowest similarity score we will accept.  A lower number returns more results, a higher number returns better matches.
  • a number specifying how many variations of the search string to use. The more variations we request, the more results we will see.
  • a string specifying whether to weight or noweight the results
These numbers will become clear after a few examples. Sometimes things become clear when you poke them with a stick and see what happens, and this is one of those times.  The fuzzy match argument is a bit tricky to type, so let's create a function to make our typing easier.

create or replace
function mk_fuzzy(p_name in varchar2,
                  p_similarity in number default 50,
                  p_n_variations in number default 10)
    return varchar2 is
    
begin

   return 'fuzzy(' || p_name ||  ',' ||
           p_similarity || ',' ||
           p_n_variations || ',W)';

end mk_fuzzy;

Now for our query.

select score(1), a.first_name, a.last_name  
from names a
where contains(all_names, mk_fuzzy('viw',1,3), 1)>= 0
order by 1 desc;

Let's start at the top.  First, the score function takes one argument, in this case the number 1.   The argument refers to the third argument in the contains function, the number 1.  These two numbers should match, and the score function returns the score from the contains function for each row returned by the query.  Below we will use the contains function twice, and the score index ties the score to a specific contains query.

Next, the contains function takes three parameters:
  • the column to search on 
  • a fuzzy argument.  In this case, we're looking for the word "view" misspelled as "viw".  The similarity score argument of 1 specifies to return any match, and the variation argument specifies to use three variations of the "viw" argument.
  • an index for the score function
 Let's run the query and examine our results:

SCORE(1) FIRST_NAME LAST_NAME
48V1VIEW
48V2VIEW
48V3VIEW
48V4VIEW
48V_EMPVIEW
8DEMO_ORDERS_BIUTRIGGER
8DEMO_ORDER_ITEMS_BITRIGGER
8DEMO_ORDER_ITEMS_BIU_GET_PRICETRIGGER
8DEMO_PRODUCT_INFO_BIUTRIGGER
8DEMO_CUSTOMERS_BIUTRIGGER
8DEMO_TAGS_BIUTRIGGER
0WEB_CLIENT2PROCEDURE
0WEB_CLIENTPROCEDURE
0TESTTABLE_SEQSEQUENCE


The scores range from 0 (no match) to 48 (poor match). 48 is not a great score, but the query does find all of our views.

The all_names column includes both first and last names.  After some trial and error against a real-life table of several thousand names, the following query produces a good combination of  relevant results:

select score(1), a.first_name,       
       score(2), a.last_name,       
       score(1) + score(2)
from names a
where contains(all_names, mk_fuzzy('viw',50,50), 1) >  0
  and contains(all_names, mk_fuzzy('emp',50,50),2) > 0 
order by 5 desc;

This query returns the each name, the score for the each name, and the sum of both scores.  Notice the score indexes:  score(1) returns the score from the first use of contains, and score(2) returns the score from the second use of contains.  Ordering the results by the sum of the scores gives us the best matches first, and it's agnostic as to whether the family names follow given names or not. And because our all_names column includes nick names, our query works well with an argument consisting of a nick name and a family name, too.

Against our sample table, the above query returns the following results:

SCORE(1)FIRST_NAMESCORE(2)LAST_NAMETOTAL_SCORE
52V_EMP76VIEW128

With both our name arguments misspelled ( "emp" instead of V_EMP, "viw" instead of VIEW), the query still returns the correct answer, and the query doesn't return any bad matches.   This is exactly what we want!

The Oracle fuzzy matching tools deliver high quality search results with a minimum of new development and much less future maintenance.  In addition, tweaking the similarity score and the term expansion in the fuzzy matching function makes the tool very flexible.   If we don't get enough results, or if the results are poor, tweaking the arguments will solve the problem, and it's much easier to tweak the two arguments than re-writing home-grown code.