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.
Files changed (46) hide show
  1. {half_orm-0.18.12 → half_orm-1.0.0}/AUTHORS +1 -0
  2. half_orm-1.0.0/PKG-INFO +176 -0
  3. half_orm-1.0.0/README.md +147 -0
  4. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/__main__.py +1 -1
  5. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/cli.py +14 -1
  6. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/field.py +111 -7
  7. half_orm-1.0.0/half_orm/migrations/BREAKING_CHANGES-1.0.0.md +50 -0
  8. half_orm-1.0.0/half_orm/migrations/__init__.py +29 -0
  9. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/model.py +282 -42
  10. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/pg_meta.py +40 -4
  11. half_orm-1.0.0/half_orm/py.typed +0 -0
  12. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation.py +745 -312
  13. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation_errors.py +38 -5
  14. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/sql_ast.py +59 -18
  15. half_orm-1.0.0/half_orm/testing.py +409 -0
  16. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/transaction.py +93 -0
  17. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/utils.py +12 -2
  18. half_orm-1.0.0/half_orm/version.txt +1 -0
  19. half_orm-1.0.0/half_orm.egg-info/PKG-INFO +176 -0
  20. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/SOURCES.txt +4 -1
  21. half_orm-1.0.0/half_orm.egg-info/requires.txt +3 -0
  22. half_orm-1.0.0/pyproject.toml +43 -0
  23. {half_orm-0.18.12 → half_orm-1.0.0}/test/test_cli.py +156 -0
  24. {half_orm-0.18.12 → half_orm-1.0.0}/test/test_main.py +4 -4
  25. {half_orm-0.18.12 → half_orm-1.0.0}/test/test_sql_ast.py +13 -12
  26. half_orm-0.18.12/PKG-INFO +0 -116
  27. half_orm-0.18.12/README.md +0 -84
  28. half_orm-0.18.12/half_orm/version.txt +0 -1
  29. half_orm-0.18.12/half_orm.egg-info/PKG-INFO +0 -116
  30. half_orm-0.18.12/half_orm.egg-info/requires.txt +0 -5
  31. half_orm-0.18.12/pyproject.toml +0 -3
  32. half_orm-0.18.12/setup.py +0 -76
  33. {half_orm-0.18.12 → half_orm-1.0.0}/LICENSE +0 -0
  34. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/__init__.py +0 -0
  35. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/cli_utils.py +0 -0
  36. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/field_errors.py +0 -0
  37. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/fkey.py +0 -0
  38. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/hotest.py +0 -0
  39. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/model_errors.py +0 -0
  40. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/null.py +0 -0
  41. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/relation_factory.py +0 -0
  42. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm/sql_adapter.py +0 -0
  43. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/dependency_links.txt +0 -0
  44. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/entry_points.txt +0 -0
  45. {half_orm-0.18.12 → half_orm-1.0.0}/half_orm.egg-info/top_level.txt +0 -0
  46. {half_orm-0.18.12 → half_orm-1.0.0}/setup.cfg +0 -0
@@ -1,2 +1,3 @@
1
1
  # This is the list of half_orm's significant contributors.
2
2
  # Hopefully, it will become larger...
3
+ Joël Maizi
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/half_orm)](https://pypi.org/project/half-orm/)
33
+ [![Python versions](https://img.shields.io/badge/Python-%20≥%203.9-blue)](https://www.python.org)
34
+ [![PostgreSQL versions](https://img.shields.io/badge/PostgreSQL-%20≥%209.6-blue)](https://www.postgresql.org)
35
+ [![License](https://img.shields.io/pypi/l/half_orm?color=green)](https://pypi.org/project/half-orm/)
36
+ [![Tests](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml/badge.svg)](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml)
37
+ [![Coverage](https://coveralls.io/repos/github/half-orm/half-orm/badge.svg?branch=main)](https://coveralls.io/github/half-orm/half-orm?branch=main)
38
+ [![Downloads](https://static.pepy.tech/badge/half_orm)](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.
@@ -0,0 +1,147 @@
1
+ # halfORM
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/half_orm)](https://pypi.org/project/half-orm/)
4
+ [![Python versions](https://img.shields.io/badge/Python-%20≥%203.9-blue)](https://www.python.org)
5
+ [![PostgreSQL versions](https://img.shields.io/badge/PostgreSQL-%20≥%209.6-blue)](https://www.postgresql.org)
6
+ [![License](https://img.shields.io/pypi/l/half_orm?color=green)](https://pypi.org/project/half-orm/)
7
+ [![Tests](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml/badge.svg)](https://github.com/half-orm/half-orm/actions/workflows/python-package.yml)
8
+ [![Coverage](https://coveralls.io/repos/github/half-orm/half-orm/badge.svg?branch=main)](https://coveralls.io/github/half-orm/half-orm?branch=main)
9
+ [![Downloads](https://static.pepy.tech/badge/half_orm)](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/tutorial/installation/#database-configuration-optional")
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
- return repr_.strip()
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} {comp_str}{cast}"
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({comp_str}{cast})"
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=f'{comp_str}{cast}',
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.__unaccent = unaccent
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