python-ppa 0.1.5__tar.gz → 0.1.7__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.
- {python_ppa-0.1.5 → python_ppa-0.1.7}/PKG-INFO +98 -24
- {python_ppa-0.1.5 → python_ppa-0.1.7}/README.md +97 -24
- python_ppa-0.1.7/ppa/__init__.py +3 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/ppa/config.py +81 -10
- python_ppa-0.1.7/ppa/postgres/__init__.py +5 -0
- python_ppa-0.1.7/ppa/postgres/interface.py +344 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/pyproject.toml +3 -2
- {python_ppa-0.1.5 → python_ppa-0.1.7}/python_ppa.egg-info/PKG-INFO +98 -24
- {python_ppa-0.1.5 → python_ppa-0.1.7}/python_ppa.egg-info/SOURCES.txt +2 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/python_ppa.egg-info/requires.txt +1 -0
- python_ppa-0.1.5/ppa/__init__.py +0 -2
- {python_ppa-0.1.5 → python_ppa-0.1.7}/LICENSE +0 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/ppa/mongo/__init__.py +0 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/ppa/mongo/interface.py +0 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/python_ppa.egg-info/dependency_links.txt +0 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/python_ppa.egg-info/top_level.txt +0 -0
- {python_ppa-0.1.5 → python_ppa-0.1.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-ppa
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: JPA-like ORM for Python
|
|
5
5
|
Author: Pratyush Kumar
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ Requires-Dist: email-validator==2.3.0
|
|
|
13
13
|
Requires-Dist: idna==3.18
|
|
14
14
|
Requires-Dist: pydantic==2.13.4
|
|
15
15
|
Requires-Dist: pydantic_core==2.46.4
|
|
16
|
+
Requires-Dist: psycopg[binary]>=3.2.0
|
|
16
17
|
Requires-Dist: pymongo==4.17.0
|
|
17
18
|
Requires-Dist: python-dotenv==1.2.2
|
|
18
19
|
Requires-Dist: PyYAML==6.0.3
|
|
@@ -20,9 +21,9 @@ Requires-Dist: typing-inspection==0.4.2
|
|
|
20
21
|
Requires-Dist: typing_extensions==4.15.0
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
23
|
-
# python-
|
|
24
|
+
# python-ppa
|
|
24
25
|
|
|
25
|
-
`python-
|
|
26
|
+
`python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
|
|
26
27
|
|
|
27
28
|
It uses advanced Python metaprogramming (`metaclass`) to dynamically generate MongoDB queries at runtime based on method naming conventions or explicit query declarations, drastically reducing boilerplate code.
|
|
28
29
|
|
|
@@ -30,9 +31,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
30
31
|
|
|
31
32
|
## 🚀 Features
|
|
32
33
|
|
|
33
|
-
* **
|
|
34
|
+
* **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
|
|
34
35
|
* **Query Naming Conventions:** Instantly supports `find_by_*`, `find_all_by_*`, `exists_by_*`, and `count_by_*` pattern resolutions.
|
|
35
|
-
* **Custom Query Annotations:** Bind complex MongoDB query templates with custom parameter injections using the `@query` decorator.
|
|
36
|
+
* **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
|
|
36
37
|
* **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
|
|
37
38
|
* **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
|
|
38
39
|
* **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
|
|
@@ -44,44 +45,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
44
45
|
```text
|
|
45
46
|
.
|
|
46
47
|
├── README.md
|
|
47
|
-
├──
|
|
48
|
+
├── ppa
|
|
48
49
|
│ ├── __init__.py
|
|
49
50
|
│ ├── config.py # App bootstrapping, YAML parsing & environment mapping
|
|
50
|
-
│
|
|
51
|
+
│ ├── mongo
|
|
51
52
|
│ ├── __init__.py
|
|
52
53
|
│ └── interface.py # Repository core interface, custom metadata, and queries
|
|
54
|
+
│ └── postgres
|
|
55
|
+
│ ├── __init__.py
|
|
56
|
+
│ └── interface.py # PostgreSQL repository interface and SQL query conventions
|
|
53
57
|
├── pyproject.toml
|
|
54
58
|
└── requirements.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 🛠️ Getting Started
|
|
62
|
+
|
|
63
|
+
### Prerequisites
|
|
64
|
+
|
|
65
|
+
* Python 3.13 or higher
|
|
66
|
+
|
|
67
|
+
### Installation & Virtual Env Setup
|
|
68
|
+
|
|
69
|
+
1. Clone the repository and navigate to its root: `cd python-ppa`
|
|
70
|
+
2. Spin up a virtual environment and update your pip core dependencies:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python3 -m venv venv
|
|
74
|
+
source venv/bin/activate
|
|
75
|
+
pip install --upgrade pip
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
3. Install the project library dependencies:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -r requirements.txt
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## ⚙️ Configuration Management
|
|
55
85
|
|
|
56
|
-
🛠️ Getting Started
|
|
57
|
-
Prerequisites
|
|
58
|
-
• Python 3.13 or higher
|
|
59
|
-
Installation & Virtual Env Setup
|
|
60
|
-
1. Clone the repository and navigate to its root: cd python-jpa
|
|
61
|
-
2. Spin up a virtual environment and update your pip core dependencies: python3 -m venv venv source venv/bin/activate # On Windows use: venv\Scripts\activate pip install --upgrade pip
|
|
62
|
-
3. Install the project library dependencies: pip install -r requirements.txt
|
|
63
|
-
⚙️ Configuration Management
|
|
64
86
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
65
|
-
Create a resources
|
|
66
|
-
|
|
87
|
+
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
88
|
+
|
|
89
|
+
1. Master Configuration (`resources/settings.yml`)
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
67
92
|
app:
|
|
68
93
|
profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
|
|
94
|
+
```
|
|
69
95
|
|
|
70
|
-
2. Environment Profile Configuration (resources/settings-dev.yml)
|
|
96
|
+
2. Environment Profile Configuration (`resources/settings-dev.yml`)
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
71
99
|
mongodb:
|
|
72
100
|
uri: ${MONGO_URI:mongodb://localhost:27017}
|
|
73
|
-
database: ${MONGO_DB_NAME:
|
|
101
|
+
database: ${MONGO_DB_NAME:ppa_database}
|
|
102
|
+
|
|
103
|
+
# Or configure PostgreSQL instead.
|
|
104
|
+
postgresql:
|
|
105
|
+
dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
|
|
106
|
+
|
|
107
|
+
# Equivalent field-based PostgreSQL configuration:
|
|
108
|
+
# postgresql:
|
|
109
|
+
# host: ${POSTGRES_HOST:localhost}
|
|
110
|
+
# port: ${POSTGRES_PORT:5432}
|
|
111
|
+
# database: ${POSTGRES_DB_NAME:ppa_database}
|
|
112
|
+
# user: ${POSTGRES_USER:postgres}
|
|
113
|
+
# password: ${POSTGRES_PASSWORD:postgres}
|
|
74
114
|
|
|
75
115
|
framework:
|
|
76
116
|
logging:
|
|
77
117
|
enabled: true
|
|
78
118
|
level: "DEBUG"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 📖 Usage Example
|
|
79
122
|
|
|
80
|
-
📖 Usage Example
|
|
81
123
|
Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
|
|
124
|
+
|
|
82
125
|
1. Define your Document Model
|
|
126
|
+
|
|
127
|
+
```python
|
|
83
128
|
from pydantic import Field
|
|
84
|
-
from
|
|
129
|
+
from ppa.mongo.interface import DocumentModel, document
|
|
85
130
|
|
|
86
131
|
@document(name="users")
|
|
87
132
|
class User(DocumentModel):
|
|
@@ -89,10 +134,14 @@ class User(DocumentModel):
|
|
|
89
134
|
username: str
|
|
90
135
|
email: str
|
|
91
136
|
age: int
|
|
137
|
+
```
|
|
92
138
|
|
|
93
139
|
2. Declare your Interface Repository
|
|
140
|
+
|
|
94
141
|
By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
|
|
95
|
-
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from ppa.mongo.interface import IRepository, query
|
|
96
145
|
from typing import List, Optional
|
|
97
146
|
|
|
98
147
|
class UserRepository(IRepository[User]):
|
|
@@ -105,9 +154,12 @@ class UserRepository(IRepository[User]):
|
|
|
105
154
|
# 2. Templated declaration using placeholder substitutions
|
|
106
155
|
@query(definition={"email": "?0", "age": {"$gte": "?1"}})
|
|
107
156
|
def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
|
|
157
|
+
```
|
|
108
158
|
|
|
109
159
|
3. Execute CRUD Actions
|
|
110
|
-
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from ppa.config import close_db_connection
|
|
111
163
|
|
|
112
164
|
# Initialize repository instance
|
|
113
165
|
user_repo = UserRepository()
|
|
@@ -122,6 +174,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
|
|
|
122
174
|
|
|
123
175
|
# Clean up connections on process termination
|
|
124
176
|
close_db_connection()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
PostgreSQL repositories follow the same naming conventions with SQL-backed models:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from pydantic import EmailStr
|
|
183
|
+
from ppa.postgres.interface import EntityModel, IRepository, query, table
|
|
184
|
+
|
|
185
|
+
@table(name="users")
|
|
186
|
+
class User(EntityModel):
|
|
187
|
+
username: str
|
|
188
|
+
email: EmailStr
|
|
189
|
+
age: int
|
|
190
|
+
|
|
191
|
+
class UserRepository(IRepository[User]):
|
|
192
|
+
def find_by_username(self, username: str) -> User | None: ...
|
|
193
|
+
def count_by_age(self, age: int) -> int: ...
|
|
194
|
+
|
|
195
|
+
@query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
|
|
196
|
+
def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## 📜 License
|
|
125
200
|
|
|
126
|
-
📜 License
|
|
127
201
|
This project is open-source software licensed under the MIT License.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# python-
|
|
1
|
+
# python-ppa
|
|
2
2
|
|
|
3
|
-
`python-
|
|
3
|
+
`python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
|
|
4
4
|
|
|
5
5
|
It uses advanced Python metaprogramming (`metaclass`) to dynamically generate MongoDB queries at runtime based on method naming conventions or explicit query declarations, drastically reducing boilerplate code.
|
|
6
6
|
|
|
@@ -8,9 +8,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
8
8
|
|
|
9
9
|
## 🚀 Features
|
|
10
10
|
|
|
11
|
-
* **
|
|
11
|
+
* **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
|
|
12
12
|
* **Query Naming Conventions:** Instantly supports `find_by_*`, `find_all_by_*`, `exists_by_*`, and `count_by_*` pattern resolutions.
|
|
13
|
-
* **Custom Query Annotations:** Bind complex MongoDB query templates with custom parameter injections using the `@query` decorator.
|
|
13
|
+
* **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
|
|
14
14
|
* **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
|
|
15
15
|
* **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
|
|
16
16
|
* **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
|
|
@@ -22,44 +22,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
22
22
|
```text
|
|
23
23
|
.
|
|
24
24
|
├── README.md
|
|
25
|
-
├──
|
|
25
|
+
├── ppa
|
|
26
26
|
│ ├── __init__.py
|
|
27
27
|
│ ├── config.py # App bootstrapping, YAML parsing & environment mapping
|
|
28
|
-
│
|
|
28
|
+
│ ├── mongo
|
|
29
29
|
│ ├── __init__.py
|
|
30
30
|
│ └── interface.py # Repository core interface, custom metadata, and queries
|
|
31
|
+
│ └── postgres
|
|
32
|
+
│ ├── __init__.py
|
|
33
|
+
│ └── interface.py # PostgreSQL repository interface and SQL query conventions
|
|
31
34
|
├── pyproject.toml
|
|
32
35
|
└── requirements.txt
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 🛠️ Getting Started
|
|
39
|
+
|
|
40
|
+
### Prerequisites
|
|
41
|
+
|
|
42
|
+
* Python 3.13 or higher
|
|
43
|
+
|
|
44
|
+
### Installation & Virtual Env Setup
|
|
45
|
+
|
|
46
|
+
1. Clone the repository and navigate to its root: `cd python-ppa`
|
|
47
|
+
2. Spin up a virtual environment and update your pip core dependencies:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 -m venv venv
|
|
51
|
+
source venv/bin/activate
|
|
52
|
+
pip install --upgrade pip
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. Install the project library dependencies:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -r requirements.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## ⚙️ Configuration Management
|
|
33
62
|
|
|
34
|
-
🛠️ Getting Started
|
|
35
|
-
Prerequisites
|
|
36
|
-
• Python 3.13 or higher
|
|
37
|
-
Installation & Virtual Env Setup
|
|
38
|
-
1. Clone the repository and navigate to its root: cd python-jpa
|
|
39
|
-
2. Spin up a virtual environment and update your pip core dependencies: python3 -m venv venv source venv/bin/activate # On Windows use: venv\Scripts\activate pip install --upgrade pip
|
|
40
|
-
3. Install the project library dependencies: pip install -r requirements.txt
|
|
41
|
-
⚙️ Configuration Management
|
|
42
63
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
43
|
-
Create a resources
|
|
44
|
-
|
|
64
|
+
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
65
|
+
|
|
66
|
+
1. Master Configuration (`resources/settings.yml`)
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
45
69
|
app:
|
|
46
70
|
profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
|
|
71
|
+
```
|
|
47
72
|
|
|
48
|
-
2. Environment Profile Configuration (resources/settings-dev.yml)
|
|
73
|
+
2. Environment Profile Configuration (`resources/settings-dev.yml`)
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
49
76
|
mongodb:
|
|
50
77
|
uri: ${MONGO_URI:mongodb://localhost:27017}
|
|
51
|
-
database: ${MONGO_DB_NAME:
|
|
78
|
+
database: ${MONGO_DB_NAME:ppa_database}
|
|
79
|
+
|
|
80
|
+
# Or configure PostgreSQL instead.
|
|
81
|
+
postgresql:
|
|
82
|
+
dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
|
|
83
|
+
|
|
84
|
+
# Equivalent field-based PostgreSQL configuration:
|
|
85
|
+
# postgresql:
|
|
86
|
+
# host: ${POSTGRES_HOST:localhost}
|
|
87
|
+
# port: ${POSTGRES_PORT:5432}
|
|
88
|
+
# database: ${POSTGRES_DB_NAME:ppa_database}
|
|
89
|
+
# user: ${POSTGRES_USER:postgres}
|
|
90
|
+
# password: ${POSTGRES_PASSWORD:postgres}
|
|
52
91
|
|
|
53
92
|
framework:
|
|
54
93
|
logging:
|
|
55
94
|
enabled: true
|
|
56
95
|
level: "DEBUG"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 📖 Usage Example
|
|
57
99
|
|
|
58
|
-
📖 Usage Example
|
|
59
100
|
Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
|
|
101
|
+
|
|
60
102
|
1. Define your Document Model
|
|
103
|
+
|
|
104
|
+
```python
|
|
61
105
|
from pydantic import Field
|
|
62
|
-
from
|
|
106
|
+
from ppa.mongo.interface import DocumentModel, document
|
|
63
107
|
|
|
64
108
|
@document(name="users")
|
|
65
109
|
class User(DocumentModel):
|
|
@@ -67,10 +111,14 @@ class User(DocumentModel):
|
|
|
67
111
|
username: str
|
|
68
112
|
email: str
|
|
69
113
|
age: int
|
|
114
|
+
```
|
|
70
115
|
|
|
71
116
|
2. Declare your Interface Repository
|
|
117
|
+
|
|
72
118
|
By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
|
|
73
|
-
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from ppa.mongo.interface import IRepository, query
|
|
74
122
|
from typing import List, Optional
|
|
75
123
|
|
|
76
124
|
class UserRepository(IRepository[User]):
|
|
@@ -83,9 +131,12 @@ class UserRepository(IRepository[User]):
|
|
|
83
131
|
# 2. Templated declaration using placeholder substitutions
|
|
84
132
|
@query(definition={"email": "?0", "age": {"$gte": "?1"}})
|
|
85
133
|
def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
|
|
134
|
+
```
|
|
86
135
|
|
|
87
136
|
3. Execute CRUD Actions
|
|
88
|
-
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from ppa.config import close_db_connection
|
|
89
140
|
|
|
90
141
|
# Initialize repository instance
|
|
91
142
|
user_repo = UserRepository()
|
|
@@ -100,6 +151,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
|
|
|
100
151
|
|
|
101
152
|
# Clean up connections on process termination
|
|
102
153
|
close_db_connection()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
PostgreSQL repositories follow the same naming conventions with SQL-backed models:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from pydantic import EmailStr
|
|
160
|
+
from ppa.postgres.interface import EntityModel, IRepository, query, table
|
|
161
|
+
|
|
162
|
+
@table(name="users")
|
|
163
|
+
class User(EntityModel):
|
|
164
|
+
username: str
|
|
165
|
+
email: EmailStr
|
|
166
|
+
age: int
|
|
167
|
+
|
|
168
|
+
class UserRepository(IRepository[User]):
|
|
169
|
+
def find_by_username(self, username: str) -> User | None: ...
|
|
170
|
+
def count_by_age(self, age: int) -> int: ...
|
|
171
|
+
|
|
172
|
+
@query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
|
|
173
|
+
def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## 📜 License
|
|
103
177
|
|
|
104
|
-
|
|
105
|
-
This project is open-source software licensed under the MIT License.
|
|
178
|
+
This project is open-source software licensed under the MIT License.
|
|
@@ -96,16 +96,33 @@ class ConfigSettings:
|
|
|
96
96
|
|
|
97
97
|
self._config_data = _load_and_interpolate_yaml(profile_config_path)
|
|
98
98
|
mongodb_config = _as_dict(self._config_data.get("mongodb"), context=f"{profile_filename}.mongodb")
|
|
99
|
+
postgres_config = _as_dict(
|
|
100
|
+
self._config_data.get("postgresql", self._config_data.get("postgres")),
|
|
101
|
+
context=f"{profile_filename}.postgresql",
|
|
102
|
+
)
|
|
99
103
|
|
|
100
104
|
# 3. Expose parameters to the framework
|
|
101
105
|
self.mongo_uri = mongodb_config.get("uri")
|
|
102
106
|
self.database_name = mongodb_config.get("database")
|
|
107
|
+
self.postgres_dsn = self._resolve_postgres_dsn(postgres_config)
|
|
108
|
+
self.postgres_database_name = postgres_config.get("database") or postgres_config.get("dbname")
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
has_mongo_config = bool(self.mongo_uri and self.database_name)
|
|
111
|
+
has_postgres_config = bool(self.postgres_dsn)
|
|
112
|
+
|
|
113
|
+
if mongodb_config and not has_mongo_config:
|
|
106
114
|
raise KeyError(
|
|
107
115
|
f"Invalid configuration format in {profile_filename}. Missing 'mongodb.uri' or 'mongodb.database'.")
|
|
108
116
|
|
|
117
|
+
if postgres_config and not has_postgres_config:
|
|
118
|
+
raise KeyError(
|
|
119
|
+
f"Invalid configuration format in {profile_filename}. Provide 'postgresql.dsn' or "
|
|
120
|
+
"'postgresql.host', 'postgresql.database', and 'postgresql.user'.")
|
|
121
|
+
|
|
122
|
+
if not has_mongo_config and not has_postgres_config:
|
|
123
|
+
raise KeyError(
|
|
124
|
+
f"Invalid configuration format in {profile_filename}. Configure either 'mongodb' or 'postgresql'.")
|
|
125
|
+
|
|
109
126
|
framework_config = _as_dict(self._config_data.get("framework"), context=f"{profile_filename}.framework")
|
|
110
127
|
framework_logging_config = _as_dict(framework_config.get("logging"),
|
|
111
128
|
context=f"{profile_filename}.framework.logging")
|
|
@@ -115,6 +132,32 @@ class ConfigSettings:
|
|
|
115
132
|
self.framework_log_date_format = str(
|
|
116
133
|
framework_logging_config.get("date_format", DEFAULT_FRAMEWORK_LOG_DATE_FORMAT))
|
|
117
134
|
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _resolve_postgres_dsn(postgres_config: dict[str, Any]) -> str | None:
|
|
137
|
+
dsn = postgres_config.get("dsn") or postgres_config.get("uri") or postgres_config.get("url")
|
|
138
|
+
if isinstance(dsn, str) and dsn:
|
|
139
|
+
return dsn
|
|
140
|
+
|
|
141
|
+
host = postgres_config.get("host")
|
|
142
|
+
database = postgres_config.get("database") or postgres_config.get("dbname")
|
|
143
|
+
user = postgres_config.get("user") or postgres_config.get("username")
|
|
144
|
+
if not host or not database or not user:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
parts = [
|
|
148
|
+
f"host={host}",
|
|
149
|
+
f"dbname={database}",
|
|
150
|
+
f"user={user}",
|
|
151
|
+
]
|
|
152
|
+
optional_fields = {
|
|
153
|
+
"password": postgres_config.get("password"),
|
|
154
|
+
"port": postgres_config.get("port"),
|
|
155
|
+
"sslmode": postgres_config.get("sslmode"),
|
|
156
|
+
"connect_timeout": postgres_config.get("connect_timeout"),
|
|
157
|
+
}
|
|
158
|
+
parts.extend(f"{key}={value}" for key, value in optional_fields.items() if value not in (None, ""))
|
|
159
|
+
return " ".join(parts)
|
|
160
|
+
|
|
118
161
|
|
|
119
162
|
def _configure_framework_logger() -> logging.Logger:
|
|
120
163
|
logger = logging.getLogger(FRAMEWORK_LOGGER_NAME)
|
|
@@ -144,11 +187,23 @@ def get_framework_logger(name: str | None = None) -> logging.Logger:
|
|
|
144
187
|
settings = ConfigSettings()
|
|
145
188
|
framework_logger = _configure_framework_logger()
|
|
146
189
|
framework_logger.info("Bootstrapping framework with active profile [%s]", settings.profile)
|
|
147
|
-
_mongo_client =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
190
|
+
_mongo_client: MongoClient | None = None
|
|
191
|
+
db = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _get_mongo_client() -> MongoClient:
|
|
195
|
+
global _mongo_client, db
|
|
196
|
+
if not isinstance(settings.mongo_uri, str) or not settings.mongo_uri:
|
|
197
|
+
raise ValueError("MongoDB URI must be provided before using Mongo repositories")
|
|
198
|
+
if not isinstance(settings.database_name, str) or not settings.database_name:
|
|
199
|
+
raise ValueError("MongoDB database name must be provided before using Mongo repositories")
|
|
200
|
+
|
|
201
|
+
if _mongo_client is None:
|
|
202
|
+
_mongo_client = MongoClient(settings.mongo_uri)
|
|
203
|
+
db = _mongo_client[settings.database_name]
|
|
204
|
+
framework_logger.debug("Mongo client initialized for database [%s]", settings.database_name)
|
|
205
|
+
|
|
206
|
+
return _mongo_client
|
|
152
207
|
|
|
153
208
|
|
|
154
209
|
def value(place_holder: str) -> Any:
|
|
@@ -196,10 +251,26 @@ def value(place_holder: str) -> Any:
|
|
|
196
251
|
|
|
197
252
|
def get_collection(collection_name: str):
|
|
198
253
|
"""Provides repositories access to the shared single client pool."""
|
|
254
|
+
_get_mongo_client()
|
|
199
255
|
return db[collection_name]
|
|
200
256
|
|
|
201
257
|
|
|
258
|
+
def get_postgres_connection():
|
|
259
|
+
"""Creates a PostgreSQL connection using the active profile settings."""
|
|
260
|
+
if not isinstance(settings.postgres_dsn, str) or not settings.postgres_dsn:
|
|
261
|
+
raise ValueError("PostgreSQL DSN must be provided before using Postgres repositories")
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
import psycopg
|
|
265
|
+
from psycopg.rows import dict_row
|
|
266
|
+
except ImportError as exc:
|
|
267
|
+
raise ImportError("Install psycopg to use PostgreSQL repositories.") from exc
|
|
268
|
+
|
|
269
|
+
return psycopg.connect(settings.postgres_dsn, row_factory=dict_row)
|
|
270
|
+
|
|
271
|
+
|
|
202
272
|
def close_db_connection():
|
|
203
|
-
"""Gracefully terminates
|
|
204
|
-
|
|
205
|
-
|
|
273
|
+
"""Gracefully terminates open framework connection pools."""
|
|
274
|
+
if _mongo_client is not None:
|
|
275
|
+
framework_logger.debug("Closing MongoDB client connection pool")
|
|
276
|
+
_mongo_client.close()
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Callable, ClassVar, Generic, List, Optional, TypeVar, cast, get_args, get_origin, get_type_hints
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from ppa.config import get_framework_logger, get_postgres_connection
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", bound="EntityModel")
|
|
10
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
11
|
+
framework_logger = get_framework_logger("postgres.repository")
|
|
12
|
+
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EntityModel(BaseModel):
|
|
16
|
+
"""Base class for PostgreSQL-backed models."""
|
|
17
|
+
|
|
18
|
+
__table_name__: ClassVar[str]
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(
|
|
21
|
+
populate_by_name=True,
|
|
22
|
+
arbitrary_types_allowed=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
id: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
28
|
+
kwargs.setdefault("exclude_none", True)
|
|
29
|
+
return super().model_dump(**kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EntityList(list[T]):
|
|
33
|
+
"""List of entities that can be used to perform bulk operations."""
|
|
34
|
+
|
|
35
|
+
def model_dump(self, **kwargs: Any) -> list[dict[str, Any]]:
|
|
36
|
+
return [entity.model_dump(**kwargs) for entity in self]
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def model_validate(cls, obj: list[Any], entity_cls: type[T]) -> "EntityList[T]":
|
|
40
|
+
if not isinstance(obj, list) and not hasattr(obj, "__iter__"):
|
|
41
|
+
raise ValueError("Data provided to EntityList.model_validate must be iterable.")
|
|
42
|
+
|
|
43
|
+
return cls([entity_cls.model_validate(item) for item in obj])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query(definition: str) -> Callable[[F], F]:
|
|
47
|
+
"""Annotation to specify a custom PostgreSQL query template with ?0 style placeholders."""
|
|
48
|
+
|
|
49
|
+
def decorator(func: F) -> F:
|
|
50
|
+
func.__query_template__ = definition
|
|
51
|
+
return func
|
|
52
|
+
|
|
53
|
+
return decorator
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def table(name: str) -> Callable[[type[EntityModel]], type[EntityModel]]:
|
|
57
|
+
"""Class decorator to bind a Pydantic model to a PostgreSQL table."""
|
|
58
|
+
|
|
59
|
+
_quote_identifier(name)
|
|
60
|
+
|
|
61
|
+
def decorator(cls: type[EntityModel]) -> type[EntityModel]:
|
|
62
|
+
cls.__table_name__ = name
|
|
63
|
+
return cls
|
|
64
|
+
|
|
65
|
+
return decorator
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _quote_identifier(identifier: str) -> str:
|
|
69
|
+
if not IDENTIFIER_PATTERN.match(identifier):
|
|
70
|
+
raise ValueError(f"Invalid SQL identifier: {identifier}")
|
|
71
|
+
return f'"{identifier}"'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _field_names(entity_cls: type[EntityModel]) -> set[str]:
|
|
75
|
+
return set(entity_cls.model_fields.keys())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_where_clause(field_name: str) -> str:
|
|
79
|
+
return f"{_quote_identifier(field_name)} = %s"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _custom_query_to_sql(template: str, ordered_args: list[Any]) -> tuple[str, list[Any]]:
|
|
83
|
+
values: list[Any] = []
|
|
84
|
+
|
|
85
|
+
def replace(match: re.Match[str]) -> str:
|
|
86
|
+
idx = int(match.group(1))
|
|
87
|
+
try:
|
|
88
|
+
values.append(ordered_args[idx])
|
|
89
|
+
except IndexError as exc:
|
|
90
|
+
raise ValueError(f"Missing argument for SQL placeholder ?{idx}") from exc
|
|
91
|
+
return "%s"
|
|
92
|
+
|
|
93
|
+
return re.sub(r"\?(\d+)", replace, template), values
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RepositoryMeta(type):
|
|
97
|
+
def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]):
|
|
98
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
99
|
+
|
|
100
|
+
if name == "IRepository":
|
|
101
|
+
return cls
|
|
102
|
+
|
|
103
|
+
entity_cls = mcs._extract_entity_cls(cls)
|
|
104
|
+
if not entity_cls:
|
|
105
|
+
return cls
|
|
106
|
+
|
|
107
|
+
typed_entity_cls = cast(type[EntityModel], entity_cls)
|
|
108
|
+
|
|
109
|
+
if not hasattr(typed_entity_cls, "__table_name__"):
|
|
110
|
+
raise TypeError(
|
|
111
|
+
f"RepositoryDefinitionError: The model '{entity_cls.__name__}' "
|
|
112
|
+
f"used by '{name}' must be annotated with @table(name='...')"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
cls._entity_cls = typed_entity_cls
|
|
116
|
+
valid_fields = _field_names(typed_entity_cls)
|
|
117
|
+
|
|
118
|
+
for attr_name, attr_value in list(namespace.items()):
|
|
119
|
+
if inspect.isfunction(attr_value) and not attr_name.startswith("_"):
|
|
120
|
+
query_template = getattr(attr_value, "__query_template__", None)
|
|
121
|
+
is_convention = re.match(r"(find_by|find_all_by|exists_by|count_by)_(.*)", attr_name)
|
|
122
|
+
|
|
123
|
+
if not is_convention and not query_template:
|
|
124
|
+
framework_logger.info("Convention does not exist, relying on user implementation")
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
hints = get_type_hints(attr_value)
|
|
128
|
+
return_type = hints.get("return")
|
|
129
|
+
|
|
130
|
+
if isinstance(query_template, str):
|
|
131
|
+
generated_method = mcs._build_annotated_method(
|
|
132
|
+
attr_name,
|
|
133
|
+
attr_value,
|
|
134
|
+
query_template,
|
|
135
|
+
return_type,
|
|
136
|
+
typed_entity_cls,
|
|
137
|
+
)
|
|
138
|
+
elif is_convention is not None:
|
|
139
|
+
generated_method = mcs._build_convention_method(
|
|
140
|
+
attr_name,
|
|
141
|
+
is_convention,
|
|
142
|
+
attr_value,
|
|
143
|
+
valid_fields,
|
|
144
|
+
return_type,
|
|
145
|
+
typed_entity_cls,
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
setattr(cls, attr_name, generated_method)
|
|
151
|
+
|
|
152
|
+
return cls
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _extract_entity_cls(cls: type) -> type[BaseModel] | None:
|
|
156
|
+
for base in getattr(cls, "__orig_bases__", []):
|
|
157
|
+
if get_origin(base) is IRepository:
|
|
158
|
+
args = get_args(base)
|
|
159
|
+
if args and issubclass(args[0], BaseModel):
|
|
160
|
+
return args[0]
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _build_annotated_method(
|
|
165
|
+
name: str,
|
|
166
|
+
func: Callable[..., Any],
|
|
167
|
+
template: str,
|
|
168
|
+
return_type: Any,
|
|
169
|
+
entity_cls: type[EntityModel],
|
|
170
|
+
) -> Callable[..., Any]:
|
|
171
|
+
sig = inspect.signature(func)
|
|
172
|
+
param_names = [p for p in sig.parameters if p != "self"]
|
|
173
|
+
|
|
174
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
175
|
+
bound = sig.bind(self, *args, **kwargs)
|
|
176
|
+
bound.apply_defaults()
|
|
177
|
+
ordered_args = [bound.arguments[p_name] for p_name in param_names if p_name in bound.arguments]
|
|
178
|
+
sql, params = _custom_query_to_sql(template, ordered_args)
|
|
179
|
+
|
|
180
|
+
framework_logger.debug(
|
|
181
|
+
"Invoked custom Postgres query %s.%s with args=%s kwargs=%s",
|
|
182
|
+
self.__class__.__name__,
|
|
183
|
+
name,
|
|
184
|
+
args,
|
|
185
|
+
kwargs,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return execute_dynamic_query(sql, params, "custom", return_type, entity_cls)
|
|
189
|
+
|
|
190
|
+
wrapper.__name__ = name
|
|
191
|
+
wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
|
|
192
|
+
return wrapper
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _build_convention_method(
|
|
196
|
+
name: str,
|
|
197
|
+
match: re.Match[str],
|
|
198
|
+
func: Callable[..., Any],
|
|
199
|
+
valid_fields: set[str],
|
|
200
|
+
return_type: Any,
|
|
201
|
+
entity_cls: type[EntityModel],
|
|
202
|
+
) -> Callable[..., Any]:
|
|
203
|
+
prefix, field_name = match.groups()
|
|
204
|
+
|
|
205
|
+
if field_name not in valid_fields:
|
|
206
|
+
raise TypeError(
|
|
207
|
+
f"RepositoryDefinitionError: Field '{field_name}' does not exist on model {entity_cls.__name__}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
sql_field = _quote_identifier(field_name)
|
|
211
|
+
table_name = _quote_identifier(entity_cls.__table_name__)
|
|
212
|
+
sig = inspect.signature(func)
|
|
213
|
+
param_names = [p for p in sig.parameters if p != "self"]
|
|
214
|
+
|
|
215
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
216
|
+
bound = sig.bind_partial(self, *args, **kwargs)
|
|
217
|
+
bound.apply_defaults()
|
|
218
|
+
actual_value = bound.arguments.get(param_names[0]) if param_names else None
|
|
219
|
+
|
|
220
|
+
if prefix == "count_by":
|
|
221
|
+
sql = f"SELECT COUNT(*) AS count FROM {table_name} WHERE {sql_field} = %s"
|
|
222
|
+
elif prefix == "exists_by":
|
|
223
|
+
sql = f"SELECT EXISTS(SELECT 1 FROM {table_name} WHERE {sql_field} = %s LIMIT 1) AS exists"
|
|
224
|
+
else:
|
|
225
|
+
sql = f"SELECT * FROM {table_name} WHERE {sql_field} = %s"
|
|
226
|
+
|
|
227
|
+
framework_logger.debug(
|
|
228
|
+
"Invoked convention Postgres query %s.%s using %s=%r",
|
|
229
|
+
self.__class__.__name__,
|
|
230
|
+
name,
|
|
231
|
+
field_name,
|
|
232
|
+
actual_value,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return execute_dynamic_query(sql, [actual_value], prefix, return_type, entity_cls)
|
|
236
|
+
|
|
237
|
+
wrapper.__name__ = name
|
|
238
|
+
wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
|
|
239
|
+
return wrapper
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def execute_dynamic_query(
|
|
243
|
+
sql: str,
|
|
244
|
+
params: list[Any],
|
|
245
|
+
strategy: str,
|
|
246
|
+
return_type: Any,
|
|
247
|
+
entity_cls: type[EntityModel],
|
|
248
|
+
) -> Any:
|
|
249
|
+
framework_logger.debug("Executing PostgreSQL query with strategy [%s]: %s", strategy, sql)
|
|
250
|
+
origin_type = get_origin(return_type) or return_type
|
|
251
|
+
|
|
252
|
+
with get_postgres_connection() as connection:
|
|
253
|
+
with connection.cursor() as cursor:
|
|
254
|
+
cursor.execute(sql, params)
|
|
255
|
+
|
|
256
|
+
if strategy == "count_by" or origin_type is int:
|
|
257
|
+
row = cursor.fetchone()
|
|
258
|
+
return int(row["count"]) if row else 0
|
|
259
|
+
|
|
260
|
+
if strategy == "exists_by" or origin_type is bool:
|
|
261
|
+
row = cursor.fetchone()
|
|
262
|
+
return bool(row["exists"]) if row else False
|
|
263
|
+
|
|
264
|
+
if origin_type in (list, EntityList):
|
|
265
|
+
rows = cursor.fetchall()
|
|
266
|
+
results = [entity_cls.model_validate(row) for row in rows]
|
|
267
|
+
return EntityList(results) if origin_type is EntityList else results
|
|
268
|
+
|
|
269
|
+
row = cursor.fetchone()
|
|
270
|
+
return entity_cls.model_validate(row) if row else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class IRepository(Generic[T], metaclass=RepositoryMeta):
|
|
274
|
+
def __init__(self):
|
|
275
|
+
entity_cls = cast(type[EntityModel], self._entity_cls)
|
|
276
|
+
self.table_name = entity_cls.__table_name__
|
|
277
|
+
framework_logger.debug(
|
|
278
|
+
"Initialized repository [%s] for table [%s]",
|
|
279
|
+
self.__class__.__name__,
|
|
280
|
+
self.table_name,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def save(self, pyd_model: T) -> str:
|
|
284
|
+
data = pyd_model.model_dump()
|
|
285
|
+
data.pop("id", None)
|
|
286
|
+
if not data:
|
|
287
|
+
raise ValueError("No data provided for insert")
|
|
288
|
+
|
|
289
|
+
columns = list(data.keys())
|
|
290
|
+
quoted_columns = ", ".join(_quote_identifier(column) for column in columns)
|
|
291
|
+
placeholders = ", ".join(["%s"] * len(columns))
|
|
292
|
+
sql = f"INSERT INTO {_quote_identifier(self.table_name)} ({quoted_columns}) VALUES ({placeholders}) RETURNING id"
|
|
293
|
+
|
|
294
|
+
with get_postgres_connection() as connection:
|
|
295
|
+
with connection.cursor() as cursor:
|
|
296
|
+
cursor.execute(sql, [data[column] for column in columns])
|
|
297
|
+
row = cursor.fetchone()
|
|
298
|
+
return str(row["id"]) if row and "id" in row else ""
|
|
299
|
+
|
|
300
|
+
def save_all(self, pyd_models: List[T]) -> List[str]:
|
|
301
|
+
return [self.save(pyd_model) for pyd_model in pyd_models]
|
|
302
|
+
|
|
303
|
+
def find_all(self) -> EntityList[T]:
|
|
304
|
+
sql = f"SELECT * FROM {_quote_identifier(self.table_name)}"
|
|
305
|
+
with get_postgres_connection() as connection:
|
|
306
|
+
with connection.cursor() as cursor:
|
|
307
|
+
cursor.execute(sql)
|
|
308
|
+
rows = cursor.fetchall()
|
|
309
|
+
return EntityList([self._entity_cls.model_validate(row) for row in rows])
|
|
310
|
+
|
|
311
|
+
def find(self, query_dict: dict[str, Any]) -> T:
|
|
312
|
+
if not query_dict:
|
|
313
|
+
raise ValueError("Query criteria must be provided")
|
|
314
|
+
|
|
315
|
+
clauses = [_build_where_clause(field_name) for field_name in query_dict]
|
|
316
|
+
sql = f"SELECT * FROM {_quote_identifier(self.table_name)} WHERE {' AND '.join(clauses)} LIMIT 1"
|
|
317
|
+
|
|
318
|
+
with get_postgres_connection() as connection:
|
|
319
|
+
with connection.cursor() as cursor:
|
|
320
|
+
cursor.execute(sql, list(query_dict.values()))
|
|
321
|
+
row = cursor.fetchone()
|
|
322
|
+
if not row:
|
|
323
|
+
raise ValueError("No data found")
|
|
324
|
+
return self._entity_cls.model_validate(row)
|
|
325
|
+
|
|
326
|
+
def delete(self, resource_id: str) -> bool:
|
|
327
|
+
sql = f"DELETE FROM {_quote_identifier(self.table_name)} WHERE {_quote_identifier('id')} = %s"
|
|
328
|
+
with get_postgres_connection() as connection:
|
|
329
|
+
with connection.cursor() as cursor:
|
|
330
|
+
cursor.execute(sql, [resource_id])
|
|
331
|
+
return cursor.rowcount > 0
|
|
332
|
+
|
|
333
|
+
def update(self, resource_id: str, data: dict[str, Any]) -> bool:
|
|
334
|
+
update_data = {key: value for key, value in data.items() if key != "id"}
|
|
335
|
+
if not update_data:
|
|
336
|
+
raise ValueError("No data provided for update")
|
|
337
|
+
|
|
338
|
+
assignments = ", ".join(f"{_quote_identifier(field_name)} = %s" for field_name in update_data)
|
|
339
|
+
sql = f"UPDATE {_quote_identifier(self.table_name)} SET {assignments} WHERE {_quote_identifier('id')} = %s"
|
|
340
|
+
|
|
341
|
+
with get_postgres_connection() as connection:
|
|
342
|
+
with connection.cursor() as cursor:
|
|
343
|
+
cursor.execute(sql, [*update_data.values(), resource_id])
|
|
344
|
+
return cursor.rowcount > 0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-ppa"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "JPA-like ORM for Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -20,6 +20,7 @@ dependencies = [
|
|
|
20
20
|
"idna==3.18",
|
|
21
21
|
"pydantic==2.13.4",
|
|
22
22
|
"pydantic_core==2.46.4",
|
|
23
|
+
"psycopg[binary]>=3.2.0",
|
|
23
24
|
"pymongo==4.17.0",
|
|
24
25
|
"python-dotenv==1.2.2",
|
|
25
26
|
"PyYAML==6.0.3",
|
|
@@ -29,4 +30,4 @@ dependencies = [
|
|
|
29
30
|
|
|
30
31
|
[tool.setuptools.packages.find]
|
|
31
32
|
# Automatically discover the 'ppa' package and its nested subpackages (like mongo)
|
|
32
|
-
include = ["ppa*"]
|
|
33
|
+
include = ["ppa*"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-ppa
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: JPA-like ORM for Python
|
|
5
5
|
Author: Pratyush Kumar
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ Requires-Dist: email-validator==2.3.0
|
|
|
13
13
|
Requires-Dist: idna==3.18
|
|
14
14
|
Requires-Dist: pydantic==2.13.4
|
|
15
15
|
Requires-Dist: pydantic_core==2.46.4
|
|
16
|
+
Requires-Dist: psycopg[binary]>=3.2.0
|
|
16
17
|
Requires-Dist: pymongo==4.17.0
|
|
17
18
|
Requires-Dist: python-dotenv==1.2.2
|
|
18
19
|
Requires-Dist: PyYAML==6.0.3
|
|
@@ -20,9 +21,9 @@ Requires-Dist: typing-inspection==0.4.2
|
|
|
20
21
|
Requires-Dist: typing_extensions==4.15.0
|
|
21
22
|
Dynamic: license-file
|
|
22
23
|
|
|
23
|
-
# python-
|
|
24
|
+
# python-ppa
|
|
24
25
|
|
|
25
|
-
`python-
|
|
26
|
+
`python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
|
|
26
27
|
|
|
27
28
|
It uses advanced Python metaprogramming (`metaclass`) to dynamically generate MongoDB queries at runtime based on method naming conventions or explicit query declarations, drastically reducing boilerplate code.
|
|
28
29
|
|
|
@@ -30,9 +31,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
30
31
|
|
|
31
32
|
## 🚀 Features
|
|
32
33
|
|
|
33
|
-
* **
|
|
34
|
+
* **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
|
|
34
35
|
* **Query Naming Conventions:** Instantly supports `find_by_*`, `find_all_by_*`, `exists_by_*`, and `count_by_*` pattern resolutions.
|
|
35
|
-
* **Custom Query Annotations:** Bind complex MongoDB query templates with custom parameter injections using the `@query` decorator.
|
|
36
|
+
* **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
|
|
36
37
|
* **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
|
|
37
38
|
* **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
|
|
38
39
|
* **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
|
|
@@ -44,44 +45,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
|
|
|
44
45
|
```text
|
|
45
46
|
.
|
|
46
47
|
├── README.md
|
|
47
|
-
├──
|
|
48
|
+
├── ppa
|
|
48
49
|
│ ├── __init__.py
|
|
49
50
|
│ ├── config.py # App bootstrapping, YAML parsing & environment mapping
|
|
50
|
-
│
|
|
51
|
+
│ ├── mongo
|
|
51
52
|
│ ├── __init__.py
|
|
52
53
|
│ └── interface.py # Repository core interface, custom metadata, and queries
|
|
54
|
+
│ └── postgres
|
|
55
|
+
│ ├── __init__.py
|
|
56
|
+
│ └── interface.py # PostgreSQL repository interface and SQL query conventions
|
|
53
57
|
├── pyproject.toml
|
|
54
58
|
└── requirements.txt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 🛠️ Getting Started
|
|
62
|
+
|
|
63
|
+
### Prerequisites
|
|
64
|
+
|
|
65
|
+
* Python 3.13 or higher
|
|
66
|
+
|
|
67
|
+
### Installation & Virtual Env Setup
|
|
68
|
+
|
|
69
|
+
1. Clone the repository and navigate to its root: `cd python-ppa`
|
|
70
|
+
2. Spin up a virtual environment and update your pip core dependencies:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python3 -m venv venv
|
|
74
|
+
source venv/bin/activate
|
|
75
|
+
pip install --upgrade pip
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
3. Install the project library dependencies:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -r requirements.txt
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## ⚙️ Configuration Management
|
|
55
85
|
|
|
56
|
-
🛠️ Getting Started
|
|
57
|
-
Prerequisites
|
|
58
|
-
• Python 3.13 or higher
|
|
59
|
-
Installation & Virtual Env Setup
|
|
60
|
-
1. Clone the repository and navigate to its root: cd python-jpa
|
|
61
|
-
2. Spin up a virtual environment and update your pip core dependencies: python3 -m venv venv source venv/bin/activate # On Windows use: venv\Scripts\activate pip install --upgrade pip
|
|
62
|
-
3. Install the project library dependencies: pip install -r requirements.txt
|
|
63
|
-
⚙️ Configuration Management
|
|
64
86
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
65
|
-
Create a resources
|
|
66
|
-
|
|
87
|
+
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
88
|
+
|
|
89
|
+
1. Master Configuration (`resources/settings.yml`)
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
67
92
|
app:
|
|
68
93
|
profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
|
|
94
|
+
```
|
|
69
95
|
|
|
70
|
-
2. Environment Profile Configuration (resources/settings-dev.yml)
|
|
96
|
+
2. Environment Profile Configuration (`resources/settings-dev.yml`)
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
71
99
|
mongodb:
|
|
72
100
|
uri: ${MONGO_URI:mongodb://localhost:27017}
|
|
73
|
-
database: ${MONGO_DB_NAME:
|
|
101
|
+
database: ${MONGO_DB_NAME:ppa_database}
|
|
102
|
+
|
|
103
|
+
# Or configure PostgreSQL instead.
|
|
104
|
+
postgresql:
|
|
105
|
+
dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
|
|
106
|
+
|
|
107
|
+
# Equivalent field-based PostgreSQL configuration:
|
|
108
|
+
# postgresql:
|
|
109
|
+
# host: ${POSTGRES_HOST:localhost}
|
|
110
|
+
# port: ${POSTGRES_PORT:5432}
|
|
111
|
+
# database: ${POSTGRES_DB_NAME:ppa_database}
|
|
112
|
+
# user: ${POSTGRES_USER:postgres}
|
|
113
|
+
# password: ${POSTGRES_PASSWORD:postgres}
|
|
74
114
|
|
|
75
115
|
framework:
|
|
76
116
|
logging:
|
|
77
117
|
enabled: true
|
|
78
118
|
level: "DEBUG"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 📖 Usage Example
|
|
79
122
|
|
|
80
|
-
📖 Usage Example
|
|
81
123
|
Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
|
|
124
|
+
|
|
82
125
|
1. Define your Document Model
|
|
126
|
+
|
|
127
|
+
```python
|
|
83
128
|
from pydantic import Field
|
|
84
|
-
from
|
|
129
|
+
from ppa.mongo.interface import DocumentModel, document
|
|
85
130
|
|
|
86
131
|
@document(name="users")
|
|
87
132
|
class User(DocumentModel):
|
|
@@ -89,10 +134,14 @@ class User(DocumentModel):
|
|
|
89
134
|
username: str
|
|
90
135
|
email: str
|
|
91
136
|
age: int
|
|
137
|
+
```
|
|
92
138
|
|
|
93
139
|
2. Declare your Interface Repository
|
|
140
|
+
|
|
94
141
|
By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
|
|
95
|
-
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from ppa.mongo.interface import IRepository, query
|
|
96
145
|
from typing import List, Optional
|
|
97
146
|
|
|
98
147
|
class UserRepository(IRepository[User]):
|
|
@@ -105,9 +154,12 @@ class UserRepository(IRepository[User]):
|
|
|
105
154
|
# 2. Templated declaration using placeholder substitutions
|
|
106
155
|
@query(definition={"email": "?0", "age": {"$gte": "?1"}})
|
|
107
156
|
def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
|
|
157
|
+
```
|
|
108
158
|
|
|
109
159
|
3. Execute CRUD Actions
|
|
110
|
-
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from ppa.config import close_db_connection
|
|
111
163
|
|
|
112
164
|
# Initialize repository instance
|
|
113
165
|
user_repo = UserRepository()
|
|
@@ -122,6 +174,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
|
|
|
122
174
|
|
|
123
175
|
# Clean up connections on process termination
|
|
124
176
|
close_db_connection()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
PostgreSQL repositories follow the same naming conventions with SQL-backed models:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from pydantic import EmailStr
|
|
183
|
+
from ppa.postgres.interface import EntityModel, IRepository, query, table
|
|
184
|
+
|
|
185
|
+
@table(name="users")
|
|
186
|
+
class User(EntityModel):
|
|
187
|
+
username: str
|
|
188
|
+
email: EmailStr
|
|
189
|
+
age: int
|
|
190
|
+
|
|
191
|
+
class UserRepository(IRepository[User]):
|
|
192
|
+
def find_by_username(self, username: str) -> User | None: ...
|
|
193
|
+
def count_by_age(self, age: int) -> int: ...
|
|
194
|
+
|
|
195
|
+
@query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
|
|
196
|
+
def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## 📜 License
|
|
125
200
|
|
|
126
|
-
📜 License
|
|
127
201
|
This project is open-source software licensed under the MIT License.
|
python_ppa-0.1.5/ppa/__init__.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|