Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 132 additions & 3 deletions backend/bills/internal/adapter/sqlite/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,50 @@ func (r *Repository) GetBillCommitteeStage(ctx context.Context, id string) (*dom
}, nil
}

func (r *Repository) GetBillVersionDiff(ctx context.Context, id, fromVersionID, toVersionID string) (*domain.BillVersionDiff, error) {
billID, err := r.lookupBillID(ctx, id)
if err != nil {
return nil, err
}
if ok, err := r.tableExists(ctx, "bill_diffs"); err != nil || !ok {
return nil, err
}
if ok, err := r.tableExists(ctx, "bill_clause_diffs"); err != nil || !ok {
return nil, err
}

var diffID string
err = r.db.QueryRowContext(ctx, `
SELECT id
FROM bill_diffs
WHERE bill_id = ? AND from_version_id = ? AND to_version_id = ?
LIMIT 1`, billID, fromVersionID, toVersionID).Scan(&diffID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query bill diff sqlite artifact: %w", err)
}

fromVersion, ok, err := r.billVersionByID(ctx, billID, fromVersionID)
if err != nil || !ok {
return nil, err
}
toVersion, ok, err := r.billVersionByID(ctx, billID, toVersionID)
if err != nil || !ok {
return nil, err
}
clauses, err := r.billClauseDiffs(ctx, billID, diffID)
if err != nil {
return nil, err
}
return &domain.BillVersionDiff{
From: fromVersion,
To: toVersion,
Clauses: clauses,
}, nil
}

func (r *Repository) queryBills(ctx context.Context, query string, args ...any) ([]domain.Bill, error) {
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
Expand Down Expand Up @@ -396,12 +440,12 @@ func (r *Repository) billVersions(ctx context.Context, billID string) ([]domain.
WHERE %s = ?
ORDER BY %s`,
columnExpr(columns, "id", "version_id"),
columnExpr(columns, "label", "version_label", "name"),
columnExpr(columns, "label", "version_label", "name", "stage"),
columnExpr(columns, "title"),
columnExpr(columns, "stage"),
columnExpr(columns, "chamber"),
columnExpr(columns, "published_on", "version_date", "date"),
columnExpr(columns, "source_url", "url"),
columnExpr(columns, "published_on", "published_date", "version_date", "date"),
columnExpr(columns, "source_url", "html_url", "url", "text_source_url", "xml_url", "pdf_url"),
billIDColumn,
orderExpr(columns),
)
Expand Down Expand Up @@ -433,6 +477,91 @@ func (r *Repository) billVersions(ctx context.Context, billID string) ([]domain.
return versions, nil
}

func (r *Repository) billVersionByID(ctx context.Context, billID, versionID string) (domain.BillVersion, bool, error) {
columns, ok, err := r.tableColumns(ctx, "bill_versions")
if err != nil || !ok {
return domain.BillVersion{}, false, err
}
billIDColumn := firstColumn(columns, "bill_id", "legisinfo_id")
versionIDColumn := firstColumn(columns, "id", "version_id")
if billIDColumn == "" || versionIDColumn == "" {
return domain.BillVersion{}, false, nil
}
query := fmt.Sprintf(`
SELECT %s, %s, %s, %s, %s, %s, %s
FROM bill_versions
WHERE %s = ? AND %s = ?
LIMIT 1`,
columnExpr(columns, "id", "version_id"),
columnExpr(columns, "label", "version_label", "name", "stage"),
columnExpr(columns, "title"),
columnExpr(columns, "stage"),
columnExpr(columns, "chamber"),
columnExpr(columns, "published_on", "published_date", "version_date", "date"),
columnExpr(columns, "source_url", "html_url", "url", "text_source_url", "xml_url", "pdf_url"),
billIDColumn,
versionIDColumn,
)

var version domain.BillVersion
var id, label, title, stage, chamber, publishedOn, sourceURL sql.NullString
err = r.db.QueryRowContext(ctx, query, billID, versionID).Scan(
&id,
&label,
&title,
&stage,
&chamber,
&publishedOn,
&sourceURL,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.BillVersion{}, false, nil
}
if err != nil {
return domain.BillVersion{}, false, fmt.Errorf("query bill version sqlite artifact: %w", err)
}
version.ID = stringValue(id)
version.Label = stringValue(label)
version.Title = stringValue(title)
version.Stage = stringValue(stage)
version.Chamber = stringValue(chamber)
version.PublishedOn = stringPtr(publishedOn)
version.SourceURL = stringValue(sourceURL)
return version, true, nil
}

func (r *Repository) billClauseDiffs(ctx context.Context, billID, diffID string) ([]domain.BillClauseDiff, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, label, change_type, from_text, to_text, hansard_anchor_url
FROM bill_clause_diffs
WHERE bill_id = ? AND diff_id = ?
ORDER BY sort_order, rowid`, billID, diffID)
if err != nil {
return nil, fmt.Errorf("query bill clause diffs sqlite artifact: %w", err)
}
defer rows.Close()

clauses := make([]domain.BillClauseDiff, 0)
for rows.Next() {
var clause domain.BillClauseDiff
var id, label, changeType, fromText, toText, hansardAnchorURL sql.NullString
if err := rows.Scan(&id, &label, &changeType, &fromText, &toText, &hansardAnchorURL); err != nil {
return nil, fmt.Errorf("scan bill clause diffs sqlite artifact: %w", err)
}
clause.ID = stringValue(id)
clause.Label = stringValue(label)
clause.ChangeType = stringValue(changeType)
clause.FromText = stringValue(fromText)
clause.ToText = stringValue(toText)
clause.HansardAnchorURL = stringPtr(hansardAnchorURL)
clauses = append(clauses, clause)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate bill clause diffs sqlite artifact: %w", err)
}
return clauses, nil
}

func (r *Repository) billAmendments(ctx context.Context, billID string) ([]domain.BillAmendment, error) {
columns, ok, err := r.tableColumns(ctx, "bill_amendments")
if err != nil || !ok {
Expand Down
161 changes: 161 additions & 0 deletions backend/bills/internal/adapter/sqlite/repository_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package sqlite

import (
"context"
"database/sql"
"errors"
"testing"

"epac/bills/internal/usecase"

_ "modernc.org/sqlite"
)

func TestRepositoryGetBillVersionDiffMapsCurrentArtifactSchema(t *testing.T) {
db := openDiffFixtureDB(t)
defer db.Close()
repo := New(db)

diff, err := repo.GetBillVersionDiff(context.Background(), "C-2", "v1", "v2")
if err != nil {
t.Fatalf("GetBillVersionDiff error: %v", err)
}
if diff == nil {
t.Fatal("diff = nil")
}
if diff.From.ID != "v1" || diff.From.Label != "First Reading" || diff.From.Stage != "First Reading" {
t.Fatalf("from version = %+v", diff.From)
}
if diff.From.PublishedOn == nil || *diff.From.PublishedOn != "2026-06-01" {
t.Fatalf("from published_on = %+v", diff.From.PublishedOn)
}
if diff.From.SourceURL != "https://www.parl.ca/v1" {
t.Fatalf("from source_url = %q", diff.From.SourceURL)
}
if diff.To.ID != "v2" || diff.To.Label != "Third Reading" {
t.Fatalf("to version = %+v", diff.To)
}
if len(diff.Clauses) != 2 {
t.Fatalf("clauses = %+v", diff.Clauses)
}
if diff.Clauses[0].ID != "clause-1" || diff.Clauses[0].ChangeType != "added" || diff.Clauses[0].FromText != "" {
t.Fatalf("first clause = %+v", diff.Clauses[0])
}
if diff.Clauses[1].ID != "clause-2" || diff.Clauses[1].HansardAnchorURL == nil || *diff.Clauses[1].HansardAnchorURL != "https://hansard.test/clause-2" {
t.Fatalf("second clause = %+v", diff.Clauses[1])
}
}

func TestRepositoryGetBillVersionDiffReturnsNilForUnknownVersionPair(t *testing.T) {
db := openDiffFixtureDB(t)
defer db.Close()
repo := New(db)

diff, err := repo.GetBillVersionDiff(context.Background(), "C-2", "v1", "missing")
if err != nil {
t.Fatalf("GetBillVersionDiff error: %v", err)
}
if diff != nil {
t.Fatalf("diff = %+v, want nil", diff)
}
}

func TestRepositoryGetBillVersionDiffReturnsNilWhenDiffTablesAreAbsent(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
db.SetMaxOpenConns(1)
defer db.Close()
if _, err := db.Exec(`
CREATE TABLE bills (id TEXT PRIMARY KEY, number TEXT NOT NULL);
INSERT INTO bills (id, number) VALUES ('13543613', 'C-2');
`); err != nil {
t.Fatalf("seed db: %v", err)
}
repo := New(db)

diff, err := repo.GetBillVersionDiff(context.Background(), "C-2", "v1", "v2")
if err != nil {
t.Fatalf("GetBillVersionDiff error: %v", err)
}
if diff != nil {
t.Fatalf("diff = %+v, want nil", diff)
}
}

func TestRepositoryGetBillVersionDiffReturnsBillNotFound(t *testing.T) {
db := openDiffFixtureDB(t)
defer db.Close()
repo := New(db)

diff, err := repo.GetBillVersionDiff(context.Background(), "C-404", "v1", "v2")
if !errors.Is(err, usecase.ErrBillNotFound) {
t.Fatalf("error = %v, want ErrBillNotFound", err)
}
if diff != nil {
t.Fatalf("diff = %+v, want nil", diff)
}
}

func openDiffFixtureDB(t *testing.T) *sql.DB {
t.Helper()

db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
db.SetMaxOpenConns(1)
statements := []string{
`CREATE TABLE bills (
id TEXT PRIMARY KEY,
number TEXT NOT NULL
)`,
`CREATE TABLE bill_versions (
bill_id TEXT NOT NULL,
id TEXT NOT NULL,
stage TEXT NOT NULL DEFAULT '',
html_url TEXT NOT NULL DEFAULT '',
published_date TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE bill_diffs (
bill_id TEXT NOT NULL,
id TEXT NOT NULL,
from_version_id TEXT NOT NULL,
to_version_id TEXT NOT NULL,
source_url TEXT NOT NULL DEFAULT '',
PRIMARY KEY (bill_id, id)
)`,
`CREATE TABLE bill_clause_diffs (
bill_id TEXT NOT NULL,
diff_id TEXT NOT NULL,
id TEXT NOT NULL,
label TEXT,
change_type TEXT NOT NULL,
from_text TEXT,
to_text TEXT,
hansard_anchor_url TEXT,
sort_order INTEGER NOT NULL,
PRIMARY KEY (bill_id, diff_id, id)
)`,
`INSERT INTO bills (id, number) VALUES ('13543613', 'C-2')`,
`INSERT INTO bill_versions (bill_id, id, stage, html_url, published_date, sort_order) VALUES
('13543613', 'v1', 'First Reading', 'https://www.parl.ca/v1', '2026-06-01', 1),
('13543613', 'v2', 'Third Reading', 'https://www.parl.ca/v2', '2026-06-10', 2)`,
`INSERT INTO bill_diffs (bill_id, id, from_version_id, to_version_id, source_url)
VALUES ('13543613', 'diff-1', 'v1', 'v2', 'https://www.parl.ca/diff')`,
`INSERT INTO bill_clause_diffs (
bill_id, diff_id, id, label, change_type, from_text, to_text, hansard_anchor_url, sort_order
) VALUES
('13543613', 'diff-1', 'clause-2', 'Clause 2', 'modified', 'Old text', 'New text', 'https://hansard.test/clause-2', 2),
('13543613', 'diff-1', 'clause-1', 'Clause 1', 'added', NULL, 'Inserted text', NULL, 1)`,
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
_ = db.Close()
t.Fatalf("exec fixture statement: %v", err)
}
}
return db
}
15 changes: 15 additions & 0 deletions backend/bills/internal/domain/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ type BillAmendment struct {
SourceURL string `json:"source_url,omitempty"`
}

type BillClauseDiff struct {
ID string `json:"id,omitempty"`
Label string `json:"label,omitempty"`
ChangeType string `json:"change_type"`
FromText string `json:"from_text"`
ToText string `json:"to_text"`
HansardAnchorURL *string `json:"hansard_anchor_url,omitempty"`
}

type BillVersionDiff struct {
From BillVersion `json:"from"`
To BillVersion `json:"to"`
Clauses []BillClauseDiff `json:"clauses"`
}

type ParliamentaryCommittee struct {
Code string `json:"code"`
Name string `json:"name"`
Expand Down
39 changes: 39 additions & 0 deletions backend/bills/internal/usecase/bills.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type BillRepository interface {
ListBills(ctx context.Context) ([]domain.Bill, error)
GetBillDepth(ctx context.Context, id string) (domain.Bill, error)
GetBillCommitteeStage(ctx context.Context, id string) (*domain.BillCommitteeStage, error)
GetBillVersionDiff(ctx context.Context, id, fromVersionID, toVersionID string) (*domain.BillVersionDiff, error)
}

type ListBillsInput struct {
Expand Down Expand Up @@ -68,6 +69,44 @@ func (u *GetBillCommitteeStage) Execute(ctx context.Context, id string) (*domain
return u.repo.GetBillCommitteeStage(ctx, id)
}

type LoadBillVersionDiffInput struct {
BillID string
FromVersionID string
ToVersionID string
}

type LoadBillVersionDiff struct {
repo BillRepository
}

func NewLoadBillVersionDiff(repo BillRepository) *LoadBillVersionDiff {
return &LoadBillVersionDiff{repo: repo}
}

func (u *LoadBillVersionDiff) Execute(ctx context.Context, input LoadBillVersionDiffInput) (*domain.BillVersionDiff, error) {
billID := strings.TrimSpace(input.BillID)
if billID == "" {
return nil, ErrBillNotFound
}
fromVersionID := strings.TrimSpace(input.FromVersionID)
if fromVersionID == "" {
return nil, ErrDiffMissingFrom
}
toVersionID := strings.TrimSpace(input.ToVersionID)
if toVersionID == "" {
return nil, ErrDiffMissingTo
}

diff, err := u.repo.GetBillVersionDiff(ctx, billID, fromVersionID, toVersionID)
if err != nil || diff == nil {
return diff, err
}
if len(diff.Clauses) == 0 {
return nil, nil
}
return diff, nil
}

var normalizeRe = regexp.MustCompile(`[^a-z0-9]+`)

func filterBills(bills []domain.Bill, statusFilter, parliamentFilter string) []domain.Bill {
Expand Down
Loading
Loading