diff --git a/docs/reference/experimental/async/schema_organization.md b/docs/reference/experimental/async/organization.md similarity index 77% rename from docs/reference/experimental/async/schema_organization.md rename to docs/reference/experimental/async/organization.md index 721a45cae..a891981a8 100644 --- a/docs/reference/experimental/async/schema_organization.md +++ b/docs/reference/experimental/async/organization.md @@ -1,8 +1,8 @@ -# SchemaOrganization +# Organization ## API Reference -::: synapseclient.models.SchemaOrganization +::: synapseclient.models.Organization options: inherited_members: true members: diff --git a/docs/reference/experimental/sync/schema_organization.md b/docs/reference/experimental/sync/organization.md similarity index 74% rename from docs/reference/experimental/sync/schema_organization.md rename to docs/reference/experimental/sync/organization.md index 2667e9f7b..986978bcb 100644 --- a/docs/reference/experimental/sync/schema_organization.md +++ b/docs/reference/experimental/sync/organization.md @@ -1,8 +1,8 @@ -# SchemaOrganization +# Organization ## API Reference -::: synapseclient.models.SchemaOrganization +::: synapseclient.models.Organization options: inherited_members: true members: diff --git a/docs/reference/json_schema.md b/docs/reference/json_schema.md index 13dd5a606..e017be3de 100644 --- a/docs/reference/json_schema.md +++ b/docs/reference/json_schema.md @@ -3,7 +3,7 @@ !!! warning "Deprecated — removed in v5.0.0" The legacy `synapseclient.services.json_schema` classes (`JsonSchema`, `JsonSchemaVersion`, `JsonSchemaOrganization`) are deprecated and will be removed in - **v5.0.0**. Use the object-oriented `JSONSchema` and `SchemaOrganization` models + **v5.0.0**. Use the object-oriented `JSONSchema` and `Organization` models instead — see the [Working with JSON Schema tutorial](../tutorials/python/json_schema.md). ::: synapseclient.services.json_schema diff --git a/docs/tutorials/python/tutorial_scripts/json_schema.py b/docs/tutorials/python/tutorial_scripts/json_schema.py index e5bf16942..98ba322f3 100644 --- a/docs/tutorials/python/tutorial_scripts/json_schema.py +++ b/docs/tutorials/python/tutorial_scripts/json_schema.py @@ -4,7 +4,7 @@ import synapseclient from synapseclient.core.utils import make_bogus_data_file -from synapseclient.models import File, Folder, JSONSchema, Project, SchemaOrganization +from synapseclient.models import File, Folder, JSONSchema, Organization, Project # 1. Set up Synapse Python client syn = synapseclient.Synapse() @@ -48,7 +48,7 @@ # 3. Try create test organization and json schema if they do not exist # --8<-- [start:create_org_and_schema] -organization = SchemaOrganization(name=ORG_NAME) +organization = Organization(name=ORG_NAME) try: organization.store() except Exception as e: diff --git a/mkdocs.yml b/mkdocs.yml index daa3cecf6..4783e355e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,7 +93,7 @@ nav: - Curator: reference/experimental/sync/curator.md - Link: reference/experimental/sync/link_entity.md - Functional Interfaces: reference/experimental/functional_interfaces.md - - SchemaOrganization: reference/experimental/sync/schema_organization.md + - Organization: reference/experimental/sync/organization.md - JSONSchema: reference/experimental/sync/json_schema.md - Wiki: reference/experimental/sync/wiki.md - FormGroup and Form: reference/experimental/sync/form.md @@ -123,7 +123,7 @@ nav: - UserProfile: reference/experimental/async/user_profile.md - Curator: reference/experimental/async/curator.md - Link: reference/experimental/async/link_entity.md - - SchemaOrganization: reference/experimental/async/schema_organization.md + - Organization: reference/experimental/async/organization.md - JSONSchema: reference/experimental/async/json_schema.md - Wiki: reference/experimental/async/wiki.md - FormGroup and Form: reference/experimental/async/form.md diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index c55e43410..12dd35d3e 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -145,6 +145,28 @@ get_project_setting, update_project_setting, ) +from .search_services import ( + autocomplete_search, + bind_search_config_to_entity, + clear_search_config_binding, + create_column_analyzer_override, + create_search_configuration, + create_synonym_set, + create_text_analyzer, + get_column_analyzer_override, + get_search_config_binding, + get_search_configuration, + get_synonym_set, + get_text_analyzer, + list_column_analyzer_overrides, + list_search_configurations, + list_synonym_sets, + list_text_analyzers, + update_column_analyzer_override, + update_search_configuration, + update_synonym_set, + update_text_analyzer, +) from .storage_location_services import ( create_storage_location_setting, get_storage_location_setting, @@ -392,4 +414,25 @@ "create_project_setting", "update_project_setting", "delete_project_setting", + # search_services + "create_text_analyzer", + "get_text_analyzer", + "update_text_analyzer", + "list_text_analyzers", + "create_column_analyzer_override", + "get_column_analyzer_override", + "update_column_analyzer_override", + "list_column_analyzer_overrides", + "create_synonym_set", + "get_synonym_set", + "update_synonym_set", + "list_synonym_sets", + "create_search_configuration", + "get_search_configuration", + "update_search_configuration", + "list_search_configurations", + "bind_search_config_to_entity", + "get_search_config_binding", + "clear_search_config_binding", + "autocomplete_search", ] diff --git a/synapseclient/api/entity_factory.py b/synapseclient/api/entity_factory.py index 919af7227..db3472641 100644 --- a/synapseclient/api/entity_factory.py +++ b/synapseclient/api/entity_factory.py @@ -343,6 +343,7 @@ class type. This will also download the file if `download_file` is set to True. MaterializedView, Project, RecordSet, + SearchIndex, SubmissionView, Table, VirtualTable, @@ -379,6 +380,7 @@ class type. This will also download the file if `download_file` is set to True. concrete_types.RECORD_SET_ENTITY: RecordSet, concrete_types.SUBMISSION_VIEW: SubmissionView, concrete_types.VIRTUAL_TABLE: VirtualTable, + concrete_types.SEARCH_INDEX_ENTITY: SearchIndex, concrete_types.LINK_ENTITY: Link, concrete_types.DOCKER_REPOSITORY: DockerRepository, } diff --git a/synapseclient/api/entity_services.py b/synapseclient/api/entity_services.py index 27561da16..af81a9ab4 100644 --- a/synapseclient/api/entity_services.py +++ b/synapseclient/api/entity_services.py @@ -1242,6 +1242,7 @@ async def main(): "submissionview", "dataset", "materializedview", + "searchindex", ] request_body = { diff --git a/synapseclient/api/search_services.py b/synapseclient/api/search_services.py new file mode 100644 index 000000000..839a48224 --- /dev/null +++ b/synapseclient/api/search_services.py @@ -0,0 +1,599 @@ +"""This module is responsible for exposing the services defined at: + + +It covers TextAnalyzer, ColumnAnalyzerOverride, SynonymSet, SearchConfiguration, +SearchConfigBinding, and the synchronous SearchIndex autocomplete endpoint. +The async SearchIndex query endpoint is exposed via the +`SearchIndexQuery.send_job_and_wait_async()` method on the model class +(`models.search_management.SearchIndexQuery`), which uses the shared +`AsynchronousCommunicator` mixin. +""" + +import json +from typing import TYPE_CHECKING, Any, Dict, Optional + +from synapseclient.core.utils import delete_none_keys + +if TYPE_CHECKING: + from synapseclient import Synapse + + +async def create_text_analyzer( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Create a new TextAnalyzer within the specified Organization. + + + + Arguments: + request: The TextAnalyzer object body. The analyzer's `settings` JSON is + parsed and any `$ref` entries inside its `filter` registry are + checked for qualified-name format and existence. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the created TextAnalyzer. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri="/search/text/analyzer", body=json.dumps(request) + ) + + +async def get_text_analyzer( + analyzer_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get a TextAnalyzer by its ID. + + + + Arguments: + analyzer_id: The numeric ID of the text analyzer to retrieve. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the requested TextAnalyzer. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async(uri=f"/search/text/analyzer/{analyzer_id}") + + +async def update_text_analyzer( + analyzer_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a TextAnalyzer. + + + + Arguments: + analyzer_id: The path ID of the text analyzer (must match the request + body's ID). + request: The updated TextAnalyzer object body. The `organizationName` + and `name` fields are immutable and cannot be modified after + creation. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the updated TextAnalyzer. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/search/text/analyzer/{analyzer_id}", body=json.dumps(request) + ) + + +async def list_text_analyzers( + organization_name: Optional[str] = None, + next_page_token: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """List TextAnalyzer objects, optionally filtered by Organization. + + + + Arguments: + organization_name: If `organizationName` is null, all text analyzers + across all Organizations are returned. + next_page_token: Results are paginated using a next page token. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the ListTextAnalyzersResponse, containing a + page of TextAnalyzers and a nextPageToken if more results exist. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + body = {"organizationName": organization_name, "nextPageToken": next_page_token} + delete_none_keys(body) + return await client.rest_post_async( + uri="/search/text/analyzer/list", body=json.dumps(body) + ) + + +async def create_column_analyzer_override( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Create a new ColumnAnalyzerOverride within the specified Organization. + + + + Arguments: + request: The ColumnAnalyzerOverride object body. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the created ColumnAnalyzerOverride. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri="/search/column/analyzer/override", body=json.dumps(request) + ) + + +async def get_column_analyzer_override( + column_analyzer_override_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get a ColumnAnalyzerOverride by its ID. + + + + Arguments: + column_analyzer_override_id: The ID of the column analyzer override to + retrieve. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the requested ColumnAnalyzerOverride. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async( + uri=f"/search/column/analyzer/override/{column_analyzer_override_id}" + ) + + +async def update_column_analyzer_override( + column_analyzer_override_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a ColumnAnalyzerOverride. + + + + Arguments: + column_analyzer_override_id: The path ID (must match the request + body's ID). + request: The updated ColumnAnalyzerOverride object body. Concurrency + is managed via the `etag` field. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the updated ColumnAnalyzerOverride. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/search/column/analyzer/override/{column_analyzer_override_id}", + body=json.dumps(request), + ) + + +async def list_column_analyzer_overrides( + organization_name: Optional[str] = None, + next_page_token: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """List ColumnAnalyzerOverride objects, optionally filtered by Organization. + + + + Arguments: + organization_name: If organizationName is null, all column analyzer + overrides across all Organizations are returned. + next_page_token: Results are paginated using a next page token. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the ListColumnAnalyzerOverridesResponse, + containing a page of ColumnAnalyzerOverrides and a nextPageToken if + more results exist. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + body = {"organizationName": organization_name, "nextPageToken": next_page_token} + delete_none_keys(body) + return await client.rest_post_async( + uri="/search/column/analyzer/override/list", body=json.dumps(body) + ) + + +async def create_synonym_set( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Create a new SynonymSet within the specified Organization. + + + + Arguments: + request: The SynonymSet object body. The supplied `definition` is + parsed to confirm it is valid JSON before being passed to AOSS; + supply synonym lists inline via the `synonyms` array rather than + using file-based parameters. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the created SynonymSet. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri="/search/synonym/set", body=json.dumps(request) + ) + + +async def get_synonym_set( + synonym_set_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get a SynonymSet by its ID. + + + + Arguments: + synonym_set_id: The ID of the synonym set to retrieve. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the requested SynonymSet. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async(uri=f"/search/synonym/set/{synonym_set_id}") + + +async def update_synonym_set( + synonym_set_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a SynonymSet. + + + + Arguments: + synonym_set_id: The path ID (must match the request body's ID). + request: The updated SynonymSet object body. The `definition` field is + re-parsed to validate JSON formatting. The `organizationName` and + `name` fields are immutable after creation. Concurrency is managed + via `etag`; mismatches return a 409 Conflict. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the updated SynonymSet. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/search/synonym/set/{synonym_set_id}", body=json.dumps(request) + ) + + +async def list_synonym_sets( + organization_name: Optional[str] = None, + next_page_token: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """List SynonymSet objects, optionally filtered by Organization. + + + + Arguments: + organization_name: If organizationName is null, all synonym sets + across all Organizations are returned. + next_page_token: Results are paginated using a next page token. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the ListSynonymSetsResponse, containing a + page of SynonymSets and a nextPageToken if more results exist. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + body = {"organizationName": organization_name, "nextPageToken": next_page_token} + delete_none_keys(body) + return await client.rest_post_async( + uri="/search/synonym/set/list", body=json.dumps(body) + ) + + +async def create_search_configuration( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Create a new SearchConfiguration within the specified Organization. + + + + Arguments: + request: The SearchConfiguration object body. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the created SearchConfiguration. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri="/search/configuration", body=json.dumps(request) + ) + + +async def get_search_configuration( + search_configuration_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get a SearchConfiguration by its ID. + + + + Arguments: + search_configuration_id: The ID of the search configuration to + retrieve. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the requested SearchConfiguration. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async( + uri=f"/search/configuration/{search_configuration_id}" + ) + + +async def update_search_configuration( + search_configuration_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a SearchConfiguration. + + + + Arguments: + search_configuration_id: The path ID (must match the request body's + ID). + request: The updated SearchConfiguration object body. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the updated SearchConfiguration. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/search/configuration/{search_configuration_id}", + body=json.dumps(request), + ) + + +async def list_search_configurations( + organization_name: Optional[str] = None, + next_page_token: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """List SearchConfiguration objects, optionally filtered by Organization. + + + + Arguments: + organization_name: If organizationName is null, all search + configurations across all Organizations are returned. + next_page_token: Results are paginated using a next page token. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the ListSearchConfigurationsResponse, + containing a page of SearchConfigurations and a nextPageToken if more + results exist. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + body = {"organizationName": organization_name, "nextPageToken": next_page_token} + delete_none_keys(body) + return await client.rest_post_async( + uri="/search/configuration/list", body=json.dumps(body) + ) + + +async def bind_search_config_to_entity( + entity_id: str, + search_configuration_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Bind a SearchConfiguration to an entity (typically a project). The + caller must have EDIT permission on the entity. Replaces any existing + binding on that entity. + + + + Arguments: + entity_id: The ID of the entity to bind to. + search_configuration_id: The ID of the SearchConfiguration to bind. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the created SearchConfigBinding. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + body = { + "entityId": entity_id, + "searchConfigurationId": search_configuration_id, + } + return await client.rest_put_async( + uri=f"/entity/{entity_id}/searchconfig/binding", body=json.dumps(body) + ) + + +async def get_search_config_binding( + entity_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get the effective SearchConfigBinding for an entity. Walks up the + entity hierarchy (entity -> folder -> project) and returns the first + binding found on the entity or any ancestor. + + + + Arguments: + entity_id: The ID of the entity to look up. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the effective SearchConfigBinding. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async(uri=f"/entity/{entity_id}/searchconfig/binding") + + +async def clear_search_config_binding( + entity_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> None: + """Clear the SearchConfigBinding on a specific entity. + + + + Arguments: + entity_id: The ID of the entity whose binding to clear. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + None + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + await client.rest_delete_async(uri=f"/entity/{entity_id}/searchconfig/binding") + + +async def autocomplete_search( + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Perform a synchronous autocomplete search query against a SearchIndex. + Purpose-built for type-ahead input: no pagination, sorting customization, + or aggregations are available; the server caps every response at 8 hits, + ordered by relevance. For more complex search needs, use the asynchronous + search endpoint instead. + + + + Arguments: + request: The SearchAutocompleteRequest body. Only two top-level keys + are permitted in the `searchQuery`: `query` (required, must use + one of `prefix`, `match_phrase_prefix`, or `match_bool_prefix`) and + `_source` (optional, a source filter to narrow returned fields). + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A dictionary representing the SearchQueryResults, capped at 8 hits. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri="/search/autocomplete", body=json.dumps(request) + ) diff --git a/synapseclient/core/constants/concrete_types.py b/synapseclient/core/constants/concrete_types.py index d8cbdbd59..a994665de 100644 --- a/synapseclient/core/constants/concrete_types.py +++ b/synapseclient/core/constants/concrete_types.py @@ -89,8 +89,12 @@ MATERIALIZED_VIEW = "org.sagebionetworks.repo.model.table.MaterializedView" SUBMISSION_VIEW = "org.sagebionetworks.repo.model.table.SubmissionView" VIRTUAL_TABLE = "org.sagebionetworks.repo.model.table.VirtualTable" +SEARCH_INDEX_ENTITY = "org.sagebionetworks.repo.model.search.table.SearchIndex" DOCKER_REPOSITORY = "org.sagebionetworks.repo.model.docker.DockerRepository" +# Search Management +SEARCH_INDEX_QUERY = "org.sagebionetworks.repo.model.search.table.SearchIndexQuery" + # upload requests MULTIPART_UPLOAD_REQUEST = "org.sagebionetworks.repo.model.file.MultipartUploadRequest" MULTIPART_UPLOAD_COPY_REQUEST = ( diff --git a/synapseclient/extensions/curator/schema_management.py b/synapseclient/extensions/curator/schema_management.py index 44b39959a..cf51e71a7 100644 --- a/synapseclient/extensions/curator/schema_management.py +++ b/synapseclient/extensions/curator/schema_management.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from synapseclient import Synapse from synapseclient.models.mixins.json_schema import JSONSchemaBinding - from synapseclient.models.schema_organization import JSONSchema + from synapseclient.models.organization import JSONSchema def register_jsonschema( @@ -129,7 +129,7 @@ async def register_jsonschema_async( ``` """ from synapseclient import Synapse - from synapseclient.models.schema_organization import JSONSchema + from synapseclient.models.organization import JSONSchema syn = Synapse.get_client(synapse_client=synapse_client) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index ce1a3bf9e..b1f8ed97f 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -28,10 +28,32 @@ from synapseclient.models.link import Link from synapseclient.models.materializedview import MaterializedView from synapseclient.models.mixins.table_components import QueryMixin +from synapseclient.models.organization import ( + JSONSchema, + Organization, + SchemaOrganization, +) from synapseclient.models.project import Project from synapseclient.models.project_setting import ProjectSetting from synapseclient.models.recordset import RecordSet -from synapseclient.models.schema_organization import JSONSchema, SchemaOrganization +from synapseclient.models.search_index import SearchIndex +from synapseclient.models.search_management import ( + ColumnAnalyzerOverride, + ColumnAnalyzerOverrideEntry, + SearchAutocompleteRequest, + SearchConfigBinding, + SearchConfiguration, + SearchFieldValue, + SearchHighlight, + SearchHit, + SearchIndexQuery, + SearchIndexState, + SearchIndexStatus, + SearchQuery, + SearchQueryPart, + SynonymSet, + TextAnalyzer, +) from synapseclient.models.services import FailureStrategy from synapseclient.models.storage_location import ( StorageLocation, @@ -166,6 +188,7 @@ "WikiHistorySnapshot", "WikiHeader", # JSON Schema models + "Organization", "SchemaOrganization", "JSONSchema", # Form models @@ -177,6 +200,23 @@ "UploadType", # Project Setting models "ProjectSetting", + # SearchIndex / Search Management models + "SearchIndex", + "SearchIndexQuery", + "SearchIndexStatus", + "SearchIndexState", + "SearchQuery", + "SearchQueryPart", + "SearchAutocompleteRequest", + "SearchHit", + "SearchFieldValue", + "SearchHighlight", + "SearchConfiguration", + "SearchConfigBinding", + "TextAnalyzer", + "ColumnAnalyzerOverride", + "ColumnAnalyzerOverrideEntry", + "SynonymSet", ] # Static methods to expose as functions diff --git a/synapseclient/models/folder.py b/synapseclient/models/folder.py index 02b4f3cc1..e49db55ab 100644 --- a/synapseclient/models/folder.py +++ b/synapseclient/models/folder.py @@ -34,6 +34,7 @@ EntityView, MaterializedView, Project, + SearchIndex, SubmissionView, Table, VirtualTable, @@ -77,6 +78,7 @@ class Folder( datasetcollections: Dataset collections that exist within this folder. materializedviews: Materialized views that exist within this folder. virtualtables: Virtual tables that exist within this folder. + searchindexes: Search indexes that exist within this folder. annotations: Additional metadata associated with the folder. The key is the name of your desired annotations. The value is an object containing a list of values (use empty list to represent no values for key) and the value type @@ -159,6 +161,9 @@ class Folder( virtualtables: List["VirtualTable"] = field(default_factory=list, compare=False) """Virtual tables that exist within this folder.""" + searchindexes: List["SearchIndex"] = field(default_factory=list, compare=False) + """Search indexes that exist within this folder.""" + annotations: Optional[ Dict[ str, diff --git a/synapseclient/models/mixins/access_control.py b/synapseclient/models/mixins/access_control.py index ccec02aae..5db5f7eca 100644 --- a/synapseclient/models/mixins/access_control.py +++ b/synapseclient/models/mixins/access_control.py @@ -33,6 +33,7 @@ File, Folder, MaterializedView, + SearchIndex, SubmissionView, Table, VirtualTable, @@ -49,6 +50,7 @@ "datasetcollection": "datasetcollections", "materializedview": "materializedviews", "virtualtable": "virtualtables", + "searchindex": "searchindexes", } @@ -203,6 +205,7 @@ class AccessControllable(AccessControllableSynchronousProtocol): datasetcollections: List["DatasetCollection"] = None materializedviews: List["MaterializedView"] = None virtualtables: List["VirtualTable"] = None + searchindexes: List["SearchIndex"] = None async def get_permissions_async( self, diff --git a/synapseclient/models/mixins/asynchronous_job.py b/synapseclient/models/mixins/asynchronous_job.py index 88cce73be..03fbe0985 100644 --- a/synapseclient/models/mixins/asynchronous_job.py +++ b/synapseclient/models/mixins/asynchronous_job.py @@ -20,6 +20,7 @@ GRID_RECORD_SET_EXPORT_REQUEST, QUERY_BUNDLE_REQUEST, QUERY_TABLE_CSV_REQUEST, + SEARCH_INDEX_QUERY, SYNCHRONIZE_GRID_REQUEST, TABLE_UPDATE_TRANSACTION_REQUEST, UPLOAD_TO_TABLE_PREVIEW_REQUEST, @@ -45,6 +46,7 @@ QUERY_BUNDLE_REQUEST: "/entity/{entityId}/table/query/async", GRID_CSV_IMPORT_REQUEST: "/grid/import/csv/async", UPLOAD_TO_TABLE_PREVIEW_REQUEST: "/table/upload/csv/preview/async", + SEARCH_INDEX_QUERY: "/search/query/async", } diff --git a/synapseclient/models/mixins/storable_container.py b/synapseclient/models/mixins/storable_container.py index df0933a41..39f35af13 100644 --- a/synapseclient/models/mixins/storable_container.py +++ b/synapseclient/models/mixins/storable_container.py @@ -34,6 +34,7 @@ LINK_ENTITY, MATERIALIZED_VIEW, PROJECT_ENTITY, + SEARCH_INDEX_ENTITY, SUBMISSION_VIEW, TABLE_ENTITY, VIRTUAL_TABLE, @@ -72,6 +73,7 @@ File, Folder, MaterializedView, + SearchIndex, SubmissionView, Table, VirtualTable, @@ -95,6 +97,7 @@ class StorableContainer(StorableContainerSynchronousProtocol): - `datasetcollections` - `materializedviews` - `virtualtables` + - `searchindexes` - `_last_persistent_instance` - `_synced_from_synapse` @@ -116,6 +119,7 @@ class StorableContainer(StorableContainerSynchronousProtocol): datasetcollections: List["DatasetCollection"] = None materializedviews: List["MaterializedView"] = None virtualtables: List["VirtualTable"] = None + searchindexes: List["SearchIndex"] = None _last_persistent_instance: None = None _synced_from_synapse: bool = False @@ -223,7 +227,7 @@ async def sync_from_synapse_async( Defaults to `["folder", "file", "table", "entityview", "dockerrepo", "submissionview", "dataset", "datasetcollection", "materializedview", - "virtualtable"]`. + "virtualtable", "searchindex"]`. manifest: Determines whether to generate a manifest CSV file. Options are: - `all` (default): generate `manifest.csv` in every synced directory @@ -530,6 +534,7 @@ async def _sync_from_synapse_async( self.datasetcollections = [] self.materializedviews = [] self.virtualtables = [] + self.searchindexes = [] for child in children: pending_tasks.extend( @@ -885,7 +890,7 @@ async def walk_async( Defaults to `["folder", "file", "table", "entityview", "dockerrepo", "submissionview", "dataset", "datasetcollection", "materializedview", - "virtualtable"]`. The "folder" type is always included so the hierarchy + "virtualtable", "searchindex"]`. The "folder" type is always included so the hierarchy can be traversed. recursive: Whether to recursively traverse subdirectories. Defaults to True. display_ascii_tree: If True, display an ASCII tree representation as the @@ -1017,6 +1022,7 @@ async def my_function(): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ] if follow_link: include_types.append("link") @@ -1169,7 +1175,7 @@ def walk( Defaults to `["folder", "file", "table", "entityview", "dockerrepo", "submissionview", "dataset", "datasetcollection", "materializedview", - "virtualtable"]`. The "folder" type is always included so the hierarchy + "virtualtable", "searchindex"]`. The "folder" type is always included so the hierarchy can be traversed. recursive: Whether to recursively traverse subdirectories. Defaults to True. display_ascii_tree: If True, display an ASCII tree representation as the @@ -1311,7 +1317,7 @@ async def _retrieve_children( Defaults to `["folder", "file", "table", "entityview", "dockerrepo", "submissionview", "dataset", "datasetcollection", "materializedview", - "virtualtable"]`. + "virtualtable", "searchindex"]`. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -1331,6 +1337,7 @@ async def _retrieve_children( "datasetcollection", "materializedview", "virtualtable", + "searchindex", ] if follow_link: include_types.append("link") @@ -1605,6 +1612,17 @@ def _create_task_for_child( ) ) ) + elif synapse_id and child_type == SEARCH_INDEX_ENTITY: + # Lazy import to avoid circular import + from synapseclient.models import SearchIndex + + searchindex = SearchIndex(id=synapse_id, name=name) + self.searchindexes.append(searchindex) + pending_tasks.append( + asyncio.create_task( + wrap_coroutine(searchindex.get_async(synapse_client=synapse_client)) + ) + ) return pending_tasks @@ -1692,6 +1710,7 @@ def _resolve_sync_from_synapse_result( "DatasetCollection", "MaterializedView", "VirtualTable", + "SearchIndex", BaseException, ], failure_strategy: FailureStrategy, @@ -1726,6 +1745,7 @@ def _resolve_sync_from_synapse_result( or result.__class__.__name__ == "DatasetCollection" or result.__class__.__name__ == "MaterializedView" or result.__class__.__name__ == "VirtualTable" + or result.__class__.__name__ == "SearchIndex" ): # Do nothing as the objects are updated in place and the container has # already been updated to append the new objects. diff --git a/synapseclient/models/mixins/table_components.py b/synapseclient/models/mixins/table_components.py index 44591714d..cf2f4dd40 100644 --- a/synapseclient/models/mixins/table_components.py +++ b/synapseclient/models/mixins/table_components.py @@ -85,7 +85,7 @@ "DatasetCollection", "SubmissionView", ] -CLASSES_WITH_READ_ONLY_SCHEMA = ["MaterializedView", "VirtualTable"] +CLASSES_WITH_READ_ONLY_SCHEMA = ["MaterializedView", "VirtualTable", "SearchIndex"] PANDAS_TABLE_TYPE = { "floating": "DOUBLE", diff --git a/synapseclient/models/schema_organization.py b/synapseclient/models/organization.py similarity index 92% rename from synapseclient/models/schema_organization.py rename to synapseclient/models/organization.py index cfa3f30df..ad1d5f195 100644 --- a/synapseclient/models/schema_organization.py +++ b/synapseclient/models/organization.py @@ -1,5 +1,5 @@ """ -This file contains the SchemaOrganization and JSONSchema classes. +This file contains the Organization and JSONSchema classes. These are used to manage Organization and JSON Schema entities in Synapse. """ @@ -7,6 +7,8 @@ from dataclasses import dataclass, field from typing import Any, AsyncGenerator, Generator, Optional, Protocol +from deprecated import deprecated + from synapseclient import Synapse from synapseclient.api import ( create_organization, @@ -30,15 +32,13 @@ from synapseclient.models.mixins.json_schema import JSONSchemaVersionInfo -class SchemaOrganizationProtocol(Protocol): +class OrganizationProtocol(Protocol): """ The protocol for methods that are asynchronous but also have a synchronous counterpart that may also be called. """ - def get( - self, *, synapse_client: Optional["Synapse"] = None - ) -> "SchemaOrganization": + def get(self, *, synapse_client: Optional["Synapse"] = None) -> "Organization": """ Gets the metadata from Synapse for this organization @@ -50,17 +50,17 @@ def get( Returns: Itself - Example: Get an existing SchemaOrganization + Example: Get an existing Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") org.get() print(org) ``` @@ -68,9 +68,7 @@ def get( """ return self - def store( - self, *, synapse_client: Optional["Synapse"] = None - ) -> "SchemaOrganization": + def store(self, *, synapse_client: Optional["Synapse"] = None) -> "Organization": """ Stores this organization in Synapse @@ -82,17 +80,17 @@ def store( Returns: Itself - Example: Store the SchemaOrganization in Synapse + Example: Store the Organization in Synapse   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("my.org.name") + org = Organization("my.org.name") org.store() print(org) ``` @@ -109,17 +107,17 @@ def delete(self, *, synapse_client: Optional["Synapse"] = None) -> None: `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor - Example: Delete the SchemaOrganization from Synapse + Example: Delete the Organization from Synapse   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("my.org.name") + org = Organization("my.org.name") org.delete() ``` """ @@ -138,17 +136,17 @@ def get_json_schemas( Returns: A Generator containing the JSONSchemas that belong to this organization - Example: Get the JSONSchemas that are part of this SchemaOrganization + Example: Get the JSONSchemas that are part of this Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") js_generator = org.get_json_schemas() for item in js_generator: print(item) @@ -172,17 +170,17 @@ def get_acl(self, *, synapse_client: Optional["Synapse"] = None) -> dict[str, An A dictionary in the form of this response: [AccessControlList]https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/AccessControlList.html - Example: Get the ACL for the SchemaOrganization + Example: Get the ACL for the Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("my.org.name") + org = Organization("my.org.name") org.get_acl() ``` """ @@ -207,17 +205,17 @@ def update_acl( `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor - Example: Update the ACL for the SchemaOrganization + Example: Update the ACL for the Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse syn = Synapse() syn.login() - org = SchemaOrganization("my.org.name") + org = Organization("my.org.name") org.update_acl( principal_id=1, access_type=["READ"] @@ -229,7 +227,7 @@ def update_acl( @dataclass() @async_to_sync -class SchemaOrganization(SchemaOrganizationProtocol): +class Organization(OrganizationProtocol): """ Represents an [Organization](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/schema/Organization.html). @@ -258,7 +256,7 @@ def __post_init__(self) -> None: async def get_async( self, *, synapse_client: Optional["Synapse"] = None - ) -> "SchemaOrganization": + ) -> "Organization": """ Gets the metadata from Synapse for this organization @@ -273,12 +271,12 @@ async def get_async( Raises: ValueError: If the name has not been set - Example: Get an existing SchemaOrganization + Example: Get an existing Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -287,7 +285,7 @@ async def get_org(): syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") await org.get_async() return org @@ -297,14 +295,14 @@ async def get_org(): ``` """ if not self.name: - raise ValueError("SchemaOrganization must have a name") + raise ValueError("Organization must have a name") response = await get_organization(self.name, synapse_client=synapse_client) self.fill_from_dict(response) return self async def store_async( self, *, synapse_client: Optional["Synapse"] = None - ) -> "SchemaOrganization": + ) -> "Organization": """ Stores this organization in Synapse @@ -319,11 +317,11 @@ async def store_async( Raises: ValueError: If the name has not been set - Example: Store a new SchemaOrganization + Example: Store a new Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -332,7 +330,7 @@ async def store_org(): syn = Synapse() syn.login() - org = SchemaOrganization("my.new.org") + org = Organization("my.new.org") await org.store_async() return org @@ -342,7 +340,7 @@ async def store_org(): ``` """ if not self.name: - raise ValueError("SchemaOrganization must have a name") + raise ValueError("Organization must have a name") response = await create_organization(self.name, synapse_client=synapse_client) self.fill_from_dict(response) return self @@ -356,11 +354,11 @@ async def delete_async(self, *, synapse_client: Optional["Synapse"] = None) -> N `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor - Example: Delete a SchemaOrganization using a name + Example: Delete a Organization using a name   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -369,17 +367,17 @@ async def delete_org(): syn = Synapse() syn.login() - org = SchemaOrganization("my.org") + org = Organization("my.org") await org.delete_async() asyncio.run(delete_org()) ``` - Example: Delete a SchemaOrganization using an id + Example: Delete a Organization using an id   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -388,7 +386,7 @@ async def delete_org(): syn = Synapse() syn.login() - org = SchemaOrganization(id=1075) + org = Organization(id=1075) await org.delete_async() asyncio.run(delete_org()) @@ -417,11 +415,11 @@ async def get_json_schemas_async( `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor - Example: Get a list of JSONSchemas that belong to the SchemaOrganization + Example: Get a list of JSONSchemas that belong to the Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -430,7 +428,7 @@ async def get_schemas(): syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") js_generator = org.get_json_schemas_async() async for item in js_generator: print(item) @@ -440,7 +438,7 @@ async def get_schemas(): """ if not self.name: - raise ValueError("SchemaOrganization must have a name") + raise ValueError("Organization must have a name") response = list_json_schemas(self.name, synapse_client=synapse_client) async for item in response: yield JSONSchema().fill_from_dict(item) @@ -460,11 +458,11 @@ async def get_acl_async( A dictionary in the form of this response: [AccessControlList]https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/AccessControlList.html - Example: Get the ACL for a SchemaOrganization + Example: Get the ACL for a Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -473,7 +471,7 @@ async def get_acl(): syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") acl = await org.get_acl_async() return acl @@ -512,11 +510,11 @@ async def update_acl_async( `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor - Example: Update the ACL for a SchemaOrganization + Example: Update the ACL for a Organization   ```python - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization from synapseclient import Synapse import asyncio @@ -525,14 +523,14 @@ async def update_acl() -> None: syn = Synapse() syn.login() - org = SchemaOrganization("dpetest") + org = Organization("dpetest") await org.update_acl_async( principal_id=1, access_type=["READ"] ) asyncio.run(update_acl()) - + ``` """ acl = await self.get_acl_async(synapse_client=synapse_client) @@ -557,7 +555,7 @@ async def update_acl() -> None: synapse_client=synapse_client, ) - def fill_from_dict(self, response: dict[str, Any]) -> "SchemaOrganization": + def fill_from_dict(self, response: dict[str, Any]) -> "Organization": """ Fills in this classes attributes using a Synapse API response @@ -575,6 +573,18 @@ def fill_from_dict(self, response: dict[str, Any]) -> "SchemaOrganization": return self +@deprecated( + version="5.0.0", + reason="SchemaOrganization has been renamed to Organization. " + "This alias will be removed in a future release; use Organization instead.", +) +class SchemaOrganization(Organization): + """Deprecated alias for [Organization][synapseclient.models.Organization]. + + This class will be removed in a future release. Use `Organization` instead. + """ + + class JSONSchemaProtocol(Protocol): """ The protocol for methods that are asynchronous but also @@ -881,7 +891,7 @@ async def get_schema(): # Check that the org exists, # if it doesn't list_json_schemas will unhelpfully return an empty generator. - org = SchemaOrganization(self.organization_name) + org = Organization(self.organization_name) await org.get_async(synapse_client=synapse_client) org_schemas = list_json_schemas( @@ -1396,9 +1406,9 @@ def _create_json_schema_version_from_response( ) -def list_json_schema_organizations( +def list_organizations( synapse_client: Optional["Synapse"] = None, -) -> list[SchemaOrganization]: +) -> list[Organization]: """ Lists all the Schema Organizations currently in Synapse @@ -1408,26 +1418,43 @@ def list_json_schema_organizations( instance from the Synapse class constructor Returns: - A list of SchemaOrganizations + A list of Organizations Example: - from synapseclient.models.schema_organization import list_json_schema_organizations + from synapseclient.models.organization import list_organizations from synapseclient import Synapse syn = Synapse() syn.login() - all_orgs = list_json_schema_organizations() + all_orgs = list_organizations() for item in all_orgs: print(item) """ all_orgs = [ - SchemaOrganization().fill_from_dict(org) + Organization().fill_from_dict(org) for org in list_organizations_sync(synapse_client=synapse_client) ] return all_orgs +@deprecated( + version="5.0.0", + reason="Renamed to list_organizations. " + "This alias will be removed in a future release; use list_organizations instead.", +) +def list_json_schema_organizations( + synapse_client: Optional["Synapse"] = None, +) -> list[Organization]: + """Deprecated alias for + [list_organizations][synapseclient.models.organization.list_organizations]. + + This function will be removed in a future release. Use `list_organizations` + instead. + """ + return list_organizations(synapse_client=synapse_client) + + def _check_org_name(name: str) -> None: """ Checks that the input name is a valid Synapse Organization diff --git a/synapseclient/models/project.py b/synapseclient/models/project.py index b1e95899f..5dd630c20 100644 --- a/synapseclient/models/project.py +++ b/synapseclient/models/project.py @@ -33,6 +33,7 @@ DatasetCollection, EntityView, MaterializedView, + SearchIndex, SubmissionView, Table, VirtualTable, @@ -75,6 +76,7 @@ class Project( datasetcollections: Any dataset collections that are at the root directory of the project. materializedviews: Any materialized views that are at the root directory of the project. virtualtables: Any virtual tables that are at the root directory of the project. + searchindexes: Any search indexes that are at the root directory of the project. annotations: Additional metadata associated with the folder. The key is the name of your desired annotations. The value is an object containing a list of values (use empty list to represent no values for key) and the value type @@ -194,6 +196,9 @@ class Project( virtualtables: List["VirtualTable"] = field(default_factory=list, compare=False) """Any virtual tables that are at the root directory of the project.""" + searchindexes: List["SearchIndex"] = field(default_factory=list, compare=False) + """Any search indexes that are at the root directory of the project.""" + annotations: Optional[ Dict[ str, diff --git a/synapseclient/models/protocols/search_index_protocol.py b/synapseclient/models/protocols/search_index_protocol.py new file mode 100644 index 000000000..6c8e2e68f --- /dev/null +++ b/synapseclient/models/protocols/search_index_protocol.py @@ -0,0 +1,169 @@ +"""Protocol for the specific methods of this class that have synchronous counterparts +generated at runtime.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol + +from typing_extensions import Self + +from synapseclient import Synapse + +if TYPE_CHECKING: + from synapseclient.models.search_management import SearchHit + + +class SearchIndexSynchronousProtocol(Protocol): + """Protocol defining the synchronous interface for SearchIndex operations.""" + + def store( + self, + dry_run: bool = False, + *, + job_timeout: int = 600, + synapse_client: Optional[Synapse] = None, + ) -> "Self": + """Store metadata about a SearchIndex including the annotations. Creates + a new SearchIndex if `id` is not set, or updates the existing one + otherwise. `defining_sql` must be set before calling this. + + Arguments: + dry_run: If True, will not actually store the SearchIndex but will log + to the console what would be created or updated. + job_timeout: The maximum amount of time to wait for the index-build job + to complete before raising an error. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself. + + Raises: + ValueError: If `defining_sql` is not set. + + Example: Create a new SearchIndex. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + syn = Synapse() + syn.login() + + index = SearchIndex( + name="My Search Index", + parent_id="syn12345", + # syn67890 must be a table or a view; multi-entity JOINs are not supported + defining_sql="SELECT * FROM syn67890", + ) + index = index.store() + print(f"Created SearchIndex: {index.id}") + ``` + """ + return self + + def get( + self, + include_activity: bool = False, + *, + synapse_client: Optional[Synapse] = None, + ) -> "Self": + """Get the metadata about the SearchIndex from Synapse. Either `id`, or + `name` and `parent_id`, must be set before calling this. + + Arguments: + include_activity: If True, will include the provenance activity on + the returned SearchIndex. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the Synapse response. + + Example: Get a SearchIndex by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + syn = Synapse() + syn.login() + + index = SearchIndex(id="syn12345").get() + print(index.name, index.defining_sql) + ``` + """ + return self + + def delete(self, *, synapse_client: Optional[Synapse] = None) -> None: + """Delete the SearchIndex from Synapse. `id` must be set before calling + this. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Delete a SearchIndex by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + syn = Synapse() + syn.login() + + SearchIndex(id="syn12345").delete() + ``` + """ + return None + + def autocomplete( + self, + query: Dict[str, Any], + source: Optional[Dict[str, Any]] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> List["SearchHit"]: + """Run a synchronous autocomplete search against this index. The + autocomplete endpoint allowlists only prefix-style queries (`prefix`, + `match_phrase_prefix`, or `match_bool_prefix`) and caps results at 8. + + Arguments: + query: The top-level OpenSearch Query DSL clause; restricted + server-side to `prefix`, `match_phrase_prefix`, or + `match_bool_prefix`. + source: Optional source filter selecting which columns are returned + on each hit. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The matching SearchHits, capped at 8. + + Raises: + ValueError: If the ``id`` attribute has not been set. + + Example: Autocomplete titles beginning with "alz". +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + syn = Synapse() + syn.login() + + index = SearchIndex(id="syn12345") + hits = index.autocomplete( + query={"match_phrase_prefix": {"title": {"query": "alz"}}}, + ) + for hit in hits: + print(hit.row_id, hit.fields) + ``` + """ + return [] diff --git a/synapseclient/models/protocols/search_management_protocol.py b/synapseclient/models/protocols/search_management_protocol.py new file mode 100644 index 000000000..11d185b7f --- /dev/null +++ b/synapseclient/models/protocols/search_management_protocol.py @@ -0,0 +1,106 @@ +"""Protocol for the specific methods of this class that have synchronous counterparts +generated at runtime.""" + +from typing import TYPE_CHECKING, Optional, Protocol + +from typing_extensions import Self + +if TYPE_CHECKING: + from synapseclient import Synapse + + +class SearchConfigBindingSynchronousProtocol(Protocol): + """Synchronous interface for SearchConfigBinding operations.""" + + def store(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Bind ``search_configuration_id`` to the entity ``object_id``. Replaces + any existing binding on that entity. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the created SearchConfigBinding. + + Raises: + ValueError: If ``object_id`` or ``search_configuration_id`` is not set. + + Example: Bind a SearchConfiguration to a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + binding = SearchConfigBinding( + object_id="syn12345", + search_configuration_id="6789", + ) + binding = binding.store() + print(f"Bound SearchConfiguration {binding.search_configuration_id} " + f"to {binding.object_id}") + ``` + """ + return self + + def get(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Get the effective binding for the entity ``object_id``, resolved by + walking up the entity hierarchy. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the resolved SearchConfigBinding. + + Raises: + ValueError: If ``object_id`` is not set. + + Example: Get the effective binding for a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + binding = SearchConfigBinding(object_id="syn12345").get() + print(binding.search_configuration_id) + ``` + """ + return self + + def delete(self, *, synapse_client: Optional["Synapse"] = None) -> None: + """Clear the binding on the entity ``object_id``. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Raises: + ValueError: If ``object_id`` is not set. + + Example: Clear the binding on a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + SearchConfigBinding(object_id="syn12345").delete() + ``` + """ + return None diff --git a/synapseclient/models/search_index.py b/synapseclient/models/search_index.py new file mode 100644 index 000000000..5e0900c54 --- /dev/null +++ b/synapseclient/models/search_index.py @@ -0,0 +1,436 @@ +"""SearchIndex entity model. + +A SearchIndex is a Synapse entity whose content is defined by a Synapse SQL query +(`defining_sql`). An OpenSearch index is built from the query results, supporting +full-text search, faceted search, and autocomplete. +""" + +from copy import deepcopy +from dataclasses import dataclass, field, replace +from datetime import date, datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +from typing_extensions import Self + +from synapseclient import Synapse +from synapseclient.core.async_utils import async_to_sync +from synapseclient.core.constants import concrete_types +from synapseclient.core.utils import delete_none_keys +from synapseclient.models.activity import Activity +from synapseclient.models.mixins.access_control import AccessControllable +from synapseclient.models.mixins.table_components import ( + DeleteMixin, + GetMixin, + QueryMixin, + TableBase, + TableStoreMixin, +) +from synapseclient.models.protocols.search_index_protocol import ( + SearchIndexSynchronousProtocol, +) + +if TYPE_CHECKING: + from synapseclient.models.search_management import SearchHit + + +@dataclass +@async_to_sync +class SearchIndex( + SearchIndexSynchronousProtocol, + AccessControllable, + TableBase, + TableStoreMixin, + DeleteMixin, + GetMixin, + QueryMixin, +): + """ + A SearchIndex is a Synapse entity whose content is defined by a Synapse SQL + query (`defining_sql`). An OpenSearch index is built from the query results, + supporting full-text search, faceted search, and autocomplete. + + The `defining_sql` must reference exactly one table-like entity. Multi-entity + JOIN/UNION queries are not supported. Optionally, a `search_configuration_id` + may be supplied to control the analyzer/synonym settings used when building + the index. If not specified, the configuration is resolved by walking up + the entity hierarchy. + + REST API model: + + Attributes: + id: The unique immutable ID for this entity. + name: The name of this entity. + description: The description of this entity. + etag: Synapse OCC etag. + created_on: Date this entity was created. + modified_on: Date this entity was last modified. + created_by: The ID of the user that created this entity. + modified_by: The ID of the user that last modified this entity. + parent_id: The ID of the parent entity. + version_number: The version number issued to this version on the object. + version_label: The version label for this entity. + version_comment: The version comment for this entity. + is_latest_version: If this is the latest version of the object. + defining_sql: The Synapse SQL statement that defines which columns and + rows are indexed. + search_configuration_id: ID of the SearchConfiguration to apply when + building this index. Optional. + annotations: Additional metadata associated with the entity. + activity: Provenance for this entity. + + Example: Create a new SearchIndex. + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + syn = Synapse() + syn.login() + + index = SearchIndex( + name="My Search Index", + parent_id="syn12345", + # syn67890 must be a table or a view; multi-entity JOINs are not supported + defining_sql="SELECT * FROM syn67890", + ) + index = index.store() + print(f"Created SearchIndex: {index.id}") + ``` + """ + + id: Optional[str] = None + """The unique immutable ID for this entity. A new ID will be generated for + new Entities. Once issued, this ID is guaranteed to never change or be + re-issued.""" + + name: Optional[str] = None + """The name of this entity. Must be 256 characters or less. Names may only + contain: letters, numbers, spaces, underscores, hyphens, periods, plus + signs, apostrophes, and parentheses.""" + + description: Optional[str] = None + """The description of this entity. Must be 1000 characters or less.""" + + etag: Optional[str] = field(default=None, compare=False) + """Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle + concurrent updates. Since the E-Tag changes every time an entity is + updated it is used to detect when a client's current representation of an + entity is out-of-date.""" + + created_on: Optional[str] = field(default=None, compare=False) + """The date this entity was created.""" + + modified_on: Optional[str] = field(default=None, compare=False) + """The date this entity was last modified.""" + + created_by: Optional[str] = field(default=None, compare=False) + """The ID of the user that created this entity.""" + + modified_by: Optional[str] = field(default=None, compare=False) + """The ID of the user that last modified this entity.""" + + parent_id: Optional[str] = None + """The ID of the Entity that is the parent of this Entity.""" + + version_number: Optional[int] = field(default=None, compare=False) + """The version number issued to this version on the object.""" + + version_label: Optional[str] = None + """The version label for this entity.""" + + version_comment: Optional[str] = None + """The version comment for this entity.""" + + is_latest_version: Optional[bool] = field(default=None, compare=False) + """If this is the latest version of the object.""" + + defining_sql: Optional[str] = None + """The Synapse SQL statement that defines which columns and rows are indexed. + Must reference exactly one entity.""" + + search_configuration_id: Optional[str] = None + """The ID of the SearchConfiguration to apply when building this search + index. If not provided, the system will check for a search configuration + binding on the parent project/folder hierarchy, or use platform defaults.""" + + _last_persistent_instance: Optional["SearchIndex"] = field( + default=None, repr=False, compare=False + ) + + annotations: Optional[ + Dict[ + str, + Union[ + List[str], + List[bool], + List[float], + List[int], + List[date], + List[datetime], + ], + ] + ] = field(default_factory=dict, compare=False) + + activity: Optional[Activity] = field(default=None, compare=False) + + @property + def has_changed(self) -> bool: + """Checks if the object has changed since the last persistent instance.""" + return self._last_persistent_instance != self + + def _set_last_persistent_instance(self) -> None: + """Stash the last time this object interacted with Synapse.""" + del self._last_persistent_instance + self._last_persistent_instance = replace(self) + self._last_persistent_instance.activity = ( + replace(self.activity) if self.activity and self.activity.id else None + ) + self._last_persistent_instance.annotations = ( + deepcopy(self.annotations) if self.annotations else {} + ) + + def fill_from_dict( + self, entity: Dict[str, Any], set_annotations: bool = True + ) -> "SearchIndex": + """Populate this dataclass from a Synapse REST API entity dict.""" + self.id = entity.get("id", None) + self.name = entity.get("name", None) + self.description = entity.get("description", None) + self.parent_id = entity.get("parentId", None) + self.etag = entity.get("etag", None) + self.created_on = entity.get("createdOn", None) + self.created_by = entity.get("createdBy", None) + self.modified_on = entity.get("modifiedOn", None) + self.modified_by = entity.get("modifiedBy", None) + self.version_number = entity.get("versionNumber", None) + self.version_label = entity.get("versionLabel", None) + self.version_comment = entity.get("versionComment", None) + self.is_latest_version = entity.get("isLatestVersion", None) + self.defining_sql = entity.get("definingSQL", None) + self.search_configuration_id = entity.get("searchConfigurationId", None) + + if set_annotations: + self.annotations = entity.get("annotations", {}) + + return self + + def to_synapse_request(self) -> Dict[str, Any]: + """Convert the request to the body expected by the Synapse REST API.""" + entity = { + "name": self.name, + "description": self.description, + "id": self.id, + "etag": self.etag, + "createdOn": self.created_on, + "modifiedOn": self.modified_on, + "createdBy": self.created_by, + "modifiedBy": self.modified_by, + "parentId": self.parent_id, + "concreteType": concrete_types.SEARCH_INDEX_ENTITY, + "versionNumber": self.version_number, + "versionLabel": self.version_label, + "versionComment": self.version_comment, + "isLatestVersion": self.is_latest_version, + "definingSQL": self.defining_sql, + "searchConfigurationId": self.search_configuration_id, + } + delete_none_keys(entity) + result = {"entity": entity} + delete_none_keys(result) + return result + + async def store_async( + self, + dry_run: bool = False, + *, + job_timeout: int = 600, + synapse_client: Optional[Synapse] = None, + ) -> "Self": + """Asynchronously store the SearchIndex entity. Creates a new SearchIndex + if `id` is not set, or updates the existing one otherwise. `defining_sql` + must be set before calling this. + + Arguments: + dry_run: If True, will not actually store the SearchIndex but will log + to the console what would be created or updated. + job_timeout: The maximum amount of time to wait for the index-build job + to complete before raising an error. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself. + + Raises: + ValueError: If `defining_sql` is not set. + + Example: Create a new SearchIndex. +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + async def main(): + syn = Synapse() + syn.login() + + index = SearchIndex( + name="My Search Index", + parent_id="syn12345", + # syn67890 must be a table or a view; + defining_sql="SELECT * FROM syn67890", + ) + index = await index.store_async() + print(f"Created SearchIndex: {index.id}") + + asyncio.run(main()) + ``` + """ + if not self.defining_sql: + raise ValueError( + "The defining_sql attribute must be set for a SearchIndex." + ) + return await super().store_async( + dry_run=dry_run, job_timeout=job_timeout, synapse_client=synapse_client + ) + + async def get_async( + self, + include_activity: bool = False, + *, + synapse_client: Optional[Synapse] = None, + ) -> "Self": + """Asynchronously fetch the SearchIndex metadata. Either `id`, or `name` + and `parent_id`, must be set before calling this. + + Arguments: + include_activity: If True, will include the provenance activity on + the returned SearchIndex. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the Synapse response. + + Example: Get a SearchIndex by ID. +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + async def main(): + syn = Synapse() + await syn.login_async() + + index = await SearchIndex(id="syn12345").get_async() + print(index.name, index.defining_sql) + + asyncio.run(main()) + ``` + """ + return await super().get_async( + include_columns=False, + include_activity=include_activity, + synapse_client=synapse_client, + ) + + async def delete_async(self, *, synapse_client: Optional[Synapse] = None) -> None: + """Asynchronously delete this SearchIndex from Synapse. `id` must be set + before calling this. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Example: Delete a SearchIndex by ID. +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + async def main(): + syn = Synapse() + await syn.login_async() + + await SearchIndex(id="syn12345").delete_async() + + asyncio.run(main()) + ``` + """ + await super().delete_async(synapse_client=synapse_client) + + async def autocomplete_async( + self, + query: Dict[str, Any], + source: Optional[Dict[str, Any]] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> List["SearchHit"]: + """Run a synchronous autocomplete search against this index. The + autocomplete endpoint allowlists only prefix-style queries (`prefix`, + `match_phrase_prefix`, or `match_bool_prefix`) and caps results at 8. + + Arguments: + query: The top-level OpenSearch Query DSL clause; restricted + server-side to `prefix`, `match_phrase_prefix`, or + `match_bool_prefix`. + source: Optional source filter selecting which columns are returned + on each hit. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The matching SearchHits, capped at 8. + + Raises: + ValueError: If the ``id`` attribute has not been set. + + Example: Autocomplete titles beginning with "alz". +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import SearchIndex + + async def main(): + syn = Synapse() + await syn.login_async() + + index = SearchIndex(id="syn12345") + hits = await index.autocomplete_async( + query={"match_phrase_prefix": {"title": {"query": "alz"}}}, + ) + for hit in hits: + print(hit.row_id, hit.fields) + + asyncio.run(main()) + ``` + """ + from synapseclient.api import autocomplete_search + from synapseclient.models.search_management import ( + SearchAutocompleteRequest, + SearchHit, + ) + + if not self.id: + raise ValueError("The id attribute must be set to call autocomplete.") + request = SearchAutocompleteRequest( + search_index_id=self.id, + query=query, + source=source, + ) + response = await autocomplete_search( + request.to_synapse_request(), synapse_client=synapse_client + ) + return [SearchHit().fill_from_dict(h) for h in response.get("hits", []) or []] diff --git a/synapseclient/models/search_management.py b/synapseclient/models/search_management.py new file mode 100644 index 000000000..e0636e276 --- /dev/null +++ b/synapseclient/models/search_management.py @@ -0,0 +1,1382 @@ +"""Search management dataclasses. + +These dataclasses model the org-level search management resources used by +SearchIndex entities (TextAnalyzer, ColumnAnalyzerOverride, SynonymSet, +SearchConfiguration, SearchConfigBinding) and the query/response types for +querying a SearchIndex's OpenSearch index. + +The query model is a thin pass-through over the OpenSearch ``_search`` request +body: ``SearchQuery`` carries the allowlisted top-level keys (``query``, +``post_filter``, ``aggregations``, ``highlight``, ``collapse``, ``rescore``, +``sort``, ``_source``, ``from``, ``size``, ``search_after``) as raw JSON. The +analyzer-resource ``settings`` / ``definition`` / ``analyzer`` slots are likewise +raw OpenSearch JSON objects. + +Each analyzer resource belongs to an Organization and is referenced by qualified +name (``{organizationName}-{name}``). Resources are publicly readable; +create/update operations are restricted to Sage Bionetworks employees. + +REST controller: +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol + +from typing_extensions import Self + +from synapseclient.api import ( + bind_search_config_to_entity, + clear_search_config_binding, + create_column_analyzer_override, + create_search_configuration, + create_synonym_set, + create_text_analyzer, + get_column_analyzer_override, + get_search_config_binding, + get_search_configuration, + get_synonym_set, + get_text_analyzer, + list_column_analyzer_overrides, + list_search_configurations, + list_synonym_sets, + list_text_analyzers, + update_column_analyzer_override, + update_search_configuration, + update_synonym_set, + update_text_analyzer, +) +from synapseclient.core.async_utils import async_to_sync +from synapseclient.core.constants import concrete_types +from synapseclient.core.utils import delete_none_keys +from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator +from synapseclient.models.protocols.search_management_protocol import ( + SearchConfigBindingSynchronousProtocol, +) +from synapseclient.models.table_components import SelectColumn + +if TYPE_CHECKING: + from synapseclient import Synapse + + +class SearchIndexState(str, Enum): + """The state of a SearchIndex's OpenSearch index.""" + + CREATING = "CREATING" + ACTIVE = "ACTIVE" + FAILED = "FAILED" + + +class SearchQueryPart(str, Enum): + """Optional response parts for a SearchQuery beyond default HITS. + + These are values for the `responseParts` field on a SearchIndexQuery. + """ + + HITS = "HITS" + TOTAL_HITS = "TOTAL_HITS" + SELECT_COLUMNS = "SELECT_COLUMNS" + + +class OrgScopedResourceProtocol(Protocol): + """Synchronous interface shared by the org-scoped search-management resources + (TextAnalyzer, ColumnAnalyzerOverride, SynonymSet, SearchConfiguration).""" + + def store(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Create this resource, or update it if it already has an ID.""" + return self + + def get(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Fetch this resource from Synapse by its ID.""" + return self + + def list( + self, + organization_name: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> List["Self"]: + """List resources of this type, optionally filtered by organization.""" + return [] + + +@async_to_sync +class OrgScopedResource(OrgScopedResourceProtocol): + """Base implementing the create/get/update/list lifecycle shared by the + org-scoped search-management resources. + + Subclasses set the ``_CREATE_FN`` / ``_GET_FN`` / ``_UPDATE_FN`` / ``_LIST_FN`` + class attributes to the matching ``api.search_services`` functions and implement + ``fill_from_dict`` / ``to_synapse_request``. These resources have no delete + endpoint; ``name`` and ``organizationName`` are immutable after creation. + """ + + _CREATE_FN: Callable = None + _GET_FN: Callable = None + _UPDATE_FN: Callable = None + _LIST_FN: Callable = None + + async def store_async( + self, *, synapse_client: Optional["Synapse"] = None + ) -> "Self": + """Create this resource, or update it if it already has an ID. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated with the server-assigned ID, etag, and timestamps. + """ + cls = type(self) + if self.id: + result = await cls._UPDATE_FN( + self.id, self.to_synapse_request(), synapse_client=synapse_client + ) + else: + result = await cls._CREATE_FN( + self.to_synapse_request(), synapse_client=synapse_client + ) + return self.fill_from_dict(result) + + async def get_async(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Fetch this resource from Synapse by its ID. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the Synapse response. + + Raises: + ValueError: If the ``id`` attribute has not been set. + """ + if not self.id: + raise ValueError(f"{type(self).__name__} must have an id set to call get.") + cls = type(self) + result = await cls._GET_FN(self.id, synapse_client=synapse_client) + return self.fill_from_dict(result) + + @classmethod + async def list_async( + cls, + organization_name: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> List["Self"]: + """List resources of this type, paginating over all pages. + + Arguments: + organization_name: If provided, only resources in this organization are + returned; otherwise resources across all organizations are listed. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A list of every matching resource across all result pages. + """ + results: List["Self"] = [] + next_page_token = None + while True: + page = await cls._LIST_FN( + organization_name=organization_name, + next_page_token=next_page_token, + synapse_client=synapse_client, + ) + for item in page.get("results", []) or []: + results.append(cls().fill_from_dict(item)) + next_page_token = page.get("nextPageToken", None) + if not next_page_token: + break + return results + + +@dataclass +class TextAnalyzer(OrgScopedResource): + """A shareable, named OpenSearch custom analyzer. Used to configure how text + is tokenized for a search index. + + A TextAnalyzer belongs to an Organization, referenced by `organization_name`. + Find an Organization you already have access to with + `synapseclient.models.organization.list_organizations()`, or create one with + `synapseclient.models.Organization` before creating a TextAnalyzer. + + Represents a [Synapse TextAnalyzer](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/TextAnalyzer.html). + + Note: TextAnalyzer has no delete endpoint on the Synapse REST API. Once + created, it cannot be removed, and its owning Organization can no longer be + deleted either. Choose `organization_name` and `name` deliberately. + + Example: Create a TextAnalyzer. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import TextAnalyzer + + syn = Synapse() + syn.login() + + analyzer = TextAnalyzer( + organization_name="my.existing.organization", + name="stemmed_english", + description="English analyzer with stemming and lowercase filtering", + settings={ + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase", "porter_stem"], + } + } + }, + ) + analyzer = analyzer.store() + print(f"Created TextAnalyzer: {analyzer.id} ({analyzer.qualified_name})") + ``` + + Example: Get an existing TextAnalyzer by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import TextAnalyzer + + syn = Synapse() + syn.login() + + analyzer = TextAnalyzer(id="12345").get() + print(analyzer.name, analyzer.settings) + ``` + + Example: Update an existing TextAnalyzer. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import TextAnalyzer + + syn = Synapse() + syn.login() + + analyzer = TextAnalyzer(id="12345").get() + analyzer.description = "Updated description" + analyzer.settings["analyzer"]["default"]["filter"].append("asciifolding") + analyzer = analyzer.store() + print(f"Updated TextAnalyzer etag: {analyzer.etag}") + ``` + + Example: List TextAnalyzers in an Organization. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import TextAnalyzer + + syn = Synapse() + syn.login() + + analyzers = TextAnalyzer.list(organization_name="my.existing.organization") + for analyzer in analyzers: + print(analyzer.id, analyzer.qualified_name) + ``` + """ + + _CREATE_FN = staticmethod(create_text_analyzer) + _GET_FN = staticmethod(get_text_analyzer) + _UPDATE_FN = staticmethod(update_text_analyzer) + _LIST_FN = staticmethod(list_text_analyzers) + + id: Optional[str] = None + """The unique immutable ID for this text analyzer.""" + + organization_name: Optional[str] = None + """The name of the organization that owns this analyzer. + Immutable after creation.""" + + name: Optional[str] = None + """The name of this analyzer. Must start with a letter and + contain only letters, digits, and underscores; unique within the + organization.""" + + description: Optional[str] = None + """Optional description of this analyzer.""" + + settings: Optional[Dict[str, Any]] = None + """Required. JSON object holding the *contents of* the `settings.analysis` + block of an OpenSearch create-index request body. Allowed root keys are + `char_filter`, `tokenizer`, `filter`, and `analyzer`; the inner `analyzer` + map must declare exactly one `default` entry (and optionally + `default_search`). A `{"$ref": "{org}-{name}"}` entry inside the `filter` + registry resolves to a SynonymSet at index-build time.""" + + etag: Optional[str] = None + """Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle + concurrent updates. The eTag changes every time this analyzer is updated; + it is used to detect when a client's copy is out-of-date.""" + + created_on: Optional[str] = None + """The date on which this analyzer was created.""" + + created_by: Optional[str] = None + """The ID of the Synapse user who created this analyzer.""" + + modified_on: Optional[str] = None + """The date on which this analyzer was last modified.""" + + modified_by: Optional[str] = None + """The ID of the Synapse user who last modified this analyzer.""" + + @property + def qualified_name(self) -> Optional[str]: + """The qualified name '{organizationName}-{name}' used to reference + this analyzer from a SearchConfiguration.""" + if self.organization_name and self.name: + return f"{self.organization_name}-{self.name}" + return None + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.id = data.get("id", None) + self.organization_name = data.get("organizationName", None) + self.name = data.get("name", None) + self.description = data.get("description", None) + self.settings = data.get("settings", None) + self.etag = data.get("etag", None) + self.created_on = data.get("createdOn", None) + self.created_by = data.get("createdBy", None) + self.modified_on = data.get("modifiedOn", None) + self.modified_by = data.get("modifiedBy", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "id": self.id, + "organizationName": self.organization_name, + "name": self.name, + "description": self.description, + "settings": self.settings, + "etag": self.etag, + } + delete_none_keys(body) + return body + + +@dataclass +class ColumnAnalyzerOverrideEntry: + """Assigns one TextAnalyzer to one column. + + Represents a [Synapse ColumnAnalyzerOverrideEntry](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/ColumnAnalyzerOverrideEntry.html). + """ + + column_name: Optional[str] = None + """The name of the column to override. Silently skipped at index-build time if the column is not present in the target SearchIndex's schema — a single override bundle can therefore be applied across several indexes that share some column names""" + + analyzer: Optional[Dict[str, Any]] = None + """The analyzer to use for this column. Either a reference to a saved + TextAnalyzer written as `{"$ref": "{organizationName}-{name}"}`, or an + inline OpenSearch `settings.analysis` block.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.column_name = data.get("columnName", None) + self.analyzer = data.get("analyzer", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "columnName": self.column_name, + "analyzer": self.analyzer, + } + delete_none_keys(body) + return body + + +@dataclass +class ColumnAnalyzerOverride(OrgScopedResource): + """A shareable bundle of per-column analyzer assignments. Each entry binds one column to an analyzer; + + A ColumnAnalyzerOverride belongs to an Organization, referenced by + `organization_name`. Find an Organization you already have access to with + `synapseclient.models.organization.list_organizations()`, or create one with + `synapseclient.models.Organization` before creating a ColumnAnalyzerOverride. + + Represents a [Synapse ColumnAnalyzerOverride](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/ColumnAnalyzerOverride.html). + + Note: ColumnAnalyzerOverride has no delete endpoint on the Synapse REST API. + Once created, it cannot be removed, and its owning Organization can no + longer be deleted either. Choose `organization_name` and `name` deliberately. + + Example: Create a ColumnAnalyzerOverride. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride, ColumnAnalyzerOverrideEntry + + syn = Synapse() + syn.login() + + override = ColumnAnalyzerOverride( + organization_name="my.existing.organization", + name="disease_column_overrides", + description="Use a keyword analyzer for the disease_code column", + overrides=[ + ColumnAnalyzerOverrideEntry( + column_name="disease_code", + analyzer={"analyzer": {"default": {"type": "keyword"}}}, + ), + ], + ) + override = override.store() + print(f"Created ColumnAnalyzerOverride: {override.id} ({override.qualified_name})") + ``` + + Example: Get an existing ColumnAnalyzerOverride by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride + + syn = Synapse() + syn.login() + + override = ColumnAnalyzerOverride(id="12345").get() + print(override.name, override.overrides) + ``` + + Example: Update an existing ColumnAnalyzerOverride. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride, ColumnAnalyzerOverrideEntry + + syn = Synapse() + syn.login() + + override = ColumnAnalyzerOverride(id="12345").get() + override.overrides.append( + ColumnAnalyzerOverrideEntry( + column_name="title", + analyzer={"analyzer": {"default": {"type": "standard"}}}, + ) + ) + override = override.store() + print(f"Updated ColumnAnalyzerOverride etag: {override.etag}") + ``` + + Example: List ColumnAnalyzerOverrides in an Organization. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride + + syn = Synapse() + syn.login() + + overrides = ColumnAnalyzerOverride.list(organization_name="my.existing.organization") + for override in overrides: + print(override.id, override.qualified_name) + ``` + """ + + _CREATE_FN = staticmethod(create_column_analyzer_override) + _GET_FN = staticmethod(get_column_analyzer_override) + _UPDATE_FN = staticmethod(update_column_analyzer_override) + _LIST_FN = staticmethod(list_column_analyzer_overrides) + + id: Optional[str] = None + """The unique ID of this column analyzer override.""" + + organization_name: Optional[str] = None + """The name of the Organization this resource belongs to. Immutable after + creation.""" + + name: Optional[str] = None + """The resource name. Must start with a letter and contain only letters, + digits, and underscores. Unique within the organization and immutable + after creation. Used as part of the qualified name + ({organizationName}-{name}) when referenced by other resources.""" + + description: Optional[str] = None + """Optional description.""" + + overrides: Optional[List[ColumnAnalyzerOverrideEntry]] = field(default_factory=list) + """The per-column analyzer assignments -- see ColumnAnalyzerOverrideEntry.""" + + etag: Optional[str] = None + """Synapse employs an Optimistic Concurrency Control (OCC) scheme.""" + + created_on: Optional[str] = None + """The date this resource was created.""" + + created_by: Optional[str] = None + """The ID of the user that created this resource.""" + + modified_on: Optional[str] = None + """The date this resource was last modified.""" + + modified_by: Optional[str] = None + """The ID of the user that last modified this resource.""" + + @property + def qualified_name(self) -> Optional[str]: + if self.organization_name and self.name: + return f"{self.organization_name}-{self.name}" + return None + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.id = data.get("id", None) + self.organization_name = data.get("organizationName", None) + self.name = data.get("name", None) + self.description = data.get("description", None) + self.overrides = [ + ColumnAnalyzerOverrideEntry().fill_from_dict(o) + for o in data.get("overrides", []) or [] + ] + self.etag = data.get("etag", None) + self.created_on = data.get("createdOn", None) + self.created_by = data.get("createdBy", None) + self.modified_on = data.get("modifiedOn", None) + self.modified_by = data.get("modifiedBy", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "id": self.id, + "organizationName": self.organization_name, + "name": self.name, + "description": self.description, + "overrides": ( + [o.to_synapse_request() for o in self.overrides] + if self.overrides + else None + ), + "etag": self.etag, + } + delete_none_keys(body) + return body + + +@dataclass +class SynonymSet(OrgScopedResource): + """A shareable OpenSearch synonym_graph (or legacy synonym) token filter. + Referenced by qualified name `{organizationName}-{name}` from a TextAnalyzer's + `settings.filter` registry map via `{"$ref": "{organizationName}-{name}"}`. + + A SynonymSet belongs to an Organization, referenced by `organization_name`. + Find an Organization you already have access to with + `synapseclient.models.organization.list_organizations()`, or create one with + `synapseclient.models.Organization` before creating a SynonymSet. + + Represents a [Synapse SynonymSet](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SynonymSet.html). + + Note: SynonymSet has no delete endpoint on the Synapse REST API. Once + created, it cannot be removed, and its owning Organization can no longer be + deleted either. Choose `organization_name` and `name` deliberately. + + Example: Create a SynonymSet. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SynonymSet + + syn = Synapse() + syn.login() + + synonyms = SynonymSet( + organization_name="my.existing.organization", + name="disease_synonyms", + description="Common synonyms for disease terms", + definition={ + "type": "synonym_graph", + "synonyms": [ + # comma-separated = equivalence set, matches either way + "tumor, neoplasm, cancer", + # "=>" = one-way mapping, "AD" expands to the right side only + "AD => Alzheimer's disease", + ], + }, + ) + synonyms = synonyms.store() + print(f"Created SynonymSet: {synonyms.id} ({synonyms.qualified_name})") + ``` + + Example: Get an existing SynonymSet by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SynonymSet + + syn = Synapse() + syn.login() + + synonyms = SynonymSet(id="12345").get() + print(synonyms.name, synonyms.definition) + ``` + + Example: Update an existing SynonymSet. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SynonymSet + + syn = Synapse() + syn.login() + + synonyms = SynonymSet(id="12345").get() + synonyms.definition["synonyms"].append("MI, myocardial infarction, heart attack") + synonyms = synonyms.store() + print(f"Updated SynonymSet etag: {synonyms.etag}") + ``` + + Example: List SynonymSets in an Organization. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SynonymSet + + syn = Synapse() + syn.login() + + synonym_sets = SynonymSet.list(organization_name="my.existing.organization") + for synonyms in synonym_sets: + print(synonyms.id, synonyms.qualified_name) + ``` + """ + + _CREATE_FN = staticmethod(create_synonym_set) + _GET_FN = staticmethod(get_synonym_set) + _UPDATE_FN = staticmethod(update_synonym_set) + _LIST_FN = staticmethod(list_synonym_sets) + + id: Optional[str] = None + """The unique ID of this synonym set.""" + + organization_name: Optional[str] = None + """The name of the Organization this resource belongs to. Immutable after + creation.""" + + name: Optional[str] = None + """The resource name. Must start with a letter and contain only letters, + digits, and underscores. Unique within the organization and immutable + after creation. Used as part of the qualified name + ({organizationName}-{name}) when referenced by other resources.""" + + description: Optional[str] = None + """Optional description of the synonym set.""" + + definition: Optional[Dict[str, Any]] = None + """Required. The full OpenSearch token filter definition as a JSON object, + exactly as documented for the synonym_graph / synonym token filters, e.g. + `{"type": "synonym_graph", "synonyms": ["tumor, neoplasm, cancer", + "AD => Alzheimer's disease"]}`.""" + + etag: Optional[str] = None + """Synapse employs an Optimistic Concurrency Control (OCC) scheme.""" + + created_on: Optional[str] = None + """The date this resource was created.""" + + created_by: Optional[str] = None + """The ID of the user that created this resource.""" + + modified_on: Optional[str] = None + """The date this resource was last modified.""" + + modified_by: Optional[str] = None + """The ID of the user that last modified this resource.""" + + @property + def qualified_name(self) -> Optional[str]: + if self.organization_name and self.name: + return f"{self.organization_name}-{self.name}" + return None + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.id = data.get("id", None) + self.organization_name = data.get("organizationName", None) + self.name = data.get("name", None) + self.description = data.get("description", None) + self.definition = data.get("definition", None) + self.etag = data.get("etag", None) + self.created_on = data.get("createdOn", None) + self.created_by = data.get("createdBy", None) + self.modified_on = data.get("modifiedOn", None) + self.modified_by = data.get("modifiedBy", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "id": self.id, + "organizationName": self.organization_name, + "name": self.name, + "description": self.description, + "definition": self.definition, + "etag": self.etag, + } + delete_none_keys(body) + return body + + +@dataclass +class SearchConfiguration(OrgScopedResource): + """Bundles the index-wide default analyzer and per-column overrides used to + build a SearchIndex. + + A SearchConfiguration belongs to an Organization, referenced by + `organization_name`. Find an Organization you already have access to with + `synapseclient.models.organization.list_organizations()`, or create one with + `synapseclient.models.Organization` before creating a SearchConfiguration. + + Represents a [Synapse SearchConfiguration](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SearchConfiguration.html). + + Note: SearchConfiguration has no delete endpoint on the Synapse REST API. + Once created, it cannot be removed, and its owning Organization can no + longer be deleted either. Choose `organization_name` and `name` deliberately. + + Example: Create a SearchConfiguration. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfiguration + + syn = Synapse() + syn.login() + + config = SearchConfiguration( + organization_name="my.existing.organization", + name="default_config", + description="Default search configuration for this project's indexes", + # Reference a previously-created TextAnalyzer by its qualified name... + default_analyzer={"$ref": "my.existing.organization-stemmed_english"}, + ) + config = config.store() + print(f"Created SearchConfiguration: {config.id} ({config.qualified_name})") + ``` + + Example: Get an existing SearchConfiguration by ID. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfiguration + + syn = Synapse() + syn.login() + + config = SearchConfiguration(id="12345").get() + print(config.name, config.default_analyzer) + ``` + + Example: Update an existing SearchConfiguration. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfiguration + + syn = Synapse() + syn.login() + + config = SearchConfiguration(id="12345").get() + config.column_analyzer_overrides.append( + {"$ref": "my.existing.organization-abstract_overrides"} + ) + config = config.store() + print(f"Updated SearchConfiguration etag: {config.etag}") + ``` + + Example: List SearchConfigurations in an Organization. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfiguration + + syn = Synapse() + syn.login() + + configs = SearchConfiguration.list(organization_name="my.existing.organization") + for config in configs: + print(config.id, config.qualified_name) + ``` + """ + + _CREATE_FN = staticmethod(create_search_configuration) + _GET_FN = staticmethod(get_search_configuration) + _UPDATE_FN = staticmethod(update_search_configuration) + _LIST_FN = staticmethod(list_search_configurations) + + id: Optional[str] = None + """The unique ID of this search configuration.""" + + organization_name: Optional[str] = None + """The name of the Organization this resource belongs to. Immutable after + creation.""" + + name: Optional[str] = None + """The resource name. Must start with a letter and contain only letters, + digits, and underscores. Unique within the organization and immutable + after creation. Used as part of the qualified name + ({organizationName}-{name}) when referenced by other resources.""" + + description: Optional[str] = None + """Optional description.""" + + default_analyzer: Optional[Dict[str, Any]] = None + """Optional. The analyzer that supplies this index's `analysis.analyzer.default` + slot. Either a reference to a saved TextAnalyzer written as + `{"$ref": "{organizationName}-{name}"}`, or an inline OpenSearch + `settings.analysis` block.""" + column_analyzer_overrides: Optional[List[Dict[str, Any]]] = field( + default_factory=list + ) + """Optional ordered list of ColumnAnalyzerOverride entries. Each entry is + either a reference `{"$ref": "{organizationName}-{name}"}` or an inline + ColumnAnalyzerOverride literal.""" + + etag: Optional[str] = None + """Synapse employs an Optimistic Concurrency Control (OCC) scheme.""" + + created_on: Optional[str] = None + """The date this resource was created.""" + + created_by: Optional[str] = None + """The ID of the user that created this resource.""" + + modified_on: Optional[str] = None + """The date this resource was last modified.""" + + modified_by: Optional[str] = None + """The ID of the user that last modified this resource.""" + + @property + def qualified_name(self) -> Optional[str]: + if self.organization_name and self.name: + return f"{self.organization_name}-{self.name}" + return None + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.id = data.get("id", None) + self.organization_name = data.get("organizationName", None) + self.name = data.get("name", None) + self.description = data.get("description", None) + self.default_analyzer = data.get("defaultAnalyzer", None) + self.column_analyzer_overrides = data.get("columnAnalyzerOverrides", []) or [] + self.etag = data.get("etag", None) + self.created_on = data.get("createdOn", None) + self.created_by = data.get("createdBy", None) + self.modified_on = data.get("modifiedOn", None) + self.modified_by = data.get("modifiedBy", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "id": self.id, + "organizationName": self.organization_name, + "name": self.name, + "description": self.description, + "defaultAnalyzer": self.default_analyzer, + "columnAnalyzerOverrides": self.column_analyzer_overrides or None, + "etag": self.etag, + } + delete_none_keys(body) + return body + + +@dataclass +@async_to_sync +class SearchConfigBinding(SearchConfigBindingSynchronousProtocol): + """Attaches a SearchConfiguration to an entity. When a SearchIndex is built, + the effective SearchConfiguration is resolved by walking up the entity + hierarchy (entity -> folder -> project) and using the first binding found. + + Represents a [Synapse SearchConfigBinding](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SearchConfigBinding.html). + """ + + bind_id: Optional[str] = None + """The unique ID of this binding.""" + + search_configuration_id: Optional[str] = None + """The ID of the SearchConfiguration bound to this entity.""" + + object_id: Optional[str] = None + """The ID of the entity this configuration is bound to.""" + + object_type: Optional[str] = None + """The type of the object this configuration is bound to.""" + + created_by: Optional[str] = None + """The ID of the user that created this binding.""" + + created_on: Optional[str] = None + """The date this binding was created.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.bind_id = data.get("bindId", None) + self.search_configuration_id = data.get("searchConfigurationId", None) + self.object_id = data.get("objectId", None) + self.object_type = data.get("objectType", None) + self.created_by = data.get("createdBy", None) + self.created_on = data.get("createdOn", None) + return self + + async def store_async( + self, *, synapse_client: Optional["Synapse"] = None + ) -> "Self": + """Bind ``search_configuration_id`` to the entity ``object_id``. Replaces + any existing binding on that entity. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the created SearchConfigBinding. + + Raises: + ValueError: If ``object_id`` or ``search_configuration_id`` is not set. + + Example: Bind a SearchConfiguration to a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + binding = SearchConfigBinding( + object_id="syn12345", + search_configuration_id="6789", + ) + binding = binding.store() + print(f"Bound SearchConfiguration {binding.search_configuration_id} " + f"to {binding.object_id}") + ``` + """ + if not self.object_id: + raise ValueError("SearchConfigBinding must have an object_id set.") + if not self.search_configuration_id: + raise ValueError( + "SearchConfigBinding must have a search_configuration_id set." + ) + result = await bind_search_config_to_entity( + self.object_id, + self.search_configuration_id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(result) + + async def get_async(self, *, synapse_client: Optional["Synapse"] = None) -> "Self": + """Get the effective binding for the entity ``object_id``, resolved by + walking up the entity hierarchy. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Itself, populated from the resolved SearchConfigBinding. + + Raises: + ValueError: If ``object_id`` is not set. + + Example: Get the effective binding for a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + binding = SearchConfigBinding(object_id="syn12345").get() + print(binding.search_configuration_id) + ``` + """ + if not self.object_id: + raise ValueError("SearchConfigBinding must have an object_id set.") + result = await get_search_config_binding( + self.object_id, synapse_client=synapse_client + ) + return self.fill_from_dict(result) + + async def delete_async(self, *, synapse_client: Optional["Synapse"] = None) -> None: + """Clear the binding on the entity ``object_id``. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Raises: + ValueError: If ``object_id`` is not set. + + Example: Clear the binding on a Project. +   + + ```python + from synapseclient import Synapse + from synapseclient.models import SearchConfigBinding + + syn = Synapse() + syn.login() + + SearchConfigBinding(object_id="syn12345").delete() + ``` + """ + if not self.object_id: + raise ValueError("SearchConfigBinding must have an object_id set.") + await clear_search_config_binding(self.object_id, synapse_client=synapse_client) + + +@dataclass +class SearchIndexStatus: + """Build status of a SearchIndex's OpenSearch index. Read it to find out + whether the index is ready, still being built, or in a failed state -- + and, if FAILED, what went wrong. + + Represents a [Synapse SearchIndexStatus](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SearchIndexStatus.html). + """ + + search_index_id: Optional[str] = None + """The ID of the SearchIndex entity.""" + + state: Optional[SearchIndexState] = None + """The state of a search index's OpenSearch index.""" + + changed_on: Optional[str] = None + """The date-time when the status last changed.""" + + error_message: Optional[str] = None + """Set when state is FAILED. Captures the diagnostic from the failed build -- + pre-flight validation errors (e.g. 'TextAnalyzer X does not resolve'), AOSS + rejection messages from index creation, or sample per-document failures + from bulk indexing. Capped at 3000 characters; the most-recent failure + replaces any earlier message.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.search_index_id = data.get("searchIndexId", None) + st = data.get("state", None) + self.state = SearchIndexState(st) if st else None + self.changed_on = data.get("changedOn", None) + self.error_message = data.get("errorMessage", None) + return self + + +@dataclass +class SearchQuery: + """The body of an OpenSearch `_search` request, narrowed to the top-level + keys Synapse accepts. Each slot's contents are pass-through OpenSearch query + DSL carried as raw JSON. + + Represents a [Synapse SearchQuery](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/SearchQuery.html). + """ + + query: Optional[Dict[str, Any]] = None + """Required. The OpenSearch query DSL clause. Use `{"match_all": {}}` to + match all documents.""" + + post_filter: Optional[Dict[str, Any]] = None + """Optional. Same DSL shape as `query`, applied after aggregations are + computed.""" + + aggregations: Optional[Dict[str, Any]] = None + """Optional. Map of caller-chosen name to aggregation definition. The raw + aggregation result comes back on `SearchIndexQuery.aggregation_results`.""" + + highlight: Optional[Dict[str, Any]] = None + """Optional. Adds per-field snippet fragments to each hit's highlights.""" + + collapse: Optional[Dict[str, Any]] = None + """Optional. Groups the result list so only one hit is returned per distinct + value of a field.""" + + rescore: Optional[Dict[str, Any]] = None + """Optional. Re-ranks the top hits returned by `query` using a secondary + scoring query.""" + + sort: Optional[List[Any]] = None + """Optional. Result ordering, in native OpenSearch sort shape (a string + column name, `{column: "asc|desc"}`, or `{column: {order: ..., mode: ...}}`). + When omitted, results are sorted by relevance descending.""" + + source: Optional[Dict[str, Any]] = None + """Optional. Source filter selecting which columns are returned on each hit. + Serialized as `_source`.""" + + from_: Optional[int] = None + """Optional. Zero-based pagination offset; default 0. Ignored when + `search_after` is supplied. Serialized as `from`.""" + + size: Optional[int] = None + """Optional. Maximum number of hits to return per page. Default 25. + Maximum 100.""" + + search_after: Optional[List[Any]] = None + """Optional. Opaque cursor emitted as `next_search_after` on the previous + response. Pass back unchanged. When supplied, `from_` is ignored.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.query = data.get("query", None) + self.post_filter = data.get("post_filter", None) + self.aggregations = data.get("aggregations", None) + self.highlight = data.get("highlight", None) + self.collapse = data.get("collapse", None) + self.rescore = data.get("rescore", None) + self.sort = data.get("sort", None) + self.source = data.get("_source", None) + self.from_ = data.get("from", None) + self.size = data.get("size", None) + self.search_after = data.get("search_after", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + body = { + "query": self.query, + "post_filter": self.post_filter, + "aggregations": self.aggregations, + "highlight": self.highlight, + "collapse": self.collapse, + "rescore": self.rescore, + "sort": self.sort, + "_source": self.source, + "from": self.from_, + "size": self.size, + "search_after": self.search_after, + } + delete_none_keys(body) + return body + + +@dataclass +class SearchAutocompleteRequest: + """Body of a synchronous autocomplete request against a SearchIndex. The + autocomplete endpoint allowlists only `query` (restricted to `prefix`, + `match_phrase_prefix`, or `match_bool_prefix`) and `_source`. + + Represents a [Synapse SearchAutocompleteRequest](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SearchAutocompleteRequest.html). + """ + + search_index_id: Optional[str] = None + """The ID of the SearchIndex entity to query.""" + + query: Optional[Dict[str, Any]] = None + """Required. The top-level Query DSL clause; restricted server-side to + `prefix`, `match_phrase_prefix`, or `match_bool_prefix`.""" + + source: Optional[Dict[str, Any]] = None + """Optional. Source filter; same shape as `SearchQuery.source`. Serialized + as `_source`.""" + + def to_synapse_request(self) -> Dict[str, Any]: + search_query = { + "query": self.query, + "_source": self.source, + } + delete_none_keys(search_query) + body = { + "searchIndexId": self.search_index_id, + "searchQuery": search_query or None, + } + delete_none_keys(body) + return body + + +@dataclass +class SearchFieldValue: + """A name/value pair returned in a SearchHit's `fields`. + + Represents a [Synapse SearchFieldValue](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/SearchFieldValue.html). + """ + + name: Optional[str] = None + """The column name.""" + + value: Optional[str] = None + """The column value.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.name = data.get("name", None) + self.value = data.get("value", None) + return self + + +@dataclass +class SearchHighlight: + """A per-field highlight payload on a SearchHit. + + Represents a [Synapse SearchHighlight](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/SearchHighlight.html). + """ + + name: Optional[str] = None + """The column name.""" + + snippets: Optional[List[str]] = field(default_factory=list) + """Highlighted snippet fragments. Matched terms are wrapped in pre/post tags + (default `` / ``).""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.name = data.get("name", None) + self.snippets = data.get("snippets", []) or [] + return self + + +@dataclass +class SearchHit: + """A single matching document in a SearchQueryResults response. + + Represents a [Synapse SearchHit](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/SearchHit.html). + """ + + row_id: Optional[int] = None + """The row ID from the source table.""" + + row_version: Optional[int] = None + """The row version from the source table.""" + + score: Optional[float] = None + """The relevance score for this hit.""" + + fields: Optional[List[SearchFieldValue]] = field(default_factory=list) + """Column name/value pairs for the requested return fields.""" + + highlights: Optional[List[SearchHighlight]] = field(default_factory=list) + """Per-field highlight payloads, if highlight was requested.""" + + def fill_from_dict(self, data: Dict[str, Any]) -> "Self": + self.row_id = data.get("rowId", None) + self.row_version = data.get("rowVersion", None) + self.score = data.get("score", None) + self.fields = [ + SearchFieldValue().fill_from_dict(f) for f in data.get("fields", []) or [] + ] + self.highlights = [ + SearchHighlight().fill_from_dict(h) + for h in data.get("highlights", []) or [] + ] + return self + + +@dataclass +class SearchIndexQuery(AsynchronousCommunicator): + """An async request to query a SearchIndex's OpenSearch index. + + Inherits from `AsynchronousCommunicator`: call `send_job_and_wait_async()` to + submit the job, poll the Synapse async job service, and populate response + fields (`hits`, `total_hits`, `select_columns`, `aggregation_results`, + `next_search_after`, `offset`) on this same instance. + + Represents a [Synapse SearchIndexQuery](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/table/SearchIndexQuery.html). + + Example: Run a search query. + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import SearchIndexQuery, SearchQuery, SearchQueryPart + + async def main(): + Synapse().login() + query = SearchIndexQuery( + search_index_id="syn22806626", + search_query=SearchQuery( + query={"match": {"title": {"query": "alzheimer"}}}, + size=10, + ), + response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS], + ) + await query.send_job_and_wait_async() + print(query.total_hits, len(query.hits)) + + asyncio.run(main()) + ``` + """ + + concrete_type: str = concrete_types.SEARCH_INDEX_QUERY + """The Synapse concrete type identifying this async request.""" + + search_index_id: Optional[str] = None + """The ID of the SearchIndex entity to query.""" + + search_query: Optional[SearchQuery] = None + """The SearchQuery (OpenSearch `_search` body) to execute against the index.""" + + response_parts: Optional[List[SearchQueryPart]] = field(default_factory=list) + """Optional list of additional response parts beyond default HITS.""" + + hits: Optional[List[SearchHit]] = field(default_factory=list) + """Response: matching documents. Populated after `send_job_and_wait_async()`.""" + + total_hits: Optional[int] = None + """Response: total number of matching documents. Populated when + SearchQueryPart.TOTAL_HITS is requested.""" + + select_columns: Optional[List[SelectColumn]] = field(default_factory=list) + """Response: columns represented in each hit's fields, in SELECT-clause + order. Populated when SearchQueryPart.SELECT_COLUMNS is requested.""" + + aggregation_results: Optional[Dict[str, Any]] = None + """Response: the raw OpenSearch aggregations response, with field references + rewritten back to column names. Populated whenever the request supplied + `search_query.aggregations`. Kept as an opaque JSON object.""" + + next_search_after: Optional[List[Any]] = None + """Response: opaque cursor for the next page. Pass back unchanged on the next + request as `search_query.search_after`. Null when there are no further pages.""" + + offset: Optional[int] = None + """Response: zero-based pagination offset echoed from the request.""" + + def to_synapse_request(self) -> Dict[str, Any]: + """Convert to the SearchIndexQuery body for the async-job /start endpoint.""" + body = { + "concreteType": self.concrete_type, + "searchIndexId": self.search_index_id, + "searchQuery": ( + self.search_query.to_synapse_request() if self.search_query else None + ), + "responseParts": ( + [p.value for p in self.response_parts] if self.response_parts else None + ), + } + delete_none_keys(body) + return body + + def fill_from_dict(self, synapse_response: Dict[str, Any]) -> "Self": + """Populate response fields from a SearchQueryResults body. + + Called by `AsynchronousCommunicator.send_job_and_wait_async()` once the + async job completes. Leaves request fields untouched. + + Modeled from [Synapse SearchQueryResults](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/search/SearchQueryResults.html). + """ + self.hits = [ + SearchHit().fill_from_dict(h) + for h in synapse_response.get("hits", []) or [] + ] + self.total_hits = synapse_response.get("totalHits", None) + self.select_columns = [ + SelectColumn.fill_from_dict(c) + for c in synapse_response.get("selectColumns", []) or [] + ] + self.aggregation_results = synapse_response.get("aggregationResults", None) + self.next_search_after = synapse_response.get("nextSearchAfter", None) + self.offset = synapse_response.get("offset", None) + return self diff --git a/synapseclient/operations/delete_operations.py b/synapseclient/operations/delete_operations.py index 304780c8b..139ed8f45 100644 --- a/synapseclient/operations/delete_operations.py +++ b/synapseclient/operations/delete_operations.py @@ -20,9 +20,9 @@ Grid, JSONSchema, MaterializedView, + Organization, Project, RecordSet, - SchemaOrganization, SubmissionView, Table, Team, @@ -45,7 +45,7 @@ def delete( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -89,7 +89,7 @@ def delete( **Not supported for version-specific deletion:** - - Project, Folder, Evaluation, Team, SchemaOrganization, CurationTask, Grid + - Project, Folder, Evaluation, Team, Organization, CurationTask, Grid Arguments: entity: The entity instance to delete, or a Synapse ID string (e.g., "syn123456" @@ -274,7 +274,7 @@ async def delete_async( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -319,7 +319,7 @@ async def delete_async( **Not supported for version-specific deletion:** - - Project, Folder, Evaluation, Team, SchemaOrganization, CurationTask, Grid + - Project, Folder, Evaluation, Team, Organization, CurationTask, Grid Arguments: entity: The entity instance to delete, or a Synapse ID string (e.g., "syn123456" @@ -443,9 +443,9 @@ async def main(): Grid, JSONSchema, MaterializedView, + Organization, Project, RecordSet, - SchemaOrganization, SubmissionView, Table, Team, @@ -570,7 +570,7 @@ async def main(): Project, Evaluation, Team, - SchemaOrganization, + Organization, CurationTask, Grid, DockerRepository, @@ -600,5 +600,5 @@ async def main(): f"Unsupported entity type: {type(entity).__name__}. " "Supported types are: str (Synapse ID), CurationTask, Dataset, DatasetCollection, " "EntityView, Evaluation, File, Folder, Grid, JSONSchema, MaterializedView, " - "Project, RecordSet, SchemaOrganization, SubmissionView, Table, Team, VirtualTable." + "Project, RecordSet, Organization, SubmissionView, Table, Team, VirtualTable." ) diff --git a/synapseclient/operations/store_operations.py b/synapseclient/operations/store_operations.py index dc50b60c0..a61d68053 100644 --- a/synapseclient/operations/store_operations.py +++ b/synapseclient/operations/store_operations.py @@ -23,9 +23,9 @@ JSONSchema, Link, MaterializedView, + Organization, Project, RecordSet, - SchemaOrganization, SubmissionView, Table, Team, @@ -242,7 +242,7 @@ def store( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -273,7 +273,7 @@ def store( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -534,7 +534,7 @@ async def store_async( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -565,7 +565,7 @@ async def store_async( "MaterializedView", "Project", "RecordSet", - "SchemaOrganization", + "Organization", "SubmissionView", "Table", "Team", @@ -773,9 +773,9 @@ async def main(): JSONSchema, Link, MaterializedView, + Organization, Project, RecordSet, - SchemaOrganization, SubmissionView, Table, Team, @@ -833,7 +833,7 @@ async def main(): else: return await entity.create_async(synapse_client=synapse_client) - elif isinstance(entity, SchemaOrganization): + elif isinstance(entity, Organization): return await entity.store_async(synapse_client=synapse_client) elif isinstance(entity, CurationTask): @@ -875,5 +875,5 @@ async def main(): "Supported types are: AgentSession, CurationTask, " "Dataset, DatasetCollection, EntityView, Evaluation, File, Folder, FormData, " "FormGroup, Grid, JSONSchema, Link, MaterializedView, Project, RecordSet, " - "SchemaOrganization, SubmissionView, Table, Team, VirtualTable." + "Organization, SubmissionView, Table, Team, VirtualTable." ) diff --git a/synapseclient/services/json_schema.py b/synapseclient/services/json_schema.py index 61604a2fb..09e7c10ed 100644 --- a/synapseclient/services/json_schema.py +++ b/synapseclient/services/json_schema.py @@ -5,7 +5,7 @@ !!! warning Everything in this module has been deprecated. - Use synapseclient.models.SchemaOrganization and synapseclient.models.JSONSchema instead. + Use synapseclient.models.Organization and synapseclient.models.JSONSchema instead. """ from __future__ import annotations @@ -285,7 +285,7 @@ def create( @deprecated( version="4.11.0", - reason="To be removed in 5.0.0. Use synapseclient.models.SchemaOrganization instead.", + reason="To be removed in 5.0.0. Use synapseclient.models.Organization instead.", ) class JsonSchemaOrganization: """Json Schema Organization @@ -503,14 +503,14 @@ def create_json_schema( version="4.11.0", reason="To be removed in 5.0.0. " "Use the OOP JSON Schema models instead, " - "synapseclient.models.SchemaOrganization and " + "synapseclient.models.Organization and " "synapseclient.models.JSONSchema.", ) class JsonSchemaService: """Json Schema Service Deprecated: To be removed in 5.0.0. Use the OOP JSON Schema models instead: - synapseclient.models.SchemaOrganization for organization management, + synapseclient.models.Organization for organization management, synapseclient.models.JSONSchema for creating and retrieving schemas, and the JSON Schema methods on entity models (e.g. File, Folder, Project) such as bind_schema(), get_schema(), validate_schema(), and unbind_schema() for binding @@ -532,12 +532,12 @@ class JsonSchemaService: # New approach (RECOMMENDED) from synapseclient import Synapse - from synapseclient.models import SchemaOrganization, JSONSchema, Folder + from synapseclient.models import Organization, JSONSchema, Folder syn = Synapse() syn.login() - organization = SchemaOrganization(name="my.organization").store() + organization = Organization(name="my.organization").store() schema = JSONSchema( organization_name="my.organization", name="my.schema" ) diff --git a/tests/integration/synapseclient/extensions/curator/test_schema_management.py b/tests/integration/synapseclient/extensions/curator/test_schema_management.py index 46f1253c9..c1f784d9d 100644 --- a/tests/integration/synapseclient/extensions/curator/test_schema_management.py +++ b/tests/integration/synapseclient/extensions/curator/test_schema_management.py @@ -12,7 +12,7 @@ from synapseclient.extensions.curator.record_based_metadata_task import ( project_id_from_entity_id, ) -from synapseclient.models import Folder, Project, SchemaOrganization +from synapseclient.models import Folder, Organization, Project def create_test_name(): @@ -22,11 +22,11 @@ def create_test_name(): @pytest.fixture(name="test_organization", scope="module") -def fixture_test_organization(syn: Synapse, request) -> SchemaOrganization: +def fixture_test_organization(syn: Synapse, request) -> Organization: """ Returns a created organization for testing schema registration """ - org = SchemaOrganization(create_test_name()) + org = Organization(create_test_name()) org.store(synapse_client=syn) def delete_org(): @@ -87,7 +87,7 @@ class TestRegisterJsonSchema: """Integration tests for register_jsonschema wrapper function""" def test_register_jsonschema_with_version( - self, syn: Synapse, test_organization: SchemaOrganization, test_schema_file: str + self, syn: Synapse, test_organization: Organization, test_schema_file: str ): """Test registering a JSON schema with a specific version""" schema_name = create_test_name() @@ -109,7 +109,7 @@ def test_register_jsonschema_with_version( assert test_organization.name in json_schema.uri def test_register_jsonschema_without_version( - self, syn: Synapse, test_organization: SchemaOrganization, test_schema_file: str + self, syn: Synapse, test_organization: Organization, test_schema_file: str ): """Test registering a JSON schema without specifying a version""" schema_name = create_test_name() @@ -134,7 +134,7 @@ class TestBindJsonSchema: def test_bind_jsonschema_to_folder( self, syn: Synapse, - test_organization: SchemaOrganization, + test_organization: Organization, test_project: Project, test_schema_file: str, ): @@ -172,7 +172,7 @@ def test_bind_jsonschema_to_folder( def test_bind_jsonschema_with_derived_annotations( self, syn: Synapse, - test_organization: SchemaOrganization, + test_organization: Organization, test_project: Project, test_schema_file: str, ): @@ -214,7 +214,7 @@ class TestRegisterAndBindWorkflow: def test_complete_workflow( self, syn: Synapse, - test_organization: SchemaOrganization, + test_organization: Organization, test_project: Project, test_schema_file: str, ): diff --git a/tests/integration/synapseclient/models/async/test_schema_organization_async.py b/tests/integration/synapseclient/models/async/test_organization_async.py similarity index 93% rename from tests/integration/synapseclient/models/async/test_schema_organization_async.py rename to tests/integration/synapseclient/models/async/test_organization_async.py index f7461c732..97356c9fd 100644 --- a/tests/integration/synapseclient/models/async/test_schema_organization_async.py +++ b/tests/integration/synapseclient/models/async/test_organization_async.py @@ -1,4 +1,4 @@ -"""Integration tests for SchemaOrganization and JSONSchema classes""" +"""Integration tests for Organization and JSONSchema classes""" import asyncio import uuid @@ -10,11 +10,11 @@ from synapseclient import Synapse from synapseclient.core.constants.concrete_types import CREATE_SCHEMA_REQUEST from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import JSONSchema, SchemaOrganization -from synapseclient.models.schema_organization import ( +from synapseclient.models import JSONSchema, Organization +from synapseclient.models.organization import ( CreateSchemaRequest, JSONSchemaVersionInfo, - list_json_schema_organizations, + list_organizations, ) @@ -41,19 +41,19 @@ def org_exists(name: str, synapse_client: Optional[Synapse] = None) -> bool: """ matching_orgs = [ org - for org in list_json_schema_organizations(synapse_client=synapse_client) + for org in list_organizations(synapse_client=synapse_client) if org.name == name ] return len(matching_orgs) == 1 @pytest.fixture(name="module_organization", scope="module") -def fixture_module_organization(syn: Synapse, request) -> SchemaOrganization: +def fixture_module_organization(syn: Synapse, request) -> Organization: """ Returns a created organization at the module scope. Used to hold JSON Schemas created by tests. """ name = create_test_entity_name() - org = SchemaOrganization(name) + org = Organization(name) org.store(synapse_client=syn) def delete_org(): @@ -67,7 +67,7 @@ def delete_org(): @pytest.fixture(name="json_schema", scope="function") -def fixture_json_schema(module_organization: SchemaOrganization) -> JSONSchema: +def fixture_json_schema(module_organization: Organization) -> JSONSchema: """ Returns a JSON Schema """ @@ -77,12 +77,12 @@ def fixture_json_schema(module_organization: SchemaOrganization) -> JSONSchema: @pytest_asyncio.fixture(name="organization", loop_scope="function", scope="function") -async def fixture_organization(syn: Synapse, request) -> SchemaOrganization: +async def fixture_organization(syn: Synapse, request) -> Organization: """ Returns a Synapse organization. """ name = create_test_entity_name() - org = SchemaOrganization(name) + org = Organization(name) def delete_org(): exists = org_exists(name, syn) @@ -97,13 +97,13 @@ def delete_org(): @pytest_asyncio.fixture( name="organization_with_schema", loop_scope="function", scope="function" ) -async def fixture_organization_with_schema(syn: Synapse, request) -> SchemaOrganization: +async def fixture_organization_with_schema(syn: Synapse, request) -> Organization: """ Returns a Synapse organization. As Cleanup it checks for JSON Schemas and deletes them""" name = create_test_entity_name() - org = SchemaOrganization(name) + org = Organization(name) await org.store_async(synapse_client=syn) js1 = JSONSchema("schema1", name) js2 = JSONSchema("schema2", name) @@ -122,14 +122,14 @@ def delete_org(): return org -class TestSchemaOrganization: - """Asynchronous integration tests for SchemaOrganization.""" +class TestOrganization: + """Asynchronous integration tests for Organization.""" @pytest.fixture(autouse=True, scope="function") def init(self, syn: Synapse) -> None: self.syn = syn - async def test_create_and_get(self, organization: SchemaOrganization) -> None: + async def test_create_and_get(self, organization: Organization) -> None: # GIVEN an initialized organization object that hasn't been stored in Synapse # THEN it shouldn't have any metadata besides it's name assert organization.name is not None @@ -150,7 +150,7 @@ async def test_create_and_get(self, organization: SchemaOrganization) -> None: exists = org_exists(organization.name, synapse_client=self.syn) assert exists # AND it should be getable by future instances with the same name - org2 = SchemaOrganization(organization.name) + org2 = Organization(organization.name) await org2.get_async(synapse_client=self.syn) assert organization.name is not None assert organization.id is not None @@ -163,8 +163,8 @@ async def test_create_and_get(self, organization: SchemaOrganization) -> None: async def test_get_json_schemas_async( self, - organization: SchemaOrganization, - organization_with_schema: SchemaOrganization, + organization: Organization, + organization_with_schema: Organization, ) -> None: # GIVEN an organization with no schemas and one with 3 schemas await organization.store_async(synapse_client=self.syn) @@ -181,9 +181,7 @@ async def test_get_json_schemas_async( schema_list2.append(item) assert len(schema_list2) == 3 - async def test_get_acl_and_update_acl( - self, organization: SchemaOrganization - ) -> None: + async def test_get_acl_and_update_acl(self, organization: Organization) -> None: # GIVEN an organization that has been initialized, but not created # THEN get_acl should raise an error with pytest.raises( @@ -243,7 +241,7 @@ async def test_store_and_get(self, json_schema: JSONSchema) -> None: assert js2.created_by assert js2.created_on - async def test_delete(self, organization_with_schema: SchemaOrganization) -> None: + async def test_delete(self, organization_with_schema: Organization) -> None: # GIVEN an organization with 3 schema schemas: list[JSONSchema] = [] async for item in organization_with_schema.get_json_schemas_async( @@ -361,7 +359,7 @@ def init(self, syn: Synapse) -> None: self.syn = syn async def test_create_schema_request_no_version( - self, module_organization: SchemaOrganization + self, module_organization: Organization ) -> None: # GIVEN an organization # WHEN creating a CreateSchemaRequest with no version given @@ -408,7 +406,7 @@ async def test_create_schema_request_no_version( # ] async def test_create_schema_request_with_version( - self, module_organization: SchemaOrganization + self, module_organization: Organization ) -> None: # GIVEN an organization # WHEN creating a CreateSchemaRequest with no version given diff --git a/tests/integration/synapseclient/models/async/test_search_index_async.py b/tests/integration/synapseclient/models/async/test_search_index_async.py new file mode 100644 index 000000000..451f83834 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_search_index_async.py @@ -0,0 +1,163 @@ +"""Integration tests for the SearchIndex entity and SearchIndexQuery. + +SearchIndex builds an OpenSearch index from a SQL view of a table-like entity. +Index creation may be restricted to Sage Bionetworks employees on some stacks; +those tests skip gracefully when the server denies the create with a 403. +""" + +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.models import ( + Project, + SearchIndex, + SearchIndexQuery, + SearchQuery, + SearchQueryPart, + Table, +) +from synapseclient.models.table_components import SchemaStorageStrategy +from tests.integration import ASYNC_JOB_TIMEOUT_SEC, QUERY_TIMEOUT_SEC +from tests.integration.helpers import wait_for_condition + + +class TestSearchIndex: + @pytest.fixture(autouse=True, scope="function") + def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: + self.syn = syn + self.schedule_for_cleanup = schedule_for_cleanup + + async def _create_source_table(self, project_model: Project) -> Table: + """Create a populated table to use as a SearchIndex source.""" + table = Table(name=str(uuid.uuid4()), parent_id=project_model.id) + table = await table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(table.id) + await table.store_rows_async( + values={ + "title": ["Alzheimer study", "Cancer cohort", "Diabetes trial"], + "disease_code": ["AD", "CA", "DB"], + }, + schema_storage_strategy=SchemaStorageStrategy.INFER_FROM_DATA, + synapse_client=self.syn, + ) + return table + + async def _store_search_index(self, **kwargs) -> SearchIndex: + """Store a SearchIndex, skipping the test if creation is not permitted.""" + try: + index = await SearchIndex(**kwargs).store_async(synapse_client=self.syn) + except SynapseHTTPError as e: + if e.response.status_code == 403: + pytest.skip(f"SearchIndex creation is restricted on this stack: {e}") + raise + self.schedule_for_cleanup(index.id) + return index + + async def test_empty_defining_sql_validation(self, project_model: Project) -> None: + # GIVEN a SearchIndex with no defining SQL + index = SearchIndex(name=str(uuid.uuid4()), parent_id=project_model.id) + + # WHEN storing it + # THEN a ValueError is raised before any API call + with pytest.raises(ValueError, match="defining_sql"): + await index.store_async(synapse_client=self.syn) + + async def test_create_and_retrieve_search_index( + self, project_model: Project + ) -> None: + # GIVEN a populated source table + table = await self._create_source_table(project_model) + + # WHEN creating a SearchIndex over it + index_name = str(uuid.uuid4()) + index = await self._store_search_index( + name=index_name, + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {table.id}", + ) + + # THEN it is created with an ID + assert index.id is not None + + # AND when retrieving it, the metadata is present + retrieved = await SearchIndex(id=index.id).get_async(synapse_client=self.syn) + assert retrieved.name == index_name + assert retrieved.defining_sql == f"SELECT * FROM {table.id}" + + async def test_query_search_index_match_all(self, project_model: Project) -> None: + # GIVEN a SearchIndex over a populated table + table = await self._create_source_table(project_model) + index = await self._store_search_index( + name=str(uuid.uuid4()), + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {table.id}", + ) + + # WHEN the OpenSearch index has finished building, a match_all query + # returns the indexed rows. The build is asynchronous, so poll until the + # query succeeds and reports the expected total. + async def _query_total_hits() -> int: + query = SearchIndexQuery( + search_index_id=index.id, + search_query=SearchQuery(query={"match_all": {}}, size=10), + response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS], + ) + await query.send_job_and_wait_async( + timeout=QUERY_TIMEOUT_SEC, synapse_client=self.syn + ) + return query.total_hits + + total_hits = await wait_for_condition( + _query_total_hits, + timeout_seconds=ASYNC_JOB_TIMEOUT_SEC, + description="SearchIndex to build and return all rows", + ) + + # THEN every source row is indexed + assert total_hits == 3 + + async def test_autocomplete_search_index(self, project_model: Project) -> None: + # GIVEN a SearchIndex over a populated table + table = await self._create_source_table(project_model) + index = await self._store_search_index( + name=str(uuid.uuid4()), + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {table.id}", + ) + + # WHEN the index has built, a prefix autocomplete returns the matching row. + # The build is asynchronous, so poll until a hit comes back. + async def _autocomplete_hits(): + return await index.autocomplete_async( + query={"match_phrase_prefix": {"title": {"query": "Alz"}}}, + synapse_client=self.syn, + ) + + hits = await wait_for_condition( + _autocomplete_hits, + timeout_seconds=ASYNC_JOB_TIMEOUT_SEC, + description="SearchIndex to build and return an autocomplete hit", + ) + + # THEN only the Alzheimer row matches the prefix + assert len(hits) == 1 + + async def test_delete_search_index(self, project_model: Project) -> None: + # GIVEN a SearchIndex + table = await self._create_source_table(project_model) + index = await self._store_search_index( + name=str(uuid.uuid4()), + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {table.id}", + ) + + # WHEN deleting it + await SearchIndex(id=index.id).delete_async(synapse_client=self.syn) + + # THEN it can no longer be retrieved + with pytest.raises(SynapseHTTPError): + await SearchIndex(id=index.id).get_async(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/async/test_search_management_async.py b/tests/integration/synapseclient/models/async/test_search_management_async.py new file mode 100644 index 000000000..fbe0485d8 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_search_management_async.py @@ -0,0 +1,299 @@ +"""Integration tests for the org-scoped search-management resources: TextAnalyzer, +SynonymSet, ColumnAnalyzerOverride, SearchConfiguration, and SearchConfigBinding. + +TextAnalyzer, SynonymSet, ColumnAnalyzerOverride, and SearchConfiguration have no +delete endpoint on the Synapse REST API, so these tests do not create or update +them -- they only get and list a fixed set of resources pre-seeded under the +`SEARCH_ORG_NAME` organization (identified by the `*_NAME`/`*_ID` constants +below), which keeps the tests idempotent and safe to run concurrently on CI. +Because an Organization cannot be deleted once one of these resources has been +attached to it, `SEARCH_ORG_NAME` is a permanent, shared test Organization +rather than one created fresh per test run. +Each resource's class docstring shows the `store()` call used to seed it. +SearchConfigBinding does support delete/clear; its test creates and tears down +its own binding on a freshly-created Folder. +""" + +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.models import ( + ColumnAnalyzerOverride, + Folder, + Project, + SearchConfigBinding, + SearchConfiguration, + SynonymSet, + TextAnalyzer, +) + +SEARCH_ORG_NAME = "SYNPY.TEST.SEARCH.MANAGEMENT" +TEXT_ANALYZER_NAME = "test_analyzer" +TEXT_ANALYZER_ID = "1023" +SYNONYM_SET_NAME = "test_synonyms" +SYNONYM_SET_ID = "32" +COLUMN_ANALYZER_OVERRIDE_NAME = "disease_column_overrides" +COLUMN_ANALYZER_OVERRIDE_ID = "15" +TEST_CONFIG_NAME = "test_config" +TEST_CONFIG_ID = "17" + + +@pytest.fixture(scope="function") +async def folder( + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], +) -> Folder: + """A fresh Folder under the shared test Project, used as the bind target for + SearchConfigBinding tests instead of the shared Project itself.""" + folder = await Folder( + name=str(uuid.uuid4()), + parent_id=project_model.id, + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) + return folder + + +class TestTextAnalyzer: + async def test_get_and_list(self, syn: Synapse) -> None: + """ + Test that a TextAnalyzer can be retrieved by ID and listed in the + organization. + + The TestTextAnalyzer was stored like below: + + from synapseclient import Synapse + from synapseclient.models import TextAnalyzer + + + syn = Synapse() + syn.login() + analyzer = TextAnalyzer( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="test_analyzer", + settings={ + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase"], + } + } + }, + ) + analyzer = analyzer.store(synapse_client=syn) + print(f"Created TextAnalyzer: {analyzer.id} ({analyzer.qualified_name})") + """ + # GIVEN a TextAnalyzer definition + name = TEXT_ANALYZER_NAME + settings = { + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase"], + } + } + } + + # AND it can be retrieved by ID with its settings intact + retrieved = await TextAnalyzer(id=TEXT_ANALYZER_ID).get_async( + synapse_client=syn + ) + assert retrieved.settings == settings + # AND it appears when listing analyzers in the organization + listed = await TextAnalyzer.list_async( + organization_name=SEARCH_ORG_NAME, synapse_client=syn + ) + assert name in [item.name for item in listed] + + +class TestSynonymSet: + async def test_get_and_list(self, syn: Synapse) -> None: + """ + Test that a SynonymSet can be retrieved by ID and listed in the + organization. + + The TestSynonymSet was stored like below: + + from synapseclient import Synapse + from synapseclient.models import SynonymSet + + + syn = Synapse() + syn.login() + synonyms = SynonymSet( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="test_synonyms", + definition={ + "type": "synonym_graph", + "synonyms": ["tumor, neoplasm, cancer"], + }, + ) + synonyms = synonyms.store(synapse_client=syn) + print(f"Created SynonymSet: {synonyms.id} ({synonyms.qualified_name})") + """ + # GIVEN a SynonymSet definition + name = SYNONYM_SET_NAME + definition = { + "type": "synonym_graph", + "synonyms": ["tumor, neoplasm, cancer"], + } + + # AND it can be retrieved by ID with its definition intact + retrieved = await SynonymSet(id=SYNONYM_SET_ID).get_async(synapse_client=syn) + assert retrieved.definition == definition + # AND it appears when listing synonym sets in the organization + listed = await SynonymSet.list_async( + organization_name=SEARCH_ORG_NAME, synapse_client=syn + ) + assert name in [item.name for item in listed] + + +class TestColumnAnalyzerOverride: + async def test_get_and_list(self, syn: Synapse) -> None: + """ + Test that a ColumnAnalyzerOverride can be retrieved by ID and listed in the + organization. + + The TestColumnAnalyzerOverride was stored like below: + + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride, ColumnAnalyzerOverrideEntry + + + syn = Synapse() + syn.login() + override = ColumnAnalyzerOverride( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="disease_column_overrides", + description="Use a keyword analyzer for the disease_code column", + overrides=[ + ColumnAnalyzerOverrideEntry( + column_name="disease_code", + analyzer={"analyzer": {"default": {"type": "keyword"}}}, + ), + ], + ) + override = override.store(synapse_client=syn) + print(f"Created ColumnAnalyzerOverride: {override.id} ({override.qualified_name})") + """ + # GIVEN a ColumnAnalyzerOverride with a single inline analyzer entry + name = COLUMN_ANALYZER_OVERRIDE_NAME + + # AND it can be retrieved by ID with its entry intact + retrieved = await ColumnAnalyzerOverride( + id=COLUMN_ANALYZER_OVERRIDE_ID + ).get_async(synapse_client=syn) + assert retrieved.overrides[0].column_name == "disease_code" + # AND it appears when listing overrides in the organization + listed = await ColumnAnalyzerOverride.list_async( + organization_name=SEARCH_ORG_NAME, synapse_client=syn + ) + assert name in [item.name for item in listed] + + +class TestSearchConfiguration: + async def test_get_and_list(self, syn: Synapse) -> None: + """ + Test that a SearchConfiguration can be retrieved by ID and listed in the + organization. + + The TestSearchConfiguration was stored like below: + + from synapseclient import Synapse + from synapseclient.models import ColumnAnalyzerOverride, ColumnAnalyzerOverrideEntry, SearchConfiguration, TextAnalyzer + + + syn = Synapse() + syn.login() + analyzer = TextAnalyzer( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="test_analyzer", + settings={ + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase"], + } + } + }, + ).store(synapse_client=syn) + override = ColumnAnalyzerOverride( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="disease_column_overrides", + description="Use a keyword analyzer for the disease_code column", + overrides=[ + ColumnAnalyzerOverrideEntry( + column_name="disease_code", + analyzer={"analyzer": {"default": {"type": "keyword"}}}, + ), + ], + ).store(synapse_client=syn) + config = SearchConfiguration( + organization_name="SYNPY.TEST.SEARCH.MANAGEMENT", + name="test_config", + default_analyzer={"$ref": analyzer.qualified_name}, + column_analyzer_overrides=[{"$ref": override.qualified_name}], + ) + config = config.store(synapse_client=syn) + print(f"Created SearchConfiguration: {config.id} ({config.qualified_name})") + """ + # GIVEN a SearchConfiguration referencing a TextAnalyzer and + # ColumnAnalyzerOverride by qualified name + name = TEST_CONFIG_NAME + analyzer_ref = {"$ref": f"{SEARCH_ORG_NAME}-{TEXT_ANALYZER_NAME}"} + override_ref = {"$ref": f"{SEARCH_ORG_NAME}-{COLUMN_ANALYZER_OVERRIDE_NAME}"} + + # AND it can be retrieved by ID with its analyzer references intact + retrieved = await SearchConfiguration(id=TEST_CONFIG_ID).get_async( + synapse_client=syn + ) + assert retrieved.default_analyzer == analyzer_ref + assert retrieved.column_analyzer_overrides == [override_ref] + # AND it appears when listing configurations in the organization + listed = await SearchConfiguration.list_async( + organization_name=SEARCH_ORG_NAME, synapse_client=syn + ) + assert name in [item.name for item in listed] + + +class TestSearchConfigBinding: + async def test_bind_get_and_clear(self, syn: Synapse, folder: Folder) -> None: + """ + Test that a SearchConfiguration can be bound to a Folder, that the + effective binding resolves to it on a fresh get, and that clearing the + binding removes it. + """ + # GIVEN a SearchConfiguration to bind + config = await SearchConfiguration(id=TEST_CONFIG_ID).get_async( + synapse_client=syn + ) + # WHEN binding it to a Folder + binding = await SearchConfigBinding( + object_id=folder.id, + search_configuration_id=config.id, + ).store_async(synapse_client=syn) + + # THEN the binding is created for that entity + assert binding.bind_id is not None + assert binding.object_id == folder.id.removeprefix("syn") + assert binding.search_configuration_id == config.id + + # AND getting the effective binding on the same entity resolves to it + effective = await SearchConfigBinding(object_id=folder.id).get_async( + synapse_client=syn + ) + assert effective.search_configuration_id == config.id + + # WHEN clearing the binding + await SearchConfigBinding(object_id=folder.id).delete_async(synapse_client=syn) + + # THEN there is no longer an effective binding on that entity + with pytest.raises(SynapseHTTPError): + await SearchConfigBinding(object_id=folder.id).get_async(synapse_client=syn) diff --git a/tests/integration/synapseclient/operations/async/test_factory_operations_store_async.py b/tests/integration/synapseclient/operations/async/test_factory_operations_store_async.py index ee0a909fc..b30691b69 100644 --- a/tests/integration/synapseclient/operations/async/test_factory_operations_store_async.py +++ b/tests/integration/synapseclient/operations/async/test_factory_operations_store_async.py @@ -28,10 +28,10 @@ JSONSchema, Link, MaterializedView, + Organization, Project, RecordBasedMetadataTaskProperties, RecordSet, - SchemaOrganization, SubmissionView, Table, Team, @@ -716,10 +716,10 @@ async def test_store_async_curation_task_basic( assert stored_task.etag is not None async def test_store_async_schema_organization_basic(self) -> None: - """Test storing a SchemaOrganization entity.""" + """Test storing a Organization entity.""" # GIVEN a new schema organization # Name must have each part start with a letter - schema_org = SchemaOrganization( + schema_org = Organization( name=f"test.schema.org.test{str(uuid.uuid4())[:8]}", ) @@ -737,9 +737,7 @@ async def test_store_async_schema_organization_basic(self) -> None: # THEN the schema organization should no longer be retrievable with pytest.raises(Exception): - await SchemaOrganization(organization_name=stored_org.name).get_async( - synapse_client=self.syn - ) + await Organization(name=stored_org.name).get_async(synapse_client=self.syn) async def test_store_async_unsupported_entity_raises_error(self) -> None: """Test that storing an unsupported entity type raises an error.""" @@ -864,7 +862,7 @@ async def test_store_async_form_data_basic(self, project_model: Project) -> None async def test_store_async_json_schema_basic(self) -> None: """Test storing a JSONSchema entity.""" # GIVEN a schema organization first - schema_org = SchemaOrganization( + schema_org = Organization( name=f"test.schema.org.test{str(uuid.uuid4())[:8]}", ) stored_org = await store_async(schema_org, synapse_client=self.syn) diff --git a/tests/integration/synapseclient/test_command_line_client.py b/tests/integration/synapseclient/test_command_line_client.py index 1c9855da8..3a004e810 100644 --- a/tests/integration/synapseclient/test_command_line_client.py +++ b/tests/integration/synapseclient/test_command_line_client.py @@ -1279,11 +1279,11 @@ class TestSchemaManagementCommands: @pytest.fixture(scope="class") def schema_organization(self, syn: Synapse, request): """Create a test organization for schema registration.""" - from synapseclient.models import SchemaOrganization + from synapseclient.models import Organization # Prefix with 'id' so the name part starts with a letter (required by schema validation) org_name = f"test.org.id{str(uuid.uuid4())[:8]}" - organization = SchemaOrganization(org_name) + organization = Organization(org_name) organization.store(synapse_client=syn) def cleanup(): diff --git a/tests/unit/synapseclient/extensions/test_schema_management.py b/tests/unit/synapseclient/extensions/test_schema_management.py index 1b0d1a252..fcdef1fca 100644 --- a/tests/unit/synapseclient/extensions/test_schema_management.py +++ b/tests/unit/synapseclient/extensions/test_schema_management.py @@ -16,7 +16,7 @@ def mock_synapse_client(): @pytest.fixture def mock_jsonschema(): - with patch("synapseclient.models.schema_organization.JSONSchema") as MockSchema: + with patch("synapseclient.models.organization.JSONSchema") as MockSchema: instance = MockSchema.return_value instance.store_async = AsyncMock() instance.uri = "syn123.456" diff --git a/tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py b/tests/unit/synapseclient/models/async/unit_test_organization_async.py similarity index 91% rename from tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py rename to tests/unit/synapseclient/models/async/unit_test_organization_async.py index 525c21b99..be883caf6 100644 --- a/tests/unit/synapseclient/models/async/unit_test_schema_organization_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_organization_async.py @@ -1,4 +1,4 @@ -"""Unit tests for the SchemaOrganization and JSONSchema models.""" +"""Unit tests for the Organization and JSONSchema models.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -6,10 +6,10 @@ from synapseclient import Synapse from synapseclient.models.mixins.json_schema import JSONSchemaVersionInfo -from synapseclient.models.schema_organization import ( +from synapseclient.models.organization import ( CreateSchemaRequest, JSONSchema, - SchemaOrganization, + Organization, _check_org_name, _check_schema_name, ) @@ -153,8 +153,8 @@ def test_name_part_starts_with_number(self) -> None: _check_schema_name("mytest.1invalid") -class TestSchemaOrganization: - """Unit tests for the SchemaOrganization model.""" +class TestOrganization: + """Unit tests for the Organization model.""" @pytest.fixture(autouse=True, scope="function") def init_syn(self, syn: Synapse) -> None: @@ -164,8 +164,8 @@ def test_fill_from_dict(self) -> None: # GIVEN an organization API response response = _get_organization_response() - # WHEN I fill a SchemaOrganization from the response - org = SchemaOrganization() + # WHEN I fill a Organization from the response + org = Organization() org.fill_from_dict(response) # THEN all fields should be populated @@ -175,12 +175,12 @@ def test_fill_from_dict(self) -> None: assert org.created_by == CREATED_BY async def test_store_async(self) -> None: - # GIVEN a SchemaOrganization with a name - org = SchemaOrganization(name=ORG_NAME) + # GIVEN a Organization with a name + org = Organization(name=ORG_NAME) # WHEN I call store_async with patch( - "synapseclient.models.schema_organization.create_organization", + "synapseclient.models.organization.create_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ) as mock_create: @@ -195,8 +195,8 @@ async def test_store_async(self) -> None: assert result.created_on == CREATED_ON async def test_store_async_without_name_raises(self) -> None: - # GIVEN a SchemaOrganization without a name - org = SchemaOrganization() + # GIVEN a Organization without a name + org = Organization() # WHEN I call store_async # THEN it should raise ValueError @@ -204,12 +204,12 @@ async def test_store_async_without_name_raises(self) -> None: await org.store_async(synapse_client=self.syn) async def test_get_async(self) -> None: - # GIVEN a SchemaOrganization with a name - org = SchemaOrganization(name=ORG_NAME) + # GIVEN a Organization with a name + org = Organization(name=ORG_NAME) # WHEN I call get_async with patch( - "synapseclient.models.schema_organization.get_organization", + "synapseclient.models.organization.get_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ) as mock_get: @@ -223,8 +223,8 @@ async def test_get_async(self) -> None: assert result.id == ORG_ID async def test_get_async_without_name_raises(self) -> None: - # GIVEN a SchemaOrganization without a name - org = SchemaOrganization() + # GIVEN a Organization without a name + org = Organization() # WHEN I call get_async # THEN it should raise ValueError @@ -232,12 +232,12 @@ async def test_get_async_without_name_raises(self) -> None: await org.get_async(synapse_client=self.syn) async def test_delete_async_with_id(self) -> None: - # GIVEN a SchemaOrganization with an id - org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + # GIVEN a Organization with an id + org = Organization(name=ORG_NAME, id=ORG_ID) # WHEN I call delete_async with patch( - "synapseclient.models.schema_organization.delete_organization", + "synapseclient.models.organization.delete_organization", new_callable=AsyncMock, return_value=None, ) as mock_delete: @@ -249,18 +249,18 @@ async def test_delete_async_with_id(self) -> None: ) async def test_delete_async_without_id_triggers_get(self) -> None: - # GIVEN a SchemaOrganization with only a name (no id) - org = SchemaOrganization(name=ORG_NAME) + # GIVEN a Organization with only a name (no id) + org = Organization(name=ORG_NAME) # WHEN I call delete_async with ( patch( - "synapseclient.models.schema_organization.get_organization", + "synapseclient.models.organization.get_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ) as mock_get, patch( - "synapseclient.models.schema_organization.delete_organization", + "synapseclient.models.organization.delete_organization", new_callable=AsyncMock, return_value=None, ) as mock_delete, @@ -276,8 +276,8 @@ async def test_delete_async_without_id_triggers_get(self) -> None: ) async def test_get_json_schemas_async(self) -> None: - # GIVEN a SchemaOrganization with a name - org = SchemaOrganization(name=ORG_NAME) + # GIVEN a Organization with a name + org = Organization(name=ORG_NAME) schema_response_1 = _get_json_schema_list_response() schema_response_2 = _get_json_schema_list_response( @@ -290,7 +290,7 @@ async def mock_list(*args, **kwargs): # WHEN I call get_json_schemas_async with patch( - "synapseclient.models.schema_organization.list_json_schemas", + "synapseclient.models.organization.list_json_schemas", return_value=mock_list(), ): results = [] @@ -306,8 +306,8 @@ async def mock_list(*args, **kwargs): assert results[1].name == "another.schema" async def test_get_json_schemas_async_without_name_raises(self) -> None: - # GIVEN a SchemaOrganization without a name - org = SchemaOrganization() + # GIVEN a Organization without a name + org = Organization() # WHEN I call get_json_schemas_async # THEN it should raise ValueError @@ -316,14 +316,14 @@ async def test_get_json_schemas_async_without_name_raises(self) -> None: pass # pragma: no cover async def test_get_acl_async(self) -> None: - # GIVEN a SchemaOrganization with an id - org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + # GIVEN a Organization with an id + org = Organization(name=ORG_NAME, id=ORG_ID) acl_response = _get_acl_response() # WHEN I call get_acl_async with patch( - "synapseclient.models.schema_organization.get_organization_acl", + "synapseclient.models.organization.get_organization_acl", new_callable=AsyncMock, return_value=acl_response, ) as mock_get_acl: @@ -338,20 +338,20 @@ async def test_get_acl_async(self) -> None: assert result["resourceAccess"][0]["principalId"] == PRINCIPAL_ID_1 async def test_get_acl_async_without_id_triggers_get(self) -> None: - # GIVEN a SchemaOrganization with only a name - org = SchemaOrganization(name=ORG_NAME) + # GIVEN a Organization with only a name + org = Organization(name=ORG_NAME) acl_response = _get_acl_response() # WHEN I call get_acl_async (id will be fetched first) with ( patch( - "synapseclient.models.schema_organization.get_organization", + "synapseclient.models.organization.get_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ) as mock_get, patch( - "synapseclient.models.schema_organization.get_organization_acl", + "synapseclient.models.organization.get_organization_acl", new_callable=AsyncMock, return_value=acl_response, ) as mock_get_acl, @@ -365,20 +365,20 @@ async def test_get_acl_async_without_id_triggers_get(self) -> None: mock_get_acl.assert_called_once_with(ORG_ID, synapse_client=self.syn) async def test_update_acl_async_add_new_principal(self) -> None: - # GIVEN a SchemaOrganization with an id - org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + # GIVEN a Organization with an id + org = Organization(name=ORG_NAME, id=ORG_ID) acl_response = _get_acl_response() # WHEN I call update_acl_async with a new principal with ( patch( - "synapseclient.models.schema_organization.get_organization_acl", + "synapseclient.models.organization.get_organization_acl", new_callable=AsyncMock, return_value=acl_response, ), patch( - "synapseclient.models.schema_organization.update_organization_acl", + "synapseclient.models.organization.update_organization_acl", new_callable=AsyncMock, return_value=None, ) as mock_update, @@ -407,20 +407,20 @@ async def test_update_acl_async_add_new_principal(self) -> None: assert new_entry["accessType"] == ["READ"] async def test_update_acl_async_update_existing_principal(self) -> None: - # GIVEN a SchemaOrganization with an id - org = SchemaOrganization(name=ORG_NAME, id=ORG_ID) + # GIVEN a Organization with an id + org = Organization(name=ORG_NAME, id=ORG_ID) acl_response = _get_acl_response() # WHEN I call update_acl_async for an existing principal with new permissions with ( patch( - "synapseclient.models.schema_organization.get_organization_acl", + "synapseclient.models.organization.get_organization_acl", new_callable=AsyncMock, return_value=acl_response, ), patch( - "synapseclient.models.schema_organization.update_organization_acl", + "synapseclient.models.organization.update_organization_acl", new_callable=AsyncMock, return_value=None, ) as mock_update, @@ -695,12 +695,12 @@ async def mock_list(*args, **kwargs): # WHEN I call get_async (org exists and schema is found) with ( patch( - "synapseclient.models.schema_organization.get_organization", + "synapseclient.models.organization.get_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ), patch( - "synapseclient.models.schema_organization.list_json_schemas", + "synapseclient.models.organization.list_json_schemas", return_value=mock_list(), ), ): @@ -726,12 +726,12 @@ async def mock_list(*args, **kwargs): # WHEN I call get_async with ( patch( - "synapseclient.models.schema_organization.get_organization", + "synapseclient.models.organization.get_organization", new_callable=AsyncMock, return_value=_get_organization_response(), ), patch( - "synapseclient.models.schema_organization.list_json_schemas", + "synapseclient.models.organization.list_json_schemas", return_value=mock_list(), ), ): @@ -763,7 +763,7 @@ async def test_delete_async_without_version(self) -> None: # WHEN I call delete_async without a version with patch( - "synapseclient.models.schema_organization.delete_json_schema", + "synapseclient.models.organization.delete_json_schema", new_callable=AsyncMock, return_value=None, ) as mock_delete: @@ -778,7 +778,7 @@ async def test_delete_async_with_version(self) -> None: # WHEN I call delete_async with a specific version with patch( - "synapseclient.models.schema_organization.delete_json_schema", + "synapseclient.models.organization.delete_json_schema", new_callable=AsyncMock, return_value=None, ) as mock_delete: @@ -819,7 +819,7 @@ async def mock_list(*args, **kwargs): # WHEN I call get_versions_async with patch( - "synapseclient.models.schema_organization.list_json_schema_versions", + "synapseclient.models.organization.list_json_schema_versions", return_value=mock_list(), ): results = [] @@ -859,7 +859,7 @@ async def mock_list(*args, **kwargs): # WHEN I call get_versions_async with patch( - "synapseclient.models.schema_organization.list_json_schema_versions", + "synapseclient.models.organization.list_json_schema_versions", return_value=mock_list(), ): results = [] @@ -880,7 +880,7 @@ async def test_get_body_async_latest(self) -> None: # WHEN I call get_body_async without a version (latest) with patch( - "synapseclient.models.schema_organization.get_json_schema_body", + "synapseclient.models.organization.get_json_schema_body", new_callable=AsyncMock, return_value=expected_body, ) as mock_get_body: @@ -900,7 +900,7 @@ async def test_get_body_async_with_version(self) -> None: # WHEN I call get_body_async with a specific version with patch( - "synapseclient.models.schema_organization.get_json_schema_body", + "synapseclient.models.organization.get_json_schema_body", new_callable=AsyncMock, return_value=expected_body, ) as mock_get_body: diff --git a/tests/unit/synapseclient/models/async/unit_test_project_async.py b/tests/unit/synapseclient/models/async/unit_test_project_async.py index b879aa675..72e4ca94a 100644 --- a/tests/unit/synapseclient/models/async/unit_test_project_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_project_async.py @@ -728,6 +728,49 @@ async def mock_get_children(*args, **kwargs): assert result.files[0].id == "syn456" assert result.files[0].name == "example_file_1" + async def test_sync_from_synapse_populates_search_indexes(self) -> None: + # GIVEN a Project object + project = Project(id=PROJECT_ID) + + # AND a SearchIndex child that exists on the project in Synapse + children = [ + { + "id": "syn789", + "type": concrete_types.SEARCH_INDEX_ENTITY, + "name": "example_search_index", + } + ] + + async def mock_get_children(*args, **kwargs): + for child in children: + yield child + + from synapseclient.models import SearchIndex + + # WHEN I call `sync_from_synapse` with the Project object + with ( + patch( + "synapseclient.models.mixins.storable_container.get_children", + side_effect=mock_get_children, + ), + patch( + "synapseclient.api.entity_factory.get_entity_id_bundle2", + new_callable=AsyncMock, + return_value=(self.get_example_rest_api_project_output()), + ), + patch( + "synapseclient.models.search_index.SearchIndex.get_async", + new_callable=AsyncMock, + return_value=SearchIndex(id="syn789", name="example_search_index"), + ) as mocked_search_index_get, + ): + result = await project.sync_from_synapse_async(synapse_client=self.syn) + + # THEN the SearchIndex child should be retrieved and populated + mocked_search_index_get.assert_called_once() + assert result.searchindexes[0].id == "syn789" + assert result.searchindexes[0].name == "example_search_index" + class TestStorageLocationMixin: """Tests for StorageLocationConfigurable mixin methods on Project.""" diff --git a/tests/unit/synapseclient/models/async/unit_test_search_index_async.py b/tests/unit/synapseclient/models/async/unit_test_search_index_async.py new file mode 100644 index 000000000..e35df3512 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_search_index_async.py @@ -0,0 +1,210 @@ +"""Unit tests for the SearchIndex entity model.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants import concrete_types +from synapseclient.models.mixins.table_components import ( + DeleteMixin, + GetMixin, + TableStoreMixin, +) +from synapseclient.models.search_index import SearchIndex +from synapseclient.models.search_management import SearchHit + + +class TestSearchIndex: + synapse_response = { + "id": "syn1234", + "name": "test_search_index", + "description": "test_description", + "parentId": "syn5678", + "etag": "etag_value", + "createdOn": "createdOn_value", + "createdBy": "createdBy_value", + "modifiedOn": "modifiedOn_value", + "modifiedBy": "modifiedBy_value", + "versionNumber": 1, + "versionLabel": "versionLabel_value", + "versionComment": "versionComment_value", + "isLatestVersion": True, + "definingSQL": "SELECT * FROM syn9999", + "searchConfigurationId": "42", + "annotations": {"key": "value"}, + } + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_fill_from_dict(self): + # GIVEN an empty SearchIndex + index = SearchIndex() + # WHEN I fill it from a Synapse response + index.fill_from_dict(self.synapse_response) + # THEN I expect the SearchIndex to be filled with the expected values + assert index.id == self.synapse_response["id"] + assert index.name == self.synapse_response["name"] + assert index.description == self.synapse_response["description"] + assert index.parent_id == self.synapse_response["parentId"] + assert index.etag == self.synapse_response["etag"] + assert index.created_on == self.synapse_response["createdOn"] + assert index.created_by == self.synapse_response["createdBy"] + assert index.modified_on == self.synapse_response["modifiedOn"] + assert index.modified_by == self.synapse_response["modifiedBy"] + assert index.version_number == self.synapse_response["versionNumber"] + assert index.version_label == self.synapse_response["versionLabel"] + assert index.version_comment == self.synapse_response["versionComment"] + assert index.is_latest_version == self.synapse_response["isLatestVersion"] + assert index.defining_sql == self.synapse_response["definingSQL"] + assert ( + index.search_configuration_id + == self.synapse_response["searchConfigurationId"] + ) + assert index.annotations == self.synapse_response["annotations"] + + def test_fill_from_dict_without_annotations(self): + # GIVEN an empty SearchIndex + index = SearchIndex() + # WHEN I fill it from a Synapse response with set_annotations=False + index.fill_from_dict(self.synapse_response, set_annotations=False) + # THEN I expect annotations to remain untouched + assert index.annotations == {} + + def test_to_synapse_request(self): + # GIVEN a SearchIndex + index = SearchIndex( + id="syn1234", + name="test_search_index", + description="test_description", + parent_id="syn5678", + etag="etag_value", + created_on="createdOn_value", + created_by="createdBy_value", + modified_on="modifiedOn_value", + modified_by="modifiedBy_value", + version_number=1, + version_label="versionLabel_value", + version_comment="versionComment_value", + is_latest_version=True, + defining_sql="SELECT * FROM syn9999", + search_configuration_id="42", + ) + # WHEN I convert it to a Synapse request + request = index.to_synapse_request() + # THEN I expect the entity body to carry the expected values + entity = request["entity"] + assert entity["concreteType"] == concrete_types.SEARCH_INDEX_ENTITY + for key, value in self.synapse_response.items(): + if key != "annotations": + assert entity[key] == value + + async def test_store_async_requires_defining_sql(self): + # GIVEN a SearchIndex without defining_sql + index = SearchIndex(name="test_search_index", parent_id="syn5678") + + with patch.object(TableStoreMixin, "store_async") as mock_super_store_async: + # WHEN I store it THEN a ValueError is raised + with pytest.raises( + ValueError, + match="The defining_sql attribute must be set for a SearchIndex.", + ): + await index.store_async(synapse_client=self.syn) + # AND the super().store_async method is never called + mock_super_store_async.assert_not_called() + + async def test_store_async_with_defining_sql_calls_super(self): + # GIVEN a SearchIndex with defining_sql set + index = SearchIndex( + name="test_search_index", + parent_id="syn5678", + defining_sql="SELECT * FROM syn9999", + ) + + with patch.object(TableStoreMixin, "store_async") as mock_super_store_async: + mock_super_store_async.return_value = index + # WHEN I store it + result = await index.store_async( + dry_run=True, job_timeout=100, synapse_client=self.syn + ) + # THEN the super().store_async method is called with the same arguments + mock_super_store_async.assert_called_once_with( + dry_run=True, job_timeout=100, synapse_client=self.syn + ) + assert result == index + + async def test_get_async_calls_super(self): + # GIVEN a SearchIndex with an id + index = SearchIndex(id="syn1234") + + with patch.object(GetMixin, "get_async") as mock_super_get_async: + mock_super_get_async.return_value = index + # WHEN I get it + result = await index.get_async(synapse_client=self.syn) + # THEN the super().get_async method is called with include_columns + # forced to False, since SearchIndex has no columns field + mock_super_get_async.assert_called_once_with( + include_columns=False, + include_activity=False, + synapse_client=self.syn, + ) + assert result == index + + async def test_delete_async_calls_super(self): + # GIVEN a SearchIndex with an id + index = SearchIndex(id="syn1234") + + with patch.object(DeleteMixin, "delete_async") as mock_super_delete_async: + # WHEN I delete it + await index.delete_async(synapse_client=self.syn) + # THEN the super().delete_async method is called + mock_super_delete_async.assert_called_once_with(synapse_client=self.syn) + + +class TestSearchIndexAutocomplete: + """Dispatch tests for SearchIndex.autocomplete_async.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_autocomplete_async_dispatch(self): + # GIVEN a SearchIndex with an id + index = SearchIndex(id="syn1") + response = {"hits": [{"rowId": 1, "fields": [{"name": "title", "value": "x"}]}]} + with patch( + "synapseclient.api.autocomplete_search", + new_callable=AsyncMock, + return_value=response, + ) as mock_autocomplete: + # WHEN I run autocomplete + hits = await index.autocomplete_async( + query={"prefix": {"title": {"value": "a"}}}, + source={"includes": ["title"]}, + synapse_client=self.syn, + ) + # THEN the request nests the query/_source under searchQuery for this index + mock_autocomplete.assert_awaited_once_with( + { + "searchIndexId": "syn1", + "searchQuery": { + "query": {"prefix": {"title": {"value": "a"}}}, + "_source": {"includes": ["title"]}, + }, + }, + synapse_client=self.syn, + ) + # AND the response hits deserialize to SearchHit + assert len(hits) == 1 + assert isinstance(hits[0], SearchHit) + assert hits[0].row_id == 1 + + async def test_autocomplete_async_requires_id(self): + # WHEN autocompleting without an id THEN a ValueError is raised + with pytest.raises(ValueError): + await SearchIndex().autocomplete_async( + query={"prefix": {"title": {"value": "a"}}}, + synapse_client=self.syn, + ) diff --git a/tests/unit/synapseclient/models/async/unit_test_search_management_async.py b/tests/unit/synapseclient/models/async/unit_test_search_management_async.py new file mode 100644 index 000000000..5f95fb207 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_search_management_async.py @@ -0,0 +1,526 @@ +"""Unit tests for the search-management dataclasses. + +Covers fill_from_dict / to_synapse_request round-trips for the raw +OpenSearch-DSL pass-through models and the SearchIndexQuery async flow. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.constants.concrete_types import SEARCH_INDEX_QUERY +from synapseclient.models.search_management import ( + ColumnAnalyzerOverride, + ColumnAnalyzerOverrideEntry, + SearchAutocompleteRequest, + SearchConfigBinding, + SearchConfiguration, + SearchFieldValue, + SearchHighlight, + SearchHit, + SearchIndexQuery, + SearchIndexState, + SearchIndexStatus, + SearchQuery, + SearchQueryPart, + SynonymSet, + TextAnalyzer, +) + + +class TestSearchQuery: + """Round-trip tests for the raw-DSL SearchQuery.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def test_to_synapse_request_maps_special_keys(self): + # GIVEN a SearchQuery exercising the keyword-renamed slots + query = SearchQuery( + query={"match_all": {}}, + post_filter={"term": {"disease": {"value": "AD"}}}, + aggregations={"by_disease": {"terms": {"field": "disease", "size": 10}}}, + highlight={"fields": {"title": {}}}, + collapse={"field": "study"}, + rescore={"window_size": 50}, + sort=[{"title": "asc"}], + source={"includes": ["title", "abstract"]}, + from_=5, + size=25, + search_after=["abc", 123], + ) + # WHEN I serialize it + request = query.to_synapse_request() + # THEN source/from_/search_after map to the OpenSearch key names + assert request["_source"] == {"includes": ["title", "abstract"]} + assert request["from"] == 5 + assert request["search_after"] == ["abc", 123] + assert "source" not in request + assert "from_" not in request + assert request["query"] == {"match_all": {}} + assert request["aggregations"] == { + "by_disease": {"terms": {"field": "disease", "size": 10}} + } + + def test_to_synapse_request_drops_none(self): + # GIVEN a minimal SearchQuery + query = SearchQuery(query={"match_all": {}}) + # WHEN I serialize it + request = query.to_synapse_request() + # THEN only the populated key is present + assert request == {"query": {"match_all": {}}} + + def test_fill_from_dict_round_trip(self): + # GIVEN a SearchQueryResults-style request body + body = { + "query": {"match": {"title": {"query": "alz"}}}, + "_source": {"includes": ["title"]}, + "from": 10, + "size": 50, + "search_after": [1, 2], + "sort": [{"_score": "desc"}], + } + # WHEN I fill a SearchQuery from it + query = SearchQuery().fill_from_dict(body) + # THEN the keyword-renamed slots are populated + assert query.source == {"includes": ["title"]} + assert query.from_ == 10 + assert query.size == 50 + assert query.search_after == [1, 2] + assert query.sort == [{"_score": "desc"}] + # AND re-serializing reproduces the original body + assert query.to_synapse_request() == body + + +class TestSearchHit: + """SearchHit deserialization including SearchHighlight.""" + + synapse_response = { + "rowId": 42, + "rowVersion": 3, + "score": 1.5, + "fields": [ + {"name": "title", "value": "Alzheimer study"}, + {"name": "disease", "value": "AD"}, + ], + "highlights": [ + {"name": "title", "snippets": ["Alzheimer study"]}, + ], + } + + def test_fill_from_dict(self): + # WHEN I fill a SearchHit from a response + hit = SearchHit().fill_from_dict(self.synapse_response) + # THEN scalar fields populate + assert hit.row_id == 42 + assert hit.row_version == 3 + assert hit.score == 1.5 + # AND fields are SearchFieldValue + assert all(isinstance(f, SearchFieldValue) for f in hit.fields) + assert hit.fields[0].name == "title" + assert hit.fields[0].value == "Alzheimer study" + # AND highlights are SearchHighlight with snippets + assert all(isinstance(h, SearchHighlight) for h in hit.highlights) + assert hit.highlights[0].name == "title" + assert hit.highlights[0].snippets == ["Alzheimer study"] + + def test_fill_from_dict_empty(self): + # WHEN I fill from an empty dict + hit = SearchHit().fill_from_dict({}) + # THEN collections default to empty lists + assert hit.fields == [] + assert hit.highlights == [] + + +class TestTextAnalyzer: + """TextAnalyzer carries raw settings.""" + + def test_round_trip(self): + # GIVEN a TextAnalyzer with a raw OpenSearch analysis block + settings = { + "tokenizer": {"std": {"type": "standard"}}, + "filter": {"med_syn": {"$ref": "biomed-medical_terms"}}, + "analyzer": { + "default": { + "type": "custom", + "tokenizer": "std", + "filter": ["lowercase", "med_syn"], + } + }, + } + analyzer = TextAnalyzer( + organization_name="biomed", + name="publications", + settings=settings, + ) + # WHEN I serialize then deserialize + request = analyzer.to_synapse_request() + # THEN settings pass through unchanged + assert request["settings"] == settings + assert request["organizationName"] == "biomed" + # AND qualified_name composes org + name + assert analyzer.qualified_name == "biomed-publications" + # AND fill_from_dict is the inverse + round_tripped = TextAnalyzer().fill_from_dict(request) + assert round_tripped.settings == settings + + +class TestSynonymSet: + """SynonymSet carries a raw definition object.""" + + def test_round_trip(self): + # GIVEN a SynonymSet with a raw synonym_graph definition + definition = { + "type": "synonym_graph", + "synonyms": ["tumor, neoplasm, cancer", "AD => Alzheimer's disease"], + } + synonym_set = SynonymSet( + organization_name="biomed", + name="medical_terms", + definition=definition, + ) + # WHEN I serialize it + request = synonym_set.to_synapse_request() + # THEN definition passes through unchanged + assert request["definition"] == definition + # AND fill_from_dict is the inverse + assert SynonymSet().fill_from_dict(request).definition == definition + + +class TestSearchConfiguration: + """SearchConfiguration with $ref and inline analyzer slots.""" + + def test_round_trip_with_refs(self): + # GIVEN a SearchConfiguration referencing saved resources + config = SearchConfiguration( + organization_name="biomed", + name="publications_v1", + default_analyzer={"$ref": "org.sagebionetworks-SCIENTIFIC"}, + column_analyzer_overrides=[{"$ref": "biomed-publications_overrides"}], + ) + # WHEN I serialize it + request = config.to_synapse_request() + # THEN the analyzer slots pass through as raw objects + assert request["defaultAnalyzer"] == {"$ref": "org.sagebionetworks-SCIENTIFIC"} + assert request["columnAnalyzerOverrides"] == [ + {"$ref": "biomed-publications_overrides"} + ] + # AND fill_from_dict is the inverse + round_tripped = SearchConfiguration().fill_from_dict(request) + assert round_tripped.default_analyzer == { + "$ref": "org.sagebionetworks-SCIENTIFIC" + } + assert round_tripped.column_analyzer_overrides == [ + {"$ref": "biomed-publications_overrides"} + ] + + +class TestColumnAnalyzerOverride: + """ColumnAnalyzerOverride with single analyzer per entry.""" + + def test_round_trip(self): + # GIVEN an override with a single analyzer per column + override = ColumnAnalyzerOverride( + organization_name="biomed", + name="publications_overrides", + overrides=[ + ColumnAnalyzerOverrideEntry( + column_name="disease_code", + analyzer={"$ref": "biomed-acronym_exact"}, + ), + ], + ) + # WHEN I serialize it + request = override.to_synapse_request() + # THEN each entry carries a single analyzer slot + assert request["overrides"] == [ + {"columnName": "disease_code", "analyzer": {"$ref": "biomed-acronym_exact"}} + ] + # AND fill_from_dict is the inverse + round_tripped = ColumnAnalyzerOverride().fill_from_dict(request) + assert round_tripped.overrides[0].column_name == "disease_code" + assert round_tripped.overrides[0].analyzer == {"$ref": "biomed-acronym_exact"} + + +class TestSearchConfigBinding: + def test_fill_from_dict(self): + # WHEN I fill a binding from a response + binding = SearchConfigBinding().fill_from_dict( + { + "bindId": "1", + "searchConfigurationId": "2", + "objectId": "syn3", + "objectType": "entity", + "createdBy": "9", + "createdOn": "2024-01-01T00:00:00.000Z", + } + ) + # THEN all fields populate + assert binding.bind_id == "1" + assert binding.search_configuration_id == "2" + assert binding.object_id == "syn3" + assert binding.object_type == "entity" + + async def test_store_async_binds_and_fills(self): + # GIVEN a binding with an object and configuration id + binding = SearchConfigBinding(object_id="syn3", search_configuration_id="2") + with patch( + "synapseclient.models.search_management.bind_search_config_to_entity", + new_callable=AsyncMock, + return_value={"bindId": "9", "searchConfigurationId": "2"}, + ) as mock_bind: + # WHEN I store it + result = await binding.store_async(synapse_client=self.syn) + # THEN the entity and configuration ids are forwarded and the response fills + mock_bind.assert_awaited_once_with("syn3", "2", synapse_client=self.syn) + assert result.bind_id == "9" + + @pytest.mark.parametrize( + "binding", + [ + SearchConfigBinding(search_configuration_id="2"), + SearchConfigBinding(object_id="syn3"), + ], + ids=["missing_object_id", "missing_configuration_id"], + ) + async def test_store_async_requires_ids(self, binding): + # WHEN storing a binding missing a required id THEN a ValueError is raised + with pytest.raises(ValueError): + await binding.store_async(synapse_client=self.syn) + + async def test_get_async_resolves_binding(self): + # GIVEN a binding for an entity + binding = SearchConfigBinding(object_id="syn3") + with patch( + "synapseclient.models.search_management.get_search_config_binding", + new_callable=AsyncMock, + return_value={"bindId": "9", "objectId": "syn3"}, + ) as mock_get: + # WHEN I get it + result = await binding.get_async(synapse_client=self.syn) + # THEN the entity id is forwarded and the response fills + mock_get.assert_awaited_once_with("syn3", synapse_client=self.syn) + assert result.bind_id == "9" + + async def test_delete_async_clears_binding(self): + # GIVEN a binding for an entity + binding = SearchConfigBinding(object_id="syn3") + with patch( + "synapseclient.models.search_management.clear_search_config_binding", + new_callable=AsyncMock, + ) as mock_clear: + # WHEN I delete it + await binding.delete_async(synapse_client=self.syn) + # THEN the clear endpoint is called for that entity + mock_clear.assert_awaited_once_with("syn3", synapse_client=self.syn) + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + +class TestOrgScopedResource: + """Dispatch tests for the create/get/update/list lifecycle shared by the + org-scoped search-management resources.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + # Each org-scoped resource wires its own api functions; validate every one. + RESOURCE_CLASSES = [ + TextAnalyzer, + ColumnAnalyzerOverride, + SynonymSet, + SearchConfiguration, + ] + + @pytest.mark.parametrize( + "cls", RESOURCE_CLASSES, ids=[c.__name__ for c in RESOURCE_CLASSES] + ) + async def test_lifecycle_dispatch(self, cls): + # GIVEN a new resource (no id) THEN store dispatches to the create fn + mock_create = AsyncMock( + return_value={"id": "5", "organizationName": "biomed", "name": "n"} + ) + with patch.object(cls, "_CREATE_FN", new=staticmethod(mock_create)): + new_resource = cls(organization_name="biomed", name="n") + expected_create_body = new_resource.to_synapse_request() + stored = await new_resource.store_async(synapse_client=self.syn) + mock_create.assert_awaited_once_with( + expected_create_body, synapse_client=self.syn + ) + assert stored.id == "5" + + # GIVEN a resource with an id THEN store dispatches to update by id + mock_update = AsyncMock(return_value={"id": "5", "name": "n2"}) + with patch.object(cls, "_UPDATE_FN", new=staticmethod(mock_update)): + existing = cls(id="5", organization_name="biomed", name="n2") + expected_update_body = existing.to_synapse_request() + await existing.store_async(synapse_client=self.syn) + assert mock_update.await_args.args[0] == "5" + assert mock_update.await_args.args[1] == expected_update_body + + # WHEN getting by id THEN get dispatches by id + mock_get = AsyncMock(return_value={"id": "5", "name": "n"}) + with patch.object(cls, "_GET_FN", new=staticmethod(mock_get)): + await cls(id="5").get_async(synapse_client=self.syn) + mock_get.assert_awaited_once_with("5", synapse_client=self.syn) + + # WHEN listing THEN all pages are followed via nextPageToken + mock_list = AsyncMock( + side_effect=[ + {"results": [{"id": "1"}], "nextPageToken": "tok"}, + {"results": [{"id": "2"}]}, + ] + ) + with patch.object(cls, "_LIST_FN", new=staticmethod(mock_list)): + listed = await cls.list_async( + organization_name="biomed", synapse_client=self.syn + ) + assert mock_list.await_count == 2 + assert [item.id for item in listed] == ["1", "2"] + # AND the second page request forwards the token from the first page + assert mock_list.await_args_list[1].kwargs["next_page_token"] == "tok" + + async def test_get_async_requires_id(self): + # WHEN getting a resource without an id THEN a ValueError is raised + with pytest.raises(ValueError): + await TextAnalyzer().get_async(synapse_client=self.syn) + + +class TestSearchIndexStatus: + def test_fill_from_dict(self): + # WHEN I fill a status from a response + status = SearchIndexStatus().fill_from_dict( + { + "searchIndexId": "syn1", + "state": "ACTIVE", + "changedOn": "2024-01-01T00:00:00.000Z", + "errorMessage": None, + } + ) + # THEN the state coerces to the enum + assert status.search_index_id == "syn1" + assert status.state is SearchIndexState.ACTIVE + + +class TestSearchAutocompleteRequest: + def test_to_synapse_request(self): + # GIVEN an autocomplete request with a prefix query + request = SearchAutocompleteRequest( + search_index_id="syn22806626", + query={"match_phrase_prefix": {"title": {"query": "alz"}}}, + source={"includes": ["title"]}, + ) + # WHEN I serialize it + body = request.to_synapse_request() + # THEN the body nests query and _source under searchQuery + assert body == { + "searchIndexId": "syn22806626", + "searchQuery": { + "query": {"match_phrase_prefix": {"title": {"query": "alz"}}}, + "_source": {"includes": ["title"]}, + }, + } + + def test_to_synapse_request_minimal(self): + # GIVEN an autocomplete request with only a query + request = SearchAutocompleteRequest( + search_index_id="syn1", + query={"prefix": {"title": {"value": "a"}}}, + ) + # WHEN I serialize it + body = request.to_synapse_request() + # THEN _source is omitted + assert body == { + "searchIndexId": "syn1", + "searchQuery": {"query": {"prefix": {"title": {"value": "a"}}}}, + } + + +class TestSearchIndexQuery: + """Request serialization, response deserialization, and the async flow.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def _build_query(self) -> SearchIndexQuery: + return SearchIndexQuery( + search_index_id="syn1", + search_query=SearchQuery(query={"match_all": {}}, size=10), + response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS], + ) + + def test_to_synapse_request(self): + # GIVEN a SearchIndexQuery + query = self._build_query() + # WHEN I serialize it + request = query.to_synapse_request() + # THEN it carries the concrete type, index id, query body, and parts + assert request == { + "concreteType": SEARCH_INDEX_QUERY, + "searchIndexId": "syn1", + "searchQuery": {"query": {"match_all": {}}, "size": 10}, + "responseParts": ["HITS", "TOTAL_HITS"], + } + + def test_fill_from_dict_response(self): + # GIVEN a SearchQueryResults response body + response = { + "hits": [ + { + "rowId": 1, + "fields": [{"name": "title", "value": "x"}], + "highlights": [{"name": "title", "snippets": ["x"]}], + } + ], + "totalHits": 7, + "selectColumns": [{"name": "title", "columnType": "STRING"}], + "aggregationResults": {"by_disease": {"buckets": []}}, + "nextSearchAfter": ["cursor", 99], + "offset": 0, + } + # WHEN I fill the query from it + query = self._build_query().fill_from_dict(response) + # THEN the response fields populate + assert query.total_hits == 7 + assert len(query.hits) == 1 + assert query.hits[0].row_id == 1 + assert query.hits[0].highlights[0].snippets == ["x"] + assert query.select_columns[0].name == "title" + assert query.aggregation_results == {"by_disease": {"buckets": []}} + assert query.next_search_after == ["cursor", 99] + assert query.offset == 0 + + async def test_send_job_and_wait_async(self): + # GIVEN a SearchIndexQuery + query = self._build_query() + response = {"hits": [], "totalHits": 0, "offset": 0} + with ( + patch( + "synapseclient.models.mixins.asynchronous_job.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=response, + ) as mock_send_job, + patch.object( + query, "fill_from_dict", wraps=query.fill_from_dict + ) as mock_fill, + ): + # WHEN I send the job and wait + await query.send_job_and_wait_async(synapse_client=self.syn) + # THEN the async job service is called with the serialized request + mock_send_job.assert_called_once_with( + request=query.to_synapse_request(), + request_type=SEARCH_INDEX_QUERY, + timeout=120, + synapse_client=self.syn, + ) + # AND fill_from_dict is invoked with the response body + mock_fill.assert_called_once_with(synapse_response=response) + # AND the response fields land on the instance + assert query.total_hits == 0 + assert query.hits == [] diff --git a/tests/unit/synapseclient/operations/unit_test_delete_operations.py b/tests/unit/synapseclient/operations/unit_test_delete_operations.py index fc6595536..cb64e4d89 100644 --- a/tests/unit/synapseclient/operations/unit_test_delete_operations.py +++ b/tests/unit/synapseclient/operations/unit_test_delete_operations.py @@ -348,11 +348,11 @@ async def test_delete_team_entity(self): assert result is None async def test_delete_schema_organization_entity(self): - """Test that a SchemaOrganization entity is deleted normally.""" - # GIVEN a mock SchemaOrganization entity - from synapseclient.models import SchemaOrganization + """Test that a Organization entity is deleted normally.""" + # GIVEN a mock Organization entity + from synapseclient.models import Organization - mock_org = SchemaOrganization(name="testorg") + mock_org = Organization(name="testorg") mock_org.delete_async = AsyncMock(return_value=None) # WHEN I call delete_async diff --git a/tests/unit/synapseclient/operations/unit_test_store_operations.py b/tests/unit/synapseclient/operations/unit_test_store_operations.py index 34d439c1f..2a8a4e42d 100644 --- a/tests/unit/synapseclient/operations/unit_test_store_operations.py +++ b/tests/unit/synapseclient/operations/unit_test_store_operations.py @@ -480,15 +480,15 @@ async def test_store_evaluation_entity(self): assert result is mock_eval -class TestStoreSchemaOrganizationRoute: - """Tests for SchemaOrganization entity routing in store_async.""" +class TestStoreOrganizationRoute: + """Tests for Organization entity routing in store_async.""" async def test_store_schema_organization(self): - """Test that SchemaOrganization routes to store_async.""" - # GIVEN a mock SchemaOrganization entity - from synapseclient.models import SchemaOrganization + """Test that Organization routes to store_async.""" + # GIVEN a mock Organization entity + from synapseclient.models import Organization - mock_org = SchemaOrganization(name="testorg") + mock_org = Organization(name="testorg") mock_org.store_async = AsyncMock(return_value=mock_org) # WHEN I call store_async diff --git a/tests/unit/synapseutils/unit_test_synapseutils_sync.py b/tests/unit/synapseutils/unit_test_synapseutils_sync.py index ff474901f..f12353270 100644 --- a/tests/unit/synapseutils/unit_test_synapseutils_sync.py +++ b/tests/unit/synapseutils/unit_test_synapseutils_sync.py @@ -610,6 +610,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ), @@ -626,6 +627,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ), @@ -811,6 +813,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ), @@ -827,6 +830,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ), @@ -979,6 +983,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ), @@ -995,6 +1000,7 @@ async def mock_get_children(*args, **kwargs): "datasetcollection", "materializedview", "virtualtable", + "searchindex", ], synapse_client=syn, ),