Sunday, December 8, 2013

FizzBuzz - Now in SQL Server

"Yet again? What do you here? Shall we give o'er and
drown? Have you a mind to sink?
"
-- Shakespeare, The Tempest
Act I, Scene I, Lines 38-39

The great thing about the FizzBuzz kata is that it is very easy to understand, but has nearly an unlimited number of ways to implement it.  I am fan of using the right tool for the job, but I do enjoy stretching things to see what they can and cannot do with the hope of gaining a different point of view.  Having said that let us take a look at implementing the FizzBuzz kata in SQL using SQL Server 2012.

Overview of FizzBuzz


The goal of the FizzBuzz kata is to create a list of values which move through a filter with the following rules:

  • If the number given is divisible by 3 then produce "Fizz"
  • If the number given is divisible by 5 then produce "Buzz"
  • If the number is not divisible by either 3 or 5 then produce the number given

FizzBuzz has one edge case:

  • If the number given is divisible by both 3 and 5 then produce "FizzBuzz"


An example would be the following:


We see that:
2  => 2
4  => 4
3  => Fizz
9  => Fizz
5  => Buzz
25 => Buzz
15 => FizzBuzz
45 => FizzBuzz

How to Generate the Dataset in SQL Server


Given that we are going to use SQL Server, the first question is how do we generate a dataset to run through our FizzBuzz?  We could create a table with all of the values that we need, but that is not very interesting.  Instead we will use a Common Table Expression (CTE) to generate our data.

We'll use a Tally Table as outline by Vinay Pugalia's blog post.

DECLARE 
 @min AS INT = 1
,@max AS INT = 20

;WITH [range] AS (
  SELECT @min AS num
  UNION ALL
  SELECT num + 1 FROM [range] WHERE num < @max
)
SELECT * 
  FROM [range]
;

This will generate the number 1 to 20 in a dataset.

http://sqlfiddle.com/#!6/9cd0c/7

We can generate any dataset starting at @min up to @max using this CTE, not bad.

Setting up the Business Logic


With our Common Table Expression set up to generate whatever range of numbers we want, we can now focus on the Business Logic of the problem.  Since we are looking for particular values to produce a result in some cases it sounds like the perfect job for the CASE statement.

DECLARE 
 @min AS INT = 1
,@max AS INT = 20

;WITH [range] AS (
  SELECT @min AS num
  UNION ALL
  SELECT num + 1 FROM [range] WHERE num < @max
)
SELECT
  CASE
    WHEN num % 15 = 0 THEN 'FizzBuzz'
    WHEN num % 3  = 0 THEN 'Fizz'
    WHEN num % 5  = 0 THEN 'Buzz'
    ELSE                   CAST(num AS VARCHAR)
  END AS value
  FROM [range]
;

Gives use the output we would want:

http://sqlfiddle.com/#!6/9cd0c/12

This gets us what we want, but personally I do not like repeating the logic for divisible by 3 and 5 in the WHEN num % 15 = 0 case.

How about if we join to a set for the Fizz and another set for the Buzz?  If we could do that we could use the CONCAT string operation to cover the divisible by 15 edge case.

DECLARE 
 @min AS INT = 1
,@max AS INT = 20

;WITH [range] AS (
  SELECT @min AS num
  UNION ALL
  SELECT num + 1 FROM [range] WHERE num < @max
)
SELECT
  CASE
    WHEN CONCAT(fizz.value, buzz.value) <> '' 
      THEN CONCAT(fizz.value, buzz.value)
    ELSE CAST(num AS VARCHAR)
  END AS value
  FROM [range]
  LEFT OUTER JOIN (
    SELECT 
      num AS id
    ,'Fizz' AS value
    FROM [range] WHERE num % 3 = 0
  ) AS fizz
    ON fizz.id = [range].num
  LEFT OUTER JOIN (
    SELECT 
      num AS id
    ,'Buzz' AS value
    FROM [range] WHERE num % 5 = 0
  ) AS buzz
    ON buzz.id = [range].num
;

We create subselects which give us the Fizz and Buzz filters we are looking for.  We then use the CONCAT to join a Fizz to a Buzz which will give us FizzBuzz on numbers divisible by both 3 and 5.  If we do not have a Fizz or a Buzz the CONCAT will give us '' (empty string), which means we'll want to return the number as a string.

This gives us:

http://sqlfiddle.com/#!6/9cd0c/20

We can make this a bit more readable using two more CTEs.

DECLARE 
 @min AS INT = 1
,@max AS INT = 20

;WITH [range] AS (
  SELECT @min AS num
  UNION ALL
  SELECT num + 1 FROM [range] WHERE num < @max
)
, fizz AS (
  SELECT 
      num AS id
    ,'Fizz' AS value
    FROM [range] WHERE num % 3 = 0
)
, buzz AS (
  SELECT 
      num AS id
    ,'Buzz' AS value
    FROM [range] WHERE num % 5 = 0
)
SELECT
  CASE
    WHEN CONCAT(fizz.value, buzz.value) <> '' 
      THEN CONCAT(fizz.value, buzz.value)
    ELSE CAST(num AS VARCHAR)
  END AS value
  FROM [range]
  LEFT OUTER JOIN fizz
    ON fizz.id = [range].num
  LEFT OUTER JOIN buzz
    ON buzz.id = [range].num
;

Which still gives us the same output.

http://sqlfiddle.com/#!6/9cd0c/22

Personally I find the three CTEs a bit cleaner to read and understand, but I understand that not everyone will feel the same way.

There you have it FizzBuzz using SQL Server.  You can find the solution on SQL Fiddle (and see all my typing errors to get to it).

"They say there's no such place... as Paradise. Even if you search to the ends of the Earth, there's nothing there. No matter how far you walk, it's always the same road. It just goes on and on. But, in spite of that... Why am I so driven to find it? A voice calls to me... It says, 'Search for Paradise.'"
-- Keiko Nobumoto, Wolf's Rain
Final Lines