DAOModel 0.2.0b0__tar.gz
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.
- daomodel-0.2.0b0/PKG-INFO +272 -0
- daomodel-0.2.0b0/README.md +244 -0
- daomodel-0.2.0b0/daomodel/__init__.py +183 -0
- daomodel-0.2.0b0/daomodel/dao.py +278 -0
- daomodel-0.2.0b0/daomodel/db.py +64 -0
- daomodel-0.2.0b0/daomodel/util.py +130 -0
- daomodel-0.2.0b0/pyproject.toml +65 -0
- daomodel-0.2.0b0/tests/conftest.py +272 -0
- daomodel-0.2.0b0/tests/test_dao.py +185 -0
- daomodel-0.2.0b0/tests/test_dao_find.py +241 -0
- daomodel-0.2.0b0/tests/test_daomodel.py +221 -0
- daomodel-0.2.0b0/tests/test_util.py +137 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: DAOModel
|
|
3
|
+
Version: 0.2.0b0
|
|
4
|
+
Summary: Provides an automatic DAO layer for your models
|
|
5
|
+
Keywords: dao,crud,model,database,db,search,query,sql,sqlmodel,sqlalchemy,pydantic
|
|
6
|
+
Author-Email: Cody M Sommer <bassmastacod@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Development Status :: 4 - Beta
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Project-URL: Repository, https://github.com/BassMastaCod/DAOModel.git
|
|
22
|
+
Project-URL: Issues, https://github.com/BassMastaCod/DAOModel/issues
|
|
23
|
+
Requires-Python: >=3.7
|
|
24
|
+
Requires-Dist: sqlalchemy
|
|
25
|
+
Requires-Dist: sqlmodel
|
|
26
|
+
Requires-Dist: str_case_util
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# DAOModel
|
|
30
|
+
An instant CRUD layer for your Python models (Powered by
|
|
31
|
+
[SQLModel](https://sqlmodel.tiangolo.com/)/
|
|
32
|
+
[Pydantic](https://docs.pydantic.dev/latest/)/
|
|
33
|
+
[SQLAlchemy](https://www.sqlalchemy.org/)).
|
|
34
|
+
|
|
35
|
+
Eliminate repetitive work by auto creating your DAOs.
|
|
36
|
+
There is no need to write SQL queries or recall how to work with
|
|
37
|
+
SQLAlchemy models when you are only looking to do basic functionality.
|
|
38
|
+
|
|
39
|
+
## Supported Functions
|
|
40
|
+
* `create`
|
|
41
|
+
* `insert`
|
|
42
|
+
* `update`
|
|
43
|
+
* `upsert`
|
|
44
|
+
* if `exists`
|
|
45
|
+
* `get`
|
|
46
|
+
* `find` (supports advanced searching, more details below)
|
|
47
|
+
* `remove`
|
|
48
|
+
* access to `query` (to do anything else not directly supported)
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
* DAOModel expands on SQLModel so no need to learn a new way to define your models.
|
|
52
|
+
* Existing SQLModel, Pydantic, and SQLAlchemy functionality is still accessible for anything not built into DAOModel.
|
|
53
|
+
* Provides many quality of life additions that I found to be repeated throughout my own projects.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
### Develop your SQLModel as usual:
|
|
57
|
+
```python
|
|
58
|
+
class Customer(SQLModel, table=True):
|
|
59
|
+
id: int = Field(primary_key=True)
|
|
60
|
+
name: str
|
|
61
|
+
```
|
|
62
|
+
More on this at [SQLModel's Documentation](https://sqlmodel.tiangolo.com/tutorial/create-db-and-table/#create-the-table-model-class)
|
|
63
|
+
|
|
64
|
+
### Inherit DAOModel in place of _SQLModel_
|
|
65
|
+
`DAOModel` acts as a middleman, adding methods to your class.
|
|
66
|
+
Yet `DAOModel` inherits from `SQLModel`
|
|
67
|
+
meaning your object still `isinstance(m, SQLModel)`.
|
|
68
|
+
```python
|
|
69
|
+
class Customer(DAOModel, table=True):
|
|
70
|
+
id: int = Field(primary_key=True)
|
|
71
|
+
name: str
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Configure and Initialize your database
|
|
75
|
+
This library doesn't really care how you setup your DB. Skip ahead if you already know how to do so.
|
|
76
|
+
Otherwise, you may find some of the library's built-in functionality useful.
|
|
77
|
+
|
|
78
|
+
#### Create your engine using DAOModel's helper function
|
|
79
|
+
```python
|
|
80
|
+
engine = create_engine("database.db")
|
|
81
|
+
```
|
|
82
|
+
This uses SQLite to store your data. If you don't need persistence,
|
|
83
|
+
an in-memory SQLite DB (perfect for testing) is achievable by excluding the path:
|
|
84
|
+
```python
|
|
85
|
+
engine = create_engine()
|
|
86
|
+
```
|
|
87
|
+
While good to start, when/if this doesn't meet your needs, please refer to
|
|
88
|
+
[SQLAlchemy's Docs on Engines](https://docs.sqlalchemy.org/core/engines_connections.html)
|
|
89
|
+
|
|
90
|
+
#### Initialize the Engine
|
|
91
|
+
Once you have your engine, you'll need to initialize each of the tables representing your models.
|
|
92
|
+
```python
|
|
93
|
+
init_db(engine)
|
|
94
|
+
```
|
|
95
|
+
This is simply a shortcut method of `SQLModel.metadata.create_all(engine)`.
|
|
96
|
+
> **NOTE:** Be sure your Models are all imported (if defined outside of this file)
|
|
97
|
+
> before executing this code or else those tables will not be included.
|
|
98
|
+
|
|
99
|
+
#### Create a DB session
|
|
100
|
+
```python
|
|
101
|
+
db = Session(engine)
|
|
102
|
+
```
|
|
103
|
+
Again, this isn't anything that is specific to DAOModel,
|
|
104
|
+
it is common across SQLModel, Flask, etc. so feel free to do this your own way.
|
|
105
|
+
There exist plenty of guides and tutorials or you can start with [SQLAlchemy's Docs on Sessions](https://docs.sqlalchemy.org/orm/session_basics.html)
|
|
106
|
+
|
|
107
|
+
<br>
|
|
108
|
+
|
|
109
|
+
Now you are ready to populate your database, but for that, we are going to use the DAO!
|
|
110
|
+
|
|
111
|
+
### Create a DAO for your DAOModel
|
|
112
|
+
Creating a DAO is simple enough, but you will need your db session your class that inherits DAOModel.
|
|
113
|
+
```python
|
|
114
|
+
DAO(Customer, db)
|
|
115
|
+
```
|
|
116
|
+
> **NOTE:** You pass the Class to DAO, not an instance of the Class
|
|
117
|
+
|
|
118
|
+
So there you have it, You now have a usable DAO layer for your model!
|
|
119
|
+
Let's look at the full code:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
class Customer(DAOModel, table=True):
|
|
123
|
+
id: int = Field(primary_key=True)
|
|
124
|
+
name: str
|
|
125
|
+
|
|
126
|
+
engine = create_engine("database.db")
|
|
127
|
+
init_db(engine)
|
|
128
|
+
db = Session(engine)
|
|
129
|
+
dao = DAO(Customer, db)
|
|
130
|
+
```
|
|
131
|
+
It may not be exactly what is wanted for your final product, but it gets you up and running quickly.
|
|
132
|
+
Just a few lines is all you need to get started!
|
|
133
|
+
|
|
134
|
+
### Using the DAO
|
|
135
|
+
The whole premise of an automatic DAO layer is to make your code more straightforward and readable.
|
|
136
|
+
Read the docs for more details, otherwise the following table should give a general understanding.
|
|
137
|
+
|
|
138
|
+
| Action | Method | Under the hood | Example |
|
|
139
|
+
|----------------------------|--------|---------------------------------------|-----------------------------|
|
|
140
|
+
| Create a new object | create | Adds values as new row to DB table | `model = dao.create(23)` |
|
|
141
|
+
| Insert an object | insert | Adds object as new row to DB table | `dao.insert(model)` |
|
|
142
|
+
| Update an object | update | Updates column values of a row | `dao.update(model)` |
|
|
143
|
+
| Update or insert an object | upsert | Updates or inserts row if not present | `dao.upsert(model)` |
|
|
144
|
+
| Check if an object exists | exists | Checks if any rows match object | `if dao.exists(model):` |
|
|
145
|
+
| Get an object | get | Selects row by primary key | `model = dao.get(23)` |
|
|
146
|
+
| Search for objects | find | Selects rows by column values | `results = dao.find("Bob")` |
|
|
147
|
+
| Delete an object | remove | Deletes row from DB table | `dao.remove(model)` |
|
|
148
|
+
|
|
149
|
+
Check out the Sample Code for a more thorough example.
|
|
150
|
+
You can even use it as a template for your own project!
|
|
151
|
+
|
|
152
|
+
## Searching
|
|
153
|
+
One of the best features about DAOModel is the robust search functionality.
|
|
154
|
+
Without assembling complicated SQL queries, you are able to search by specific columns.
|
|
155
|
+
```python
|
|
156
|
+
results = dao.find(name="Bob")
|
|
157
|
+
```
|
|
158
|
+
Said columns can even be foreign keys or columns from related tables
|
|
159
|
+
(provided those columns are defined as searchable).
|
|
160
|
+
Take a look at `test_dao_find.py` to see some examples.
|
|
161
|
+
|
|
162
|
+
### Is column value set?
|
|
163
|
+
Perhaps you don't want to search for _Bob_, but rather find all customers who do not have any 'name'.
|
|
164
|
+
Using `True` and `False` limits your results to rows having, or not having, a value.
|
|
165
|
+
```python
|
|
166
|
+
# find all nameless customers
|
|
167
|
+
results = dao.find(name=False)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Duplicate (or unique) values
|
|
171
|
+
Sometimes your customers (or other data) gets added more than once.
|
|
172
|
+
Wouldn't it be great to easily find all of these duplicates? Say no more!
|
|
173
|
+
```python
|
|
174
|
+
# find all customers that share a name with another customer
|
|
175
|
+
results = dao.find(duplicate=Customer.name)
|
|
176
|
+
```
|
|
177
|
+
Now I can see that I have 3 customers named _Bob_ and 2 named _Joe_ without listing each other customer.
|
|
178
|
+
|
|
179
|
+
Or maybe it's the unique values you wish to see:
|
|
180
|
+
`dao.find(unique=Customer.name)` will provide all customers that don't share a name,
|
|
181
|
+
in this case, excluding all the _Bobs_ and _Joes_.
|
|
182
|
+
|
|
183
|
+
### Sorting
|
|
184
|
+
The order of your results can easily be specified.
|
|
185
|
+
By default, results are sorted by primary key.
|
|
186
|
+
But you can sort by any column, foreign key, or foreign property you desire.
|
|
187
|
+
```python
|
|
188
|
+
# sort by name and then id
|
|
189
|
+
results = dao.find(order=(Customer.name, Customer.id))
|
|
190
|
+
```
|
|
191
|
+
> NOTE: wrap a column with `desc()` to reverse the order
|
|
192
|
+
|
|
193
|
+
### All of the above
|
|
194
|
+
The previously stated options can be done together if needed:
|
|
195
|
+
```python
|
|
196
|
+
results = dao.find(name=True, region="US",
|
|
197
|
+
duplicate=Customer.name, unique=Customer.address,
|
|
198
|
+
order=desc(Customer.last_modified))
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Integrated Pagination
|
|
202
|
+
Pages of results come naturally for searches done with DAOModel DAOs.
|
|
203
|
+
Indicate the number of intended results using the `per_page` argument.
|
|
204
|
+
Optionally specify which page number to retrieve using the `page` argument.
|
|
205
|
+
```python
|
|
206
|
+
for result in dao.find(page=2, per_page=10):
|
|
207
|
+
```
|
|
208
|
+
As seen above, the returned SearchResults are directly iterable.
|
|
209
|
+
But they also include properties for the total number of results.
|
|
210
|
+
The page number and number of results per page are also included for convenience.
|
|
211
|
+
|
|
212
|
+
## Additional Functionality
|
|
213
|
+
### Commit on Demand
|
|
214
|
+
Each of the modifying actions in the table above will auto-commit.
|
|
215
|
+
However, if you wish to prevent this, include the argument `commit=False`
|
|
216
|
+
In that scenario, you will need to call `dao.commit()` explicitly.
|
|
217
|
+
This is most useful when conducting batch actions, or when you may wish to abort the changes.
|
|
218
|
+
|
|
219
|
+
### Copying values
|
|
220
|
+
Values (other than the primary key) can be copied from one model instance to another.
|
|
221
|
+
This is done through `model.copy_model(other_model)` or `model.copy_values(**dictionary)`.
|
|
222
|
+
Both the `create` and `get` functionality have copying built-in in the form of `create_with` and `get_with`.
|
|
223
|
+
```python
|
|
224
|
+
# create a new row but also populate the name column in the same line
|
|
225
|
+
model = dao.create_with(id=52, name="Bob")
|
|
226
|
+
|
|
227
|
+
# select the now existing row but reassign the name
|
|
228
|
+
model = dao.get_with(id=52, name="Joe")
|
|
229
|
+
|
|
230
|
+
# calling get_with does not modify the DB, so you need to explicitly update or commit
|
|
231
|
+
dao.update(model)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### DAOFactory
|
|
235
|
+
The DAOFactory allows you to easily open and close sessions as needed.
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
In order to use it, get yourself a [session factory](https://docs.sqlalchemy.org/orm/session_basics.html#using-a-sessionmaker) and then use a `with` statement.
|
|
239
|
+
```python
|
|
240
|
+
session_factory = sessionmaker(engine)
|
|
241
|
+
with DAOFactory(session_factory) as daos:
|
|
242
|
+
dao = daos[Customer]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Again, this may not fit your needs exactly,
|
|
246
|
+
but you can inherit from DAOFactory in order to create your own solution.
|
|
247
|
+
|
|
248
|
+
### Auto-increment ID
|
|
249
|
+
The [SQLModel Tutorial](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)
|
|
250
|
+
discusses how to have an auto incrementing primary key.
|
|
251
|
+
This library contains a utility called `next_id()` that,
|
|
252
|
+
when passed as an argument, will not specify an ID so that it is auto generated.
|
|
253
|
+
```python
|
|
254
|
+
model = dao.create(next_id())
|
|
255
|
+
```
|
|
256
|
+
This code is equivalent to passing `None` as the argument.
|
|
257
|
+
However, the named method makes the line easier to understand.
|
|
258
|
+
|
|
259
|
+
## Caveats
|
|
260
|
+
Most testing has been completed using SQLite, though since SQLModel/SQLAlchemy
|
|
261
|
+
support other database solutions, DAOModel is expected to as well.
|
|
262
|
+
|
|
263
|
+
Speaking of SQLite, this library configures Foreign Key constraints to be enforced by default in SQLite.
|
|
264
|
+
|
|
265
|
+
Table names are configured to be snake_case which differs from SQLModel.
|
|
266
|
+
This can be adjusted by overridding `def __tablename__` in your own child class.
|
|
267
|
+
|
|
268
|
+
Not all functionality will work as intended through DAOModel.
|
|
269
|
+
If something isn't supported, submit a ticket or pull request.
|
|
270
|
+
And remember that you may always use what you can and then
|
|
271
|
+
override the code or use the query method in DAO to do the rest.
|
|
272
|
+
It should still save you a lot of lines of code.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# DAOModel
|
|
2
|
+
An instant CRUD layer for your Python models (Powered by
|
|
3
|
+
[SQLModel](https://sqlmodel.tiangolo.com/)/
|
|
4
|
+
[Pydantic](https://docs.pydantic.dev/latest/)/
|
|
5
|
+
[SQLAlchemy](https://www.sqlalchemy.org/)).
|
|
6
|
+
|
|
7
|
+
Eliminate repetitive work by auto creating your DAOs.
|
|
8
|
+
There is no need to write SQL queries or recall how to work with
|
|
9
|
+
SQLAlchemy models when you are only looking to do basic functionality.
|
|
10
|
+
|
|
11
|
+
## Supported Functions
|
|
12
|
+
* `create`
|
|
13
|
+
* `insert`
|
|
14
|
+
* `update`
|
|
15
|
+
* `upsert`
|
|
16
|
+
* if `exists`
|
|
17
|
+
* `get`
|
|
18
|
+
* `find` (supports advanced searching, more details below)
|
|
19
|
+
* `remove`
|
|
20
|
+
* access to `query` (to do anything else not directly supported)
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
* DAOModel expands on SQLModel so no need to learn a new way to define your models.
|
|
24
|
+
* Existing SQLModel, Pydantic, and SQLAlchemy functionality is still accessible for anything not built into DAOModel.
|
|
25
|
+
* Provides many quality of life additions that I found to be repeated throughout my own projects.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
### Develop your SQLModel as usual:
|
|
29
|
+
```python
|
|
30
|
+
class Customer(SQLModel, table=True):
|
|
31
|
+
id: int = Field(primary_key=True)
|
|
32
|
+
name: str
|
|
33
|
+
```
|
|
34
|
+
More on this at [SQLModel's Documentation](https://sqlmodel.tiangolo.com/tutorial/create-db-and-table/#create-the-table-model-class)
|
|
35
|
+
|
|
36
|
+
### Inherit DAOModel in place of _SQLModel_
|
|
37
|
+
`DAOModel` acts as a middleman, adding methods to your class.
|
|
38
|
+
Yet `DAOModel` inherits from `SQLModel`
|
|
39
|
+
meaning your object still `isinstance(m, SQLModel)`.
|
|
40
|
+
```python
|
|
41
|
+
class Customer(DAOModel, table=True):
|
|
42
|
+
id: int = Field(primary_key=True)
|
|
43
|
+
name: str
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Configure and Initialize your database
|
|
47
|
+
This library doesn't really care how you setup your DB. Skip ahead if you already know how to do so.
|
|
48
|
+
Otherwise, you may find some of the library's built-in functionality useful.
|
|
49
|
+
|
|
50
|
+
#### Create your engine using DAOModel's helper function
|
|
51
|
+
```python
|
|
52
|
+
engine = create_engine("database.db")
|
|
53
|
+
```
|
|
54
|
+
This uses SQLite to store your data. If you don't need persistence,
|
|
55
|
+
an in-memory SQLite DB (perfect for testing) is achievable by excluding the path:
|
|
56
|
+
```python
|
|
57
|
+
engine = create_engine()
|
|
58
|
+
```
|
|
59
|
+
While good to start, when/if this doesn't meet your needs, please refer to
|
|
60
|
+
[SQLAlchemy's Docs on Engines](https://docs.sqlalchemy.org/core/engines_connections.html)
|
|
61
|
+
|
|
62
|
+
#### Initialize the Engine
|
|
63
|
+
Once you have your engine, you'll need to initialize each of the tables representing your models.
|
|
64
|
+
```python
|
|
65
|
+
init_db(engine)
|
|
66
|
+
```
|
|
67
|
+
This is simply a shortcut method of `SQLModel.metadata.create_all(engine)`.
|
|
68
|
+
> **NOTE:** Be sure your Models are all imported (if defined outside of this file)
|
|
69
|
+
> before executing this code or else those tables will not be included.
|
|
70
|
+
|
|
71
|
+
#### Create a DB session
|
|
72
|
+
```python
|
|
73
|
+
db = Session(engine)
|
|
74
|
+
```
|
|
75
|
+
Again, this isn't anything that is specific to DAOModel,
|
|
76
|
+
it is common across SQLModel, Flask, etc. so feel free to do this your own way.
|
|
77
|
+
There exist plenty of guides and tutorials or you can start with [SQLAlchemy's Docs on Sessions](https://docs.sqlalchemy.org/orm/session_basics.html)
|
|
78
|
+
|
|
79
|
+
<br>
|
|
80
|
+
|
|
81
|
+
Now you are ready to populate your database, but for that, we are going to use the DAO!
|
|
82
|
+
|
|
83
|
+
### Create a DAO for your DAOModel
|
|
84
|
+
Creating a DAO is simple enough, but you will need your db session your class that inherits DAOModel.
|
|
85
|
+
```python
|
|
86
|
+
DAO(Customer, db)
|
|
87
|
+
```
|
|
88
|
+
> **NOTE:** You pass the Class to DAO, not an instance of the Class
|
|
89
|
+
|
|
90
|
+
So there you have it, You now have a usable DAO layer for your model!
|
|
91
|
+
Let's look at the full code:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
class Customer(DAOModel, table=True):
|
|
95
|
+
id: int = Field(primary_key=True)
|
|
96
|
+
name: str
|
|
97
|
+
|
|
98
|
+
engine = create_engine("database.db")
|
|
99
|
+
init_db(engine)
|
|
100
|
+
db = Session(engine)
|
|
101
|
+
dao = DAO(Customer, db)
|
|
102
|
+
```
|
|
103
|
+
It may not be exactly what is wanted for your final product, but it gets you up and running quickly.
|
|
104
|
+
Just a few lines is all you need to get started!
|
|
105
|
+
|
|
106
|
+
### Using the DAO
|
|
107
|
+
The whole premise of an automatic DAO layer is to make your code more straightforward and readable.
|
|
108
|
+
Read the docs for more details, otherwise the following table should give a general understanding.
|
|
109
|
+
|
|
110
|
+
| Action | Method | Under the hood | Example |
|
|
111
|
+
|----------------------------|--------|---------------------------------------|-----------------------------|
|
|
112
|
+
| Create a new object | create | Adds values as new row to DB table | `model = dao.create(23)` |
|
|
113
|
+
| Insert an object | insert | Adds object as new row to DB table | `dao.insert(model)` |
|
|
114
|
+
| Update an object | update | Updates column values of a row | `dao.update(model)` |
|
|
115
|
+
| Update or insert an object | upsert | Updates or inserts row if not present | `dao.upsert(model)` |
|
|
116
|
+
| Check if an object exists | exists | Checks if any rows match object | `if dao.exists(model):` |
|
|
117
|
+
| Get an object | get | Selects row by primary key | `model = dao.get(23)` |
|
|
118
|
+
| Search for objects | find | Selects rows by column values | `results = dao.find("Bob")` |
|
|
119
|
+
| Delete an object | remove | Deletes row from DB table | `dao.remove(model)` |
|
|
120
|
+
|
|
121
|
+
Check out the Sample Code for a more thorough example.
|
|
122
|
+
You can even use it as a template for your own project!
|
|
123
|
+
|
|
124
|
+
## Searching
|
|
125
|
+
One of the best features about DAOModel is the robust search functionality.
|
|
126
|
+
Without assembling complicated SQL queries, you are able to search by specific columns.
|
|
127
|
+
```python
|
|
128
|
+
results = dao.find(name="Bob")
|
|
129
|
+
```
|
|
130
|
+
Said columns can even be foreign keys or columns from related tables
|
|
131
|
+
(provided those columns are defined as searchable).
|
|
132
|
+
Take a look at `test_dao_find.py` to see some examples.
|
|
133
|
+
|
|
134
|
+
### Is column value set?
|
|
135
|
+
Perhaps you don't want to search for _Bob_, but rather find all customers who do not have any 'name'.
|
|
136
|
+
Using `True` and `False` limits your results to rows having, or not having, a value.
|
|
137
|
+
```python
|
|
138
|
+
# find all nameless customers
|
|
139
|
+
results = dao.find(name=False)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Duplicate (or unique) values
|
|
143
|
+
Sometimes your customers (or other data) gets added more than once.
|
|
144
|
+
Wouldn't it be great to easily find all of these duplicates? Say no more!
|
|
145
|
+
```python
|
|
146
|
+
# find all customers that share a name with another customer
|
|
147
|
+
results = dao.find(duplicate=Customer.name)
|
|
148
|
+
```
|
|
149
|
+
Now I can see that I have 3 customers named _Bob_ and 2 named _Joe_ without listing each other customer.
|
|
150
|
+
|
|
151
|
+
Or maybe it's the unique values you wish to see:
|
|
152
|
+
`dao.find(unique=Customer.name)` will provide all customers that don't share a name,
|
|
153
|
+
in this case, excluding all the _Bobs_ and _Joes_.
|
|
154
|
+
|
|
155
|
+
### Sorting
|
|
156
|
+
The order of your results can easily be specified.
|
|
157
|
+
By default, results are sorted by primary key.
|
|
158
|
+
But you can sort by any column, foreign key, or foreign property you desire.
|
|
159
|
+
```python
|
|
160
|
+
# sort by name and then id
|
|
161
|
+
results = dao.find(order=(Customer.name, Customer.id))
|
|
162
|
+
```
|
|
163
|
+
> NOTE: wrap a column with `desc()` to reverse the order
|
|
164
|
+
|
|
165
|
+
### All of the above
|
|
166
|
+
The previously stated options can be done together if needed:
|
|
167
|
+
```python
|
|
168
|
+
results = dao.find(name=True, region="US",
|
|
169
|
+
duplicate=Customer.name, unique=Customer.address,
|
|
170
|
+
order=desc(Customer.last_modified))
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Integrated Pagination
|
|
174
|
+
Pages of results come naturally for searches done with DAOModel DAOs.
|
|
175
|
+
Indicate the number of intended results using the `per_page` argument.
|
|
176
|
+
Optionally specify which page number to retrieve using the `page` argument.
|
|
177
|
+
```python
|
|
178
|
+
for result in dao.find(page=2, per_page=10):
|
|
179
|
+
```
|
|
180
|
+
As seen above, the returned SearchResults are directly iterable.
|
|
181
|
+
But they also include properties for the total number of results.
|
|
182
|
+
The page number and number of results per page are also included for convenience.
|
|
183
|
+
|
|
184
|
+
## Additional Functionality
|
|
185
|
+
### Commit on Demand
|
|
186
|
+
Each of the modifying actions in the table above will auto-commit.
|
|
187
|
+
However, if you wish to prevent this, include the argument `commit=False`
|
|
188
|
+
In that scenario, you will need to call `dao.commit()` explicitly.
|
|
189
|
+
This is most useful when conducting batch actions, or when you may wish to abort the changes.
|
|
190
|
+
|
|
191
|
+
### Copying values
|
|
192
|
+
Values (other than the primary key) can be copied from one model instance to another.
|
|
193
|
+
This is done through `model.copy_model(other_model)` or `model.copy_values(**dictionary)`.
|
|
194
|
+
Both the `create` and `get` functionality have copying built-in in the form of `create_with` and `get_with`.
|
|
195
|
+
```python
|
|
196
|
+
# create a new row but also populate the name column in the same line
|
|
197
|
+
model = dao.create_with(id=52, name="Bob")
|
|
198
|
+
|
|
199
|
+
# select the now existing row but reassign the name
|
|
200
|
+
model = dao.get_with(id=52, name="Joe")
|
|
201
|
+
|
|
202
|
+
# calling get_with does not modify the DB, so you need to explicitly update or commit
|
|
203
|
+
dao.update(model)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### DAOFactory
|
|
207
|
+
The DAOFactory allows you to easily open and close sessions as needed.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
In order to use it, get yourself a [session factory](https://docs.sqlalchemy.org/orm/session_basics.html#using-a-sessionmaker) and then use a `with` statement.
|
|
211
|
+
```python
|
|
212
|
+
session_factory = sessionmaker(engine)
|
|
213
|
+
with DAOFactory(session_factory) as daos:
|
|
214
|
+
dao = daos[Customer]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Again, this may not fit your needs exactly,
|
|
218
|
+
but you can inherit from DAOFactory in order to create your own solution.
|
|
219
|
+
|
|
220
|
+
### Auto-increment ID
|
|
221
|
+
The [SQLModel Tutorial](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)
|
|
222
|
+
discusses how to have an auto incrementing primary key.
|
|
223
|
+
This library contains a utility called `next_id()` that,
|
|
224
|
+
when passed as an argument, will not specify an ID so that it is auto generated.
|
|
225
|
+
```python
|
|
226
|
+
model = dao.create(next_id())
|
|
227
|
+
```
|
|
228
|
+
This code is equivalent to passing `None` as the argument.
|
|
229
|
+
However, the named method makes the line easier to understand.
|
|
230
|
+
|
|
231
|
+
## Caveats
|
|
232
|
+
Most testing has been completed using SQLite, though since SQLModel/SQLAlchemy
|
|
233
|
+
support other database solutions, DAOModel is expected to as well.
|
|
234
|
+
|
|
235
|
+
Speaking of SQLite, this library configures Foreign Key constraints to be enforced by default in SQLite.
|
|
236
|
+
|
|
237
|
+
Table names are configured to be snake_case which differs from SQLModel.
|
|
238
|
+
This can be adjusted by overridding `def __tablename__` in your own child class.
|
|
239
|
+
|
|
240
|
+
Not all functionality will work as intended through DAOModel.
|
|
241
|
+
If something isn't supported, submit a ticket or pull request.
|
|
242
|
+
And remember that you may always use what you can and then
|
|
243
|
+
override the code or use the query method in DAO to do the rest.
|
|
244
|
+
It should still save you a lot of lines of code.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from typing import Any, Self, Iterable, Union
|
|
2
|
+
from sqlmodel import SQLModel
|
|
3
|
+
from sqlalchemy import Column
|
|
4
|
+
from str_case_util import Case
|
|
5
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
6
|
+
|
|
7
|
+
from daomodel.util import reference_of, names_of
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DAOModel(SQLModel):
|
|
11
|
+
"""An SQLModel specifically designed to support a DAO."""
|
|
12
|
+
|
|
13
|
+
@declared_attr
|
|
14
|
+
def __tablename__(self) -> str:
|
|
15
|
+
return self.normalized_name()
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def normalized_name(cls) -> str:
|
|
19
|
+
"""
|
|
20
|
+
A normalized version of this Model name.
|
|
21
|
+
|
|
22
|
+
:return: The model name in snake_case form
|
|
23
|
+
"""
|
|
24
|
+
return Case.SNAKE_CASE.format(cls.__name__)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def doc_name(cls) -> str:
|
|
28
|
+
"""
|
|
29
|
+
A reader friendly version of this Model name to be used within documentation.
|
|
30
|
+
|
|
31
|
+
:return: The model name in Title Case
|
|
32
|
+
"""
|
|
33
|
+
return Case.TITLE_CASE.format(cls.__name__)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def get_pk(cls) -> list[Column]:
|
|
37
|
+
"""
|
|
38
|
+
Returns the Columns that comprise the Primary Key for this Model.
|
|
39
|
+
|
|
40
|
+
:return: A list of primary key columns
|
|
41
|
+
"""
|
|
42
|
+
return cls.__table__.primary_key
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def get_pk_names(cls) -> list[str]:
|
|
46
|
+
"""
|
|
47
|
+
Returns the names of Columns that comprise the Primary Key for this Model.
|
|
48
|
+
|
|
49
|
+
:return: A list of str of the primary key
|
|
50
|
+
"""
|
|
51
|
+
return names_of(cls.get_pk())
|
|
52
|
+
|
|
53
|
+
def get_pk_values(self) -> tuple[Any, ...]:
|
|
54
|
+
"""
|
|
55
|
+
Returns the values that comprise the Primary Key for this instance of the Model.
|
|
56
|
+
|
|
57
|
+
:return: A tuple of primary key values
|
|
58
|
+
"""
|
|
59
|
+
return tuple(list(getattr(self, key) for key in names_of(self.get_pk())))
|
|
60
|
+
|
|
61
|
+
def get_pk_dict(self) -> dict[str, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Returns the dictionary Primary Keys for this instance of the Model.
|
|
64
|
+
|
|
65
|
+
:return: A dict of primary key names/values
|
|
66
|
+
"""
|
|
67
|
+
return self.model_dump(include=set(self.get_pk_names()))
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def get_fks(cls) -> set[Column]:
|
|
71
|
+
"""
|
|
72
|
+
Returns the Columns of other objects that are represented by Foreign Keys for this Model.
|
|
73
|
+
|
|
74
|
+
:return: An unordered set of columns
|
|
75
|
+
"""
|
|
76
|
+
return {fk.column for fk in cls.__table__.foreign_keys}
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_fk_properties(cls) -> set[Column]:
|
|
80
|
+
"""
|
|
81
|
+
Returns the Columns that represent Foreign Keys for this Model.
|
|
82
|
+
|
|
83
|
+
:return: An unordered set of foreign key columns
|
|
84
|
+
"""
|
|
85
|
+
return {fk.parent for fk in cls.__table__.foreign_keys}
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def get_properties(cls) -> Iterable[Column]:
|
|
89
|
+
"""
|
|
90
|
+
Returns all the Columns for this Model.
|
|
91
|
+
|
|
92
|
+
:return: A list of columns
|
|
93
|
+
"""
|
|
94
|
+
return cls.__table__.c
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_searchable_properties(cls) -> Iterable[Column|tuple[Self, ..., Column]]:
|
|
98
|
+
"""
|
|
99
|
+
Returns all the Columns for this Model that may be searched using the DAO find function.
|
|
100
|
+
|
|
101
|
+
:return: A list of searchable columns
|
|
102
|
+
"""
|
|
103
|
+
return cls.get_properties()
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def find_searchable_column(cls, prop: Union[str, Column], foreign_tables: list[Self]) -> Column:
|
|
107
|
+
"""
|
|
108
|
+
Returns the specified searchable Column.
|
|
109
|
+
|
|
110
|
+
:param prop: str type reference of the Column or the Column itself
|
|
111
|
+
:param foreign_tables: A list of foreign tables to populated with tables of properties deemed to be foreign
|
|
112
|
+
:return: The searchable Column
|
|
113
|
+
:raises: Unsearchable if the property is not Searchable for this class
|
|
114
|
+
"""
|
|
115
|
+
if type(prop) is not str:
|
|
116
|
+
prop = reference_of(prop)
|
|
117
|
+
for column in cls.get_searchable_properties():
|
|
118
|
+
tables = []
|
|
119
|
+
if type(column) is tuple:
|
|
120
|
+
tables = column[:-1]
|
|
121
|
+
column = column[-1]
|
|
122
|
+
if reference_of(column) in [prop, f"{cls.normalized_name()}.{prop}"]:
|
|
123
|
+
foreign_tables.extend([t.__table__ for t in tables])
|
|
124
|
+
if column.table is not cls.__table__:
|
|
125
|
+
foreign_tables.append(column.table)
|
|
126
|
+
return column
|
|
127
|
+
raise Unsearchable(prop, cls)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def pk_values_to_dict(cls, *pk_values) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Converts the primary key values to a dictionary.
|
|
133
|
+
|
|
134
|
+
:param pk_values: The primary key values, in order
|
|
135
|
+
:return: A new dict containing the primary key values
|
|
136
|
+
"""
|
|
137
|
+
return dict(zip(cls.get_pk_names(), *pk_values))
|
|
138
|
+
|
|
139
|
+
def copy_model(self, source: Self) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Copies all values, except the primary key, from another instance of this Model.
|
|
142
|
+
|
|
143
|
+
:param source: The model instance from which to copy values
|
|
144
|
+
"""
|
|
145
|
+
primary_key = set(source.get_pk_names())
|
|
146
|
+
values = source.model_dump(exclude=primary_key)
|
|
147
|
+
self.copy_values(**values)
|
|
148
|
+
|
|
149
|
+
def copy_values(self, **values) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Copies all non-pk property values to this Model.
|
|
152
|
+
|
|
153
|
+
:param values: The dict including values to copy
|
|
154
|
+
"""
|
|
155
|
+
pk = self.get_pk_names()
|
|
156
|
+
properties = names_of(self.get_properties())
|
|
157
|
+
for k, v in values.items():
|
|
158
|
+
if k in properties and k not in pk:
|
|
159
|
+
setattr(self, k, v)
|
|
160
|
+
|
|
161
|
+
def __eq__(self, other: Self):
|
|
162
|
+
"""Instances are determined to be equal based on only their primary key."""
|
|
163
|
+
return self.get_pk_values() == other.get_pk_values() if type(self) == type(other) else False
|
|
164
|
+
|
|
165
|
+
def __hash__(self):
|
|
166
|
+
return hash(self.get_pk_values())
|
|
167
|
+
|
|
168
|
+
def __str__(self):
|
|
169
|
+
"""
|
|
170
|
+
str representation of this is a str of the primary key.
|
|
171
|
+
A single-column PK results in a simple str value of said column i.e. "1234"
|
|
172
|
+
A multi-column PK results in a str of tuple of PK values i.e. ("Cod", "123 Lake Way")
|
|
173
|
+
"""
|
|
174
|
+
pk_values = self.get_pk_values()
|
|
175
|
+
if len(pk_values) == 1:
|
|
176
|
+
pk_values = pk_values[0]
|
|
177
|
+
return str(pk_values)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Unsearchable(Exception):
|
|
181
|
+
"""Indicates that the Search Query is not allowed for the specified field."""
|
|
182
|
+
def __init__(self, prop: str, model: type(DAOModel)):
|
|
183
|
+
self.detail = f"Cannot search for {prop} of {model.doc_name()}"
|