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.
@@ -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