diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 090411660a2873cb98769b2b68f10f86c2935718..d0066139e34ece2e4a1dd3b143c0b9b04d6b718b 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -61,6 +61,12 @@ from .storage_common import ( required_passes, ) +from .schema import ( + get_schema_version, + get_schema_upgrades, + run_schema_upgrades, +) + class ILeaseMaintenanceObserver(Interface): """ @@ -88,13 +94,9 @@ class StoreOpenError(Exception): self.reason = reason -class SchemaError(TypeError): - pass - - CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3" -def open_and_initialize(path, required_schema_version, connect=None): +def open_and_initialize(path, connect=None): """ Open a SQLite3 database for use as a voucher store. @@ -103,13 +105,6 @@ def open_and_initialize(path, required_schema_version, connect=None): :param FilePath path: The location of the SQLite3 database file. - :param int required_schema_version: The schema version which must be - present in the database in order for a SQLite3 connection to be - returned. - - :raise SchemaError: If the schema in the database does not match the - required schema version. - :return: A SQLite3 connection object for the database at the given path. """ if connect is None: @@ -135,86 +130,9 @@ def open_and_initialize(path, required_schema_version, connect=None): with conn: cursor = conn.cursor() - cursor.execute( - # This code knows how to create schema version 1. This is - # regardless of what the caller *wants* to find in the database. - """ - CREATE TABLE IF NOT EXISTS [version] AS SELECT 1 AS [version] - """ - ) - cursor.execute( - """ - SELECT [version] FROM [version] - """ - ) - [(actual_version,)] = cursor.fetchall() - if actual_version != required_schema_version: - raise SchemaError( - "Unexpected database schema version. Required {}. Got {}.".format( - required_schema_version, - actual_version, - ), - ) - - cursor.execute( - # A denormalized schema because, for now, it's simpler. :/ - """ - CREATE TABLE IF NOT EXISTS [vouchers] ( - [number] text, - [created] text, -- An ISO8601 date+time string. - [state] text DEFAULT "pending", -- pending, double-spend, redeemed - - [finished] text DEFAULT NULL, -- ISO8601 date+time string when - -- the current terminal state was entered. - - [token-count] num DEFAULT NULL, -- Set in the redeemed state to the number - -- of tokens received on this voucher's - -- redemption. - - PRIMARY KEY([number]) - ) - """, - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS [tokens] ( - [text] text, -- The random string that defines the token. - [voucher] text, -- Reference to the voucher these tokens go with. - - PRIMARY KEY([text]) - FOREIGN KEY([voucher]) REFERENCES [vouchers]([number]) - ) - """, - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS [unblinded-tokens] ( - [token] text, -- The base64 encoded unblinded token. - - PRIMARY KEY([token]) - ) - """, - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS [lease-maintenance-spending] ( - [id] integer, -- A unique identifier for a group of activity. - [started] text, -- ISO8601 date+time string when the activity began. - [finished] text, -- ISO8601 date+time string when the activity completed (or null). - - -- The number of passes that would be required to renew all - -- shares encountered during this activity. Note that because - -- leases on different shares don't necessarily expire at the - -- same time this is not necessarily the number of passes - -- **actually** used during this activity. Some shares may - -- not have required lease renewal. Also note that while the - -- activity is ongoing this value may change. - [count] integer, - - PRIMARY KEY([id]) - ) - """, - ) + actual_version = get_schema_version(cursor) + schema_upgrades = list(get_schema_upgrades(actual_version)) + run_schema_upgrades(schema_upgrades, cursor) return conn @@ -280,7 +198,6 @@ class VoucherStore(object): db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME)) conn = open_and_initialize( db_path, - required_schema_version=1, connect=connect, ) return cls( diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..cf71a9c2a9d11b26ba4756e505284770a6952dd3 --- /dev/null +++ b/src/_zkapauthorizer/schema.py @@ -0,0 +1,134 @@ +# Copyright 2019 PrivateStorage.io, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import ( + unicode_literals, +) + +""" +This module defines the database schema used by the model interface. +""" + +def get_schema_version(cursor): + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [version] AS SELECT 0 AS [version] + """ + ) + cursor.execute( + """ + SELECT [version] FROM [version] + """ + ) + [(actual_version,)] = cursor.fetchall() + return actual_version + + +def get_schema_upgrades(from_version): + """ + Generate unicode strings containing SQL expressions to alter a schema from + ``from_version`` to the latest version. + + :param int from_version: The version of the schema which may require + upgrade. + """ + while from_version in _UPGRADES: + for upgrade in _UPGRADES[from_version]: + yield upgrade + yield _INCREMENT_VERSION + from_version += 1 + + +def run_schema_upgrades(upgrades, cursor): + """ + Apply the given upgrades using the given cursor. + + :param list[unicode] upgrades: The SQL statements to apply for the + upgrade. + + :param cursor: A DB-API cursor to use to run the SQL. + """ + for upgrade in upgrades: + cursor.execute(upgrade) + + +_INCREMENT_VERSION = ( + """ + UPDATE [version] + SET [version] = [version] + 1 + """ +) + +# A mapping from old schema versions to lists of unicode strings of SQL to +# execute against that version of the schema to create the successor schema. +_UPGRADES = { + 0: [ + """ + CREATE TABLE [vouchers] ( + [number] text, + [created] text, -- An ISO8601 date+time string. + [state] text DEFAULT "pending", -- pending, double-spend, redeemed + + [finished] text DEFAULT NULL, -- ISO8601 date+time string when + -- the current terminal state was entered. + + [token-count] num DEFAULT NULL, -- Set in the redeemed state to the number + -- of tokens received on this voucher's + -- redemption. + + PRIMARY KEY([number]) + ) + """, + """ + CREATE TABLE [tokens] ( + [text] text, -- The random string that defines the token. + [voucher] text, -- Reference to the voucher these tokens go with. + + PRIMARY KEY([text]) + FOREIGN KEY([voucher]) REFERENCES [vouchers]([number]) + ) + """, + """ + CREATE TABLE [unblinded-tokens] ( + [token] text, -- The base64 encoded unblinded token. + + PRIMARY KEY([token]) + ) + """, + """ + CREATE TABLE [lease-maintenance-spending] ( + [id] integer, -- A unique identifier for a group of activity. + [started] text, -- ISO8601 date+time string when the activity began. + [finished] text, -- ISO8601 date+time string when the activity completed (or null). + + -- The number of passes that would be required to renew all + -- shares encountered during this activity. Note that because + -- leases on different shares don't necessarily expire at the + -- same time this is not necessarily the number of passes + -- **actually** used during this activity. Some shares may + -- not have required lease renewal. Also note that while the + -- activity is ongoing this value may change. + [count] integer, + + PRIMARY KEY([id]) + ) + """, + ], +} + +def _check_consistency(): + if _UPGRADES.keys() != range(len(_UPGRADES)): + raise TypeError("Inconsistent schema versions in schema upgraders.") + +_check_consistency() diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 9ea41591dfadd3eac428726d22856fead977f00b..bf2dbaf7e3c4849ef8c8159450cb01ee7dca45ad 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -68,16 +68,12 @@ from hypothesis.strategies import ( from twisted.python.runtime import ( platform, ) -from twisted.python.filepath import ( - FilePath, -) from ..storage_common import ( BYTES_PER_PASS, ) from ..model import ( - SchemaError, StoreOpenError, VoucherStore, Voucher, @@ -85,7 +81,6 @@ from ..model import ( DoubleSpend, Redeemed, LeaseMaintenanceActivity, - open_and_initialize, memory_connect, ) @@ -107,22 +102,6 @@ class VoucherStoreTests(TestCase): """ Tests for ``VoucherStore``. """ - def test_create_mismatched_schema(self): - """ - ``open_and_initialize`` raises ``SchemaError`` if asked for a database - with a schema version other than it can create. - """ - tempdir = self.useFixture(TempDir()) - dbpath = tempdir.join(b"db.sqlite3") - self.assertThat( - lambda: open_and_initialize( - FilePath(dbpath), - required_schema_version=100, - ), - raises(SchemaError), - ) - - @given(tahoe_configs(), datetimes(), vouchers()) def test_get_missing(self, get_config, now, voucher): """