activemodel 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- activemodel/__init__.py +6 -0
- activemodel/base_model.py +137 -0
- activemodel/query_wrapper.py +84 -0
- activemodel/timestamps.py +38 -0
- activemodel-0.3.0.dist-info/LICENSE +21 -0
- activemodel-0.3.0.dist-info/METADATA +34 -0
- activemodel-0.3.0.dist-info/RECORD +10 -0
- activemodel-0.3.0.dist-info/WHEEL +5 -0
- activemodel-0.3.0.dist-info/entry_points.txt +2 -0
- activemodel-0.3.0.dist-info/top_level.txt +1 -0
activemodel/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typing as t
|
|
3
|
+
|
|
4
|
+
import pydash
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from sqlalchemy.orm import declared_attr
|
|
7
|
+
from sqlmodel import Session, SQLModel
|
|
8
|
+
|
|
9
|
+
from .query_wrapper import QueryWrapper
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseModel(SQLModel):
|
|
13
|
+
"""
|
|
14
|
+
Base model class to inherit from so we can hate python less
|
|
15
|
+
|
|
16
|
+
https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# TODO implement actually calling these hooks
|
|
20
|
+
|
|
21
|
+
def before_delete(self):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def after_delete(self):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def before_save(self):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
def after_save(self):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def before_update(self):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def after_update(self):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@declared_attr
|
|
40
|
+
def __tablename__(cls) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Automatically generates the table name for the model by converting the class name from camel case to snake case.
|
|
43
|
+
|
|
44
|
+
By default, the class is lower cased which makes it harder to read.
|
|
45
|
+
|
|
46
|
+
Many snake_case libraries struggle with snake case for names like LLMCache, which is why we are using a more
|
|
47
|
+
complicated implementation from pydash.
|
|
48
|
+
|
|
49
|
+
https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
|
|
50
|
+
"""
|
|
51
|
+
return pydash.strings.snake_case(cls.__name__)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def select(cls, *args):
|
|
55
|
+
return QueryWrapper[cls](cls, *args)
|
|
56
|
+
|
|
57
|
+
def save(self):
|
|
58
|
+
old_session = Session.object_session(self)
|
|
59
|
+
with get_session() as session:
|
|
60
|
+
if old_session:
|
|
61
|
+
# I was running into an issue where the object was already
|
|
62
|
+
# associated with a session, but the session had been closed,
|
|
63
|
+
# to get around this, you need to remove it from the old one,
|
|
64
|
+
# then add it to the new one (below)
|
|
65
|
+
old_session.expunge(self)
|
|
66
|
+
|
|
67
|
+
self.before_update()
|
|
68
|
+
# self.before_save()
|
|
69
|
+
|
|
70
|
+
session.add(self)
|
|
71
|
+
session.commit()
|
|
72
|
+
session.refresh(self)
|
|
73
|
+
|
|
74
|
+
self.after_update()
|
|
75
|
+
# self.after_save()
|
|
76
|
+
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
# except IntegrityError:
|
|
80
|
+
# log.quiet(f"{self} already exists in the database.")
|
|
81
|
+
# session.rollback()
|
|
82
|
+
|
|
83
|
+
# TODO shouldn't this be handled by pydantic?
|
|
84
|
+
def json(self, **kwargs):
|
|
85
|
+
return json.dumps(self.dict(), default=str, **kwargs)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def count(cls):
|
|
89
|
+
"""
|
|
90
|
+
Returns the number of records in the database.
|
|
91
|
+
"""
|
|
92
|
+
# TODO should move this to the wrapper
|
|
93
|
+
with get_session() as session:
|
|
94
|
+
query = sql.select(sql.func.count()).select_from(cls)
|
|
95
|
+
return session.exec(query).one()
|
|
96
|
+
|
|
97
|
+
# TODO what's super dangerous here is you pass a kwarg which does not map to a specific
|
|
98
|
+
# field it will result in `True`, which will return all records, and not give you any typing
|
|
99
|
+
# errors. Dangerous when iterating on structure quickly
|
|
100
|
+
# TODO can we pass the generic of the superclass in?
|
|
101
|
+
@classmethod
|
|
102
|
+
def get(cls, *args: sa.BinaryExpression, **kwargs: t.Any):
|
|
103
|
+
"""
|
|
104
|
+
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
# special case for getting by ID
|
|
108
|
+
if len(args) == 1 and isinstance(args[0], int):
|
|
109
|
+
# TODO id is hardcoded, not good! Need to dynamically pick the best uid field
|
|
110
|
+
kwargs["id"] = args[0]
|
|
111
|
+
args = []
|
|
112
|
+
|
|
113
|
+
statement = sql.select(cls).filter(*args).filter_by(**kwargs)
|
|
114
|
+
with get_session() as session:
|
|
115
|
+
return session.exec(statement).first()
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def all(cls):
|
|
119
|
+
with get_session() as session:
|
|
120
|
+
results = session.exec(sql.select(cls))
|
|
121
|
+
|
|
122
|
+
# TODO do we need this or can we just return results?
|
|
123
|
+
for result in results:
|
|
124
|
+
yield result
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def sample(cls):
|
|
128
|
+
"""
|
|
129
|
+
Pick a random record from the database.
|
|
130
|
+
|
|
131
|
+
Helpful for testing and console debugging.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
query = sql.select(cls).order_by(sql.func.random()).limit(1)
|
|
135
|
+
|
|
136
|
+
with get_session() as session:
|
|
137
|
+
return session.exec(query).one()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
from sqlmodel.sql.expression import SelectOfScalar
|
|
4
|
+
|
|
5
|
+
WrappedModelType = TypeVar("WrappedModelType")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compile_sql(target: SelectOfScalar):
|
|
9
|
+
return str(target.compile(get_engine().connect()))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueryWrapper(Generic[WrappedModelType]):
|
|
13
|
+
"""
|
|
14
|
+
Make it easy to run queries off of a model
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, cls, *args) -> None:
|
|
18
|
+
# TODO add generics here
|
|
19
|
+
# self.target: SelectOfScalar[T] = sql.select(cls)
|
|
20
|
+
|
|
21
|
+
if args:
|
|
22
|
+
# very naive, let's assume the args are specific select statements
|
|
23
|
+
self.target = sql.select(*args).select_from(cls)
|
|
24
|
+
else:
|
|
25
|
+
self.target = sql.select(cls)
|
|
26
|
+
|
|
27
|
+
# TODO the .exec results should be handled in one shot
|
|
28
|
+
|
|
29
|
+
def first(self):
|
|
30
|
+
with get_session() as session:
|
|
31
|
+
return session.exec(self.target).first()
|
|
32
|
+
|
|
33
|
+
def one(self):
|
|
34
|
+
with get_session() as session:
|
|
35
|
+
return session.exec(self.target).one()
|
|
36
|
+
|
|
37
|
+
def all(self):
|
|
38
|
+
with get_session() as session:
|
|
39
|
+
result = session.exec(self.target)
|
|
40
|
+
for row in result:
|
|
41
|
+
yield row
|
|
42
|
+
|
|
43
|
+
def exec(self):
|
|
44
|
+
# TODO do we really need a unique session each time?
|
|
45
|
+
with get_session() as session:
|
|
46
|
+
return session.exec(self.target)
|
|
47
|
+
|
|
48
|
+
def delete(self):
|
|
49
|
+
with get_session() as session:
|
|
50
|
+
session.delete(self.target)
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name):
|
|
53
|
+
"""
|
|
54
|
+
This is called to retrieve the function to execute
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# TODO prefer methods defined in this class
|
|
58
|
+
if not hasattr(self.target, name):
|
|
59
|
+
return super().__getattribute__(name)
|
|
60
|
+
|
|
61
|
+
attr = getattr(self.target, name)
|
|
62
|
+
|
|
63
|
+
if callable(attr):
|
|
64
|
+
|
|
65
|
+
def wrapper(*args, **kwargs):
|
|
66
|
+
result = attr(*args, **kwargs)
|
|
67
|
+
self.target = result
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
# If the attribute or method is not defined in this class,
|
|
73
|
+
# delegate the call to the `target` object
|
|
74
|
+
return getattr(self.target, name)
|
|
75
|
+
|
|
76
|
+
def sql(self):
|
|
77
|
+
"""
|
|
78
|
+
Output the raw SQL of the query
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
return compile_sql(self.target)
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return f"{self.__class__.__name__}: Current SQL:\n{self.sql()}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
|
|
5
|
+
# TODO raw sql https://github.com/tiangolo/sqlmodel/discussions/772
|
|
6
|
+
from sqlmodel import Field
|
|
7
|
+
|
|
8
|
+
# @classmethod
|
|
9
|
+
# def select(cls):
|
|
10
|
+
# with get_session() as session:
|
|
11
|
+
# results = session.exec(sql.select(cls))
|
|
12
|
+
|
|
13
|
+
# for result in results:
|
|
14
|
+
# yield result
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TimestampMixin:
|
|
18
|
+
"""
|
|
19
|
+
Simple created at and updated at timestamps. Mix them into your model:
|
|
20
|
+
|
|
21
|
+
>>> class MyModel(TimestampMixin, SQLModel):
|
|
22
|
+
>>> pass
|
|
23
|
+
|
|
24
|
+
Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
created_at: datetime | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
sa_type=sa.DateTime(timezone=True),
|
|
30
|
+
sa_column_kwargs={"server_default": sa.func.now()},
|
|
31
|
+
nullable=False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
updated_at: datetime | None = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
sa_type=sa.DateTime(timezone=True),
|
|
37
|
+
sa_column_kwargs={"onupdate": sa.func.now(), "server_default": sa.func.now()},
|
|
38
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Michael Bianco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: activemodel
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Make SQLModel more like an a real ORM
|
|
5
|
+
Author-email: Michael Bianco <iloveitaly@gmail.com>
|
|
6
|
+
Project-URL: Repository, https://github.com/iloveitaly/activemodel
|
|
7
|
+
Keywords: sqlmodel,orm,activerecord,activemodel,sqlalchemy
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pydash>=8.0.4
|
|
12
|
+
Requires-Dist: sqlmodel>=0.0.22
|
|
13
|
+
|
|
14
|
+
# ActiveModel: ORM Wrapper for SQLModel
|
|
15
|
+
|
|
16
|
+
No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
|
|
17
|
+
|
|
18
|
+
SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
|
|
19
|
+
|
|
20
|
+
This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
|
|
21
|
+
|
|
22
|
+
* Timestamp column mixins
|
|
23
|
+
* Lifecycle hooks
|
|
24
|
+
|
|
25
|
+
## Related Projects
|
|
26
|
+
|
|
27
|
+
* https://github.com/woofz/sqlmodel-basecrud
|
|
28
|
+
|
|
29
|
+
## Inspiration
|
|
30
|
+
|
|
31
|
+
* https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
|
|
32
|
+
* [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
|
|
33
|
+
* https://github.com/fastapiutils/fastapi-utils/
|
|
34
|
+
*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
activemodel/__init__.py,sha256=WWLMytAi2VO3-HdySf4IpbeSCPMlVVO_GGQlQNA6UWU,216
|
|
2
|
+
activemodel/base_model.py,sha256=fk_g6jC1GCkAFo78UYjTONyFE8iYQH1bfvxdWG-_178,4200
|
|
3
|
+
activemodel/query_wrapper.py,sha256=JiYN8EN9_gXpKrFw348gs4QixwtrEN50fJF-3uv5Gmg,2319
|
|
4
|
+
activemodel/timestamps.py,sha256=8odUxQ1c0OouPAVioMTkD277w6S28Pk17pwCsaxKgww,991
|
|
5
|
+
activemodel-0.3.0.dist-info/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
|
|
6
|
+
activemodel-0.3.0.dist-info/METADATA,sha256=PrnCUuQVLSuCQ-tbwEMzHXrqMHq9MHTNYVgvRh9uXng,1174
|
|
7
|
+
activemodel-0.3.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
8
|
+
activemodel-0.3.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
|
|
9
|
+
activemodel-0.3.0.dist-info/top_level.txt,sha256=JCMUN_seFIi6GXtnTQRWfxXDx6Oj1uok8qapQWbWKDM,12
|
|
10
|
+
activemodel-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
activemodel
|