SQLite User Forum

Inserting into two tables in one statement, one of which requires a foreign key reference to the other?
Login

Inserting into two tables in one statement, one of which requires a foreign key reference to the other?

(1.1) By Matthew Bloch (mattbee) on 2022-05-14 09:42:09 edited from 1.0 [link] [source]

Hello! I think I've hit a wall when it comes to designing a schema & SQLite's current capabilities, and hoped an SQLite expert could confirm.

This is a simplified schema for a double-entry bookkeeping system, with a possibly-handy trigger to ensure data integrity:

CREATE TABLE journal (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  narrative TEXT NOT NULL DEFAULT ""
);

CREATE TABLE trans (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  journal_id INTEGER NOT NULL,
  account TEXT NOT NULL,
  cr INTEGER,
  FOREIGN KEY(journal_id) REFERENCES journal(id)
);

CREATE TRIGGER verify_trans AFTER INSERT ON journal
BEGIN
  SELECT
    CASE (SUM(cr) <> 0) WHEN 1 THEN RAISE(ROLLBACK, "Transactions don't balance") END
    -- etc.
  FROM 
    trans 
  WHERE 
    trans.journal_id=new.id;
END;

What I want to do is insert a row into "journal", and rows into "trans" which balance the journal entry (i.e. the totals add up to zero).

My ideal would be e.g.

WITH j AS  ( INSERT INTO journal ... RETURNING id ) 
INSERT INTO trans ... VALUES (j.id ...), (j.id ...)

But I understand RETURNING values can't be brought into other statements, only back to the application.

Or is there some other way round I might have missed, so a trigger like this could work?

(2.1) By Simon Slavin (slavin) on 2022-05-14 12:45:34 edited from 2.0 in reply to 1.1 [source]

https://sqlite.org/lang_corefunc.html#last_insert_rowid

I haven't tried this in the middle of a statement in the middle of a trigger. It might not work properly. But that's the way I'd expect to do it.

(3) By Keith Medcalf (kmedcalf) on 2022-05-14 17:30:11 in reply to 1.1 [link] [source]

But I understand RETURNING values can't be brought into other statements, only back to the application.

Or is there some other way round I might have missed, so a trigger like this could work?

No. No.

Your implementation cannot work, in any version of SQL.

Bookkeeping (Inventory Control of Money) is a rather simple, you simply (like any other goop or stuff control system) maintain mass balance. This means that the thing you are inventorying is neither created nor destroyed, but simply moved from place to place.

Your master journal entries should have two accounts -- the one that is debited and the one that is credited, for the same singular amount, in the same transaction. That is, something like:

create table Accounts
(
  id integer primary key,
  AccountNo text not null collate nocase unique,
  Balance real not null default 0
);
create table Journal
(
  id integer primary key,
  CreditAccount integer not null references Accounts(id),
  DebitAccount  integer not null references Accounts(id),
  Amount real not null
  -- other data, such as dates, notes, comments, etc
);
create trigger journal_del before delete on journal
begin
 select raise('You cannot modify a transaction after it is journalled');
end;
create trigger journal_upd before update on journal
begin
 select raise('You cannot modify a transaction after it is journalled');
end;
create trigger journal_ins after insert on journal
begin
  update Accounts 
     set Balance = Balance + new.Amount
   where id == new.CreditAccount;
 update Accounts
    set Balance = Balance - new.Amount
  where id == new.DebitAccount;
end;

(4) By Matthew Bloch (mattbee) on 2022-05-14 23:40:08 in reply to 2.1 [link] [source]

Thank you! I'm not trying to access the new row ID in a trigger (I already get that accessing via new.), but insert one new journal entry & multiple new transactions, with the transactions bearing the new journal ID.

I see this question asked of other databases, and they seem to allow it using INSERT ... RETURNING in a CTE, allowing straightforward access to the new id.

So I wanted to check whether there was some other way that I could do it in SQLite.

Right now I might relax the data model so that the checks only trigger when I set e.g. a "finalised_at" column on the journal. That would allow me to insert the journal (query 1), insert the transactions (query 2), and then set the "finalised_at" column (query 3) to trigger the checks and make the data visible to the application.

At the moment I'm just experimenting with schemas and performance to see what might work harmoniously & performantly with SQLite.

(5) By Matthew Bloch (mattbee) on 2022-05-14 23:53:32 in reply to 3 [link] [source]

Thanks! Using one row per journal would make this problem simpler, but that loses the flexibility of using more than 2 accounts. Also, (and maybe I should have abstracted my problem domain a bit better to make clear this was an SQL question, the domain is something I'm researching) but I wouldn't use a floating-point data type to represent money.

(6) By David Raymond (dvdraymond) on 2022-05-16 13:10:01 in reply to 1.1 [link] [source]

So, you're looking for it to be in 1 statement because your auditing is done by trigger on insert into journal table, and your need the id from there to do the insert inserts into the trans table first.

Could you define the id for a new insert to be -1, then update it? Then do it as multiple statements in a transaction rather than trying for 1 statemenet?

foreign key (journal_id) references journal (id)
on update cascade
deferrable initially deferred

begin;
insert into trans values ...; --with journal_id = -1
insert into trans values ...; --with journal_id = -1
...
insert into journal values (-1, 'narative here');--Your audit trigger runs here
update journal set id = (select max(id) + 1 from journal) where id = -1;--id cascades to the trans table
commit;

(7) By Bill Wade (billwade) on 2022-05-16 19:23:30 in reply to 5 [link] [source]

Consider sticking to the two accounts per journal row approach (at the database level), but support grouping multiple journal rows into a split entry.

10$ from checking account to my mortgage lender, split as

7$ interest payment
5$ reduced mortgage balance
-2$ escrow (in this case a refund) account

Those are three journal entries against the checking account, all three entries have the same splitId.

Your user interface can arrange that information as it wishes, but there is no built-in limit to how many entries share the same splitId.

Obviously the same transaction could have been represented differently (perhaps three operations against mortgage balance, rather than three against checking account).

I haven't ever implemented this for real, but I assume my checkbook software does something similar.