-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(pipeline): Add Firestore DatabaseClient (#12)
* feat(pipeline): add Rating Model class * build(pipeline): add jsonschema to requirements.txt to validate json schema in tests * feat(pipeline): add Congestion model * test(pipeline): check pydantic model export conforms to schema * feat(pipeline): add DatabaseClient to interact with firebase database * doc(readme): document pipeline need for GOOGLE_APPLICATION_CREDENTIALS * ci(pipeline): add gcp auth needed for firestore integration tests
- Loading branch information
Showing
8 changed files
with
263 additions
and
7 deletions.
There are no files selected for viewing
This file contains 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 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 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 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,111 @@ | ||
# | ||
# Flowmotion | ||
# Pipeline | ||
# Firestore DB Client | ||
# | ||
|
||
from typing import Any, Iterable, Optional, cast | ||
|
||
import firebase_admin | ||
from firebase_admin import firestore | ||
from google.cloud.firestore import DocumentReference | ||
from pydantic import BaseModel | ||
|
||
from model import to_json_dict | ||
|
||
|
||
class DatabaseClient: | ||
"""Firestore Database (DB) client""" | ||
|
||
def __init__(self) -> None: | ||
"""Creates a new Firestore DB client. | ||
Uses Google Application Default credentials with authenticate DB requests. | ||
See https://firebase.google.com/docs/admin/setup#initialize-sdk. | ||
""" | ||
app = firebase_admin.initialize_app() | ||
self._db = firestore.client(app) | ||
|
||
def insert(self, table: str, data: BaseModel) -> str: | ||
""" | ||
Inserts the given data into the specified table. | ||
Args: | ||
table: Name of Firestore collection to insert to. | ||
data: Pydantic model to insert as a document. | ||
Returns: | ||
Key of the inserted firebase document. | ||
""" | ||
_, doc = self._db.collection(table).add(to_json_dict(data)) | ||
return _to_key(doc) | ||
|
||
def update(self, table: str, key: str, data: BaseModel): | ||
""" | ||
Updates the given data into the specified table. | ||
Args: | ||
table: Name of Firestore collection to update to. | ||
key: Key specifying the Firestore document to update. | ||
data: Pydantic model to update Firestore document's contents | ||
""" | ||
self._db.collection(table).document(_doc_id(key)).set(to_json_dict(data)) | ||
|
||
def delete(self, table: str, key: str): | ||
""" | ||
Deletes the row (document) with key from the specified table. | ||
Args: | ||
table: Name of Firestore collection to delete from. | ||
key: Key specifying the Firestore document to delete. | ||
""" | ||
self._db.collection(table).document(_doc_id(key)).delete() | ||
|
||
def get(self, table: str, key: str) -> Optional[dict[str, Any]]: | ||
""" | ||
Retrieves the contents of the row (document) with key from the specified table. | ||
Args: | ||
table: Name of Firestore collection to delete from. | ||
key: Key specifying the Firestore document to delete. | ||
Returns: | ||
Contents of matching document as dict or None if not such document exists. | ||
""" | ||
return self._db.collection(table).document(_doc_id(key)).get().to_dict() | ||
|
||
def query(self, table: str, **params) -> Iterable[str]: | ||
""" | ||
Query keys of all rows (Firestore documents) on the specified table that match params. | ||
Args: | ||
table: Name of Firestore collection to query from. | ||
params: Query parameters are given as document fields in in the format | ||
<field>=(<operator>, <value>) where: | ||
- <field> is a field path "<name>[__<subfield>...]" which refers the documents | ||
<name> field (or <name>.<subfield> if optional sub field name is specified). | ||
- <operator> is one of Firestore's supported operators. | ||
See https://firebase.google.com/docs/firestore/query-data/queries | ||
- <value> is used by the operator to find matching rows. | ||
Example: | ||
Get User rows with `name="john"`: | ||
db = DatabaseClient() | ||
users = db.query("Users", name=("==", "john")) | ||
Returns: | ||
Iterator of keys of matching rows in on the table. | ||
""" | ||
# build query by applying query params | ||
collection = self._db.collection(table) | ||
for field, (op, value) in params.items(): | ||
collection = collection.where(field.replace("__", "."), op, value) | ||
|
||
for document in self._db.collection(table).list_documents(): | ||
yield _to_key(document) | ||
|
||
|
||
def _to_key(ref: DocumentReference) -> str: | ||
return cast(DocumentReference, ref).path | ||
|
||
|
||
def _doc_id(key: str) -> str: | ||
return key.split("/")[-1] |
This file contains 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 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 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,60 @@ | ||
# | ||
# Flowmotion | ||
# Firestore DB Client | ||
# Integration Test | ||
# | ||
|
||
|
||
# Usage: | ||
# - Google Application Default credentials should be provided to authenticate | ||
# with firestore eg. by setting GOOGLE_APPLICATION_CREDENTIALS env var. | ||
|
||
|
||
import os | ||
from uuid import uuid4 | ||
|
||
import pytest | ||
from pydantic import BaseModel | ||
|
||
from db import DatabaseClient | ||
from model import to_json_dict | ||
|
||
|
||
class Model(BaseModel): | ||
field: str | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def db() -> DatabaseClient: | ||
os.environ["GOOGLE_CLOUD_PROJECT"] = "flowmotion-4e268" | ||
return DatabaseClient() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def collection(db: DatabaseClient): | ||
# unique collection name for testing | ||
name = f"test_db_{uuid4()}" | ||
yield name | ||
|
||
# empty collection of any existing documents to cleanup test collection | ||
collection = db._db.collection(name) | ||
for document in collection.list_documents(): | ||
document.delete() | ||
|
||
|
||
@pytest.fixture | ||
def model() -> Model: | ||
return Model(field="test") | ||
|
||
|
||
@pytest.mark.integration | ||
def test_db_insert_get_delete_query(db: DatabaseClient, collection: str, model: Model): | ||
# test: insert model into collection | ||
key = db.insert(table=collection, data=model) | ||
assert len(key) > 0 | ||
# test: get by key | ||
assert db.get(table=collection, key=key) == to_json_dict(model) | ||
# test: query by field value | ||
got_key = list(db.query(table=collection, field=("==", "test")))[0] | ||
assert got_key == key | ||
db.delete(collection, key) |
This file contains 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,37 @@ | ||
# | ||
# Flowmotion | ||
# Models | ||
# Unit Tests | ||
# | ||
|
||
|
||
import json | ||
from datetime import datetime | ||
from pathlib import Path | ||
|
||
from jsonschema import validate | ||
|
||
from model import Camera, Congestion, Location, Rating, to_json_dict | ||
|
||
CONGESTION_SCHEMA = Path(__file__).parent.parent / "schema" / "congestion.schema.json" | ||
|
||
|
||
def test_congestion_json(): | ||
congestion = Congestion( | ||
camera=Camera( | ||
id="1001", | ||
image_url="https://images.data.gov.sg/api/traffic/1001.jpg", | ||
captured_on=datetime(2024, 9, 27, 8, 30, 0), | ||
retrieved_on=datetime(2024, 9, 27, 8, 31, 0), | ||
location=Location(longitude=103.851959, latitude=1.290270), | ||
), | ||
rating=Rating( | ||
rated_on=datetime(2024, 9, 27, 8, 32, 0), model_id="v1.0", value=0.75 | ||
), | ||
updated_on=datetime(2024, 9, 27, 8, 33, 0), | ||
) | ||
|
||
with open(CONGESTION_SCHEMA, "r") as f: | ||
schema = json.load(f) | ||
|
||
validate(to_json_dict(congestion), schema) |