How to write clean code without all these cascading error Christmas trees?

Viewed 261

I wrote a function that should do a simple thing:

  1. look up for a specific address in a table and return the ID, if already existing
  2. if not, create a new record for this particular address
  3. return the ID of this newly created record

As RDMS I use mysql here. I put everything in a transaction to avoid race conditions in my concurrent go-routines that makes calls to this function.

However, the tons of constant checks for err makes the code ugly and full test coverage hard to get.

Is there anything I can improve here in terms of better code quality?

func getAddressId(db *sql.DB, address string) (int64, error) {
  tx, err := db.Begin()
  if err != nil {
    tx.Rollback()
    return 0, err
  }

  stmt, err := tx.Prepare("SELECT id FROM address WHERE `address`=?")
  if err != nil {
    tx.Rollback()
    return 0, err
  }
  defer stmt.Close()

  var result sql.NullInt64
  err = stmt.QueryRow(address).Scan(&result)
  if err != nil && err != sql.ErrNoRows {
    tx.Rollback()
    return 0, err
  }

  if result.Valid {
    tx.Commit()
    return result.Int64, nil
  }

  stmt, err = tx.Prepare("INSERT INTO address (address) VALUES (?)")
  if err != nil {
    tx.Rollback()
    return 0, err
  }

  var res sql.Result = nil
  res, err = stmt.Exec(address)
  if err != nil {
    tx.Rollback()
    return 0, err
  }

  tx.Commit()

  var id int64 = 0
  id, err = res.LastInsertId()

  return id, err
}
1 Answers

First, and most importantly, there's very little wrong with the above code. There are a few pieces I'd adjust (and will below), but generally it is very clear, straightforward, and (almost) hard to get wrong. There is nothing ugly about that.

Second, see Error Handling and Go for thoughts on error handling Go, though I won't be using those techniques here because they're not necessary.

Now there is one thing that's a bit bad, which is that it's easy to forget to call tx.Rollback() or tx.Commit() in the right places. In my opinion, that's reasonable to fix (but it's really more style than substance). The below isn't tested.

// Name your return values so that we can use bare returns.
func getAddressId(db *sql.DB, address string) (id int64, err error) {
    tx, err := db.Begin()
    if err != nil {
        return // This is a bare return. No need to write "0, err" everywhere.
    }

    // From this point on, if we exit with an error, then rollback, otherwise commit.
    defer func() {
        if err != nil {
            tx.Rollback()
        } else {
            tx.Commit()
        }
    }()

    stmt, err := tx.Prepare("SELECT id FROM address WHERE `address`=?")
    if err != nil {
        return
    }
    defer stmt.Close()  // I'm not sure this is correct, because you reuse stmt

    // This is purely style, but you can tighten up `err = ...; if err` logic like this:
    var result sql.NullInt64
    if err = stmt.QueryRow(address).Scan(&result); err != nil && err != sql.ErrNoRows {
        return
    }

    if result.Valid {
        id = result.Int64
        return
    }

    if stmt, err = tx.Prepare("INSERT INTO address (address) VALUES (?)"); err != nil {
        return
    }

    res, err := stmt.Exec(address)
    if err != nil {
        return
    }

    id = res.LastInsertId()
}

That said, I think this function is doing way too much, and if you break it up, it becomes easier to understand. For example (again, untested):

func getExistingAddressId(tx *sql.Tx, address string) (id int64, err error) {
    stmt, err := tx.Prepare("SELECT id FROM address WHERE `address`=?")
    if err != nil {
        return
    }
    // I believe you need to close both statements, and splitting it up makes that clearer
    defer stmt.Close()

    var result sql.NullInt64
    if err = stmt.QueryRow(address).Scan(&result); err != nil && err != sql.ErrNoRows {
        return
    }

    // This is probably over-complicated. If !Valid, then .Int64 is 0.
    if result.Valid {
        return result.Int64, nil
    }

    return 0, nil
}

func insertNewAddress(tx *sql.Tx, address string) (id int64, err error) {
    stmt, err := tx.Prepare("INSERT INTO address (address) VALUES (?)")
    if err != nil {
        return
    }
    defer stmt.Close()

    res, err := stmt.Exec(address)
    if err != nil {
        return
    }

    return res.LastInsertId()
}

func getAddressId(db *sql.DB, address string) (id int64, err error) {
    tx, err := db.Begin()
    if err != nil {
        return
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        } else {
            tx.Commit()
        }
    }()

    if id, err = getExistingAddressId(tx, address); err != nil || id != 0 {
        return
    }

    return insertNewAddress(tx, address)
}

Using named return values like this is a matter of style, and you could certainly not do it that way and it would be just as clear. But the point (a) defer is a powerful way to avoid duplicating logic that must always run and (b) if a function becomes a mess of error handling, it probably is doing too much.

As a side note, I strongly suspect you could get rid of the Prepare calls here, would would simplify things significantly. You only use the Statements one time. If you cached that Statements and reused them, then it would make sense to Prepare them. If you do that, then the code simplifies to:

func getExistingAddressId(tx *sql.Tx, address string) (int64, error) {
    var result sql.NullInt64
    if err := tx.QueryRow("SELECT id FROM address WHERE `address`=?", address).
        Scan(&result); err != nil && err != sql.ErrNoRows {
        return 0, err
    }

    return result.Int64, nil
}

func insertNewAddress(tx *sql.Tx, address string) (int64, error) {
    res, err := tx.Exec("INSERT INTO address (address) VALUES (?)", address)
    if err != nil {
        return 0, err
    }

    return res.LastInsertId()
}

func getAddressId(db *sql.DB, address string) (id int64, err error) {
    tx, err := db.Begin()
    if err != nil {
        return 0, err
    }

    defer func() {
        if err != nil {
            tx.Rollback()
        } else {
            tx.Commit()
        }
    }()

    if id, err = getExistingAddressId(tx, address); err != nil || id != 0 {
        return
    }

    return insertNewAddress(tx, address)
}

Rather than trying to simplify Go syntax, this simplifies the operation, which as a side effect makes the syntax simpler.

A small subtlety that may go overlooked if you're not very familiar with named return values. In return insertNewAddress(...), the return value of the function call gets assigned to id and err before the defer runs, so the if err != nil check will correctly reflect the returned value. This can be a bit tricky, so you may prefer to write this all more explicitly, especially now that the function is so much shorter.

func getAddressId(db *sql.DB, address string) (int64, error) {
    tx, err := db.Begin()
    if err != nil {
        return 0, err
    }

    var id Int64
    id, err = getExistingAddressId(tx, address)

    if err == nil && id == 0 {
        id, err = insertNewAddress(tx, address)
    }

    if err != nil {
        tx.Rollback()
        return 0, err
    }

    tx.Commit()
    return id, nil
}

And now the code is very straightforward, with no tricks, which IMO is Go at its best.

Related