-
Notifications
You must be signed in to change notification settings - Fork 3
feat: DM-3945 add table reference CRUD client APIs #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
beneboy
wants to merge
3
commits into
main
Choose a base branch
from
DM-3945-table-reference-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| """Table reference models for the DataMasque API.""" | ||
|
|
||
| import enum | ||
| from datetime import datetime | ||
| from typing import NewType, Optional, Union | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, field_validator | ||
|
|
||
| from datamasque.client.models.connection import ConnectionConfig, ConnectionId, unwrap_connection_id | ||
|
|
||
| TableReferenceId = NewType("TableReferenceId", str) | ||
|
|
||
|
|
||
| class TableReferenceFormat(enum.Enum): | ||
| """ | ||
| File format of the data a table reference points at. | ||
|
|
||
| Only meaningful for a table reference on a file connection, | ||
| and an explicit choice: the source path's suffix carries no meaning. | ||
| """ | ||
|
|
||
| csv = "csv" | ||
| parquet = "parquet" | ||
|
|
||
|
|
||
| class TableReferenceOptions(BaseModel): | ||
| """ | ||
| Format and CSV parsing options for a table reference. | ||
|
|
||
| These apply to file connections only — | ||
| they are ignored for a table reference on a database connection, | ||
| and the CSV options are ignored when the `format` is `parquet`. | ||
|
|
||
| There is deliberately no header toggle: | ||
| a CSV identity map's columns must be addressable by name, | ||
| so the first row is always read as the header. | ||
|
|
||
| Every option has a default, and the server fills in any option omitted from a request, | ||
| so the values here are the same defaults the server applies. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(extra="allow", populate_by_name=True) | ||
|
|
||
| format: TableReferenceFormat = TableReferenceFormat.csv | ||
| delimiter: str = "," | ||
| encoding: str = "utf-8" | ||
| quotechar: str = '"' | ||
| null_string: Optional[str] = None | ||
| """The string in the source data to read as a null value, or `None` to read no value as null.""" | ||
|
|
||
|
|
||
| class TableReference(BaseModel): | ||
| """ | ||
| Represents a named, persisted reference to a table of identity data held outside DataMasque. | ||
|
|
||
| The data lives in the referenced connection — | ||
| a CSV or Parquet file in a file connection, or a table in a database connection — | ||
| so a ruleset can reference the identity map once, by name, | ||
| instead of re-declaring the source each time. | ||
|
|
||
| A table reference stores no credentials of its own; it borrows the referenced connection's. | ||
|
|
||
| `source` is interpreted according to that connection's own type: | ||
|
|
||
| * file connection — a path to the file within the connection's fileset. | ||
| The format comes from `options.format`, never from the path's suffix. | ||
| * database connection — a dotted, schema-qualified `schema.table` reference. | ||
|
|
||
| Names are unique across live table references (a deleted reference frees its name for reuse), | ||
| and the referenced connection must not be archived. | ||
| """ | ||
|
|
||
| model_config = ConfigDict(extra="allow", populate_by_name=True) | ||
|
|
||
| name: str | ||
| connection: Union[ConnectionId, ConnectionConfig] | ||
| """The connection the data lives in. Accepts a `ConnectionId` or a `ConnectionConfig` (its `id` is extracted).""" | ||
| source: str | ||
| options: Optional[TableReferenceOptions] = None | ||
| """Format and CSV parsing options; when `None`, the server applies its defaults on create.""" | ||
|
|
||
| # Server-populated read-only fields, excluded from request bodies. | ||
| id: Optional[TableReferenceId] = Field(default=None, exclude=True) | ||
| created: Optional[datetime] = Field(default=None, exclude=True) | ||
| modified: Optional[datetime] = Field(default=None, exclude=True) | ||
|
|
||
| @field_validator("connection", mode="before") | ||
| @classmethod | ||
| def _unwrap_connection(cls, value: object) -> object: | ||
| return unwrap_connection_id(value) | ||
|
|
||
| @property | ||
| def connection_id(self) -> ConnectionId: | ||
| """ | ||
| The ID of the referenced connection. | ||
|
|
||
| `connection` accepts a whole `ConnectionConfig` for convenience but always holds an ID | ||
| once validated, so this narrows the declared type back down for callers reading it. | ||
| """ | ||
|
|
||
| return ConnectionId(unwrap_connection_id(self.connection)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import logging | ||
| from typing import Optional | ||
|
|
||
| from datamasque.client.base import BaseClient | ||
| from datamasque.client.exceptions import DataMasqueApiError | ||
| from datamasque.client.models.table_reference import TableReference, TableReferenceId | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class TableReferenceClient(BaseClient): | ||
| """Table reference CRUD API methods. Mixed into `DataMasqueClient`.""" | ||
|
|
||
| def list_table_references(self) -> list[TableReference]: | ||
| """ | ||
| Lists all table references. | ||
|
|
||
| Unlike most list endpoints, this one is not paginated; | ||
| the server returns every table reference in a single response. | ||
| Deleted table references are archived rather than removed, and are not listed. | ||
| """ | ||
|
|
||
| response = self.make_request("GET", "/api/table-references/") | ||
| return [TableReference.model_validate(item) for item in response.json()] | ||
|
|
||
| def get_table_reference(self, table_reference_id: TableReferenceId) -> TableReference: | ||
| """Retrieves a single table reference by ID.""" | ||
|
|
||
| response = self.make_request("GET", f"/api/table-references/{table_reference_id}/") | ||
| return TableReference.model_validate(response.json()) | ||
|
|
||
| def get_table_reference_by_name(self, name: str) -> Optional[TableReference]: | ||
| """ | ||
| Looks for a table reference with the given name (case-sensitive, exact match). | ||
|
|
||
| Names are unique across live table references, so at most one can match. | ||
| Returns it if found, otherwise `None`. | ||
|
|
||
| The endpoint takes no name filter, so the match is made client-side over the full listing. | ||
| """ | ||
|
|
||
| matches = [reference for reference in self.list_table_references() if reference.name == name] | ||
| if not matches: | ||
| return None | ||
|
|
||
| return matches[0] | ||
|
|
||
| def _get_table_reference_id_by_name(self, name: str) -> Optional[TableReferenceId]: | ||
| """Return the ID of the table reference with the given name, or `None` if there is none.""" | ||
|
|
||
| response = self.make_request("GET", "/api/table-references/") | ||
| matches = [item for item in response.json() if item.get("name") == name] | ||
| if not matches: | ||
| return None | ||
|
|
||
| existing_id = matches[0].get("id") | ||
| if existing_id is None: | ||
| raise DataMasqueApiError( | ||
| f'Server returned a table reference named "{name}" without an `id`.', | ||
| response=response, | ||
| ) | ||
|
|
||
| return TableReferenceId(existing_id) | ||
|
|
||
| def create_table_reference(self, table_reference: TableReference) -> TableReference: | ||
| """ | ||
| Creates a new table reference on the server. | ||
|
|
||
| Sets the table reference's server-assigned fields (`id`, `created`, `modified`) | ||
| and its `options` as stored by the server (with any omitted option filled in with its default), | ||
| then returns the table reference. | ||
| """ | ||
|
|
||
| data = table_reference.model_dump(by_alias=True, mode="json") | ||
| if data.get("options") is None: | ||
| data.pop("options", None) | ||
| response = self.make_request("POST", "/api/table-references/", data=data) | ||
| created = TableReference.model_validate(response.json()) | ||
| table_reference.id = created.id | ||
| table_reference.options = created.options | ||
| table_reference.created = created.created | ||
| table_reference.modified = created.modified | ||
| logger.info('Creation of table reference "%s" successful', table_reference.name) | ||
| return table_reference | ||
|
|
||
| def update_table_reference(self, table_reference: TableReference) -> TableReference: | ||
| """ | ||
| Performs a full update of the table reference. | ||
|
|
||
| The table reference must have its `id` set | ||
| (i.e., it must have been previously created or retrieved from the server). | ||
|
|
||
| `options` are replaced wholesale when set, so an option left at its model default | ||
| is sent as that default rather than keeping whatever the server had stored. | ||
| Leaving `options` as `None` sends none at all, which leaves the stored options untouched. | ||
| """ | ||
|
|
||
| if table_reference.id is None: | ||
| raise ValueError("Cannot update a table reference that has not been created yet (id is None)") | ||
|
|
||
| data = table_reference.model_dump(by_alias=True, mode="json") | ||
| if data.get("options") is None: | ||
| data.pop("options", None) | ||
| response = self.make_request("PUT", f"/api/table-references/{table_reference.id}/", data=data) | ||
| updated = TableReference.model_validate(response.json()) | ||
| table_reference.options = updated.options | ||
| table_reference.modified = updated.modified | ||
| logger.debug('Update of table reference "%s" successful', table_reference.name) | ||
| return table_reference | ||
|
|
||
| def create_or_update_table_reference(self, table_reference: TableReference) -> TableReference: | ||
| """ | ||
| Creates the table reference, or updates the existing one with the same name. | ||
|
|
||
| Sets the table reference's `id` property. | ||
| """ | ||
|
|
||
| existing_id = self._get_table_reference_id_by_name(table_reference.name) | ||
| if existing_id is not None: | ||
| table_reference.id = existing_id | ||
| return self.update_table_reference(table_reference) | ||
|
|
||
| return self.create_table_reference(table_reference) | ||
|
|
||
| def delete_table_reference_by_id_if_exists(self, table_reference_id: TableReferenceId) -> None: | ||
| """ | ||
| Deletes the table reference with the given ID. | ||
|
|
||
| The server archives rather than removes it, which frees its name for reuse. | ||
| No-op if the table reference does not exist. | ||
| """ | ||
|
|
||
| self._delete_if_exists(f"/api/table-references/{table_reference_id}/") | ||
|
|
||
| def delete_table_reference_by_name_if_exists(self, name: str) -> None: | ||
| """ | ||
| Deletes the table reference with the given name. | ||
|
|
||
| No-op if no such table reference exists. | ||
| """ | ||
|
|
||
| table_reference_id = self._get_table_reference_id_by_name(name) | ||
| if table_reference_id is not None: | ||
| self.delete_table_reference_by_id_if_exists(table_reference_id) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| [bumpversion] | ||
| current_version = 1.1.7 | ||
| current_version = 1.1.8 | ||
| commit = True | ||
| tag = True | ||
|
|
||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to strip server-only fields, the same way
_strip_server_only_fieldsdoes for connections?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked — unlike connections there's no secret/write-only field on this model (no password equivalent). Server response only ever contains name/connection/source/options/id/created/modified, all modeled; id/created/modified already excluded from request bodies via Field(exclude=True). No stripping needed here.