From 86b228a14795ac8390be25fb52827e317027f96c Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:03:50 +0800 Subject: [PATCH 1/6] handle disabling created_at or updated_at columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows the ‘date_created_at’ or ‘date_updated_at’ columns to be disabled individually per model. Throws an AttributeError if timestamps (__timestamps__ = True) and both the ‘date_xxx_at’ attribuses are not set --- src/masoniteorm/scopes/TimeStampsMixin.py | 12 ++++- src/masoniteorm/scopes/TimeStampsScope.py | 58 ++++++++++++++++------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/masoniteorm/scopes/TimeStampsMixin.py b/src/masoniteorm/scopes/TimeStampsMixin.py index 9970a1f5..9f23fe72 100644 --- a/src/masoniteorm/scopes/TimeStampsMixin.py +++ b/src/masoniteorm/scopes/TimeStampsMixin.py @@ -2,9 +2,19 @@ class TimeStampsMixin: - """Global scope class to add soft deleting to models.""" + """Global scope class to add automatic timestamps to models.""" def boot_TimeStampsMixin(self, builder): + if not self.__timestamps__: + return + + if not self.date_created_at and not self.date_updated_at: + raise AttributeError( + "Timestamps are enabled but not defined in the model. " + "Please define at least one of the 'date_created_at' or 'date_updated_at' attributes. " + "If you want to disable timestamps, set the '__timestamps__' attribute to False." + ) + builder.set_global_scope(TimeStampsScope()) def activate_timestamps(self, boolean=True): diff --git a/src/masoniteorm/scopes/TimeStampsScope.py b/src/masoniteorm/scopes/TimeStampsScope.py index e9da387e..e7a12bbd 100644 --- a/src/masoniteorm/scopes/TimeStampsScope.py +++ b/src/masoniteorm/scopes/TimeStampsScope.py @@ -3,45 +3,67 @@ class TimeStampsScope(BaseScope): - """Global scope class to add soft deleting to models.""" + """Global scope class to add automatic timestamps to a builder query.""" def on_boot(self, builder): - builder.set_global_scope( - "_timestamps", self.set_timestamp_create, action="insert" - ) + if not builder._model.__timestamps__: + return - builder.set_global_scope( - "_timestamp_update", self.set_timestamp_update, action="update" - ) + created_column = builder._model.date_created_at + updated_column = builder._model.date_updated_at + if created_column or updated_column: + builder.set_global_scope( + "_timestamps", self.set_timestamp_create, action="insert" + ) + + if updated_column: + builder.set_global_scope( + "_timestamp_update", self.set_timestamp_update, action="update" + ) def on_remove(self, builder): pass def set_timestamp(owner_cls, query): - owner_cls.updated_at = "now" + if owner_cls.date_updated_at: + column = owner_cls.date_updated_at + setattr(owner_cls, column, "now") def set_timestamp_create(self, builder): if not builder._model.__timestamps__: - return builder + return - builder._creates.update( - { - builder._model.date_updated_at: builder._model.get_new_date().to_datetime_string(), - builder._model.date_created_at: builder._model.get_new_date().to_datetime_string(), - } - ) + columns = {} + created_column = builder._model.date_created_at + if created_column: + columns[created_column] = ( + builder._model.get_new_date().to_datetime_string() + ) + + updated_column = builder._model.date_updated_at + if updated_column: + columns[updated_column] = ( + builder._model.get_new_date().to_datetime_string() + ) + + builder._creates.update(columns) def set_timestamp_update(self, builder): if not builder._model.__timestamps__: - return builder + return + + updated_column = builder._model.date_updated_at + if not updated_column: + return for update in builder._updates: - if builder._model.date_updated_at in update.column: + if updated_column in update.column: return + builder._updates += ( UpdateQueryExpression( { - builder._model.date_updated_at: builder._model.get_new_date().to_datetime_string() + updated_column: builder._model.get_new_date().to_datetime_string() } ), ) From 89d02e6d64ed1e9e80b6f97abd4dd0477f59d6e8 Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:04:32 +0800 Subject: [PATCH 2/6] added and updated tests --- .../scopes/test_can_use_global_scopes.py | 23 +------ tests/scopes/test_default_global_scopes.py | 60 ++++++++++++++----- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/tests/mysql/scopes/test_can_use_global_scopes.py b/tests/mysql/scopes/test_can_use_global_scopes.py index f259f6d6..63bec69b 100644 --- a/tests/mysql/scopes/test_can_use_global_scopes.py +++ b/tests/mysql/scopes/test_can_use_global_scopes.py @@ -20,24 +20,7 @@ def test_can_use_global_scopes_on_select(self): query_sql = UserSoft.where("name", "joe").to_sql() self.assertEqual(query_sql, expected_sql) - # def test_can_use_global_scopes_on_delete(self): - # expected_sql = "UPDATE `users` SET `users`.`deleted_at` = 'now' WHERE `users`.`name` = 'joe'" - # self.assertEqual( - # expected_sql, - # User.apply_scope(SoftDeletes) - # .where("name", "joe") - # .delete(query=True) - # .to_sql(), - # ) - def test_can_use_global_scopes_on_time(self): - expected_sql = "INSERT INTO `users` (`users`.`name`, `users`.`updated_at`, `users`.`created_at`) VALUES ('Joe'" - self.assertTrue( - User.create({"name": "Joe"}, query=True) - .to_sql() - .startswith(expected_sql) - ) - - # def test_can_use_global_scopes_on_inherit(self): - # sql = "SELECT * FROM `user_softs` WHERE `user_softs`.`deleted_at` IS NULL" - # self.assertEqual(sql, UserSoft.all(query=True)) + expected_sql = "INSERT INTO `users` (`users`.`name`, `users`.`created_at`, `users`.`updated_at`) VALUES ('Joe'" + query_sql = User.create({"name": "Joe"}, query=True).to_sql() + self.assertTrue(query_sql.startswith(expected_sql)) diff --git a/tests/scopes/test_default_global_scopes.py b/tests/scopes/test_default_global_scopes.py index 710fe27b..c1f6df88 100644 --- a/tests/scopes/test_default_global_scopes.py +++ b/tests/scopes/test_default_global_scopes.py @@ -2,13 +2,13 @@ import unittest import uuid +from unittest.mock import patch import pendulum from src.masoniteorm.models import Model from src.masoniteorm.scopes import ( SoftDeletesMixin, - TimeStampsMixin, TimeStampsScope, UUIDPrimaryKeyMixin, UUIDPrimaryKeyScope, @@ -26,11 +26,11 @@ class UserWithUUID(Model, UUIDPrimaryKeyMixin): __dry__ = True -class UserWithTimeStamps(Model, TimeStampsMixin): +class UserWithTimeStamps(Model): __dry__ = True -class UserWithCustomTimeStamps(Model, TimeStampsMixin): +class UserWithCustomTimeStamps(Model): __dry__ = True date_updated_at = "updated_ts" date_created_at = "created_ts" @@ -101,33 +101,52 @@ class TestTimeStampsScope(unittest.TestCase): def setUp(self): self.builder = MockBuilder(UserWithTimeStamps) self.scope = TimeStampsScope() - try: - del UserWithTimeStamps.__timestamps__ - except Exception: - pass + # try: + # del UserWithTimeStamps.__timestamps__ + # except Exception: + # pass def test_updated_and_created_dates_are_set_when_create(self): self.scope.set_timestamp_create(self.builder) self.assertIn("created_at", self.builder._creates) self.assertIn("updated_at", self.builder._creates) created_at = pendulum.parse(self.builder._creates["created_at"]) - updated_at = pendulum.parse(self.builder._creates["updated_at"]) self.assertIsInstance(created_at, pendulum.DateTime) + updated_at = pendulum.parse(self.builder._creates["updated_at"]) self.assertIsInstance(updated_at, pendulum.DateTime) def test_timestamps_can_be_disabled(self): - UserWithTimeStamps.__timestamps__ = False - self.scope.set_timestamp_create(self.builder) - self.assertNotIn("created_at", self.builder._creates) - self.assertNotIn("updated_at", self.builder._creates) + with patch.object(UserWithTimeStamps, "__timestamps__", False): + self.assertFalse(self.builder._model.__timestamps__) + self.scope.set_timestamp_create(self.builder) + self.assertNotIn("created_at", self.builder._creates) + self.assertNotIn("updated_at", self.builder._creates) + + def test_created_at_timestamp_can_be_disabled(self): + with patch.object(UserWithTimeStamps, "date_created_at", None): + self.assertIsNone(self.builder._model.date_created_at) + self.scope.set_timestamp_create(self.builder) + self.assertNotIn("created_at", self.builder._creates) + self.assertIn("updated_at", self.builder._creates) + + def test_updated_at_timestamp_can_be_disabled(self): + with patch.object(UserWithTimeStamps, "date_updated_at", None): + self.assertIsNone(self.builder._model.date_updated_at) + self.scope.set_timestamp_create(self.builder) + self.assertIn("created_at", self.builder._creates) + self.assertNotIn("updated_at", self.builder._creates) def test_uses_custom_timestamp_columns_on_create(self): self.builder = MockBuilder(UserWithCustomTimeStamps) + created_column = self.builder._model.date_created_at + updated_column = self.builder._model.date_updated_at self.scope.set_timestamp_create(self.builder) - created_column = UserWithCustomTimeStamps.date_created_at - updated_column = UserWithCustomTimeStamps.date_updated_at - self.assertNotIn("created_at", self.builder._creates) - self.assertNotIn("updated_at", self.builder._creates) + self.assertNotIn( + UserWithTimeStamps.date_created_at, self.builder._creates + ) + self.assertNotIn( + UserWithTimeStamps.date_updated_at, self.builder._creates + ) self.assertIn(created_column, self.builder._creates) self.assertIn(updated_column, self.builder._creates) self.assertIsInstance( @@ -139,6 +158,15 @@ def test_uses_custom_timestamp_columns_on_create(self): pendulum.DateTime, ) + def test_enabked_timestamos_throw_if_both_missing(self): + class BrokenTimestampsSetup(Model): + __dry__ = True + date_created_at = None + date_updated_at = None + + with self.assertRaises(AttributeError): + BrokenTimestampsSetup() + def test_uses_custom_updated_column_on_update(self): user = UserWithCustomTimeStamps.hydrate({"id": 1}) query_sql = user.update({"id": 2}).to_sql() From 33e7aee46f19246cbf52564374e5ae25783587e3 Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:55:45 +0800 Subject: [PATCH 3/6] Added customising timestamps during migation --- src/masoniteorm/schema/Blueprint.py | 13 ++++++++++--- tests/mysql/schema/test_mysql_schema_builder.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/masoniteorm/schema/Blueprint.py b/src/masoniteorm/schema/Blueprint.py index f3af26bf..452bdcf5 100644 --- a/src/masoniteorm/schema/Blueprint.py +++ b/src/masoniteorm/schema/Blueprint.py @@ -383,15 +383,22 @@ def timestamp(self, column, nullable=False, now=False): return self - def timestamps(self): + def timestamps(self, created_at="created_at", updated_at="updated_at"): """Creates `created_at` and `updated_at` timestamp columns. Returns: self """ - self.datetime("created_at", nullable=True, now=True) + if not created_at and not updated_at: + raise Exception( + "Invalid 'timestamps' arguments, 'created_at' and/or 'updated_at' parameters are required" + ) + + if created_at: + self.datetime(created_at, nullable=True, now=True) - self.datetime("updated_at", nullable=True, now=True) + if updated_at: + self.datetime(updated_at, nullable=True, now=True) return self diff --git a/tests/mysql/schema/test_mysql_schema_builder.py b/tests/mysql/schema/test_mysql_schema_builder.py index a5509a0f..76a92854 100644 --- a/tests/mysql/schema/test_mysql_schema_builder.py +++ b/tests/mysql/schema/test_mysql_schema_builder.py @@ -140,9 +140,9 @@ def test_can_advanced_table_creation(self): blueprint.string("option").default("ADMIN") blueprint.string("remember_token").nullable() blueprint.timestamp("verified_at").nullable() - blueprint.timestamps() + blueprint.timestamps(updated_at=False) - self.assertEqual(len(blueprint.table.added_columns), 14) + self.assertEqual(len(blueprint.table.added_columns), 13) self.assertEqual( blueprint.to_sql(), [ @@ -151,11 +151,18 @@ def test_can_advanced_table_creation(self): "`name` VARCHAR(255) NOT NULL, `active` TINYINT(1) NOT NULL, `email` VARCHAR(255) NOT NULL, `gender` ENUM('male', 'female') NOT NULL, " "`password` VARCHAR(255) NOT NULL, `money` DECIMAL(17, 6) NOT NULL, " "`admin` INT(11) NOT NULL DEFAULT 0, `option` VARCHAR(255) NOT NULL DEFAULT 'ADMIN', `remember_token` VARCHAR(255) NULL, `verified_at` TIMESTAMP NULL, " - "`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, " + "`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, " "CONSTRAINT users_email_unique UNIQUE (email))" ], ) + def test_raise_on_empty_timestamps(self): + with self.assertRaises(Exception): + with self.schema.create("users") as blueprint: + blueprint.increments("id") + blueprint.string("name") + blueprint.timestamps(created_at=False, updated_at=False) + def test_can_add_primary_constraint_without_column_name(self): with self.schema.create("users") as blueprint: blueprint.integer("user_id").primary() From 8165683ccc441b2943827a119810face50d51696 Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:14:19 +0800 Subject: [PATCH 4/6] fix postgres alter timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix altercolumn was adding length even if not set also fixed premapped defaults ignored on altered columns eg “current”, now --- .../schema/platforms/PostgresPlatform.py | 18 ++++++++++-------- .../test_postgres_schema_builder_alter.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/masoniteorm/schema/platforms/PostgresPlatform.py b/src/masoniteorm/schema/platforms/PostgresPlatform.py index 6ec55150..261d13d5 100644 --- a/src/masoniteorm/schema/platforms/PostgresPlatform.py +++ b/src/masoniteorm/schema/platforms/PostgresPlatform.py @@ -288,7 +288,6 @@ def compile_alter_sql(self, table): columns=", ".join(dropped_sql), ) ) - if table.changed_columns: changed_sql = [] @@ -297,18 +296,18 @@ def compile_alter_sql(self, table): if column.column_type == "enum": values = ", ".join(f"'{x}'" for x in column.values) column_constraint = f" CHECK({column.name} IN ({values}))" + length = "" + if column.length: + length = self.create_column_length( + column.column_type + ).format(length=column.length) changed_sql.append( self.modify_column_string() .format( name=self.wrap_column(name), data_type=self.type_map.get(column.column_type), nullable="NULL" if column.is_null else "NOT NULL", - length=( - "(" + str(column.length) + ")" - if column.column_type - not in self.types_without_lengths - else "" - ), + length=length, column_constraint=column_constraint, constraint="PRIMARY KEY" if column.primary else "", ) @@ -325,8 +324,11 @@ def compile_alter_sql(self, table): ) if column.default is not None: + default = f" DEFAULT {column.default}" + if column.default in self.premapped_defaults: + default = self.premapped_defaults[column.default] changed_sql.append( - f"ALTER COLUMN {self.wrap_column(name)} SET DEFAULT {column.default}" + f"ALTER COLUMN {self.wrap_column(name)} SET{default}" ) sql.append( diff --git a/tests/postgres/schema/test_postgres_schema_builder_alter.py b/tests/postgres/schema/test_postgres_schema_builder_alter.py index 7bfb71af..a5c7b789 100644 --- a/tests/postgres/schema/test_postgres_schema_builder_alter.py +++ b/tests/postgres/schema/test_postgres_schema_builder_alter.py @@ -322,6 +322,19 @@ def test_timestamp_alter_add_nullable_column(self): query_sql = blueprint.to_sql() self.assertEqual(query_sql, expected_sql) + def test_alter_existing_timestamp(self): + with self.schema.table("users") as blueprint: + blueprint.timestamp("created_at", now=True).change() + query_sql = blueprint.to_sql() + expected_sql = [ + 'ALTER TABLE "users" ' + 'ALTER COLUMN "created_at" TYPE TIMESTAMP, ' + 'ALTER COLUMN "created_at" SET NOT NULL, ' + 'ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP' + ] + + self.assertEqual(query_sql, expected_sql) + def test_alter_drop_on_table_schema_table(self): with self.schema.table("table_schema") as blueprint: blueprint.drop_column("name") From 3b221aae7f3b63da90c0ee8d597febf3b155ea69 Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:21:26 +0800 Subject: [PATCH 5/6] added type hints for softDeleteMixin --- src/masoniteorm/scopes/SoftDeletesMixin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/masoniteorm/scopes/SoftDeletesMixin.py b/src/masoniteorm/scopes/SoftDeletesMixin.py index dddf202e..1809b5a3 100644 --- a/src/masoniteorm/scopes/SoftDeletesMixin.py +++ b/src/masoniteorm/scopes/SoftDeletesMixin.py @@ -1,5 +1,10 @@ +from typing import TYPE_CHECKING + from .SoftDeleteScope import SoftDeleteScope +if TYPE_CHECKING: + from ..query import QueryBuilder + class SoftDeletesMixin: """Global scope class to add soft deleting to models.""" @@ -9,5 +14,16 @@ class SoftDeletesMixin: def boot_SoftDeletesMixin(self, builder): builder.set_global_scope(SoftDeleteScope(self.__deleted_at__)) + if TYPE_CHECKING: + + @staticmethod + def with_trashed() -> QueryBuilder: ... + @staticmethod + def only_trashed() -> QueryBuilder: ... + @staticmethod + def force_delete() -> QueryBuilder: ... + @staticmethod + def restore() -> QueryBuilder: ... + def get_deleted_at_column(self): return self.__deleted_at__ From 0f1016b688d8427010e3936a8195141db404c09a Mon Sep 17 00:00:00 2001 From: Kieren Eaton <499977+circulon@users.noreply.github.com> Date: Wed, 12 Aug 2026 07:29:31 +0800 Subject: [PATCH 6/6] corrected SoftDeleteMixin stabs use @classmethod instead of @staticmethod for stubs added docstrings to stubs --- src/masoniteorm/scopes/SoftDeletesMixin.py | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/masoniteorm/scopes/SoftDeletesMixin.py b/src/masoniteorm/scopes/SoftDeletesMixin.py index 1809b5a3..61a88e58 100644 --- a/src/masoniteorm/scopes/SoftDeletesMixin.py +++ b/src/masoniteorm/scopes/SoftDeletesMixin.py @@ -16,14 +16,25 @@ def boot_SoftDeletesMixin(self, builder): if TYPE_CHECKING: - @staticmethod - def with_trashed() -> QueryBuilder: ... - @staticmethod - def only_trashed() -> QueryBuilder: ... - @staticmethod - def force_delete() -> QueryBuilder: ... - @staticmethod - def restore() -> QueryBuilder: ... + @classmethod + def with_trashed(cls) -> QueryBuilder: + """Include records flagged as deleted""" + ... + + @classmethod + def only_trashed(cls) -> QueryBuilder: + """Filter for records marked as deleted""" + ... + + @classmethod + def force_delete(cls) -> QueryBuilder: + """Remove the record from the table""" + ... + + @classmethod + def restore(cls) -> QueryBuilder: + """Mark the record as not deleted""" + ... def get_deleted_at_column(self): return self.__deleted_at__