half-orm 0.18.12__tar.gz → 1.0.0__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.
- {half_orm-0.18.12 → half_orm-1.0.0}/AUTHORS +1 -0
- half_orm-1.0.0/PKG-INFO +176 -0
- half_orm-1.0.0/README.md +147 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/__main__.py +1 -1
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/cli.py +14 -1
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/field.py +111 -7
- half_orm-1.0.0/half_orm/migrations/BREAKING_CHANGES-1.0.0.md +50 -0
- half_orm-1.0.0/half_orm/migrations/__init__.py +29 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/model.py +282 -42
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/pg_meta.py +40 -4
- half_orm-1.0.0/half_orm/py.typed +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation.py +745 -312
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation_errors.py +38 -5
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/sql_ast.py +59 -18
- half_orm-1.0.0/half_orm/testing.py +409 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/transaction.py +93 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/utils.py +12 -2
- half_orm-1.0.0/half_orm/version.txt +1 -0
- half_orm-1.0.0/half_orm.egg-info/PKG-INFO +176 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/SOURCES.txt +4 -1
- half_orm-1.0.0/half_orm.egg-info/requires.txt +3 -0
- half_orm-1.0.0/pyproject.toml +43 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/test/test_cli.py +156 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/test/test_main.py +4 -4
- {half_orm-0.18.12 → half_orm-1.0.0}/test/test_sql_ast.py +13 -12
- half_orm-0.18.12/PKG-INFO +0 -116
- half_orm-0.18.12/README.md +0 -84
- half_orm-0.18.12/half_orm/version.txt +0 -1
- half_orm-0.18.12/half_orm.egg-info/PKG-INFO +0 -116
- half_orm-0.18.12/half_orm.egg-info/requires.txt +0 -5
- half_orm-0.18.12/pyproject.toml +0 -3
- half_orm-0.18.12/setup.py +0 -76
- {half_orm-0.18.12 → half_orm-1.0.0}/LICENSE +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/__init__.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/cli_utils.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/field_errors.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/fkey.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/hotest.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/model_errors.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/null.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation_factory.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/sql_adapter.py +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/dependency_links.txt +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/entry_points.txt +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/top_level.txt +0 -0
- {half_orm-0.18.12 → half_orm-1.0.0}/setup.cfg +0 -0
half_orm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: half_orm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A database-first ORM for PostgreSQL
|
|
5
|
+
Author-email: Joël Maïzi <joel.maizi@collorg.org>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/half-orm/half-orm
|
|
8
|
+
Project-URL: Documentation, https://half-orm.github.io/half-orm/
|
|
9
|
+
Project-URL: Changelog, https://github.com/half-orm/half-orm/blob/main/CHANGELOG.md
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Database
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
License-File: AUTHORS
|
|
25
|
+
Requires-Dist: psycopg[binary]
|
|
26
|
+
Requires-Dist: click
|
|
27
|
+
Requires-Dist: pyyaml
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# halfORM
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/half-orm/)
|
|
33
|
+
[](https://www.python.org)
|
|
34
|
+
[](https://www.postgresql.org)
|
|
35
|
+
[](https://pypi.org/project/half-orm/)
|
|
36
|
+
[](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml)
|
|
37
|
+
[](https://coveralls.io/github/half-orm/half-orm?branch=main)
|
|
38
|
+
[](https://pepy.tech/project/half_orm)
|
|
39
|
+
|
|
40
|
+
> ## ⚠️ BREAKING CHANGES in v1.0.0
|
|
41
|
+
>
|
|
42
|
+
> **Legacy unprefixed API removed** — the pre-`ho_` method names, deprecated
|
|
43
|
+
> since 0.x, no longer exist: `select`, `insert`, `update`, `delete`, `get`,
|
|
44
|
+
> `count`, `is_empty`, `unaccent`, `order_by`, `limit`, `offset`, `_mogrify`.
|
|
45
|
+
> Use their `ho_`-prefixed equivalents (`ho_select`, `ho_insert`, `ho_update`,
|
|
46
|
+
> `ho_delete`, `ho_get`, `ho_count`, `ho_is_empty`, `ho_unaccent`, `ho_mogrify`,
|
|
47
|
+
> passing `order_by`/`limit`/`offset` as `ho_select()` keyword arguments).
|
|
48
|
+
>
|
|
49
|
+
> **`DC_Relation`** removed from `half_orm.relation` — this IDE type-checking
|
|
50
|
+
> stub is now generated per-project by `half-orm-dev` instead of shipped in
|
|
51
|
+
> the library.
|
|
52
|
+
>
|
|
53
|
+
> **`ho_get()`** is now public and returns a `dict` directly. It raises
|
|
54
|
+
> `NotFoundError` (0 rows) or `MultipleRowsError` (> 1 row) instead of
|
|
55
|
+
> counting first.
|
|
56
|
+
>
|
|
57
|
+
> **Deprecated query-builder setters removed** — `ho_limit`, `ho_offset`,
|
|
58
|
+
> `ho_order_by`, `ho_distinct` no longer exist as setters. Pass these as
|
|
59
|
+
> keyword arguments to `ho_select()`:
|
|
60
|
+
> ```python
|
|
61
|
+
> # Before (0.x)
|
|
62
|
+
> rel.ho_limit = 10
|
|
63
|
+
> rel.ho_order_by = 'name'
|
|
64
|
+
> list(rel)
|
|
65
|
+
>
|
|
66
|
+
> # After (1.0)
|
|
67
|
+
> list(rel.ho_select(limit=10, order_by='name'))
|
|
68
|
+
> ```
|
|
69
|
+
>
|
|
70
|
+
> **`FKEYS_PROPERTIES` / `FKEYS`** class attributes removed — use `Fkeys` only.
|
|
71
|
+
>
|
|
72
|
+
> **`ho_cast()`** now raises `CastError` if the target is not in the
|
|
73
|
+
> PostgreSQL inheritance hierarchy of the relation.
|
|
74
|
+
>
|
|
75
|
+
> See [CHANGELOG.md](CHANGELOG.md) for the full list of changes.
|
|
76
|
+
|
|
77
|
+
halfORM is a database-first ORM for PostgreSQL. Your schema lives in the
|
|
78
|
+
database; halfORM introspects it at runtime and gives you Python objects to work
|
|
79
|
+
with your data. No migrations, no code generation.
|
|
80
|
+
|
|
81
|
+
**The central idea:** a `Relation` object is a **predicate**. It describes the
|
|
82
|
+
logical condition that rows must satisfy to belong to the relation. Its
|
|
83
|
+
*extension* — the set of rows currently satisfying the predicate in the
|
|
84
|
+
database — is what you read, update, or delete.
|
|
85
|
+
|
|
86
|
+
## Key features
|
|
87
|
+
|
|
88
|
+
- **Database-first** — the schema lives in PostgreSQL, not in Python classes.
|
|
89
|
+
- **Predicate model** — relations are predicates; set operators (`|`, `&`, `-`)
|
|
90
|
+
compose them before any SQL is sent.
|
|
91
|
+
- **FK navigation** — join across tables (and views) by setting FK attributes;
|
|
92
|
+
explicit `Fkeys` dict for views where PostgreSQL stores no FK metadata.
|
|
93
|
+
- **Sync + async** — every executor has an `a`-prefixed async counterpart
|
|
94
|
+
(`ho_aselect`, `ho_ainsert`, …).
|
|
95
|
+
- **Bulk load** — `ho_copy` / `ho_acopy` for high-throughput inserts via
|
|
96
|
+
PostgreSQL `COPY`.
|
|
97
|
+
- **No magic** — queries are built from the predicate you describe and executed
|
|
98
|
+
only when you call an executor. `ho_mogrify()` shows the exact SQL.
|
|
99
|
+
|
|
100
|
+
## Install
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install half_orm
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Requires **[psycopg 3](https://www.psycopg.org/psycopg3/)** (`psycopg[binary]`).
|
|
107
|
+
|
|
108
|
+
## Configure
|
|
109
|
+
|
|
110
|
+
```ini
|
|
111
|
+
# ~/.half_orm/blog
|
|
112
|
+
[database]
|
|
113
|
+
name = blog
|
|
114
|
+
user = alice
|
|
115
|
+
password = secret
|
|
116
|
+
host = localhost
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Usage
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from half_orm.model import Model
|
|
123
|
+
from half_orm.relation_errors import NotFoundError, MultipleRowsError
|
|
124
|
+
|
|
125
|
+
blog = Model('blog')
|
|
126
|
+
Post = blog.get_relation_class('blog.post')
|
|
127
|
+
Author = blog.get_relation_class('blog.author')
|
|
128
|
+
|
|
129
|
+
# Insert — returns the inserted row as a dict
|
|
130
|
+
alice = Author(
|
|
131
|
+
first_name='Alice', last_name='Martin', email='alice@example.com'
|
|
132
|
+
).ho_insert()
|
|
133
|
+
|
|
134
|
+
# Query — Author(last_name='Martin') is a predicate, not a query
|
|
135
|
+
for row in Author(last_name='Martin').ho_select('id', 'email'):
|
|
136
|
+
print(row)
|
|
137
|
+
|
|
138
|
+
# Get exactly one row
|
|
139
|
+
try:
|
|
140
|
+
author = Author(last_name='Martin').ho_get()
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
print("no such author")
|
|
143
|
+
except MultipleRowsError:
|
|
144
|
+
print("ambiguous — more than one author named Martin")
|
|
145
|
+
|
|
146
|
+
# FK navigation — no JOIN written by hand
|
|
147
|
+
post = Post(id=1)
|
|
148
|
+
post.author_fk.set() # join all authors
|
|
149
|
+
for row in post.ho_select(json_agg={'author_fk': ['first_name', 'last_name']}):
|
|
150
|
+
print(row['author_fk']) # {'first_name': 'Alice', 'last_name': 'Martin'}
|
|
151
|
+
|
|
152
|
+
# Set operators — compose predicates before hitting the database
|
|
153
|
+
recent = Post(created_at=('>', '2024-01-01'))
|
|
154
|
+
featured = Post(featured=True)
|
|
155
|
+
combined = recent | featured # UNION
|
|
156
|
+
print(combined.ho_count())
|
|
157
|
+
|
|
158
|
+
# Update / delete
|
|
159
|
+
Author(id=alice['id']).ho_update(email='alice@newdomain.com')
|
|
160
|
+
Author(id=alice['id']).ho_delete()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Documentation
|
|
164
|
+
|
|
165
|
+
- [Learn halfORM in half an hour](https://half-orm.github.io/half-orm/dev/half-an-hour/)
|
|
166
|
+
- [API Reference](https://half-orm.github.io/half-orm/dev/api/relation/)
|
|
167
|
+
|
|
168
|
+
## Extensions
|
|
169
|
+
|
|
170
|
+
| Extension | Description |
|
|
171
|
+
|-----------|-------------|
|
|
172
|
+
| [half-orm-dev](https://github.com/half-orm/half-orm-dev) | Development tools — `half_orm dev` project scaffolding, patch management, and schema synchronisation. |
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
halfORM is licensed under the [GPL-3.0](LICENSE) license.
|
half_orm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# halfORM
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/half-orm/)
|
|
4
|
+
[](https://www.python.org)
|
|
5
|
+
[](https://www.postgresql.org)
|
|
6
|
+
[](https://pypi.org/project/half-orm/)
|
|
7
|
+
[](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml)
|
|
8
|
+
[](https://coveralls.io/github/half-orm/half-orm?branch=main)
|
|
9
|
+
[](https://pepy.tech/project/half_orm)
|
|
10
|
+
|
|
11
|
+
> ## ⚠️ BREAKING CHANGES in v1.0.0
|
|
12
|
+
>
|
|
13
|
+
> **Legacy unprefixed API removed** — the pre-`ho_` method names, deprecated
|
|
14
|
+
> since 0.x, no longer exist: `select`, `insert`, `update`, `delete`, `get`,
|
|
15
|
+
> `count`, `is_empty`, `unaccent`, `order_by`, `limit`, `offset`, `_mogrify`.
|
|
16
|
+
> Use their `ho_`-prefixed equivalents (`ho_select`, `ho_insert`, `ho_update`,
|
|
17
|
+
> `ho_delete`, `ho_get`, `ho_count`, `ho_is_empty`, `ho_unaccent`, `ho_mogrify`,
|
|
18
|
+
> passing `order_by`/`limit`/`offset` as `ho_select()` keyword arguments).
|
|
19
|
+
>
|
|
20
|
+
> **`DC_Relation`** removed from `half_orm.relation` — this IDE type-checking
|
|
21
|
+
> stub is now generated per-project by `half-orm-dev` instead of shipped in
|
|
22
|
+
> the library.
|
|
23
|
+
>
|
|
24
|
+
> **`ho_get()`** is now public and returns a `dict` directly. It raises
|
|
25
|
+
> `NotFoundError` (0 rows) or `MultipleRowsError` (> 1 row) instead of
|
|
26
|
+
> counting first.
|
|
27
|
+
>
|
|
28
|
+
> **Deprecated query-builder setters removed** — `ho_limit`, `ho_offset`,
|
|
29
|
+
> `ho_order_by`, `ho_distinct` no longer exist as setters. Pass these as
|
|
30
|
+
> keyword arguments to `ho_select()`:
|
|
31
|
+
> ```python
|
|
32
|
+
> # Before (0.x)
|
|
33
|
+
> rel.ho_limit = 10
|
|
34
|
+
> rel.ho_order_by = 'name'
|
|
35
|
+
> list(rel)
|
|
36
|
+
>
|
|
37
|
+
> # After (1.0)
|
|
38
|
+
> list(rel.ho_select(limit=10, order_by='name'))
|
|
39
|
+
> ```
|
|
40
|
+
>
|
|
41
|
+
> **`FKEYS_PROPERTIES` / `FKEYS`** class attributes removed — use `Fkeys` only.
|
|
42
|
+
>
|
|
43
|
+
> **`ho_cast()`** now raises `CastError` if the target is not in the
|
|
44
|
+
> PostgreSQL inheritance hierarchy of the relation.
|
|
45
|
+
>
|
|
46
|
+
> See [CHANGELOG.md](CHANGELOG.md) for the full list of changes.
|
|
47
|
+
|
|
48
|
+
halfORM is a database-first ORM for PostgreSQL. Your schema lives in the
|
|
49
|
+
database; halfORM introspects it at runtime and gives you Python objects to work
|
|
50
|
+
with your data. No migrations, no code generation.
|
|
51
|
+
|
|
52
|
+
**The central idea:** a `Relation` object is a **predicate**. It describes the
|
|
53
|
+
logical condition that rows must satisfy to belong to the relation. Its
|
|
54
|
+
*extension* — the set of rows currently satisfying the predicate in the
|
|
55
|
+
database — is what you read, update, or delete.
|
|
56
|
+
|
|
57
|
+
## Key features
|
|
58
|
+
|
|
59
|
+
- **Database-first** — the schema lives in PostgreSQL, not in Python classes.
|
|
60
|
+
- **Predicate model** — relations are predicates; set operators (`|`, `&`, `-`)
|
|
61
|
+
compose them before any SQL is sent.
|
|
62
|
+
- **FK navigation** — join across tables (and views) by setting FK attributes;
|
|
63
|
+
explicit `Fkeys` dict for views where PostgreSQL stores no FK metadata.
|
|
64
|
+
- **Sync + async** — every executor has an `a`-prefixed async counterpart
|
|
65
|
+
(`ho_aselect`, `ho_ainsert`, …).
|
|
66
|
+
- **Bulk load** — `ho_copy` / `ho_acopy` for high-throughput inserts via
|
|
67
|
+
PostgreSQL `COPY`.
|
|
68
|
+
- **No magic** — queries are built from the predicate you describe and executed
|
|
69
|
+
only when you call an executor. `ho_mogrify()` shows the exact SQL.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install half_orm
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires **[psycopg 3](https://www.psycopg.org/psycopg3/)** (`psycopg[binary]`).
|
|
78
|
+
|
|
79
|
+
## Configure
|
|
80
|
+
|
|
81
|
+
```ini
|
|
82
|
+
# ~/.half_orm/blog
|
|
83
|
+
[database]
|
|
84
|
+
name = blog
|
|
85
|
+
user = alice
|
|
86
|
+
password = secret
|
|
87
|
+
host = localhost
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from half_orm.model import Model
|
|
94
|
+
from half_orm.relation_errors import NotFoundError, MultipleRowsError
|
|
95
|
+
|
|
96
|
+
blog = Model('blog')
|
|
97
|
+
Post = blog.get_relation_class('blog.post')
|
|
98
|
+
Author = blog.get_relation_class('blog.author')
|
|
99
|
+
|
|
100
|
+
# Insert — returns the inserted row as a dict
|
|
101
|
+
alice = Author(
|
|
102
|
+
first_name='Alice', last_name='Martin', email='alice@example.com'
|
|
103
|
+
).ho_insert()
|
|
104
|
+
|
|
105
|
+
# Query — Author(last_name='Martin') is a predicate, not a query
|
|
106
|
+
for row in Author(last_name='Martin').ho_select('id', 'email'):
|
|
107
|
+
print(row)
|
|
108
|
+
|
|
109
|
+
# Get exactly one row
|
|
110
|
+
try:
|
|
111
|
+
author = Author(last_name='Martin').ho_get()
|
|
112
|
+
except NotFoundError:
|
|
113
|
+
print("no such author")
|
|
114
|
+
except MultipleRowsError:
|
|
115
|
+
print("ambiguous — more than one author named Martin")
|
|
116
|
+
|
|
117
|
+
# FK navigation — no JOIN written by hand
|
|
118
|
+
post = Post(id=1)
|
|
119
|
+
post.author_fk.set() # join all authors
|
|
120
|
+
for row in post.ho_select(json_agg={'author_fk': ['first_name', 'last_name']}):
|
|
121
|
+
print(row['author_fk']) # {'first_name': 'Alice', 'last_name': 'Martin'}
|
|
122
|
+
|
|
123
|
+
# Set operators — compose predicates before hitting the database
|
|
124
|
+
recent = Post(created_at=('>', '2024-01-01'))
|
|
125
|
+
featured = Post(featured=True)
|
|
126
|
+
combined = recent | featured # UNION
|
|
127
|
+
print(combined.ho_count())
|
|
128
|
+
|
|
129
|
+
# Update / delete
|
|
130
|
+
Author(id=alice['id']).ho_update(email='alice@newdomain.com')
|
|
131
|
+
Author(id=alice['id']).ho_delete()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Documentation
|
|
135
|
+
|
|
136
|
+
- [Learn halfORM in half an hour](https://half-orm.github.io/half-orm/dev/half-an-hour/)
|
|
137
|
+
- [API Reference](https://half-orm.github.io/half-orm/dev/api/relation/)
|
|
138
|
+
|
|
139
|
+
## Extensions
|
|
140
|
+
|
|
141
|
+
| Extension | Description |
|
|
142
|
+
|-----------|-------------|
|
|
143
|
+
| [half-orm-dev](https://github.com/half-orm/half-orm-dev) | Development tools — `half_orm dev` project scaffolding, patch management, and schema synchronisation. |
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
halfORM is licensed under the [GPL-3.0](LICENSE) license.
|
|
@@ -50,7 +50,7 @@ def check_databases_access():
|
|
|
50
50
|
except FileNotFoundError:
|
|
51
51
|
sys.stderr.write(f"❌ '{CONF_DIR}' does not exist.\nChange HALFORM_CONF_DIR variable\n")
|
|
52
52
|
sys.stderr.flush()
|
|
53
|
-
print(f"\nCheck the documentation on https://half-orm.github.io/half-orm/
|
|
53
|
+
print(f"\nCheck the documentation on https://half-orm.github.io/half-orm/half-an-hour/#1-connect-1-min")
|
|
54
54
|
|
|
55
55
|
def show_version():
|
|
56
56
|
print(f"[halfORM] version {half_orm.__version__}")
|
|
@@ -43,6 +43,17 @@ class CustomGroup(click.Group):
|
|
|
43
43
|
|
|
44
44
|
def resolve_command(self, ctx, args):
|
|
45
45
|
"""Override to show available commands when command not found."""
|
|
46
|
+
extensions = discover_extensions()
|
|
47
|
+
for ext_data in extensions.values():
|
|
48
|
+
pre_check = ext_data.get('pre_check')
|
|
49
|
+
if pre_check is not None:
|
|
50
|
+
try:
|
|
51
|
+
pre_check(ctx)
|
|
52
|
+
except (click.ClickException, SystemExit):
|
|
53
|
+
raise
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise click.ClickException(str(e))
|
|
56
|
+
|
|
46
57
|
try:
|
|
47
58
|
return super().resolve_command(ctx, args)
|
|
48
59
|
except click.UsageError as e:
|
|
@@ -87,6 +98,7 @@ OFFICIAL_EXTENSIONS = {
|
|
|
87
98
|
'half_orm_test_extension',
|
|
88
99
|
'half_orm_inspect',
|
|
89
100
|
'half_orm_dev',
|
|
101
|
+
'half_orm_gen'
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
def get_config_file():
|
|
@@ -253,7 +265,8 @@ def discover_extensions() -> Dict[str, Any]:
|
|
|
253
265
|
'package_name': package_name,
|
|
254
266
|
'version': current_version,
|
|
255
267
|
'metadata': pkg_metadata, # Use auto-discovered metadata
|
|
256
|
-
'display_name': display_name
|
|
268
|
+
'display_name': display_name,
|
|
269
|
+
'pre_check': getattr(extension_module, 'pre_check', None),
|
|
257
270
|
}
|
|
258
271
|
|
|
259
272
|
except ImportError as exc:
|
|
@@ -6,12 +6,43 @@
|
|
|
6
6
|
import re
|
|
7
7
|
import sys
|
|
8
8
|
import typing
|
|
9
|
+
import warnings
|
|
10
|
+
import yaml
|
|
9
11
|
from collections.abc import Iterable
|
|
10
12
|
from half_orm.null import NULL
|
|
11
13
|
from half_orm.sql_adapter import SQL_ADAPTER
|
|
12
14
|
from half_orm.sql_ast import FieldExpr
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
_TEXT_LIKE_TYPES = {'text', 'varchar', 'character varying', 'char', 'bpchar', 'name', 'citext'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_text_like_sql_type(sql_type: str) -> bool:
|
|
21
|
+
"""True if `sql_type` (a PostgreSQL type name, e.g. from ho_meta()'s
|
|
22
|
+
per-field 'sql_type') is one ``ilike``/``unaccent`` make sense against.
|
|
23
|
+
|
|
24
|
+
Array types (leading ``_``, e.g. ``_text``) are unwrapped first. Used by
|
|
25
|
+
:attr:`Field._is_text_like` internally, and by callers outside this
|
|
26
|
+
module (e.g. half_orm_gen's search) that need the same "is this a text
|
|
27
|
+
type" judgment before choosing a comparator, without duplicating the
|
|
28
|
+
type list.
|
|
29
|
+
"""
|
|
30
|
+
return sql_type.lstrip('_') in _TEXT_LIKE_TYPES
|
|
31
|
+
|
|
32
|
+
# (comparator, column_sql_type) -> right-hand-side SQL template (with a single
|
|
33
|
+
# '%s' bind placeholder), used instead of the default "cast the bound value to
|
|
34
|
+
# the column's own type" behavior. Needed whenever a PostgreSQL operator's
|
|
35
|
+
# right operand isn't of the same type as the left (column) operand — e.g.
|
|
36
|
+
# `tsvector @@ tsquery`, not `tsvector @@ tsvector` — so a bare `%s::sql_type`
|
|
37
|
+
# cast would be wrong (or, for tsquery specifically, would reject a plain
|
|
38
|
+
# multi-word search string since the tsquery input parser requires explicit
|
|
39
|
+
# &/|/! operators between lexemes; plainto_tsquery() builds a valid tsquery
|
|
40
|
+
# from free text instead).
|
|
41
|
+
_OPERATOR_RHS_TEMPLATES = {
|
|
42
|
+
('@@', 'tsvector'): 'plainto_tsquery(%s)',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
15
46
|
class Expr:
|
|
16
47
|
"""A raw SQL expression for use with :meth:`Field.set`.
|
|
17
48
|
|
|
@@ -82,6 +113,7 @@ class Field():
|
|
|
82
113
|
self.__value = None
|
|
83
114
|
self.__unaccent = False
|
|
84
115
|
self.__comp = '='
|
|
116
|
+
self.__json_schema = self.__parse_json_schema()
|
|
85
117
|
|
|
86
118
|
@property
|
|
87
119
|
def _relation(self): # pragma: no cover
|
|
@@ -165,13 +197,63 @@ class Field():
|
|
|
165
197
|
"""
|
|
166
198
|
return bool(self.__metadata['notnull'])
|
|
167
199
|
|
|
200
|
+
@property
|
|
201
|
+
def has_default_value(self):
|
|
202
|
+
"""The default expression for this column, or ``None`` if there is none.
|
|
203
|
+
|
|
204
|
+
Returns the PostgreSQL expression string as stored in ``pg_attrdef``,
|
|
205
|
+
e.g. ``"nextval('seq'::regclass)"``, ``"'active'::text"``, ``"now()"``.
|
|
206
|
+
|
|
207
|
+
Example::
|
|
208
|
+
|
|
209
|
+
Author().id.has_default_value # "nextval('author_id_seq'::regclass)"
|
|
210
|
+
Author().last_name.has_default_value # None
|
|
211
|
+
"""
|
|
212
|
+
return self.__metadata.get('default_expr')
|
|
213
|
+
|
|
214
|
+
def __parse_json_schema(self):
|
|
215
|
+
desc = self.__metadata.get('fielddescription') or ''
|
|
216
|
+
m = re.search(r'@json\s*```yaml\s*(.*?)(?:```|\Z)', desc, re.DOTALL)
|
|
217
|
+
if not m:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
return yaml.safe_load(m.group(1))
|
|
221
|
+
except yaml.YAMLError:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def json_schema(self):
|
|
226
|
+
"""Parsed structure from the ``@json`` block in the column comment, or ``None``.
|
|
227
|
+
|
|
228
|
+
Returns the YAML structure as a Python object (dict/list/str) when the
|
|
229
|
+
column comment contains an ``@json`` block::
|
|
230
|
+
|
|
231
|
+
@json
|
|
232
|
+
```yaml
|
|
233
|
+
lang: text # ISO 639-1
|
|
234
|
+
views: integer
|
|
235
|
+
tags: [text]
|
|
236
|
+
items:
|
|
237
|
+
- id: uuid
|
|
238
|
+
name: text
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Returns ``None`` when no ``@json`` block is present.
|
|
242
|
+
"""
|
|
243
|
+
return self.__json_schema
|
|
244
|
+
|
|
168
245
|
def __repr__(self):
|
|
169
246
|
md_ = self.__metadata
|
|
170
247
|
field_constraint = f"{md_['notnull'] and 'NOT NULL' or ''}"
|
|
171
248
|
repr_ = f"({md_['fieldtype']}) {field_constraint}"
|
|
172
249
|
if self.__is_set:
|
|
173
250
|
repr_ = f"{repr_} ({self.__name} {self.__comp} {self.__value})"
|
|
174
|
-
|
|
251
|
+
repr_ = repr_.strip()
|
|
252
|
+
if self.__json_schema is not None:
|
|
253
|
+
yaml_str = yaml.dump(self.__json_schema, default_flow_style=False, allow_unicode=True)
|
|
254
|
+
for line in yaml_str.rstrip('\n').splitlines():
|
|
255
|
+
repr_ += f'\n {line}'
|
|
256
|
+
return repr_
|
|
175
257
|
|
|
176
258
|
def __str__(self):
|
|
177
259
|
return str(self.__value)
|
|
@@ -198,12 +280,13 @@ class Field():
|
|
|
198
280
|
cast = ''
|
|
199
281
|
if self.__value != NULL and not isiterable:
|
|
200
282
|
cast = f'::{self.__sql_type}'
|
|
283
|
+
rhs = _OPERATOR_RHS_TEMPLATES.get((comp, self.__sql_type), f'{comp_str}{cast}')
|
|
201
284
|
if col_is_array and comp == '=':
|
|
202
285
|
where_repr = f'{comp_str} = ANY({self.__praf(query, ho_id)})'
|
|
203
|
-
elif not self.unaccent:
|
|
204
|
-
where_repr = f"{self.__praf(query, ho_id)} {comp} {
|
|
286
|
+
elif not self.unaccent or not self._is_text_like:
|
|
287
|
+
where_repr = f"{self.__praf(query, ho_id)} {comp} {rhs}"
|
|
205
288
|
else:
|
|
206
|
-
where_repr = f"unaccent({self.__praf(query, ho_id)}) {comp} unaccent({
|
|
289
|
+
where_repr = f"unaccent({self.__praf(query, ho_id)}) {comp} unaccent({rhs})"
|
|
207
290
|
return where_repr
|
|
208
291
|
|
|
209
292
|
def _where_expr(self, query, ho_id):
|
|
@@ -239,12 +322,13 @@ class Field():
|
|
|
239
322
|
cast = ''
|
|
240
323
|
if not is_array_any and self.__value != NULL and not isiterable:
|
|
241
324
|
cast = f'::{self.__sql_type}'
|
|
325
|
+
placeholder = _OPERATOR_RHS_TEMPLATES.get((comp, self.__sql_type), f'{comp_str}{cast}')
|
|
242
326
|
return FieldExpr(
|
|
243
327
|
column=self.__praf(query, ho_id),
|
|
244
328
|
comp=comp,
|
|
245
|
-
placeholder=
|
|
329
|
+
placeholder=placeholder,
|
|
246
330
|
value=self,
|
|
247
|
-
unaccent=self.unaccent,
|
|
331
|
+
unaccent=self.unaccent and self._is_text_like,
|
|
248
332
|
array_any=is_array_any,
|
|
249
333
|
)
|
|
250
334
|
|
|
@@ -314,7 +398,7 @@ class Field():
|
|
|
314
398
|
self.__comp = '='
|
|
315
399
|
self.__unaccent = False
|
|
316
400
|
return
|
|
317
|
-
self.
|
|
401
|
+
self.unaccent = unaccent
|
|
318
402
|
comp = None
|
|
319
403
|
if isinstance(value, tuple):
|
|
320
404
|
if len(value) != 2:
|
|
@@ -376,10 +460,30 @@ class Field():
|
|
|
376
460
|
"""
|
|
377
461
|
return self.__unaccent
|
|
378
462
|
|
|
463
|
+
@property
|
|
464
|
+
def _is_text_like(self):
|
|
465
|
+
return is_text_like_sql_type(self.__sql_type)
|
|
466
|
+
|
|
379
467
|
@unaccent.setter
|
|
380
468
|
def unaccent(self, value):
|
|
381
469
|
if not isinstance(value, bool):
|
|
382
470
|
raise RuntimeError('unaccent value must be True or False!')
|
|
471
|
+
if value and not self._is_text_like:
|
|
472
|
+
warnings.warn(
|
|
473
|
+
f"unaccent ignored: field '{self.__name}' has non-text type '{self.__sql_type}'",
|
|
474
|
+
UserWarning,
|
|
475
|
+
stacklevel=2,
|
|
476
|
+
)
|
|
477
|
+
value = False
|
|
478
|
+
if value and not self.__relation._ho_model.has_extension('unaccent'):
|
|
479
|
+
warnings.warn(
|
|
480
|
+
f"unaccent ignored: the \"unaccent\" PostgreSQL extension is not "
|
|
481
|
+
f"installed on database '{self.__relation._ho_model._dbname}'. "
|
|
482
|
+
f"Install it with: CREATE EXTENSION IF NOT EXISTS unaccent;",
|
|
483
|
+
UserWarning,
|
|
484
|
+
stacklevel=2,
|
|
485
|
+
)
|
|
486
|
+
value = False
|
|
383
487
|
self.__unaccent = value
|
|
384
488
|
|
|
385
489
|
def _comp(self):
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# half-orm 1.0.0 — Breaking Changes
|
|
2
|
+
|
|
3
|
+
## `ho_get()` returns a `dict` and raises on 0 or >1 rows
|
|
4
|
+
|
|
5
|
+
`ho_get()` now returns a plain `dict` directly (no longer a Relation
|
|
6
|
+
object). It raises:
|
|
7
|
+
- `NotFoundError` if no row matches
|
|
8
|
+
- `MultipleRowsError` if more than one row matches
|
|
9
|
+
|
|
10
|
+
**Before:**
|
|
11
|
+
```python
|
|
12
|
+
obj = MyTable(id=1).ho_get() # returned a Relation
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**After:**
|
|
16
|
+
```python
|
|
17
|
+
row = MyTable(id=1).ho_get() # returns dict, or raises
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The async counterpart `ho_aget()` has been added with the same semantics.
|
|
21
|
+
|
|
22
|
+
## Deprecated query-builder setters removed
|
|
23
|
+
|
|
24
|
+
`ho_limit`, `ho_offset`, `ho_order_by`, `ho_distinct` no longer exist as
|
|
25
|
+
property setters. Pass them as keyword arguments to `ho_select()`.
|
|
26
|
+
|
|
27
|
+
**Before:**
|
|
28
|
+
```python
|
|
29
|
+
rel.ho_limit = 10
|
|
30
|
+
rel.ho_order_by = "name"
|
|
31
|
+
for row in rel.ho_select():
|
|
32
|
+
...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**After:**
|
|
36
|
+
```python
|
|
37
|
+
for row in rel.ho_select(limit=10, order_by="name"):
|
|
38
|
+
...
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## `FKEYS_PROPERTIES` / `FKEYS` class attributes removed
|
|
42
|
+
|
|
43
|
+
Use `Fkeys` only. Any subclass that still defines `FKEYS_PROPERTIES` or
|
|
44
|
+
`FKEYS` will raise an error at class definition time.
|
|
45
|
+
|
|
46
|
+
## `ho_cast()` raises `CastError` for invalid inheritance targets
|
|
47
|
+
|
|
48
|
+
`ho_cast(TargetClass)` now raises `half_orm.relation_errors.CastError` if
|
|
49
|
+
`TargetClass` is not in the PostgreSQL inheritance hierarchy of the
|
|
50
|
+
source table.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Migration support for halfORM extensions.
|
|
2
|
+
|
|
3
|
+
Extensions (e.g. half-orm-dev) use :func:`get_breaking_changes_dir` to locate
|
|
4
|
+
the ``BREAKING_CHANGES-X.Y.Z.md`` files shipped with this package and display
|
|
5
|
+
relevant migration notes when the user upgrades through a breaking version.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_breaking_changes_dir() -> Path:
|
|
12
|
+
"""Return the directory containing ``BREAKING_CHANGES-X.Y.Z.md`` files.
|
|
13
|
+
|
|
14
|
+
The returned path always points to the ``migrations/`` directory inside
|
|
15
|
+
the installed ``half_orm`` package, regardless of the installation method
|
|
16
|
+
(regular install, editable install, virtual environment, etc.).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Path: an existing directory; never ``None``.
|
|
20
|
+
|
|
21
|
+
Example (in an extension)::
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from half_orm.migrations import get_breaking_changes_dir
|
|
25
|
+
half_orm_migrations = get_breaking_changes_dir()
|
|
26
|
+
except (ImportError, AttributeError):
|
|
27
|
+
half_orm_migrations = None # older half-orm — ignore silently
|
|
28
|
+
"""
|
|
29
|
+
return Path(__file__).parent
|