db: finish sql commands
All checks were successful
Build and test / Build (push) Successful in 2m23s

This commit is contained in:
Muyao CHEN 2024-10-19 17:08:05 +02:00
parent dac36db284
commit 3d616bff50
10 changed files with 493 additions and 44 deletions

View File

@ -421,27 +421,28 @@ The following basic use cases are to be implemented at the first time.
- [X] A user signs up - [X] A user signs up
- [X] A user logs in - [X] A user logs in
- [X] A user lists their events (pagination) - [ ] A user lists their events (pagination)
- [X] A user sees the detail of an event (description, members, amount) - [ ] A user sees the detail of an event (description, members, amount)
- [ ] A user sees the expenses of an event (total amount, personal expenses, pagination) - [ ] A user sees the expenses of an event (total amount, personal expenses, pagination)
- [ ] A user sees the detail of an expense: (time, amount, payers, recipients) - [ ] A user sees the detail of an expense: (time, amount, payers, recipients)
- [ ] A user adds an expense - [ ] A user adds an expense
- [ ] A user updates/changes an expense (may handle some extra access control) - [ ] A user updates/changes an expense (may handle some extra access control)
- [] A user deletes an expense (may handle some extra access control) - [ ] A user can pay the debt to other members (just a special case of expense)
- [] A user restore a deleted expense - [ ] A user creates an event (and participate to it)
- [] A user can pay the debt to other members - [ ] A user updates the event info
- [X] A user creates an event (and participate to it) - [ ] A user invites another user by sending a mail with a token.
- [X] A user updates the event info - [ ] A user joins an event by accepting an invitation
- [X] A user invites another user by sending a mail with a token.
- [X] A user joins an event by accepting an invitation
- [] ~A user quits an event (they cannot actually, but we can make as if they quitted)~
**No we can't quit!**
- [ ] A user cannot see other user's information - [ ] A user cannot see other user's information
- [ ] A user cannot see the events that they didn't participated in. - [ ] A user cannot see the events that they didn't participated in.
For the second stage: For the second stage:
- [ ] A user can archive an event - [ ] A user can archive an event
- [ ] A user deletes an expense (may handle some extra access control)
- [ ] A user restore a deleted expense
- [ ] Audit log for expense updates/deletes
- [ ] ~A user quits an event (they cannot actually, but we can make as if they
quitted)~ **No we can't quit!**
With those functionalities, there will be an usable product. And then we can With those functionalities, there will be an usable product. And then we can
work on other aspects. For example: work on other aspects. For example:
@ -491,3 +492,30 @@ GROUP BY
o.id, o.first_name, o.last_name; o.id, o.first_name, o.last_name;
``` ```
### 2024/10/19
I don't plan to handle deletions at this first stage, but I note down what I
have thought of.
1. Just delete. But keep a replica at the front end of the object that we are
deleting. And propose an option to restore (so a new record is added to the DB)
2. Just delete, but wait. The request is sent to a queue with a timeout of
several seconds, if the user regrets, they can cancel the request. This can be
done on the front, but also on the back. I think it is better to do in on the
front-end.
3. Never deletes. But keep a state in the DB `deleted`. They will just be
ignored when counting.
4. Deletes when doing database cleanup. They lines deleted will be processed
when we cleanup the DB. And they will be definitely deleted at that time.
I can create a audit log table to log all the critical
changes in my `expense` table (update or delete).
Finished with the basic SQL commands. Learned a lot from SQL about `JOIN`,
aggregation and `CTE`. SQL itself has quite amount of things to learn, this
is on my future learning plan!
_I found it quite interesting that simply with SQL, we can simulate the most
business logic. It is a must-have competence for software design and
development._

View File

@ -0,0 +1,72 @@
-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;
-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1;
-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1;
-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING *;
-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1;
-- name: ListExpensesByEventIDByUserID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1;
-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1;

View File

@ -0,0 +1,264 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: expense.sql
package sqlc
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const deleteExpense = `-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1
`
func (q *Queries) DeleteExpense(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteExpense, id)
return err
}
const deleteTransactionsOfExpenseID = `-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1
`
func (q *Queries) DeleteTransactionsOfExpenseID(ctx context.Context, expenseID int32) error {
_, err := q.db.ExecContext(ctx, deleteTransactionsOfExpenseID, expenseID)
return err
}
const getExpenseByID = `-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1
`
type GetExpenseByIDRow struct {
ID int32
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
Payments json.RawMessage
Benefits json.RawMessage
}
func (q *Queries) GetExpenseByID(ctx context.Context, id int32) (GetExpenseByIDRow, error) {
row := q.db.QueryRowContext(ctx, getExpenseByID, id)
var i GetExpenseByIDRow
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
&i.Payments,
&i.Benefits,
)
return i, err
}
const insertExpense = `-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type InsertExpenseParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
}
func (q *Queries) InsertExpense(ctx context.Context, arg InsertExpenseParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, insertExpense,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.EventID,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}
const listExpensesByEventID = `-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1
`
func (q *Queries) ListExpensesByEventID(ctx context.Context, id int32) ([]Expense, error) {
rows, err := q.db.QueryContext(ctx, listExpensesByEventID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Expense
for rows.Next() {
var i Expense
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listExpensesByEventIDByUserID = `-- name: ListExpensesByEventIDByUserID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1
`
func (q *Queries) ListExpensesByEventIDByUserID(ctx context.Context, id int32) ([]Expense, error) {
rows, err := q.db.QueryContext(ctx, listExpensesByEventIDByUserID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Expense
for rows.Next() {
var i Expense
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateExpenseByID = `-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type UpdateExpenseByIDParams struct {
ID int32
UpdatedAt time.Time
Amount int32
Currency string
Name sql.NullString
Place sql.NullString
}
func (q *Queries) UpdateExpenseByID(ctx context.Context, arg UpdateExpenseByIDParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, updateExpenseByID,
arg.ID,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}

View File

@ -34,8 +34,8 @@ type Expense struct {
Amount int32 Amount int32
Currency string Currency string
EventID int32 EventID int32
Name sql.NullInt32 Name sql.NullString
Place sql.NullInt32 Place sql.NullString
} }
type Participation struct { type Participation struct {

View File

@ -0,0 +1,5 @@
-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;

View File

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: transaction.sql
package sqlc
import (
"context"
"time"
)
const insertTransaction = `-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, expense_id, user_id, amount, currency, is_income, created_at, updated_at
`
type InsertTransactionParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
ExpenseID int32
UserID int32
IsIncome bool
}
func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) error {
_, err := q.db.ExecContext(ctx, insertTransaction,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.ExpenseID,
arg.UserID,
arg.IsIncome,
)
return err
}

View File

@ -24,14 +24,38 @@ package model
import "time" import "time"
// {{{ Requrest
type ExpenseRequest struct { type ExpenseRequest struct {
Amount Money `json:"money" binding:"required,number"` Amount Money `json:"money" binding:"required"`
PayerIDs []int `json:"payer_ids" binding:"required"` Payments []Payment `json:"payments" binding:"required"`
RecipientIDs []int `json:"recipient_ids" binding:"required"` Benefits []Benefit `json:"benefits" binding:"required"`
EventID int `json:"event_id" binding:"required"` EventID int `json:"event_id" binding:"required"`
Detail ExpenseDetail `json:"detail"` Detail ExpenseDetail `json:"detail"`
} }
// }}}
// {{{ Response
type ExpensesListResponse struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
}
type ExpenseGetResponse Expense
// }}}
// {{{ Retrieved
type ExpensesListRetrieved ExpensesListResponse
// }}}
// {{{ Entity // {{{ Entity
type ExpenseEntity struct { type ExpenseEntity struct {
@ -48,25 +72,40 @@ type ExpenseEntity struct {
Place string Place string
} }
// }}}
// {{{ Domain Models
type ExpenseDetail struct { type ExpenseDetail struct {
Name string `json:"name"` Name string `json:"name"`
Place string `json:"place"` Place string `json:"place"`
} }
// }}} type Payment struct {
type Expense struct { PayerID int `json:"payer_id" binding:"required,number"`
ID int PayerFirstName string `json:"payer_first_name"`
CreatedAt time.Time PayerLastName string `json:"payer_last_name"`
UpdatedAt time.Time Amount Money `json:"amount" binding:"required"`
Amount Money
// Lazy aggregate using Transaction join
PayerIDs []int
// Lazy aggregate using Transaction join
RecipientIDs []int
EventID int
Detail ExpenseDetail
} }
type Benefit struct {
RecipientID int `json:"recipient_id" binding:"required,number"`
RecipientFirstName string `json:"recipient_first_name"`
RecipientLastName string `json:"recipient_last_name"`
Amount Money `json:"amount" binding:"required"`
}
type Expense struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
Payments []Payment `json:"payments"`
Benefits []Benefit `json:"benefits"`
}
// }}}

View File

@ -1,2 +1 @@
CREATE TABLE "transaction";

View File

@ -6,8 +6,8 @@ CREATE TABLE "expense" (
"amount" integer NOT NULL, "amount" integer NOT NULL,
"currency" character varying NOT NULL, "currency" character varying NOT NULL,
"event_id" integer NOT NULL, "event_id" integer NOT NULL,
"name" integer NULL, "name" character varying(255) NULL,
"place" integer NULL "place" character varying(1000) NULL
); );
ALTER TABLE "expense" ALTER TABLE "expense"

View File

@ -0,0 +1 @@
DROP TABLE transaction;