5

I have the following requirements:

  • Every transaction has one of the following types; debit, credit, deposit, or withdraw.

  • Debit or credit transactions must have a linked invoice record and no bank account record.

  • Deposit or withdraw transactions must have a linked bank account record and no invoice record.


Currently my basic design is like this:

Rough Database Design

My current solutions are:

  1. Have atwo nullable foreign keys to the invoice and bank account tables in the transaction table. However, I believe a nullable foreign key column isn't a good design.
  2. Have a foreign key in the invoice and bank account tables that link to the transaction table. However it's seems harder to query and it models a one-to-many relation (not one-to-optional-one relation I want).

Which approach that I outlined is a better solution? Or is there another way to meet my constraints that is even better?

1

Let me suggest a third alternative: You can use triggers to check your business rules and fail the insert/update in a similar fashion a foreign key constraint would.

For your scenario I setup something like this:

CREATE TRIGGER Transaction_BEFORE_INSERT BEFORE INSERT ON Transaction_Table FOR EACH ROW
BEGIN

-- deposit, withdraw must have a bank account and no invoice
if NEW.`Type` in ('deposit','withdraw') then

   if not(select count(*) from Account_Table where id = NEW.id) 
      or (select count(*) from Invoice_Table where id = NEW.id) 
   then
      SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Error message';
   end if;
-- credit, debit must have an invoice and no bank account
elseif NEW.`Type` in ('credit','debit') then
   if not(select count(*) from Invoice_Table where id = NEW.id) 
      or (select count(*) from Account_Table where id = NEW.id) 
   then
      SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Another error message';
   end if;
end if;
END

I've used SQLSTATE 45000 which is a generic state for user-defined exceptions. You may customize that and also set an appropiate error message.

You would also need to create a BEFORE UPDATE trigger that does a similar check.

There will be a performance penalty for abusing triggers this way. Each insert/update will run 2 selects on the other table. You should optimize the selects to be as fast as possible. On high transaction databases it could be a bad solution. So if you choose taking this route please test the impact before running it on production.

0

Although very many NULLable columns are normally a sign of bad design, in some instances, it's perfectly legit to have them, and use the database CONSTRAINTS to make sure that the 0-1 relations and your business rules are kept. This would be your solution #1. Your solution #2 isn't easily helped by the database, you could eventually have one transaction without the corresponding rows in both invoices and bank_accounts tables.

Let's assume, for a moment, that you're not using MySQL1 (at least, as of version 5.7).

Let's assume you are using another database that actually performs the checks2, it would make sense to use a schema like the following one, with one invoice_id and one bank_account_id columns, and the necessary constraints that guarantee that they're REFERENCEing the proper rows in the proper tables (what you call links), and the CHECKS make sure that the proper ones appear, and the ones that do not correspond are not there:

CREATE TYPE transaction_type AS ENUM
    ('debit', 'credit', 'deposit', 'withdraw') ;
-- Note: this could be a table with four values (and probably four ids)    

CREATE TABLE invoices
(
    invoice_id integer /* serial */ PRIMARY KEY,
    other_data text
) ;

CREATE TABLE bank_accounts
(
    bank_account_id integer /* serial */ PRIMARY KEY,
    name text,
    other_data text
) ;

CREATE TABLE transactions
(
    transaction_id integer /* serial */ PRIMARY KEY, 
    type transaction_type,
    nominal decimal(10, 2),
    invoice_id integer REFERENCES invoices(invoice_id) ON UPDATE CASCADE ON DELETE RESTRICT,
    bank_account_id integer REFERENCES bank_accounts(bank_account_id) ON UPDATE CASCADE ON DELETE RESTRICT,

    -- Constraints for your business rules
    CONSTRAINT chk_debit_and_credit_must_have_bank_account 
         CHECK (case when type in ('debit','credit') then 
                    bank_account_id IS NOT NULL 
                else true end),
    CONSTRAINT chk_debit_and_credit_must_not_have_invoice 
         CHECK(case when type in ('debit','credit') then 
                   invoice_id IS  NULL 
                else true end),
    CONSTRAINT chk_deposit_and_withdraw_must_have_invoice 
        CHECK(case when type in ('deposit','withdraw') then 
                  invoice_id IS NOT NULL 
              else true end),
    CONSTRAINT chk_deposit_and_withdraw_must_not_have_bank_account 
        CHECK(case when type in ('deposit','withdraw') then 
                  bank_account_id IS NULL 
              else true end) 
) ;

With this in mind, and the following data...

-- Adding two invoices
INSERT INTO invoices
   (invoice_id, other_data)
VALUES
   (1, 'data for invoice 1'),
   (2, 'data for invoice 2')
;

-- Adding two bank accounts
INSERT INTO bank_accounts
   (bank_account_id, other_data)
VALUES
   (1000, 'Bank account 1000'),
   (1001, 'Bank account 1001')
;

... you can have one legit INSERT

-- Good credit and debit
INSERT INTO transactions 
    (transaction_id, type, nominal, invoice_id, bank_account_id)
VALUES
    (2000, 'credit', 1000.00, NULL, 1000),
    (2001, 'debit',   900.00, NULL, 1000) ;

... and some illegal ones (being rejected by the database)

-- Bad credit, it's got invoice
INSERT INTO transactions 
    (transaction_id, type, nominal, invoice_id, bank_account_id)
VALUES
    (2002, 'credit', 1000.00, 1, 1000) ;
ERROR:  new row for relation "transactions" violates check constraint "chk_debit_and_credit_must_not_have_invoice"
DETAIL:  Failing row contains (2002, credit, 1000.00, 1, 1000).
-- Bad credit, it's got not bank_account_id
INSERT INTO transactions 
    (transaction_id, type, nominal, invoice_id, bank_account_id)
VALUES
    (2003, 'credit', 1000.00, NULL, NULL) ;
ERROR:  new row for relation "transactions" violates check constraint "chk_debit_and_credit_must_have_bank_account"
DETAIL:  Failing row contains (2003, credit, 1000.00, null, null).

(and all other combinations)

A careful choice of names for the constraints can help a lot on debugging erroneous inserts or updates. If you need maximum speed, all the constraints can be reduced to just one single check expression. I normally would try to have the database help me, and keep it simple (4 easy names and 4 easy-to-read expressions, instead of a single one).

You can find all the setup at dbfiddle here


1This is the reason why:

Unfortunately, MySQL doesn't help much, because from MySQL's 5.7 manual on CREATE TABLE

CHECK

The CHECK clause is parsed but ignored by all storage engines. See Section 1.8.2.3, “Foreign Key Differences”.

2I've used PostgreSQL. With some syntax variations, this would also work with SQL Server or Oracle

0

You don't have a transaction table, you have a [BankAccountTransaction] and a [CreditTransaction] table.

Yes, split you transaction in two tables, one has a FK to [BankAccount] and the another got a FK to [Invoice].

If the need arises you can create a "parent" [Transaction] table and use it to intermediate the relation between the above two tables to a table don't see the difference between them. Or if that important only to report like queries you can create a view where you easily use a UNION.

                        +---------------------+                +---------------+
                        |                     |                |               |
        +---------------+  CreditTransaction  +---------------->    Invoice    |
+-------v------+        |                     |                |               |
|              |        +---------------------+                +---------------+
|              |
| Transaction  |
|              |
|              |          +------------------------+       +----------------+
+-------^------+          |                        |       |                |
        |                 | BankAccountTransaction |       | BankAccount    |
        +-----------------+                        +------->                |
                          |                        |       |                |
                          +------------------------+       +----------------+

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.