Skip to content

Commit 1479f19

Browse files
committed
[DMS-803] Add filtered read test suite
1 parent 7be6767 commit 1479f19

19 files changed

+598
-234
lines changed

src/edfi-paging-test/edfi_paging_test/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ PERF_RESOURCE_LIST="["students", "studentEducationOrganizationAssociations"]"
1414

1515
# Set to true when using HTTP instead of HTTPS or when using self-signed certificates
1616
IGNORE_TLS_CERTIFICATE=false
17+
18+
PERF_PAGING_TEST_TYPE=DEEP_PAGING

src/edfi-paging-test/edfi_paging_test/api/request_client.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
from edfi_paging_test.api.paginated_result import PaginatedResult
2020
from edfi_paging_test.api.api_info import APIInfo
2121
from edfi_paging_test.helpers.argparser import MainArguments
22-
from edfi_paging_test.reporter.request_logger import log_request
22+
from edfi_paging_test.reporter.paging_request_logger import PaggingRequestLogger
23+
from edfi_paging_test.reporter.filtered_read_request_logger import FilteredReadRequestLogger
2324
from edfi_paging_test.helpers.api_metadata import get_base_api_response
25+
from urllib.parse import quote
2426

2527
EDFI_DATA_MODEL_NAME = "ed-fi"
2628

@@ -110,7 +112,7 @@ def _authorize(self) -> None:
110112
self.oauth.fetch_token(
111113
self._get_api_info().oauth_url, auth=self.auth,
112114
verify=self.verify_cert
113-
)
115+
)
114116

115117
def _urljoin(self, base_url: str, relative_url: str) -> str:
116118
"""
@@ -214,7 +216,7 @@ def get_total(self, resource: str) -> int:
214216
)
215217
return 0
216218

217-
def get_page(self, resource: str, page: int = 1) -> PaginatedResult:
219+
def get_page(self, resource: str, pagingRequestLogger: PaggingRequestLogger, page: int = 1) -> PaginatedResult:
218220
"""Send an HTTP GET request for the next page.
219221
220222
Returns
@@ -230,9 +232,10 @@ def get_page(self, resource: str, page: int = 1) -> PaginatedResult:
230232
logger.debug(f"GET {next_url}")
231233
elapsed, response = timeit(lambda: self._get(next_url))
232234

233-
items = response.json() if len(response.text) > 0 else []
235+
items = response.json() if len(
236+
response.text) > 0 and response.status_code == HTTPStatus.OK else []
234237

235-
log_request(
238+
pagingRequestLogger.log_request(
236239
resource,
237240
next_url,
238241
page,
@@ -250,7 +253,7 @@ def get_page(self, resource: str, page: int = 1) -> PaginatedResult:
250253
status_code=response.status_code,
251254
)
252255

253-
def get_all(self, resource: str) -> List[Dict[str, Any]]:
256+
def get_all(self, resource: str, pagingRequestLogger: PaggingRequestLogger) -> List[Dict[str, Any]]:
254257
"""
255258
Send an HTTP GET request for all pages of a resource.
256259
@@ -267,18 +270,48 @@ def get_all(self, resource: str) -> List[Dict[str, Any]]:
267270

268271
logger.info(f"Retrieving all {resource} records...")
269272

270-
pagination_result = self.get_page(resource, 1)
273+
pagination_result = self.get_page(resource, pagingRequestLogger, 1)
271274
page_items = pagination_result.current_page_items
272275
# Assign to empty list if result is not a list, e.g. an error response from the API
273276
items: List[Any] = page_items if (isinstance(page_items, list)) else []
274277

275278
while True:
276279
pagination_result = self.get_page(
277-
resource, pagination_result.current_page + 1
280+
resource, pagingRequestLogger, pagination_result.current_page + 1
278281
)
279282
items.extend(pagination_result.current_page_items)
280283

281284
if pagination_result.size < self.page_size:
282285
break
283286

284287
return items
288+
289+
def filtered_get(self, resource_name: str, filters: Dict[str, str], limit: int, filteredReadRequestLogger: FilteredReadRequestLogger) -> List[Dict[str, Any]]:
290+
"""
291+
Sends an HTTP GET request to the given resource applying the provider filters.
292+
293+
Args:
294+
resource_name: Name of the resource to query
295+
filters: Dictionary of filter name and value
296+
limit: Number of entries to retrieve
297+
298+
Returns:
299+
List containing the matching resource entries
300+
"""
301+
302+
query_string = '&'.join([f"{key}={quote(str(value))}" for key, value in filters.items()])
303+
url = f"{self._build_url_for_resource(resource_name)}?limit={limit}&{query_string}"
304+
305+
elapsed, response = timeit(lambda: self._get(url))
306+
307+
items = response.json() if len(response.text) > 0 and response.status_code == HTTPStatus.OK else []
308+
309+
filteredReadRequestLogger.log_request(
310+
resource_name,
311+
url,
312+
len(filters),
313+
elapsed,
314+
response.status_code,
315+
)
316+
317+
return items

src/edfi-paging-test/edfi_paging_test/helpers/api_metadata.py

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def get_base_api_response(api_base_url: str, verify_cert: bool = True) -> Dict[s
3535
return requests.get(
3636
api_base_url,
3737
verify=verify_cert
38-
).json()
38+
).json()
3939
except requests.exceptions.RequestException as e:
4040
raise RuntimeError(f"Error: {e}.") from e
4141

@@ -61,7 +61,7 @@ def get_openapi_metadata_response(api_base_url: str, verify_cert: bool = True) -
6161
return requests.get(
6262
base_api_response["urls"]["openApiMetadata"],
6363
verify=verify_cert
64-
).json()
64+
).json()
6565

6666

6767
@cache
@@ -88,44 +88,48 @@ def get_resource_metadata_response(api_base_url: str, verify_cert: bool = True)
8888
return requests.get(
8989
resource_metadata["endpointUri"],
9090
verify=verify_cert
91-
).json()
91+
).json()
9292

9393

94-
def get_resource_paths(api_base_url: str, verify_cert: bool = True) -> List[str]:
94+
def get_filters_by_resource_name(api_base_url: str, verify_cert: bool = True) -> Dict[str, List[str]]:
9595
"""
96-
Gets the resources for the API as relative paths, including the
97-
project/extension prefix.
96+
Analyzes the OpenAPI metadata to determine which filters are available
97+
for each resource endpoint.
9898
99-
Parameters
100-
----------
101-
api_base_url : str
102-
The base URL of the API.
99+
Args:
100+
api_base_url (str): Base URL of the Ed-Fi API
101+
verify_cert (bool): Whether to verify SSL certificates
103102
104-
Returns
105-
-------
106-
List[str]
107-
A list of resource relative paths, including the extension prefix if
108-
relevant. For example: ["schools", "tpdm/candidates"]
103+
Returns:
104+
Dict[str, List[str]]: Dictionary mapping resource names to their
105+
available filters
109106
"""
110-
resource_metadata_response: Dict[
111-
str, Dict[str, str]
112-
] = get_resource_metadata_response(api_base_url, verify_cert)
113-
all_paths: List[str] = list(resource_metadata_response["paths"].keys())
107+
108+
resource_metadata_response: Dict[str, Dict[str, Any]
109+
] = get_resource_metadata_response(api_base_url, verify_cert)
110+
114111
# filter out paths that are for get by id, deletes, keyChanges or partitions
115-
return list(
116-
filter(
117-
lambda p: (
118-
"{id}" not in p
119-
and "/deletes" not in p
120-
and "/keyChanges" not in p
121-
and "/partitions" not in p
122-
)
123-
, all_paths
124-
)
125-
)
112+
operations_by_resource = [(normalize_resource_path(path), operations) for path, operations in resource_metadata_response["paths"].items()
113+
if "{id}" not in path
114+
and "/deletes" not in path
115+
and "/keyChanges" not in path
116+
and "/partitions" not in path]
117+
118+
filters_by_resource = {resource: get_filters(
119+
operations) for resource, operations in operations_by_resource}
126120

121+
return filters_by_resource
127122

128-
def normalize_resource_paths(resource_paths: List[str]) -> List[str]:
123+
124+
def get_filters(operations) -> List[str]:
125+
pagination_params = {'limit', 'offset', 'totalCount'}
126+
return [param['name'] for param in operations['get']['parameters']
127+
if 'in' in param and param['in'] == 'query'
128+
and param['name'] != 'id'
129+
and param['name'] not in pagination_params]
130+
131+
132+
def normalize_resource_path(resource_path: str) -> str:
129133
"""
130134
Takes a list of resource relative paths and normalizes to lowercase
131135
and with the "ed-fi" namespace prefix removed.
@@ -137,13 +141,8 @@ def normalize_resource_paths(resource_paths: List[str]) -> List[str]:
137141
138142
Returns
139143
-------
140-
List[str]
141-
A list of normalized resource relative paths.
142-
For example: ["studentschoolassociations", "tpdm/candidates"]
144+
str
145+
A normalized resource relative path.
146+
For example: "studentschoolassociations", "tpdm/candidates"
143147
"""
144-
return list(
145-
map(
146-
lambda r: r.removeprefix("/").removeprefix("ed-fi/"),
147-
resource_paths,
148-
)
149-
)
148+
return resource_path.removeprefix("/").removeprefix("ed-fi/")

src/edfi-paging-test/edfi_paging_test/helpers/argparser.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from edfi_paging_test.helpers.output_format import OutputFormat
99
from edfi_paging_test.helpers.log_level import LogLevel
1010
from edfi_paging_test.helpers.main_arguments import MainArguments
11+
from edfi_paging_test.helpers.test_type import TestType
1112

1213

1314
def parse_main_arguments() -> MainArguments:
@@ -113,6 +114,22 @@ def parse_main_arguments() -> MainArguments:
113114
default="Paging Volume Test Run",
114115
env_var="PERF_DESCRIPTION",
115116
)
117+
parser.add( # type: ignore
118+
"-e",
119+
"--testType",
120+
help="Type of paging performance test to run: DEEP_PAGING, FILTERED_READ",
121+
type=TestType,
122+
choices=list(TestType),
123+
default=TestType.DEEP_PAGING,
124+
env_var="PERF_PAGING_TEST_TYPE",
125+
)
126+
parser.add( # type: ignore
127+
"--combinationSizeLimit",
128+
help="The maximumum size of the filter combinations to test per resource. For use with the 'FILTERED_READ' testType only",
129+
type=int,
130+
default=6,
131+
env_var="PERF_FILTER_COMBINATION_SIZE_LIMIT",
132+
)
116133

117134
args_parsed = parser.parse_args()
118135

@@ -128,6 +145,8 @@ def parse_main_arguments() -> MainArguments:
128145
args_parsed.resourceList or [],
129146
args_parsed.pageSize,
130147
args_parsed.logLevel,
148+
args_parsed.testType,
149+
args_parsed.combinationSizeLimit,
131150
)
132151

133152
return arguments

src/edfi-paging-test/edfi_paging_test/helpers/main_arguments.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from edfi_paging_test.helpers.output_format import OutputFormat
1010
from edfi_paging_test.helpers.log_level import LogLevel
11+
from edfi_paging_test.helpers.test_type import TestType
1112

1213

1314
@dataclass
@@ -27,3 +28,5 @@ class MainArguments:
2728
resourceList: List[str]
2829
pageSize: int = 100
2930
log_level: LogLevel = LogLevel.INFO
31+
test_type: TestType = TestType.DEEP_PAGING
32+
combination_size_limit: int = 6
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Licensed to the Ed-Fi Alliance under one or more agreements.
3+
# The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
# See the LICENSE and NOTICES files in the project root for more information.
5+
6+
import random
7+
from typing import Any, Dict, List, Tuple
8+
9+
10+
class ResourceEntriesCache:
11+
12+
def __init__(self, entries: List[Dict[str, Any]], filters: List[str]):
13+
"""
14+
Stores the given resource entries in a columnar fashion to speed up local searches.
15+
16+
Args:
17+
entries: Resource entries
18+
filters: List of filters that a resource supports
19+
"""
20+
21+
# Create a dictionary where the key is the filter name and the value is a list of entries
22+
# with a non-null value for the given filter
23+
self.resources_by_filter_name: Dict[str, list[Dict[str, Any]]] = {filter: [] for filter in filters}
24+
25+
for entry in entries:
26+
for filter in filters:
27+
if filter in entry and entry[filter] is not None:
28+
self.resources_by_filter_name[filter].append(entry)
29+
30+
def get_entries_with_non_null_filters(self, filters: Tuple[str, ...], count: int) -> List[Dict[str, Any]]:
31+
"""
32+
Searches through the resource entries to find the ones that have non-null values
33+
for all the specified filters, up to the requested count.
34+
35+
Args:
36+
filters: The required filters
37+
count: Maximum number of matching entries to return
38+
"""
39+
40+
# Get the filter with the highest cardinality
41+
entries = list(self.resources_by_filter_name.items())[0][1]
42+
for filter in filters:
43+
if len(self.resources_by_filter_name[filter]) < len(entries):
44+
entries = self.resources_by_filter_name[filter]
45+
46+
random.shuffle(entries)
47+
48+
result: List[Dict[str, Any]] = []
49+
for entry in entries:
50+
has_all_non_null_filters = True
51+
for filter in filters:
52+
if filter not in entry or entry[filter] is None:
53+
has_all_non_null_filters = False
54+
break
55+
56+
if has_all_non_null_filters:
57+
result.append(entry)
58+
59+
if len(result) >= count:
60+
break
61+
62+
return result
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Licensed to the Ed-Fi Alliance under one or more agreements.
3+
# The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
4+
# See the LICENSE and NOTICES files in the project root for more information.
5+
6+
from edfi_paging_test.helpers.case_insensitive_enum import CaseInsensitiveEnum
7+
8+
9+
class TestType(CaseInsensitiveEnum):
10+
DEEP_PAGING = "DEEP_PAGING"
11+
FILTERED_READ = "FILTERED_READ"
12+
13+
def __eq__(self, other: object) -> bool:
14+
try:
15+
return self.value == TestType(other).value
16+
except: # noqa: E722
17+
return False
18+
19+
# Prevent pytest from trying to discover tests in this class
20+
__test__ = False

0 commit comments

Comments
 (0)