summaryrefslogtreecommitdiff
path: root/tools/patman/database.py
blob: 9c25b04a72012a0668b7ec55e3a9f0e374b2567c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
# SPDX-License-Identifier: GPL-2.0+
#
# Copyright 2025 Simon Glass <sjg@chromium.org>
#
"""Handles the patman database

This uses sqlite3 with a local file.

To adjsut the schema, increment LATEST, create a migrate_to_v<x>() function
and write some code in migrate_to() to call it.
"""

from collections import namedtuple, OrderedDict
import os
import sqlite3

from u_boot_pylib import tools
from u_boot_pylib import tout
from patman.series import Series

# Schema version (version 0 means there is no database yet)
LATEST = 4

# Information about a series/version record
SerVer = namedtuple(
    'SER_VER',
    'idnum,series_id,version,link,cover_id,cover_num_comments,name,'
    'archive_tag')

# Record from the pcommit table:
# idnum (int): record ID
# seq (int): Patch sequence in series (0 is first)
# subject (str): patch subject
# svid (int): ID of series/version record in ser_ver table
# change_id (str): Change-ID value
# state (str): Current status in patchwork
# patch_id (int): Patchwork's patch ID for this patch
# num_comments (int): Number of comments attached to the commit
Pcommit = namedtuple(
    'PCOMMIT',
    'idnum,seq,subject,svid,change_id,state,patch_id,num_comments')


class Database:
    """Database of information used by patman"""

    # dict of databases:
    #   key: filename
    #   value: Database object
    instances = {}

    def __init__(self, db_path):
        """Set up a new database object

        Args:
            db_path (str): Path to the database
        """
        if db_path in Database.instances:
            # Two connections to the database can cause:
            # sqlite3.OperationalError: database is locked
            raise ValueError(f"There is already a database for '{db_path}'")
        self.con = None
        self.cur = None
        self.db_path = db_path
        self.is_open = False
        Database.instances[db_path] = self

    @staticmethod
    def get_instance(db_path):
        """Get the database instance for a path

        This is provides to ensure that different callers can obtain the
        same database object when accessing the same database file.

        Args:
            db_path (str): Path to the database

        Return:
            Database: Database instance, which is created if necessary
        """
        db = Database.instances.get(db_path)
        if db:
            return db, False
        return Database(db_path), True

    def start(self):
        """Open the database read for use, migrate to latest schema"""
        self.open_it()
        self.migrate_to(LATEST)

    def open_it(self):
        """Open the database, creating it if necessary"""
        if self.is_open:
            raise ValueError('Already open')
        if not os.path.exists(self.db_path):
            tout.warning(f'Creating new database {self.db_path}')
        self.con = sqlite3.connect(self.db_path)
        self.cur = self.con.cursor()
        self.is_open = True

    def close(self):
        """Close the database"""
        if not self.is_open:
            raise ValueError('Already closed')
        self.con.close()
        self.cur = None
        self.con = None
        self.is_open = False

    def create_v1(self):
        """Create a database with the v1 schema"""
        self.cur.execute(
            'CREATE TABLE series (id INTEGER PRIMARY KEY AUTOINCREMENT,'
            'name UNIQUE, desc, archived BIT)')

        # Provides a series_id/version pair, which is used to refer to a
        # particular series version sent to patchwork. This stores the link
        # to patchwork
        self.cur.execute(
            'CREATE TABLE ser_ver (id INTEGER PRIMARY KEY AUTOINCREMENT,'
            'series_id INTEGER, version INTEGER, link,'
            'FOREIGN KEY (series_id) REFERENCES series (id))')

        self.cur.execute(
            'CREATE TABLE upstream (name UNIQUE, url, is_default BIT)')

        # change_id is the Change-Id
        # patch_id is the ID of the patch on the patchwork server
        self.cur.execute(
            'CREATE TABLE pcommit (id INTEGER PRIMARY KEY AUTOINCREMENT,'
            'svid INTEGER, seq INTEGER, subject, patch_id INTEGER, '
            'change_id, state, num_comments INTEGER, '
            'FOREIGN KEY (svid) REFERENCES ser_ver (id))')

        self.cur.execute(
            'CREATE TABLE settings (name UNIQUE, proj_id INT, link_name)')

    def _migrate_to_v2(self):
        """Add a schema_version table"""
        self.cur.execute('CREATE TABLE schema_version (version INTEGER)')

    def _migrate_to_v3(self):
        """Store the number of cover-letter comments in the schema"""
        self.cur.execute('ALTER TABLE ser_ver ADD COLUMN cover_id')
        self.cur.execute('ALTER TABLE ser_ver ADD COLUMN cover_num_comments '
                         'INTEGER')
        self.cur.execute('ALTER TABLE ser_ver ADD COLUMN name')

    def _migrate_to_v4(self):
        """Add an archive tag for each ser_ver"""
        self.cur.execute('ALTER TABLE ser_ver ADD COLUMN archive_tag')

    def migrate_to(self, dest_version):
        """Migrate the database to the selected version

        Args:
            dest_version (int): Version to migrate to
        """
        while True:
            version = self.get_schema_version()
            if version == dest_version:
                break

            self.close()
            tools.write_file(f'{self.db_path}old.v{version}',
                             tools.read_file(self.db_path))

            version += 1
            tout.info(f'Update database to v{version}')
            self.open_it()
            if version == 1:
                self.create_v1()
            elif version == 2:
                self._migrate_to_v2()
            elif version == 3:
                self._migrate_to_v3()
            elif version == 4:
                self._migrate_to_v4()

            # Save the new version if we have a schema_version table
            if version > 1:
                self.cur.execute('DELETE FROM schema_version')
                self.cur.execute(
                    'INSERT INTO schema_version (version) VALUES (?)',
                    (version,))
            self.commit()

    def get_schema_version(self):
        """Get the version of the database's schema

        Return:
            int: Database version, 0 means there is no data; anything less than
                LATEST means the schema is out of date and must be updated
        """
        # If there is no database at all, assume v0
        version = 0
        try:
            self.cur.execute('SELECT name FROM series')
        except sqlite3.OperationalError:
            return 0

        # If there is no schema, assume v1
        try:
            self.cur.execute('SELECT version FROM schema_version')
            version = self.cur.fetchone()[0]
        except sqlite3.OperationalError:
            return 1
        return version

    def execute(self, query, parameters=()):
        """Execute a database query

        Args:
            query (str): Query string
            parameters (list of values): Parameters to pass

        Return:

        """
        return self.cur.execute(query, parameters)

    def commit(self):
        """Commit changes to the database"""
        self.con.commit()

    def rollback(self):
        """Roll back changes to the database"""
        self.con.rollback()

    def lastrowid(self):
        """Get the last row-ID reported by the database

        Return:
            int: Value for lastrowid
        """
        return self.cur.lastrowid

    def rowcount(self):
        """Get the row-count reported by the database

        Return:
            int: Value for rowcount
        """
        return self.cur.rowcount

    def _get_series_list(self, include_archived):
        """Get a list of Series objects from the database

        Args:
            include_archived (bool): True to include archives series

        Return:
            list of Series
        """
        res = self.execute(
            'SELECT id, name, desc FROM series ' +
            ('WHERE archived = 0' if not include_archived else ''))
        return [Series.from_fields(idnum=idnum, name=name, desc=desc)
                for idnum, name, desc in res.fetchall()]

    # series functions

    def series_get_dict_by_id(self, include_archived=False):
        """Get a dict of Series objects from the database

        Args:
            include_archived (bool): True to include archives series

        Return:
            OrderedDict:
                key: series ID
                value: Series with idnum, name and desc filled out
        """
        sdict = OrderedDict()
        for ser in self._get_series_list(include_archived):
            sdict[ser.idnum] = ser
        return sdict

    def series_find_by_name(self, name, include_archived=False):
        """Find a series and return its details

        Args:
            name (str): Name to search for
            include_archived (bool): True to include archives series

        Returns:
            idnum, or None if not found
        """
        res = self.execute(
            'SELECT id FROM series WHERE name = ?' +
            ('AND archived = 0' if not include_archived else ''), (name,))
        recs = res.fetchall()

        # This shouldn't happen
        assert len(recs) <= 1, 'Expected one match, but multiple found'

        if len(recs) != 1:
            return None
        return recs[0][0]

    def series_get_info(self, idnum):
        """Get information for a series from the database

        Args:
            idnum (int): Series ID to look up

        Return: tuple:
            str: Series name
            str: Series description

        Raises:
            ValueError: Series is not found
        """
        res = self.execute('SELECT name, desc FROM series WHERE id = ?',
                           (idnum,))
        recs = res.fetchall()
        if len(recs) != 1:
            raise ValueError(f'No series found (id {idnum} len {len(recs)})')
        return recs[0]

    def series_get_dict(self, include_archived=False):
        """Get a dict of Series objects from the database

        Args:
            include_archived (bool): True to include archives series

        Return:
            OrderedDict:
                key: series name
                value: Series with idnum, name and desc filled out
        """
        sdict = OrderedDict()
        for ser in self._get_series_list(include_archived):
            sdict[ser.name] = ser
        return sdict

    def series_get_version_list(self, series_idnum):
        """Get a list of the versions available for a series

        Args:
            series_idnum (int): ID of series to look up

        Return:
            str: List of versions, which may be empty if the series is in the
                process of being added
        """
        res = self.execute('SELECT version FROM ser_ver WHERE series_id = ?',
                           (series_idnum,))
        return [x[0] for x in res.fetchall()]

    def series_get_max_version(self, series_idnum):
        """Get the highest version number available for a series

        Args:
            series_idnum (int): ID of series to look up

        Return:
            int: Maximum version number
        """
        res = self.execute(
            'SELECT MAX(version) FROM ser_ver WHERE series_id = ?',
            (series_idnum,))
        return res.fetchall()[0][0]

    def series_get_all_max_versions(self):
        """Find the latest version of all series

        Return: list of:
            int: ser_ver ID
            int: series ID
            int: Maximum version
        """
        res = self.execute(
            'SELECT id, series_id, MAX(version) FROM ser_ver '
            'GROUP BY series_id')
        return res.fetchall()

    def series_add(self, name, desc):
        """Add a new series record

        The new record is set to not archived

        Args:
            name (str): Series name
            desc (str): Series description

        Return:
            int: ID num of the new series record
        """
        self.execute(
            'INSERT INTO series (name, desc, archived) '
            f"VALUES ('{name}', '{desc}', 0)")
        return self.lastrowid()

    def series_remove(self, idnum):
        """Remove a series from the database

        The series must exist

        Args:
            idnum (int): ID num of series to remove
        """
        self.execute('DELETE FROM series WHERE id = ?', (idnum,))
        assert self.rowcount() == 1

    def series_remove_by_name(self, name):
        """Remove a series from the database

        Args:
            name (str): Name of series to remove

        Raises:
            ValueError: Series does not exist (database is rolled back)
        """
        self.execute('DELETE FROM series WHERE name = ?', (name,))
        if self.rowcount() != 1:
            self.rollback()
            raise ValueError(f"No such series '{name}'")

    def series_set_archived(self, series_idnum, archived):
        """Update archive flag for a series

        Args:
            series_idnum (int): ID num of the series
            archived (bool): Whether to mark the series as archived or
                unarchived
        """
        self.execute(
            'UPDATE series SET archived = ? WHERE id = ?',
            (archived, series_idnum))

    def series_set_name(self, series_idnum, name):
        """Update name for a series

        Args:
            series_idnum (int): ID num of the series
            name (str): new name to use
        """
        self.execute(
            'UPDATE series SET name = ? WHERE id = ?', (name, series_idnum))

    # ser_ver functions

    def ser_ver_get_link(self, series_idnum, version):
        """Get the link for a series version

        Args:
            series_idnum (int): ID num of the series
            version (int): Version number to search for

        Return:
            str: Patchwork link as a string, e.g. '12325', or None if none

        Raises:
            ValueError: Multiple matches are found
        """
        res = self.execute(
            'SELECT link FROM ser_ver WHERE '
            f"series_id = {series_idnum} AND version = '{version}'")
        recs = res.fetchall()
        if not recs:
            return None
        if len(recs) > 1:
            raise ValueError('Expected one match, but multiple matches found')
        return recs[0][0]

    def ser_ver_set_link(self, series_idnum, version, link):
        """Set the link for a series version

        Args:
            series_idnum (int): ID num of the series
            version (int): Version number to search for
            link (str): Patchwork link for the ser_ver

        Return:
            bool: True if the record was found and updated, else False
        """
        if link is None:
            link = ''
        self.execute(
            'UPDATE ser_ver SET link = ? WHERE series_id = ? AND version = ?',
            (str(link), series_idnum, version))
        return self.rowcount() != 0

    def ser_ver_set_info(self, info):
        """Set the info for a series version

        Args:
            info (SER_VER): Info to set. Only two options are supported:
                1: svid,cover_id,cover_num_comments,name
                2: svid,name

        Return:
            bool: True if the record was found and updated, else False
        """
        assert info.idnum is not None
        if info.cover_id:
            assert info.series_id is None
            self.execute(
                'UPDATE ser_ver SET cover_id = ?, cover_num_comments = ?, '
                'name = ? WHERE id = ?',
                (info.cover_id, info.cover_num_comments, info.name,
                 info.idnum))
        else:
            assert not info.cover_id
            assert not info.cover_num_comments
            assert not info.series_id
            assert not info.version
            assert not info.link
            self.execute('UPDATE ser_ver SET name = ? WHERE id = ?',
                         (info.name, info.idnum))

        return self.rowcount() != 0

    def ser_ver_set_version(self, svid, version):
        """Sets the version for a ser_ver record

        Args:
            svid (int): Record ID to update
            version (int): Version number to add

        Raises:
            ValueError: svid was not found
        """
        self.execute(
            'UPDATE ser_ver SET version = ? WHERE id = ?', (version, svid))
        if self.rowcount() != 1:
            raise ValueError(f'No ser_ver updated (svid {svid})')

    def ser_ver_set_archive_tag(self, svid, tag):
        """Sets the archive tag for a ser_ver record

        Args:
            svid (int): Record ID to update
            tag (tag): Tag to add

        Raises:
            ValueError: svid was not found
        """
        self.execute(
            'UPDATE ser_ver SET archive_tag = ? WHERE id = ?', (tag, svid))
        if self.rowcount() != 1:
            raise ValueError(f'No ser_ver updated (svid {svid})')

    def ser_ver_add(self, series_idnum, version, link=None):
        """Add a new ser_ver record

        Args:
            series_idnum (int): ID num of the series which is getting a new
                version
            version (int): Version number to add
            link (str): Patchwork link, or None if not known

        Return:
            int: ID num of the new ser_ver record
        """
        self.execute(
            'INSERT INTO ser_ver (series_id, version, link) VALUES (?, ?, ?)',
            (series_idnum, version, link))
        return self.lastrowid()

    def ser_ver_get_for_series(self, series_idnum, version=None):
        """Get a list of ser_ver records for a given series ID

        Args:
            series_idnum (int): ID num of the series to search
            version (int): Version number to search for, or None for all

        Return:
            SER_VER: Requested information

        Raises:
            ValueError: There is no matching idnum/version
        """
        base = ('SELECT id, series_id, version, link, cover_id, '
                'cover_num_comments, name, archive_tag FROM ser_ver '
                'WHERE series_id = ?')
        if version:
            res = self.execute(base + ' AND version = ?',
                               (series_idnum, version))
        else:
            res = self.execute(base, (series_idnum,))
        recs = res.fetchall()
        if not recs:
            raise ValueError(
                f'No matching series for id {series_idnum} version {version}')
        if version:
            return SerVer(*recs[0])
        return [SerVer(*x) for x in recs]

    def ser_ver_get_ids_for_series(self, series_idnum, version=None):
        """Get a list of ser_ver records for a given series ID

        Args:
            series_idnum (int): ID num of the series to search
            version (int): Version number to search for, or None for all

        Return:
            list of int: List of svids for the matching records
        """
        if version:
            res = self.execute(
                'SELECT id FROM ser_ver WHERE series_id = ? AND version = ?',
                (series_idnum, version))
        else:
            res = self.execute(
                'SELECT id FROM ser_ver WHERE series_id = ?', (series_idnum,))
        return list(res.fetchall()[0])

    def ser_ver_get_list(self):
        """Get a list of patchwork entries from the database

        Return:
            list of SER_VER
        """
        res = self.execute(
            'SELECT id, series_id, version, link, cover_id, '
            'cover_num_comments, name, archive_tag FROM ser_ver')
        items = res.fetchall()
        return [SerVer(*x) for x in items]

    def ser_ver_remove(self, series_idnum, version=None, remove_pcommits=True,
                       remove_series=True):
        """Delete a ser_ver record

        Removes the record which has the given series ID num and version

        Args:
            series_idnum (int): ID num of the series
            version (int): Version number, or None to remove all versions
            remove_pcommits (bool): True to remove associated pcommits too
            remove_series (bool): True to remove the series if versions is None
        """
        if remove_pcommits:
            # Figure out svids to delete
            svids = self.ser_ver_get_ids_for_series(series_idnum, version)

            self.pcommit_delete_list(svids)

        if version:
            self.execute(
                'DELETE FROM ser_ver WHERE series_id = ? AND version = ?',
                (series_idnum, version))
        else:
            self.execute(
                'DELETE FROM ser_ver WHERE series_id = ?',
                (series_idnum,))
        if not version and remove_series:
            self.series_remove(series_idnum)

    # pcommit functions

    def pcommit_get_list(self, find_svid=None):
        """Get a dict of pcommits entries from the database

        Args:
            find_svid (int): If not None, finds the records associated with a
                particular series and version; otherwise returns all records

        Return:
            list of PCOMMIT: pcommit records
        """
        query = ('SELECT id, seq, subject, svid, change_id, state, patch_id, '
                 'num_comments FROM pcommit')
        if find_svid is not None:
            query += f' WHERE svid = {find_svid}'
        res = self.execute(query)
        return [Pcommit(*rec) for rec in res.fetchall()]

    def pcommit_add_list(self, svid, pcommits):
        """Add records to the pcommit table

        Args:
            svid (int): ser_ver ID num
            pcommits (list of PCOMMIT): Only seq, subject, change_id are
                uses; svid comes from the argument passed in and the others
                are assumed to be obtained from patchwork later
        """
        for pcm in pcommits:
            self.execute(
                'INSERT INTO pcommit (svid, seq, subject, change_id) VALUES '
                '(?, ?, ?, ?)', (svid, pcm.seq, pcm.subject, pcm.change_id))

    def pcommit_delete(self, svid):
        """Delete pcommit records for a given ser_ver ID

        Args_:
            svid (int): ser_ver ID num of records to delete
        """
        self.execute('DELETE FROM pcommit WHERE svid = ?', (svid,))

    def pcommit_delete_list(self, svid_list):
        """Delete pcommit records for a given set of ser_ver IDs

        Args_:
            svid (list int): ser_ver ID nums of records to delete
        """
        vals = ', '.join([str(x) for x in svid_list])
        self.execute('DELETE FROM pcommit WHERE svid IN (?)', (vals,))

    def pcommit_update(self, pcm):
        """Update a pcommit record

        Args:
            pcm (PCOMMIT): Information to write; only the idnum, state,
                patch_id and num_comments are used

        Return:
            True if the data was written
        """
        self.execute(
            'UPDATE pcommit SET '
            'patch_id = ?, state = ?, num_comments = ? WHERE id = ?',
            (pcm.patch_id, pcm.state, pcm.num_comments, pcm.idnum))
        return self.rowcount() > 0

    # upstream functions

    def upstream_add(self, name, url):
        """Add a new upstream record

        Args:
            name (str): Name of the tree
            url (str): URL for the tree

        Raises:
            ValueError if the name already exists in the database
        """
        try:
            self.execute(
                'INSERT INTO upstream (name, url) VALUES (?, ?)', (name, url))
        except sqlite3.IntegrityError as exc:
            if 'UNIQUE constraint failed: upstream.name' in str(exc):
                raise ValueError(f"Upstream '{name}' already exists") from exc

    def upstream_set_default(self, name):
        """Mark (only) the given upstream as the default

        Args:
            name (str): Name of the upstream remote to set as default, or None

        Raises:
            ValueError if more than one name matches (should not happen);
                database is rolled back
        """
        self.execute("UPDATE upstream SET is_default = 0")
        if name is not None:
            self.execute(
                'UPDATE upstream SET is_default = 1 WHERE name = ?', (name,))
            if self.rowcount() != 1:
                self.rollback()
                raise ValueError(f"No such upstream '{name}'")

    def upstream_get_default(self):
        """Get the name of the default upstream

        Return:
            str: Default-upstream name, or None if there is no default
        """
        res = self.execute(
            "SELECT name FROM upstream WHERE is_default = 1")
        recs = res.fetchall()
        if len(recs) != 1:
            return None
        return recs[0][0]

    def upstream_delete(self, name):
        """Delete an upstream target

        Args:
            name (str): Name of the upstream remote to delete

        Raises:
            ValueError: Upstream does not exist (database is rolled back)
        """
        self.execute(f"DELETE FROM upstream WHERE name = '{name}'")
        if self.rowcount() != 1:
            self.rollback()
            raise ValueError(f"No such upstream '{name}'")

    def upstream_get_dict(self):
        """Get a list of upstream entries from the database

        Return:
            OrderedDict:
                key (str): upstream name
                value (str): url
        """
        res = self.execute('SELECT name, url, is_default FROM upstream')
        udict = OrderedDict()
        for name, url, is_default in res.fetchall():
            udict[name] = url, is_default
        return udict

    # settings functions

    def settings_update(self, name, proj_id, link_name):
        """Set the patchwork settings of the project

        Args:
            name (str): Name of the project to use in patchwork
            proj_id (int): Project ID for the project
            link_name (str): Link name for the project
        """
        self.execute('DELETE FROM settings')
        self.execute(
                'INSERT INTO settings (name, proj_id, link_name) '
                'VALUES (?, ?, ?)', (name, proj_id, link_name))

    def settings_get(self):
        """Get the patchwork settings of the project

        Returns:
            tuple or None if there are no settings:
                name (str): Project name, e.g. 'U-Boot'
                proj_id (int): Patchworks project ID for this project
                link_name (str): Patchwork's link-name for the project
        """
        res = self.execute("SELECT name, proj_id, link_name FROM settings")
        recs = res.fetchall()
        if len(recs) != 1:
            return None
        return recs[0]