datacommons-db 1.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datacommons_db/__init__.py +17 -0
- datacommons_db/clients/__init__.py +29 -0
- datacommons_db/clients/spanner_client.py +251 -0
- datacommons_db/migrations/__init__.py +25 -0
- datacommons_db/migrations/base.py +42 -0
- datacommons_db/migrations/migration_runner.py +297 -0
- datacommons_db/migrations/migration_scripts/20260817000000_bootstrap.py +50 -0
- datacommons_db/migrations/migration_scripts/__init__.py +13 -0
- datacommons_db/models/__init__.py +0 -0
- datacommons_db/models/base.py +18 -0
- datacommons_db/models/edge.py +82 -0
- datacommons_db/models/node.py +57 -0
- datacommons_db/models/observation.py +66 -0
- datacommons_db/repositories/__init__.py +0 -0
- datacommons_db/repositories/node_repository.py +38 -0
- datacommons_db/session.py +93 -0
- datacommons_db/utils/__init__.py +27 -0
- datacommons_db/utils/validators.py +55 -0
- datacommons_db-1.1.3.dist-info/METADATA +241 -0
- datacommons_db-1.1.3.dist-info/RECORD +22 -0
- datacommons_db-1.1.3.dist-info/WHEEL +5 -0
- datacommons_db-1.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright 2025 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import importlib.metadata
|
|
16
|
+
|
|
17
|
+
__version__ = importlib.metadata.version("datacommons-db")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from datacommons_db.clients.spanner_client import (
|
|
16
|
+
DdlResult,
|
|
17
|
+
DmlResult,
|
|
18
|
+
ExecutionStatus,
|
|
19
|
+
QueryResult,
|
|
20
|
+
SpannerClient,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DdlResult",
|
|
25
|
+
"DmlResult",
|
|
26
|
+
"ExecutionStatus",
|
|
27
|
+
"QueryResult",
|
|
28
|
+
"SpannerClient",
|
|
29
|
+
]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from google.auth.credentials import Credentials
|
|
21
|
+
from google.cloud import spanner
|
|
22
|
+
from google.cloud.spanner_v1.transaction import Transaction
|
|
23
|
+
|
|
24
|
+
from datacommons_db.utils.validators import (
|
|
25
|
+
validate_resource_id,
|
|
26
|
+
validate_table_name,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExecutionStatus(StrEnum):
|
|
31
|
+
"""Status of a Spanner database operation."""
|
|
32
|
+
|
|
33
|
+
SUCCESS = "SUCCESS"
|
|
34
|
+
ERROR = "ERROR"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DdlResult:
|
|
39
|
+
"""Result of a DDL statement execution.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
status: Execution status enum (SUCCESS or ERROR).
|
|
43
|
+
error_message: Error message string if execution failed, None otherwise.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
status: ExecutionStatus
|
|
47
|
+
error_message: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class DmlResult:
|
|
52
|
+
"""Result of a DML statement execution inside a read-write transaction.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
status: Execution status enum (SUCCESS or ERROR).
|
|
56
|
+
rows_affected: Number of rows modified by the DML statement (0 on failure).
|
|
57
|
+
error_message: Error message string if execution failed, None otherwise.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
status: ExecutionStatus
|
|
61
|
+
rows_affected: int = 0
|
|
62
|
+
error_message: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class QueryResult:
|
|
67
|
+
"""Result of a snapshot read query.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
status: Execution status enum (SUCCESS or ERROR).
|
|
71
|
+
rows: List of rows where each row is a list of column values ([] on failure).
|
|
72
|
+
error_message: Error message string if execution failed, None otherwise.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
status: ExecutionStatus
|
|
76
|
+
rows: list[list[Any]] = field(default_factory=list)
|
|
77
|
+
error_message: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SpannerClient:
|
|
81
|
+
"""Client for Cloud Spanner operations, DDL execution, and query execution."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
project_id: str,
|
|
86
|
+
instance_id: str,
|
|
87
|
+
database_id: str,
|
|
88
|
+
credentials: Credentials | None = None,
|
|
89
|
+
*,
|
|
90
|
+
disable_builtin_metrics: bool = True,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Initialize the SpannerClient.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
project_id: GCP project ID.
|
|
96
|
+
instance_id: Cloud Spanner instance ID.
|
|
97
|
+
database_id: Cloud Spanner database ID.
|
|
98
|
+
credentials: Optional Google Cloud credentials object.
|
|
99
|
+
disable_builtin_metrics: Whether to disable built-in Cloud Monitoring metrics export.
|
|
100
|
+
"""
|
|
101
|
+
validate_resource_id("project_id", project_id)
|
|
102
|
+
validate_resource_id("instance_id", instance_id)
|
|
103
|
+
validate_resource_id("database_id", database_id)
|
|
104
|
+
|
|
105
|
+
self.project_id = project_id
|
|
106
|
+
self.instance_id = instance_id
|
|
107
|
+
self.database_id = database_id
|
|
108
|
+
|
|
109
|
+
self.client = spanner.Client(
|
|
110
|
+
project=project_id,
|
|
111
|
+
credentials=credentials,
|
|
112
|
+
disable_builtin_metrics=disable_builtin_metrics,
|
|
113
|
+
)
|
|
114
|
+
self.instance = self.client.instance(self.instance_id)
|
|
115
|
+
self.database = self.instance.database(self.database_id)
|
|
116
|
+
|
|
117
|
+
def table_exists(self, table_name: str) -> bool:
|
|
118
|
+
"""Check if a table exists in the Cloud Spanner database.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
table_name: The table name to check.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if the table exists, False otherwise.
|
|
125
|
+
"""
|
|
126
|
+
validate_table_name(table_name)
|
|
127
|
+
|
|
128
|
+
query = (
|
|
129
|
+
"SELECT 1 FROM information_schema.tables "
|
|
130
|
+
"WHERE table_schema IN ('', 'public') AND table_name = @table_name LIMIT 1"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
params = {"table_name": table_name}
|
|
134
|
+
param_types = {"table_name": spanner.param_types.STRING}
|
|
135
|
+
|
|
136
|
+
result = self.execute_query(query, params=params, param_types=param_types)
|
|
137
|
+
return bool(result.status == ExecutionStatus.SUCCESS and result.rows)
|
|
138
|
+
|
|
139
|
+
def execute_ddl(self, ddl_statements: list[str]) -> DdlResult:
|
|
140
|
+
"""Execute DDL statements and wait for completion.
|
|
141
|
+
|
|
142
|
+
Handles operations like CREATE TABLE, ALTER TABLE, etc.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
ddl_statements: A non-empty list of DDL statement strings.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
DdlResult with execution status and optional error message.
|
|
149
|
+
"""
|
|
150
|
+
if not ddl_statements or not isinstance(ddl_statements, list):
|
|
151
|
+
return DdlResult(
|
|
152
|
+
status=ExecutionStatus.ERROR,
|
|
153
|
+
error_message="ddl_statements must be a non-empty list of str.",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
operation = self.database.update_ddl(ddl_statements)
|
|
158
|
+
operation.result()
|
|
159
|
+
return DdlResult(status=ExecutionStatus.SUCCESS)
|
|
160
|
+
except Exception as e: # noqa: BLE001 - must catch all exceptions to ensure a DdlResult is always returned
|
|
161
|
+
return DdlResult(status=ExecutionStatus.ERROR, error_message=str(e))
|
|
162
|
+
|
|
163
|
+
def execute_dml(
|
|
164
|
+
self,
|
|
165
|
+
query: str,
|
|
166
|
+
params: dict[str, Any] | None = None,
|
|
167
|
+
param_types: dict[str, Any] | None = None,
|
|
168
|
+
) -> DmlResult:
|
|
169
|
+
"""Execute a DML statement inside a Spanner read-write transaction.
|
|
170
|
+
|
|
171
|
+
Handles operations like INSERT, UPDATE, DELETE
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
query: The parameterized DML statement.
|
|
175
|
+
params: Dictionary of parameters.
|
|
176
|
+
param_types: Dictionary of parameter types.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
DmlResult with execution status, rows affected, and optional error message.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def _unit_of_work(transaction: Transaction) -> int:
|
|
183
|
+
return transaction.execute_update(
|
|
184
|
+
query, params=params, param_types=param_types
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
rows_affected = self.database.run_in_transaction(_unit_of_work)
|
|
189
|
+
return DmlResult(
|
|
190
|
+
status=ExecutionStatus.SUCCESS, rows_affected=rows_affected
|
|
191
|
+
)
|
|
192
|
+
except Exception as e: # noqa: BLE001 - must catch all exceptions to ensure a DmlResult is always returned
|
|
193
|
+
return DmlResult(
|
|
194
|
+
status=ExecutionStatus.ERROR,
|
|
195
|
+
rows_affected=0,
|
|
196
|
+
error_message=str(e),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _execute_query_stream(
|
|
200
|
+
self,
|
|
201
|
+
query: str,
|
|
202
|
+
params: dict[str, Any] | None = None,
|
|
203
|
+
param_types: dict[str, Any] | None = None,
|
|
204
|
+
) -> Iterator[list[Any]]:
|
|
205
|
+
"""Stream query results lazily row-by-row from a point-in-time snapshot.
|
|
206
|
+
This prevents OOM errors when querying large tables.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
query: The parameterized SQL query string.
|
|
210
|
+
params: Dictionary of query parameters.
|
|
211
|
+
param_types: Dictionary of parameter types.
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
Rows as lists of column values.
|
|
215
|
+
"""
|
|
216
|
+
with self.database.snapshot() as snapshot:
|
|
217
|
+
results = snapshot.execute_sql(
|
|
218
|
+
query, params=params, param_types=param_types
|
|
219
|
+
)
|
|
220
|
+
for row in results:
|
|
221
|
+
yield list(row)
|
|
222
|
+
|
|
223
|
+
def execute_query(
|
|
224
|
+
self,
|
|
225
|
+
query: str,
|
|
226
|
+
params: dict[str, Any] | None = None,
|
|
227
|
+
param_types: dict[str, Any] | None = None,
|
|
228
|
+
) -> QueryResult:
|
|
229
|
+
"""Execute a read query within a snapshot transaction.
|
|
230
|
+
|
|
231
|
+
Handles operations like SELECT.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
query: The parameterized SQL query.
|
|
235
|
+
params: Dictionary of parameters.
|
|
236
|
+
param_types: Dictionary of parameter types.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
QueryResult with execution status, rows, and optional error message.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
rows = list(
|
|
243
|
+
self._execute_query_stream(
|
|
244
|
+
query, params=params, param_types=param_types
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
return QueryResult(status=ExecutionStatus.SUCCESS, rows=rows)
|
|
248
|
+
except Exception as e: # noqa: BLE001 - must catch all exceptions to ensure a QueryResult is always returned
|
|
249
|
+
return QueryResult(
|
|
250
|
+
status=ExecutionStatus.ERROR, rows=[], error_message=str(e)
|
|
251
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from datacommons_db.migrations.base import SchemaMigration
|
|
16
|
+
from datacommons_db.migrations.migration_runner import (
|
|
17
|
+
MigrationResult,
|
|
18
|
+
MigrationRunner,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"MigrationResult",
|
|
23
|
+
"MigrationRunner",
|
|
24
|
+
"SchemaMigration",
|
|
25
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
|
|
17
|
+
from datacommons_db.clients.spanner_client import SpannerClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaMigration(ABC):
|
|
21
|
+
"""Base class for defining Spanner schema migration scripts."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def description(self) -> str:
|
|
26
|
+
"""Human-readable description of this schema migration."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def creation_timestamp(self) -> str:
|
|
31
|
+
"""UTC ISO-8601 creation timestamp (e.g. '2026-08-17T15:30:00Z')."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def upgrade(self, spanner_client: SpannerClient) -> None:
|
|
35
|
+
"""Executes forward schema changes to upgrade the database.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
spanner_client: SpannerClient instance to execute DDL / DML.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RuntimeError: If any DDL or DML operation fails.
|
|
42
|
+
"""
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import inspect
|
|
17
|
+
import logging
|
|
18
|
+
import pkgutil
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from google.cloud import spanner
|
|
22
|
+
|
|
23
|
+
import datacommons_db.migrations.migration_scripts
|
|
24
|
+
from datacommons_db.clients.spanner_client import ExecutionStatus, SpannerClient
|
|
25
|
+
from datacommons_db.migrations.base import SchemaMigration
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class MigrationResult:
|
|
32
|
+
"""Result of applying a schema migration.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
status: ExecutionStatus enum (SUCCESS or ERROR).
|
|
36
|
+
creation_timestamp: UTC ISO-8601 creation timestamp of the migration.
|
|
37
|
+
description: Description of the schema migration.
|
|
38
|
+
error_message: Error message string if migration failed, None otherwise.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
status: ExecutionStatus
|
|
42
|
+
creation_timestamp: str
|
|
43
|
+
description: str
|
|
44
|
+
error_message: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MigrationRunner:
|
|
48
|
+
"""Discovers, validates, plans, and executes Spanner database schema migrations."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
spanner_client: SpannerClient,
|
|
53
|
+
migrations: list[SchemaMigration] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Initialize the MigrationRunner.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
spanner_client: SpannerClient instance.
|
|
59
|
+
migrations: Optional explicit list of SchemaMigration instances.
|
|
60
|
+
If omitted, migrations will be automatically discovered.
|
|
61
|
+
"""
|
|
62
|
+
self.spanner_client = spanner_client
|
|
63
|
+
if migrations is not None:
|
|
64
|
+
self.migrations = self.validate_migrations(migrations)
|
|
65
|
+
else:
|
|
66
|
+
self.migrations = self.discover_migrations()
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def validate_migrations(
|
|
70
|
+
migrations: list[SchemaMigration],
|
|
71
|
+
) -> list[SchemaMigration]:
|
|
72
|
+
"""Validate and sort migrations chronologically by creation_timestamp.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
migrations: List of SchemaMigration instances.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Sorted list of SchemaMigration instances.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If migrations have duplicate creation_timestamps.
|
|
82
|
+
"""
|
|
83
|
+
if not migrations:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
sorted_migrations = sorted(migrations, key=lambda m: m.creation_timestamp)
|
|
87
|
+
|
|
88
|
+
# Check that there are no duplicates
|
|
89
|
+
seen_timestamps: set[str] = set()
|
|
90
|
+
for migration in sorted_migrations:
|
|
91
|
+
ts = migration.creation_timestamp
|
|
92
|
+
if ts in seen_timestamps:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Duplicate migration creation_timestamp detected: {ts}"
|
|
95
|
+
)
|
|
96
|
+
seen_timestamps.add(ts)
|
|
97
|
+
|
|
98
|
+
return sorted_migrations
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def discover_migrations(cls) -> list[SchemaMigration]:
|
|
102
|
+
"""Automatically discover and validate all SchemaMigration subclasses in migration_scripts.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Sorted, validated list of SchemaMigration instances.
|
|
106
|
+
"""
|
|
107
|
+
discovered: list[SchemaMigration] = []
|
|
108
|
+
|
|
109
|
+
# Only iterate through files from __path__.
|
|
110
|
+
# This prevents external path injection or local file execution when running a packaged release.
|
|
111
|
+
for module_info in pkgutil.iter_modules(
|
|
112
|
+
datacommons_db.migrations.migration_scripts.__path__
|
|
113
|
+
):
|
|
114
|
+
if module_info.name.startswith("_"):
|
|
115
|
+
# Treat files starting with _ as private, do not import.
|
|
116
|
+
continue
|
|
117
|
+
module = importlib.import_module(
|
|
118
|
+
f"datacommons_db.migrations.migration_scripts.{module_info.name}"
|
|
119
|
+
)
|
|
120
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
121
|
+
if (
|
|
122
|
+
issubclass(obj, SchemaMigration)
|
|
123
|
+
and not inspect.isabstract(obj)
|
|
124
|
+
and obj.__module__ == module.__name__ # Filter out imported objects
|
|
125
|
+
):
|
|
126
|
+
discovered.append(obj())
|
|
127
|
+
|
|
128
|
+
return cls.validate_migrations(discovered)
|
|
129
|
+
|
|
130
|
+
def get_applied_migrations(self) -> set[str]:
|
|
131
|
+
"""Query the target database to determine the set of applied migration creation timestamps.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Set of applied creation_timestamp strings, or empty set if SchemaMigrations table does not exist.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
RuntimeError: If querying SchemaMigrations fails unexpectedly.
|
|
138
|
+
"""
|
|
139
|
+
if not self.spanner_client.table_exists("SchemaMigrations"):
|
|
140
|
+
return set()
|
|
141
|
+
|
|
142
|
+
get_applied_migrations_query = "SELECT CreationTimestamp FROM SchemaMigrations"
|
|
143
|
+
result = self.spanner_client.execute_query(get_applied_migrations_query)
|
|
144
|
+
if result.status != ExecutionStatus.SUCCESS:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"Failed to query SchemaMigrations table: {result.error_message}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
applied_migrations: set[str] = set()
|
|
150
|
+
for row in result.rows:
|
|
151
|
+
# Check each row for validity.
|
|
152
|
+
if not isinstance(row, (list, tuple)) or len(row) == 0:
|
|
153
|
+
raise RuntimeError(
|
|
154
|
+
f"Invalid or non-indexable row format in SchemaMigrations query results: {row!r}"
|
|
155
|
+
)
|
|
156
|
+
applied_migrations.add(str(row[0]))
|
|
157
|
+
return applied_migrations
|
|
158
|
+
|
|
159
|
+
def get_pending_migrations(self) -> list[SchemaMigration]:
|
|
160
|
+
"""Get the list of pending migrations to be applied in chronological order.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of SchemaMigration instances that have not yet been applied.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
RuntimeError: If querying SchemaMigrations fails unexpectedly.
|
|
167
|
+
"""
|
|
168
|
+
applied_migrations = self.get_applied_migrations()
|
|
169
|
+
|
|
170
|
+
known_timestamps = {m.creation_timestamp for m in self.migrations}
|
|
171
|
+
unknown_applied = applied_migrations - known_timestamps
|
|
172
|
+
if unknown_applied:
|
|
173
|
+
logger.warning(
|
|
174
|
+
"Database contains %d applied migration(s) not recognized by this codebase: %s. "
|
|
175
|
+
"The database schema may be newer than this application.",
|
|
176
|
+
len(unknown_applied),
|
|
177
|
+
sorted(unknown_applied),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
m for m in self.migrations if m.creation_timestamp not in applied_migrations
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
def apply_migration(self, migration: SchemaMigration) -> MigrationResult:
|
|
185
|
+
"""Apply a single migration and record its completion in SchemaMigrations.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
migration: The SchemaMigration instance to apply.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
MigrationResult indicating successful execution.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
RuntimeError: If migration execution or recording in SchemaMigrations fails.
|
|
195
|
+
"""
|
|
196
|
+
logger.info(
|
|
197
|
+
"Applying migration %s: %s",
|
|
198
|
+
migration.creation_timestamp,
|
|
199
|
+
migration.description,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 1. Execute the migration's upgrade changes
|
|
203
|
+
migration.upgrade(self.spanner_client)
|
|
204
|
+
|
|
205
|
+
# 2. Record the applied migration in SchemaMigrations
|
|
206
|
+
self.record_applied_migration(
|
|
207
|
+
creation_timestamp=migration.creation_timestamp,
|
|
208
|
+
description=migration.description,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
logger.info(
|
|
212
|
+
"Successfully applied migration %s",
|
|
213
|
+
migration.creation_timestamp,
|
|
214
|
+
)
|
|
215
|
+
return MigrationResult(
|
|
216
|
+
status=ExecutionStatus.SUCCESS,
|
|
217
|
+
creation_timestamp=migration.creation_timestamp,
|
|
218
|
+
description=migration.description,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def get_latest_applied_migration(self) -> str | None:
|
|
222
|
+
"""Get the creation timestamp of the latest applied migration.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The latest applied creation_timestamp string, or None if no migrations have been applied.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
RuntimeError: If querying SchemaMigrations fails unexpectedly.
|
|
229
|
+
"""
|
|
230
|
+
applied = self.get_applied_migrations()
|
|
231
|
+
return max(applied) if applied else None
|
|
232
|
+
|
|
233
|
+
def record_applied_migration(
|
|
234
|
+
self, creation_timestamp: str, description: str
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Record an applied migration in the SchemaMigrations table.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
creation_timestamp: The migration creation_timestamp to record.
|
|
240
|
+
description: Description of the schema migration.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
RuntimeError: If inserting into SchemaMigrations fails.
|
|
244
|
+
"""
|
|
245
|
+
insert_applied_migration_record_dml = (
|
|
246
|
+
"INSERT INTO SchemaMigrations (CreationTimestamp, AppliedTimestamp, Description) "
|
|
247
|
+
"VALUES (@creation_timestamp, PENDING_COMMIT_TIMESTAMP(), @description)"
|
|
248
|
+
)
|
|
249
|
+
params = {
|
|
250
|
+
"creation_timestamp": creation_timestamp,
|
|
251
|
+
"description": description,
|
|
252
|
+
}
|
|
253
|
+
param_types = {
|
|
254
|
+
"creation_timestamp": spanner.param_types.STRING,
|
|
255
|
+
"description": spanner.param_types.STRING,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
dml_result = self.spanner_client.execute_dml(
|
|
259
|
+
insert_applied_migration_record_dml,
|
|
260
|
+
params=params,
|
|
261
|
+
param_types=param_types,
|
|
262
|
+
)
|
|
263
|
+
if dml_result.status != ExecutionStatus.SUCCESS:
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"Failed to record migration {creation_timestamp} in SchemaMigrations: "
|
|
266
|
+
f"{dml_result.error_message}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def run_migrations(self) -> list[MigrationResult]:
|
|
270
|
+
"""Execute all pending migrations in sequential chronological order.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of MigrationResult instances for all applied migrations.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
RuntimeError: If any migration fails during execution.
|
|
277
|
+
"""
|
|
278
|
+
pending = self.get_pending_migrations()
|
|
279
|
+
|
|
280
|
+
if not pending:
|
|
281
|
+
logger.info("Database is already up-to-date. No migrations to apply.")
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
results: list[MigrationResult] = []
|
|
285
|
+
for migration in pending:
|
|
286
|
+
try:
|
|
287
|
+
result = self.apply_migration(migration)
|
|
288
|
+
results.append(result)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(
|
|
291
|
+
"Migration %s failed: %s. Halting migration sequence.",
|
|
292
|
+
migration.creation_timestamp,
|
|
293
|
+
e,
|
|
294
|
+
)
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
return results
|