Skip to content

Commit 8e9fb90

Browse files
authored
schema support (#75)
* refactor model metaclass and options for improved configuration handling (#75) * move _to_snake_case function to utils.py (#75) * update documentation to reflect table name customization via Meta class (#75) * add schema support (#75)
1 parent 82923c7 commit 8e9fb90

File tree

10 files changed

+150
-64
lines changed

10 files changed

+150
-64
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,9 @@ class User(BaseSBModel):
3535
is_active: bool = True
3636

3737
# By default table name is class name in snake_case
38-
# If you want to change it - you should implement _get_table_name method
39-
@classmethod
40-
def _get_table_name(cls) -> str:
41-
return 'db_user'
38+
# you can override it by setting `Meta.table_name` attribute
39+
class Meta:
40+
table_name = 'db_user'
4241

4342
# Save user
4443
active_user = User(name='John Snow')

docs/index.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,9 @@ class User(BaseSBModel):
3131
is_active: bool = True
3232

3333
# By default table name is class name in snake_case
34-
# If you want to change it - you should implement _get_table_name method
35-
@classmethod
36-
def _get_table_name(cls) -> str:
37-
return 'db_user'
34+
# you can override it by setting `Meta.table_name` attribute
35+
class Meta:
36+
table_name = 'db_user'
3837

3938
# Save user
4039
active_user = User(name='John Snow')

supadantic/clients/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class BaseClient(ABC, metaclass=BaseClientMeta):
2828
for interacting with a specific database or service.
2929
"""
3030

31-
def __init__(self, table_name: str) -> None:
31+
def __init__(self, table_name: str, schema: str | None = None) -> None:
3232
"""
3333
Initializes the client with the table name.
3434
@@ -39,6 +39,7 @@ def __init__(self, table_name: str) -> None:
3939
"""
4040

4141
self.table_name = table_name
42+
self.schema = schema
4243

4344
def execute(self, *, query_builder: QueryBuilder) -> list[dict[str, Any]] | int:
4445
"""

supadantic/clients/cache.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class CacheClient(BaseClient, metaclass=SingletoneMeta):
5252
It is NOT suitable for production environments.
5353
"""
5454

55-
def __init__(self, table_name: str) -> None:
55+
def __init__(self, table_name: str, schema: str | None = None) -> None:
5656
"""
5757
Initializes the client with the table name and an empty cache.
5858
@@ -63,7 +63,7 @@ def __init__(self, table_name: str) -> None:
6363
`BaseClient` interface and may be used in future
6464
extensions of this class.
6565
"""
66-
super().__init__(table_name=table_name)
66+
super().__init__(table_name=table_name, schema=schema)
6767

6868
self._cache_data: dict[int, dict[str, Any]] = {}
6969

supadantic/clients/supabase.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88

99
if TYPE_CHECKING:
10-
from postgrest._sync.request_builder import SyncSelectRequestBuilder
10+
from postgrest._sync.request_builder import SyncRequestBuilder, SyncSelectRequestBuilder
1111
from postgrest.base_request_builder import BaseFilterRequestBuilder
1212

1313
from supadantic.query_builder import QueryBuilder
@@ -25,19 +25,35 @@ class SupabaseClient(BaseClient):
2525
to initialize the Supabase client.
2626
"""
2727

28-
def __init__(self, table_name: str):
28+
def __init__(self, table_name: str, schema: str | None = None) -> None:
2929
"""
3030
Initializes the Supabase client and sets up the query object.
3131
3232
Args:
3333
table_name (str): The name of the table to interact with.
3434
"""
3535

36-
super().__init__(table_name=table_name)
36+
super().__init__(table_name=table_name, schema=schema)
3737
url: str = os.getenv('SUPABASE_URL', default='')
3838
key: str = os.getenv('SUPABASE_KEY', default='')
39+
40+
supabase_client = self._get_supabase_client(url=url, key=key)
41+
self.query = supabase_client
42+
43+
def _get_supabase_client(self, url: str, key: str) -> 'SyncRequestBuilder':
44+
"""
45+
Returns the Supabase client query object.
46+
47+
This method is used to access the underlying Supabase client for executing queries.
48+
It is primarily used internally by other methods in this class.
49+
50+
Returns:
51+
(SyncRequestBuilder): The Supabase client query object.
52+
"""
3953
supabase_client = create_client(url, key)
40-
self.query = supabase_client.table(table_name=self.table_name)
54+
if self.schema:
55+
supabase_client = supabase_client.schema(self.schema)
56+
return supabase_client.table(self.table_name)
4157

4258
def _delete(self, *, query_builder: 'QueryBuilder') -> list[dict[str, Any]]:
4359
"""

supadantic/models.py

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import ast
2-
import re
32
from abc import ABC
43
from copy import copy
5-
from typing import TYPE_CHECKING, Any, TypeVar
4+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
65

76
from pydantic import BaseModel, model_validator
87
from pydantic._internal._model_construction import ModelMetaclass as PydanticModelMetaclass
98

109
from .clients import SupabaseClient
1110
from .q_set import QSet
1211
from .query_builder import QueryBuilder
12+
from .utils import _to_snake_case
1313

1414

1515
if TYPE_CHECKING:
@@ -19,35 +19,44 @@
1919
_M = TypeVar('_M', bound='BaseSBModel')
2020

2121

22-
def _to_snake_case(value: str) -> str:
22+
class ModelOptions:
23+
"""
24+
Configuration class to store model metadata and options.
2325
"""
24-
Converts a string from camel case or Pascal case to snake case.
25-
26-
This function uses a regular expression to find uppercase letters within
27-
the string and inserts an underscore before them (except at the beginning
28-
of the string). The entire string is then converted to lowercase.
2926

30-
Args:
31-
value (str): The string to convert.
27+
def __init__(
28+
self,
29+
table_name: str | None = None,
30+
db_client: type['BaseClient'] | None = None,
31+
schema: str | None = None,
32+
):
33+
self.table_name = table_name
34+
self.db_client = db_client or SupabaseClient
35+
self.schema = schema
3236

33-
Returns:
34-
(str): The snake_case version of the input string.
3537

36-
Example:
37-
>>> _to_snake_case("MyClassName")
38-
'my_class_name'
38+
class ModelMetaclass(PydanticModelMetaclass):
39+
"""
40+
Metaclass for BaseSBModel, handling Meta class configuration and objects property.
3941
"""
40-
return re.sub(r'(?<!^)(?=[A-Z])', '_', value).lower()
4142

43+
def __new__(mcs, name, bases, namespace):
44+
cls = super().__new__(mcs, name, bases, namespace)
45+
meta = namespace.get('Meta')
46+
options = ModelOptions()
4247

43-
class ModelMetaclass(PydanticModelMetaclass):
44-
"""
45-
Metaclass for BaseSBModel, adding a custom `objects` property.
48+
if meta is not None:
49+
if hasattr(meta, 'table_name'):
50+
options.table_name = meta.table_name
4651

47-
This metaclass extends Pydantic's ModelMetaclass to provide a custom `objects`
48-
property on each class that uses it. The `objects` property returns a `QSet`
49-
instance, which is used for performing database queries related to the model.
50-
"""
52+
if hasattr(meta, 'db_client'):
53+
options.db_client = meta.db_client
54+
55+
if hasattr(meta, 'schema'):
56+
options.schema = meta.schema
57+
58+
cls._meta = options
59+
return cls
5160

5261
@property
5362
def objects(cls: type[_M]) -> QSet[_M]: # type: ignore
@@ -85,6 +94,7 @@ class MultipleObjectsReturned(Exception):
8594
pass
8695

8796
id: int | None = None
97+
_meta: ClassVar[ModelOptions]
8898

8999
def save(self: _M) -> _M:
90100
"""
@@ -142,35 +152,25 @@ def db_client(cls) -> type['BaseClient']:
142152
(BaseClient): The database client class.
143153
"""
144154

145-
return SupabaseClient
155+
return cls._meta.db_client
146156

147157
@classmethod
148158
def _get_table_name(cls) -> str:
149159
"""
150-
Gets the table name associated with the model, converting the class name to snake case.
151-
152-
This method converts the class name to snake_case to determine the corresponding
153-
table name in the database.
154-
155-
Returns:
156-
(str): The table name in snake_case.
160+
Gets the table name associated with the model.
161+
If no table_name is specified in Meta class, converts class name to snake_case.
157162
"""
158-
return _to_snake_case(cls.__name__)
163+
return cls._meta.table_name or _to_snake_case(cls.__name__)
159164

160165
@classmethod
161166
def _get_db_client(cls) -> 'BaseClient':
162167
"""
163-
Retrieves the database client instance for the model, configured with the table name.
164-
165-
This method creates a database client instance using the `db_client()` method
166-
and initializes it with the appropriate table name.
167-
168-
Returns:
169-
(BaseClient): An initialized instance of the database client.
168+
Retrieves the database client instance for the model.
170169
"""
171-
172170
table_name = cls._get_table_name()
173-
return cls.db_client()(table_name)
171+
schema = cls._meta.schema
172+
client = cls.db_client()(table_name, schema)
173+
return client
174174

175175
@model_validator(mode='before')
176176
def _validate_data_from_supabase(cls, data: dict[str, Any]) -> dict[str, Any]:

supadantic/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import re
2+
3+
4+
def _to_snake_case(value: str) -> str:
5+
"""
6+
Converts a string from camel case or Pascal case to snake case.
7+
8+
This function uses a regular expression to find uppercase letters within
9+
the string and inserts an underscore before them (except at the beginning
10+
of the string). The entire string is then converted to lowercase.
11+
12+
Args:
13+
value (str): The string to convert.
14+
15+
Returns:
16+
(str): The snake_case version of the input string.
17+
18+
Example:
19+
>>> _to_snake_case("MyClassName")
20+
'my_class_name'
21+
"""
22+
return re.sub(r'(?<!^)(?=[A-Z])', '_', value).lower()

tests/fixtures/model.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,43 @@
99
if TYPE_CHECKING:
1010
from collections.abc import Generator
1111

12-
from supadantic.clients.base import BaseClient
13-
1412

1513
class ModelMock(BaseSBModel):
1614
id: int | None = None
1715
name: str
1816
age: int | None = None
1917
some_optional_list: list[str] | None = None
20-
some_optional_tuple: tuple[str, ...] | None = None
18+
some_optional_tuple: tuple[str, ...] | None = None # noqa: CCE001
19+
20+
class Meta:
21+
db_client = CacheClient
22+
2123

22-
@classmethod
23-
def db_client(cls) -> type['BaseClient']:
24-
return CacheClient
24+
class ModelMockCustomSchema(ModelMock):
25+
class Meta:
26+
db_client = CacheClient
27+
schema = 'custom_schema'
2528

2629

2730
@pytest.fixture(scope='function')
2831
def model_mock() -> type[ModelMock]:
2932
return ModelMock
3033

3134

35+
@pytest.fixture(scope='function')
36+
def model_mock_custom_schema() -> type[ModelMockCustomSchema]:
37+
return ModelMockCustomSchema
38+
39+
3240
@pytest.fixture(autouse=True, scope='function')
3341
def clean_db_cache(model_mock: type['ModelMock']) -> 'Generator':
3442
yield
3543
model_mock.objects._cache = []
3644
model_mock.objects.all().delete()
45+
46+
47+
@pytest.fixture(autouse=True, scope='function')
48+
def clean_db_cache_custom_schema(model_mock_custom_schema: type['ModelMockCustomSchema']) -> 'Generator':
49+
yield
50+
model_mock_custom_schema.objects._cache = []
51+
model_mock_custom_schema.objects.all().delete()

tests/test_models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,15 @@ def test_update(self, model_mock: type['ModelMock']):
4747

4848
def test_objects(self, model_mock: type['ModelMock']):
4949
assert isinstance(model_mock.objects, QSet)
50+
51+
52+
class TestBaseSBModelCustomSchema:
53+
def test_db_client_with_custom_schema(self, model_mock_custom_schema: type['ModelMock']):
54+
# Prepare data
55+
test_entity = model_mock_custom_schema(name='test_name')
56+
57+
# Execution
58+
db_client = test_entity._get_db_client()
59+
60+
# Testing
61+
assert db_client.schema == 'custom_schema'

tests/test_supabase_client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ def test_filter(self, supabase_client: SupabaseClient, httpx_mock: 'HTTPXMock'):
8383
),
8484
status_code=200,
8585
)
86-
httpx_mock.add_response(is_optional=True)
8786

8887
query_builder = QueryBuilder()
8988
query_builder.set_equal(id=1)
@@ -132,7 +131,6 @@ def test_order_by(self, supabase_client: SupabaseClient, httpx_mock: 'HTTPXMock'
132131
),
133132
status_code=200,
134133
)
135-
httpx_mock.add_response(is_optional=True)
136134

137135
query_buider = QueryBuilder()
138136
query_buider.set_not_equal(title='test')
@@ -144,3 +142,27 @@ def test_order_by(self, supabase_client: SupabaseClient, httpx_mock: 'HTTPXMock'
144142

145143
# Assert
146144
assert len(httpx_mock.get_requests()) == 1
145+
146+
def test_select_with_schema(self, httpx_mock: 'HTTPXMock'):
147+
# Arrange
148+
httpx_mock.add_response(
149+
method='GET',
150+
url=httpx.URL(
151+
'https://test.supabase.co/rest/v1/table_name',
152+
params={'select': '*', 'id': 'eq.1', 'title': 'neq.test'},
153+
),
154+
status_code=200,
155+
)
156+
157+
query_builder = QueryBuilder()
158+
query_builder.set_equal(id=1)
159+
query_builder.set_not_equal(title='test')
160+
161+
# Act
162+
supabase_client = SupabaseClient(table_name='table_name', schema='foo')
163+
supabase_client.execute(query_builder=query_builder)
164+
165+
# Assert
166+
assert len(httpx_mock.get_requests()) == 1
167+
request = httpx_mock.get_requests()[0]
168+
assert request.headers['accept-profile'] == 'foo'

0 commit comments

Comments
 (0)