Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-peewee for openSUSE:Factory checked in at 2026-04-25 23:27:54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-peewee (Old) and /work/SRC/openSUSE:Factory/.python-peewee.new.11940 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-peewee" Sat Apr 25 23:27:54 2026 rev:40 rq:1349267 version:4.0.5 Changes: -------- --- /work/SRC/openSUSE:Factory/python-peewee/python-peewee.changes 2026-04-09 16:22:38.397318291 +0200 +++ /work/SRC/openSUSE:Factory/.python-peewee.new.11940/python-peewee.changes 2026-04-25 23:28:11.929531308 +0200 @@ -1,0 +2,12 @@ +Sat Apr 25 19:36:45 UTC 2026 - Dirk Müller <[email protected]> + +- update to 4.0.5: + * Fix bug where `db_value()` may not get called in subclasses + of Postgres JSONField / BinaryJSONField, refs #3044. + * Fix bug where indexes for table may be defined on multiple + schema, #3043. + * Always fall-through to base exception class if exception is + not recognized in DB drivers. This simplifies checking + driver-specific subclasses of standard DB-API exceptions. + +------------------------------------------------------------------- Old: ---- peewee-4.0.4.tar.gz New: ---- peewee-4.0.5.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-peewee.spec ++++++ --- /var/tmp/diff_new_pack.a2OQ4k/_old 2026-04-25 23:28:12.565557322 +0200 +++ /var/tmp/diff_new_pack.a2OQ4k/_new 2026-04-25 23:28:12.565557322 +0200 @@ -23,7 +23,7 @@ %endif %{?sle15_python_module_pythons} Name: python-peewee -Version: 4.0.4 +Version: 4.0.5 Release: 0 Summary: An expressive ORM that supports multiple SQL backends License: MIT @@ -34,6 +34,7 @@ BuildRequires: %{python_module devel} BuildRequires: %{python_module pip} BuildRequires: %{python_module pytest} +BuildRequires: %{python_module setuptools} BuildRequires: %{python_module wheel} BuildRequires: %{pythons} BuildRequires: fdupes ++++++ peewee-4.0.4.tar.gz -> peewee-4.0.5.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/CHANGELOG.md new/peewee-4.0.5/CHANGELOG.md --- old/peewee-4.0.4/CHANGELOG.md 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/CHANGELOG.md 2026-04-23 23:15:53.000000000 +0200 @@ -7,7 +7,18 @@ ## master -[View commits](https://github.com/coleifer/peewee/compare/4.0.4...master) +[View commits](https://github.com/coleifer/peewee/compare/4.0.5...master) + +## 4.0.5 + +* Fix bug where `db_value()` may not get called in subclasses of Postgres + JSONField / BinaryJSONField, refs #3044. +* Fix bug where indexes for table may be defined on multiple schema, #3043. +* Always fall-through to base exception class if exception is not recognized in + DB drivers. This simplifies checking driver-specific subclasses of standard + DB-API exceptions. + +[View commits](https://github.com/coleifer/peewee/compare/4.0.4...4.0.5) ## 4.0.4 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/docs/peewee/api.rst new/peewee-4.0.5/docs/peewee/api.rst --- old/peewee-4.0.4/docs/peewee/api.rst 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/docs/peewee/api.rst 2026-04-23 23:15:53.000000000 +0200 @@ -1210,6 +1210,113 @@ Create a transaction context-manager, optionally using the specified isolation level (if unspecified, the server default will be used). + +.. class:: _atomic + + Context-manager or decorator implementation for :meth:`Database.atomic`. + Uses a transaction at the outermost level and savepoints for nested calls. + + See :ref:`transactions` for details on transaction management. + + .. method:: __enter__() + + Begin the transaction or savepoint. + + :return: Either a :class:`_transaction` or :class:`_savepoint` instance, + depending on nesting. + + .. method:: __exit__(exc_type, exc_val, exc_tb) + + Commit the transaction or savepoint if exiting cleanly. If an unhandled + exception occurred, roll back. + + .. method:: __call__(fn) + + Decorate the wrapped function with a transaction or savepoint. Equivalent + to: + + .. code-block:: + + @db.atomic() + def modify_data(): + ... + + def modify_data(): + with db.atomic(): + ... + + +.. class:: _transaction + + The object yielded by :meth:`Database.transaction` and the outermost layer + of :meth:`Database.atomic`. Users do not instantiate ``_transaction`` directly. + + A ``_transaction`` can be used as a context manager or as a decorator. + On clean exit, the wrapped block is committed; on an unhandled + exception, it is rolled back and the exception re-raised. Nested + ``_transaction`` blocks share the outermost transaction - inner + ``__enter__`` / ``__exit__`` pushes and pops a stack entry but does + not emit ``BEGIN`` / ``COMMIT`` at the database level. For true + nesting, use :meth:`Database.atomic`, which emits a savepoint for + inner blocks. + + See :ref:`transactions` for details on transaction management. + + .. method:: commit(begin=True) + + :param bool begin: If ``True`` (default), automatically start a new + transaction after the commit completes. + + Commit the current transaction. Subsequent statements inside the + wrapped block run in the new transaction unless ``begin=False``. + + .. method:: rollback(begin=True) + + :param bool begin: If ``True`` (default), automatically start a new + transaction after the rollback completes. + + Roll back the current transaction. Subsequent statements inside + the wrapped block run in the new transaction unless + ``begin=False``. + +.. class:: _savepoint + + The object yielded by :meth:`Database.savepoint` and inner layers of calls + to :meth:`Database.atomic`. Users do not instantiate ``_savepoint`` directly. + + A ``_savepoint`` emits ``SAVEPOINT <id>`` on entry and either + ``RELEASE SAVEPOINT <id>`` (on clean exit) or ``ROLLBACK TO SAVEPOINT <id>`` + (on exception). The savepoint name is generated automatically and is unique + per invocation. Savepoints can be nested arbitrarily, but must occur inside + an active transaction. + + See :ref:`transactions` for details on transaction management. + + .. method:: commit(begin=True) + + :param bool begin: If ``True`` (default), automatically begin a + new savepoint after releasing. + + Release the current savepoint. Subsequent statements run inside + the new savepoint unless ``begin=False``. + + .. method:: rollback(begin=True) + + :param bool begin: If ``True`` (default), automatically begin a + new savepoint after rollback. + + Roll back to the current savepoint. Subsequent statements run + inside the new savepoint unless ``begin=False``. + +.. note:: + + Inside ``with db.atomic() as txn:``, the object bound to ``txn`` is + a :class:`_transaction` when ``atomic()`` is the outermost block, and + a :class:`_savepoint` when ``atomic()`` is nested inside another + transaction or savepoint. Both classes expose the same ``commit()`` / + ``rollback()`` interface, so code that calls ``txn.commit()`` or + ``txn.rollback()`` works identically in either case. + .. _model-api: Model @@ -2331,6 +2438,29 @@ query = Note.select(fn.COUNT(Note.id).alias('count')) assert query.scalar(as_dict=True) == {'count': 123} + .. method:: scalars() + + Return an iterator that yields scalar values from the first column of + each result row. This is the multi-row equivalent of + :py:meth:`~BaseQuery.scalar`. + + Equivalent to: + + .. code-block:: python + + [row[0] for row in query.tuples()] + + Example: + + .. code-block:: python + + query = Note.select(Note.timestamp).order_by(Note.timestamp) + all_timestamps = list(query.scalars()) + + # Can also be iterated directly: + for ts in Note.select(Note.timestamp).scalars(): + print(ts) + .. method:: count(clear_limit=False) :param bool clear_limit: Clear any LIMIT clause when counting. @@ -2380,9 +2510,9 @@ for row in query: print(row) # {'username': 'Alice', 'tweet_count': 12} - .. method:: tuples(as_tuples=True) + .. method:: tuples(as_tuple=True) - :param bool as_tuples: Specify whether to return rows as tuples. + :param bool as_tuple: Specify whether to return rows as tuples. Return rows as tuples. @@ -2574,7 +2704,9 @@ .. method:: prefetch(*subqueries, prefetch_type=PREFETCH_TYPE.WHERE) :param subqueries: A list of :class:`Model` classes or select - queries to prefetch. + queries to prefetch. Each element may also be a ``(query, target_model)`` + tuple: the ``target_model`` names which previously-listed model + should be joined through when more than one candidate exists. :param prefetch_type: Query type to use for the subqueries. :return: a list of models with selected relations prefetched. @@ -2603,6 +2735,20 @@ related models are selected, so that the related objects can be mapped correctly. + **Disambiguating multi-reference subqueries.** If a subquery relates to + more than one previously-fetched query (for example, a ``Favorite`` row + that has foreign keys to both ``User`` and ``Tweet``), use the + ``(subquery, target_model)`` tuple form to pin the relationship: + + .. code-block:: python + + users = User.select() + tweets = Tweet.select() + favorites = Favorite.select() + + # Favorite will be attached to User (via Favorite.user), not to Tweet. + query = prefetch(users, tweets, (favorites, User)) + .. class:: DoesNotExist @@ -3486,7 +3632,7 @@ ['CS 101', 'CS151', 'English 101', 'English 151'] To remove all relationships from a collection, you can use the - :meth:`~SelectQuery.clear` method. Let's say that English 101 is + :meth:`~ManyToManyQuery.clear` method. Let's say that English 101 is canceled, so we need to remove all the students from it: .. code-block:: pycon @@ -3689,6 +3835,15 @@ Execute DROP INDEX queries for the indexes defined for the model. + .. method:: drop_index(field=None, index=None, safe=True) + + :param Field field: field index to drop (for single-column indexes). + :param ModelIndex index: index to drop (for indexes other than + single-column, e.g. multi-col, partial, etc). + :param bool safe: Specify IF EXISTS clause. + + Execute DROP INDEX query for the index specified. + .. method:: create_sequence(field) :param Field field: Field instance which specifies a sequence. @@ -3751,6 +3906,30 @@ Drop table for the model and associated indexes. + .. method:: create_table_as(table_name, query, safe=True, **meta) + + :param str table_name: Name of the new table. + :param Select query: Query whose result set will populate the new + table. + :param bool safe: Specify IF NOT EXISTS clause. + :param meta: Additional table options forwarded to the context. + + Execute ``CREATE TABLE ... AS SELECT ...`` for the given model. The + new table's schema and column names are derived from the SELECT + query's result set. + + .. method:: create_sequences() + + Create every sequence referenced by a field on the model. On + databases where :attr:`Database.sequences` is ``False`` this method + is a no-op. Called implicitly by :meth:`create_all`. + + .. method:: drop_sequences() + + Drop every sequence referenced by a field on the model. On + databases where :attr:`Database.sequences` is ``False`` this method + is a no-op. Called implicitly by :meth:`drop_all`. + .. class:: Index(name, table, expressions, unique=False, safe=False, where=None, using=None, nulls_distinct=None) @@ -4140,7 +4319,7 @@ # FROM (VALUES (1, 'first'), (2, 'second')) AS v(idx, name) -.. class:: CTE(name, query, recursive=False, columns=None) +.. class:: CTE(name, query, recursive=False, columns=None, materialized=None) Represent a common-table-expression. For example queries, see :ref:`cte`. @@ -4148,6 +4327,8 @@ :param query: :class:`Select` query describing CTE. :param bool recursive: Whether the CTE is recursive. :param list columns: Explicit list of columns produced by CTE (optional). + :param bool materialized: Specify ``MATERIALIZED`` or ``NOT MATERIALIZED`` + clause. .. method:: select_from(*columns) @@ -4209,6 +4390,27 @@ Indicate the alias that should be given to the specified column-like object. + .. method:: bind_to(dest) + + :param dest: Model to bind this expression to when constructing the + model-graph. Destination must be among the JOINed tables. + :return: a :class:`BindTo` object encapsulating the binding. + + Bind the current column/expression to a specific model. + + Example: + + .. code-block:: python + + name = Case(User.username, [ + ('u1', 'User One'), + ('u2', 'User Two')], 'Someone Else') + query = (Tweet + .select(Tweet.content, name.alias('display').bind_to(User)) + .join(User)) + for tweet in query: + print(tweet.content, tweet.user.display) + .. method:: cast(as_type) :param str as_type: Type name to cast to. @@ -4265,12 +4467,15 @@ the new alias is ``None``, then the original column-like object is returned. +.. class:: BindTo(node, dest) + + Represent the binding of a given expression/value to a destination. Created + by :meth:`ColumnBase.bind_to`. .. class:: Negated(node) Represents a negated column-like object. - .. class:: Value(value, converter=None, unpack=True) :param value: Python object or scalar value. @@ -4334,7 +4539,7 @@ Short-hand for instantiating an descending :class:`Ordering` object. -.. class:: Expression(lhs, op, rhs, flat=True) +.. class:: Expression(lhs, op, rhs, flat=False) :param lhs: Left-hand side. :param op: Operation. @@ -4814,9 +5019,9 @@ Return rows as dictionaries. - .. method:: tuples(as_tuples=True) + .. method:: tuples(as_tuple=True) - :param bool as_tuples: Specify whether to return rows as tuples. + :param bool as_tuple: Specify whether to return rows as tuples. Return rows as tuples. @@ -5695,9 +5900,12 @@ :param sq: Query to use as starting-point. :param subqueries: One or more models or :class:`ModelSelect` queries - to eagerly fetch. + to eagerly fetch. Each element may also be a ``(query, target_model)`` + tuple: the ``target_model`` names which previously-listed model + should be joined through when more than one candidate exists. :param prefetch_type: Query type to use for the subqueries. - :return: a list of models with selected relations prefetched. + :return: a list of models with selected relations prefetched. When called + with no subqueries returns ``sq`` unmodified. Eagerly fetch related objects, allowing efficient querying of multiple tables when a 1-to-many relationship exists. The prefetch type changes how diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/docs/peewee/mysql.rst new/peewee-4.0.5/docs/peewee/mysql.rst --- old/peewee-4.0.4/docs/peewee/mysql.rst 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/docs/peewee/mysql.rst 2026-04-23 23:15:53.000000000 +0200 @@ -46,8 +46,6 @@ MySQL-specific helpers: -.. module:: playhouse.mysql_ext: - .. class:: JSONField() Extends :class:`TextField` with transparent JSON encoding/decoding. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/docs/peewee/relationships.rst new/peewee-4.0.5/docs/peewee/relationships.rst --- old/peewee-4.0.4/docs/peewee/relationships.rst 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/docs/peewee/relationships.rst 2026-04-23 23:15:53.000000000 +0200 @@ -728,6 +728,8 @@ Passing the field instance to ``on=`` tells Peewee which foreign key column to use for the join. +.. _joining-without-fk: + Joining without a foreign key ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -774,6 +776,89 @@ Recursive queries over self-referential structures are covered in :ref:`cte` using recursive CTEs. +.. _related-naming: + +Related-instance Names +---------------------- + +When Peewee reconstructs a model graph from a query, each related instance is +attached to the parent object under an attribute. The attribute name follows +a small set of conventions and can always be overridden explicitly. + +Default names +^^^^^^^^^^^^^ + +For a :class:`ForeignKeyField`: + +* **Forward direction**: the foreign-key field's own name. ``Tweet.user`` + produces ``tweet.user``. +* **Back-reference**: the ``backref`` argument to the foreign-key. If ``backref`` + is not given, the default is ``<lowercase_classname>_set``, e.g. ``user.tweet_set``. + Pass ``backref='+'`` to suppress the back-reference entirely. + +For a :class:`ManyToManyField`, the default back-reference is the lowercase +name of the declaring model with an ``s`` suffix. Declaring ``ManyToManyField(User)`` +on a ``Note`` model produces ``user.notes``. Pass ``backref='...'`` to override, +or ``backref='+'`` to suppress. + +For joins performed by :meth:`~ModelSelect.join`: + +* If the join follows a foreign key that Peewee can resolve, the forward + direction uses the FK field's name, and the backref direction uses the + destination model's ``_meta.name`` (typically the lowercase class name). +* If the join has no resolvable foreign key (for example, joining on an + arbitrary expression, a :class:`Table`, or a subquery), you must supply + ``attr=`` to name the attachment point - see below. + +Overriding with ``attr=`` +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:meth:`~ModelSelect.join` accepts an ``attr`` keyword that overrides the +attribute name used to attach the joined instance: + +.. code-block:: python + + query = (Tweet + .select(Tweet, User) + .join(User, attr='author')) + + for tweet in query: + print(tweet.author.username) # Instead of tweet.user. + +``attr`` is required when joining to a :class:`Table`, a subquery, or any +source for which Peewee cannot resolve a foreign key (see +:ref:`joining without a foreign key <joining-without-fk>`). It is also +required in the rare case that a join's inferred name would collide with +the ``<fk>_id`` name of an aliased foreign key, in which case passing +``attr`` equal to that ``<fk>_id`` name raises :exc:`ValueError`. + +Directing computed columns with ``bind_to`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a :class:`SELECT` includes a computed or aliased column whose logical +owner is one of the joined sources, use :meth:`~ColumnBase.bind_to` to tell +Peewee which source the column belongs to. The column is then attached to +the corresponding instance in the reconstructed graph: + +.. code-block:: python + + name = Case(User.username, [ + ('u1', 'User One'), + ('u2', 'User Two')], 'Someone Else') + + query = (Tweet + .select(Tweet.content, name.alias('display').bind_to(User)) + .join(User)) + + for tweet in query: + print(tweet.content, tweet.user.display) + +Without ``bind_to``, the computed ``display`` column would be bound to the +``tweet`` instance. ``bind_to`` accepts a :class:`Model`, :class:`ModelAlias`, +or any other source that is present in the query's FROM / JOIN list. If the +target is not selected, peewee raises a :exc:`ValueError` when building the +result set. + .. _manytomany: Many-to-Many Relationships @@ -941,6 +1026,16 @@ print(f'{user.username}: {tweet.content} ' f'({len(tweet.favorites)} favorites)') +When a subquery relates to more than one previously-listed query - for +example, a ``Favorite`` that has foreign keys to both ``User`` and ``Tweet`` - +pass a ``(query, target_model)`` tuple to disambiguate which relationship +to follow: + +.. code-block:: python + + # Fetch favorites via User, not via Tweet. + query = prefetch(users, tweets, (favorites, User)) + Filtering prefetched rows ^^^^^^^^^^^^^^^^^^^^^^^^^ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/docs/peewee/transactions.rst new/peewee-4.0.5/docs/peewee/transactions.rst --- old/peewee-4.0.4/docs/peewee/transactions.rst 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/docs/peewee/transactions.rst 2026-04-23 23:15:53.000000000 +0200 @@ -63,8 +63,8 @@ ------------------------ You can commit or roll-back explicitly inside an :meth:`~Database.atomic` -block. After calling :meth:`~Transaction.commit` or :meth:`~Transaction.rollback` -a new transaction (or savepoint) begins automatically: +block. After calling ``commit()`` or ``rollback()`` a new transaction (or +savepoint) begins automatically: .. code-block:: python diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/peewee.py new/peewee-4.0.5/peewee.py --- old/peewee-4.0.4/peewee.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/peewee.py 2026-04-23 23:15:53.000000000 +0200 @@ -44,21 +44,19 @@ pass try: import psycopg2 - from psycopg2 import errors as pg_errors from psycopg2 import extensions as pg_extensions from psycopg2.extras import register_uuid as pg_register_uuid from psycopg2.extras import Json as Json_pg2 pg_register_uuid() except ImportError: - psycopg2 = pg_errors = Json_pg2 = None + psycopg2 = Json_pg2 = None try: import psycopg - from psycopg import errors as pg3_errors from psycopg.pq import TransactionStatus from psycopg.types.json import Json as Json_pg3 from psycopg.types.json import Jsonb as Jsonb_pg3 except ImportError: - psycopg = pg3_errors = Json_pg3 = Jsonb_pg3 = None + psycopg = Json_pg3 = Jsonb_pg3 = None mysql_passwd = False try: @@ -71,7 +69,7 @@ mysql = None -__version__ = '4.0.4' +__version__ = '4.0.5' __all__ = [ 'AnyField', 'AsIs', @@ -1123,13 +1121,15 @@ raise ValueError('select_from() must specify one or more columns ' 'from the CTE to select.') - query = (Select((self,), columns) - .with_cte(self) - .bind(self._query._database)) - try: - query = query.objects(self._query.model) - except AttributeError: - pass + if hasattr(self._query, 'model'): + query = (self._query.model + .select(*columns) + .from_(self) + .with_cte(self)) + else: + query = (Select((self,), columns) + .with_cte(self) + .bind(self._query._database)) return query def _get_hash(self): @@ -3107,14 +3107,8 @@ def __exit__(self, exc_type, exc_value, traceback): if exc_type is None: return - # psycopg shits out a million cute error types. Try to catch em all. - if pg_errors is not None and exc_type.__name__ not in self.exceptions \ - and issubclass(exc_type, pg_errors.Error): - exc_type = exc_type.__bases__[0] - elif pg3_errors is not None and \ - exc_type.__name__ not in self.exceptions \ - and issubclass(exc_type, pg3_errors.Error): - exc_type = exc_type.__bases__[0] + if exc_type.__name__ not in self.exceptions: + exc_type = exc_type.__bases__[0] # Try grabbing DB-API class. if exc_type.__name__ in self.exceptions: new_type = self.exceptions[exc_type.__name__] exc_args = exc_value.args @@ -4220,11 +4214,14 @@ FROM generate_subscripts(idx.indkey, 1) AS k ORDER BY k), ',') FROM pg_catalog.pg_class AS t + INNER JOIN pg_catalog.pg_namespace AS n ON t.relnamespace = n.oid INNER JOIN pg_catalog.pg_index AS idx ON t.oid = idx.indrelid INNER JOIN pg_catalog.pg_class AS i ON idx.indexrelid = i.oid - INNER JOIN pg_catalog.pg_indexes AS idxs ON - (idxs.tablename = t.relname AND idxs.indexname = i.relname) - WHERE t.relname = %s AND t.relkind = %s AND idxs.schemaname = %s + INNER JOIN pg_catalog.pg_indexes AS idxs ON ( + idxs.tablename = t.relname + AND idxs.indexname = i.relname + AND idxs.schemaname = n.nspname) + WHERE t.relname = %s AND t.relkind = %s AND n.nspname = %s ORDER BY idx.indisunique DESC, i.relname;""" cursor = self.execute_sql(query, (table, 'r', schema or 'public')) return [IndexMetadata(name, sql.rstrip(' ;'), columns.split(','), @@ -6022,7 +6019,6 @@ return type(klass_name, (Model,), attrs) def get_through_model(self): - # XXX: Deprecated. Just use the "through_model" property. return self.through_model @@ -6308,6 +6304,14 @@ .literal(statement) .sql(index_name)) + def drop_index(self, field=None, index=None, safe=True): + if field is None and index is None: + raise ValueError('field or index must be specified.') + elif field: + index = ModelIndex(self.model, (field,), unique=field.unique, + using=field.index_type) + return self.database.execute(self._drop_index(index, safe=safe)) + def drop_indexes(self, safe=True): for query in self._drop_indexes(safe=safe): self.database.execute(query) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/playhouse/postgres_ext.py new/peewee-4.0.5/playhouse/postgres_ext.py --- old/peewee-4.0.4/playhouse/postgres_ext.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/playhouse/postgres_ext.py 2026-04-23 23:15:53.000000000 +0200 @@ -367,7 +367,7 @@ # untyped, so we need an explicit cast with psycopg2. if case and self.cast_json_case: return Cast(self.json_type(value), self._json_datatype) - return self.json_type(value) + return self.db_value(value) def __getitem__(self, value): return JsonLookup(self, [value]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/tests/fields.py new/peewee-4.0.5/tests/fields.py --- old/peewee-4.0.4/tests/fields.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/tests/fields.py 2026-04-23 23:15:53.000000000 +0200 @@ -73,7 +73,7 @@ self.assertEqual(i_db.value_null, 3) -class TestDefaultValues(ModelTestCase): +class TestDefaultValuesFields(ModelTestCase): requires = [DfltM] def test_default_values(self): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/tests/models.py new/peewee-4.0.5/tests/models.py --- old/peewee-4.0.4/tests/models.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/tests/models.py 2026-04-23 23:15:53.000000000 +0200 @@ -27,6 +27,7 @@ from .base import IS_MYSQL from .base import IS_MYSQL_ADVANCED_FEATURES from .base import IS_POSTGRESQL +from .base import IS_PSYCOPG3 from .base import IS_SQLITE from .base import IS_SQLITE_OLD from .base import IS_SQLITE_15 # Row-values. @@ -7051,3 +7052,87 @@ gc_idx = dep_models.index(DepGrandChild) c_idx = dep_models.index(DepChild) self.assertTrue(gc_idx < c_idx) + + +class Customer(TestModel): + name = TextField() + +class Prod(TestModel): + name = TextField() + price = DecimalField(decimal_places=2) + class Meta: + table_name = 'product' + +class Order(TestModel): + customer = ForeignKeyField(Customer) + order_date = DateField() + +class OrderItem(TestModel): + order = ForeignKeyField(Order) + product = ForeignKeyField(Prod) + unit_price = DecimalField(decimal_places=2) + quantity = IntegerField(default=1) + + +@skip_unless(IS_SQLITE_25 or IS_POSTGRESQL or IS_MYSQL_ADVANCED_FEATURES) +class TestAnalyticalQueries(ModelTestCase): + requires = [Customer, Prod, Order, OrderItem] + + def setUp(self): + super(TestAnalyticalQueries, self).setUp() + self.populate_sample_data() + + def populate_sample_data(self): + c1 = Customer.create(name='c1') + c2 = Customer.create(name='c2') + + p1 = Prod.create(name='p1', price=100) + p2 = Prod.create(name='p2', price=2) + + def create_order(c, order_date, items): + o = Order.create(customer=c, order_date=order_date) + for p, q, up in items: + product = p1 if p == 'p1' else p2 + OrderItem.create(order=o, product=product, quantity=q or 1, + unit_price=up or product.price) + + create_order(c1, datetime.date(2026, 1, 1), [('p1', 1, None)]) + create_order(c1, datetime.date(2026, 3, 1), [('p1', 2, 75)]) + create_order(c1, datetime.date(2026, 4, 1), [ + ('p1', 2, 100), ('p2', 1, 1)]) + create_order(c1, datetime.date(2026, 4, 2), [('p1', 2, 100)]) + + create_order(c2, datetime.date(2026, 1, 31), [('p1', 1, None)]) + create_order(c2, datetime.date(2026, 3, 31), [('p2', 1, None)]) + create_order(c2, datetime.date(2026, 4, 29), [ + ('p1', 5, 100), ('p2', 10, None)]) + create_order(c2, datetime.date(2026, 4, 30), [('p1', 1, 100)]) + + @skip_if(IS_PSYCOPG3) + def test_monthly_revenue(self): + revenue = fn.SUM(OrderItem.quantity * OrderItem.unit_price) + monthly = (Order + .select( + Order.order_date.truncate('month').alias('month'), + revenue.alias('revenue')) + .join(OrderItem, JOIN.LEFT_OUTER) + .group_by(Order.order_date.truncate('month')) + .cte('monthly_revenue')) + + window = Window(order_by=[monthly.c.month]) + query = (monthly + .select_from( + monthly.c.month.converter(Order.order_date.python_value), + monthly.c.revenue, + fn.SUM(monthly.c.revenue).over(window).alias('cum_rev'), + (monthly.c.revenue - + fn.LAG(monthly.c.revenue).over(window)).alias('mom_chg')) + .window(window) + .order_by(monthly.c.month)) + + data = list(query.tuples()) + self.assertEqual(data, [ + (datetime.date(2026, 1, 1), 200, 200, None), + (datetime.date(2026, 3, 1), 152, 352, -48), + (datetime.date(2026, 4, 1), 1021, 1373, 869), + ]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/tests/postgres.py new/peewee-4.0.5/tests/postgres.py --- old/peewee-4.0.4/tests/postgres.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/tests/postgres.py 2026-04-23 23:15:53.000000000 +0200 @@ -718,6 +718,26 @@ self.assertRaises(ProgrammingError, fails) +class Point(object): + def __init__(self, x, y): + self.x, self.y = x, y + def __eq__(self, other): + return (self.x, self.y) == (other.x, other.y) + +class CustomJsonField(BinaryJSONField): + def db_value(self, value): + if isinstance(value, Point): + value = {'x': value.x, 'y': value.y} + return super(CustomJsonField, self).db_value(value) + def python_value(self, value): + if value is not None: + return Point(**value) + +class CJM(TestModel): + name = TextField() + point = CustomJsonField() + + class TestJsonFieldRegressions(ModelTestCase): database = db requires = [JData] @@ -743,6 +763,29 @@ self.assertEqual(JD.d1.index_type, 'GIN') self.assertFalse(JD.d2.index) + @requires_models(CJM) + def test_json_field_subclass(self): + c1 = CJM.create(name='c1', point=Point(1, 2)) + c2 = CJM.insert(name='c2', point=Point(2, 3)).execute() + + c1_db = CJM.get(CJM.name == 'c1') + c2_db = CJM.get(CJM.name == 'c2') + self.assertEqual(c1_db.point, Point(1, 2)) + self.assertEqual(c2_db.point, Point(2, 3)) + + c2_db = CJM.select().where(CJM.point == Point(2, 3)).get() + self.assertEqual(c2_db.name, 'c2') + + CJM.update(point=Point(3, 4)).where(CJM.point == Point(1, 2)).execute() + c1, c2 = CJM.select().order_by(CJM.name) + self.assertEqual(c1.point, Point(3, 4)) + self.assertEqual(c2.point, Point(2, 3)) + + c1.point = Point(1.2, 2.5) + c1.save() + c1_db = CJM.get(CJM.name == 'c1') + self.assertEqual(c1_db.point, Point(1.2, 2.5)) + class TestJSONFieldCustomDumps(ModelTestCase): database = db diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/tests/schema.py new/peewee-4.0.5/tests/schema.py --- old/peewee-4.0.4/tests/schema.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/tests/schema.py 2026-04-23 23:15:53.000000000 +0200 @@ -15,12 +15,13 @@ from peewee import NodeList from .base import BaseTestCase -from .base import get_in_memory_db from .base import IS_CRDB from .base import IS_SQLITE from .base import ModelDatabaseTestCase from .base import ModelTestCase from .base import TestModel +from .base import get_in_memory_db +from .base import requires_postgresql from .base_models import Category from .base_models import Note from .base_models import Person @@ -1237,3 +1238,36 @@ TempModel._schema.drop_all() self.assertFalse(self.database.table_exists('temp_model')) + +@requires_postgresql +class TestSchemaGetIndexes(ModelTestCase): + def setUp(self): + super(TestSchemaGetIndexes, self).setUp() + queries = [ + 'create schema s1', 'create schema s2', + 'create table s1.t (c1 integer, c2 integer, c3 integer)', + 'create table s2.t (c1 integer, c2 integer, c3 integer)', + 'create index i1 on s1.t (c1, c2)', + 'create index i1 on s2.t (c1, c2)', + 'create index i2 on s1.t (c1)', + ] + with self.database: + for query in queries: + self.database.execute_sql(query) + + def tearDown(self): + with self.database: + self.database.execute_sql('drop schema s1 cascade') + self.database.execute_sql('drop schema s2 cascade') + super(TestSchemaGetIndexes, self).setUp() + + def test_schema_get_indexes(self): + tables = self.database.get_tables(schema='s1') + self.assertEqual(tables, ['t']) + idxs = self.database.get_indexes('t', schema='s1') + self.assertEqual([(i.name, i.columns) for i in idxs], + [('i1', ['c1', 'c2']), ('i2', ['c1'])]) + + idxs = self.database.get_indexes('t', schema='s2') + self.assertEqual([(i.name, i.columns) for i in idxs], + [('i1', ['c1', 'c2'])]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.4/tests/sql.py new/peewee-4.0.5/tests/sql.py --- old/peewee-4.0.4/tests/sql.py 2026-04-02 15:50:42.000000000 +0200 +++ new/peewee-4.0.5/tests/sql.py 2026-04-23 23:15:53.000000000 +0200 @@ -2573,7 +2573,7 @@ class TestOnConflictSqlite(BaseTestCase): database = SqliteDatabase(None) - def test_replace(self): + def test_replace_sqlite(self): query = Person.insert(name='huey').on_conflict('replace') self.assertSQL(query, ( 'INSERT OR REPLACE INTO "person" ("name") VALUES (?)'), ['huey']) @@ -2598,7 +2598,7 @@ super(TestOnConflictMySQL, self).setUp() self.database.server_version = None - def test_replace(self): + def test_replace_mysql(self): query = Person.insert(name='huey').on_conflict('replace') self.assertSQL(query, ( 'REPLACE INTO "person" ("name") VALUES (?)'), ['huey'])
