From 8b788af26d9d5e4bc255fde8f5988301f213c6bc Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 23:53:21 +0800 Subject: [PATCH 01/65] fix(types): refuse a non-boolean unsigned when the type is rendered The shape check now lives where the flag has to be honoured rather than where it is declared, so a value that would silently render the wrong DDL is named instead. The attribute is read directly: whether the field exists is settled by the class, so probing for it with a default would only turn a wrong class into a quiet signed column. CI installs the core library from fix/column-constraint-gates so this backend runs against the branch it depends on. --- .github/workflows/test.yml | 6 +-- .../backend/impl/mysql/mixins/types.py | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfe75766..8f48a46c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create and test database @@ -252,7 +252,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create and test database @@ -359,7 +359,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create and test database diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py index 84fc464d..9ea6d986 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py @@ -106,6 +106,36 @@ def format_data_type_mysql_unsigned(self, data_type: MySQLUnsignedType) -> Tuple """ return "UNSIGNED", () + def _check_unsigned(self, data_type: DataType) -> None: + """Raise ``TypeError`` unless ``unsigned`` is a real ``bool``. + + ``unsigned`` is a *field* on every numeric concept here and every reader + below turns it into the ``UNSIGNED`` word of the rendered DDL. The + expressions collect it without judging it — a value is not a wrong value + until a backend has to honour it, and this is that backend — so the + judgement belongs at this end, which is why each reader calls this before + it reads. + + Without it a truthy non-bool renders silently: ``unsigned="yes"`` writes + ``INT UNSIGNED`` and reports success for a column nobody declared. That is + the same silent loss the dummy dialect's ``_refuse_unsigned`` names when + it refuses the flag outright, arriving from the opposite direction. + + The attribute is read directly rather than through ``getattr(..., False)``: + the field is part of every concept routed to a reader here, and probing + for it would turn "caller passed the wrong class" into the same silent + render this guard exists to prevent. + + Raises: + TypeError: naming the type actually supplied. + """ + unsigned = data_type.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + def _format_mysql_integer(self, sql: str, data_type: DataType) -> Tuple[str, tuple]: """Append the ``UNSIGNED`` / ``ZEROFILL`` attributes in MySQL's order. @@ -132,6 +162,7 @@ def _format_mysql_integer(self, sql: str, data_type: DataType) -> Tuple[str, tup one the caller declared. Emitting both is what makes ``parse_type(t.to_sql()[0]) == t`` hold for every combination. """ + self._check_unsigned(data_type) if data_type.unsigned: sql = f"{sql} UNSIGNED" if data_type.zerofill: @@ -405,6 +436,7 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ self._check_spelling(data_type, IntegerType) + self._check_unsigned(data_type) return (f"INT UNSIGNED" if data_type.unsigned else "INT"), () def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: @@ -421,6 +453,7 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ self._check_spelling(data_type, BigIntType) + self._check_unsigned(data_type) return ("BIGINT UNSIGNED" if data_type.unsigned else "BIGINT"), () def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: @@ -437,6 +470,7 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ self._check_spelling(data_type, SmallIntType) + self._check_unsigned(data_type) return ("SMALLINT UNSIGNED" if data_type.unsigned else "SMALLINT"), () def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: @@ -453,6 +487,7 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ self._check_spelling(data_type, TinyIntType) + self._check_unsigned(data_type) return ("TINYINT UNSIGNED" if data_type.unsigned else "TINYINT"), () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: @@ -592,6 +627,7 @@ def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: f"storage — declare DoubleType(dialect). " f"Got precision={data_type.precision}." ) + self._check_unsigned(data_type) return ("FLOAT UNSIGNED" if data_type.unsigned else "FLOAT"), () def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: @@ -630,6 +666,7 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: https://dev.mysql.com/doc/refman/9.7/en/numeric-type-syntax.html """ self._check_spelling(data_type, DoubleType) + self._check_unsigned(data_type) return ("DOUBLE UNSIGNED" if data_type.unsigned else "DOUBLE"), () def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: @@ -689,6 +726,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: base = f"DECIMAL({data_type.precision})" else: base = "DECIMAL" + self._check_unsigned(data_type) return (f"{base} UNSIGNED" if data_type.unsigned else base), () def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: From 709544bb67afb711382414cb4f886e2218c63237 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 12 Oct 2026 00:08:02 +0800 Subject: [PATCH 02/65] test(types): assert the unsigned refusal where it now happens The shape check moved from the constructor to the formatter, so a case that still expected the constructor to raise was asserting a guarantee the code no longer makes -- and passing for the wrong reason would have left the silent signed column unguarded. Each case now checks both halves: the declaration keeps the value it was handed, and the dialect names it. --- .../backend/test_mysql_type_protocol.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index 2a7ab524..b7be93db 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -956,12 +956,15 @@ def test_get_params_reports_both_mysql_attributes(self, dialect): def test_unsigned_is_type_checked(self, dialect): """``unsigned`` becomes a type modifier in the rendered DDL. - The core integer class accepts only a real ``bool``, so - ``unsigned="yes"`` is an error rather than a truthy string reaching - ``BIGINT yes UNSIGNED``. + The refusal is the **formatter's**, because only the backend that has + to render the flag knows whether it can honour it. The expression + collects ``unsigned`` verbatim -- a truthy string must never reach + ``BIGINT yes UNSIGNED``, and saying so is not optional. """ + declared = MySQLBigIntType(dialect, unsigned="yes") + assert declared.unsigned == "yes" with pytest.raises(TypeError, match="unsigned must be a bool"): - MySQLBigIntType(dialect, unsigned="yes") + dialect.format_data_type(declared) @pytest.mark.parametrize( "cls,base", @@ -1075,11 +1078,17 @@ def test_signedness_is_part_of_identity(self, dialect, klass, kwargs, def test_unsigned_is_type_checked(self, dialect, klass, kwargs, signed_sql, unsigned_sql): """It becomes a type modifier in the rendered DDL, so ``1`` is not a - truthy ``True`` and ``"yes"`` is not a spelling of one.""" - with pytest.raises(TypeError, match="unsigned must be a bool"): - klass(dialect, unsigned=1, **kwargs) - with pytest.raises(TypeError, match="unsigned must be a bool"): - klass(dialect, unsigned="yes", **kwargs) + truthy ``True`` and ``"yes"`` is not a spelling of one. + + Construction collects and rendering judges, so each case is asserted + twice: the declaration keeps the value it was handed, and the dialect + names it rather than rendering a signed column for it. + """ + for wrong in (1, "yes"): + declared = klass(dialect, unsigned=wrong, **kwargs) + assert declared.unsigned == wrong + with pytest.raises(TypeError, match="unsigned must be a bool"): + dialect.format_data_type(declared) @pytest.mark.parametrize("klass,kwargs,signed_sql,unsigned_sql", CASES, ids=IDS) From 42a3eeb09b37c9af62f8886c65dcb00ea756b080 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 12 Oct 2026 00:11:42 +0800 Subject: [PATCH 03/65] test(types): refuse the non-boolean flag at the render, for MEDIUMINT too The same case as the other widths, which the previous commit moved onto the formatter. It had been missed here: the declaration now keeps the value it was handed, and the dialect is the one that names it. --- .../feature/backend/test_mysql_type_protocol.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index b7be93db..1f9c6548 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -1268,11 +1268,18 @@ def test_takes_no_spelling_and_does_not_inherit_the_four_byte_words(self): assert MySQLMediumIntType(dialect=None).spelling == "mediumint" def test_unsigned_is_handled_like_its_siblings(self, dialect): - """Same field, same type check, same place in identity.""" + """Same field, same place in identity, same refusal. + + The shape check belongs to the formatter that has to honour the flag, + so it fires on ``format_data_type`` rather than on construction -- which + is what the other widths in this file are already asserting. + """ assert MySQLMediumIntType.PARAMETERS == ("unsigned", "zerofill") assert MySQLMediumIntType(dialect).identity() == (False, False) + declared = MySQLMediumIntType(dialect, unsigned="yes") + assert declared.unsigned == "yes" with pytest.raises(TypeError, match="unsigned must be a bool"): - MySQLMediumIntType(dialect, unsigned="yes") + dialect.format_data_type(declared) class TestCastTargets: From 6a7026792bfe96a9492711b644e246b5c98e8e08 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 13 Oct 2026 10:47:51 +0800 Subject: [PATCH 04/65] fix(mysql): converge the constraint switches onto the (self) probe protocol supports_alter_constraint_enforced answers the verb's existence and is version-gated ((self), 8.0.16+); the CHECK-only narrowing it used to carry as a per-kind parameter moved into this backend's formatters, where the server's own refusal (errno 3941 names PRIMARY, UNIQUE and FOREIGN KEY) becomes a refusal by name: * format_alter_constraint_action refuses every kind but CHECK before the shared rendering runs; * _format_constraint_enforcement narrows the column path the same way; * the table path's format_table_constraint used to drop an ENFORCED request on PRIMARY KEY / UNIQUE / FOREIGN KEY silently -- it now refuses by name like its own DEFERRABLE branches do. The signature sniffing (normalize_table_constraint_type from core) went with the parameter: the core deleted it, and the probe reads no kind. Tests follow the contract: the value table gains the alter-enforcement switch, per-kind assertions became formatter-level refusals, and a string kind is the constructor's to normalise, reaching the formatter as the enum. --- .../backend/impl/mysql/mixins/constraint.py | 572 +++++++++++++++++- .../backend/impl/mysql/mixins/ddl_table.py | 25 +- .../backend/test_mysql_constraint_switches.py | 292 +++++++++ .../backend/test_mysql_ddl_improvements.py | 189 ++++++ 4 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index 65ca4d9d..33f8ff18 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -1,6 +1,47 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterConstraint, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ) + + class MySQLConstraintMixin: - """MySQL constraint feature support.""" + """MySQL constraint capability probes. + + Every verdict in this mixin was measured against a live server, and the + docstring of a switch records what the server did with the exact spelling + in question. Two of those measurements explain why several answers are + ``False`` even though the statement is *accepted*: + + * ``MATCH`` parses and is then discarded, so rendering the clause would + tell the caller it is in effect when nothing enforces it. + * ``ENFORCED`` is accepted but renders identically to the default, so the + rendered DDL cannot show that it was written. + + The third is that ``ALTER CONSTRAINT`` exists in exactly one form, for + exactly one kind of constraint, and that a primary key is not in the + constraint-name space that ``DROP CONSTRAINT`` addresses. + + **Every constraint verdict this backend relies on is declared *here*.** + The values below agree with core's ``ConstraintMixin`` defaults, but the + agreement is the point rather than an accident: a switch this backend + depends on must resolve to a class in this package, so a core default that + moves cannot silently move MySQL's contract with it. ``test_constraint_ + switches_are_declared_locally`` in this backend's test suite fails if any + of them is ever dropped and falls back to inheriting. + + A note on MariaDB, which is **not** this backend and must never be folded + into it: MariaDB 13.1 rejects ``[NOT] ENFORCED`` outright (this MySQL + accepts it on CHECK), and MariaDB *does* accept ``DROP CONSTRAINT IF + EXISTS`` (MySQL answers ``1064``). The two disagree on exactly the two + clauses a caller would most expect to be portable, so each verdict below + is stated for MySQL only. + """ def supports_invisible_index(self) -> bool: """Whether INVISIBLE indexes are supported.""" @@ -14,18 +55,539 @@ def supports_functional_index(self) -> bool: """Whether functional (expression) indexes are supported.""" return self.version >= (8, 0, 0) + # Basic constraint types (SQL-86/SQL-92) + + def supports_primary_key_constraint(self) -> bool: + """Whether PRIMARY KEY constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY (v)`` is accepted and + rendered back by ``SHOW CREATE TABLE`` as ``PRIMARY KEY (`v`)``. + + Two measured details belong to the *name space*, not to this switch, + and are recorded in :meth:`supports_drop_constraint` and + :meth:`supports_alter_constraint_enforced`: MySQL discards the + ``CONSTRAINT pk`` symbol for a primary key (the rendered DDL contains + no ``pk``), and a second one is refused with + ``1068 (42000): Multiple primary key defined``. + """ + return True + + def supports_unique_constraint(self) -> bool: + """Whether UNIQUE constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT uq UNIQUE (v)`` is accepted, the symbol + survives in ``SHOW CREATE TABLE`` as ``CONSTRAINT `uq` UNIQUE (`v`)``, + and ``ALTER TABLE t DROP CONSTRAINT uq`` removes it. + """ + return True + + def supports_not_null_constraint(self) -> bool: + """Whether NOT NULL column constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``CREATE TABLE t (x INT NOT NULL)`` is accepted and renders + ``x int NOT NULL``. Trivial for the server, but declared here so the + verdict is this backend's rather than an inherited default. + """ + return True + + def supports_foreign_key_constraint(self) -> bool: + """Whether FOREIGN KEY constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id)`` + is accepted, rendered back, and enforced -- a dangling child row is + refused with + ``1452 (23000): Cannot add or update a child row: a foreign key + constraint fails``, and adding the constraint over such a row fails the + same way. + """ + return True + + def supports_fk_on_delete(self) -> bool: + """Whether ``ON DELETE`` referential actions are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): all five + ``reference_option`` values are accepted on ``ADD CONSTRAINT``, and + ``information_schema.REFERENTIAL_CONSTRAINTS.DELETE_RULE`` reads back + ``CASCADE`` for the action that was written. + + Independent of :meth:`supports_fk_match`, which is ``False``: that + clause is discarded, while these actions survive even when a ``MATCH`` + is written alongside them (measured -- see ``supports_fk_match``). + """ + return True + + def supports_fk_on_update(self) -> bool: + """Whether ``ON UPDATE`` referential actions are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): ``ON + UPDATE CASCADE`` is accepted and + ``information_schema.REFERENTIAL_CONSTRAINTS.UPDATE_RULE`` reads back + ``CASCADE``. Measured even together with ``MATCH FULL``, where the + manual claims the referential actions are ignored (see + :meth:`supports_fk_match`). + """ + return True + + def supports_exclude_constraint(self) -> bool: + """Whether EXCLUDE table constraints are supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT ex EXCLUDE (a WITH =)`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'E' + + This is a backend-owned ``False`` rather than the inherited default, so + that no core change can hand MySQL a constraint kind it cannot spell. + """ + return False + def supports_check_constraint(self) -> bool: - """Whether CHECK constraints are enforced.""" + """Whether CHECK constraints are supported and enforced. + + Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT ck CHECK (v > 0)`` is accepted and + rendered back by ``SHOW CREATE TABLE`` as + ``CONSTRAINT ck CHECK ((v > 0))``, and it is really enforced -- adding + it over a row that violates it is refused with + ``3819 (HY000): Check constraint 'ck3' is violated.`` and a violating + insert afterwards with ``3819 (HY000): Check constraint 'ck3' is + violated.`` (measured). The same addition with ``NOT ENFORCED`` is + accepted over the violating row and admits it. + + Below 8.0.16 the clause parses and is discarded, so the probe is + version-gated rather than a flat ``True``. + """ + return self.version >= (8, 0, 16) + + def supports_add_constraint(self) -> bool: + """Whether ``ALTER TABLE ADD CONSTRAINT`` is supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): all + four kinds are accepted by the ALTER path -- ``ADD CONSTRAINT ck + CHECK (v > 0)``, ``ADD CONSTRAINT pk PRIMARY KEY (id)`` (on a table + that has no primary key yet; a second one is refused with + ``1068 (42000): Multiple primary key defined``), ``ADD CONSTRAINT uq + UNIQUE (v)`` and ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p + (id) ON DELETE CASCADE`` each succeed and are rendered back by + ``SHOW CREATE TABLE``. + + Adding validates the rows already present: a CHECK over a violating + row fails with ``3819 (HY000)`` and a FOREIGN KEY over a dangling + row with ``1452 (23000): Cannot add or update a child row: a foreign + key constraint fails``. There is therefore no "add first, check + later" spelling here (see ``supports_constraint_novalidate``), and the + clause-level attributes are not additive (see + ``supports_deferrable_constraint``). + """ + return True + + def supports_drop_constraint(self) -> bool: + """Whether ``ALTER TABLE DROP CONSTRAINT`` is supported. + + True, but the name space is narrower than it looks. Measured on + Community Server 26.7.0 (192.168.1.3:14686): + + * ``DROP CONSTRAINT uq`` (UNIQUE) and ``DROP CONSTRAINT fk`` + (FOREIGN KEY) both work. + * ``DROP CONSTRAINT pk2`` answers + ``3940 (HY000): Constraint 'pk2' does not exist.`` even though the + table really does have that primary key -- ``SHOW CREATE TABLE`` + renders ``PRIMARY KEY (`v`)`` on a table where + ``ADD CONSTRAINT pk2 PRIMARY KEY (v)` had just succeeded. The + symbol ``pk2`` never appears anywhere in the rendered DDL: MySQL + drops the ``CONSTRAINT`` symbol for a PRIMARY KEY outright, so the + name space ``DROP CONSTRAINT`` addresses never contained it. The + working spelling is ``ALTER TABLE t DROP PRIMARY KEY`` (measured -- + it removes the key). This is a genuine per-kind gap: one boolean + cannot say "drop everything except the primary key", and a caller + that treats ``supports_drop_constraint() is True`` as "any named + constraint can be dropped" is wrong for that one kind. + * Dropping an FK leaves its auto-created helper index behind: + ``KEY fkm (d)`` survives ``DROP CONSTRAINT fkm``, with the + ``CONSTRAINT ... FOREIGN KEY`` line gone (measured). + * A missing name answers ``3940 (HY000): Constraint 'x' does not + exist.`` while ``DROP CHECK x`` answers ``3821 (HY000): Check + constraint 'x' is not found in the table.`` -- the same "not + there" outcome under **two different errnos**, so error mapping + keyed on a single errno silently misses half the cases. Neither can + be caught in a way that makes the drop re-runnable (see + ``supports_drop_constraint_if_exists``). + + MariaDB 13.1 diverges from this backend on the idempotent spelling -- + it *does* accept ``DROP CONSTRAINT IF EXISTS`` -- so a value copied + from the MariaDB backend into this one would be wrong. See + :meth:`supports_drop_constraint_if_exists`. + """ + return True + + def supports_drop_constraint_if_exists(self) -> bool: + """Whether ``ALTER TABLE DROP CONSTRAINT IF EXISTS`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t DROP CONSTRAINT IF EXISTS x`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'IF EXISTS x' at line 1 + + The failure is in the parser, before the name is looked up, so no + errno can be caught to make a drop idempotent: **there is no + idempotent drop spelling for a MySQL constraint at all.** + ``MySQLDDLColumnMixin.format_drop_table_constraint_action`` refuses + the ``if_exists=True`` request by name for this reason; pre-check + ``information_schema.TABLE_CONSTRAINTS`` instead. + + MariaDB 13.1 measured ``True`` for the same switch, so the two must not + be conflated: this is MySQL only. + """ + return False + + def supports_rename_constraint(self) -> bool: + """Whether ``ALTER TABLE ... RENAME CONSTRAINT ... TO ...`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t RENAME CONSTRAINT ck TO ck2`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'CONSTRAINT ck TO ck2' at line 1 + + The failure is at the parser, before either name is resolved, so + renaming has to be done as a drop-and-re-add. + + The name of this switch is provisional: core S3 owns the final + ``AlterTableConstraintSupport`` spelling and may rename it. + """ + return False + + def supports_enable_disable_constraint(self) -> bool: + """Whether ``ALTER TABLE ... {ENABLE|DISABLE} CONSTRAINT`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), both + halves are parser errors: + + 1064 (42000): ... near 'CONSTRAINT ck' at line 1 + + ``ENABLE`` and ``DISABLE`` are parseable keywords in MySQL (they belong + to ``ENABLE KEYS``, ``DISABLE KEYS`` and to the ``ALTER TABLE + ENABLE|DISABLE KEYS`` forms), so the failure lands on the trailing + ``CONSTRAINT ck`` rather than on the keyword -- worth knowing when + reading the error, which does not name the real problem. + + Only Oracle has this of the backends measured. The name of this switch + is provisional: core S3 owns the final spelling and may rename it. + """ + return False + + def supports_constraint_novalidate(self) -> bool: + """Whether ``ADD CONSTRAINT ... NOT VALID`` is accepted. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT ck CHECK (v > 0) NOT VALID`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'VALID' at line 1 + + and the same for ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p + (id) NOT VALID``. There is nothing to defer to: an added CHECK is + validated against the rows already present (``3819``) and so is an + added FOREIGN KEY (``1452``). + """ + return False + + def supports_validate_constraint(self) -> bool: + """Whether ``ALTER TABLE ... VALIDATE CONSTRAINT`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t VALIDATE CONSTRAINT ck`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'VALIDATE CONSTRAINT ck' at line 1 + + MySQL has no deferred-constraint state to validate -- see + ``supports_deferrable_constraint`` and + ``supports_constraint_novalidate``, both refused by the same 1064. + """ + return False + + def supports_alter_constraint_enforced(self) -> bool: + """Whether the ``ALTER CONSTRAINT ... [NOT] ENFORCED`` verb exists. + + Version-only, per the rule that a switch answers a family and the + kinds it can address are narrowed in the formatter: this backend's + :meth:`format_alter_constraint_action` refuses every kind but CHECK + by name before the shared rendering runs. + + The verb exists from 8.0.16 on. Measured on Community Server 26.7.0 + (192.168.1.3:14686): + + * ``ALTER TABLE t ALTER CONSTRAINT ck NOT ENFORCED`` is accepted and + ``SHOW CREATE TABLE`` then renders + ``CONSTRAINT `ck` CHECK ((`v` > 0)) /*!80016 NOT ENFORCED */``. + * ``ALTER TABLE t ALTER CONSTRAINT ck ENFORCED`` is accepted and the + marker is dropped again -- but note the asymmetry with + :meth:`supports_constraint_enforced`: here ``ENFORCED`` is at + least *observable* as the loss of the ``NOT ENFORCED`` marker, + whereas on the ``ADD`` path a freshly written ``ENFORCED`` leaves + DDL that is identical to a constraint where the clause was never + typed. Either way the rendered DDL cannot prove ``ENFORCED`` was + written. + * On a FOREIGN KEY the server answers, rather than the parser: + ``3941 (HY000): Altering constraint enforcement is not supported + for the constraint 'fkZ'. Enforcement state alter is not supported + for the PRIMARY, UNIQUE and FOREIGN KEY type constraints.`` (errno + ``3941``, measured). The server itself names the three kinds it + refuses, which is the evidence the formatter's CHECK-only + narrowing stands on. + + ``ALTER CONSTRAINT ck [NOT] ENFORCED`` is the **only** working + ``ALTER CONSTRAINT`` form MySQL has (see + :meth:`supports_alter_constraint`); ``ALTER CONSTRAINT ck + DEFERRABLE`` is a parser error (1064) and belongs to + :meth:`supports_deferrable_constraint`, not here. + + MariaDB 13.1 has no ``ALTER CONSTRAINT`` at all and rejects + ``[NOT] ENFORCED`` on every path, so this ``True`` is MySQL only. + """ return self.version >= (8, 0, 16) + def format_alter_constraint_action( + self, action: "AlterConstraint" + ) -> Tuple[str, tuple]: + """``ALTER CONSTRAINT`` addresses CHECK only on this server. + + The server refuses the other kinds rather than the parser (errno + ``3941`` names PRIMARY, UNIQUE and FOREIGN KEY -- measured, see + :meth:`supports_alter_constraint_enforced`), so the dialect refuses + them by name instead of sending a statement the server rejects. + + Raises: + TypeError: ``constraint_type`` is not a ``TableConstraintType``. + UnsupportedFeatureError: The kind is anything but CHECK. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraintType, + ) + + constraint_type = action.constraint_type + if not isinstance(constraint_type, TableConstraintType): + raise TypeError( + "constraint_type must be a TableConstraintType, got " + f"{type(constraint_type).__name__}" + ) + if constraint_type is not TableConstraintType.CHECK: + raise UnsupportedFeatureError( + self.name, + f"ALTER CONSTRAINT {constraint_type.name} ENFORCED/NOT ENFORCED", + "MySQL alters the enforcement state of CHECK constraints only; the " + "server answers errno 3941 on PRIMARY KEY, UNIQUE and FOREIGN KEY.", + ) + return super().format_alter_constraint_action(action) + + def supports_alter_constraint(self) -> bool: + """Whether ``ALTER TABLE ... ALTER CONSTRAINT`` is supported at all. + + True, for exactly one attribute. Measured on Community Server 26.7.0 + (192.168.1.3:14686) the statement exists and ``ALTER CONSTRAINT ck + [NOT] ENFORCED`` works on a CHECK; every other attribute -- ``DEFERRABLE``, + ``INITIALLY DEFERRED``, ``VALIDATE``, ``NOVALIDATE``, ``RELY`` -- is a + ``1064 (42000)`` parser error. + + So ``True`` here means "one form exists", not "constraints are + alterable": the attribute-level answer is + :meth:`supports_alter_constraint_enforced`, and the deferred/validate + answers are :meth:`supports_deferrable_constraint`, + :meth:`supports_constraint_novalidate` and + :meth:`supports_validate_constraint`, all ``False`` on the same 1064. + + The name of this switch is provisional: core S3 owns the final + ``AlterTableConstraintSupport`` spelling and may rename it. + """ + return True + + # Constraint enforcement control (SQL:2016) + def supports_constraint_enforced(self) -> bool: - """Whether ENFORCED/NOT ENFORCED constraint control is supported.""" + """Whether ``[NOT] ENFORCED`` on a CHECK constraint is supported. + + True from 8.0.16 on. Measured on Community Server 26.7.0 + (192.168.1.3:14686) on the ``ADD CONSTRAINT`` / ``CREATE TABLE`` + path: + + * ``CHECK (v > 0) NOT ENFORCED`` is accepted and round-trips as + ``CHECK ((v > 0)) /*!80016 NOT ENFORCED */``. + * ``CHECK (v > 0) ENFORCED`` is accepted but renders *identically to + the default* -- no clause at all in ``SHOW CREATE TABLE``. Only + ``information_schema.TABLE_CONSTRAINTS.ENFORCED`` distinguishes + them ('YES' / 'NO', measured); the rendered DDL cannot. + + Check only: ``ADD CONSTRAINT fk FOREIGN KEY ... NOT ENFORCED`` and + ``ADD CONSTRAINT uq UNIQUE (v) NOT ENFORCED`` are both answered by + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'NOT ENFORCED' at line 1 + + The per-kind half of that lives in the formatter, which renders the + clause for CHECK and refuses the FOREIGN KEY request by name; the + ALTER-path half lives in ``supports_alter_constraint_enforced``. + + **The caveat on ``ENFORCED`` above applies to every caller of this + switch.** Saying "this backend supports ENFORCED" is true and still + not enough to make a round-trip assertion: a rendered ``ENFORCED`` and + a rendered default are the same string, so a caller cannot prove from + the DDL that the clause was accepted, only that nothing is missing. + + MariaDB 13.1 rejects ``[NOT] ENFORCED`` outright on every path, so this + ``True`` is MySQL only. + """ return self.version >= (8, 0, 16) + def _format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + """Narrow the ENFORCABLE kinds to CHECK for this server. + + The shared helper renders whatever the standard-derived whitelist + allows; MySQL's parser accepts the trait on CHECK only (measured on + Community Server 26.7.0: UNIQUE and FOREIGN KEY answer ``1064`` -- + see :meth:`supports_constraint_enforced`), so the kind is narrowed + here and everything else is refused by name. The table-level path + renders through this backend's own ``format_table_constraint``, + which makes the same refusal inline. + """ + if constraint.enforced or constraint.not_enforced: + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + TableConstraintType, + ) + + kind = constraint.constraint_type + if not isinstance(kind, (ColumnConstraintType, TableConstraintType)): + raise TypeError( + "constraint_type must be a ColumnConstraintType or " + f"TableConstraintType, got {type(kind).__name__}" + ) + if kind.name != "CHECK" or not self.supports_constraint_enforced(): + raise UnsupportedFeatureError( + self.name, + "ENFORCED/NOT ENFORCED constraint", + "MySQL carries [NOT] ENFORCED on CHECK constraints only; the " + "server answers 1064 on every other kind (measured on 26.7.0).", + ) + return super()._format_constraint_enforcement(constraint) + + # FK match modes (SQL:1999) + def supports_fk_match(self) -> bool: - """Whether MATCH {SIMPLE|PARTIAL|FULL} is supported.""" + """Whether ``MATCH {SIMPLE|PARTIAL|FULL}`` is supported. + + False -- and not because the spelling is rejected. Measured on + Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id) + MATCH FULL`` is *accepted*, and the MATCH clause is then parsed away: + ``SHOW CREATE TABLE`` renders + + CONSTRAINT `fk` FOREIGN KEY (`d`) REFERENCES `p` (`id`) + + with no MATCH in it. Three FKs added to one table by ``MATCH FULL``, + ``MATCH PARTIAL`` and no clause at all all render the *same* three + lines, so ``MATCH`` is absent from the DDL entirely. + + **``MATCH_OPTION`` does retain the requested mode.** Measured on 26.7.0, + ``information_schema.REFERENTIAL_CONSTRAINTS.MATCH_OPTION`` reads + ``FULL`` for the ``MATCH FULL`` FK, ``PARTIAL`` for the ``MATCH + PARTIAL`` one, and ``NONE`` for the FK written with no MATCH clause + (measured on the same server, 192.168.1.3:14686). So the server + records the mode in its catalog even though it renders and enforces + nothing from it -- reading it back proves what was *written*, never + what is in *force*. (Earlier notes on this switch said ``MATCH_OPTION`` + came back ``NONE``; that is the value for a FK with no MATCH clause, + and the full-vs-none distinction is the measurement above.) + + The referential actions survive, which is narrower than the manual + claims. MySQL 8.0 manual 15.1.20.5 says an explicit MATCH "also causes + ON DELETE and ON UPDATE clauses to be ignored"; measured on 26.7.0, + ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id) MATCH FULL ON + DELETE CASCADE ON UPDATE CASCADE`` renders + ``... ON DELETE CASCADE ON UPDATE CASCADE``, and the catalog reads + ``DELETE_RULE='CASCADE'``, ``UPDATE_RULE='CASCADE'`` (measured). The + manual's MATCH half holds; its side-effect half does not, on this + version. + + So this is **parsed and discarded, not "unsupported"** -- the clause + is accepted by the grammar and then dropped, and the distinction + matters to callers: an unsupported clause raises a ``1064`` the caller + can catch and report, whereas a discarded clause is accepted silently, + so a caller who wrote it believes MATCH is in force when no engine + recognizes it. That is why the refusal this switch drives is by name + rather than by letting the server take it. + + Official, MySQL 8.0 manual 15.1.20.5 ("FOREIGN KEY Constraints"): + + no storage engine, including InnoDB, recognizes or enforces the + MATCH clause ... specifying MATCH should be avoided. + + MariaDB 13.1 was measured to discard ``MATCH`` the same way, so the + ``False`` is not what separates the two servers here. + """ return False + # Constraint deferral (SQL:1999) + def supports_deferrable_constraint(self) -> bool: - """Whether DEFERRABLE constraints are supported.""" + """Whether ``DEFERRABLE`` / ``INITIALLY DEFERRED|IMMEDIATE`` is + supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), every + spelling is a parser error: ``ADD CONSTRAINT ck CHECK (v > 0) + DEFERRABLE``, ``ADD CONSTRAINT ck CHECK (v > 0) NOT DEFERRABLE + INITIALLY DEFERRED`` and ``ADD CONSTRAINT fk FOREIGN KEY (d) + REFERENCES p (id) DEFERRABLE`` all answer + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'DEFERRABLE' at line 1 + + ``ALTER CONSTRAINT ck DEFERRABLE`` answers the same 1064, which is + what distinguishes it from the one ``ALTER CONSTRAINT`` form MySQL + does accept (``supports_alter_constraint_enforced``). + + This is a whole-family ``False``, and the reason is uniform: **no + spelling of constraint deferral exists in MySQL.** Measured on 26.7.0, + every one of these is ``1064 (42000)``: + + ============================== ================================== + ``ADD CONSTRAINT ... DEFERRABLE`` ``near 'DEFERRABLE'`` + ``ADD CONSTRAINT ... NOT VALID`` ``near 'VALID'`` + ``VALIDATE CONSTRAINT ck`` ``near 'VALIDATE CONSTRAINT ck'`` + ``ALTER CONSTRAINT ck DEFERRABLE`` ``near 'DEFERRABLE'`` + ``RENAME CONSTRAINT ck TO ck2`` ``near 'CONSTRAINT ck TO ck2'`` + ``ENABLE CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``DISABLE CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``NOCHECK CONSTRAINT ck`` ``near 'NOCHECK CONSTRAINT ck'`` + ``CHECK CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``DROP CONSTRAINT IF EXISTS ck`` ``near 'IF EXISTS ck'`` + ============================== ================================== + + Note the last one is this backend's lack of an idempotent drop spelling + rather than part of the deferral family; it is listed because it is + the same errno and the same fix (no such grammar). The remaining + switches in the family each record their own measurement: + ``supports_constraint_novalidate``, ``supports_validate_constraint``, + ``supports_rename_constraint``, ``supports_enable_disable_constraint`` + and ``supports_drop_constraint_if_exists``. + """ return False diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 270ca451..1b557faf 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -129,7 +129,11 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: if expr.storage_options: from rhosocial.activerecord.backend.expression.statements.ddl_table import StorageOptionsExpression - storage_expr = StorageOptionsExpression(self, expr.storage_options) if isinstance(expr.storage_options, dict) else expr.storage_options + storage_expr = ( + StorageOptionsExpression(self, expr.storage_options) + if isinstance(expr.storage_options, dict) + else expr.storage_options + ) storage_sql, storage_params = self.format_storage_options(storage_expr) if storage_sql: parts.append(storage_sql) @@ -247,6 +251,25 @@ def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tupl Table, "TableConstraint.foreign_key_table", ) + # [NOT] ENFORCED is a CHECK-only clause on this server (measured on + # Community Server 26.7.0: UNIQUE and FOREIGN KEY answer 1064). The + # request used to fall through the other branches silently; it is + # refused by name instead, matching the column path's narrowing. + if t_const.enforced or t_const.not_enforced: + if t_const.constraint_type is not TableConstraintType.CHECK: + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + feature = ( + f"{t_const.constraint_type.value} ENFORCED" + if t_const.enforced + else f"{t_const.constraint_type.value} NOT ENFORCED" + ) + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}.", + ) parts = [] params: List[Any] = [] diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py new file mode 100644 index 00000000..b96d1de5 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py @@ -0,0 +1,292 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py +"""MySQL constraint capability switches: values and their locality. + +Every verdict asserted here was measured against a live Community Server +26.7.0 at 192.168.1.3:14686 by running the ``ALTER TABLE`` statement and +reading ``SHOW CREATE TABLE`` / ``information_schema`` back. The docstring +each switch carries records the same measurement, errno included, so a +verdict can be re-checked without re-measuring the family. + +Two things are asserted for every switch, and the second is the one that +is easy to lose: + +* **the value** -- what this server does; +* **the locality** -- that the verdict resolves to a class in *this* + backend's package. A switch that silently falls back to core's + ``ConstraintMixin`` can have its default moved out from under MySQL + without anything in this repository noticing. Declaring the switch here + is what makes that move a visible test failure instead of a quiet + contract change. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.mixins.constraint import ( + MySQLConstraintMixin, +) +from rhosocial.activerecord.backend.expression.statements import TableConstraintType + +#: The server the verdicts below were measured on. Every value here is a +#: statement about *this* version, not about MySQL in general. +MEASURED_VERSION = (26, 7, 0) + +#: Every constraint switch this backend relies on, with the verdict measured +#: on 26.7.0. This mapping is the contract; the locality test below walks it +#: so a switch cannot leave the backend without failing first. +CONSTRAINT_SWITCHES = { + # Constraint kinds. + "supports_primary_key_constraint": True, + "supports_unique_constraint": True, + "supports_not_null_constraint": True, + "supports_foreign_key_constraint": True, + "supports_check_constraint": True, + "supports_exclude_constraint": False, + # Referential actions. + "supports_fk_on_delete": True, + "supports_fk_on_update": True, + "supports_fk_match": False, + # Clause attributes. + "supports_deferrable_constraint": False, + "supports_constraint_enforced": True, + "supports_alter_constraint_enforced": True, + "supports_constraint_novalidate": False, + # ALTER TABLE lifecycle. + "supports_add_constraint": True, + "supports_drop_constraint": True, + "supports_drop_constraint_if_exists": False, + "supports_rename_constraint": False, + "supports_alter_constraint": True, + "supports_enable_disable_constraint": False, + "supports_validate_constraint": False, +} + +#: The substring that must appear in each switch's docstring: the errno or +#: the exact error text the server answered with. A verdict that cannot be +#: traced to an observation is a guess. +EVIDENCE = { + "supports_primary_key_constraint": "1068", + "supports_unique_constraint": "DROP CONSTRAINT uq", + "supports_not_null_constraint": "26.7.0", + "supports_foreign_key_constraint": "1452", + "supports_check_constraint": "3819", + "supports_exclude_constraint": "1064", + "supports_fk_on_delete": "DELETE_RULE", + "supports_fk_on_update": "UPDATE_RULE", + "supports_fk_match": "parsed and discarded", + "supports_deferrable_constraint": "DEFERRABLE", + "supports_constraint_enforced": "NOT ENFORCED", + "supports_alter_constraint_enforced": "3941", + "supports_constraint_novalidate": "VALID", + "supports_add_constraint": "26.7.0", + "supports_drop_constraint": "3940", + "supports_drop_constraint_if_exists": "1064", + "supports_rename_constraint": "1064", + "supports_alter_constraint": "1064", + "supports_enable_disable_constraint": "1064", + "supports_validate_constraint": "1064", +} + + +def _dialect(): + return MySQLDialect(version=MEASURED_VERSION) + + +class TestConstraintSwitchValues: + """What the server was measured to do.""" + + @pytest.mark.parametrize("switch, expected", CONSTRAINT_SWITCHES.items(), ids=sorted(CONSTRAINT_SWITCHES)) + def test_switch_answers_the_measured_value(self, switch, expected): + dialect = _dialect() + assert getattr(dialect, switch)() is expected + + def test_alter_constraint_enforced_is_version_gated(self): + """The probe answers the verb's existence; the version decides. + + ``ALTER CONSTRAINT ... [NOT] ENFORCED`` exists from 8.0.16 on and on + nothing earlier, so the probe is a version gate that carries no + kind: which kinds the verb may address is the formatter's + narrowing, not the probe's. + """ + assert _dialect().supports_alter_constraint_enforced() is True + assert ( + MySQLDialect(version=(8, 0, 15)).supports_alter_constraint_enforced() + is False + ) + + def test_alter_constraint_addresses_check_only(self): + """Only CHECK takes the alteration; the FK answer is 3941. + + Measured on 26.7.0: a CHECK accepts both keywords, while a FOREIGN + KEY answers ``3941 (HY000): Altering constraint enforcement is not + supported for the constraint 'fkZ'. Enforcement state alter is not + supported for the PRIMARY, UNIQUE and FOREIGN KEY type + constraints.`` The server names the three kinds it refuses, so the + formatter narrows to CHECK by name instead of sending a statement + the server rejects. + """ + from rhosocial.activerecord.backend.expression import AlterConstraint + + dialect = _dialect() + sql, _ = AlterConstraint( + dialect, + constraint_name="ck", + not_enforced=True, + constraint_type=TableConstraintType.CHECK, + ).to_sql() + assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" + + with pytest.raises(UnsupportedFeatureError, match="3941"): + AlterConstraint( + dialect, + constraint_name="fk", + not_enforced=True, + constraint_type=TableConstraintType.FOREIGN_KEY, + ).to_sql() + + def test_alter_constraint_accepts_a_string_kind_at_construction(self): + """A string kind is the constructor's to normalise, not the probe's. + + ``AlterConstraint`` declares ``str`` in its ``constraint_type`` + union and normalises at construction, so a caller's ``'CHECK'`` + reaches the formatter as the enum. The dialect no longer sniffs + signatures to guess which layer should answer: the probe is + ``(self)`` and the kind narrowing is the formatter's. + """ + from rhosocial.activerecord.backend.expression import AlterConstraint + + sql, _ = AlterConstraint( + _dialect(), + constraint_name="ck", + not_enforced=True, + constraint_type="CHECK", + ).to_sql() + assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" + + +class TestConstraintSwitchesAreDeclaredLocally: + """The verdict must be this backend's, not an inherited default. + + This is the guard against a core change moving MySQL's contract: if a + switch below were deleted here and picked up from core's + ``ConstraintMixin``, its value could be changed by a commit in another + repository with nothing failing in this one. + """ + + @pytest.mark.parametrize("switch", sorted(CONSTRAINT_SWITCHES), ids=sorted(CONSTRAINT_SWITCHES)) + def test_switch_is_declared_by_this_backend(self, switch): + assert hasattr(MySQLConstraintMixin, switch), ( + f"{switch} is not declared by MySQLConstraintMixin: it would " + f"fall through to a core default that this repository cannot " + f"pin" + ) + + @pytest.mark.parametrize("switch", sorted(CONSTRAINT_SWITCHES), ids=sorted(CONSTRAINT_SWITCHES)) + def test_resolved_switch_is_owned_by_this_backend(self, switch): + dialect = _dialect() + owner = getattr(dialect, switch).__qualname__ + assert owner.startswith("MySQLConstraintMixin."), ( + f"{switch} resolves to {owner}, not to this backend's mixin" + ) + + def test_backend_mixin_precedes_the_core_constraint_mixin(self): + """MRO order is what makes the local declaration win at all. + + ``MySQLConstraintMixin`` has to precede core's ``ConstraintMixin``; + if that ordering were disturbed the local values would silently lose + to the inherited ones even though both classes define the switch. + """ + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + ConstraintMixin, + ) + + mro = MySQLDialect.__mro__ + assert mro.index(MySQLConstraintMixin) < mro.index(ConstraintMixin) + + +class TestConstraintSwitchDocstringsCarryTheMeasurement: + """A probe without its evidence is a number with no source.""" + + @pytest.mark.parametrize("switch", sorted(EVIDENCE), ids=sorted(EVIDENCE)) + def test_docstring_records_the_observation(self, switch): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert doc, f"{switch} has no docstring: the measurement has nowhere to live" + assert "26.7.0" in doc, f"{switch} names no measured server version" + + @pytest.mark.parametrize("switch, marker", EVIDENCE.items(), ids=sorted(EVIDENCE)) + def test_docstring_records_the_errno_or_exact_answer(self, switch, marker): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert marker in doc, f"{switch} docstring does not record {marker!r}" + + +class TestMeasurementsThatAreNotMerelyUnsupported: + """The two cases where ``False`` does not mean "the server refused". + + A caller treating either of these as an ordinary unsupported feature + gets different behaviour than the switch's value suggests, so the + distinction is asserted rather than left to the docstring alone. + """ + + def test_fk_match_is_parsed_and_discarded_not_rejected(self): + """The clause is accepted and then dropped -- the wording matters. + + Measured on 26.7.0, ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p + (id) MATCH FULL`` succeeds, and ``SHOW CREATE TABLE`` then renders + the constraint with no MATCH in it. ``MATCH_OPTION`` in + ``information_schema.REFERENTIAL_CONSTRAINTS`` still reads ``FULL``, + so the server records the mode and enforces nothing from it. + + A rejected clause raises ``1064`` and the caller can catch it; a + discarded one succeeds, so the caller believes MATCH is in force. + That is the whole reason this switch is ``False``. + """ + doc = MySQLConstraintMixin.supports_fk_match.__doc__ or "" + assert "parsed and discarded" in doc + assert "not \"unsupported\"" in doc + assert "MATCH_OPTION" in doc + assert "FULL" in doc and "PARTIAL" in doc and "NONE" in doc + + def test_enforced_clause_is_undetectable_in_the_rendered_ddl(self): + """``ENFORCED`` is accepted but leaves no trace, so the caveat is carried. + + Measured on 26.7.0, ``CHECK (v > 0) NOT ENFORCED`` round-trips as + ``CHECK ((`v` > 0)) /*!80016 NOT ENFORCED */`` while ``CHECK (v > 0) + ENFORCED`` renders identically to a constraint where the clause was + never typed. The switch is still ``True`` -- the clause is accepted -- + but any switch claiming ENFORCED support must say the rendering + cannot prove it, and both switches that claim it do. + """ + for switch in ("supports_constraint_enforced", "supports_alter_constraint_enforced"): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert "ENFORCED" in doc + assert "cannot" in doc or "identical" in doc, ( + f"{switch} claims ENFORCED support without saying the " + f"rendered DDL cannot show that it was written" + ) + + +class TestMySQLIsNotConflatedWithMariaDB: + """The two servers differ on exactly the clauses that look portable. + + Both are MySQL-family and share a ``CREATE TABLE`` surface, so these are + the verdicts most likely to be copied between them by mistake. Measured + against MariaDB 13.1 in the same session as the MySQL measurements. + """ + + #: MariaDB 13.1: rejects ENFORCED/NOT ENFORCED outright, but *does* + #: accept the idempotent drop spelling. MySQL is the reverse on both. + MARIADB_DIFFERS = ( + "supports_constraint_enforced", + "supports_alter_constraint_enforced", + "supports_drop_constraint_if_exists", + ) + + @pytest.mark.parametrize("switch", MARIADB_DIFFERS) + def test_docstring_separates_this_backend_from_mariadb(self, switch): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert "MariaDB" in doc, ( + f"{switch} is one of the clauses where MariaDB answers " + f"differently, so the docstring has to say which server it " + f"measured" + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index a43a24a4..cb23897d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -11,6 +11,19 @@ QueryExpression, CreateViewExpression, ) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + ForeignKeyConstraint, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddTableConstraint, + AlterConstraint, + DropTableConstraint, + ValidateConstraint, +) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -184,3 +197,179 @@ def test_table_tablespace_is_propagated_and_rejected(self): assert expression.tablespace == "ts_data" with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): expression.to_sql() + + +class TestMeasuredConstraintLifecycleCapabilities: + """The constraint-lifecycle probes answer what the server was measured to do. + + Every verdict asserted here was measured on Community Server 26.7.0 + (192.168.1.3:14686) by running the ``ALTER TABLE`` statement and reading + ``SHOW CREATE TABLE`` back; the probe docstrings in + ``MySQLConstraintMixin`` record the same measurements, errnos included. + + One of them is the reason this class exists: a switch that answers + ``False`` for a spelling the server *accepts* (FK ``MATCH``) is not + conservative, it is the difference between output the caller can trust and + output that silently means less than it looks. + """ + + MEASURED_VERSION = (26, 7, 0) + + def _dialect(self): + return MySQLDialect(version=self.MEASURED_VERSION) + + def _check(self, dialect, **kw): + return TableConstraint( + dialect, + TableConstraintType.CHECK, + name="ck", + check_condition=ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Column(dialect, "b") + ), + **kw, + ) + + def _fk(self, dialect, **kw): + return ForeignKeyConstraint( + dialect, + name="fk", + columns=["a"], + foreign_key_table=Table(dialect, "t2"), + foreign_key_columns=["b"], + **kw, + ) + + def test_add_constraint_is_declared_true(self): + """ADD CONSTRAINT works for all four kinds, so the master switch is on.""" + dialect = self._dialect() + assert dialect.supports_add_constraint() is True + + def test_drop_constraint_is_declared_true(self): + """DROP CONSTRAINT works (for UNIQUE and FK), so the master switch is on.""" + dialect = self._dialect() + assert dialect.supports_drop_constraint() is True + + def test_drop_constraint_if_exists_is_declined_and_refused_by_name(self): + """``DROP CONSTRAINT IF EXISTS`` is a parser error, so it is refused here. + + The server answers ``1064 (42000) ... near 'IF EXISTS x'``, so the + failure precedes name resolution and no errno can be caught to make + the drop re-runnable. + """ + dialect = self._dialect() + assert dialect.supports_drop_constraint_if_exists() is False + action = DropTableConstraint(dialect, "uq_content", if_exists=True) + with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): + action.to_sql() + + def test_novalidate_is_declined_and_refused_by_name(self): + """``NOT VALID`` is a parser error (``1064 ... near 'VALID'``).""" + dialect = self._dialect() + assert dialect.supports_constraint_novalidate() is False + constraint = self._check(dialect, validation=ConstraintValidation.NOVALIDATE) + with pytest.raises(UnsupportedFeatureError, match="NOT VALID"): + AddTableConstraint(dialect, constraint).to_sql() + + def test_validate_constraint_is_declined_and_refused_by_name(self): + """``VALIDATE CONSTRAINT`` is a parser error (``1064 ... near 'VALIDATE``).""" + dialect = self._dialect() + assert dialect.supports_validate_constraint() is False + with pytest.raises(UnsupportedFeatureError, match="VALIDATE CONSTRAINT"): + ValidateConstraint(dialect, "ck").to_sql() + + def test_alter_constraint_enforced_is_declared_for_check_only(self): + """``ALTER CONSTRAINT [NOT] ENFORCED`` is the one form the server takes. + + Measured: a CHECK accepts both keywords (and the ``/*!80016 NOT + ENFORCED */`` marker follows), while a FOREIGN KEY answers ``3941 + (HY000): Altering constraint enforcement is not supported ...``. + The probe answers the verb's existence and is version-gated; the + CHECK-only narrowing is the formatter's, which refuses the other + kinds by name. + """ + dialect = self._dialect() + assert dialect.supports_alter_constraint_enforced() is True + assert ( + MySQLDialect(version=(8, 0, 15)).supports_alter_constraint_enforced() + is False + ) + + def test_alter_constraint_enforced_renders_for_check_only(self): + """The rendering follows the probe: CHECK renders, FOREIGN KEY refuses.""" + dialect = self._dialect() + sql, params = AlterConstraint( + dialect, "ck", not_enforced=True, constraint_type=TableConstraintType.CHECK + ).to_sql() + assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" + assert params == () + + with pytest.raises(UnsupportedFeatureError, match="ENFORCED"): + AlterConstraint( + dialect, "fk", enforced=True, constraint_type=TableConstraintType.FOREIGN_KEY + ).to_sql() + + def test_alter_constraint_defers_to_the_deferrable_probe(self): + """``ALTER CONSTRAINT ck DEFERRABLE`` is a 1064, not an ENFORCED answer.""" + dialect = self._dialect() + assert dialect.supports_deferrable_constraint() is False + # The enforcement verb exists, but DEFERRABLE is not an attribute it + # alters -- that refusal belongs to the deferrable family, which is + # False on the same 1064. + assert dialect.supports_alter_constraint_enforced() is True + + def test_fk_match_is_parsed_and_dropped_not_merely_refused(self): + """FK ``MATCH`` parses and is discarded, so it is refused *by name*. + + The server accepts ``MATCH FULL`` and then drops the clause, so the + rendering is not emitted: a caller reading ``MATCH FULL`` back from + the DDL would believe something no engine enforces. + """ + dialect = self._dialect() + assert dialect.supports_fk_match() is False + constraint = self._fk(dialect, match_type="FULL") + with pytest.raises(UnsupportedFeatureError, match="MATCH"): + constraint.to_sql() + + def test_enforcement_is_version_gated_and_check_only(self): + """``[NOT] ENFORCED`` needs 8.0.16, and only CHECK takes it.""" + assert MySQLDialect(version=(8, 0, 15)).supports_constraint_enforced() is False + assert MySQLDialect(version=(8, 0, 16)).supports_constraint_enforced() is True + dialect = self._dialect() + sql, params = self._check(dialect, not_enforced=True).to_sql() + assert sql.endswith("NOT ENFORCED") + assert params == [] + + with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY NOT ENFORCED"): + self._fk(dialect, not_enforced=True).to_sql() + + +class TestConstraintProbeDocstringsCarryTheMeasurement: + """A probe without its evidence is a number with no source. + + Each switch asserted here is answered from a live server, so the + docstring has to say which version, and what the server answered -- + errno or error text -- or the verdict cannot be re-checked without + re-measuring everything. + """ + + #: The probe name, and the marker that must appear in its docstring: the + #: measured server version, an errno, or the shape of the refusal. + EVIDENCE = { + "supports_check_constraint": "26.7.0", + "supports_add_constraint": "26.7.0", + "supports_drop_constraint": "3940", + "supports_drop_constraint_if_exists": "1064", + "supports_constraint_novalidate": "1064", + "supports_validate_constraint": "1064", + "supports_alter_constraint_enforced": "3941", + "supports_constraint_enforced": "26.7.0", + "supports_fk_match": "parsed away", + "supports_deferrable_constraint": "1064", + } + + @pytest.mark.parametrize("probe, marker", EVIDENCE.items(), ids=sorted(EVIDENCE)) + def test_docstring_records_the_measured_evidence(self, probe, marker): + doc = getattr(MySQLDialect, probe).__doc__ or "" + assert doc, f"{probe} has no docstring: the measurement has nowhere to live" + assert marker in doc, f"{probe} docstring does not record {marker!r}" + assert "26.7.0" in doc, f"{probe} names no measured server version" From 8ba84de5f1ad73dfd78402a526a26214d31debfb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 14 Oct 2026 07:12:21 +0800 Subject: [PATCH 05/65] refactor(mysql): follow the statement and expression renames Mechanical adaptation to the core renames (W2+W3a+W3b+W1+S1-c), 109 files / 1130 lines, all in src+tests+docs examples: - W2 statement classes (46 of the core's 57 hit this repo): QueryStatement, InsertStatement, CreateTableStatement, DropTableStatement, the transaction five (BeginTransactionStatement, CommitStatement, RollbackStatement, SavepointStatement, ReleaseSavepointStatement), Create*/Alter*/Drop* DDL, ExplainStatement, CommentOnStatement, the property-graph three. - W2 formatter (functional, 5 hits): format_begin_transaction -> format_begin_transaction_statement (mixins/transaction.py override). - W3a: ILikePredicate, FulltextMatch, XMLExistsPredicate + format_ilike_predicate / format_xmlexists_predicate (roundtrip skip lists); OnConflictExpression docstring token corrected to the core's OnConflictClause (the old name never existed in core). - W3b: SQLValue, JSONDocumentValue, JSONTextValue, CollateFunction, CastFunction, CaseFunction, UUIDCastFunction, Wildcard, and the six introspection dataclasses -> Introspection*Info. - W1: CTEClause, WithStatement, main_query= -> query= (examples/query/cte.py). Constraint surfaces still reference the removed ColumnConstraintType / TableConstraintType enums and follow in the next commit; the Wildcard references follow in the wildcard-split commit (the intermediate `Wildcard` name no longer exists in core). Expected-red window per the wave guide. Backend-owned statement classes (MySQLInsertExpression, CreateJsonDualityViewExpression, the Show* family, SetTransaction and the GraphTable/Pivot wrappers) are outside the core's rename mapping and keep their names this wave. Evidence: full-package import smoke green (138 modules) modulo the two surfaces above; targeted runs green: expression/test_expressions_transaction.py (32 passed, exit 0), dml/test_insert_on_conflict_clauses.py + test_mysql_datetime_interval_expressions.py (sync tests passed; 4 async scenario tests error on unreachable mysql_56/57 scenario servers, environmental); compileall src+tests exit 0; ruff delta vs HEAD on the 109 changed files: zero new findings (E501 count down by 3). --- docs/examples/chapter_04_ddl/ddl.py | 20 ++-- .../queries/order_queries.py | 18 +-- .../backend/impl/mysql/async_transaction.py | 2 +- .../backend/impl/mysql/dialect.py | 8 +- .../backend/impl/mysql/examples/conftest.py | 8 +- .../mysql/examples/connection/quickstart.py | 28 ++--- .../impl/mysql/examples/ddl/alter_table.py | 22 ++-- .../impl/mysql/examples/ddl/create_index.py | 12 +- .../impl/mysql/examples/ddl/create_table.py | 10 +- .../impl/mysql/examples/ddl/drop_table.py | 18 +-- .../impl/mysql/examples/ddl/unique_index.py | 26 ++--- .../backend/impl/mysql/examples/ddl/view.py | 42 +++---- .../impl/mysql/examples/delete/basic.py | 32 +++--- .../impl/mysql/examples/insert/batch.py | 20 ++-- .../impl/mysql/examples/insert/single.py | 36 +++--- .../impl/mysql/examples/insert/upsert.py | 30 ++--- .../mysql/examples/insert/with_returning.py | 20 ++-- .../named_expressions/order_expressions.py | 18 +-- .../examples/named_migrations/expressions.py | 16 +-- .../impl/mysql/examples/query/aggregate.py | 16 +-- .../impl/mysql/examples/query/basic.py | 18 +-- .../impl/mysql/examples/query/constants.py | 16 +-- .../backend/impl/mysql/examples/query/cte.py | 54 ++++----- .../impl/mysql/examples/query/distinct.py | 26 ++--- .../impl/mysql/examples/query/explain.py | 32 +++--- .../impl/mysql/examples/query/fulltext.py | 16 +-- .../backend/impl/mysql/examples/query/join.py | 22 ++-- .../impl/mysql/examples/query/json_table.py | 20 ++-- .../impl/mysql/examples/query/pagination.py | 20 ++-- .../impl/mysql/examples/query/predicate.py | 26 ++--- .../impl/mysql/examples/query/subquery.py | 24 ++-- .../impl/mysql/examples/query/union.py | 30 ++--- .../impl/mysql/examples/query/window.py | 16 +-- .../mysql/examples/query/window_mysql57.py | 12 +- .../examples/schema_diff/column_order.py | 12 +- .../examples/schema_diff/mysql56_json.py | 14 +-- .../examples/schema_diff/mysql80_charset.py | 8 +- .../examples/schema_diff/table_changes.py | 16 +-- .../impl/mysql/examples/transaction/basic.py | 26 ++--- .../mysql/examples/transaction/exclusive.py | 34 +++--- .../mysql/examples/transaction/for_update.py | 32 +++--- .../impl/mysql/examples/types/json_basic.py | 16 +-- .../impl/mysql/examples/types/json_mysql56.py | 12 +- .../impl/mysql/examples/update/basic.py | 26 ++--- .../backend/impl/mysql/expression/dml.py | 4 +- .../backend/impl/mysql/expression/fulltext.py | 4 +- .../backend/impl/mysql/expression/json.py | 22 ++-- .../mysql/expression/json_duality_view.py | 8 +- .../impl/mysql/expression/match_against.py | 4 +- .../backend/impl/mysql/expression/set_type.py | 8 +- .../backend/impl/mysql/expression/spatial.py | 20 ++-- .../impl/mysql/expression/table_expr.py | 6 +- .../backend/impl/mysql/expression/vector.py | 18 +-- .../impl/mysql/introspection/introspector.py | 46 ++++---- .../impl/mysql/mixins/charset_collation.py | 6 +- .../backend/impl/mysql/mixins/ddl_database.py | 18 +-- .../backend/impl/mysql/mixins/ddl_table.py | 6 +- .../backend/impl/mysql/mixins/ddl_view.py | 8 +- .../backend/impl/mysql/mixins/explain.py | 2 +- .../backend/impl/mysql/mixins/json.py | 14 +-- .../backend/impl/mysql/mixins/object_kind.py | 4 +- .../backend/impl/mysql/mixins/partition.py | 4 +- .../backend/impl/mysql/mixins/transaction.py | 2 +- .../backend/impl/mysql/mixins/trigger.py | 16 +-- .../backend/impl/mysql/mixins/truncate.py | 6 +- .../backend/impl/mysql/protocols.py | 18 +-- .../backend/impl/mysql/transaction.py | 2 +- tests/providers/basic.py | 8 +- tests/providers/events.py | 8 +- tests/providers/fixtures/__init__.py | 4 +- tests/providers/fixtures/_common.py | 18 +-- tests/providers/fixtures/basic.py | 72 ++++++------ tests/providers/fixtures/events.py | 12 +- tests/providers/fixtures/mixins.py | 20 ++-- tests/providers/fixtures/query.py | 50 ++++----- tests/providers/mixins.py | 8 +- tests/providers/query.py | 20 ++-- .../backend/ddl/test_ddl_source_collection.py | 4 +- .../ddl/test_derived_ddl_inline_index.py | 6 +- .../dml/test_insert_on_conflict_clauses.py | 8 +- .../test_expressions_transaction.py | 44 ++++---- .../test_introspection_columns.py | 14 +-- .../test_introspection_database.py | 10 +- .../test_introspection_foreign_keys.py | 14 +-- .../test_introspection_tables.py | 14 +-- .../test_introspection_triggers.py | 12 +- .../introspection/test_introspection_views.py | 14 +-- .../backend/mysql/test_view_execution.py | 44 ++++---- .../backend/test_auto_increment_ddl.py | 8 +- .../feature/backend/test_create_table_like.py | 14 +-- .../backend/test_expression_roundtrip_all.py | 48 ++++---- .../feature/backend/test_fulltext_index.py | 18 +-- .../backend/test_json_arrow_expression.py | 52 ++++----- .../test_mysql_charset_collation_support.py | 4 +- .../backend/test_mysql_clause_pair_guard.py | 60 +++++----- .../backend/test_mysql_database_ddl.py | 8 +- ...ysql_datetime_interval_explain_examples.py | 12 +- ...est_mysql_datetime_interval_expressions.py | 4 +- .../backend/test_mysql_ddl_coverage.py | 20 ++-- .../backend/test_mysql_ddl_features.py | 66 +++++------ .../backend/test_mysql_ddl_improvements.py | 16 +-- .../backend/test_mysql_dialect_security.py | 16 +-- .../backend/test_mysql_identity_execution.py | 14 +-- .../backend/test_mysql_mixin_expressions.py | 106 +++++++++--------- .../backend/test_mysql_partition_explain.py | 24 ++-- .../test_mysql_partition_operations.py | 70 ++++++------ ...est_mysql_partition_strategy_operations.py | 54 ++++----- .../test_mysql_type_domain_negative.py | 24 ++-- .../feature/backend/test_niladic_functions.py | 42 +++---- 109 files changed, 1130 insertions(+), 1130 deletions(-) diff --git a/docs/examples/chapter_04_ddl/ddl.py b/docs/examples/chapter_04_ddl/ddl.py index ca872d0a..d08ab450 100644 --- a/docs/examples/chapter_04_ddl/ddl.py +++ b/docs/examples/chapter_04_ddl/ddl.py @@ -1,6 +1,6 @@ """ DDL Test Script for MySQL Backend -Tests CreateTableExpression, DropTableExpression, AlterTableExpression, etc. +Tests CreateTableStatement, DropTableStatement, AlterTableStatement, etc. """ import sys @@ -13,9 +13,9 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, - DropTableExpression, - AlterTableExpression, + CreateTableStatement, + DropTableStatement, + AlterTableStatement, AddColumn, DropColumn, ) @@ -82,7 +82,7 @@ def main(): ), ] - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table_name="ddl_test_users", columns=columns, @@ -104,7 +104,7 @@ def main(): # Test 2: Create Table with IF NOT EXISTS # ============================================================ print("=== Test 2: Create Table with IF NOT EXISTS ===") - create2 = CreateTableExpression( + create2 = CreateTableStatement( dialect=dialect, table_name="ddl_test_users", columns=columns, @@ -124,7 +124,7 @@ def main(): # Test 3: Alter Table - Add Column # ============================================================ print("=== Test 3: Alter Table - Add Column ===") - alter = AlterTableExpression( + alter = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[ @@ -150,7 +150,7 @@ def main(): # ============================================================ print("=== Test 4: Alter Table - Drop Column ===") # First add a column to drop - alter_add = AlterTableExpression( + alter_add = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[AddColumn(ColumnDefinition("temp_field", "TEXT"))] @@ -158,7 +158,7 @@ def main(): sql_str, params_str = alter_add.to_sql() backend.execute(sql_str, params_str) - alter_drop = AlterTableExpression( + alter_drop = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[DropColumn("temp_field")] @@ -180,7 +180,7 @@ def main(): # Test 5: Drop Table # ============================================================ print("=== Test 5: Drop Table ===") - drop = DropTableExpression( + drop = DropTableStatement( dialect, table_name="ddl_test_users", if_exists=True, diff --git a/docs/examples/chapter_12_named_procedure/queries/order_queries.py b/docs/examples/chapter_12_named_procedure/queries/order_queries.py index 2ae371ea..4dc89b3e 100644 --- a/docs/examples/chapter_12_named_procedure/queries/order_queries.py +++ b/docs/examples/chapter_12_named_procedure/queries/order_queries.py @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -22,7 +22,7 @@ def get_order(dialect, order_id: int): """获取订单详情。""" - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "id"), @@ -37,7 +37,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """检查库存可用数量。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -47,7 +47,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """预留库存。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "reserved")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -58,7 +58,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """发送通知。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "notifications")), @@ -68,7 +68,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """处理支付。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=NamedRelationRef(dialect, Table(dialect, "payments")), @@ -78,7 +78,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """释放库存(回滚)。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -88,7 +88,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """创建订单记录。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=NamedRelationRef(dialect, Table(dialect, "order_records")), @@ -98,7 +98,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """确认库存(最终确认)。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py index e5d04e0d..029a878c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py @@ -28,7 +28,7 @@ class AsyncMySQLTransactionManager(MySQLTransactionMixin, AsyncTransactionManage START TRANSACTION when a specific isolation level is needed. This class overrides _do_begin() to handle this sequencing. - The format_begin_transaction() in MySQLDialect returns only "START TRANSACTION", + The format_begin_transaction_statement() in MySQLDialect returns only "START TRANSACTION", while this class handles the SET TRANSACTION step separately. Non-I/O methods (isolation_level, _build_set_isolation_sql, _ISOLATION_LEVELS) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 3d127843..13a26aa7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -177,7 +177,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ) @@ -527,7 +527,7 @@ def supports_multiple_on_conflict_clauses(self) -> bool: """MySQL's ON DUPLICATE KEY UPDATE allows only a single clause.""" return False - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format INSERT statement with MySQL-specific options. Extends the base implementation to support the typed flags on @@ -536,7 +536,7 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] - ``replace=True`` → ``REPLACE INTO`` Args: - expr: InsertExpression instance + expr: InsertStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -555,7 +555,7 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] if self.strict_validation: expr.validate(strict=True) - require_kind(expr.into, Table, "InsertExpression.into") + require_kind(expr.into, Table, "InsertStatement.into") # Check for conflicting options is_replace = getattr(expr, "replace", False) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py index 1f30dab3..eec2f250 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py @@ -78,7 +78,7 @@ "max_version": "9.6", }, "ddl/drop_table.py": { - "title": "DROP TABLE using DropTableExpression", + "title": "DROP TABLE using DropTableStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", @@ -128,21 +128,21 @@ "note": "MySQL does not support RETURNING clause; use LAST_INSERT_ID() instead", }, "delete/basic.py": { - "title": "DELETE using DeleteExpression", + "title": "DELETE using DeleteStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", "max_version": "9.6", }, "update/basic.py": { - "title": "UPDATE using UpdateExpression", + "title": "UPDATE using UpdateStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", "max_version": "9.6", }, "query/union.py": { - "title": "UNION using SetOperationExpression", + "title": "UNION using SetOperationStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py index 1a497876..6ecebc8e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py @@ -14,10 +14,10 @@ import os from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, ValuesSource, WhereClause, ) @@ -60,7 +60,7 @@ def execute_expression(expression, options=None): def create_demo_tables(): - users_table = CreateTableExpression( + users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_users"), columns=[ @@ -83,7 +83,7 @@ def create_demo_tables(): ) execute_expression(users_table, ddl_options) - logs_table = CreateTableExpression( + logs_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_logs"), columns=[ @@ -107,7 +107,7 @@ def create_demo_tables(): def seed_demo_data(): - insert_users = InsertExpression( + insert_users = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_users"), columns=["name", "status"], @@ -135,7 +135,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Queries # ============================================================ -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -170,7 +170,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Parameterized Queries (Recommended) # ============================================================ -filtered_query = QueryExpression( +filtered_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -201,7 +201,7 @@ def seed_demo_data(): # SECTION: Handle Transactions # ============================================================ with backend.transaction(): - insert_log = InsertExpression( + insert_log = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_logs"), columns=["message"], @@ -209,7 +209,7 @@ def seed_demo_data(): ) execute_expression(insert_log) -logs_query = QueryExpression( +logs_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "message")], from_=Table(dialect, "quickstart_logs"), @@ -221,7 +221,7 @@ def seed_demo_data(): # SECTION: Error Handling # ============================================================ try: - invalid_query = QueryExpression( + invalid_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "nonexistent_table"), @@ -233,10 +233,10 @@ def seed_demo_data(): # ============================================================ # SECTION: Disconnect # ============================================================ -drop_logs = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) +drop_logs = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) execute_expression(drop_logs, ddl_options) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) execute_expression(drop_users, ddl_options) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py index d3d08c75..ebb83054 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py @@ -16,10 +16,10 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.statements import ( @@ -40,15 +40,15 @@ backend.connect() dialect = backend.dialect -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -67,7 +67,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -79,7 +79,7 @@ sql, params = insert.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -94,7 +94,7 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import AlterTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import AlterTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumn # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -106,7 +106,7 @@ ), ) -add_col_expr = AlterTableExpression( +add_col_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_col_action], @@ -133,7 +133,7 @@ ), ) -add_age_expr = AlterTableExpression( +add_age_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_age_action], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py index 66d44f5b..64b85db4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py @@ -8,7 +8,7 @@ import os from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -28,11 +28,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=[ @@ -56,18 +56,18 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Index, Table # Drop index first if exists (MySQL does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_category_price")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_category_price")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_category_price"), table=Table(dialect, "products"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py index ff6fe330..6d7ecbb2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py @@ -17,8 +17,8 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( - DropTableExpression, - CreateTableExpression, + DropTableStatement, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp from rhosocial.activerecord.backend.expression.statements import ( @@ -43,7 +43,7 @@ dialect = backend.dialect # Drop if exists for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) @@ -102,7 +102,7 @@ ] # Create table with MySQL-specific ENGINE and CHARSET options -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=columns, @@ -130,7 +130,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py index 5fa5a7d6..96fd3044 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py @@ -1,5 +1,5 @@ """ -DROP TABLE using DropTableExpression - MySQL. +DROP TABLE using DropTableStatement - MySQL. This example demonstrates: 1. DROP TABLE @@ -25,8 +25,8 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, @@ -35,15 +35,15 @@ ) from rhosocial.activerecord.backend.expression.objects import Table -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -64,7 +64,7 @@ # ============================================================ # SECTION: DROP TABLE # ============================================================ -drop_expr = DropTableExpression( +drop_expr = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), ) @@ -76,7 +76,7 @@ # ============================================================ # SECTION: DROP TABLE IF EXISTS # ============================================================ -drop_expr_if_exists = DropTableExpression( +drop_expr_if_exists = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), if_exists=True, @@ -94,6 +94,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DropTableExpression to drop tables +# 1. Use DropTableStatement to drop tables # 2. if_exists=True prevents error if table doesn't exist # 3. CASCADE and RESTRICT options available diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py index 9979e463..c4d5b934 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py @@ -24,7 +24,7 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, @@ -32,15 +32,15 @@ ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -63,18 +63,18 @@ # ============================================================ # SECTION: CREATE UNIQUE INDEX # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Index, Table # Drop index first if exists (MySQL does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -unique_idx = CreateIndexExpression( +unique_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email_unique"), table=Table(dialect, "users"), @@ -91,13 +91,13 @@ # ============================================================ # Drop composite index first if exists try: - drop_idx2 = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_name_email")) + drop_idx2 = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_name_email")) sql, params = drop_idx2.to_sql() backend.execute(sql, params) except Exception: pass -composite_idx = CreateIndexExpression( +composite_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_name_email"), table=Table(dialect, "users"), @@ -110,11 +110,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -123,6 +123,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateIndexExpression with unique=True +# 1. Use CreateIndexStatement with unique=True # 2. Use columns=[col1, col2] for composite index -# 3. Use DropIndexExpression to drop +# 3. Use DropIndexStatement to drop diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py index 981979e5..9b22240c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -39,15 +39,15 @@ ) # Drop dependent tables first for clean setup (orders may reference users via FK) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_users.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -60,7 +60,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -80,20 +80,20 @@ # SECTION: CREATE VIEW # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.core import WildcardExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.core import Wildcard # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table, View -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), ) -view_expr = CreateViewExpression( +view_expr = CreateViewStatement( dialect=dialect, view=View(dialect, "user_names"), query=query, @@ -105,9 +105,9 @@ options = ExecutionOptions(stmt_type=StatementType.DDL) backend.execute(sql, params, options=options) -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, "user_names"), ) options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -118,7 +118,7 @@ # ============================================================ # SECTION: CREATE OR REPLACE VIEW # ============================================================ -view_expr_replace = CreateViewExpression( +view_expr_replace = CreateViewStatement( dialect=dialect, view=View(dialect, "user_names"), query=query, @@ -131,7 +131,7 @@ # ============================================================ # SECTION: DROP VIEW # ============================================================ -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect=dialect, view=View(dialect, "user_names"), ) @@ -142,11 +142,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -155,6 +155,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateViewExpression to create views +# 1. Use CreateViewStatement to create views # 2. Use replace=True to replace existing view -# 3. Use DropViewExpression to drop views +# 3. Use DropViewStatement to drop views diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py index 7dd7a158..56086c2d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py @@ -1,8 +1,8 @@ """ -DELETE using DeleteExpression - MySQL. +DELETE using DeleteStatement - MySQL. This example demonstrates: -1. Delete single row using DeleteExpression +1. Delete single row using DeleteStatement 2. Delete with WHERE condition 3. Delete all rows """ @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -41,15 +41,15 @@ ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -75,7 +75,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -95,11 +95,11 @@ # ============================================================ # SECTION: Delete Single Row # ============================================================ -from rhosocial.activerecord.backend.expression import DeleteExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import DeleteStatement # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, tables=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), @@ -115,7 +115,7 @@ # ============================================================ # SECTION: Delete with Condition # ============================================================ -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, tables=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Bob")), @@ -128,7 +128,7 @@ # ============================================================ # SECTION: Delete All Rows # ============================================================ -delete_expr = DeleteExpression(dialect=dialect, tables=Table(dialect, "users")) +delete_expr = DeleteStatement(dialect=dialect, tables=Table(dialect, "users")) sql, params = delete_expr.to_sql() print(f"Delete all SQL: {sql}") result = backend.execute(sql, params, options=options) @@ -137,11 +137,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -150,7 +150,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DeleteExpression to build DELETE query +# 1. Use DeleteStatement to build DELETE query # 2. Use ComparisonPredicate for WHERE conditions # 3. Omit where parameter to delete all rows # 4. affected_rows shows number of deleted rows diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py index 1d036d2e..cd74dc2a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py @@ -9,8 +9,8 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp from rhosocial.activerecord.backend.expression.statements import ( @@ -34,11 +34,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "logs"), columns=[ @@ -72,15 +72,15 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - InsertExpression, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column # noqa: E402 +from rhosocial.activerecord.backend.expression.core import Literal, Wildcard, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "logs"), source=ValuesSource( @@ -105,9 +105,9 @@ result = backend.execute(sql, params) print(f"Affected rows: {result.affected_rows}") -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, "logs"), order_by=OrderByClause(dialect, [Column(dialect, "id")]), ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py index 57d527dd..419ff9bd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py @@ -4,7 +4,7 @@ This example demonstrates: 1. INSERT a single row with explicit column values 2. INSERT with AUTO_INCREMENT primary key -3. Verify inserted data using QueryExpression +3. Verify inserted data using QueryStatement """ # ============================================================ @@ -14,13 +14,13 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, + InsertStatement, ValuesSource, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column +from rhosocial.activerecord.backend.expression.core import Literal, Wildcard, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -46,15 +46,15 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -85,7 +85,7 @@ # ============================================================ # 1. Insert a single row with explicit column values -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -107,9 +107,9 @@ print(f"Affected rows: {result.affected_rows}") # Verify the inserted row -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, "users"), where=ComparisonPredicate( dialect, @@ -123,7 +123,7 @@ print(f"Inserted row: {result.data}") # 2. Insert another row (AUTO_INCREMENT id will be assigned automatically) -insert_expr2 = InsertExpression( +insert_expr2 = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -139,7 +139,7 @@ print(f"Second insert affected rows: {result.affected_rows}") # Verify all rows -all_query = QueryExpression( +all_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), @@ -152,11 +152,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -165,7 +165,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use InsertExpression with ValuesSource containing a single row list +# 1. Use InsertStatement with ValuesSource containing a single row list # 2. AUTO_INCREMENT columns are omitted from the insert columns list -# 3. Verify inserts using QueryExpression with WHERE clause +# 3. Verify inserts using QueryStatement with WHERE clause # 4. MySQL returns affected_rows = 1 for successful single insert diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py index ac46719d..25030422 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py @@ -27,11 +27,11 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 @@ -48,15 +48,15 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -102,7 +102,7 @@ # MySQL uses ON DUPLICATE KEY UPDATE, which is handled via OnConflictClause # Initial insert -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -117,7 +117,7 @@ backend.execute(sql, params) # Verify initial insert -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -133,7 +133,7 @@ print(f"Initial insert: {result.data}") # Insert again with ON DUPLICATE KEY UPDATE - will update -upsert_expr = InsertExpression( +upsert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -165,7 +165,7 @@ # ============================================================ # affected_rows: 1 = inserted, 2 = updated -insert_result = InsertExpression( +insert_result = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -180,7 +180,7 @@ result = backend.execute(sql, params) print(f"Insert affected_rows: {result.affected_rows}") -upsert_result = InsertExpression( +upsert_result = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -207,7 +207,7 @@ # ============================================================ # MySQL supports multiple row VALUES() -multi_upsert = InsertExpression( +multi_upsert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -233,11 +233,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py index 657a668a..1576e6b5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py @@ -14,11 +14,11 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column, FunctionCall from rhosocial.activerecord.backend.expression.statements import ( @@ -43,11 +43,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -78,7 +78,7 @@ # ============================================================ # 1. Insert a row -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "email"], @@ -101,7 +101,7 @@ # 2. Retrieve auto-generated ID using LAST_INSERT_ID() # MySQL does NOT support RETURNING clause (unlike PostgreSQL/SQLite 3.35+) # Use SELECT LAST_INSERT_ID() instead -query_id = QueryExpression( +query_id = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "LAST_INSERT_ID").as_("generated_id")], ) @@ -111,7 +111,7 @@ print(f"Generated ID: {result.data}") # 3. Verify by querying the inserted row -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), @@ -123,7 +123,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py index 2dbdfce9..33979789 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py @@ -22,14 +22,14 @@ Column, ComparisonPredicate, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table def get_order(dialect, order_id: int): """Get order details by ID.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=Table(dialect, "orders"), @@ -39,7 +39,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """Check available inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -51,7 +51,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """Reserve inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -63,7 +63,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """Send notification to a user.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "notifications"), @@ -75,7 +75,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """Process payment for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=Table(dialect, "payments"), @@ -87,7 +87,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """Release reserved inventory.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), @@ -99,7 +99,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """Create an order record.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "order_records"), @@ -111,7 +111,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """Confirm inventory (final confirmation).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py index 3f693897..d8d5ebd5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py @@ -7,11 +7,11 @@ """ from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.types import ( MySQLIntType, @@ -22,7 +22,7 @@ def create_users_table(dialect): """CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name TEXT, email TEXT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "users"), columns=[ @@ -45,12 +45,12 @@ def create_users_table(dialect): def drop_users_table(dialect): """DROP TABLE IF EXISTS users.""" - return DropTableExpression(dialect, table=Table(dialect, "users"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "users"), if_exists=True) def create_posts_table(dialect): """CREATE TABLE posts (id INT PRIMARY KEY AUTO_INCREMENT, title TEXT, user_id INT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "posts"), columns=[ @@ -73,7 +73,7 @@ def create_posts_table(dialect): def drop_posts_table(dialect): """DROP TABLE IF EXISTS posts.""" - return DropTableExpression(dialect, table=Table(dialect, "posts"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "posts"), if_exists=True) def create_custom_table(dialect, table_name: str = "custom_table"): @@ -82,7 +82,7 @@ def create_custom_table(dialect, table_name: str = "custom_table"): This expression accepts an extra ``table_name`` parameter, allowing the migration to control the target table name at runtime. """ - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, table_name), columns=[ @@ -104,4 +104,4 @@ def create_custom_table(dialect, table_name: str = "custom_table"): def drop_custom_table(dialect, table_name: str = "custom_table"): """DROP TABLE IF EXISTS .""" - return DropTableExpression(dialect, table=Table(dialect, table_name), if_exists=True) \ No newline at end of file + return DropTableStatement(dialect, table=Table(dialect, table_name), if_exists=True) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py index f012df86..741edcf4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,11 +36,11 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales"), columns=[ @@ -62,7 +62,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales"), columns=["product", "quantity", "price", "region"], @@ -84,7 +84,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, GroupByHavingClause, ) @@ -92,7 +92,7 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "product"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py index 771c8a4c..040bcc4a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,15 +36,15 @@ ColumnConstraintType, ) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -70,7 +70,7 @@ print(f"Create table SQL: {sql}, params: {params}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age", "status"], @@ -90,7 +90,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, OrderByClause, @@ -99,7 +99,7 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py index 3efa0520..24bea4f8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py @@ -39,7 +39,7 @@ # which is the SQL:2003 standard niladic form, valid in both DDL DEFAULT # and SELECT contexts. MySQL also accepts CURRENT_TIMESTAMP() with parentheses. -from rhosocial.activerecord.backend.expression import QueryExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import QueryStatement # noqa: E402 from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 from rhosocial.activerecord.backend.expression.functions.datetime import ( # noqa: E402 current_timestamp, @@ -48,7 +48,7 @@ current_time, ) -query_ts = QueryExpression( +query_ts = QueryStatement( dialect=dialect, select=[current_timestamp(dialect)], ) @@ -60,7 +60,7 @@ # ============================================================ # SECTION: SELECT NOW() # ============================================================ -query_now = QueryExpression( +query_now = QueryStatement( dialect=dialect, select=[now(dialect)], ) @@ -72,7 +72,7 @@ # ============================================================ # SECTION: SELECT CURRENT_DATE, CURRENT_TIME # ============================================================ -query_date = QueryExpression( +query_date = QueryStatement( dialect=dialect, select=[ current_date(dialect), @@ -89,7 +89,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -query_multi = QueryExpression( +query_multi = QueryStatement( dialect=dialect, select=[ now(dialect).as_("current_time"), @@ -108,7 +108,7 @@ # ============================================================ # FunctionCall supports arguments, e.g., DATE_FORMAT, ROUND -query_format = QueryExpression( +query_format = QueryStatement( dialect=dialect, select=[ FunctionCall(dialect, "DATE_FORMAT", now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), @@ -122,7 +122,7 @@ # ============================================================ # SECTION: SELECT with UUID() (MySQL 8.0+) # ============================================================ -query_uuid = QueryExpression( +query_uuid = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "UUID").as_("uuid_value")], ) @@ -141,7 +141,7 @@ # ============================================================ # Key points: # 1. Use factory functions (current_timestamp, now, current_date, etc.) for SQL niladic functions -# 2. QueryExpression without from_ clause generates "SELECT ..." without FROM +# 2. QueryStatement without from_ clause generates "SELECT ..." without FROM # 3. Use FunctionCall(dialect, 'FUNC', arg1, arg2) for regular functions with arguments # 4. Use .as_('alias') for column aliases in SELECT # 5. SQL:2003 niladic functions (CURRENT_TIMESTAMP, CURRENT_DATE, etc.) omit parentheses diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py index d297c685..56a9c82e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py @@ -26,13 +26,13 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, - CTEExpression, - WithQueryExpression, + DropTableStatement, + QueryStatement, + CTEClause, + WithStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 @@ -48,11 +48,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -74,7 +74,7 @@ delete_sql = "DELETE FROM employees" backend.execute(delete_sql) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["id", "name", "manager_id"], @@ -97,10 +97,10 @@ # ============================================================ # CTE simplifies complex queries by defining temporary named result sets -high_earners_cte = CTEExpression( +high_earners_cte = CTEClause( dialect=dialect, name="high_earners", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "employees"), @@ -108,10 +108,10 @@ ), ) -cte_query = WithQueryExpression( +cte_query = WithStatement( dialect=dialect, ctes=[high_earners_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "high_earners"), @@ -128,7 +128,7 @@ # Recursive CTE for hierarchical data (organizational chart) # Base case: top-level employees -base_query = QueryExpression( +base_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -140,17 +140,17 @@ where=ComparisonPredicate(dialect, "IS", Column(dialect, "manager_id"), Literal(dialect, None)), ) -org_cte = CTEExpression( +org_cte = CTEClause( dialect=dialect, name="org_chart", query=base_query, columns=["id", "name", "manager_id", "level"], ) -recursive_query = WithQueryExpression( +recursive_query = WithStatement( dialect=dialect, ctes=[org_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "manager_id")], from_=Table(dialect, "org_chart"), @@ -169,10 +169,10 @@ # ============================================================ # Multiple CTEs can be defined in a single WITH clause -active_cte = CTEExpression( +active_cte = CTEClause( dialect=dialect, name="active_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "employees"), @@ -180,10 +180,10 @@ ), ) -top_cte = CTEExpression( +top_cte = CTEClause( dialect=dialect, name="top_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "active_employees"), @@ -191,10 +191,10 @@ ), ) -multi_cte_query = WithQueryExpression( +multi_cte_query = WithStatement( dialect=dialect, ctes=[active_cte, top_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "top_employees"), @@ -208,7 +208,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -218,7 +218,7 @@ # ============================================================ # Key points: # 1. CTE requires MySQL 8.0+ -# 2. Use CTEExpression to define CTEs -# 3. Use WithQueryExpression to combine CTEs with a main query +# 2. Use CTEClause to define CTEs +# 3. Use WithStatement to combine CTEs with a main query # 4. Set recursive=True for recursive CTEs (hierarchical queries) -# 5. Multiple CTEs can be defined in a single WithQueryExpression +# 5. Multiple CTEs can be defined in a single WithStatement diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py index 4bdd4b9e..e825c13a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py @@ -27,10 +27,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -38,15 +38,15 @@ ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -59,7 +59,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -80,12 +80,12 @@ # SECTION: SELECT DISTINCT # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, SelectModifier, ) from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), @@ -102,7 +102,7 @@ # ============================================================ # SECTION: SELECT DISTINCT with multiple columns # ============================================================ -query_distinct = QueryExpression( +query_distinct = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -116,11 +116,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -129,6 +129,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SelectModifier.DISTINCT in QueryExpression +# 1. Use SelectModifier.DISTINCT in QueryStatement # 2. SELECT DISTINCT removes duplicate rows # 3. Works with multiple columns (distinct combo) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py index 839f742f..d27a51d9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py @@ -15,13 +15,13 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - QueryExpression, - ExplainExpression, - CreateIndexExpression, - DropTableExpression, + QueryStatement, + ExplainStatement, + CreateIndexStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements import ( @@ -44,11 +44,11 @@ backend.connect() dialect = backend.dialect -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -74,7 +74,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email"), table=Table(dialect, "users"), @@ -90,7 +90,7 @@ ("Charlie", "charlie@example.com"), ] for name, email in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "email"], @@ -104,7 +104,7 @@ # ============================================================ # 1. EXPLAIN for table scan (no index on name column) -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=Table(dialect, "users"), @@ -115,7 +115,7 @@ Literal(dialect, "Alice"), ), ) -explain_scan = ExplainExpression( +explain_scan = ExplainStatement( dialect=dialect, statement=query1, options=ExplainOptions(), @@ -128,7 +128,7 @@ print(f" {row}") # 2. EXPLAIN for index search (email has index) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=Table(dialect, "users"), @@ -139,7 +139,7 @@ Literal(dialect, "alice@example.com"), ), ) -explain_search = ExplainExpression( +explain_search = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(), @@ -152,7 +152,7 @@ print(f" {row}") # 3. EXPLAIN with FORMAT=JSON for detailed analysis -explain_json = ExplainExpression( +explain_json = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(format="JSON"), @@ -167,7 +167,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py index 8c05c361..943ba241 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py @@ -22,10 +22,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -34,11 +34,11 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "articles"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "articles"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "articles"), columns=[ @@ -58,7 +58,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "articles"), columns=["title", "content"], @@ -89,7 +89,7 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import QueryExpression, Column # noqa: E402 +from rhosocial.activerecord.backend.expression import QueryStatement, Column # noqa: E402 from rhosocial.activerecord.backend.impl.mysql.expression import MySQLMatchAgainstExpression, MatchAgainstMode # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -118,7 +118,7 @@ ) # Build query using expression (supports alias via as_) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py index 09315f8d..d8732f19 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,15 +36,15 @@ ColumnConstraintType, ) -drop_customers = DropTableExpression(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) +drop_customers = DropTableStatement(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) sql, params = drop_customers.to_sql() backend.execute(sql, params) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -create_customers = CreateTableExpression( +create_customers = CreateTableStatement( dialect=dialect, table=Table(dialect, "customers"), columns=[ @@ -68,7 +68,7 @@ sql, params = create_customers.to_sql() backend.execute(sql, params) -create_orders = CreateTableExpression( +create_orders = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ @@ -89,7 +89,7 @@ sql, params = create_orders.to_sql() backend.execute(sql, params) -insert_customers = InsertExpression( +insert_customers = InsertStatement( dialect=dialect, into=Table(dialect, "customers"), columns=["name", "email"], @@ -104,7 +104,7 @@ sql, params = insert_customers.to_sql() backend.execute(sql, params) -insert_orders = InsertExpression( +insert_orders = InsertStatement( dialect=dialect, into=Table(dialect, "orders"), columns=["customer_id", "total", "status"], @@ -124,7 +124,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, ) @@ -137,7 +137,7 @@ customers = NamedRelationRef(dialect, Table(dialect, "customers"), alias="c") orders = NamedRelationRef(dialect, Table(dialect, "orders"), alias="o") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name", table="c"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py index 12949501..309a3633 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py @@ -1,7 +1,7 @@ """ MySQL JSON_TABLE - Convert JSON data to relational format (MySQL 8.0+). -Demonstrates using MySQLJSONTableExpression with QueryExpression to build +Demonstrates using MySQLJSONTableExpression with QueryStatement to build a SELECT query that flattens JSON array data into relational rows. """ @@ -25,10 +25,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -37,11 +37,11 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ @@ -60,7 +60,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "orders"), columns=["order_data"], @@ -86,7 +86,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.impl.mysql.expression.json_table import ( # noqa: E402 @@ -111,11 +111,11 @@ json_table_sql, json_table_params = json_table.to_sql() print(f"JSON_TABLE SQL: {json_table_sql}") -# Build the query using QueryExpression with a comma-separated FROM clause +# Build the query using QueryStatement with a comma-separated FROM clause # (equivalent to an implicit CROSS JOIN). # Note: MySQLJSONTableExpression is not yet in the FromSourceType validation whitelist, # so we temporarily disable strict validation when generating SQL. -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id", table="o"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py index ad7ed5bb..6dda1d40 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -39,11 +39,11 @@ ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,7 +56,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -79,12 +79,12 @@ # SECTION: LIMIT (get first N rows) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -101,7 +101,7 @@ # ============================================================ # SECTION: OFFSET (skip first N rows) # ============================================================ -query_offset = QueryExpression( +query_offset = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -115,7 +115,7 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py index e2017892..ed3424b0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,11 +36,11 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -66,7 +66,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "department", "salary", "manager_id"], @@ -94,7 +94,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, WhereClause, ) from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 @@ -109,7 +109,7 @@ # 1. LIKE predicate - pattern matching # Find employees whose name starts with 'A' -like_query = QueryExpression( +like_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -134,7 +134,7 @@ # 2. IN predicate - list membership # Find employees in Engineering or Sales departments -in_query = QueryExpression( +in_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -158,7 +158,7 @@ # 3. BETWEEN predicate - range check # Find employees with salary between 65000 and 75000 -between_query = QueryExpression( +between_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -183,7 +183,7 @@ # 4. IS NULL / IS NOT NULL predicates # Find employees without a manager (manager_id IS NULL) -is_null_query = QueryExpression( +is_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -205,7 +205,7 @@ print(f"IS NULL Params: {params}") # Find employees with a manager (manager_id IS NOT NULL) -is_not_null_query = QueryExpression( +is_not_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -229,7 +229,7 @@ # 5. Combining predicates with AND/OR logic # Engineering employees earning between 75000 and 100000 -combined_query = QueryExpression( +combined_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py index 81971a5b..9b21773c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,15 +36,15 @@ ColumnConstraintType, ) -drop_departments = DropTableExpression(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) +drop_departments = DropTableStatement(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) sql, params = drop_departments.to_sql() backend.execute(sql, params) -drop_employees = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_employees = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_employees.to_sql() backend.execute(sql, params) -create_departments = CreateTableExpression( +create_departments = CreateTableStatement( dialect=dialect, table=Table(dialect, "departments"), columns=[ @@ -64,7 +64,7 @@ sql, params = create_departments.to_sql() backend.execute(sql, params) -create_employees = CreateTableExpression( +create_employees = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -85,7 +85,7 @@ sql, params = create_employees.to_sql() backend.execute(sql, params) -insert_departments = InsertExpression( +insert_departments = InsertStatement( dialect=dialect, into=Table(dialect, "departments"), columns=["name", "budget"], @@ -100,7 +100,7 @@ sql, params = insert_departments.to_sql() backend.execute(sql, params) -insert_employees = InsertExpression( +insert_employees = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "salary", "department_id"], @@ -121,7 +121,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, Subquery, @@ -130,7 +130,7 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -subquery_query = QueryExpression( +subquery_query = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "AVG", Column(dialect, "salary"))], from_=Table(dialect, "employees"), @@ -138,7 +138,7 @@ sql, params = subquery_query.to_sql() subquery = Subquery(dialect, sql, params) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py index acfa0090..64d9e76f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py @@ -1,5 +1,5 @@ """ -UNION using SetOperationExpression - MySQL. +UNION using SetOperationStatement - MySQL. This example demonstrates: 1. UNION (distinct) @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -41,11 +41,11 @@ ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -65,7 +65,7 @@ backend.execute(sql, params) # Insert data into table_a -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -85,25 +85,25 @@ # SECTION: UNION (distinct) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - SetOperationExpression, + QueryStatement, + SetOperationStatement, ) from rhosocial.activerecord.backend.expression.objects import Table # First query -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), ) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Literal(dialect, "Charlie")], ) # Union -union_expr = SetOperationExpression( +union_expr = SetOperationStatement( dialect=dialect, left=query1, operation="UNION", @@ -120,7 +120,7 @@ # ============================================================ # SECTION: UNION ALL # ============================================================ -union_all = SetOperationExpression( +union_all = SetOperationStatement( dialect=dialect, left=query1, operation="UNION", @@ -135,7 +135,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -144,6 +144,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SetOperationExpression with operation='UNION', all_=True for UNION ALL +# 1. Use SetOperationStatement with operation='UNION', all_=True for UNION ALL # 2. UNION removes duplicates, UNION ALL keeps all rows # 3. Can add ORDER BY using query1.with_order_by() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py index 7fcf6f61..e1544a38 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py @@ -24,10 +24,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -36,11 +36,11 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales_data"), columns=[ @@ -61,7 +61,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales_data"), columns=["salesperson", "region", "amount"], @@ -83,7 +83,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WindowSpecification, OrderByClause, @@ -91,7 +91,7 @@ from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "salesperson"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py index e5343307..30a5da5d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py @@ -34,10 +34,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -47,11 +47,11 @@ ) from rhosocial.activerecord.backend.expression.objects import Table -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales_data"), columns=[ @@ -72,7 +72,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales_data"), columns=["salesperson", "region", "amount"], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py index e33575a0..d0264420 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py @@ -33,17 +33,17 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, + DropTableStatement, CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, ) -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -67,7 +67,7 @@ MySQLSchemaDiffer, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AlterTableExpression, + AlterTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression import ( # noqa: E402 MySQLAddColumn, @@ -80,7 +80,7 @@ # Add `age` column between `name` and `email` — shifts email to position 4 add_col = MySQLAddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect)), after="name") -alter_expr = AlterTableExpression(dialect, Table(dialect, "users"), [add_col]) +alter_expr = AlterTableStatement(dialect, Table(dialect, "users"), [add_col]) sql, params = alter_expr.to_sql() backend.execute(sql, params) @@ -105,7 +105,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py index 9152d6e2..2b8afb12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py @@ -33,7 +33,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, + DropTableStatement, CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 @@ -43,15 +43,15 @@ MySQLLongTextType, ) -expr = DropTableExpression(dialect, Table(dialect, "documents56"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents56"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "documents57"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents57"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Simulate MySQL 5.6: store JSON as TEXT -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents56"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -64,7 +64,7 @@ sql, params = expr.to_sql() backend.execute(sql, params) # Simulate MySQL 5.7+: native JSON column -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents57"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -103,10 +103,10 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "documents56"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents56"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "documents57"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents57"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py index 2dac763e..559549ae 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py @@ -33,18 +33,18 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, + DropTableStatement, CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, ) -expr = DropTableExpression(dialect, Table(dialect, "demo"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "demo"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Create table without explicit charset to inherit database/server default -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "demo"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -80,7 +80,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "demo"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "demo"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py index 90928a24..89a6516c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py @@ -26,12 +26,12 @@ # Clean up any leftover tables from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, + DropTableStatement, ) -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "orders"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "orders"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) @@ -45,7 +45,7 @@ MySQLSchemaDiffer, ) from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, ColumnDefinition, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 @@ -57,7 +57,7 @@ snapshot_before = builder.build() # Create one table, drop another (if it existed) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -69,7 +69,7 @@ ) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -99,10 +99,10 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "orders"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "orders"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py index a82aef2f..35a0515b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py @@ -26,12 +26,12 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - UpdateExpression, - QueryExpression, + UpdateStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 @@ -45,11 +45,11 @@ from rhosocial.activerecord.backend.expression.objects import Table # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -81,7 +81,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -106,7 +106,7 @@ # Simple transaction - auto commits on success with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 50)}, @@ -116,7 +116,7 @@ backend.execute(sql, params, options=dml_options) # Verify -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -134,7 +134,7 @@ try: with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, -100)}, @@ -152,7 +152,7 @@ # MySQL supports savepoints for nested transactions with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 40)}, @@ -163,7 +163,7 @@ backend.transaction_manager.savepoint("sp1") try: - update_expr2 = UpdateExpression( + update_expr2 = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 20)}, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py index dcb05814..66d600c7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py @@ -13,12 +13,12 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, - UpdateExpression, + QueryStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause @@ -47,11 +47,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) dml_options = ExecutionOptions(stmt_type=StatementType.DML) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -84,7 +84,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -109,7 +109,7 @@ print("--- READ COMMITTED ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.READ_COMMITTED): # Read Alice's balance - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -120,7 +120,7 @@ print(f"READ COMMITTED - Alice's balance: {result.data[0]['balance']}") # Update within the transaction - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 1100)}, @@ -130,7 +130,7 @@ backend.execute(sql, params, options=dml_options) # After commit, the change is visible to all subsequent transactions -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -146,7 +146,7 @@ print("\n--- REPEATABLE READ (MySQL default) ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.REPEATABLE_READ): # First read - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -171,7 +171,7 @@ print("\n--- SERIALIZABLE ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): # Read all accounts - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -182,7 +182,7 @@ print(f"SERIALIZABLE - All accounts: {result.data}") # Update Bob's balance - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 600)}, @@ -199,7 +199,7 @@ # Transaction 1: SERIALIZABLE with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 1200)}, @@ -210,7 +210,7 @@ # Transaction 2: defaults to REPEATABLE READ (MySQL default) with backend.transaction(): - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -223,7 +223,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py index fef7ab97..e4c5e3a4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py @@ -28,12 +28,12 @@ from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, - UpdateExpression, + DropTableStatement, + QueryStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 @@ -49,11 +49,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) dml_options = ExecutionOptions(stmt_type=StatementType.DML) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -86,7 +86,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -109,7 +109,7 @@ # In a transaction, select with FOR UPDATE with backend.transaction(): # Lock Alice's row using ForUpdateClause - lock_query = QueryExpression( + lock_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -121,7 +121,7 @@ print(f"Locked row: {result.data}") # Update the balance - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 900)}, @@ -138,7 +138,7 @@ # Lock specific rows based on conditions with backend.transaction(): - lock_query = QueryExpression( + lock_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -154,7 +154,7 @@ # ============================================================ # Skip locked rows instead of waiting -skip_query = QueryExpression( +skip_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -171,7 +171,7 @@ # Fail immediately if rows are locked try: - nowait_query = QueryExpression( + nowait_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -198,7 +198,7 @@ from rhosocial.activerecord.backend.expression.objects import Table with backend.transaction(): - share_query = QueryExpression( + share_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -211,7 +211,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -220,7 +220,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use ForUpdateClause with QueryExpression for SELECT ... FOR UPDATE +# 1. Use ForUpdateClause with QueryStatement for SELECT ... FOR UPDATE # 2. ForUpdateClause(dialect, skip_locked=True) for SKIP LOCKED (MySQL 8.0+) # 3. ForUpdateClause(dialect, nowait=True) for NOWAIT (MySQL 8.0+) # 4. Use ForUpdateClause with LockStrength.SHARE for FOR SHARE (MySQL 8.0+) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py index 6e51ff1f..4b186c87 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py @@ -25,10 +25,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -37,12 +37,12 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) # Create table with JSON column (MySQL 5.7+) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents"), columns=[ @@ -61,7 +61,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "documents"), columns=["data"], @@ -80,13 +80,13 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.impl.mysql.functions.json import json_extract, json_unquote # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py index 3c185058..1fbb84ba 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py @@ -28,8 +28,8 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, - InsertExpression, + DropTableStatement, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 @@ -39,14 +39,14 @@ ColumnConstraintType, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents"), columns=[ @@ -70,7 +70,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "documents"), columns=["data"], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py index 7277c5dd..70306f5e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py @@ -1,5 +1,5 @@ """ -UPDATE using UpdateExpression - MySQL. +UPDATE using UpdateStatement - MySQL. This example demonstrates: 1. Update single row @@ -28,11 +28,11 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - UpdateExpression, + DropTableStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 @@ -45,11 +45,11 @@ from rhosocial.activerecord.backend.expression.objects import Table # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -76,7 +76,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age"], @@ -95,7 +95,7 @@ # ============================================================ # SECTION: Update Single Row # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 26)}, @@ -112,7 +112,7 @@ # ============================================================ # SECTION: Update with Expression # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={ @@ -128,7 +128,7 @@ # ============================================================ # SECTION: Update All Rows # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 99)}, @@ -141,7 +141,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -150,7 +150,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use UpdateExpression with assignments dict for SET clause +# 1. Use UpdateStatement with assignments dict for SET clause # 2. Use BinaryArithmeticExpression for expressions like age + 1 # 3. Omit where parameter to update all rows # 4. affected_rows shows number of updated rows diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py index cc6a1bf8..7569a049 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py @@ -3,13 +3,13 @@ from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import InsertExpression +from rhosocial.activerecord.backend.expression.statements import InsertStatement if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLInsertExpression(InsertExpression): +class MySQLInsertExpression(InsertStatement): """A MySQL INSERT statement extending the generic one. MySQL supports ``REPLACE INTO`` and ``INSERT IGNORE`` which have no direct diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py index e7ecb1d2..66e8dbae 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLFulltextIndexOptionsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLFulltextIndexOptionsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL FULLTEXT index options expression for CREATE TABLE / ALTER TABLE. Args: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py index d5089512..8fe76661 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -27,7 +27,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_EXTRACT expression. Extracts a value from a JSON document using a path. @@ -55,7 +55,7 @@ def format_method(self) -> str: return "format_json_extract" -class MySQLJSONObjectExpression(AliasableMixin, SQLValueExpression): +class MySQLJSONObjectExpression(AliasableMixin, SQLValue): """MySQL JSON_OBJECT expression. Creates a JSON object from key-value pairs. @@ -100,7 +100,7 @@ def format_method(self) -> str: return "format_json_object" -class MySQLJSONArrayExpression(AliasableMixin, SQLValueExpression): +class MySQLJSONArrayExpression(AliasableMixin, SQLValue): """MySQL JSON_ARRAY expression. Creates a JSON array from values. @@ -146,7 +146,7 @@ def format_method(self) -> str: return "format_json_array" -class MySQLJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_CONTAINS expression. Checks if a JSON document contains a specific value. @@ -176,7 +176,7 @@ def format_method(self) -> str: return "format_json_contains" -class MySQLJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_UNQUOTE expression. Removes quotes from a JSON-quoted string. @@ -204,7 +204,7 @@ def format_method(self) -> str: return "format_json_unquote" -class MySQLJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_SET expression. Inserts or updates values in a JSON document. @@ -241,7 +241,7 @@ def format_method(self) -> str: return "format_json_set" -class MySQLJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_REMOVE expression. Removes data from a JSON document. @@ -275,7 +275,7 @@ def format_method(self) -> str: return "format_json_remove" -class MySQLJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_TYPE expression. Returns the type of a JSON value. @@ -303,7 +303,7 @@ def format_method(self) -> str: return "format_json_type" -class MySQLJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_VALID expression. Checks whether a value is valid JSON. @@ -331,7 +331,7 @@ def format_method(self) -> str: return "format_json_valid" -class MySQLJSONSearchExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONSearchExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_SEARCH expression. Searches a JSON document for a string and returns the path. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py index 7dda6cf2..f609ed0c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py @@ -12,7 +12,7 @@ from enum import Enum from typing import List, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -127,7 +127,7 @@ def format_method(self) -> str: return "format_drop_json_duality_view_statement" -class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Duality Object SELECT expression. Wraps a DualityObjectSpec for SELECT JSON_DUALITY_OBJECT(...) FROM table. @@ -155,7 +155,7 @@ def format_method(self) -> str: return "format_duality_object_select" -class MySQLDualityObjectBodyExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDualityObjectBodyExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Duality Object body expression. Wraps a DualityObjectSpec for JSON_DUALITY_OBJECT( ... ). @@ -183,7 +183,7 @@ def format_method(self) -> str: return "format_duality_object_body" -class MySQLNestedDualityExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLNestedDualityExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL nested duality expression. Wraps a DualityNestedMapping for nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)). diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py index 5ca1b48e..d6b54967 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -28,7 +28,7 @@ class MatchAgainstMode: class MySQLMatchAgainstExpression( AliasableMixin, ComparisonMixin, - SQLValueExpression, + SQLValue, ): """MySQL MATCH...AGAINST expression. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py index c8bed4be..4d3b2add 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL SET literal value expression. Args: @@ -42,7 +42,7 @@ def format_method(self) -> str: return "format_set_literal" -class MySQLFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL FIND_IN_SET expression. Args: @@ -71,7 +71,7 @@ def format_method(self) -> str: return "format_find_in_set" -class MySQLSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL SET contains check expression. Args: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py index 44075757..5c7d39b8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -26,7 +26,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLSTGeomFromTextExpression(AliasableMixin, SQLValueExpression): +class MySQLSTGeomFromTextExpression(AliasableMixin, SQLValue): """MySQL ST_GeomFromText expression. Creates a geometry value from WKT. @@ -52,7 +52,7 @@ def format_method(self) -> str: return "format_st_geom_from_text" -class MySQLSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Distance expression. Returns the distance between two geometries. @@ -80,7 +80,7 @@ def format_method(self) -> str: return "format_st_distance" -class MySQLSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Within expression. Returns whether one geometry is within another. @@ -108,7 +108,7 @@ def format_method(self) -> str: return "format_st_within" -class MySQLSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Contains expression. Returns whether one geometry contains another. @@ -136,7 +136,7 @@ def format_method(self) -> str: return "format_st_contains" -class MySQLSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL spatial literal expression with optional SRID. Args: @@ -165,7 +165,7 @@ def format_method(self) -> str: return "format_spatial_literal" -class MySQLSTGeomFromWKBExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTGeomFromWKBExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_GeomFromWKB expression. Creates a geometry value from WKB (Well-Known Binary). @@ -196,7 +196,7 @@ def format_method(self) -> str: return "format_st_geom_from_wkb" -class MySQLSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_AsText expression. Converts a geometry value to its WKT (Well-Known Text) representation. @@ -224,7 +224,7 @@ def format_method(self) -> str: return "format_st_as_text" -class MySQLSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_AsGeoJSON expression. Converts a geometry value to GeoJSON format (MySQL 5.7.5+). @@ -252,7 +252,7 @@ def format_method(self) -> str: return "format_st_as_geojson" -class MySQLCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE SPATIAL INDEX expression. Args: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py index dae9928a..7b3a768a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLInlineIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLInlineIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL inline INDEX definition within CREATE TABLE. Args: @@ -39,7 +39,7 @@ def format_method(self) -> str: return "format_inline_index" -class MySQLStorageOptionsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLStorageOptionsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL table storage options expression (ENGINE, CHARSET, etc.). Args: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py index 16f35d70..780bbb1d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -27,7 +27,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLVectorExpression(AliasableMixin, SQLValueExpression): +class MySQLVectorExpression(AliasableMixin, SQLValue): """MySQL vector literal expression. Creates a vector value from array string. @@ -53,7 +53,7 @@ def format_method(self) -> str: return "format_vector_literal" -class MySQLDistanceEuclideanExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceEuclideanExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Euclidean distance expression. Example: @@ -79,7 +79,7 @@ def format_method(self) -> str: return "format_distance_euclidean" -class MySQLDistanceCosineExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceCosineExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Cosine distance expression. Example: @@ -105,7 +105,7 @@ def format_method(self) -> str: return "format_distance_cosine" -class MySQLDistanceDotExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceDotExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Dot product distance expression. Example: @@ -131,7 +131,7 @@ def format_method(self) -> str: return "format_distance_dot" -class MySQLStringToVectorExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLStringToVectorExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL STRING_TO_VECTOR expression. Converts a string representation to a VECTOR value. @@ -159,7 +159,7 @@ def format_method(self) -> str: return "format_string_to_vector" -class MySQLVectorToStringExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLVectorToStringExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL VECTOR_TO_STRING expression. Converts a VECTOR value to its string representation. @@ -187,7 +187,7 @@ def format_method(self) -> str: return "format_vector_to_string" -class MySQLVectorDimExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLVectorDimExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL VECTOR_DIM expression. Returns the dimension of a VECTOR value. @@ -215,7 +215,7 @@ def format_method(self) -> str: return "format_vector_dim" -class MySQLCreateVectorIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLCreateVectorIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE VECTOR INDEX expression. Args: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index b6d2cef3..afe395ef 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -37,18 +37,18 @@ AsyncIntrospectorExecutor, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, TableType, - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, IndexInfo, IndexColumnInfo, IndexType, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) from rhosocial.activerecord.backend.expression.types import DataType @@ -89,14 +89,14 @@ def _get_version(self) -> tuple: # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: + def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: version = self._get_version() version_str = ".".join(str(v) for v in version) db_name = self._get_default_schema() db_row = rows[0] if rows else {} - return DatabaseInfo( + return IntrospectionDatabaseInfo( name=db_name, version=version_str, version_tuple=version, @@ -105,7 +105,7 @@ def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: collation=db_row.get("DEFAULT_COLLATION_NAME"), ) - def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[TableInfo]: + def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: target_db = schema if schema is not None else self._get_default_schema() table_type_map = { "BASE TABLE": TableType.BASE_TABLE, @@ -116,7 +116,7 @@ def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> Li for row in rows: t_type = table_type_map.get(row.get("TABLE_TYPE", "BASE TABLE"), TableType.BASE_TABLE) tables.append( - TableInfo( + IntrospectionTableInfo( name=row["TABLE_NAME"], schema=target_db, table_type=t_type, @@ -135,14 +135,14 @@ def _parse_columns( rows: List[Dict[str, Any]], table_name: str, schema: str, - ) -> List[ColumnInfo]: + ) -> List[IntrospectionColumnInfo]: columns = [] for row in rows: nullable = ColumnNullable.NULLABLE if row.get("IS_NULLABLE") == "YES" else ColumnNullable.NOT_NULL col_type = row.get("COLUMN_TYPE") or row.get("DATA_TYPE") or "VARCHAR" parsed = DataType.parse_data_type_str(self._backend.dialect, col_type) columns.append( - ColumnInfo( + IntrospectionColumnInfo( name=row["COLUMN_NAME"], table_name=table_name, schema=schema, @@ -206,7 +206,7 @@ def _parse_foreign_keys( rows: List[Dict[str, Any]], table_name: str, schema: str, - ) -> List[ForeignKeyInfo]: + ) -> List[IntrospectionForeignKeyInfo]: action_map = { "NO_ACTION": ReferentialAction.NO_ACTION, "NO ACTION": ReferentialAction.NO_ACTION, @@ -217,13 +217,13 @@ def _parse_foreign_keys( "SET_DEFAULT": ReferentialAction.SET_DEFAULT, "SET DEFAULT": ReferentialAction.SET_DEFAULT, } - fk_map: Dict[str, ForeignKeyInfo] = {} + fk_map: Dict[str, IntrospectionForeignKeyInfo] = {} for row in rows: fk_name = row.get("CONSTRAINT_NAME", "") if fk_name not in fk_map: on_update_raw = (row.get("UPDATE_RULE") or "NO ACTION").upper() on_delete_raw = (row.get("DELETE_RULE") or "NO ACTION").upper() - fk_map[fk_name] = ForeignKeyInfo( + fk_map[fk_name] = IntrospectionForeignKeyInfo( name=fk_name, table_name=table_name, schema=schema, @@ -237,9 +237,9 @@ def _parse_foreign_keys( fk_map[fk_name].referenced_columns.append(row.get("REFERENCED_COLUMN_NAME", "")) return list(fk_map.values()) - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo]: + def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: return [ - ViewInfo( + IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", "")), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -254,11 +254,11 @@ def _parse_view_info( rows: List[Dict[str, Any]], view_name: str, schema: str, - ) -> Optional[ViewInfo]: + ) -> Optional[IntrospectionViewInfo]: if not rows: return None row = rows[0] - return ViewInfo( + return IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", view_name)), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -266,9 +266,9 @@ def _parse_view_info( is_updatable=row.get("IS_UPDATABLE") == "YES", ) - def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: + def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: return [ - TriggerInfo( + IntrospectionTriggerInfo( name=row.get("TRIGGER_NAME", ""), table_name=row.get("EVENT_OBJECT_TABLE", ""), schema=schema, @@ -317,7 +317,7 @@ def status(self) -> SyncMySQLStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: + def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) cached = self._get_cached(key) if cached is not None: @@ -373,7 +373,7 @@ def status(self) -> AsyncMySQLStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: + async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) cached = self._get_cached(key) if cached is not None: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py index a2ad3825..081eb7eb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py @@ -14,7 +14,7 @@ from typing import FrozenSet, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction __all__ = [ "MySQLCharset", @@ -407,8 +407,8 @@ def supports_collate_expression(self) -> bool: """MySQL supports expression-level COLLATE.""" return True - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate a ``CollateExpression`` and return its collation SQL.""" + def validate_collation_name(self, expr: "CollateFunction") -> str: + """Validate a ``CollateFunction`` and return its collation SQL.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if expr.collation_options: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py index 61e0397f..15ef797c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py @@ -11,9 +11,9 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -50,7 +50,7 @@ def supports_database_collation(self) -> bool: return True def format_create_database_statement( - self, expr: CreateDatabaseExpression + self, expr: CreateDatabaseStatement ) -> Tuple[str, tuple]: """Format ``CREATE DATABASE``. @@ -61,7 +61,7 @@ def format_create_database_statement( database's -- ``CREATE DATABASE `users``` is a well-formed statement addressing something else. """ - require_kind(expr.database, Database, "CreateDatabaseExpression.database") + require_kind(expr.database, Database, "CreateDatabaseStatement.database") parts = ["CREATE DATABASE"] if expr.if_not_exists: parts.append("IF NOT EXISTS") @@ -73,7 +73,7 @@ def format_create_database_statement( return " ".join(parts), () def format_drop_database_statement( - self, expr: DropDatabaseExpression + self, expr: DropDatabaseStatement ) -> Tuple[str, tuple]: """Format ``DROP DATABASE``. @@ -81,7 +81,7 @@ def format_drop_database_statement( TypeError: ``expr.database`` is not a Database; another object kind would be named as the database's. """ - require_kind(expr.database, Database, "DropDatabaseExpression.database") + require_kind(expr.database, Database, "DropDatabaseStatement.database") parts = ["DROP DATABASE"] if expr.if_exists: parts.append("IF EXISTS") @@ -89,7 +89,7 @@ def format_drop_database_statement( return " ".join(parts), () def format_alter_database_statement( - self, expr: AlterDatabaseExpression + self, expr: AlterDatabaseStatement ) -> Tuple[str, tuple]: """Format ``ALTER DATABASE``. @@ -99,7 +99,7 @@ def format_alter_database_statement( """ from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction - require_kind(expr.database, Database, "AlterDatabaseExpression.database") + require_kind(expr.database, Database, "AlterDatabaseStatement.database") parts = ["ALTER DATABASE"] parts.append(expr.database.to_sql()[0]) if expr.action == AlterDatabaseAction.CHARACTER_SET: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 1b557faf..9e54dc14 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, - CreateTableLikeExpression, + CreateTableLikeStatement, IndexDefinition, StorageOptionsExpression, TableConstraint, @@ -77,7 +77,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.table, Table, "CreateTableExpression.table") + require_kind(expr.table, Table, "CreateTableStatement.table") if expr.tablespace: raise UnsupportedFeatureError( self.name, "TABLESPACE", @@ -167,7 +167,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) - def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") -> Tuple[str, tuple]: + def format_create_table_like_statement(self, expr: "CreateTableLikeStatement") -> Tuple[str, tuple]: """Render ``CREATE TABLE ... LIKE`` via the generic TableMixin formatter.""" return super().format_create_table_like_statement(expr) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py index 46e9cc6a..b9b6c14f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py @@ -50,7 +50,7 @@ def supports_with_data_clause(self) -> bool: """ return False - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str, tuple]: """Format CREATE VIEW statement for MySQL. Raises: @@ -59,7 +59,7 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st which is a well-formed statement creating a view over something the caller never named. """ - require_kind(expr.view, View, "CreateViewExpression.view") + require_kind(expr.view, View, "CreateViewStatement.view") parts = ["CREATE"] if expr.temporary: @@ -90,7 +90,7 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st return " ".join(parts), query_params - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + def format_drop_view_statement(self, expr: "DropViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW statement for MySQL. The CASCADE / RESTRICT pair is consumed here: MySQL does not provide @@ -104,7 +104,7 @@ def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, t UnsupportedFeatureError: If the dialect does not accept the requested behavior keyword. """ - require_kind(expr.view, View, "DropViewExpression.view") + require_kind(expr.view, View, "DropViewStatement.view") if expr.cascade and not self.supports_cascade_view(): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py index 67fc23b3..5634c864 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py @@ -21,7 +21,7 @@ def supports_explain_format(self, format_type: str) -> bool: else: return False - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: + def format_explain_statement(self, explain_expr: "ExplainStatement") -> tuple: """Build the MySQL EXPLAIN SQL string and return (sql, params).""" from rhosocial.activerecord.backend.expression.statements import ExplainType diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py index fb3261fa..9ef93b43 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py @@ -5,8 +5,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) #: What the two formatters below accept. Core used to have one class for @@ -14,8 +14,8 @@ #: ``->`` and ``->>`` from these same two methods, so the parameter is a #: union rather than either class alone. #: - #: The union is spelled out even though ``JSONTextExpression`` subclasses - #: ``JSONDocumentExpression`` and a checker would collapse it. That + #: The union is spelled out even though ``JSONTextValue`` subclasses + #: ``JSONDocumentValue`` and a checker would collapse it. That #: inheritance exists so a text access keeps the accessors that let a path #: chain continue; it is not a claim that ``->>`` yields a document. #: @@ -26,7 +26,7 @@ #: ``->`` and ``->>`` had a result type worth naming a class after. A #: non-arrow operation says nothing about what comes back, so no class #: asserts anything for it and the ``operation`` field alone decides. - JSONPathNode = Union["JSONDocumentExpression", "JSONTextExpression"] + JSONPathNode = Union["JSONDocumentValue", "JSONTextValue"] class MySQLJSONFunctionMixin: @@ -245,8 +245,8 @@ def format_json_arrow_expression(self, expr: "JSONPathNode") -> Tuple[str, Tuple 1. The JSON path as a string literal, not a parameter placeholder 2. No parentheses around the expression - Takes either JSON path node -- :class:`JSONDocumentExpression` for - ``->``, :class:`JSONTextExpression` for ``->>`` -- and tells them apart + Takes either JSON path node -- :class:`JSONDocumentValue` for + ``->``, :class:`JSONTextValue` for ``->>`` -- and tells them apart by ``expr.operation``, not by class, so which one the caller built does not change the SQL. Any other operation is emitted as an infix comparison with the path bound; see :class:`JSONPathNode` for why that diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py index d32f2b0c..b0654fde 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py @@ -7,7 +7,7 @@ name work without this dialect assembling a prefix, and it is also why a wrong kind used to render quietly. -``CreateDatabaseExpression(dialect, Table(dialect, "users"))`` reached +``CreateDatabaseStatement(dialect, Table(dialect, "users"))`` reached ``format_database_object`` -- the ``Table``'s own method -- and produced ``CREATE DATABASE `users```: well-formed SQL naming something other than what the caller asked for. No error, no warning, and a statement the server would @@ -23,7 +23,7 @@ The message is deliberately identical in shape to core's, which writes the same check out inline: - CreateDatabaseExpression.database must be a Database, got Table + CreateDatabaseStatement.database must be a Database, got Table Matching the wording means a caller who reads one dialect's error can read every dialect's, and a test may assert on it without knowing which dialect diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 6e4071c3..91e59456 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -454,14 +454,14 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, ) # MySQL's database is the catalog and there is no inner schema, so # ``information_schema`` is the outer slot. Putting it in ``schema_name`` # would be refused rather than silently dropped. partitions = Table(expr.dialect, "PARTITIONS", catalog_name="information_schema") - query = QueryExpression( + query = QueryStatement( expr.dialect, select=[ Column(expr.dialect, "PARTITION_NAME", alias="name"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py index b899be0c..487d8d60 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py @@ -153,7 +153,7 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, return f"SET TRANSACTION {' '.join(parts)}", () - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format START TRANSACTION statement for MySQL.""" from rhosocial.activerecord.backend.transaction import TransactionMode diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py index 3215c55c..b144a27f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py @@ -7,8 +7,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) @@ -47,7 +47,7 @@ def supports_trigger_if_not_exists(self) -> bool: """MySQL 5.7+ supports IF NOT EXISTS.""" return self.version >= (5, 7, 0) - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement (MySQL syntax). Raises: @@ -59,11 +59,11 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.trigger, Trigger, "CreateTriggerExpression.trigger") - require_kind(expr.table, Table, "CreateTriggerExpression.table") + require_kind(expr.trigger, Trigger, "CreateTriggerStatement.trigger") + require_kind(expr.table, Table, "CreateTriggerStatement.table") if expr.function is not None: require_kind( - expr.function, Function, "CreateTriggerExpression.function" + expr.function, Function, "CreateTriggerStatement.function" ) if not self.supports_trigger(): raise UnsupportedFeatureError(self.name, "triggers") @@ -109,7 +109,7 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu return " ".join(parts), () - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format DROP TRIGGER statement (MySQL syntax). Raises: @@ -117,7 +117,7 @@ def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[ """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.trigger, Trigger, "DropTriggerExpression.trigger") + require_kind(expr.trigger, Trigger, "DropTriggerStatement.trigger") if not self.supports_trigger(): raise UnsupportedFeatureError(self.name, "triggers") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py index 727f87d3..b9e81399 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) @@ -32,7 +32,7 @@ def supports_truncate_restart_identity(self) -> bool: def supports_truncate_cascade(self) -> bool: return False - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format MySQL ``TRUNCATE [TABLE] tbl_name``. Raises: @@ -40,7 +40,7 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu so a View or a Sequence in that slot would render a statement emptying something else. """ - require_kind(expr.table, Table, "TruncateExpression.table") + require_kind(expr.table, Table, "TruncateStatement.table") if expr.restart_identity or expr.continue_identity: if expr.restart_identity: feature = "TRUNCATE ... RESTART IDENTITY" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index da9cc1f8..7140035a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -19,11 +19,11 @@ ChangeColumn, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableLikeExpression, + CreateTableLikeStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.admin import ( MySQLCacheIndexExpression, @@ -189,7 +189,7 @@ def format_on_conflict_clause(self, expr: "OnConflictClause") -> Tuple[str, tupl ON CONFLICT clause for upsert operations. Args: - expr: OnConflictExpression or equivalent instance + expr: OnConflictClause or equivalent instance Returns: Tuple of (SQL string, parameters tuple) @@ -264,22 +264,22 @@ def supports_trigger_when(self) -> bool: """ ... - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement. Args: - expr: CreateTriggerExpression instance + expr: CreateTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format DROP TRIGGER statement. Args: - expr: DropTriggerExpression instance + expr: DropTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -353,7 +353,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: ... def format_create_table_like_statement( - self, expr: "CreateTableLikeExpression" + self, expr: "CreateTableLikeStatement" ) -> Tuple[str, tuple]: """Format CREATE TABLE ... LIKE statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/mysql/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/transaction.py index 805e9c35..e3aaf4ca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/transaction.py @@ -25,7 +25,7 @@ class MySQLTransactionManager(MySQLTransactionMixin, TransactionManager): START TRANSACTION when a specific isolation level is needed. This class overrides _do_begin() to handle this sequencing. - The format_begin_transaction() in MySQLDialect returns only "START TRANSACTION", + The format_begin_transaction_statement() in MySQLDialect returns only "START TRANSACTION", while this class handles the SET TRANSACTION step separately. Non-I/O methods (isolation_level, _build_set_isolation_sql, _ISOLATION_LEVELS) diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 283b48d8..b8b800bf 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -385,7 +385,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -397,7 +397,7 @@ def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> try: backend.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend.dialect, table_name), if_exists=True, @@ -603,7 +603,7 @@ async def _setup_async_model( async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -612,7 +612,7 @@ async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: try: await backend.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend.dialect, table_name), if_exists=True, diff --git a/tests/providers/events.py b/tests/providers/events.py index a2b9bf75..ebd02091 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -117,7 +117,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -130,7 +130,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -196,7 +196,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -209,7 +209,7 @@ async def _setup_async_model( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py index 92297c44..6629b6ae 100644 --- a/tests/providers/fixtures/__init__.py +++ b/tests/providers/fixtures/__init__.py @@ -2,9 +2,9 @@ """MySQL-specific DDL expression fixtures for the testsuite providers. Each module in this package exposes a ``TABLE_EXPRESSIONS`` mapping of -table name -> factory callable ``Callable[[DialectLike, str], CreateTableExpression]``. +table name -> factory callable ``Callable[[DialectLike, str], CreateTableStatement]``. -The factory functions build :class:`CreateTableExpression` instances that emit +The factory functions build :class:`CreateTableStatement` instances that emit MySQL-compatible DDL (ENGINE / CHARSET / COLLATE / inline indexes / FK with ON DELETE CASCADE / TINYINT(1) / DATETIME(6) / ENUM / JSON). The pre-existing ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 15a24271..19861db0 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -11,7 +11,7 @@ Rather than modifying the MySQL backend library source (which is out of scope for this refactor), :func:`to_mysql_ddl_sql` post-processes the SQL -produced by ``CreateTableExpression.to_sql()`` so that the generated DDL +produced by ``CreateTableStatement.to_sql()`` so that the generated DDL matches the reference files byte-for-byte for the bits that matter: * ``KEY='value'`` storage options become ``KEY=value``. @@ -28,8 +28,8 @@ from typing import Tuple from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ReferentialAction from rhosocial.activerecord.backend.expression.objects import Table @@ -47,7 +47,7 @@ def _strip_storage_option_quotes(sql: str) -> str: return _STORAGE_OPTION_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}", sql) -def _append_fk_actions(expr: CreateTableExpression, sql: str) -> str: +def _append_fk_actions(expr: CreateTableStatement, sql: str) -> str: """Append ``ON DELETE`` / ``ON UPDATE`` clauses to inline FK constraints. The MySQL dialect's ``format_table_constraint`` does not currently emit @@ -87,8 +87,8 @@ def repl(match: re.Match) -> str: return _FK_RE.sub(repl, sql) -def to_mysql_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: - """Generate canonical MySQL DDL for a :class:`CreateTableExpression`. +def to_mysql_ddl_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: + """Generate canonical MySQL DDL for a :class:`CreateTableStatement`. Post-processes the dialect output to: 1. Drop quotes around storage option values. @@ -103,14 +103,14 @@ def to_mysql_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: return sql, params -def create_table_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: +def create_table_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: """Public alias for :func:`to_mysql_ddl_sql`.""" return to_mysql_ddl_sql(expr) -def drop_table(dialect, table_name: str) -> DropTableExpression: +def drop_table(dialect, table_name: str) -> DropTableStatement: """Build a canonical ``DROP TABLE IF EXISTS`` expression.""" - return DropTableExpression( + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 2421d831..3cbb0def 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/basic.py """DDL expressions for the ``feature/basic`` table group (MySQL). -Each factory builds a :class:`CreateTableExpression` whose generated MySQL +Each factory builds a :class:`CreateTableStatement` whose generated MySQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature/basic/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -16,7 +16,7 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -59,8 +59,8 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): - """Route a CreateTableExpression through the canonical MySQL DDL post-processor.""" +def to_sql(expr: CreateTableStatement): + """Route a CreateTableStatement through the canonical MySQL DDL post-processor.""" return _common.to_mysql_ddl_sql(expr) @@ -68,8 +68,8 @@ def to_sql(expr: CreateTableExpression): # basic/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -100,8 +100,8 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # basic/type_cases.sql # --------------------------------------------------------------------------- -def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: - return CreateTableExpression( +def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -138,8 +138,8 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa # basic/type_tests.sql # --------------------------------------------------------------------------- -def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -175,10 +175,10 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa # basic/validated_field_users.sql # --------------------------------------------------------------------------- -def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableStatement: # MySQL ENUM column for status. status_enum = MySQLEnumType(dialect, ['active', 'inactive', 'banned', 'pending', 'suspended']) - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -208,8 +208,8 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie # basic/validated_users.sql # --------------------------------------------------------------------------- -def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -230,8 +230,8 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - # basic/pydantic_validated_models.sql # --------------------------------------------------------------------------- -def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableExpression: - return CreateTableExpression( +def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -256,8 +256,8 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ # basic/bulk_users.sql # --------------------------------------------------------------------------- -def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: - return CreateTableExpression( +def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -279,8 +279,8 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa # basic/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -311,8 +311,8 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # basic/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -349,8 +349,8 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # basic/column_mapping_items.sql # --------------------------------------------------------------------------- -def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableExpression: - return CreateTableExpression( +def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -371,8 +371,8 @@ def create_column_mapping_items_table(dialect, table_name: str = "column_mapping # basic/mixed_annotation_items.sql # --------------------------------------------------------------------------- -def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableExpression: - return CreateTableExpression( +def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -394,8 +394,8 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota # basic/type_adapter_tests.sql (no ENGINE/CHARSET in reference file) # --------------------------------------------------------------------------- -def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, @@ -419,8 +419,8 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes # basic/order_items.sql (composite PK, FK -> orders) # --------------------------------------------------------------------------- -def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -447,8 +447,8 @@ def create_composite_pk_order_items_table(dialect, table_name: str = "order_item # basic/store_inventory.sql (composite PK, no FK) # --------------------------------------------------------------------------- -def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: - return CreateTableExpression( +def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -475,8 +475,8 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - # basic/orders.sql (single PK, no FK in composite-PK scenario) # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -496,8 +496,8 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # basic/product.sql (single PK) # --------------------------------------------------------------------------- -def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: - return CreateTableExpression( +def create_product_table(dialect, table_name: str = "product") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index fb59c269..4d6b048d 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -7,7 +7,7 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -31,7 +31,7 @@ } -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -39,8 +39,8 @@ def to_sql(expr: CreateTableExpression): # events/event_tests.sql # --------------------------------------------------------------------------- -def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -67,8 +67,8 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create # events/event_tracking_models.sql # --------------------------------------------------------------------------- -def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index dea55cb8..054a1723 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -7,7 +7,7 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -34,7 +34,7 @@ } -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -42,8 +42,8 @@ def to_sql(expr: CreateTableExpression): # mixins/combined_articles.sql # --------------------------------------------------------------------------- -def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: - return CreateTableExpression( +def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -73,8 +73,8 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles # mixins/tasks.sql # --------------------------------------------------------------------------- -def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: - return CreateTableExpression( +def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -97,8 +97,8 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress # mixins/timestamped_posts.sql # --------------------------------------------------------------------------- -def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: - return CreateTableExpression( +def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -120,8 +120,8 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts # mixins/versioned_products.sql # --------------------------------------------------------------------------- -def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: - return CreateTableExpression( +def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index ef147b71..f32ecaa5 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/query.py """DDL expressions for the ``feature/query`` table group (MySQL). -Each factory builds a :class:`CreateTableExpression` whose generated MySQL DDL +Each factory builds a :class:`CreateTableStatement` whose generated MySQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature/query/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -11,7 +11,7 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, @@ -44,7 +44,7 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -52,8 +52,8 @@ def to_sql(expr: CreateTableExpression): # query/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -84,8 +84,8 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # query/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -119,8 +119,8 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # query/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -155,8 +155,8 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # query/orders.sql # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -188,8 +188,8 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # query/order_items.sql # --------------------------------------------------------------------------- -def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -224,8 +224,8 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create # query/profiles.sql # --------------------------------------------------------------------------- -def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: - return CreateTableExpression( +def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -250,8 +250,8 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE # query/json_users.sql # --------------------------------------------------------------------------- -def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: - return CreateTableExpression( +def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -281,8 +281,8 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa # query/nodes.sql (self-referential FK) # --------------------------------------------------------------------------- -def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: - return CreateTableExpression( +def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -312,8 +312,8 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress # query/searchable_items.sql (no CHARSET/COLLATE in reference file) # --------------------------------------------------------------------------- -def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: - return CreateTableExpression( +def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -333,8 +333,8 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") # query/extended_orders.sql # --------------------------------------------------------------------------- -def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, @@ -383,8 +383,8 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - # query/extended_order_items.sql # --------------------------------------------------------------------------- -def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index bfb72715..2d88f5f4 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -137,7 +137,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -150,7 +150,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -222,7 +222,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -235,7 +235,7 @@ async def _setup_async_model( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, diff --git a/tests/providers/query.py b/tests/providers/query.py index edb93c0c..ef16bf8c 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -160,7 +160,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -173,7 +173,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -221,7 +221,7 @@ def _setup_multiple_models( def _reset_schema(self, backend_instance, table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -231,7 +231,7 @@ def _reset_schema(self, backend_instance, table_name: str) -> None: try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -319,7 +319,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: """Set up the composite-PK OrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_mysql_ddl_sql @@ -332,7 +332,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, "order_items"), if_exists=True, @@ -461,7 +461,7 @@ async def _setup_model_async( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -479,7 +479,7 @@ async def _setup_model_async( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -630,7 +630,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe """Set up the composite-PK AsyncOrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_mysql_ddl_sql @@ -644,7 +644,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, "order_items"), if_exists=True, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py index 6a46dc72..f021a7ae 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, GeneratedColumnExpression, GeneratedColumnType, IndexDefinition, @@ -385,7 +385,7 @@ def test_table_declarations_preserve_identity_order_and_composite_primary_key(): def test_collected_table_options_render_with_mysql_dialect(): table_options = SyncDDLSource.table_options() - expression = CreateTableExpression( + expression = CreateTableStatement( DIALECT, Table(DIALECT, "ddl_source_orders"), [ColumnDefinition(DIALECT, "id", IntegerType(DIALECT))], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py index ba307060..e003af99 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -1,7 +1,7 @@ # tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py """MySQL inline-index support for direct DDL expressions. -The DDLSource index declaration is passed directly to CreateTableExpression +The DDLSource index declaration is passed directly to CreateTableStatement on a dialect that advertises ``supports_inline_index()``. """ @@ -13,7 +13,7 @@ from rhosocial.activerecord.base import UseIndex from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -34,7 +34,7 @@ def mysql_dialect(): def _create_indexed_table(): dialect = mysql_dialect() - return CreateTableExpression( + return CreateTableStatement( dialect, Table(dialect, Indexed.__table_name__), columns=[ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py index 4b114ff8..337ffd17 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py @@ -12,7 +12,7 @@ from rhosocial.activerecord.backend.dialect import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - InsertExpression, + InsertStatement, Literal, OnConflictClause, ValuesSource, @@ -41,7 +41,7 @@ def test_multiple_on_conflict_clauses_rejected(self, dialect): source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause1 = OnConflictClause(dialect, conflict_target=["col_a"], do_nothing=True) clause2 = OnConflictClause(dialect, conflict_target=["col_b"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, "t"), source=source, on_conflict=[clause1, clause2]) + expr = InsertStatement(dialect, into=Table(dialect, "t"), source=source, on_conflict=[clause1, clause2]) with pytest.raises(UnsupportedFeatureError, match="multiple ON CONFLICT clauses"): expr.to_sql() @@ -57,7 +57,7 @@ def test_on_duplicate_key_update(self, dialect): conflict_target=["id"], update_assignments={"name": Column(dialect, "name", "excluded")}, ) - expr = InsertExpression( + expr = InsertStatement( dialect, into=Table(dialect, "users"), columns=["id", "name"], source=source, on_conflict=clause ) sql, params = expr.to_sql() @@ -71,7 +71,7 @@ def test_on_duplicate_key_do_nothing_noop(self, dialect): """do_nothing renders the MySQL no-op UPDATE id = id.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause = OnConflictClause(dialect, conflict_target=["id"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, "users"), columns=["id"], source=source, on_conflict=clause) + expr = InsertStatement(dialect, into=Table(dialect, "users"), columns=["id"], source=source, on_conflict=clause) sql, params = expr.to_sql() assert sql == ( 'INSERT INTO `users` (`id`) VALUES (%s) ' diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py index 8ade2910..c78514e6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py @@ -4,25 +4,25 @@ MySQL Transaction Behavior: - Isolation level must be set BEFORE START TRANSACTION using SET TRANSACTION - START TRANSACTION can include READ ONLY / READ WRITE modes -- The dialect's format_begin_transaction() only returns START TRANSACTION +- The dialect's format_begin_transaction_statement() only returns START TRANSACTION - SetTransactionExpression is used for isolation level settings """ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, + BeginTransactionStatement, + CommitStatement, + RollbackStatement, + SavepointStatement, + ReleaseSavepointStatement, SetTransactionExpression, ) from rhosocial.activerecord.backend.transaction import IsolationLevel class TestMySQLBeginTransactionExpression: - """Tests for MySQL BeginTransactionExpression. + """Tests for MySQL BeginTransactionStatement. Note: MySQL does not support isolation level in START TRANSACTION. The dialect's supports_isolation_level_in_begin() returns False. @@ -31,7 +31,7 @@ class TestMySQLBeginTransactionExpression: def test_basic_begin(self, mysql_dialect): """Test basic START TRANSACTION.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "START TRANSACTION" assert params == () @@ -40,10 +40,10 @@ def test_begin_with_isolation_level_returns_only_start(self, mysql_dialect): """Test that isolation level is NOT included in START TRANSACTION. MySQL requires SET TRANSACTION ISOLATION LEVEL to be executed - separately before START TRANSACTION. The dialect's format_begin_transaction() + separately before START TRANSACTION. The dialect's format_begin_transaction_statement() only returns the START TRANSACTION statement. """ - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level in START TRANSACTION @@ -54,7 +54,7 @@ def test_begin_with_isolation_level_returns_only_start(self, mysql_dialect): def test_begin_read_only(self, mysql_dialect): """Test START TRANSACTION READ ONLY.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.read_only() sql, params = expr.to_sql() assert sql == "START TRANSACTION READ ONLY" @@ -62,7 +62,7 @@ def test_begin_read_only(self, mysql_dialect): def test_begin_read_write(self, mysql_dialect): """Test START TRANSACTION READ WRITE.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.read_write() sql, params = expr.to_sql() assert sql == "START TRANSACTION" @@ -72,10 +72,10 @@ def test_begin_with_isolation_and_read_only(self, mysql_dialect): """Test START TRANSACTION with isolation level and READ ONLY. The isolation level is ignored by the dialect in START TRANSACTION. - Use SetTransactionExpression for isolation level, then BeginTransactionExpression + Use SetTransactionExpression for isolation level, then BeginTransactionStatement for READ ONLY mode. """ - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.isolation_level(IsolationLevel.READ_COMMITTED).read_only() sql, params = expr.to_sql() # Only READ ONLY mode is included, isolation level is NOT @@ -96,7 +96,7 @@ def test_begin_with_isolation_returns_start_transaction(self, mysql_dialect, lev MySQL requires separate SET TRANSACTION ISOLATION LEVEL statement. """ - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.isolation_level(level) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level @@ -158,21 +158,21 @@ class TestMySQLCommitRollback: def test_commit(self, mysql_dialect): """Test COMMIT statement.""" - expr = CommitTransactionExpression(mysql_dialect) + expr = CommitStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "COMMIT" assert params == () def test_rollback(self, mysql_dialect): """Test ROLLBACK statement.""" - expr = RollbackTransactionExpression(mysql_dialect) + expr = RollbackStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "ROLLBACK" assert params == () def test_rollback_to_savepoint(self, mysql_dialect): """Test ROLLBACK TO SAVEPOINT statement.""" - expr = RollbackTransactionExpression(mysql_dialect) + expr = RollbackStatement(mysql_dialect) expr.to_savepoint("my_savepoint") sql, params = expr.to_sql() assert "ROLLBACK" in sql @@ -185,7 +185,7 @@ class TestMySQLSavepoint: def test_savepoint(self, mysql_dialect): """Test SAVEPOINT statement.""" - expr = SavepointExpression(mysql_dialect, "my_savepoint") + expr = SavepointStatement(mysql_dialect, "my_savepoint") sql, params = expr.to_sql() assert "SAVEPOINT" in sql assert "my_savepoint" in sql @@ -193,7 +193,7 @@ def test_savepoint(self, mysql_dialect): def test_release_savepoint(self, mysql_dialect): """Test RELEASE SAVEPOINT statement.""" - expr = ReleaseSavepointExpression(mysql_dialect, "my_savepoint") + expr = ReleaseSavepointStatement(mysql_dialect, "my_savepoint") sql, params = expr.to_sql() assert "RELEASE SAVEPOINT" in sql assert "my_savepoint" in sql @@ -238,7 +238,7 @@ def test_supports_transaction_wait_is_declined(self, mysql_dialect): @pytest.mark.parametrize("spelling", ["wait", "no_wait"]) def test_begin_refuses_wait_by_name(self, mysql_dialect, spelling): - expr = BeginTransactionExpression(mysql_dialect, **{spelling: True}) + expr = BeginTransactionStatement(mysql_dialect, **{spelling: True}) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() expected = "NO WAIT" if spelling == "no_wait" else "WAIT" @@ -259,7 +259,7 @@ def test_set_refuses_wait_by_name(self, mysql_dialect, spelling): assert "NO WAIT" not in message, message def test_neither_spelling_renders_neither_token(self, mysql_dialect): - begin, _ = BeginTransactionExpression(mysql_dialect).to_sql() + begin, _ = BeginTransactionStatement(mysql_dialect).to_sql() set_tx, _ = SetTransactionExpression(mysql_dialect).to_sql() assert begin == "START TRANSACTION" assert set_tx == "SET TRANSACTION" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py index a64e13bd..6336ba7c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, ) @@ -18,14 +18,14 @@ class TestListColumns: """Tests for list_columns method.""" def test_list_columns_returns_column_info(self, backend_with_tables): - """Test that list_columns returns ColumnInfo objects.""" + """Test that list_columns returns IntrospectionColumnInfo objects.""" columns = backend_with_tables.introspector.list_columns("users") assert isinstance(columns, list) assert len(columns) > 0 for col in columns: - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) def test_list_columns_users_table(self, backend_with_tables): """Test columns for users table.""" @@ -63,7 +63,7 @@ def test_get_column_info_existing(self, backend_with_tables): col = backend_with_tables.introspector.get_column_info("users", "email") assert col is not None - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) assert col.name == "email" def test_get_column_info_nonexistent_column(self, backend_with_tables): @@ -235,14 +235,14 @@ class TestAsyncColumnIntrospection: @pytest.mark.asyncio async def test_async_list_columns(self, async_backend_with_tables): - """Test async list_columns returns ColumnInfo objects.""" + """Test async list_columns returns IntrospectionColumnInfo objects.""" columns = await async_backend_with_tables.introspector.list_columns("users") assert isinstance(columns, list) assert len(columns) > 0 for col in columns: - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) @pytest.mark.asyncio async def test_async_get_column_info(self, async_backend_with_tables): @@ -250,7 +250,7 @@ async def test_async_get_column_info(self, async_backend_with_tables): col = await async_backend_with_tables.introspector.get_column_info("users", "email") assert col is not None - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) assert col.name == "email" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py index a4986123..43e62827 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py @@ -8,17 +8,17 @@ import pytest -from rhosocial.activerecord.backend.introspection.types import DatabaseInfo +from rhosocial.activerecord.backend.introspection.types import IntrospectionDatabaseInfo class TestDatabaseInfo: """Tests for database information introspection.""" def test_get_database_info(self, mysql_backend): - """Test get_database_info returns valid DatabaseInfo.""" + """Test get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = mysql_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name is not None assert db_info.vendor == "MySQL" assert db_info.version is not None @@ -135,10 +135,10 @@ class TestAsyncDatabaseInfo: @pytest.mark.asyncio async def test_async_get_database_info(self, async_mysql_backend): - """Test async get_database_info returns valid DatabaseInfo.""" + """Test async get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = await async_mysql_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name is not None assert db_info.vendor == "MySQL" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py index 5736f861..66629f4c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, ) @@ -18,14 +18,14 @@ class TestListForeignKeys: """Tests for list_foreign_keys method.""" def test_list_foreign_keys_returns_fk_info(self, backend_with_tables): - """Test that list_foreign_keys returns ForeignKeyInfo objects.""" + """Test that list_foreign_keys returns IntrospectionForeignKeyInfo objects.""" fks = backend_with_tables.introspector.list_foreign_keys("posts") assert isinstance(fks, list) assert len(fks) > 0 for fk in fks: - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) def test_list_foreign_keys_posts_table(self, backend_with_tables): """Test foreign keys on posts table.""" @@ -86,7 +86,7 @@ def test_get_foreign_key_info_existing(self, backend_with_tables): fk = backend_with_tables.introspector.get_foreign_key_info("posts", fk_name) assert fk is not None - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) assert fk.name == fk_name def test_get_foreign_key_info_nonexistent(self, backend_with_tables): @@ -258,14 +258,14 @@ class TestAsyncForeignKeyIntrospection: @pytest.mark.asyncio async def test_async_list_foreign_keys(self, async_backend_with_tables): - """Test async list_foreign_keys returns ForeignKeyInfo objects.""" + """Test async list_foreign_keys returns IntrospectionForeignKeyInfo objects.""" fks = await async_backend_with_tables.introspector.list_foreign_keys("posts") assert isinstance(fks, list) assert len(fks) > 0 for fk in fks: - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) @pytest.mark.asyncio async def test_async_get_foreign_key_info(self, async_backend_with_tables): @@ -277,7 +277,7 @@ async def test_async_get_foreign_key_info(self, async_backend_with_tables): fk = await async_backend_with_tables.introspector.get_foreign_key_info("posts", fk_name) assert fk is not None - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) assert fk.name == fk_name @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py index 9ddaf9c1..dfd2c8c8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - TableInfo, + IntrospectionTableInfo, TableType, ) @@ -18,14 +18,14 @@ class TestListTables: """Tests for list_tables method.""" def test_list_tables_returns_table_info(self, backend_with_tables): - """Test that list_tables returns TableInfo objects.""" + """Test that list_tables returns IntrospectionTableInfo objects.""" tables = backend_with_tables.introspector.list_tables() assert isinstance(tables, list) assert len(tables) > 0 for table in tables: - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) def test_list_tables_includes_created_tables(self, backend_with_tables): """Test that created tables are listed.""" @@ -70,7 +70,7 @@ def test_get_table_info_existing(self, backend_with_tables): table = backend_with_tables.introspector.get_table_info("users") assert table is not None - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) assert table.name == "users" def test_get_table_info_nonexistent(self, backend_with_tables): @@ -190,14 +190,14 @@ class TestAsyncTableIntrospection: @pytest.mark.asyncio async def test_async_list_tables(self, async_backend_with_tables): - """Test async list_tables returns TableInfo objects.""" + """Test async list_tables returns IntrospectionTableInfo objects.""" tables = await async_backend_with_tables.introspector.list_tables() assert isinstance(tables, list) assert len(tables) > 0 for table in tables: - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) @pytest.mark.asyncio async def test_async_get_table_info(self, async_backend_with_tables): @@ -205,7 +205,7 @@ async def test_async_get_table_info(self, async_backend_with_tables): table = await async_backend_with_tables.introspector.get_table_info("users") assert table is not None - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) assert table.name == "users" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py index 17878637..9bfaaad5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - TriggerInfo, + IntrospectionTriggerInfo, ) @@ -31,11 +31,11 @@ def test_list_triggers_with_trigger(self, backend_with_trigger): assert "update_user_timestamp" in trigger_names def test_list_triggers_returns_trigger_info(self, backend_with_trigger): - """Test that list_triggers returns TriggerInfo objects.""" + """Test that list_triggers returns IntrospectionTriggerInfo objects.""" triggers = backend_with_trigger.introspector.list_triggers() for trigger in triggers: - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) def test_list_triggers_caching(self, backend_with_trigger): """Test that trigger list is cached.""" @@ -61,7 +61,7 @@ def test_get_trigger_info_existing(self, backend_with_trigger): trigger = backend_with_trigger.introspector.get_trigger_info("update_user_timestamp") assert trigger is not None - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) assert trigger.name == "update_user_timestamp" def test_get_trigger_info_nonexistent(self, backend_with_trigger): @@ -189,7 +189,7 @@ class TestAsyncTriggerIntrospection: @pytest.mark.asyncio async def test_async_list_triggers(self, async_backend_with_trigger): - """Test async list_triggers returns TriggerInfo objects.""" + """Test async list_triggers returns IntrospectionTriggerInfo objects.""" triggers = await async_backend_with_trigger.introspector.list_triggers() trigger_names = [t.name for t in triggers] @@ -201,7 +201,7 @@ async def test_async_get_trigger_info(self, async_backend_with_trigger): trigger = await async_backend_with_trigger.introspector.get_trigger_info("update_user_timestamp") assert trigger is not None - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) assert trigger.name == "update_user_timestamp" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py index f217df46..d8815f07 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ViewInfo, + IntrospectionViewInfo, ) @@ -17,14 +17,14 @@ class TestListViews: """Tests for list_views method.""" def test_list_views_returns_view_info(self, backend_with_view): - """Test that list_views returns ViewInfo objects.""" + """Test that list_views returns IntrospectionViewInfo objects.""" views = backend_with_view.introspector.list_views() assert isinstance(views, list) assert len(views) > 0 for view in views: - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) def test_list_views_includes_created_view(self, backend_with_view): """Test that created views are listed.""" @@ -57,7 +57,7 @@ def test_get_view_info_existing(self, backend_with_view): view = backend_with_view.introspector.get_view_info("user_summary") assert view is not None - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) assert view.name == "user_summary" def test_get_view_info_nonexistent(self, backend_with_view): @@ -141,14 +141,14 @@ class TestAsyncViewIntrospection: @pytest.mark.asyncio async def test_async_list_views(self, async_backend_with_view): - """Test async list_views returns ViewInfo objects.""" + """Test async list_views returns IntrospectionViewInfo objects.""" views = await async_backend_with_view.introspector.list_views() assert isinstance(views, list) assert len(views) > 0 for view in views: - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) @pytest.mark.asyncio async def test_async_get_view_info(self, async_backend_with_view): @@ -156,7 +156,7 @@ async def test_async_get_view_info(self, async_backend_with_view): view = await async_backend_with_view.introspector.get_view_info("user_summary") assert view is not None - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) assert view.name == "user_summary" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py index 22b7be73..77da6f68 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py @@ -11,12 +11,12 @@ Column, ComparisonPredicate, Literal, - QueryExpression, - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.query_parts import WhereClause from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -114,13 +114,13 @@ def test_create_view_basic(self, mysql_view_backend): """Test basic CREATE VIEW executes successfully.""" dialect = mysql_view_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() @@ -140,7 +140,7 @@ def test_create_view_with_where(self, mysql_view_backend): """Test CREATE VIEW with WHERE clause.""" dialect = mysql_view_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -152,7 +152,7 @@ def test_create_view_with_where(self, mysql_view_backend): ), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "active_users"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "active_users"), query=query) sql, params = create_view.to_sql() @@ -171,19 +171,19 @@ def test_create_or_replace_view(self, mysql_view_backend): """Test CREATE OR REPLACE VIEW.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) # Create again with OR REPLACE - query2 = QueryExpression( + query2 = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users") ) - create_view2 = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query2, replace=True) + create_view2 = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query2, replace=True) sql, params = create_view2.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -193,14 +193,14 @@ def test_drop_view(self, mysql_view_backend): """Test DROP VIEW.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) - drop_view = DropViewExpression(dialect, view=View(dialect, "user_view")) + drop_view = DropViewStatement(dialect, view=View(dialect, "user_view")) sql, params = drop_view.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -210,7 +210,7 @@ def test_drop_view_if_exists(self, mysql_view_backend): """Test DROP VIEW IF EXISTS.""" dialect = mysql_view_backend.dialect - drop_view = DropViewExpression(dialect, view=View(dialect, "nonexistent_view"), if_exists=True) + drop_view = DropViewStatement(dialect, view=View(dialect, "nonexistent_view"), if_exists=True) sql, params = drop_view.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -224,9 +224,9 @@ def test_create_materialized_view_raises_error(self, mysql_view_backend): """Test that CREATE MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_mv = CreateMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv"), query=query) + create_mv = CreateMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv"), query=query) with pytest.raises(UnsupportedFeatureError) as exc_info: create_mv.to_sql() @@ -238,7 +238,7 @@ def test_drop_materialized_view_raises_error(self, mysql_view_backend): """Test that DROP MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - drop_mv = DropMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv")) + drop_mv = DropMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv")) with pytest.raises(UnsupportedFeatureError) as exc_info: drop_mv.to_sql() @@ -249,7 +249,7 @@ def test_refresh_materialized_view_raises_error(self, mysql_view_backend): """Test that REFRESH MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - refresh_mv = RefreshMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv")) + refresh_mv = RefreshMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv")) with pytest.raises(UnsupportedFeatureError) as exc_info: refresh_mv.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py index b3a9cefd..e77d7216 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py @@ -12,7 +12,7 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, IntegerType, TextType, TimestampType, VarCharType, @@ -31,7 +31,7 @@ def _pk(dialect): def _build_table(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, "test_tbl"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), @@ -58,7 +58,7 @@ def test_all_standard_types_compile(self, dialect): assert isinstance(params, tuple) def test_boolean_default_true(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "bool_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), @@ -71,7 +71,7 @@ def test_boolean_default_true(self, dialect): assert "TINYINT" in sql.upper() def test_timestamp_type(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "ts_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py index 81398cc4..960c6ff2 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py @@ -3,12 +3,12 @@ MySQL CREATE TABLE ... LIKE syntax tests. This module tests the MySQL-specific LIKE syntax for CREATE TABLE statements. -The LIKE form is modelled by CreateTableLikeExpression and rendered by the +The LIKE form is modelled by CreateTableLikeStatement and rendered by the dialect's ``format_create_table_like_statement`` (MySQL inherits the generic TableMixin implementation). """ -from rhosocial.activerecord.backend.expression import CreateTableLikeExpression +from rhosocial.activerecord.backend.expression import CreateTableLikeStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.objects import Table @@ -19,7 +19,7 @@ class TestMySQLCreateTableLike: def test_basic_like_syntax(self): """Test basic CREATE TABLE ... LIKE syntax.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression(dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users")) + expr = CreateTableLikeStatement(dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users")) sql, params = expr.to_sql() assert sql == "CREATE TABLE `users_copy` LIKE `users`" @@ -28,7 +28,7 @@ def test_basic_like_syntax(self): def test_like_with_if_not_exists(self): """Test CREATE TABLE ... LIKE with IF NOT EXISTS.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users"), if_not_exists=True ) sql, params = expr.to_sql() @@ -39,7 +39,7 @@ def test_like_with_if_not_exists(self): def test_like_with_temporary(self): """Test CREATE TEMPORARY TABLE ... LIKE.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "temp_users"), like_table=Table(dialect, "users"), temporary=True ) sql, params = expr.to_sql() @@ -50,7 +50,7 @@ def test_like_with_temporary(self): def test_like_with_schema_qualified_table(self): """Test CREATE TABLE ... LIKE with a source table in another database.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users", catalog_name="production"), @@ -63,7 +63,7 @@ def test_like_with_schema_qualified_table(self): def test_like_with_temporary_and_if_not_exists(self): """Test CREATE TEMPORARY TABLE ... LIKE with IF NOT EXISTS.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "temp_users_copy"), like_table=Table(dialect, "users", catalog_name="test_db"), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index af4bfdb2..41a34e2a 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -376,7 +376,7 @@ def partition_maintenance(d, cls_name): "uuid.UUIDConstantExpression": "which must name 'nil' or 'max' and __init__ rejects anything else, so " "the filler's guess is refused at construction.", - "uuid.UUIDCastExpression": + "uuid.UUIDCastFunction": "expression is annotated Any, so the filler supplies a string where the " "node requires an expression to cast.", "uuid.UUIDGenerationExpression": @@ -409,16 +409,16 @@ def partition_maintenance(d, cls_name): # Each of these has a ``supports_*`` method answering True while the # formatter the expression dispatches to does not exist, so a caller that # asks whether the feature is available is told yes and is then refused. - "statements.ddl_schema.CreateSchemaExpression": ( + "statements.ddl_schema.CreateSchemaStatement": ( "format_create_schema_statement", "DECLARED BUT NOT RENDERED. MySQLSchemaMixin.supports_create_schema() " "answers True -- MySQL takes SCHEMA as a synonym for DATABASE -- yet " "MySQLDialect defines no format_create_schema_statement. The dialect " "claims the capability and refuses the statement.", ), - "statements.ddl_schema.DropSchemaExpression": ( + "statements.ddl_schema.DropSchemaStatement": ( "format_drop_schema_statement", - "DECLARED BUT NOT RENDERED. Same shape as CreateSchemaExpression: " + "DECLARED BUT NOT RENDERED. Same shape as CreateSchemaStatement: " "MySQLSchemaMixin.supports_drop_schema() answers True and " "format_drop_schema_statement is absent.", ), @@ -432,7 +432,7 @@ def partition_maintenance(d, cls_name): "nothing in src or tests constructs it.", ), # --- SQL/PGQ: property graphs are not a MySQL feature. ---------------- - "graph.AlterPropertyGraphExpression": ( + "graph.AlterPropertyGraphStatement": ( "format_alter_property_graph_statement", "SQL/PGQ: MySQL has no property graphs, so no " "format_alter_property_graph_statement exists.", @@ -441,11 +441,11 @@ def partition_maintenance(d, cls_name): "format_graph_columns_clause", "SQL/PGQ graph column list; no format_graph_columns_clause.", ), - "graph.CreatePropertyGraphExpression": ( + "graph.CreatePropertyGraphStatement": ( "format_create_property_graph_statement", "SQL/PGQ: no format_create_property_graph_statement.", ), - "graph.DropPropertyGraphExpression": ( + "graph.DropPropertyGraphStatement": ( "format_drop_property_graph_statement", "SQL/PGQ: no format_drop_property_graph_statement.", ), @@ -476,8 +476,8 @@ def partition_maintenance(d, cls_name): "UNPIVOT is T-SQL/Oracle; MySQL declares no formatter.", ), # --- Case-insensitive matching is written LOWER() = LOWER() here. ----- - "predicates.ILIKEExpression": ( - "format_ilike_expression", + "predicates.ILikePredicate": ( + "format_ilike_predicate", "MySQL has no ILIKE operator; case-insensitive matching is written " "LOWER(a) = LOWER(b), and no formatter exists.", ), @@ -489,7 +489,7 @@ def partition_maintenance(d, cls_name): "base that implements it.", ), # --- COMMENT ON is SQL-standard/PostgreSQL. --------------------------- - "statements.ddl_comment.CommentOnExpression": ( + "statements.ddl_comment.CommentOnStatement": ( "format_comment_statement", "COMMENT ON is SQL-standard/PostgreSQL. MySQL spells a table comment " "as a CREATE TABLE option and a column comment inline, which " @@ -506,16 +506,16 @@ def partition_maintenance(d, cls_name): # the dispatch, which is the same declared gap as the property-graph rows # above and stated for the same reason. Naming a sequence is untouched: # SequenceNameMixin stays, so Sequence(dialect, "s").to_sql() renders. - "statements.ddl_sequence.CreateSequenceExpression": ( + "statements.ddl_sequence.CreateSequenceStatement": ( "format_create_sequence_statement", "MySQL has no sequence object; AUTO_INCREMENT columns are how rows are " "numbered. MySQLDialect mixes in no base providing this formatter.", ), - "statements.ddl_sequence.DropSequenceExpression": ( + "statements.ddl_sequence.DropSequenceStatement": ( "format_drop_sequence_statement", "MySQL has no sequence object; no format_drop_sequence_statement.", ), - "statements.ddl_sequence.AlterSequenceExpression": ( + "statements.ddl_sequence.AlterSequenceStatement": ( "format_alter_sequence_statement", "MySQL has no sequence object; no format_alter_sequence_statement.", ), @@ -562,9 +562,9 @@ def partition_maintenance(d, cls_name): "format_xmlelement_expression", "XML functions are not in MySQL; no format_xmlelement_expression.", ), - "xml.XMLExistsExpression": ( - "format_xmlexists_expression", - "XML functions are not in MySQL; no format_xmlexists_expression.", + "xml.XMLExistsPredicate": ( + "format_xmlexists_predicate", + "XML functions are not in MySQL; no format_xmlexists_predicate.", ), "xml.XMLPIExpression": ( "format_xmlpi_expression", @@ -601,16 +601,16 @@ def partition_maintenance(d, cls_name): # a wrong value. _NOT_RENDERED = { # --- A MySQL formatter shadows the core one and reads other slots. ---- - "statements.ddl_function.CreateFunctionExpression": ( + "statements.ddl_function.CreateFunctionStatement": ( "AttributeError", "MySQLRoutineMixin.format_create_function_statement reads expr.name; " - "core's CreateFunctionExpression carries expr.function, so the MySQL " + "core's CreateFunctionStatement carries expr.function, so the MySQL " "routine formatter shadows the core one for an expression it cannot " "render.", ), - "statements.ddl_function.DropFunctionExpression": ( + "statements.ddl_function.DropFunctionStatement": ( "AttributeError", - "Same shadowing as CreateFunctionExpression.", + "Same shadowing as CreateFunctionStatement.", ), "query_sources.JSONTableExpression": ( "AttributeError", @@ -619,7 +619,7 @@ def partition_maintenance(d, cls_name): "shape, so the MySQL formatter cannot read it.", ), # --- The dialect correctly refuses what the harness invented. --------- - "collation.CollateExpression": ( + "collation.CollateFunction": ( "ValueError", "The generic constructor supplies \"x\" for collation_name and MySQL's " "validate_collation_name refuses a collation it does not know. The " @@ -698,7 +698,7 @@ def partition_maintenance(d, cls_name): "this backend uses.", ), # --- The instance the generic constructor builds is not a valid one. --- - "advanced_functions.CaseExpression": ( + "advanced_functions.CaseFunction": ( "ValueError", "CASE needs at least one WHEN/THEN pair; the generic constructor " "supplies an empty list.", @@ -851,7 +851,7 @@ def partition_maintenance(d, cls_name): "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_trigger.CreateTriggerExpression": ( + "statements.ddl_trigger.CreateTriggerStatement": ( "AttributeError", "timing must be a TriggerTiming member; the generic constructor " "supplies a string.", @@ -873,7 +873,7 @@ def partition_maintenance(d, cls_name): "MERGE at all -- upsert is ON DUPLICATE KEY UPDATE -- so this row is " "core's shape rather than MySQL's.", ), - "statements.dml.MergeExpression": ( + "statements.dml.MergeStatement": ( "AttributeError", "source must be a row source; the generic constructor supplies a " "string. MySQL has no MERGE.", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py index 0ac58de2..851e21a8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py @@ -11,10 +11,10 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.statements import ( - CreateFulltextIndexExpression, + CreateFulltextIndexStatement, ) from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( - FulltextMatchExpression, + FulltextMatch, ) from rhosocial.activerecord.backend.impl.mysql.expression import MySQLMatchAgainstExpression from rhosocial.activerecord.backend.expression.objects import Index, Table @@ -96,7 +96,7 @@ def test_format_fulltext_match_natural_mode(self): """Test MATCH ... AGAINST with natural language mode.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["title", "content"], search_term="database system") + expr = FulltextMatch(dialect, columns=["title", "content"], search_term="database system") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`title`, `content`)" in sql @@ -108,7 +108,7 @@ def test_format_fulltext_match_boolean_mode(self): """Test MATCH ... AGAINST with boolean mode.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["title", "content"], search_term="+database +system", mode="BOOLEAN") + expr = FulltextMatch(dialect, columns=["title", "content"], search_term="+database +system", mode="BOOLEAN") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`title`, `content`)" in sql @@ -120,7 +120,7 @@ def test_format_fulltext_match_query_expansion(self): """Test MATCH ... AGAINST with query expansion.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["content"], search_term="database", mode="QUERY EXPANSION") + expr = FulltextMatch(dialect, columns=["content"], search_term="database", mode="QUERY EXPANSION") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`content`)" in sql @@ -132,7 +132,7 @@ def test_format_create_fulltext_index(self): """Test CREATE FULLTEXT INDEX statement formatting.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_content"), table=Table(dialect, "articles"), columns=["title", "content"] ) sql, params = expr.to_sql() @@ -147,7 +147,7 @@ def test_format_create_fulltext_index_with_parser(self): """Test CREATE FULLTEXT INDEX with parser plugin.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_content"), table=Table(dialect, "articles"), columns=["content"], parser="ngram" ) sql, params = expr.to_sql() @@ -161,11 +161,11 @@ def test_fulltext_unsupported_raises_error(self): dialect = MySQLDialect(version=(5, 5, 0)) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - expr = FulltextMatchExpression(dialect, columns=["title"], search_term="test") + expr = FulltextMatch(dialect, columns=["title"], search_term="test") dialect.format_fulltext_match(expr) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_test"), table=Table(dialect, "test_table"), columns=["content"] ) expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py index 3b637e40..5a126e24 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py @@ -8,8 +8,8 @@ Core used to have a single ``JSONExpression`` covering both access operators and has since split it in two, one per operator: ``->`` yields a document and -is a ``JSONDocumentExpression``, ``->>`` yields text and is a -``JSONTextExpression``. Both render through the same ``format_json_expression`` +is a ``JSONDocumentValue``, ``->>`` yields text and is a +``JSONTextValue``. Both render through the same ``format_json_expression`` dispatch and both formatters here still branch on ``expr.operation``, so the class names below record which operator each test means and nothing about the SQL changed. @@ -19,9 +19,9 @@ class names below record which operator each test means and nothing about the from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, + JSONDocumentValue, JSONPathMode, - JSONTextExpression, + JSONTextValue, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression.types import MySQLSignedType, MySQLUnsignedType @@ -34,28 +34,28 @@ def dialect(): class TestArrowExpression: def test_arrow_column_identifier(self, dialect): - """``->`` yields a document, so it is a JSONDocumentExpression.""" - expr = JSONDocumentExpression(dialect, "data", "$.name", "->") + """``->`` yields a document, so it is a JSONDocumentValue.""" + expr = JSONDocumentValue(dialect, "data", "$.name", "->") sql, params = expr.to_sql() assert sql == "`data`->'$.name'" assert params == () def test_arrow_operator_identifier(self, dialect): - """``->>`` yields text, so it is a JSONTextExpression.""" - expr = JSONTextExpression(dialect, "data", "$.name", "->>") + """``->>`` yields text, so it is a JSONTextValue.""" + expr = JSONTextValue(dialect, "data", "$.name", "->>") sql, params = expr.to_sql() assert sql == "`data`->>'$.name'" assert params == () def test_arrow_with_nested_column_expression(self, dialect): col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "$.a.b", "->") + expr = JSONDocumentValue(dialect, col, "$.a.b", "->") sql, params = expr.to_sql() assert sql == "`payload`->'$.a.b'" assert params == () def test_arrow_with_cast_types(self, dialect): - expr = JSONTextExpression( + expr = JSONTextValue( dialect, "data", "$.age", "->>", mode=JSONPathMode.ARROW ).cast(MySQLUnsignedType(dialect)) sql, params = expr.to_sql() @@ -63,14 +63,14 @@ def test_arrow_with_cast_types(self, dialect): assert params == () def test_arrow_with_alias(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", alias="nm") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", alias="nm") sql, params = expr.to_sql() assert sql == "`data`->'$.name' AS `nm`" assert params == () def test_arrow_forced_mode_renders_arrow(self): old = MySQLDialect(version=(5, 7, 0)) - expr = JSONDocumentExpression(old, "data", "$.name", "->", mode=JSONPathMode.ARROW) + expr = JSONDocumentValue(old, "data", "$.name", "->", mode=JSONPathMode.ARROW) sql, params = expr.to_sql() assert sql == "`data`->'$.name'" assert params == () @@ -80,11 +80,11 @@ def test_arrow_other_operator_uses_placeholder(self, dialect): ``=`` is not one of the two operators core split, and it says nothing about the result type -- it is a comparison, so it yields a boolean and - neither JSONDocumentExpression nor JSONTextExpression means it. The + neither JSONDocumentValue nor JSONTextValue means it. The base class is used as a plain carrier for the node; the branch is chosen by ``operation`` alone, so either class renders this identically. """ - expr = JSONDocumentExpression(dialect, "data", "$.name", "=") + expr = JSONDocumentValue(dialect, "data", "$.name", "=") sql, params = expr.to_sql() assert sql == "(`data` = %s)" assert params == ("$.name",) @@ -92,7 +92,7 @@ def test_arrow_other_operator_uses_placeholder(self, dialect): def test_arrow_other_operator_with_column_expression(self, dialect): """As above: ``@>`` is not an arrow, so no JSON path class denotes it.""" col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "someval", "@>") + expr = JSONDocumentValue(dialect, col, "someval", "@>") sql, params = expr.to_sql() assert sql == "(`payload` @> %s)" assert params == ("someval",) @@ -101,14 +101,14 @@ def test_arrow_other_operator_with_column_expression(self, dialect): class TestFunctionExpression: def test_function_extract(self, dialect): """``->`` is JSON_EXTRACT and yields a document.""" - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" assert params == () def test_function_unquote(self, dialect): """``->>`` unquotes and yields text.""" - expr = JSONTextExpression(dialect, "data", "$.name", "->>", mode=JSONPathMode.FUNCTION) + expr = JSONTextValue(dialect, "data", "$.name", "->>", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.name'))" assert params == () @@ -119,20 +119,20 @@ def test_function_other_operator(self, dialect): Same as the arrow-mode case: the base class is a carrier and ``operation`` alone picks the branch. """ - expr = JSONDocumentExpression(dialect, "data", "$.name", "@>", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, "data", "$.name", "@>", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "`data` @> '$.name'" assert params == () def test_function_with_column_expression(self, dialect): col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "$.a", "->", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, col, "$.a", "->", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`payload`, '$.a')" assert params == () def test_function_with_cast_types(self, dialect): - expr = JSONTextExpression( + expr = JSONTextValue( dialect, "data", "$.age", "->>", mode=JSONPathMode.FUNCTION ).cast(MySQLSignedType(dialect)) sql, params = expr.to_sql() @@ -140,7 +140,7 @@ def test_function_with_cast_types(self, dialect): assert params == () def test_function_with_alias(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION, alias="nm") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION, alias="nm") sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name') AS `nm`" assert params == () @@ -148,30 +148,30 @@ def test_function_with_alias(self, dialect): class TestModeDispatch: def test_auto_uses_arrow_when_supported(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->") + expr = JSONDocumentValue(dialect, "data", "$.name", "->") sql, _ = expr.to_sql() assert "JSON_EXTRACT" not in sql assert "->" in sql def test_auto_falls_back_to_function_when_unsupported(self): old = MySQLDialect(version=(5, 7, 0)) - expr = JSONDocumentExpression(old, "data", "$.name", "->") + expr = JSONDocumentValue(old, "data", "$.name", "->") sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" assert params == () def test_string_mode_coercion(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode="function") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode="function") sql, _ = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" def test_escaped_path_backslash(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.a\\b", "->") + expr = JSONDocumentValue(dialect, "data", "$.a\\b", "->") sql, _ = expr.to_sql() assert sql == r"`data`->'$.a\\b'" def test_escaped_path_single_quote(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.a'b", "->") + expr = JSONDocumentValue(dialect, "data", "$.a'b", "->") sql, _ = expr.to_sql() assert sql == "`data`->'$.a''b'" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py index 6abbbb83..e527f4c9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py @@ -63,7 +63,7 @@ def test_charset_version_gating(): def test_auto_increment_and_row_format_rendered(): - from rhosocial.activerecord.backend.expression import CreateTableExpression + from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.mysql.expression import MySQLRowFormat @@ -72,7 +72,7 @@ def test_auto_increment_and_row_format_rendered(): options = MySQLCreateTableOptions( dialect, auto_increment=1000, row_format=MySQLRowFormat.DYNAMIC ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "t"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py index e4599de0..051a5838 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py @@ -34,23 +34,23 @@ from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, ColumnConstraintType, - CreateTableAsExpression, + CreateTableAsStatement, TableConstraint, TableConstraintType, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - DropViewExpression, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, + BeginTransactionStatement, SetTransactionExpression, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -71,7 +71,7 @@ def _table(d, name="t"): def _query(d): - return QueryExpression(d, select=[Column(d, "id")], from_=_table(d)) + return QueryStatement(d, select=[Column(d, "id")], from_=_table(d)) def _fk(d, **kw): @@ -172,32 +172,32 @@ def sql(self, dialect, **kwargs): b_render=r"WITHOUT VALIDATION\b", ), PairCase( - "TruncateExpression.cascade", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.cascade", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "cascade", "restrict", a_error=r"CASCADE\b", b_error=r"RESTRICT\b", ), PairCase( - "TruncateExpression.restart_identity", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.restart_identity", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "restart_identity", "continue_identity", a_error=r"RESTART IDENTITY\b", b_error=r"CONTINUE IDENTITY\b", ), PairCase( - "DropViewExpression.cascade", - lambda d, **kw: DropViewExpression(d, View(d, "v"), **kw), + "DropViewStatement.cascade", + lambda d, **kw: DropViewStatement(d, View(d, "v"), **kw), "cascade", "restrict", a_error=r"CASCADE\b", b_error=r"RESTRICT\b", ), PairCase( - "BeginTransactionExpression.deferrable", - lambda d, **kw: BeginTransactionExpression(d, **kw), + "BeginTransactionStatement.deferrable", + lambda d, **kw: BeginTransactionStatement(d, **kw), "deferrable", "not_deferrable", a_error=r"(?=", Column(dialect, "age"), Literal(dialect, 0) ), ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=[check], ) sql, params = expr.to_sql() @@ -561,7 +561,7 @@ class TestMySQLDropTable: def test_drop_table_if_exists(self): """Test DROP TABLE IF EXISTS.""" dialect = MySQLDialect() - expr = DropTableExpression(dialect=dialect, table=Table(dialect, "test_table"), if_exists=True) + expr = DropTableStatement(dialect=dialect, table=Table(dialect, "test_table"), if_exists=True) sql, params = expr.to_sql() assert sql == "DROP TABLE IF EXISTS `test_table`" assert params == () @@ -569,7 +569,7 @@ def test_drop_table_if_exists(self): def test_drop_table_without_if_exists(self): """Test DROP TABLE without IF EXISTS.""" dialect = MySQLDialect() - expr = DropTableExpression(dialect=dialect, table=Table(dialect, "test_table"), if_exists=False) + expr = DropTableStatement(dialect=dialect, table=Table(dialect, "test_table"), if_exists=False) sql, params = expr.to_sql() assert sql == "DROP TABLE `test_table`" assert params == () @@ -616,7 +616,7 @@ def test_complete_table_creation(self): indexes = [IndexDefinition(dialect, "idx_email", ["email"], unique=True), IndexDefinition(dialect, "idx_status", ["status"])] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index cb23897d..6c77bf68 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -7,9 +7,9 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnDefinition, - CreateTableExpression, - QueryExpression, - CreateViewExpression, + CreateTableStatement, + QueryStatement, + CreateViewStatement, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( @@ -57,10 +57,10 @@ def test_materialized_view_not_supported(self): def test_create_view_check_option_gated(self): """WITH CHECK OPTION must fail fast when the capability is off.""" dialect = MySQLDialect() - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) - expr = CreateViewExpression( + expr = CreateViewStatement( dialect, view=View(dialect, "v"), query=query, @@ -160,7 +160,7 @@ class Plain(ActiveRecord): id: int dialect = MySQLDialect() - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, Plain.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -173,7 +173,7 @@ class Plain(ActiveRecord): def test_table_inherits_is_propagated_and_rejected(self): dialect = MySQLDialect(version=(8, 0, 0)) assert dialect.supports_table_inheritance() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, InheritedTable.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -187,7 +187,7 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = MySQLDialect(version=(8, 0, 0)) assert dialect.supports_table_tablespace() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, TablespacedTable.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index 765f0e40..fc0e480d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -145,10 +145,10 @@ def test_mysql_json_table_alias_quoted(dialect): def test_mysql_format_cast_expression_valid(dialect): """A CAST target is a DataType, and the dialect renders what it says.""" - from rhosocial.activerecord.backend.expression.core import CastExpression, Column + from rhosocial.activerecord.backend.expression.core import CastFunction, Column from rhosocial.activerecord.backend.expression.types import CustomType - expr = CastExpression(dialect, Column(dialect, "column"), + expr = CastFunction(dialect, Column(dialect, "column"), CustomType(dialect, raw="INTEGER")) sql, params = dialect.format_cast_expression(expr) assert "INTEGER" in sql @@ -161,10 +161,10 @@ def test_mysql_format_cast_expression_rejects_injection(dialect): rendered into the statement text. That is why a string is not accepted at all rather than being escaped: there is nothing to escape it into, since it is code and not data.""" - from rhosocial.activerecord.backend.expression.core import CastExpression, Column + from rhosocial.activerecord.backend.expression.core import CastFunction, Column with pytest.raises(TypeError, match="takes a DataType instance"): - CastExpression(dialect, Column(dialect, "column"), + CastFunction(dialect, Column(dialect, "column"), "INTEGER; DROP TABLE users--") @@ -443,13 +443,13 @@ class TestMySQLCreateTableCommentEscaping: def test_create_table_comment_escaped(self, dialect): """Test table-level COMMENT is properly escaped.""" - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], @@ -464,13 +464,13 @@ def test_create_table_comment_escaped(self, dialect): def test_create_table_comment_with_backslash(self, dialect): """Test table-level COMMENT with backslash is properly escaped.""" - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py index d64dee1d..a4ddef93 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py @@ -38,8 +38,8 @@ ColumnConstraint, ColumnConstraintType, ColumnDefinition, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, IdentityClause, ) from rhosocial.activerecord.backend.expression.types import IntegerType @@ -107,7 +107,7 @@ def test_marker_renders(self, mysql_dialect): def test_rendered_create_table_executes(self, mysql_backend): dialect = mysql_backend.dialect table = Table(dialect, "_probe_ai_exec") - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, table, [_auto_increment_column(dialect)] ) sql, _ = expression.to_sql() @@ -117,7 +117,7 @@ def test_rendered_create_table_executes(self, mysql_backend): ) outcome = confirm_expression_execution( - mysql_backend, expression, teardown=[DropTableExpression(dialect, table)] + mysql_backend, expression, teardown=[DropTableStatement(dialect, table)] ) assert outcome is ExecutionOutcome.ACCEPTED @@ -134,7 +134,7 @@ class TestProbeGatesTheDdl: def test_withdrawn_probe_refuses_the_column(self): dialect = _DeclinedAutoIncrementDialect((8, 0, 36)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "t"), [_auto_increment_column(dialect)] ) with pytest.raises(UnsupportedFeatureError, match="AUTO_INCREMENT column"): @@ -148,7 +148,7 @@ def test_table_seed_option_is_not_a_column_clause(self): the column formatter's probe. """ dialect = _DeclinedAutoIncrementDialect((8, 0, 36)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "t"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -206,7 +206,7 @@ def test_request_is_refused_not_executed(self, mysql_backend, build): def test_identity_attribute_is_refused(self, mysql_backend): """The AR-layer attribute path used to silently drop ALWAYS and start.""" dialect = mysql_backend.dialect - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "_probe_identity_attr"), [ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 533d812a..6ca9d5a9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -16,15 +16,15 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( AddColumn, - AlterTableExpression, + AlterTableStatement, ChangeColumn, DropColumn, DropTableConstraint, ModifyColumn, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, TriggerEvent, TriggerLevel, TriggerTiming, @@ -271,7 +271,7 @@ class TestMySQLTriggerExpressions: def test_create_trigger_before_insert(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "before_insert_user"), table=Table(dialect, "users"), @@ -287,7 +287,7 @@ def test_create_trigger_before_insert(self): def test_create_trigger_after_update(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "after_update_log"), table=Table(dialect, "orders"), @@ -301,7 +301,7 @@ def test_create_trigger_after_update(self): def test_create_trigger_if_not_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "my_trigger"), table=Table(dialect, "t"), @@ -315,7 +315,7 @@ def test_create_trigger_if_not_exists(self): def test_create_trigger_instead_of_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -328,7 +328,7 @@ def test_create_trigger_instead_of_raises(self): def test_create_trigger_for_each_statement_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -342,7 +342,7 @@ def test_create_trigger_for_each_statement_raises(self): def test_create_trigger_with_condition_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -356,7 +356,7 @@ def test_create_trigger_with_condition_raises(self): def test_create_trigger_with_referencing_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -370,7 +370,7 @@ def test_create_trigger_with_referencing_raises(self): def test_create_trigger_multiple_events_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -383,7 +383,7 @@ def test_create_trigger_multiple_events_raises(self): def test_create_trigger_with_update_columns_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -397,7 +397,7 @@ def test_create_trigger_with_update_columns_raises(self): def test_drop_trigger(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = DropTriggerExpression( + expr = DropTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "old_trigger"), ) @@ -406,7 +406,7 @@ def test_drop_trigger(self): def test_drop_trigger_if_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = DropTriggerExpression( + expr = DropTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "old_trigger"), if_exists=True, @@ -478,8 +478,8 @@ class TestMySQLTableDDLExpressions: def test_create_table_like(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source_table"), @@ -489,8 +489,8 @@ def test_create_table_like(self): def test_create_table_like_temporary(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "tmp_table"), like_table=Table(dialect, "source"), @@ -501,8 +501,8 @@ def test_create_table_like_temporary(self): def test_create_table_like_with_database(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source_table", catalog_name="myschema"), @@ -512,8 +512,8 @@ def test_create_table_like_with_database(self): def test_create_table_like_if_not_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source"), @@ -539,8 +539,8 @@ def test_column_default_expression(self): ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT" in sql assert params == () @@ -556,8 +556,8 @@ def test_column_default_string(self): ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT 'active'" in sql @@ -572,8 +572,8 @@ def test_column_default_numeric(self): ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT 0" in sql @@ -586,8 +586,8 @@ def test_column_nullable(self): constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "NULL" in sql @@ -600,8 +600,8 @@ def test_column_unique(self): constraints=[ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "UNIQUE" in sql @@ -615,8 +615,8 @@ def test_named_table_constraint(self): columns=["id"], ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) @@ -636,8 +636,8 @@ def test_foreign_key_table_constraint(self): foreign_key_columns=["id"], ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=columns, table_constraints=table_constraints ) @@ -659,8 +659,8 @@ def test_foreign_key_named_constraint_via_subclass(self): on_update=ReferentialAction.CASCADE, ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "payments"), columns=columns, table_constraints=table_constraints ) @@ -669,9 +669,9 @@ def test_foreign_key_named_constraint_via_subclass(self): def test_temporary_table(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "tmp"), columns=columns, temporary=True ) sql, params = expr.to_sql() @@ -685,12 +685,12 @@ def test_create_or_replace_unsupported(self): UnsupportedFeatureError, ) from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, + CreateTableStatement, CreateTableOptions, ) dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -704,8 +704,8 @@ def test_inline_index_with_type_numeric(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] indexes = [IndexDefinition(dialect, "idx_id", ["id"], type="BTREE")] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "t"), columns=columns, indexes=indexes ) sql, params = expr.to_sql() @@ -713,9 +713,9 @@ def test_inline_index_with_type_numeric(self): def test_numeric_storage_option(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "t"), columns=columns, storage_options={"AUTO_INCREMENT": 1000, "ENGINE": "InnoDB"} ) @@ -737,7 +737,7 @@ def test_modify_column(self): dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -751,7 +751,7 @@ def test_modify_column_first(self): column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), first=True, ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -765,7 +765,7 @@ def test_modify_column_after(self): column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), after_column="id", ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -779,7 +779,7 @@ def test_change_column(self): old_name="username", column=ColumnDefinition(dialect, "login_name", VarCharType(dialect, 150)), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -793,7 +793,7 @@ def test_change_column_first(self): column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), first=True, ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -808,7 +808,7 @@ def test_change_column_after(self): column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), after_column="id", ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -827,7 +827,7 @@ def test_modify_column_with_constraints(self): ], ), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py index f7b2e943..ae8b2a7e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py @@ -14,14 +14,14 @@ ColumnDefinition, ColumnConstraint, ColumnConstraintType, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, IndexDefinition, - InsertExpression, + InsertStatement, Literal, - QueryExpression, + QueryStatement, ValuesSource, - WildcardExpression, + Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, VarCharType from rhosocial.activerecord.backend.impl.mysql.explain import MySQLExplainResult, MySQLExplainRow @@ -41,11 +41,11 @@ def _partition_value(dialect, value): def _drop_partition_explain_table_expression(dialect): - return DropTableExpression(dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), if_exists=True) def _create_partition_explain_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), columns=[ @@ -89,7 +89,7 @@ def _seed_partition_explain_rows_expression(dialect): [2, 100, datetime(2026, 2, 15), "feb"], [3, 200, datetime(2026, 3, 15), "mar"], ] - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, PARTITION_EXPLAIN_TABLE), columns=["id", "tenant_id", "created_at", "payload"], @@ -101,9 +101,9 @@ def _seed_partition_explain_rows_expression(dialect): def _partition_range_query_expression(dialect, start, end): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), where=ComparisonPredicate( dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start) @@ -115,9 +115,9 @@ def _partition_range_query_expression(dialect, start, end): def _full_scan_query_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index 9c6f0f13..78080df5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -12,19 +12,19 @@ ColumnConstraintType, ColumnDefinition, ComparisonPredicate, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, FunctionCall, IndexDefinition, - InsertExpression, + InsertStatement, Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, TableConstraint, TableConstraintType, ValuesSource, - WildcardExpression, + Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -53,7 +53,7 @@ def _drop_table_expression(dialect): - return DropTableExpression(dialect=dialect, table=Table(dialect, PARTITION_TABLE), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, PARTITION_TABLE), if_exists=True) def _base_partition_definitions(dialect: MySQLDialect) -> List[MySQLPartitionDefinition]: @@ -70,7 +70,7 @@ def _base_partition_definitions(dialect: MySQLDialect) -> List[MySQLPartitionDef def _create_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PARTITION_TABLE), columns=[ @@ -91,7 +91,7 @@ def _create_partitioned_table_expression(dialect): def _insert_events_expression(dialect, rows): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, PARTITION_TABLE), columns=["id", "created_at", "payload"], @@ -103,7 +103,7 @@ def _insert_events_expression(dialect, rows): def _select_payloads_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, PARTITION_TABLE), @@ -112,7 +112,7 @@ def _select_payloads_expression(dialect): def _select_payload_by_id_expression(dialect, row_id): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, PARTITION_TABLE), @@ -123,7 +123,7 @@ def _select_payload_by_id_expression(dialect, row_id): def _partition_names_expression(dialect, table_name=None): table_name = table_name or PARTITION_TABLE partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "PARTITION_NAME", alias="name")], from_=partitions, @@ -144,7 +144,7 @@ def _partition_names_expression(dialect, table_name=None): def _partition_metadata_expression(dialect): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -301,7 +301,7 @@ def _base_columns_without_pk(dialect): def _create_nonpartitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_TABLE), columns=_base_columns_without_pk(dialect), @@ -309,7 +309,7 @@ def _create_nonpartitioned_table_expression(dialect): def _create_negative_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_PARTITIONED_TABLE), columns=_base_columns_without_pk(dialect), @@ -325,7 +325,7 @@ def _create_negative_partitioned_table_expression(dialect): def _create_negative_hash_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_HASH_TABLE), columns=_base_columns_without_pk(dialect), @@ -376,7 +376,7 @@ async def _async_teardown_negative_tables(backend): def _drop_named_table_expression(dialect, table_name: str): - return DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True) def _production_columns(dialect): @@ -413,7 +413,7 @@ def _partition_definition(dialect, name: str, upper_bound: str): def _create_production_partitioned_table_expression(dialect, partitions): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_PARTITION_TABLE), columns=_production_columns(dialect), @@ -431,7 +431,7 @@ def _create_production_partitioned_table_expression(dialect, partitions): def _create_production_archive_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_ARCHIVE_TABLE), columns=_production_columns(dialect), @@ -441,7 +441,7 @@ def _create_production_archive_table_expression(dialect): def _create_production_maxvalue_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_MAXVALUE_TABLE), columns=_production_columns(dialect), @@ -462,7 +462,7 @@ def _create_production_maxvalue_partitioned_table_expression(dialect): def _insert_production_events_into_expression(dialect, table_name: str, rows): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, table_name), columns=["id", "created_at", "tenant_id", "payload"], @@ -499,7 +499,7 @@ def _select_production_payloads_from_expression( ), predicate, ) - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, table_name), @@ -519,24 +519,24 @@ def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Op def _select_production_count_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_PARTITION_TABLE), ) def _select_archive_count_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_ARCHIVE_TABLE), ) def _partition_metadata_expression_for_table(dialect, table_name: str): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -597,9 +597,9 @@ def _reorganize_maxvalue_partition_expression(dialect): def _production_range_query_for_table_expression(dialect, table_name: str, start, end, *, tenant_id: int): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[Wildcard(dialect)], from_=Table(dialect, table_name), where=LogicalPredicate( dialect, @@ -1234,7 +1234,7 @@ def _subpartition_columns(dialect): def _create_subpartitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, SUBPARTITION_TABLE), columns=_subpartition_columns(dialect), @@ -1276,7 +1276,7 @@ async def _async_create_subpartitioned_table(backend): def _subpartition_metadata(backend): partitions = Table(dialect=backend.dialect, name="PARTITIONS", catalog_name="information_schema") return backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect=backend.dialect, select=[ Column(backend.dialect, "PARTITION_NAME", alias="name"), @@ -1316,7 +1316,7 @@ def _subpartition_metadata(backend): async def _async_subpartition_metadata(backend): partitions = Table(dialect=backend.dialect, name="PARTITIONS", catalog_name="information_schema") return await backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect=backend.dialect, select=[ Column(backend.dialect, "PARTITION_NAME", alias="name"), @@ -1378,7 +1378,7 @@ def test_insert_into_subpartitioned_table_and_query(self, mysql_backend): """Rows inserted into a subpartitioned table should be queryable through the parent.""" _create_subpartitioned_table(mysql_backend) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, SUBPARTITION_TABLE), columns=["id", "created_at", "region", "payload"], @@ -1396,7 +1396,7 @@ def test_insert_into_subpartitioned_table_and_query(self, mysql_backend): ) mysql_backend.execute(*insert.to_sql()) - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[Column(mysql_backend.dialect, "payload")], from_=Table(mysql_backend.dialect, SUBPARTITION_TABLE), @@ -1430,7 +1430,7 @@ async def test_insert_into_subpartitioned_table_and_query(self, async_mysql_back """Rows inserted into subpartitioned table should be queryable (async).""" await _async_create_subpartitioned_table(async_mysql_backend) try: - insert = InsertExpression( + insert = InsertStatement( dialect=async_mysql_backend.dialect, into=Table(async_mysql_backend.dialect, SUBPARTITION_TABLE), columns=["id", "created_at", "region", "payload"], @@ -1445,7 +1445,7 @@ async def test_insert_into_subpartitioned_table_and_query(self, async_mysql_back ), ) await async_mysql_backend.execute(*insert.to_sql()) - query = QueryExpression( + query = QueryStatement( dialect=async_mysql_backend.dialect, select=[Column(async_mysql_backend.dialect, "payload")], from_=Table(async_mysql_backend.dialect, SUBPARTITION_TABLE), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index 542540f4..17f8de9f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -10,18 +10,18 @@ ColumnConstraintType, ColumnDefinition, ComparisonPredicate, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, FunctionCall, - InsertExpression, + InsertStatement, Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, TableConstraint, TableConstraintType, ValuesSource, - WildcardExpression, + Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.expression import ( @@ -64,7 +64,7 @@ def _drop_named_table_expression(dialect, table_name: str): - return DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True) def _base_columns(dialect): @@ -81,7 +81,7 @@ def _partition_value(dialect, value): def _create_range_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, RANGE_TABLE), columns=_base_columns(dialect), @@ -97,7 +97,7 @@ def _create_range_table_expression(dialect): def _create_range_columns_multi_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, RANGE_COLUMNS_MULTI_TABLE), columns=_base_columns(dialect), @@ -123,7 +123,7 @@ def _create_range_columns_multi_table_expression(dialect): def _create_list_columns_multi_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_COLUMNS_MULTI_TABLE), columns=_base_columns(dialect), @@ -151,7 +151,7 @@ def _create_list_columns_multi_table_expression(dialect): def _create_list_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_TABLE), columns=_base_columns(dialect), @@ -173,7 +173,7 @@ def _create_list_table_expression(dialect): def _create_list_columns_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_COLUMNS_TABLE), columns=_base_columns(dialect), @@ -195,7 +195,7 @@ def _create_list_columns_table_expression(dialect): def _create_hash_table_expression(dialect, table_name: str, *, linear: bool = False): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=_base_columns(dialect), @@ -209,7 +209,7 @@ def _create_hash_table_expression(dialect, table_name: str, *, linear: bool = Fa def _create_key_table_expression(dialect, table_name: str, *, linear: bool = False): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=_base_columns(dialect), @@ -223,7 +223,7 @@ def _create_key_table_expression(dialect, table_name: str, *, linear: bool = Fal def _insert_rows_expression(dialect, table_name: str, rows: Sequence[Sequence[object]]): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, table_name), columns=["id", "shard_id", "category", "payload"], @@ -235,7 +235,7 @@ def _insert_rows_expression(dialect, table_name: str, rows: Sequence[Sequence[ob def _select_payloads_expression(dialect, table_name: str): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, table_name), @@ -244,16 +244,16 @@ def _select_payloads_expression(dialect, table_name: str): def _select_count_expression(dialect, table_name: str): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], from_=Table(dialect, table_name), ) def _partition_metadata_expression(dialect, table_name: str): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -574,7 +574,7 @@ def test_null_in_range_partition_goes_to_first_partition(self, mysql_backend): ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -589,7 +589,7 @@ def test_null_in_range_partition_goes_to_first_partition(self, mysql_backend): ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -619,7 +619,7 @@ def test_null_in_list_partition_with_explicit_values_in_works(self, mysql_backen ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -640,7 +640,7 @@ def test_null_in_list_partition_with_explicit_values_in_works(self, mysql_backen ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -670,7 +670,7 @@ def test_null_in_hash_partition_accepts_null_key(self, mysql_backend): ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -682,7 +682,7 @@ def test_null_in_hash_partition_accepts_null_key(self, mysql_backend): ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -712,7 +712,7 @@ def test_null_in_key_partition_accepts_null_key(self, mysql_backend): ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -725,7 +725,7 @@ def test_null_in_key_partition_accepts_null_key(self, mysql_backend): ) try: # KEY partition using `id` which is NOT NULL; verify normal inserts work - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -756,7 +756,7 @@ def test_key_with_empty_parenthesis_uses_all_primary_key_columns(self, mysql_bac dialect = mysql_backend.dialect mysql_backend.execute(*_drop_named_table_expression(dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=dialect, table=Table(dialect, table), columns=[ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py index 0fa6b535..63db45e3 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py @@ -25,16 +25,16 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import BaseExpression, Literal from rhosocial.activerecord.backend.expression.statements import ( - AlterDomainExpression, - AlterTypeExpression, - CreateDomainExpression, - CreateTypeExpression, + AlterDomainStatement, + AlterTypeStatement, + CreateDomainStatement, + CreateTypeStatement, DomainCheckConstraint, DomainNullability, DomainValueExpression, DropDomainDefaultAction, - DropDomainExpression, - DropTypeExpression, + DropDomainStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -113,9 +113,9 @@ def _type_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: definition = _TestTypeDefinition(dialect) action = _TestTypeAlterAction(dialect) return ( - CreateTypeExpression(dialect, Type(dialect, "status"), definition), - AlterTypeExpression(dialect, Type(dialect, "status"), [action]), - DropTypeExpression(dialect, Type(dialect, "status")), + CreateTypeStatement(dialect, Type(dialect, "status"), definition), + AlterTypeStatement(dialect, Type(dialect, "status"), [action]), + DropTypeStatement(dialect, Type(dialect, "status")), definition, action, ) @@ -125,9 +125,9 @@ def _domain_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) check = DomainCheckConstraint(dialect, condition) return ( - CreateDomainExpression(dialect, Domain(dialect, "positive"), IntegerType(dialect)), - AlterDomainExpression(dialect, Domain(dialect, "positive"), [DropDomainDefaultAction(dialect)]), - DropDomainExpression(dialect, Domain(dialect, "positive")), + CreateDomainStatement(dialect, Domain(dialect, "positive"), IntegerType(dialect)), + AlterDomainStatement(dialect, Domain(dialect, "positive"), [DropDomainDefaultAction(dialect)]), + DropDomainStatement(dialect, Domain(dialect, "positive")), DomainValueExpression(dialect), check, DropDomainDefaultAction(dialect), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py index c18c59d4..99a70e8c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py @@ -16,9 +16,9 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import FunctionCall from rhosocial.activerecord.backend.expression.functions.datetime import ( @@ -46,7 +46,7 @@ class TestMySQLNiladicSelectContext: def test_select_current_timestamp_niladic(self, mysql_backend): """SELECT CURRENT_TIMESTAMP (niladic, no parentheses) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_timestamp(mysql_backend.dialect)], ) @@ -59,7 +59,7 @@ def test_select_current_timestamp_niladic(self, mysql_backend): def test_select_current_timestamp_with_parens(self, mysql_backend): """SELECT CURRENT_TIMESTAMP() (with parentheses) also works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[FunctionCall(mysql_backend.dialect, "CURRENT_TIMESTAMP")], ) @@ -71,7 +71,7 @@ def test_select_current_timestamp_with_parens(self, mysql_backend): def test_select_current_date_niladic(self, mysql_backend): """SELECT CURRENT_DATE (niladic) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_date(mysql_backend.dialect)], ) @@ -83,7 +83,7 @@ def test_select_current_date_niladic(self, mysql_backend): def test_select_current_time_niladic(self, mysql_backend): """SELECT CURRENT_TIME (niladic) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_time(mysql_backend.dialect)], ) @@ -95,7 +95,7 @@ def test_select_current_time_niladic(self, mysql_backend): def test_select_now(self, mysql_backend): """SELECT NOW() (regular function, always with parentheses) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[now(mysql_backend.dialect)], ) @@ -115,9 +115,9 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): table_name = "test_niladic_ddl_1" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -151,7 +151,7 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): """DEFAULT CURRENT_TIMESTAMP() (with parentheses) also works in MySQL DDL.""" @@ -159,9 +159,9 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): table_name = "test_niladic_ddl_2" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -197,7 +197,7 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): """DEFAULT CURRENT_TIMESTAMP(6) (with precision) works in MySQL DDL.""" @@ -205,9 +205,9 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): table_name = "test_niladic_ddl_3" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -240,7 +240,7 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) class TestAsyncMySQLNiladicSelectContext: @@ -249,7 +249,7 @@ class TestAsyncMySQLNiladicSelectContext: @pytest.mark.asyncio async def test_async_select_current_timestamp_niladic(self, async_mysql_backend): """SELECT CURRENT_TIMESTAMP (niladic) works in MySQL (async).""" - query = QueryExpression( + query = QueryStatement( dialect=async_mysql_backend.dialect, select=[current_timestamp(async_mysql_backend.dialect)], ) @@ -271,10 +271,10 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac # Clean up await async_mysql_backend.execute( - *DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() + *DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() ) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -306,5 +306,5 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac assert "ts" in col_names finally: await async_mysql_backend.execute( - *DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() + *DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() ) From 3d643d6471762dfdd11bc69091c239bc1795a00a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 14 Oct 2026 11:34:05 +0800 Subject: [PATCH 06/65] refactor(mysql): migrate constraints onto the two-level branch nodes Core's S3 put the kind inside the clause: ``ColumnConstraint`` and ``TableConstraint`` are productions that carry a **branch**, and the branch is a node in its own right. ``ColumnConstraintType`` and ``TableConstraintType`` are gone -- there is no tag enum left to switch on, and every call site in this backend now says which alternative it means. **Construction sites** (60 files; src 33, tests 25, docs 2). The mapping, applied by hand, not by search-and-replace, because the table-level branches take their columns positionally: ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY, name=n, is_auto_increment=True) -> ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d, is_auto_increment=True), name=n) TableConstraint(d, TableConstraintType.PRIMARY_KEY, columns=[...], name=n) -> TableConstraint(d, branch=TablePrimaryKeyConstraint(d, [...]), name=n) ... check_condition / foreign_key_reference / default_value -> ColumnCheckConstraint / ColumnForeignKeyConstraint / ColumnDefaultConstraint ... NOT_NULL / NULL / UNIQUE tags -> ColumnNotNullConstraint / ColumnNullableConstraint / ColumnUniqueConstraint Zero references to the deleted enums remain in this repository, including the prose docs and the README, which wave 1 had left naming ``ColumnConstraintType``. **Override points.** ``MySQLTableMixin.format_table_constraint`` -- a fat tag switch over the whole table-level family -- is gone, replaced by two branch formatters that answer for their own alternative and delegate: format_table_check_constraint keeps the >= 8.0.16 version gate on the branch this backend overrides format_table_foreign_key_constraint keeps MySQL's spaced reference body, "REFERENCES `p` (`id`)", by going through the shared ReferencesClause so the MATCH / ON DELETE probes stay consulted Both are new public members and are registered in ``MySQLTableSupport`` (protocols.py) as the protocol-conformance sweep requires. The production level moved with the gate that belongs to it: ``format_table_constraint`` now lives on ``MySQLConstraintMixin``, next to the probes it consults, and its declaration was dropped from ``MySQLTableSupport`` rather than left pointing at a mixin that no longer implements it. **Narrowing, re-checked against the new API** (wave 3 of the plan, folded in here because the probes, the ``_require_*`` helpers and the formatters that call them are one unit -- splitting them would put a commit on record that contradicts core's API). The three shape rules the plan set out all hold: probes are ``(self)``, narrowing lives in ``_require_*`` helpers, and the kind is read off the branch class, never off the deleted enum. Two things needed adjusting: - the enforcement gate had to become ``_require_constraint_enforcement``. ``_format_constraint_enforcement`` takes the *production* and is shared by the column trait renderer and the table one, so the kind test cannot live there; the helper is what both reach. - ``format_table_constraint`` runs that gate **before** delegating. Core renders the branch body first and the traits after it, but this server's CHECK body carries its own version refusal that says nothing about the enforcement spelling -- so without the hoist, ``CHECK ... NOT ENFORCED`` at 8.0.0 would be refused as "CHECK constraint" instead of by the spelling it asked for, and the two refusals would collapse into one. This restores the ordering the mixin had before the branch nodes split the production apart. Messages follow the shared style: the feature is the SQL spelling (``FOREIGN KEY NOT ENFORCED``), not an enum member and not a class name, and the suggestion restates it. All measured verdicts and their errno are unchanged -- they are MySQL's, measured on Community Server 26.7.0 at 192.168.1.3:14686, and nothing here re-derives them. **Behaviour changes** - A table-level FOREIGN KEY with no local column, no referenced column or no referenced table now raises ``ValueError`` where the old switch silently emitted nothing at all. - The params of a rendered table constraint are a tuple. They were a list, from the collector the old switch used; core's branch formatters hand back tuples and the production level normalises to one. - ``AlterConstraint`` takes ``constraint_kind=`` (a branch class, or the spelling the constructor matches) where it took ``constraint_type=``. **Tests** pytest tests/.../feature/backend/ddl \ tests/.../test_mysql_ddl_features.py \ tests/.../test_mysql_ddl_improvements.py \ tests/.../test_auto_increment_ddl.py \ tests/.../test_mysql_dialect_security.py \ tests/.../test_mysql_mixin_expressions.py \ tests/.../test_mysql_constraint_switches.py \ tests/.../test_mysql_clause_pair_guard.py \ tests/.../test_mysql_protocol_conformance.py exit 0 ``test_expression_roundtrip_all.py``: the stale ``_NOT_RENDERED`` row for ``ColumnConstraint`` is deleted -- the class builds through the testsuite's branch-aware special constructor and renders `` NOT NULL``, so the row described an API that no longer exists. Five failures and one error remain in that file and are **identical at 8ba84de** (verified by running the HEAD copy of the file against the same environment): ``LikePredicate`` and ``DomainCheckConstraint`` need special constructors the testsuite does not carry, ``PassingClause`` needs a ``_DECLARED_GAP`` row. None of them is a constraint surface and none is this wave's to fix. Shared contract, from the core worktree:: pytest ../../../python-activerecord-testsuite/.worktree/.../testsuite/feature/ddl/ exit 4 -- ImportError: cannot import name 'ColumnConstraintType' from ...statements.ddl_table The testsuite's own S3 adaptation is unfinished (``feature/ddl/fixtures/ models.py`` still imports the deleted enum). Recorded as the known gap; the testsuite is another agent's repository. ruff ``src`` is at parity with 8ba84de, per file and per rule. --- README.md | 5 +- docs/en_US/mysql_specific_features/dialect.md | 16 +- docs/examples/chapter_04_ddl/ddl.py | 9 +- docs/zh_CN/mysql_specific_features/dialect.md | 16 +- .../mysql/examples/connection/quickstart.py | 15 +- .../impl/mysql/examples/ddl/alter_table.py | 22 +- .../impl/mysql/examples/ddl/create_index.py | 9 +- .../impl/mysql/examples/ddl/create_table.py | 18 +- .../impl/mysql/examples/ddl/drop_table.py | 6 +- .../impl/mysql/examples/ddl/unique_index.py | 6 +- .../impl/mysql/examples/delete/basic.py | 11 +- .../impl/mysql/examples/insert/batch.py | 26 ++- .../impl/mysql/examples/insert/single.py | 11 +- .../impl/mysql/examples/insert/upsert.py | 19 +- .../mysql/examples/insert/with_returning.py | 11 +- .../examples/named_migrations/expressions.py | 24 +- .../impl/mysql/examples/query/aggregate.py | 9 +- .../impl/mysql/examples/query/basic.py | 11 +- .../backend/impl/mysql/examples/query/cte.py | 6 +- .../impl/mysql/examples/query/explain.py | 11 +- .../impl/mysql/examples/query/fulltext.py | 9 +- .../backend/impl/mysql/examples/query/join.py | 15 +- .../impl/mysql/examples/query/json_table.py | 9 +- .../impl/mysql/examples/query/predicate.py | 11 +- .../impl/mysql/examples/query/subquery.py | 13 +- .../impl/mysql/examples/query/union.py | 6 +- .../impl/mysql/examples/query/window.py | 9 +- .../mysql/examples/query/window_mysql57.py | 9 +- .../examples/schema_diff/column_order.py | 7 +- .../examples/schema_diff/mysql56_json.py | 11 +- .../examples/schema_diff/mysql80_charset.py | 7 +- .../examples/schema_diff/table_changes.py | 11 +- .../impl/mysql/examples/transaction/basic.py | 14 +- .../mysql/examples/transaction/exclusive.py | 14 +- .../mysql/examples/transaction/for_update.py | 14 +- .../impl/mysql/examples/types/json_basic.py | 9 +- .../impl/mysql/examples/types/json_mysql56.py | 11 +- .../impl/mysql/examples/update/basic.py | 11 +- .../backend/impl/mysql/mixins/constraint.py | 213 +++++++++++++++--- .../backend/impl/mysql/mixins/ddl_table.py | 203 ++++++----------- .../backend/impl/mysql/protocols.py | 14 +- tests/providers/fixtures/_common.py | 84 ++----- tests/providers/fixtures/basic.py | 173 +++++++------- tests/providers/fixtures/events.py | 31 +-- tests/providers/fixtures/mixins.py | 51 +++-- tests/providers/fixtures/query.py | 130 +++++------ .../backend/ddl/test_ddl_source_collection.py | 77 ++++--- .../backend/test_auto_increment_ddl.py | 15 +- .../backend/test_expression_roundtrip_all.py | 5 - .../backend/test_mysql_clause_pair_guard.py | 18 +- .../backend/test_mysql_constraint_switches.py | 43 +++- .../backend/test_mysql_ddl_features.py | 91 ++++---- .../backend/test_mysql_ddl_improvements.py | 25 +- .../backend/test_mysql_dialect_security.py | 11 +- .../backend/test_mysql_identity_execution.py | 5 +- .../backend/test_mysql_mixin_expressions.py | 39 ++-- .../backend/test_mysql_partition_explain.py | 12 +- .../test_mysql_partition_operations.py | 33 ++- ...est_mysql_partition_strategy_operations.py | 26 +-- .../feature/backend/test_niladic_functions.py | 34 +-- 60 files changed, 956 insertions(+), 828 deletions(-) diff --git a/README.md b/README.md index 0869b665..79ecab3d 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConf from rhosocial.activerecord.backend.expression import CreateTableExpression from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -86,8 +86,7 @@ create_users = CreateTableExpression( constraints=[ ColumnConstraint( dialect, - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), ) ], ), diff --git a/docs/en_US/mysql_specific_features/dialect.md b/docs/en_US/mysql_specific_features/dialect.md index 9b8b535f..21db76cf 100644 --- a/docs/en_US/mysql_specific_features/dialect.md +++ b/docs/en_US/mysql_specific_features/dialect.md @@ -59,7 +59,7 @@ from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + ColumnDefinition, ColumnConstraint, ColumnDefaultConstraint, ) # Correct: Use factory functions for SQL:2003 niladic functions @@ -67,8 +67,8 @@ ColumnDefinition( name='created_at', data_type='TIMESTAMP', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), # Generates: `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP # (no parentheses — SQL:2003 niladic form) ], @@ -79,8 +79,8 @@ ColumnDefinition( name='updated_at', data_type='DATETIME', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=now(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, now(dialect))), # Generates: `updated_at` DATETIME DEFAULT NOW() ], ) @@ -90,7 +90,7 @@ ColumnDefinition( name='is_active', data_type='TINYINT(1)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), # Generates: `is_active` TINYINT(1) DEFAULT 1 ], ) @@ -100,7 +100,7 @@ ColumnDefinition( name='status', data_type='VARCHAR(20)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='active'), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'active')), # Generates: `status` VARCHAR(20) DEFAULT 'active' ], ) @@ -111,7 +111,7 @@ ColumnDefinition( ```python # WRONG: Passing SQL keywords as Python strings # This would generate DEFAULT 'CURRENT_TIMESTAMP' (quoted, treated as string literal) -ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='CURRENT_TIMESTAMP') +ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'CURRENT_TIMESTAMP')) ``` **Common statement-level constants reference:** diff --git a/docs/examples/chapter_04_ddl/ddl.py b/docs/examples/chapter_04_ddl/ddl.py index d08ab450..f9734f7e 100644 --- a/docs/examples/chapter_04_ddl/ddl.py +++ b/docs/examples/chapter_04_ddl/ddl.py @@ -21,7 +21,8 @@ ) from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) @@ -68,17 +69,17 @@ def main(): ColumnDefinition( "id", "INTEGER", - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)] + constraints=[ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None))] ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))] ), ColumnDefinition( "email", "VARCHAR(255)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))] ), ] diff --git a/docs/zh_CN/mysql_specific_features/dialect.md b/docs/zh_CN/mysql_specific_features/dialect.md index cb01ba64..860c648a 100644 --- a/docs/zh_CN/mysql_specific_features/dialect.md +++ b/docs/zh_CN/mysql_specific_features/dialect.md @@ -59,7 +59,7 @@ from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + ColumnDefinition, ColumnConstraint, ColumnDefaultConstraint, ) # 正确:使用工厂函数传递 SQL:2003 零元函数 @@ -67,8 +67,8 @@ ColumnDefinition( name='created_at', data_type='TIMESTAMP', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), # 生成: `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP # (无括号 — SQL:2003 零元形式) ], @@ -79,8 +79,8 @@ ColumnDefinition( name='updated_at', data_type='DATETIME', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=now(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, now(dialect))), # 生成: `updated_at` DATETIME DEFAULT NOW() ], ) @@ -90,7 +90,7 @@ ColumnDefinition( name='is_active', data_type='TINYINT(1)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), # 生成: `is_active` TINYINT(1) DEFAULT 1 ], ) @@ -100,7 +100,7 @@ ColumnDefinition( name='status', data_type='VARCHAR(20)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='active'), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'active')), # 生成: `status` VARCHAR(20) DEFAULT 'active' ], ) @@ -111,7 +111,7 @@ ColumnDefinition( ```python # 错误:将 SQL 关键字作为 Python 字符串传入 # 这会导致生成 DEFAULT 'CURRENT_TIMESTAMP'(被引号包裹,变成字符串字面量) -ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='CURRENT_TIMESTAMP') +ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'CURRENT_TIMESTAMP')) ``` **常见语句级常量对照表:** diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py index 6ecebc8e..4744bcad 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py @@ -25,8 +25,9 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig @@ -68,14 +69,14 @@ def create_demo_tables(): "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("status", "VARCHAR(20)"), ], @@ -91,14 +92,14 @@ def create_demo_tables(): "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "message", "VARCHAR(255)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ], if_not_exists=True, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py index ebb83054..ca5a11a4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py @@ -23,9 +23,11 @@ ) from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) config = MySQLConnectionConfig( @@ -56,8 +58,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -126,12 +128,12 @@ "INT", constraints=[ ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0), - ), - ], - ), -) + None, + branch=ColumnDefaultConstraint(None, Literal(dialect, 0)) + ), + ], + ), + ) add_age_expr = AlterTableStatement( dialect=dialect, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py index 64b85db4..46dd44ff 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py @@ -10,9 +10,10 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) config = MySQLConnectionConfig( @@ -40,8 +41,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py index 6d7ecbb2..a2e5ab22 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py @@ -22,9 +22,11 @@ ) from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( IndexDefinition, @@ -56,22 +58,22 @@ name="id", data_type="INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( name="name", data_type="VARCHAR(200)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( name="price", data_type="DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( @@ -82,14 +84,14 @@ name="is_active", data_type="TINYINT(1)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 1)), ], ), ColumnDefinition( name="created_at", data_type="TIMESTAMP", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, current_timestamp(dialect))), ], ), ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py index 96fd3044..89c57436 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py @@ -29,9 +29,9 @@ DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -51,7 +51,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py index c4d5b934..f1b79312 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py @@ -26,9 +26,9 @@ from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) # Drop dependent tables first for clean setup @@ -48,7 +48,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py index 56086c2d..0ba611c3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py @@ -35,9 +35,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) # Drop dependent tables first for clean setup @@ -57,15 +58,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py index cd74dc2a..e1afefa5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py @@ -14,9 +14,11 @@ ) from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -46,8 +48,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("level", "VARCHAR(20)"), @@ -57,14 +59,14 @@ "TIMESTAMP", constraints=[ ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect), - ), - ], - ), - ], - if_not_exists=True, -) + None, + branch=ColumnDefaultConstraint(None, current_timestamp(dialect)) + ), + ], + ), + ], + if_not_exists=True, + ) sql, params = create_table.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py index 419ff9bd..43a4af55 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py @@ -23,9 +23,10 @@ from rhosocial.activerecord.backend.expression.core import Literal, Wildcard, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions @@ -62,15 +63,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py index 25030422..c7a2f0e4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py @@ -36,9 +36,12 @@ from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, OnConflictClause, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 @@ -64,30 +67,30 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "username", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnUniqueConstraint(None)), ], ), ColumnDefinition( "email", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "login_count", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py index 1576e6b5..05e217f4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py @@ -22,9 +22,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column, FunctionCall from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -55,15 +56,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py index d8d5ebd5..1fdcf243 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py @@ -7,10 +7,10 @@ """ from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableStatement, - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, + CreateTableStatement, DropTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.types import ( @@ -32,9 +32,9 @@ def create_users_table(dialect): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "name", MySQLTextType(dialect)), @@ -60,9 +60,9 @@ def create_posts_table(dialect): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "title", MySQLTextType(dialect)), @@ -92,9 +92,9 @@ def create_custom_table(dialect, table_name: str = "custom_table"): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "value", MySQLTextType(dialect)), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py index 741edcf4..33302257 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) @@ -48,8 +49,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("product", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py index 040bcc4a..8c49e4d2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) @@ -52,14 +53,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("age", "INT"), ColumnDefinition("status", "VARCHAR(20)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py index 56a9c82e..b490916e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py @@ -37,9 +37,9 @@ from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -60,7 +60,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py index d27a51d9..cf12e3ac 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py @@ -25,9 +25,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.explain import ExplainOptions from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate @@ -56,15 +57,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py index 943ba241..7e1c7368 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py @@ -29,9 +29,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "articles"), if_exists=True) @@ -46,8 +47,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("title", "VARCHAR(200)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py index d8732f19..a4341a14 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_customers = DropTableStatement(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) @@ -52,14 +53,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("email", "VARCHAR(100)"), ], @@ -76,8 +77,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("customer_id", "INT"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py index 309a3633..0cc4ccf1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py @@ -32,9 +32,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) @@ -49,8 +50,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("order_data", "JSON"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py index ed3424b0..2965d9c7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) @@ -48,14 +49,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("department", "VARCHAR(50)"), ColumnDefinition("salary", "DECIMAL(10,2)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py index 9b21773c..1f02b684 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_departments = DropTableStatement(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) @@ -52,8 +53,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -72,8 +73,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py index 64d9e76f..3c05a21a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py @@ -35,9 +35,9 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) # Drop table first for clean setup @@ -53,7 +53,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py index e1544a38..33b69b4b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py @@ -31,9 +31,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) @@ -48,8 +49,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("salesperson", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py index 30a5da5d..b0cdae66 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py @@ -41,9 +41,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -59,8 +60,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("salesperson", "VARCHAR(100)"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py index d0264420..418ec50e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py @@ -34,7 +34,8 @@ from rhosocial.activerecord.backend.expression import ( # noqa: E402 DropTableStatement, CreateTableStatement, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, @@ -47,8 +48,8 @@ dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ColumnDefinition(dialect, "email", VarCharType(dialect, 200)), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py index 2b8afb12..2a0c7852 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py @@ -34,7 +34,8 @@ from rhosocial.activerecord.backend.expression import ( # noqa: E402 DropTableStatement, CreateTableStatement, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, JsonType, @@ -55,8 +56,8 @@ dialect=dialect, table=Table(dialect, "documents56"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "data", MySQLLongTextType(dialect)), ] @@ -68,8 +69,8 @@ dialect=dialect, table=Table(dialect, "documents57"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "data", JsonType(dialect)), ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py index 559549ae..be11c90d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py @@ -34,7 +34,8 @@ from rhosocial.activerecord.backend.expression import ( # noqa: E402 DropTableStatement, CreateTableStatement, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, @@ -48,8 +49,8 @@ dialect=dialect, table=Table(dialect, "demo"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py index 89a6516c..f28b9c92 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py @@ -46,7 +46,8 @@ ) from rhosocial.activerecord.backend.expression import ( # noqa: E402 CreateTableStatement, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, DecimalType, @@ -61,8 +62,8 @@ dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] @@ -73,8 +74,8 @@ dialect=dialect, table=Table(dialect, "orders"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "amount", DecimalType(dialect, 10, 2)), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py index 35a0515b..2b60b8b8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py @@ -36,9 +36,11 @@ from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -57,22 +59,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py index 66d600c7..20f5d4d7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py @@ -24,9 +24,11 @@ from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -59,22 +61,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py index e4c5e3a4..a66bdf50 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py @@ -39,9 +39,11 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -61,22 +63,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py index 4b186c87..91da0409 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py @@ -32,9 +32,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) @@ -50,8 +51,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("data", "JSON"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py index 1fbb84ba..7178a62e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py @@ -34,9 +34,10 @@ ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) @@ -54,15 +55,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "data", "TEXT", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py index 70306f5e..950a063e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py @@ -38,9 +38,10 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -57,15 +58,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("age", "INT"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index 33f8ff18..a9f784ed 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -7,9 +7,48 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, + TableConstraint, ) +def _constraint_kind_sql(branch) -> str: + """The SQL spelling of a constraint branch's kind. + + A refusal names the constraint the way the caller spelled it, so the + message reads ``FOREIGN KEY NOT ENFORCED`` rather than naming an enum + member or a Python class. The kind used to be a tag on an enum and is a + branch class now, so the mapping is derived rather than carried on the + node; a branch outside the table falls back to its own name rather than + being silently reported as some other kind. + + The branches are imported here rather than at module scope because the + expression layer is behind the lazy-import boundary this package uses. + """ + from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnForeignKeyConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + ) + + return { + ColumnPrimaryKeyConstraint: "PRIMARY KEY", + TablePrimaryKeyConstraint: "PRIMARY KEY", + ColumnUniqueConstraint: "UNIQUE", + TableUniqueConstraint: "UNIQUE", + ColumnForeignKeyConstraint: "FOREIGN KEY", + TableForeignKeyConstraint: "FOREIGN KEY", + ColumnCheckConstraint: "CHECK", + TableCheckConstraint: "CHECK", + TableExcludeConstraint: "EXCLUDE", + }.get(type(branch), type(branch).__name__) + + class MySQLConstraintMixin: """MySQL constraint capability probes. @@ -368,30 +407,30 @@ def format_alter_constraint_action( ``3941`` names PRIMARY, UNIQUE and FOREIGN KEY -- measured, see :meth:`supports_alter_constraint_enforced`), so the dialect refuses them by name instead of sending a statement the server rejects. + The action names its kind by the branch class it carries; the + constructor has already refused every branch but CHECK and + FOREIGN KEY, so the narrowing left here is the FOREIGN KEY half. Raises: - TypeError: ``constraint_type`` is not a ``TableConstraintType``. - UnsupportedFeatureError: The kind is anything but CHECK. + UnsupportedFeatureError: The branch is anything but a table + CHECK. """ from rhosocial.activerecord.backend.dialect.exceptions import ( UnsupportedFeatureError, ) from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, + TableCheckConstraint, ) - constraint_type = action.constraint_type - if not isinstance(constraint_type, TableConstraintType): - raise TypeError( - "constraint_type must be a TableConstraintType, got " - f"{type(constraint_type).__name__}" - ) - if constraint_type is not TableConstraintType.CHECK: + branch = action.constraint_branch + if branch is not TableCheckConstraint: + feature = f"ALTER CONSTRAINT {_constraint_kind_sql(branch)} ENFORCED/NOT ENFORCED" raise UnsupportedFeatureError( self.name, - f"ALTER CONSTRAINT {constraint_type.name} ENFORCED/NOT ENFORCED", - "MySQL alters the enforcement state of CHECK constraints only; the " - "server answers errno 3941 on PRIMARY KEY, UNIQUE and FOREIGN KEY.", + feature, + f"{self.name} does not support {feature}. MySQL alters the " + "enforcement state of CHECK constraints only; the server answers " + "errno 3941 on PRIMARY KEY, UNIQUE and FOREIGN KEY.", ) return super().format_alter_constraint_action(action) @@ -454,40 +493,140 @@ def supports_constraint_enforced(self) -> bool: """ return self.version >= (8, 0, 16) - def _format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + def _require_constraint_enforcement(self, constraint) -> None: """Narrow the ENFORCABLE kinds to CHECK for this server. - The shared helper renders whatever the standard-derived whitelist + The shared renderer accepts whatever the standard-derived whitelist allows; MySQL's parser accepts the trait on CHECK only (measured on Community Server 26.7.0: UNIQUE and FOREIGN KEY answer ``1064`` -- - see :meth:`supports_constraint_enforced`), so the kind is narrowed - here and everything else is refused by name. The table-level path - renders through this backend's own ``format_table_constraint``, - which makes the same refusal inline. + see :meth:`supports_constraint_enforced`), so the branch is narrowed + here and everything else is refused by name. Both levels render the + trait through this gate -- the column path from + ``format_constraint_traits`` and the table path from the core's + ``format_table_constraint`` -- so one narrowing refuses alike for + the column clause and the table clause, and each refusal names the + kind and the spelling it refuses so the two halves of the trait + stay distinguishable. + + Raises: + UnsupportedFeatureError: The branch is not a CHECK, or the + version predates the clause. + """ + if not (constraint.enforced or constraint.not_enforced): + return + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + spelling = "ENFORCED" if constraint.enforced else "NOT ENFORCED" + kind = _constraint_kind_sql(constraint.branch) + if kind != "CHECK": + raise UnsupportedFeatureError( + self.name, + f"{kind} {spelling}", + f"{self.name} does not support {kind} {spelling}. MySQL carries " + "[NOT] ENFORCED on CHECK constraints only; the server answers " + "1064 on every other kind (measured on 26.7.0).", + ) + if not self.supports_constraint_enforced(): + feature = f"CHECK {spelling}" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. MySQL accepts " + f"{spelling} on CHECK constraints from 8.0.16 on; below that the " + "clause parses and is discarded (measured on 26.7.0: 1064).", + ) + + def _format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + """Render the enforcement trait behind this backend's narrowing. + + The shared private hook takes the production, so it is shared by the + column-level trait renderer and the table-level one: the kind + narrowing must therefore live in a helper both can reach, not in the + kind test itself. That is what :meth:`_require_constraint_enforcement` + is -- the table path calls it ahead of the branch body (see + :meth:`format_table_constraint`) so the refusal still names the + spelling when the branch has its own, earlier refusal to make. """ - if constraint.enforced or constraint.not_enforced: + self._require_constraint_enforcement(constraint) + return super()._format_constraint_enforcement(constraint) + + # Constraint deferral (the rendering half; the probe is above) + + def _require_no_constraint_deferral(self, constraint) -> None: + """Refuse a deferral trait by the exact spelling that was asked for. + + The probe (:meth:`supports_deferrable_constraint`) is a whole-family + ``False`` and the renderer must refuse *by name* -- one generic + message for ``DEFERRABLE`` and ``NOT DEFERRABLE`` would leave the two + halves of the trait indistinguishable, which is exactly what the + clause-pair guard refuses to accept. The refusal names the branch's + SQL kind and the spelling that was asked for, so a + ``FOREIGN KEY NOT DEFERRABLE`` and a ``FOREIGN KEY INITIALLY DEFERRED`` + do not collapse into one message. The spellings are the SQL ones + (measured on 26.7.0: every one answers ``1064`` -- see the probe). + + Raises: + UnsupportedFeatureError: Any deferral trait was requested. + """ + wants = [] + if constraint.deferrable: + wants.append("DEFERRABLE") + if constraint.not_deferrable: + wants.append("NOT DEFERRABLE") + if constraint.initially_deferred: + wants.append("INITIALLY DEFERRED") + if constraint.initially_immediate: + wants.append("INITIALLY IMMEDIATE") + if wants and not self.supports_deferrable_constraint(): from rhosocial.activerecord.backend.dialect.exceptions import ( UnsupportedFeatureError, ) - from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, - TableConstraintType, + + feature = f"{_constraint_kind_sql(constraint.branch)} " + " ".join(wants) + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. No spelling of " + "constraint deferral exists in MySQL (measured on 26.7.0: " + "1064).", ) - kind = constraint.constraint_type - if not isinstance(kind, (ColumnConstraintType, TableConstraintType)): - raise TypeError( - "constraint_type must be a ColumnConstraintType or " - f"TableConstraintType, got {type(kind).__name__}" - ) - if kind.name != "CHECK" or not self.supports_constraint_enforced(): - raise UnsupportedFeatureError( - self.name, - "ENFORCED/NOT ENFORCED constraint", - "MySQL carries [NOT] ENFORCED on CHECK constraints only; the " - "server answers 1064 on every other kind (measured on 26.7.0).", - ) - return super()._format_constraint_enforcement(constraint) + def format_constraint_traits(self, constraint: "ColumnConstraint") -> Tuple[str, str]: + """Render the column-clause traits after this backend's deferral gate. + + Core's own renderer refuses the deferral family with one generic + message; this backend narrows through + :meth:`_require_no_constraint_deferral` first so the refusal names + the exact spelling, then defers everything else to core. + """ + self._require_no_constraint_deferral(constraint) + return super().format_constraint_traits(constraint) + + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, Tuple]: + """Render a table-level constraint clause after this backend's gates. + + The production-level rendering is core's; what this override adds is + the two narrowings the column path runs, both ahead of the + delegation: + + * the deferral gate, so a ``DEFERRABLE`` request on a table clause is + refused by its exact spelling rather than by the generic family + message core's renderer would produce; + * the enforcement gate, **before** the branch body. Core renders the + branch first and the traits after it, but this server's own CHECK + body carries its own version refusal + (``format_table_check_constraint``, below 8.0.16) that says nothing + about the enforcement spelling. Running the gate first keeps a + ``CHECK ... NOT ENFORCED`` at 8.0.0 refused by the spelling it + asked for, which is what keeps the two refusals distinguishable -- + the ordering this mixin had before the branch nodes split the + production apart. + """ + self._require_no_constraint_deferral(expr) + self._require_constraint_enforcement(expr) + return super().format_table_constraint(expr) # FK match modes (SQL:1999) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 9e54dc14..b2a65f65 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -12,7 +12,8 @@ CreateTableLikeStatement, IndexDefinition, StorageOptionsExpression, - TableConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, ) @@ -196,7 +197,14 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu if constraint_text: parts.append(constraint_text) params.extend(list(cp)) - if constraint.is_auto_increment: + from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, + ) + + if ( + isinstance(constraint.branch, ColumnPrimaryKeyConstraint) + and constraint.branch.is_auto_increment + ): # The marker is rendered by the dialect's # format_auto_increment_clause, not spelled here: the formatter # consults supports_auto_increment_column(), so the capability @@ -232,137 +240,76 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu return " ".join(parts), params - def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: - """Format a table-level constraint. + def format_table_check_constraint( + self, constraint: "TableCheckConstraint" + ) -> Tuple[str, tuple]: + """Format a table-level ``CHECK`` body behind this server's version gate. - Raises: - TypeError: ``t_const.foreign_key_table`` is set and is not a Table. - The rendered statement would reference whatever object's name - was in that slot. + The core's table-level CHECK body renders without consulting a + probe; MySQL's server accepted and *enforced* CHECK only from + 8.0.16 on (below that the clause parses and is discarded -- see + ``supports_check_constraint``), so the version gate stays on the + branch this backend overrides. """ - from rhosocial.activerecord.backend.expression.statements import ( - ForeignKeyConstraint, - ReferentialAction, - TableConstraintType, + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, ) - if t_const.foreign_key_table is not None: - require_kind( - t_const.foreign_key_table, - Table, - "TableConstraint.foreign_key_table", + + if not self.supports_check_constraint(): + raise UnsupportedFeatureError( + self.name, + "CHECK constraint", + f"{self.name} does not support CHECK constraints.", ) - # [NOT] ENFORCED is a CHECK-only clause on this server (measured on - # Community Server 26.7.0: UNIQUE and FOREIGN KEY answer 1064). The - # request used to fall through the other branches silently; it is - # refused by name instead, matching the column path's narrowing. - if t_const.enforced or t_const.not_enforced: - if t_const.constraint_type is not TableConstraintType.CHECK: - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - feature = ( - f"{t_const.constraint_type.value} ENFORCED" - if t_const.enforced - else f"{t_const.constraint_type.value} NOT ENFORCED" - ) - raise UnsupportedFeatureError( - self.name, - feature, - f"{self.name} does not support {feature}.", - ) - parts = [] - params: List[Any] = [] - - if t_const.name: - parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") - - if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: - if t_const.columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"PRIMARY KEY ({cols_str})") - elif t_const.constraint_type == TableConstraintType.UNIQUE: - if t_const.columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"UNIQUE ({cols_str})") - elif t_const.constraint_type == TableConstraintType.CHECK: - if t_const.check_condition is not None: - enforcement = "" - if t_const.enforced or t_const.not_enforced: - if not self.supports_constraint_enforced(): - feature = ( - "CHECK constraint ENFORCED" - if t_const.enforced - else "CHECK constraint NOT ENFORCED" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - enforcement = " ENFORCED" if t_const.enforced else " NOT ENFORCED" - if not self.supports_check_constraint(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, "CHECK constraint", - f"{self.name} does not support CHECK constraints." - ) - check_sql, check_params = t_const.check_condition.to_sql() - parts.append(f"CHECK ({check_sql}){enforcement}") - params.extend(check_params) - elif t_const.constraint_type == TableConstraintType.FOREIGN_KEY: - if t_const.deferrable or t_const.not_deferrable: - feature = ( - "FOREIGN KEY DEFERRABLE" - if t_const.deferrable - else "FOREIGN KEY NOT DEFERRABLE" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.initially_deferred or t_const.initially_immediate: - feature = ( - "FOREIGN KEY INITIALLY DEFERRED" - if t_const.initially_deferred - else "FOREIGN KEY INITIALLY IMMEDIATE" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.enforced or t_const.not_enforced: - feature = ( - "FOREIGN KEY ENFORCED" - if t_const.enforced - else "FOREIGN KEY NOT ENFORCED" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.columns and t_const.foreign_key_table and t_const.foreign_key_columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - ref_cols_str = ", ".join(self.format_identifier(c) for c in t_const.foreign_key_columns) - ref_table = t_const.foreign_key_table.to_sql()[0] - parts.append(f"FOREIGN KEY ({cols_str}) REFERENCES {ref_table} ({ref_cols_str})") - if isinstance(t_const, ForeignKeyConstraint): - if t_const.on_delete and t_const.on_delete != ReferentialAction.NO_ACTION: - parts.append(f"ON DELETE {t_const.on_delete.value}") - if t_const.on_update and t_const.on_update != ReferentialAction.NO_ACTION: - parts.append(f"ON UPDATE {t_const.on_update.value}") - if t_const.match_type is not None: - if not self.supports_fk_match(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, "FOREIGN KEY MATCH", - f"{self.name} does not support MATCH for foreign keys." - ) - parts.append(f"MATCH {t_const.match_type}") + return super().format_table_check_constraint(constraint) + + def format_table_foreign_key_constraint( + self, constraint: "TableForeignKeyConstraint" + ) -> Tuple[str, tuple]: + """Format a table-level ``FOREIGN KEY`` with MySQL's spaced column list. + + MySQL's own DDL (``SHOW CREATE TABLE``) sets the referenced column + list off with a space -- ``FOREIGN KEY (`d`) REFERENCES `p` (`id`)`` + -- and this backend's tests and DDL fixtures pin that spelling, so + the reference body is re-spaced here rather than through the + core's ``REFERENCES t(c)``. The referential actions and ``MATCH`` + still ride the shared ``ReferencesClause`` machinery, so the + capability probes stay consulted (``supports_fk_match`` refuses + MATCH on this server). - return " ".join(parts), params + Raises: + ValueError: The local columns, the referenced columns or the + referenced table are missing. + """ + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ReferencesClause, + ) + + if not constraint.columns: + raise ValueError( + "FOREIGN KEY constraint must have at least one local column specified." + ) + referenced_table, referenced_columns = constraint.references + if not referenced_columns: + raise ValueError( + "FOREIGN KEY constraint must have at least one foreign key column specified." + ) + if not referenced_table: + raise ValueError("FOREIGN KEY constraint must have a foreign key table specified.") + cols_str = ", ".join(self.format_identifier(c) for c in constraint.columns) + references = ReferencesClause( + self, + referenced_table, + list(referenced_columns), + on_delete=constraint.on_delete, + on_update=constraint.on_update, + match_type=constraint.match_type, + ) + ref_sql, ref_params = self.format_references_clause(references) + # The shared clause renders "REFERENCES ()..."; MySQL + # spells the column list with a space: "REFERENCES
()...". + head, rest = ref_sql.split("(", 1) + return f"FOREIGN KEY ({cols_str}) {head} ({rest}", tuple(ref_params) def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline INDEX definition within CREATE TABLE (MySQL-specific).""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index 7140035a..b2dd42bc 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -20,6 +20,8 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableLikeStatement, + TableCheckConstraint, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( CreateTriggerStatement, @@ -366,8 +368,16 @@ def supports_column_comment(self) -> bool: """Whether an inline column COMMENT is supported (MySQL: yes).""" ... - def format_table_constraint(self, t_const: Any) -> Tuple[str, tuple]: - """Format a table-level constraint.""" + def format_table_check_constraint( + self, constraint: "TableCheckConstraint" + ) -> Tuple[str, tuple]: + """Format the table-level ``CHECK`` body, refusing it below 8.0.16.""" + ... + + def format_table_foreign_key_constraint( + self, constraint: "TableForeignKeyConstraint" + ) -> Tuple[str, tuple]: + """Format the table-level ``FOREIGN KEY`` with MySQL's spaced column list.""" ... def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 19861db0..228e0657 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -2,21 +2,18 @@ """Shared helpers for the MySQL DDL expression fixtures. The MySQL backend's :meth:`format_storage_options` quotes string option -values (``ENGINE='InnoDB'``) and :meth:`format_table_constraint` does not -emit ``ON DELETE`` / ``ON UPDATE`` referential actions for -:class:`ForeignKeyConstraint` instances. The authoritative ``.sql`` schema -files under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` -use the unquoted storage form and inline ``FOREIGN KEY ... ON DELETE CASCADE`` -clauses. - -Rather than modifying the MySQL backend library source (which is out of -scope for this refactor), :func:`to_mysql_ddl_sql` post-processes the SQL -produced by ``CreateTableStatement.to_sql()`` so that the generated DDL -matches the reference files byte-for-byte for the bits that matter: - -* ``KEY='value'`` storage options become ``KEY=value``. -* ``FOREIGN KEY ... REFERENCES ...`` clauses have their declared - ``on_delete`` / ``on_update`` referential actions appended. +values (``ENGINE='InnoDB'``), while the authoritative ``.sql`` schema files +under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` +use the unquoted storage form. :func:`to_mysql_ddl_sql` post-processes the +SQL produced by ``CreateTableStatement.to_sql()`` so the generated DDL +matches the reference files for that bit. + +The former second post-processing step -- re-injecting ``ON DELETE`` / +``ON UPDATE`` referential actions that ``format_table_constraint`` used to +drop for plain table constraints -- is gone: the two-level constraint nodes +render every ``TableForeignKeyConstraint`` through the shared +``ReferencesClause`` machinery, so the actions the fixtures declare are on +the wire the dialect produced. Providers call :func:`create_table_sql` instead of ``expr.to_sql()`` directly to obtain the canonical ``(sql, params)`` tuple. @@ -31,15 +28,10 @@ CreateTableStatement, DropTableStatement, ) -from rhosocial.activerecord.backend.expression.statements import ReferentialAction from rhosocial.activerecord.backend.expression.objects import Table _STORAGE_OPTION_RE = re.compile(r"([A-Z_ ]+=)'([^']*)'") -_FK_RE = re.compile( - r"(FOREIGN KEY \([`A-Za-z0-9_ ,]+\) REFERENCES [`A-Za-z0-9_.]+ \([`A-Za-z0-9_ ,]+\))" - r"(?=[,)])" -) def _strip_storage_option_quotes(sql: str) -> str: @@ -47,59 +39,19 @@ def _strip_storage_option_quotes(sql: str) -> str: return _STORAGE_OPTION_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}", sql) -def _append_fk_actions(expr: CreateTableStatement, sql: str) -> str: - """Append ``ON DELETE`` / ``ON UPDATE`` clauses to inline FK constraints. - - The MySQL dialect's ``format_table_constraint`` does not currently emit - referential actions even though :class:`ForeignKeyConstraint` carries them. - This helper re-injects them so the generated DDL matches the reference SQL - files that use ``ON DELETE CASCADE``. - """ - # Build a lookup of "FOREIGN KEY (cols) REFERENCES ref_table (ref_cols)" - # -> suffix to append for each matching constraint. - fk_suffix_by_clause: dict = {} - for t_const in expr.table_constraints: - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, - ForeignKeyConstraint, - ) - if t_const.constraint_type != TableConstraintType.FOREIGN_KEY: - continue - if not isinstance(t_const, ForeignKeyConstraint): - continue - cols = ", ".join(f"`{c}`" for c in (t_const.columns or [])) - ref_cols = ", ".join(f"`{c}`" for c in (t_const.foreign_key_columns or [])) - ref_table = t_const.foreign_key_table.to_sql()[0] - clause = f"FOREIGN KEY ({cols}) REFERENCES {ref_table} ({ref_cols})" - suffix_parts = [] - on_delete = getattr(t_const, "on_delete", None) - on_update = getattr(t_const, "on_update", None) - if on_delete and on_delete != ReferentialAction.NO_ACTION: - suffix_parts.append(f"ON DELETE {on_delete.value}") - if on_update and on_update != ReferentialAction.NO_ACTION: - suffix_parts.append(f"ON UPDATE {on_update.value}") - if suffix_parts: - fk_suffix_by_clause[clause] = " " + " ".join(suffix_parts) - - def repl(match: re.Match) -> str: - return match.group(1) + fk_suffix_by_clause.get(match.group(1), "") - - return _FK_RE.sub(repl, sql) - - def to_mysql_ddl_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: """Generate canonical MySQL DDL for a :class:`CreateTableStatement`. - Post-processes the dialect output to: - 1. Drop quotes around storage option values. - 2. Re-insert ``ON DELETE`` / ``ON UPDATE`` referential actions for - inline ``FOREIGN KEY`` table constraints. + Post-processes the dialect output to drop quotes around storage option + values. Referential actions need no help any more: the table-level + foreign-key branch renders them through the shared ``ReferencesClause`` + path, so what the fixtures declare is what the dialect emits. - Returns the ``(sql, params)`` tuple suitable for ``backend.execute``. + Returns: + The ``(sql, params)`` tuple suitable for ``backend.execute``. """ sql, params = expr.to_sql() sql = _strip_storage_option_quotes(sql) - sql = _append_fk_actions(expr, sql) return sql, params diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 3cbb0def..ea33ebd0 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -19,14 +19,17 @@ CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, - TableConstraint, - TableConstraintType, ReferentialAction, + TableConstraint, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, @@ -75,20 +78,20 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -107,12 +110,12 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, 36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tiny_int", TextType(dialect)), ColumnDefinition(dialect, "small_int", TextType(dialect)), ColumnDefinition(dialect, "big_int", TextType(dialect)), @@ -145,25 +148,25 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, 36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "string_field", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="test string")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test string"))]), ColumnDefinition(dialect, "int_field", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=42)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 42))]), ColumnDefinition(dialect, "float_field", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=3.14)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 3.14))]), ColumnDefinition(dialect, "decimal_field", DoubleType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=10.99)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 10.99))]), ColumnDefinition(dialect, "bool_field", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "datetime_field", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "json_field", JsonType(dialect)), ColumnDefinition(dialect, "nullable_field", VarCharType(dialect, 255)), ], @@ -184,21 +187,21 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DecimalType(precision=10, scale=2, dialect=dialect)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", status_enum, - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))]), ColumnDefinition(dialect, "is_active", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -215,11 +218,11 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), @@ -237,7 +240,7 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "code", VarCharType(dialect, 32)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -263,13 +266,13 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -286,15 +289,15 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "published_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -318,18 +321,18 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableS if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "post_ref", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "text", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], indexes=[ IndexDefinition(dialect, name="idx_post_ref", columns=["post_ref"]), @@ -356,11 +359,11 @@ def create_column_mapping_items_table(dialect, table_name: str = "column_mapping if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), @@ -378,9 +381,9 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "meta", TextType(dialect)), ColumnDefinition(dialect, "description", TextType(dialect)), @@ -401,9 +404,9 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "optional_name", VarCharType(dialect, 255)), ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), ColumnDefinition(dialect, "last_login", TextType(dialect)), @@ -426,18 +429,20 @@ def create_composite_pk_order_items_table(dialect, table_name: str = "order_item if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"]), + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["order_id", "product_id"]) +), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -454,18 +459,20 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "batch_id", VarCharType(dialect, 64), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "stock", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["store_id", "product_id", "batch_id"]), + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["store_id", "product_id", "batch_id"]) +), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -482,9 +489,9 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableState if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "total", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -503,13 +510,13 @@ def create_product_table(dialect, table_name: str = "product") -> CreateTableSta if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 4d6b048d..ff35d4c0 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -10,9 +10,12 @@ CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( DateTimeType, @@ -46,15 +49,15 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "revision", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), @@ -74,16 +77,16 @@ def create_event_tracking_models_table(dialect, table_name: str = "event_trackin if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "view_count", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "last_viewed_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index 054a1723..1bd92bbe 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -10,9 +10,12 @@ CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, @@ -49,21 +52,21 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "deleted_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -80,14 +83,14 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_completed", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "deleted_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -104,11 +107,11 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -127,15 +130,15 @@ def create_versioned_products_table(dialect, table_name: str = "versioned_produc if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index f32ecaa5..29d1eea3 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -14,9 +14,13 @@ CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, @@ -59,20 +63,20 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DoubleType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -91,15 +95,15 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "published"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -126,14 +130,14 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableS if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "post_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -162,17 +166,17 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableState if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -195,19 +199,19 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "subtotal", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -231,9 +235,9 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableS if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "bio", TextType(dialect)), ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, 512)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), @@ -257,11 +261,11 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), @@ -288,14 +292,14 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableStateme if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "parent_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ColumnDefinition(dialect, "value", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -319,7 +323,7 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255)), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), @@ -340,23 +344,23 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "priority", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "medium"))]), ColumnDefinition(dialect, "region", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "default"))]), ColumnDefinition(dialect, "category", VarCharType(dialect, 255)), ColumnDefinition(dialect, "product", VarCharType(dialect, 255)), ColumnDefinition(dialect, "department", VarCharType(dialect, 255)), @@ -390,16 +394,16 @@ def create_extended_order_items_table(dialect, table_name: str = "extended_order if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "category", VarCharType(dialect, 255)), ColumnDefinition(dialect, "region", VarCharType(dialect, 50)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py index f021a7ae..40722115 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -8,13 +8,13 @@ from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, + ColumnGeneratedConstraint, CreateTableStatement, - GeneratedColumnExpression, GeneratedColumnType, IndexDefinition, StorageOptionsExpression, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -42,7 +42,14 @@ UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableForeignKeyConstraint, + TableUniqueConstraint, +) from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.backend.expression.objects import Table @@ -53,12 +60,12 @@ SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) DEFAULT_CONSTRAINT = UseConstraint( - ColumnConstraintType.DEFAULT, + ColumnDefaultConstraint, name="df_status_default", - default_value=DEFAULT_VALUE, + value=DEFAULT_VALUE, ) UNIQUE_CONSTRAINT = UseConstraint( - ColumnConstraintType.UNIQUE, + ColumnUniqueConstraint, name="uq_status", ) COLLATION = CollationAttribute(name="utf8mb4_0900_ai_ci") @@ -75,7 +82,7 @@ tablespace="innodb_index", ) SECOND_INDEX = UseIndex("idx_display_label_suffix", type="BTREE") -GENERATED = GeneratedColumnExpression( +GENERATED = ColumnGeneratedConstraint( DIALECT, Column(DIALECT, "order_id"), GeneratedColumnType.STORED, @@ -95,17 +102,15 @@ TABLE_CONSTRAINTS = [ TableConstraint( DIALECT, - TableConstraintType.FOREIGN_KEY, + branch=TableForeignKeyConstraint( + DIALECT, ["tenant_id"], (Table(DIALECT, "tenant"), ["id"]) + ), name="fk_order_tenant", - columns=["tenant_id"], - foreign_key_table=Table(DIALECT, "tenant"), - foreign_key_columns=["id"], ), TableConstraint( DIALECT, - TableConstraintType.UNIQUE, + branch=TableUniqueConstraint(DIALECT, ["tenant_id", "order_id"]), name="uq_order_pair", - columns=["tenant_id", "order_id"], ), ] TABLE_INDEXES = [ @@ -277,11 +282,11 @@ def test_field_markers_preserve_declared_objects_and_order(): constraints = SyncDDLSource.column_constraints("status") assert constraints[0] is DEFAULT_CONSTRAINT.constraint assert constraints[1] is UNIQUE_CONSTRAINT.constraint - assert constraints[0].default_value is DEFAULT_VALUE - assert [item.constraint_type for item in constraints] == [ - ColumnConstraintType.DEFAULT, - ColumnConstraintType.UNIQUE, - ColumnConstraintType.NOT_NULL, + assert constraints[0].branch.value is DEFAULT_VALUE + assert [type(item.branch) for item in constraints] == [ + ColumnDefaultConstraint, + ColumnUniqueConstraint, + ColumnNotNullConstraint, ] attributes = SyncDDLSource.column_attributes("status") @@ -335,18 +340,18 @@ def test_primary_keys_use_physical_column_names_and_declaration_order(): } for field in ("tenant_id", "order_id"): - constraint_types = [ - item.constraint_type for item in SyncDDLSource.column_constraints(field) + constraint_kinds = [ + type(item.branch) for item in SyncDDLSource.column_constraints(field) ] - assert ColumnConstraintType.PRIMARY_KEY not in constraint_types - assert ColumnConstraintType.NOT_NULL in constraint_types + assert ColumnPrimaryKeyConstraint not in constraint_kinds + assert ColumnNotNullConstraint in constraint_kinds single_types = [ - item.constraint_type for item in SyncSingleKey.column_constraints("record_id") + type(item.branch) for item in SyncSingleKey.column_constraints("record_id") ] assert single_types == [ - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} assert SyncSingleKey.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER @@ -359,12 +364,12 @@ def test_table_declarations_preserve_identity_order_and_composite_primary_key(): assert constraints is not TABLE_CONSTRAINTS assert constraints[0] is TABLE_CONSTRAINTS[0] assert constraints[1] is TABLE_CONSTRAINTS[1] - assert [item.constraint_type for item in constraints] == [ - TableConstraintType.FOREIGN_KEY, - TableConstraintType.UNIQUE, - TableConstraintType.PRIMARY_KEY, + assert [type(item.branch) for item in constraints] == [ + TableForeignKeyConstraint, + TableUniqueConstraint, + TablePrimaryKeyConstraint, ] - assert constraints[2].columns == ["tenant_key", "order_key"] + assert constraints[2].branch.columns == ["tenant_key", "order_key"] assert len(TABLE_CONSTRAINTS) == 2 indexes = SyncDDLSource.table_indexes() @@ -445,8 +450,8 @@ def test_sync_and_async_sources_collect_the_same_declarations(): async_constraints = AsyncDDLSource.column_constraints("status") assert sync_constraints[0] is async_constraints[0] assert sync_constraints[1] is async_constraints[1] - assert [item.constraint_type for item in async_constraints] == [ - item.constraint_type for item in sync_constraints + assert [type(item.branch) for item in async_constraints] == [ + type(item.branch) for item in sync_constraints ] sync_indexes = SyncDDLSource.column_indexes("label") @@ -467,12 +472,12 @@ def test_sync_and_async_sources_collect_the_same_declarations(): async_table_constraints = AsyncDDLSource.table_constraints() assert sync_table_constraints[0] is async_table_constraints[0] assert sync_table_constraints[1] is async_table_constraints[1] - assert async_table_constraints[2].columns == ["tenant_key", "order_key"] + assert async_table_constraints[2].branch.columns == ["tenant_key", "order_key"] for model in (SyncSingleKey, AsyncSingleKey): - assert [item.constraint_type for item in model.column_constraints("record_id")] == [ - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, + assert [type(item.branch) for item in model.column_constraints("record_id")] == [ + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] assert model.column_name("record_id") == "record_pk" assert model.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py index e77d7216..bb5bd880 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py @@ -12,7 +12,8 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, + ColumnDefaultConstraint, ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, IntegerType, TextType, TimestampType, VarCharType, @@ -27,7 +28,7 @@ def dialect(): def _pk(dialect): - return [ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + return [ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))] def _build_table(dialect): @@ -36,10 +37,10 @@ def _build_table(dialect): columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ColumnDefinition(dialect, "code", VarCharType(dialect, 16)), ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect)), ], @@ -63,8 +64,8 @@ def test_boolean_default_true(self, dialect): columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ], ) sql, _ = dialect.format_create_table_statement(expr) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 41a34e2a..e6cec107 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -856,11 +856,6 @@ def partition_maintenance(d, cls_name): "timing must be a TriggerTiming member; the generic constructor " "supplies a string.", ), - "statements.ddl_table.ColumnConstraint": ( - "ValueError", - "constraint_type must be a ColumnConstraintType member or its string " - "value; the generic constructor supplies \"x\".", - ), "statements.ddl_table.ReferencesClause": ( "ValueError", "REFERENCES needs at least one referenced column; the generic " diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py index 051a5838..e2ca8508 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py @@ -37,10 +37,11 @@ from rhosocial.activerecord.backend.expression.query_sources import CTEClause from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, + ColumnForeignKeyConstraint, CreateTableAsStatement, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( TruncateStatement, @@ -77,11 +78,8 @@ def _query(d): def _fk(d, **kw): return TableConstraint( d, - TableConstraintType.FOREIGN_KEY, + branch=TableForeignKeyConstraint(d, ["a"], (_table(d, "t2"), ["b"])), name="fk", - columns=["a"], - foreign_key_table=_table(d, "t2"), - foreign_key_columns=["b"], **kw, ) @@ -89,9 +87,10 @@ def _fk(d, **kw): def _check(d, **kw): return TableConstraint( d, - TableConstraintType.CHECK, + branch=TableCheckConstraint( + d, ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + ), name="ck", - check_condition=ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")), **kw, ) @@ -99,9 +98,8 @@ def _check(d, **kw): def _column_fk(d, **kw): return ColumnConstraint( d, - ColumnConstraintType.FOREIGN_KEY, + branch=ColumnForeignKeyConstraint(d, (_table(d, "t2"), ["b"])), name="fk", - foreign_key_reference=(_table(d, "t2"), ["b"]), **kw, ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py index b96d1de5..e3b079e6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py @@ -26,7 +26,10 @@ from rhosocial.activerecord.backend.impl.mysql.mixins.constraint import ( MySQLConstraintMixin, ) -from rhosocial.activerecord.backend.expression.statements import TableConstraintType +from rhosocial.activerecord.backend.expression.statements import ( + TableCheckConstraint, + TableForeignKeyConstraint, +) #: The server the verdicts below were measured on. Every value here is a #: statement about *this* version, not about MySQL in general. @@ -133,7 +136,7 @@ def test_alter_constraint_addresses_check_only(self): dialect, constraint_name="ck", not_enforced=True, - constraint_type=TableConstraintType.CHECK, + constraint_kind=TableCheckConstraint, ).to_sql() assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" @@ -142,17 +145,29 @@ def test_alter_constraint_addresses_check_only(self): dialect, constraint_name="fk", not_enforced=True, - constraint_type=TableConstraintType.FOREIGN_KEY, + constraint_kind=TableForeignKeyConstraint, ).to_sql() - def test_alter_constraint_accepts_a_string_kind_at_construction(self): + def test_alter_constraint_normalises_a_string_kind_at_construction(self): """A string kind is the constructor's to normalise, not the probe's. - ``AlterConstraint`` declares ``str`` in its ``constraint_type`` - union and normalises at construction, so a caller's ``'CHECK'`` - reaches the formatter as the enum. The dialect no longer sniffs - signatures to guess which layer should answer: the probe is - ``(self)`` and the kind narrowing is the formatter's. + ``AlterConstraint`` declares ``str`` in its ``constraint_kind`` + union and normalises at construction, so a spelling that matches a + branch reaches the formatter as the branch *class* -- and the + formatter, not the probe, is what narrows it to CHECK on this + server. The dialect no longer sniffs signatures to guess which layer + should answer: the probe is ``(self)`` and the kind narrowing is the + formatter's. + + The spelling the constructor matches is the branch's own name minus + the ``Table`` prefix, upper-cased -- ``CHECKCONSTRAINT`` -- not the + SQL keyword ``CHECK``. That is what makes the read side round-trip + (core's ``constraint_kind`` view, and PostgreSQL's, both derive the + string by that same rule), so the test asserts the spelling that + actually round-trips instead of the one the constructor's docstring + advertises. ``"CHECK"`` is refused by the constructor with a + ``ValueError``; the expression layer owns that mapping and this + backend has no say in it. """ from rhosocial.activerecord.backend.expression import AlterConstraint @@ -160,10 +175,18 @@ def test_alter_constraint_accepts_a_string_kind_at_construction(self): _dialect(), constraint_name="ck", not_enforced=True, - constraint_type="CHECK", + constraint_kind="CheckConstraint", ).to_sql() assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" + with pytest.raises(ValueError): + AlterConstraint( + _dialect(), + constraint_name="ck", + not_enforced=True, + constraint_kind="CHECK", + ) + class TestConstraintSwitchesAreDeclaredLocally: """The verdict must be this backend's, not an inherited default. diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py index c950f8c4..c68b9b8c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py @@ -13,15 +13,18 @@ import pytest from rhosocial.activerecord.backend.expression import ( + ColumnConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, CreateTableStatement, DropTableStatement, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, + ForeignKeyConstraint, IndexDefinition, + TableCheckConstraint, TableConstraint, - TableConstraintType, - ForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.statements import ReferentialAction @@ -44,7 +47,7 @@ class TestMySQLStorageOptions: def test_engine_option(self): """Test ENGINE storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"ENGINE": "InnoDB"} ) @@ -54,7 +57,7 @@ def test_engine_option(self): def test_charset_option(self): """Test DEFAULT CHARSET storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"DEFAULT CHARSET": "utf8mb4"} ) @@ -64,7 +67,7 @@ def test_charset_option(self): def test_collate_option(self): """Test COLLATE storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"COLLATE": "utf8mb4_unicode_ci"} ) @@ -74,7 +77,7 @@ def test_collate_option(self): def test_multiple_storage_options(self): """Test multiple storage options combined.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), @@ -89,7 +92,7 @@ def test_multiple_storage_options(self): def test_storage_options_with_if_not_exists(self): """Test storage options with IF NOT EXISTS.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), @@ -108,7 +111,7 @@ class TestMySQLTableComment: def test_table_comment(self): """Test table-level COMMENT.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "用户信息表")), @@ -119,7 +122,7 @@ def test_table_comment(self): def test_table_comment_with_storage_options(self): """Test table COMMENT with storage options.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), @@ -135,7 +138,7 @@ def test_table_comment_with_storage_options(self): def test_table_comment_special_characters(self): """Test table COMMENT with special characters.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test"), columns=columns, table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "测试's表")), @@ -147,7 +150,7 @@ def test_table_comment_via_table_options(self): """Test table COMMENT via typed CreateTableOptions (new API).""" from rhosocial.activerecord.backend.expression.statements.ddl_table import CreateTableOptions dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] opts = CreateTableOptions(dialect, comment=TableCommentClause(dialect, "typed comment")) expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_options=opts, @@ -158,7 +161,7 @@ def test_table_comment_via_table_options(self): def test_engine_charset_via_table_options(self): """Test ENGINE/CHARSET/COLLATE via typed MySQLCreateTableOptions.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] opts = MySQLCreateTableOptions( dialect, engine="InnoDB", charset="utf8mb4", collate="utf8mb4_unicode_ci" ) @@ -179,7 +182,7 @@ def test_column_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键ID") + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], comment=ColumnCommentClause(dialect, "主键ID") ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "用户名")), ] @@ -193,7 +196,7 @@ def test_column_comment_with_table_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键") + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], comment=ColumnCommentClause(dialect, "主键") ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "名称")), ] @@ -218,8 +221,8 @@ def test_auto_increment_primary_key(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ) ] @@ -236,8 +239,8 @@ def test_auto_increment_with_comment(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], comment=ColumnCommentClause(dialect, "自增主键"), ) @@ -255,8 +258,8 @@ def test_auto_increment_not_null(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ) ] @@ -273,7 +276,7 @@ def test_simple_index(self): """Test simple INDEX definition.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_name", ["name"])] @@ -286,7 +289,7 @@ def test_unique_index(self): """Test UNIQUE INDEX definition.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_email", ["email"], unique=True)] @@ -299,7 +302,7 @@ def test_composite_index(self): """Test composite index on multiple columns.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect)), ] @@ -312,7 +315,7 @@ def test_index_with_type(self): """Test INDEX with USING clause.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_name", ["name"], type="BTREE")] @@ -324,7 +327,7 @@ def test_hash_index(self): """Test HASH index type.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "key", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_key", ["key"], type="HASH")] @@ -336,7 +339,7 @@ def test_multiple_indexes(self): """Test multiple inline indexes.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ColumnDefinition(dialect, "username", VarCharType(dialect, 50)), ] @@ -404,9 +407,9 @@ def test_enum_in_column_definition(self): dialect = MySQLDialect() status_enum = MySQLEnumType(dialect, ["draft", "published", "archived"]) columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, - "status", status_enum, constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)] + "status", status_enum, constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] ), ] expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "articles"), columns=columns) @@ -465,7 +468,7 @@ def test_primary_key_constraint(self): """Test PRIMARY KEY table constraint.""" dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect)), ColumnDefinition(dialect, "name", VarCharType(dialect, 100))] - table_constraints = [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"])] + table_constraints = [TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"]))] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) @@ -476,10 +479,10 @@ def test_unique_constraint(self): """Test UNIQUE table constraint.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ] - table_constraints = [TableConstraint(dialect, TableConstraintType.UNIQUE, columns=["email"])] + table_constraints = [TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["email"]))] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) @@ -490,7 +493,7 @@ def test_composite_primary_key(self): """Test composite PRIMARY KEY.""" dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "role_id", IntegerType(dialect))] - table_constraints = [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["user_id", "role_id"])] + table_constraints = [TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["user_id", "role_id"]))] expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "user_roles"), columns=columns, table_constraints=table_constraints ) @@ -543,11 +546,9 @@ def test_check_constraint(self): ColumnDefinition(dialect, "age", IntegerType(dialect)), ] check = TableConstraint( - dialect, TableConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", Column(dialect, "age"), Literal(dialect, 0) - ), - ) + dialect, + branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, ">=", Column(dialect, "age"), Literal(dialect, 0) )) +) expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=[check], ) @@ -588,27 +589,27 @@ def test_complete_table_creation(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], comment=ColumnCommentClause(dialect, "Primary key"), ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "User name"), ), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "Email address"), ), ColumnDefinition(dialect, "status", status_enum, - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "User status"), ), ColumnDefinition(dialect, "created_at", DateTimeType(dialect), comment=ColumnCommentClause(dialect, "Creation timestamp")), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index 6c77bf68..21535aaf 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -15,8 +15,9 @@ from rhosocial.activerecord.backend.expression.statements import ( ConstraintValidation, ForeignKeyConstraint, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( AddTableConstraint, @@ -220,14 +221,11 @@ def _dialect(self): def _check(self, dialect, **kw): return TableConstraint( - dialect, - TableConstraintType.CHECK, - name="ck", - check_condition=ComparisonPredicate( - dialect, "=", Column(dialect, "a"), Column(dialect, "b") - ), - **kw, - ) + dialect, + branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, "=", Column(dialect, "a"), Column(dialect, "b") )), + name="ck", + **kw +) def _fk(self, dialect, **kw): return ForeignKeyConstraint( @@ -298,14 +296,14 @@ def test_alter_constraint_enforced_renders_for_check_only(self): """The rendering follows the probe: CHECK renders, FOREIGN KEY refuses.""" dialect = self._dialect() sql, params = AlterConstraint( - dialect, "ck", not_enforced=True, constraint_type=TableConstraintType.CHECK + dialect, "ck", not_enforced=True, constraint_kind=TableCheckConstraint ).to_sql() assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" assert params == () with pytest.raises(UnsupportedFeatureError, match="ENFORCED"): AlterConstraint( - dialect, "fk", enforced=True, constraint_type=TableConstraintType.FOREIGN_KEY + dialect, "fk", enforced=True, constraint_kind=TableForeignKeyConstraint ).to_sql() def test_alter_constraint_defers_to_the_deferrable_probe(self): @@ -337,7 +335,10 @@ def test_enforcement_is_version_gated_and_check_only(self): dialect = self._dialect() sql, params = self._check(dialect, not_enforced=True).to_sql() assert sql.endswith("NOT ENFORCED") - assert params == [] + # The params are a tuple: core's branch-level formatters hand back + # tuples, and the production-level renderer normalises to one. The old + # fat-tag collector returned a list here. + assert params == () with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY NOT ENFORCED"): self._fk(dialect, not_enforced=True).to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index fc0e480d..2e5672bd 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -11,9 +11,9 @@ from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, ) from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.types import VarCharType @@ -33,10 +33,9 @@ def dialect(): def test_mysql_format_column_definition_default_string_escaping(dialect): """Test DEFAULT constraint string is escaped in MySQL.""" constraint = ColumnConstraint( - dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) + dialect, + branch=ColumnDefaultConstraint(dialect, "test's value") +) col_def = ColumnDefinition(dialect, "test_col", VarCharType(dialect, 255), constraints=[constraint]) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py index a4ddef93..356aa616 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py @@ -36,8 +36,8 @@ from rhosocial.activerecord.backend.expression.statements import ( AutoIncrementClause, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, CreateTableStatement, DropTableStatement, IdentityClause, @@ -56,7 +56,8 @@ def _auto_increment_column(dialect, name="id"): IntegerType(dialect), constraints=[ ColumnConstraint( - dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True) ) ], ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 6ca9d5a9..8461393a 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -5,13 +5,17 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( @@ -533,8 +537,7 @@ def test_column_default_expression(self): constraints=[ ColumnConstraint( dialect, - ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, "NOW()"), + branch=ColumnDefaultConstraint(dialect, Literal(dialect, "NOW()")) ) ], ), @@ -552,7 +555,7 @@ def test_column_default_string(self): "status", VarCharType(dialect, 20), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active") + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")) ], ), ] @@ -568,7 +571,7 @@ def test_column_default_numeric(self): "count", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0)) ], ), ] @@ -583,7 +586,7 @@ def test_column_nullable(self): ColumnDefinition(dialect, "optional", VarCharType(dialect, 100), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))], ), ] from rhosocial.activerecord.backend.expression.statements import CreateTableStatement @@ -597,7 +600,7 @@ def test_column_unique(self): ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)], + constraints=[ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))], ), ] from rhosocial.activerecord.backend.expression.statements import CreateTableStatement @@ -609,10 +612,10 @@ def test_named_table_constraint(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] table_constraints = [ - TableConstraint(dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - name="pk_users", - columns=["id"], + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["id"]), + name="pk_users" ) ] from rhosocial.activerecord.backend.expression.statements import CreateTableStatement @@ -628,12 +631,10 @@ def test_foreign_key_table_constraint(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "user_id", IntegerType(dialect))] table_constraints = [ - TableConstraint(dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - name="fk_orders_user", - columns=["user_id"], - foreign_key_table=Table(dialect, "users"), - foreign_key_columns=["id"], + TableConstraint( + dialect, + branch=TableForeignKeyConstraint(dialect, ["user_id"], (Table(dialect, "users"), ["id"])), + name="fk_orders_user" ) ] from rhosocial.activerecord.backend.expression.statements import CreateTableStatement @@ -823,7 +824,7 @@ def test_modify_column_with_constraints(self): "email", VarCharType(dialect, 255), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py index ae8b2a7e..b95f4f39 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py @@ -10,10 +10,10 @@ from rhosocial.activerecord.backend.expression import ( Column, - ComparisonPredicate, - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ComparisonPredicate, CreateTableStatement, DropTableStatement, IndexDefinition, @@ -49,9 +49,9 @@ def _create_partition_explain_table_expression(dialect): dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], indexes=[ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index 78080df5..bf2ceed6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -9,8 +9,8 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, ComparisonPredicate, CreateTableStatement, DropTableStatement, @@ -22,7 +22,7 @@ OrderByClause, QueryStatement, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ValuesSource, Wildcard, ) @@ -74,8 +74,8 @@ def _create_partitioned_table_expression(dialect): dialect=dialect, table=Table(dialect, PARTITION_TABLE), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], indexes=[ @@ -294,8 +294,8 @@ def _assert_show_create_table_includes_partition_definition(create_sql): def _base_columns_without_pk(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "shard_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "shard_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] @@ -381,10 +381,10 @@ def _drop_named_table_expression(dialect, table_name: str): def _production_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect, 6), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, 6), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ] @@ -398,10 +398,9 @@ def _production_indexes(dialect): def _production_table_constraints(dialect): return [ TableConstraint( - dialect, - TableConstraintType.PRIMARY_KEY, - columns=["id", "created_at"], - ), + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["id", "created_at"]) +), ] @@ -1226,9 +1225,9 @@ async def test_partition_ddl_triggers_implicit_commit( def _subpartition_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "region", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "region", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index 17f8de9f..e7f32f9a 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -7,8 +7,8 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, ComparisonPredicate, CreateTableStatement, DropTableStatement, @@ -19,7 +19,7 @@ OrderByClause, QueryStatement, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ValuesSource, Wildcard, ) @@ -69,10 +69,10 @@ def _drop_named_table_expression(dialect, table_name: str): def _base_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "category", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "category", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ] @@ -568,7 +568,7 @@ def test_null_in_range_partition_goes_to_first_partition(self, mysql_backend): table = "ar_mysql_null_range" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), # nullable ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] @@ -613,7 +613,7 @@ def test_null_in_list_partition_with_explicit_values_in_works(self, mysql_backen table = "ar_mysql_null_list" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] @@ -664,7 +664,7 @@ def test_null_in_hash_partition_accepts_null_key(self, mysql_backend): table = "ar_mysql_null_hash" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] @@ -706,7 +706,7 @@ def test_null_in_key_partition_accepts_null_key(self, mysql_backend): table = "ar_mysql_null_key" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] @@ -760,12 +760,12 @@ def test_key_with_empty_parenthesis_uses_all_primary_key_columns(self, mysql_bac dialect=dialect, table=Table(dialect, table), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], table_constraints=[ - TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id", "created_at"]), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id", "created_at"])), ], partition=MySQLPartitionByKey( mysql_backend.dialect, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py index 99a70e8c..bf187d7f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py @@ -28,9 +28,11 @@ now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType from rhosocial.activerecord.backend.options import ExecutionOptions @@ -125,8 +127,8 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -134,7 +136,7 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): "ts", TimestampType(dialect=dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ], ), ], @@ -169,8 +171,8 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -179,9 +181,9 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): TimestampType(dialect=dialect), constraints=[ ColumnConstraint( - dialect, - ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, "CURRENT_TIMESTAMP") - ), + dialect, + branch=ColumnDefaultConstraint(dialect, FunctionCall(dialect, "CURRENT_TIMESTAMP")) +), ], ), ], @@ -215,8 +217,8 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -224,7 +226,7 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): "ts", TimestampType(dialect, 6), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect, 6)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect, 6))), ], ), ], @@ -282,8 +284,8 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -291,7 +293,7 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac "ts", TimestampType(dialect=dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ], ), ], From 0ae569c519d8a4a7abe3ea84df359575266e9c58 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 14 Oct 2026 12:15:40 +0800 Subject: [PATCH 07/65] refactor(mysql): split the wildcard by grammar position Core's wildcard split (`61a1a458`) replaced the single `Wildcard` node with `AllColumnsWildcard` for select-list / RETURNING positions and `RowCountWildcard` for the COUNT argument position, and the shared formatters were renamed to match (`format_all_columns_wildcard` / `format_row_count_wildcard`). There was never an intermediate `Wildcard` to pass through, so every call site moves straight to its grammar position's node. **Call sites** (6 files; src 3, tests 3), all expression-level -- the `COUNT(*)` occurrences that remain are raw SQL strings handed to `backend.execute`, not expression nodes, and need no migration: select=[Wildcard(dialect)] -> AllColumnsWildcard(dialect) FunctionCall(dialect, "COUNT", Wildcard(dialect)) -> FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)) No `format_wildcard` override existed in this backend (`WildcardSupport` is inherited from the core protocol), so none needed renaming. No backend-specific wildcard class is warranted here: `COUNT(t.*)` is a postgres / snowflake affair and MySQL's `GRANT *.*` is a plain string default on the grant expression (`on_object`), not a wildcard node -- both stay as they are. **Tests** (live scenarios mysql_97/94/92/84/80 at 192.168.1.3; the localhost:14681/14680 async scenarios mysql_56/57 refuse connections in this environment, a pre-existing gap unrelated to this change): pytest .../test_mysql_partition_explain.py -k "not mysql_56 and not mysql_57" exit 0 pytest .../test_mysql_partition_operations.py -k "not mysql_56 and not mysql_57" exit 0 pytest .../test_mysql_partition_strategy_operations.py -k "not mysql_56 and not mysql_57" exit 0 python -m compileall over the six changed files: exit 0. ruff over the six changed files: 63 findings, byte-for-byte the same rules and counts as the same files at 3d643d6 (pre-existing E501/B017/F821/F811/E402/F401; the S3 migration in 3d643d6 lengthened the constraint construction lines past 120) -- this change adds none. --- .../activerecord/backend/impl/mysql/examples/ddl/view.py | 4 ++-- .../backend/impl/mysql/examples/insert/batch.py | 4 ++-- .../backend/impl/mysql/examples/insert/single.py | 4 ++-- .../feature/backend/test_mysql_partition_explain.py | 6 +++--- .../feature/backend/test_mysql_partition_operations.py | 9 +++++---- .../backend/test_mysql_partition_strategy_operations.py | 4 ++-- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py index 9b22240c..b0fe4e12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py @@ -84,7 +84,7 @@ CreateViewStatement, DropViewStatement, ) -from rhosocial.activerecord.backend.expression.core import Wildcard # noqa: E402 +from rhosocial.activerecord.backend.expression.core import AllColumnsWildcard # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table, View query = QueryStatement( @@ -107,7 +107,7 @@ verify_query = QueryStatement( dialect=dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "user_names"), ) options = ExecutionOptions(stmt_type=StatementType.DQL) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py index e1afefa5..d40d974c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py @@ -78,7 +78,7 @@ ValuesSource, QueryStatement, ) -from rhosocial.activerecord.backend.expression.core import Literal, Wildcard, Column # noqa: E402 +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -109,7 +109,7 @@ verify_query = QueryStatement( dialect=dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "logs"), order_by=OrderByClause(dialect, [Column(dialect, "id")]), ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py index 43a4af55..11c697a9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py @@ -20,7 +20,7 @@ InsertStatement, ValuesSource, ) -from rhosocial.activerecord.backend.expression.core import Literal, Wildcard, Column +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, @@ -110,7 +110,7 @@ # Verify the inserted row verify_query = QueryStatement( dialect=dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "users"), where=ComparisonPredicate( dialect, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py index b95f4f39..a81dbad7 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py @@ -9,6 +9,7 @@ import pytest from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, Column, ColumnConstraint, ColumnDefinition, @@ -21,7 +22,6 @@ Literal, QueryStatement, ValuesSource, - Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, VarCharType from rhosocial.activerecord.backend.impl.mysql.explain import MySQLExplainResult, MySQLExplainRow @@ -103,7 +103,7 @@ def _seed_partition_explain_rows_expression(dialect): def _partition_range_query_expression(dialect, start, end): return QueryStatement( dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), where=ComparisonPredicate( dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start) @@ -117,7 +117,7 @@ def _partition_range_query_expression(dialect, start, end): def _full_scan_query_expression(dialect): return QueryStatement( dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index bf2ceed6..5cf90181 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -7,6 +7,7 @@ import pytest from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, Column, ColumnConstraint, ColumnDefinition, @@ -21,10 +22,10 @@ LogicalPredicate, OrderByClause, QueryStatement, + RowCountWildcard, TableConstraint, TablePrimaryKeyConstraint, ValuesSource, - Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -520,7 +521,7 @@ def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Op def _select_production_count_expression(dialect): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_PARTITION_TABLE), ) @@ -528,7 +529,7 @@ def _select_production_count_expression(dialect): def _select_archive_count_expression(dialect): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_ARCHIVE_TABLE), ) @@ -598,7 +599,7 @@ def _reorganize_maxvalue_partition_expression(dialect): def _production_range_query_for_table_expression(dialect, table_name: str, start, end, *, tenant_id: int): return QueryStatement( dialect, - select=[Wildcard(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, table_name), where=LogicalPredicate( dialect, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index e7f32f9a..6a77b8a0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -18,10 +18,10 @@ LogicalPredicate, OrderByClause, QueryStatement, + RowCountWildcard, TableConstraint, TablePrimaryKeyConstraint, ValuesSource, - Wildcard, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.expression import ( @@ -246,7 +246,7 @@ def _select_payloads_expression(dialect, table_name: str): def _select_count_expression(dialect, table_name: str): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", Wildcard(dialect)).as_("count")], + select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, table_name), ) From edf1279036b596227c5ff952b39051d0bef6691a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 14 Oct 2026 12:17:12 +0800 Subject: [PATCH 08/65] ci: point testsuite at the matching branch Every install line in .github/workflows/test.yml (three jobs: sync, async, scenarios) pinned the testsuite to its release branch while the core line already pointed at fix/column-constraint-gates. The two have to move together: the release branch's testsuite still imports the deleted `ColumnConstraintType` (its own S3 adaptation is still in progress), so against this backend's two-level nodes it would fail at import. rhosocial-activerecord-testsuite @ .../python-activerecord-testsuite.git @release/v1.0.0.dev17 -> @fix/column-constraint-gates (3 places) Checked and already correct, so untouched: the core install line is git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates at all three places. No other workflow installs either dependency by ref (publish.yml and changelog-check.yml have no such line; their `release/v1` hits are GitHub Action refs). No test evidence: a workflow-file ref change has nothing to run against. The import smoke over the backend package (115 modules, 0 failures; example scripts skipped -- they connect on import) is what stands behind the branch pairing it points at. --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f48a46c..7d8bb4a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | @@ -253,7 +253,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | @@ -360,7 +360,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | From a611b650455cb97590b3fce767e2a47525c78e49 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 14 Oct 2026 14:23:40 +0800 Subject: [PATCH 09/65] test(mysql): carry the wildcard-split follow-ups into the roundtrip matrix The core's wildcard commit (61a1a458) noted the roundtrip contract needs dedicated constructors for LIKE/ILIKE, whose operand slots the generic guess fills with plain strings -- the core registered them in its dummy2 matrix, and this file is where the same registration belongs for MySQL: two factory rows with reasons, matching what the matrix's table-exactness tests demand. xml.PassingClause joins the declared gaps: MySQL has no XML family, so the S1-c clause's formatter is absent and the dispatch refuses it like every other xml.* row. The remaining failures were environmental in appearance only: CI runs with -x, so the DomainCheckConstraint setup error (fixed testsuite-side in 11b0f6c) stopped the session before these were reached. --- .../backend/test_expression_roundtrip_all.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index e6cec107..8c3b01bb 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -195,6 +195,16 @@ def _table(d, name="t"): "types.MySQLEnumType": "values must be a non-empty list of labels.", "types.MySQLSetType": "values must be a non-empty list of labels.", "types.MySQLVectorType": "dim must be a positive integer.", + # Core predicates whose operand slots the generic guess fills with plain + # strings (the core's wildcard-split commit calls this out: value nodes in + # operand positions, supplied as strings). The factories hand them real + # value nodes, exactly as the core's own dummy2 matrix registers them. + "predicates.LikePredicate": "op is annotated str and the two operands " + "are string forward references, so the generic constructor supplies " + "plain strings where value nodes belong and the instance cannot render.", + "predicates.ILikePredicate": "column is annotated Any, so the generic " + "constructor supplies a plain string where a value node belongs and the " + "instance cannot render.", } #: Every class with a dedicated constructor, in one mapping, so @@ -255,7 +265,16 @@ def partition_maintenance(d, cls_name): return getattr(mod, cls_name)(d, table=Table(d, "t"), partitions=["p0"]) + from rhosocial.activerecord.backend.expression.predicates import ( + ILikePredicate, + LikePredicate, + ) + factories = { + "predicates.LikePredicate": + lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), + "predicates.ILikePredicate": + lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), "match_against.MySQLMatchAgainstExpression": lambda d: MySQLMatchAgainstExpression(d, columns=["title"], search_string="x"), "json.MySQLJSONObjectExpression": @@ -520,6 +539,13 @@ def partition_maintenance(d, cls_name): "MySQL has no sequence object; no format_alter_sequence_statement.", ), # --- XML functions are not in MySQL. ----------------------------------- + # The S1-c PASSING clause rides the XML querying expressions; MySQL has + # none of that family, so the clause's own formatter is absent too and the + # dispatch refuses it -- the same declared gap as the rows below it. + "xml.PassingClause": ( + "format_passing_clause", + "XML functions are not in MySQL; no format_passing_clause.", + ), "xml.XMLAggExpression": ( "format_xmlagg_expression", "XML functions are not in MySQL; no format_xmlagg_expression.", From 034eae86d9d7b2fbcef93fcdef30eb3c5fbb866e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 04:31:58 +0800 Subject: [PATCH 10/65] docs(mysql): carry the renames into the README and docs --- README.md | 4 ++-- docs/en_US/ddl/README.md | 22 +++++++++---------- docs/en_US/mysql_specific_features/dialect.md | 22 +++++++++---------- docs/zh_CN/ddl/README.md | 22 +++++++++---------- docs/zh_CN/mysql_specific_features/dialect.md | 22 +++++++++---------- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 79ecab3d..5069c8bf 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnDefinition, @@ -77,7 +77,7 @@ User.configure( # DDL is expressed, not stringified. Note that every node takes the dialect as # its first argument, and column types are DataType instances, not strings. dialect = User.__backend__.dialect -create_users = CreateTableExpression( +create_users = CreateTableStatement( dialect, "users", columns=[ diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index 114748d4..7009cb30 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -6,14 +6,14 @@ The MySQL backend supports the same type-safe DDL expressions as the core librar | Operation | MySQL Support | Notes | |----------|--------------|-------| -| `CreateTableExpression` | ✅ Full | PRIMARY KEY, NOT NULL, UNIQUE, etc. | -| `DropTableExpression` | ✅ Full | IF EXISTS support | -| `AlterTableExpression` | ✅ Full | ADD/DROP COLUMN | -| `CreateIndexExpression` | ✅ Full | Index types (BTREE, HASH) | -| `DropIndexExpression` | ✅ Full | | -| `CreateViewExpression` | ✅ Full | MySQL ALGORITHM options | -| `DropViewExpression` | ✅ Full | | -| `CreateTableExpression` | ✅ Full | Partition support (RANGE, LIST, HASH, KEY) | +| `CreateTableStatement` | ✅ Full | PRIMARY KEY, NOT NULL, UNIQUE, etc. | +| `DropTableStatement` | ✅ Full | IF EXISTS support | +| `AlterTableStatement` | ✅ Full | ADD/DROP COLUMN | +| `CreateIndexStatement` | ✅ Full | Index types (BTREE, HASH) | +| `DropIndexStatement` | ✅ Full | | +| `CreateViewStatement` | ✅ Full | MySQL ALGORITHM options | +| `DropViewStatement` | ✅ Full | | +| `CreateTableStatement` | ✅ Full | Partition support (RANGE, LIST, HASH, KEY) | | `PartitionByRange` | ✅ Full | RANGE and RANGE COLUMNS partitioning | | `PartitionByList` | ✅ Full | LIST and LIST COLUMNS partitioning | | `PartitionByHash` | ✅ Full | HASH and LINEAR HASH partitioning | @@ -39,7 +39,7 @@ partition = MySQLPartitionByRange( ], ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="orders", columns=[...], @@ -54,7 +54,7 @@ MySQL views support ALGORITHM to control execution: ```python from rhosocial.activerecord.backend.expression import ViewOptions, ViewAlgorithm -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, view_name="optimized_view", query=query, @@ -67,7 +67,7 @@ create_view = CreateViewExpression( MySQL supports specifying storage engine: ```python -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="users", columns=columns, diff --git a/docs/en_US/mysql_specific_features/dialect.md b/docs/en_US/mysql_specific_features/dialect.md index 21db76cf..bdb6b531 100644 --- a/docs/en_US/mysql_specific_features/dialect.md +++ b/docs/en_US/mysql_specific_features/dialect.md @@ -11,11 +11,11 @@ MySQL has some specific SQL syntax and functions. This section covers commonly u MySQL supports copying table structure using the `LIKE` clause. This is useful for creating table backups or test tables with identical structure. ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect # Basic usage - copy table structure -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -24,7 +24,7 @@ create_expr = CreateTableExpression( # Generates: CREATE TABLE `users_copy` LIKE `users` # With schema-qualified source table -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -33,7 +33,7 @@ create_expr = CreateTableExpression( # Generates: CREATE TABLE `users_copy` LIKE `production`.`users` # With TEMPORARY and IF NOT EXISTS -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="temp_users", columns=[], @@ -176,17 +176,17 @@ class GroupConcat(FunctionExpression): ## Querying Runtime Functions and Constants -MySQL supports querying runtime functions without a data source, such as `SELECT CURRENT_TIMESTAMP`, `SELECT NOW()`, `SELECT VERSION()`. Use `QueryExpression` without specifying `from_` to achieve this. +MySQL supports querying runtime functions without a data source, such as `SELECT CURRENT_TIMESTAMP`, `SELECT NOW()`, `SELECT VERSION()`. Use `QueryStatement` without specifying `from_` to achieve this. ```python -from rhosocial.activerecord.backend.expression import QueryExpression +from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, current_date, current_time, ) # SELECT CURRENT_TIMESTAMP (niladic — no parentheses) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[current_timestamp(dialect)], ) @@ -194,14 +194,14 @@ sql, params = query.to_sql() # Generates: SELECT CURRENT_TIMESTAMP # SELECT NOW() -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[now(dialect)], ) # Generates: SELECT NOW() # SELECT CURRENT_DATE, CURRENT_TIME (niladic — no parentheses) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ current_date(dialect), @@ -211,7 +211,7 @@ query = QueryExpression( # Generates: SELECT CURRENT_DATE, CURRENT_TIME # Multi-function query with aliases -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ now(dialect).as_('current_time'), @@ -222,7 +222,7 @@ query = QueryExpression( # Generates: SELECT NOW() AS `current_time`, DATABASE() AS `db_name`, VERSION() AS `db_version` # Function calls with arguments -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ FunctionCall(dialect, 'DATE_FORMAT', diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index 21682aa5..c24b775f 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -6,14 +6,14 @@ MySQL 后端支持与核心库相同的类型安全 DDL 表达式。 | 操作 | MySQL 支持 | 备注 | |----------|--------------|-------| -| `CreateTableExpression` | ✅ 完整 | PRIMARY KEY, NOT NULL, UNIQUE 等 | -| `DropTableExpression` | ✅ 完整 | IF EXISTS 支持 | -| `AlterTableExpression` | ✅ 完整 | ADD/DROP COLUMN | -| `CreateIndexExpression` | ✅ 完整 | 索引类型 (BTREE, HASH) | -| `DropIndexExpression` | ✅ 完整 | | -| `CreateViewExpression` | ✅ 完整 | MySQL ALGORITHM 选项 | -| `DropViewExpression` | ✅ 完整 | | -| `CreateTableExpression` | ✅ 完整 | 分区支持(RANGE, LIST, HASH, KEY) | +| `CreateTableStatement` | ✅ 完整 | PRIMARY KEY, NOT NULL, UNIQUE 等 | +| `DropTableStatement` | ✅ 完整 | IF EXISTS 支持 | +| `AlterTableStatement` | ✅ 完整 | ADD/DROP COLUMN | +| `CreateIndexStatement` | ✅ 完整 | 索引类型 (BTREE, HASH) | +| `DropIndexStatement` | ✅ 完整 | | +| `CreateViewStatement` | ✅ 完整 | MySQL ALGORITHM 选项 | +| `DropViewStatement` | ✅ 完整 | | +| `CreateTableStatement` | ✅ 完整 | 分区支持(RANGE, LIST, HASH, KEY) | | `PartitionByRange` | ✅ 完整 | RANGE 和 RANGE COLUMNS 分区 | | `PartitionByList` | ✅ 完整 | LIST 和 LIST COLUMNS 分区 | | `PartitionByHash` | ✅ 完整 | HASH 和 LINEAR HASH 分区 | @@ -39,7 +39,7 @@ partition = MySQLPartitionByRange( ] ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="orders", columns=[...], @@ -54,7 +54,7 @@ MySQL 视图支持 ALGORITHM 来控制执行方式: ```python from rhosocial.activerecord.backend.expression import ViewOptions, ViewAlgorithm -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, view_name="optimized_view", query=query, @@ -67,7 +67,7 @@ create_view = CreateViewExpression( MySQL 支持指定存储引擎: ```python -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="users", columns=columns, diff --git a/docs/zh_CN/mysql_specific_features/dialect.md b/docs/zh_CN/mysql_specific_features/dialect.md index 860c648a..839a3e62 100644 --- a/docs/zh_CN/mysql_specific_features/dialect.md +++ b/docs/zh_CN/mysql_specific_features/dialect.md @@ -11,11 +11,11 @@ MySQL 有一些特定的 SQL 语法和函数,本节介绍常用的 MySQL 特 MySQL 支持使用 `LIKE` 子句复制表结构。这对于创建具有相同结构的表备份或测试表非常有用。 ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect # 基本用法 - 复制表结构 -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -24,7 +24,7 @@ create_expr = CreateTableExpression( # 生成: CREATE TABLE `users_copy` LIKE `users` # 带模式限定的源表 -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -33,7 +33,7 @@ create_expr = CreateTableExpression( # 生成: CREATE TABLE `users_copy` LIKE `production`.`users` # 带临时表和 IF NOT EXISTS -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="temp_users", columns=[], @@ -291,17 +291,17 @@ if dialect.supports_json_duality_view(): ## 查询运行时函数与常量 -MySQL 支持不涉及数据源的纯函数查询,如 `SELECT CURRENT_TIMESTAMP`、`SELECT NOW()`、`SELECT VERSION()` 等。使用 `QueryExpression` 不指定 `from_` 子句即可实现。 +MySQL 支持不涉及数据源的纯函数查询,如 `SELECT CURRENT_TIMESTAMP`、`SELECT NOW()`、`SELECT VERSION()` 等。使用 `QueryStatement` 不指定 `from_` 子句即可实现。 ```python -from rhosocial.activerecord.backend.expression import QueryExpression +from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, current_date, current_time, ) # SELECT CURRENT_TIMESTAMP(零元函数 — 无括号) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[current_timestamp(dialect)], ) @@ -309,14 +309,14 @@ sql, params = query.to_sql() # 生成: SELECT CURRENT_TIMESTAMP # SELECT NOW() -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[now(dialect)], ) # 生成: SELECT NOW() # SELECT CURRENT_DATE, CURRENT_TIME(零元函数 — 无括号) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ current_date(dialect), @@ -326,7 +326,7 @@ query = QueryExpression( # 生成: SELECT CURRENT_DATE, CURRENT_TIME # 带别名的多函数查询 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ now(dialect).as_('current_time'), @@ -337,7 +337,7 @@ query = QueryExpression( # 生成: SELECT NOW() AS `current_time`, DATABASE() AS `db_name`, VERSION() AS `db_version` # 带参数的函数调用 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ FunctionCall(dialect, 'DATE_FORMAT', From ec04b89af1c7749e958fd1aa36ad9a52f91f968f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 16 Oct 2026 23:26:21 +0800 Subject: [PATCH 11/65] refactor(expression): follow the W3c class renames Core dropped the Function/Expression suffix on seven nodes and renamed two dialect hooks. Carry the same names here: RawSQLExpression is RawSQL, CastFunction is CastExpression, CaseFunction is CaseExpression, PivotExpression is Pivot and UnpivotExpression is Unpivot -- the last two with their formatters following to format_pivot and format_unpivot. The roundtrip matrix names its rows by fqn suffix and asserts them exactly in both directions, so the three renamed rows move with the classes. Two rows are added at the same time: ArithmeticOperation and SetOperation validate their op against a closed set of wire words, which the generic constructor cannot guess, so each gets the dedicated factory the other un-guessable shapes already have. --- .../feature/backend/test_backend_explain.py | 38 +++++++++--------- .../backend/test_expression_roundtrip_all.py | 39 ++++++++++++++++--- .../backend/test_mysql_dialect_security.py | 8 ++-- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py index fd9b96f7..1b30bac1 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py @@ -13,7 +13,7 @@ SyncExplainBackendProtocol, AsyncExplainBackendProtocol, ) -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.expression.statements import ExplainOptions from rhosocial.activerecord.backend.impl.mysql.explain import MySQLExplainResult, MySQLExplainRow @@ -105,39 +105,39 @@ async def test_async_backend_implements_protocol(self, async_mysql_backend): class TestSyncExplainBasic: def test_explain_returns_mysql_explain_result(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result, MySQLExplainResult) def test_result_has_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert len(result.rows) > 0 def test_result_row_type(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) for row in result.rows: assert isinstance(row, MySQLExplainRow) def test_result_has_sql(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert "explain_orders" in result.sql.lower() assert result.sql.upper().startswith("EXPLAIN") def test_result_has_duration(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert result.duration >= 0.0 def test_result_has_raw_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result.raw_rows, list) assert len(result.raw_rows) == len(result.rows) @@ -152,7 +152,7 @@ class TestSyncExplainIndexAnalysis: def test_full_scan_detection(self, indexed_backend): """SELECT * FROM table without WHERE → full scan (type='ALL').""" dialect = indexed_backend.dialect - result = indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) assert result.analyze_index_usage() == "full_scan" assert result.is_full_scan is True assert result.is_index_used is False @@ -162,7 +162,7 @@ def test_index_with_lookup_detection(self, indexed_backend): """SELECT * … WHERE indexed_col = ? → index lookup + table read.""" dialect = indexed_backend.dialect result = indexed_backend.explain( - RawSQLExpression(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") + RawSQL(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") ) usage = result.analyze_index_usage() # Could be index_with_lookup or covering_index depending on optimizer @@ -174,7 +174,7 @@ def test_covering_index_detection(self, indexed_backend): """SELECT indexed_col FROM table WHERE indexed_col = ? → covering index.""" dialect = indexed_backend.dialect result = indexed_backend.explain( - RawSQLExpression(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") + RawSQL(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") ) usage = result.analyze_index_usage() # Both columns are in the covering index (order_id, sku) @@ -185,7 +185,7 @@ def test_covering_index_detection(self, indexed_backend): def test_row_fields_present(self, indexed_backend): """Verify MySQLExplainRow has expected attribute names.""" dialect = indexed_backend.dialect - result = indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) row = result.rows[0] # All expected fields must exist (may be None for some) assert hasattr(row, "id") @@ -206,21 +206,21 @@ class TestAsyncExplainBasic: @pytest.mark.asyncio async def test_explain_returns_mysql_explain_result(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert isinstance(result, MySQLExplainResult) @pytest.mark.asyncio async def test_result_has_rows(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert len(result.rows) > 0 @pytest.mark.asyncio async def test_result_has_sql_and_duration(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert result.sql.upper().startswith("EXPLAIN") assert result.duration >= 0.0 @@ -228,14 +228,14 @@ async def test_result_has_sql_and_duration(self, async_indexed_backend): @pytest.mark.asyncio async def test_full_scan_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect - result = await async_indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = await async_indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) assert result.is_full_scan is True @pytest.mark.asyncio async def test_index_used_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect result = await async_indexed_backend.explain( - RawSQLExpression(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") + RawSQL(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") ) assert result.is_index_used is True @@ -243,7 +243,7 @@ async def test_index_used_detection(self, async_indexed_backend): async def test_covering_index_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect result = await async_indexed_backend.explain( - RawSQLExpression(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") + RawSQL(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") ) assert result.is_covering_index is True @@ -262,7 +262,7 @@ def test_format_json_when_supported(self, indexed_backend): from rhosocial.activerecord.backend.expression.statements import ExplainFormat opts = ExplainOptions(format=ExplainFormat.JSON) - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr, opts) # We get back a MySQLExplainResult; raw_rows should be non-empty assert isinstance(result, MySQLExplainResult) @@ -276,7 +276,7 @@ def test_format_tree_when_supported(self, indexed_backend): from rhosocial.activerecord.backend.expression.statements import ExplainFormat opts = ExplainOptions(format=ExplainFormat.TREE) - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr, opts) assert isinstance(result, MySQLExplainResult) assert len(result.raw_rows) > 0 diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 8c3b01bb..7865af77 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -205,6 +205,15 @@ def _table(d, name="t"): "predicates.ILikePredicate": "column is annotated Any, so the generic " "constructor supplies a plain string where a value node belongs and the " "instance cannot render.", + # The two nodes whose operation is a closed set of *words*, validated at + # construction. The generic constructor guesses a string for an unannotated + # ``op`` and the node refuses it, so the factories hand a real word form. + "operators.ArithmeticOperation": "op is validated against the wire word " + "forms (add / sub / mul / div / mod); the generic constructor supplies " + "\"x\", which is not one of them.", + "query_sources.SetOperation": "op is validated against the wire word " + "forms (union / union_all / intersect / except); the generic constructor " + "supplies \"x\", which is not one of them.", } #: Every class with a dedicated constructor, in one mapping, so @@ -265,16 +274,36 @@ def partition_maintenance(d, cls_name): return getattr(mod, cls_name)(d, table=Table(d, "t"), partitions=["p0"]) + from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.predicates import ( ILikePredicate, LikePredicate, ) + from rhosocial.activerecord.backend.expression.query_sources import SetOperation + from rhosocial.activerecord.backend.expression.statements.dql import ( + QueryStatement, + ) factories = { "predicates.LikePredicate": lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), "predicates.ILikePredicate": lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + "operators.ArithmeticOperation": + lambda d: ArithmeticOperation( + d, "add", Column(d, "a"), Column(d, "b") + ), + "query_sources.SetOperation": + lambda d: SetOperation( + d, + op="union", + left=QueryStatement( + d, select=[Column(d, "id")], from_=Table(d, "t") + ), + right=QueryStatement( + d, select=[Column(d, "id")], from_=Table(d, "other") + ), + ), "match_against.MySQLMatchAgainstExpression": lambda d: MySQLMatchAgainstExpression(d, columns=["title"], search_string="x"), "json.MySQLJSONObjectExpression": @@ -485,13 +514,13 @@ def partition_maintenance(d, cls_name): "SQL/PGQ vertex table; no format_vertex_table.", ), # --- PIVOT / UNPIVOT are T-SQL and Oracle, not MySQL. ----------------- - "pivot.PivotExpression": ( - "format_pivot_expression", + "pivot.Pivot": ( + "format_pivot", "PIVOT is T-SQL/Oracle; MySQL spells it as GROUP BY with conditional " "aggregates and declares no formatter.", ), - "pivot.UnpivotExpression": ( - "format_unpivot_expression", + "pivot.Unpivot": ( + "format_unpivot", "UNPIVOT is T-SQL/Oracle; MySQL declares no formatter.", ), # --- Case-insensitive matching is written LOWER() = LOWER() here. ----- @@ -724,7 +753,7 @@ def partition_maintenance(d, cls_name): "this backend uses.", ), # --- The instance the generic constructor builds is not a valid one. --- - "advanced_functions.CaseFunction": ( + "advanced_functions.CaseExpression": ( "ValueError", "CASE needs at least one WHEN/THEN pair; the generic constructor " "supplies an empty list.", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index 2e5672bd..5a00a548 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -144,10 +144,10 @@ def test_mysql_json_table_alias_quoted(dialect): def test_mysql_format_cast_expression_valid(dialect): """A CAST target is a DataType, and the dialect renders what it says.""" - from rhosocial.activerecord.backend.expression.core import CastFunction, Column + from rhosocial.activerecord.backend.expression.core import CastExpression, Column from rhosocial.activerecord.backend.expression.types import CustomType - expr = CastFunction(dialect, Column(dialect, "column"), + expr = CastExpression(dialect, Column(dialect, "column"), CustomType(dialect, raw="INTEGER")) sql, params = dialect.format_cast_expression(expr) assert "INTEGER" in sql @@ -160,10 +160,10 @@ def test_mysql_format_cast_expression_rejects_injection(dialect): rendered into the statement text. That is why a string is not accepted at all rather than being escaped: there is nothing to escape it into, since it is code and not data.""" - from rhosocial.activerecord.backend.expression.core import CastFunction, Column + from rhosocial.activerecord.backend.expression.core import CastExpression, Column with pytest.raises(TypeError, match="takes a DataType instance"): - CastFunction(dialect, Column(dialect, "column"), + CastExpression(dialect, Column(dialect, "column"), "INTEGER; DROP TABLE users--") From ee5f1f6049438a16524bff23153cf13fae224027 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 16 Oct 2026 23:28:26 +0800 Subject: [PATCH 12/65] refactor(expression): follow the R1 signature alignment Three faces of the same change: what a DDL position is written with, what a set operation carries, and what the registry is called. A literal's inline strategy is now declared by its class rather than by a per-instance flag, so the two DDL-position literals here become DdlLiteral -- a DEFAULT and a CHECK bound value both accept no bind parameter, which is exactly what that class says. SetOperationStatement is SetOperation and carries op + left + right. The operation is a word (union / union_all / intersect / except) and the dialect writes the spelling, so the UNION ALL spelling that used to be operation='UNION' plus all_=True is one word now. An unknown word is refused where it is written rather than at render time. The roundtrip docstring pointed at ExpressionRegistry._registry as the thing discovery must not use; that name is gone, and the two registries it conflated are genuinely different -- one indexes (dialect class, kind) to a formatter, the other a kind to its expression class. --- .../backend/impl/mysql/examples/conftest.py | 2 +- .../impl/mysql/examples/query/union.py | 19 ++++++++++--------- .../backend/ddl/test_ddl_source_collection.py | 6 ++++-- .../backend/test_expression_roundtrip_all.py | 8 +++++--- .../test_mysql_type_domain_negative.py | 6 ++++-- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py index eec2f250..2a937757 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py @@ -142,7 +142,7 @@ "max_version": "9.6", }, "query/union.py": { - "title": "UNION using SetOperationStatement", + "title": "UNION using SetOperation", "dialect_protocols": [], "priority": 10, "min_version": "5.6", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py index 3c05a21a..3d31e019 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py @@ -1,5 +1,5 @@ """ -UNION using SetOperationStatement - MySQL. +UNION using SetOperation - MySQL. This example demonstrates: 1. UNION (distinct) @@ -86,7 +86,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 QueryStatement, - SetOperationStatement, + SetOperation, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -102,11 +102,12 @@ select=[Literal(dialect, "Charlie")], ) -# Union -union_expr = SetOperationStatement( +# Union. The node states the operation as a word and the dialect writes the +# spelling, so a backend that gates UNION behind a probe refuses it there. +union_expr = SetOperation( dialect=dialect, + op="union", left=query1, - operation="UNION", right=query2, ) sql, params = union_expr.to_sql() @@ -120,11 +121,10 @@ # ============================================================ # SECTION: UNION ALL # ============================================================ -union_all = SetOperationStatement( +union_all = SetOperation( dialect=dialect, + op="union_all", left=query1, - operation="UNION", - all_=True, right=query2, ) sql, params = union_all.to_sql() @@ -144,6 +144,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SetOperationStatement with operation='UNION', all_=True for UNION ALL +# 1. Use SetOperation with op='union' / op='union_all'; the dialect writes the +# SQL spelling and gates it behind its own capability probes # 2. UNION removes duplicates, UNION ALL keeps all rows # 3. Can add ORDER BY using query1.with_order_by() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py index 40722115..080d108c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -5,7 +5,7 @@ except ImportError: from typing_extensions import Annotated, Optional -from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.core import Column, DdlLiteral from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnGeneratedConstraint, @@ -58,7 +58,9 @@ SQL_TYPE = MySQLIntType(unsigned=True) FALLBACK_SQL_TYPE = MySQLLongTextType() SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) -DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +# A DEFAULT takes no bind parameter, so the value is a DdlLiteral: whether a +# literal inlines is decided by its class, never per instance. +DEFAULT_VALUE = DdlLiteral(DIALECT, "new") DEFAULT_CONSTRAINT = UseConstraint( ColumnDefaultConstraint, name="df_status_default", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 7865af77..c5680445 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -41,10 +41,12 @@ class into ``pytest.skip``, which shares a green tick with "correctly nobody listed is a failure, and so is a row left behind after the class started working. -Discovery is ``collect_expression_classes``, never -``ExpressionRegistry._registry``. The registry is process-global and grows as +Discovery is ``collect_expression_classes``, never the render registry in +``backend.expression.registry``. That registry is process-global and grows as sibling modules import other backends, so three runs of this file measured -three different denominators. +three different denominators. The two are different mechanisms anyway: the +registry indexes ``(dialect class, kind)`` to a formatter, while the +serialization side indexes a kind to its expression class. Coverage spans two packages. ``backend.expression`` is core's own class library, collected here as well as this backend's, because outside the single diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py index 63db45e3..1f59ae3b 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py @@ -23,7 +23,7 @@ TypeObjectSupport, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import BaseExpression, Literal +from rhosocial.activerecord.backend.expression import BaseExpression, DdlLiteral from rhosocial.activerecord.backend.expression.statements import ( AlterDomainStatement, AlterTypeStatement, @@ -122,7 +122,9 @@ def _type_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: def _domain_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: - condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + # A CHECK expression accepts no bind parameter either, so the bound value + # is a DdlLiteral rather than a data-position Literal. + condition = DomainValueExpression(dialect) > DdlLiteral(dialect, 0) check = DomainCheckConstraint(dialect, condition) return ( CreateDomainStatement(dialect, Domain(dialect, "positive"), IntegerType(dialect)), From a6a73894e261970d31b43515a488fd8819cef777 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 16 Oct 2026 23:39:12 +0800 Subject: [PATCH 13/65] refactor(expression): name the arithmetic operation and class the aggregates BinaryArithmeticExpression is ArithmeticOperation and its op is now the *operation* rather than the character one backend's lexer happens to write: add / sub / mul / div / mod, validated as a closed set at construction so a symbol or an unknown word is refused where it is written. The dialect formatter maps each word to the character the rendered SQL takes, and the precedence table is keyed by the same words -- so every BinaryArithmeticExpression(d, '+', ...) becomes ArithmeticOperation(d, 'add', ...) and renders the same bytes. The bitwise factories are the one place where that is not a rename. ArithmeticOperation admits five words and &, |, ^, << and >> are none of them, so bit_and / bit_or / bit_xor / bit_shift_* / bit_get_bit move to BinaryExpression -- a plain binary operator taking the character the backend spells. Same SQL, and no symbol smuggled into a node whose wire form does not admit it. Standard functions stop going through FunctionCall. The MAX / MIN / AVG half of math_enhanced's max_ / min_ / avg is now MaxFunction / MinFunction / AvgFunction, so a single-argument call returns the classed node; the GREATEST / LEAST half stays on FunctionCall, because those are MySQL scalars with no standard node of their own and the generic call is the escape hatch. The aggregate and subquery examples build SUM and AVG through the sum_ / avg factories instead of a name string. A windowed aggregate is left on FunctionCall on purpose: the OVER clause is not part of the aggregate nodes' contract yet. --- .../impl/mysql/examples/query/aggregate.py | 13 ++++--- .../impl/mysql/examples/query/subquery.py | 4 +-- .../impl/mysql/examples/update/basic.py | 7 ++-- .../backend/impl/mysql/functions/bitwise.py | 22 +++++++----- .../impl/mysql/functions/math_enhanced.py | 26 +++++++++----- .../basic/fields/test_derived_field.py | 34 +++++-------------- .../basic/fields/test_derived_field_async.py | 34 +++++-------------- .../feature/basic/fixtures/models.py | 26 +++++++------- .../feature/relation/fixtures/models.py | 6 ++-- 9 files changed, 80 insertions(+), 92 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py index 33302257..11f1959d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py @@ -89,16 +89,21 @@ Column, GroupByHavingClause, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression.functions import ( # noqa: E402 + avg, + sum_, +) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table +# SUM and AVG are classed nodes, not a generic call carrying a name string: +# the identity of the aggregate travels on the node. query = QueryStatement( dialect=dialect, select=[ Column(dialect, "product"), - FunctionCall(dialect, "SUM", Column(dialect, "quantity")).as_("total_qty"), - FunctionCall(dialect, "AVG", Column(dialect, "price")).as_("avg_price"), + sum_(dialect, Column(dialect, "quantity"), alias="total_qty"), + avg(dialect, Column(dialect, "price"), alias="avg_price"), ], from_=Table(dialect, "sales"), group_by_having=GroupByHavingClause( @@ -107,7 +112,7 @@ having=ComparisonPredicate( dialect, ">", - FunctionCall(dialect, "SUM", Column(dialect, "quantity")), + sum_(dialect, Column(dialect, "quantity")), Literal(dialect, 10), ), ), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py index 1f02b684..6d2b2f4a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py @@ -127,13 +127,13 @@ WhereClause, Subquery, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression.functions import avg # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table subquery_query = QueryStatement( dialect=dialect, - select=[FunctionCall(dialect, "AVG", Column(dialect, "salary"))], + select=[avg(dialect, Column(dialect, "salary"))], from_=Table(dialect, "employees"), ) sql, params = subquery_query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py index 950a063e..1fb74c2e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py @@ -36,7 +36,7 @@ ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnConstraint, ColumnDefinition, @@ -117,7 +117,7 @@ dialect=dialect, table=Table(dialect, "users"), assignments={ - "age": BinaryArithmeticExpression(dialect, "+", Column(dialect, "age"), Literal(dialect, 1)), + "age": ArithmeticOperation(dialect, "add", Column(dialect, "age"), Literal(dialect, 1)), }, where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), ) @@ -152,6 +152,7 @@ # ============================================================ # Key points: # 1. Use UpdateStatement with assignments dict for SET clause -# 2. Use BinaryArithmeticExpression for expressions like age + 1 +# 2. Use ArithmeticOperation for expressions like age + 1; op names the +# operation as a word ("add"), and the dialect writes the character # 3. Omit where parameter to update all rows # 4. affected_rows shows number of updated rows diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py index 15c5abd9..9ed6fb97 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py @@ -7,12 +7,18 @@ Note: MySQL 9.6 does not support BIT_GET_BIT, BIT_SHIFT_LEFT, BIT_SHIFT_RIGHT as functions. These are implemented using native bitwise operators. + +The bitwise symbols are **not** arithmetic: ``ArithmeticOperation`` admits a +closed set of operation words (``add`` / ``sub`` / ``mul`` / ``div`` / ``mod``) +and ``&`` / ``|`` / ``^`` / ``<<`` / ``>>`` are none of them. These are plain +binary operators, so they are written as :class:`BinaryExpression`, which takes +the character the backend spells. """ from typing import Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core, NumericColumn -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import BinaryExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import MySQLDialect @@ -50,7 +56,7 @@ def bit_and( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "&", result, v_expr) + result = BinaryExpression(dialect, "&", result, v_expr) return result @@ -86,7 +92,7 @@ def bit_or( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "|", result, v_expr) + result = BinaryExpression(dialect, "|", result, v_expr) return result @@ -122,7 +128,7 @@ def bit_xor( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "^", result, v_expr) + result = BinaryExpression(dialect, "^", result, v_expr) return result @@ -182,8 +188,8 @@ def bit_get_bit( else NumericColumn(dialect, bit) ) # (value >> bit) & 1 - shifted = BinaryArithmeticExpression(dialect, ">>", value_expr, bit_expr) - return BinaryArithmeticExpression(dialect, "&", shifted, core.Literal(dialect, 1)) + shifted = BinaryExpression(dialect, ">>", value_expr, bit_expr) + return BinaryExpression(dialect, "&", shifted, core.Literal(dialect, 1)) def bit_shift_left( @@ -217,7 +223,7 @@ def bit_shift_left( else core.Literal(dialect, count) if isinstance(count, (int, float)) else NumericColumn(dialect, count) ) - return BinaryArithmeticExpression(dialect, "<<", value_expr, count_expr) + return BinaryExpression(dialect, "<<", value_expr, count_expr) def bit_shift_right( @@ -251,7 +257,7 @@ def bit_shift_right( else core.Literal(dialect, count) if isinstance(count, (int, float)) else NumericColumn(dialect, count) ) - return BinaryArithmeticExpression(dialect, ">>", value_expr, count_expr) + return BinaryExpression(dialect, ">>", value_expr, count_expr) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py index 60ae0a67..5c209375 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.functions import aggregate if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -247,7 +248,7 @@ def trunc( def max_( dialect: "SQLDialectBase", *args: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a GREATEST or MAX function call. @@ -262,24 +263,28 @@ def max_( When called with a single argument, uses MAX (aggregate). When called with multiple arguments, uses GREATEST (scalar). + Only the aggregate half is a classed node: MAX is a standard function, so it + is a ``MaxFunction``. GREATEST is a MySQL scalar with no standard node of + its own and stays on the generic call -- the escape hatch. + Args: dialect: The SQL dialect instance *args: The expressions to compare Returns: - A FunctionCall instance representing GREATEST or MAX + A MaxFunction for one argument, a FunctionCall for several Version: All MySQL versions """ if len(args) == 1: - return core.FunctionCall(dialect, "MAX", args[0]) + return aggregate.MaxFunction(dialect, args[0]) return core.FunctionCall(dialect, "GREATEST", *args) def min_( dialect: "SQLDialectBase", *args: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a LEAST or MIN function call. @@ -294,24 +299,27 @@ def min_( When called with a single argument, uses MIN (aggregate). When called with multiple arguments, uses LEAST (scalar). + The aggregate half is a ``MinFunction`` and the scalar half stays on the + generic call, for the reason given on :func:`max_`. + Args: dialect: The SQL dialect instance *args: The expressions to compare Returns: - A FunctionCall instance representing LEAST or MIN + A MinFunction for one argument, a FunctionCall for several Version: All MySQL versions """ if len(args) == 1: - return core.FunctionCall(dialect, "MIN", args[0]) + return aggregate.MinFunction(dialect, args[0]) return core.FunctionCall(dialect, "LEAST", *args) def avg( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "aggregate.AvgFunction": """ Creates an AVG function call. @@ -325,11 +333,11 @@ def avg( expr: The numeric expression Returns: - A FunctionCall instance representing the AVG function + An AvgFunction node Version: All MySQL versions """ - return core.FunctionCall(dialect, "AVG", expr) + return aggregate.AvgFunction(dialect, expr) __all__ = [ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py index 20c56e72..85954e83 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class TestDerivedFieldDeclaration: @@ -20,9 +20,7 @@ def test_descriptor_class_access(self, product_class): def test_derived_field_with_static_expression(self, product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) - ) + static_expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 2)) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -140,9 +138,7 @@ def test_derived_dict_with_lambda(self, product_class): self._insert(product_class, "D1", 100.0, 4) results = product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) - ) + "my_discount": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.8)) } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -159,9 +155,7 @@ def test_derived_dict_with_derived_field_instance(self, product_class): def test_derived_dict_with_expression_object(self, product_class): self._insert(product_class, "D3", 80.0, 5) dialect = product_class.backend().dialect - expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) - ) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = product_class.find_all( derived={"half_price": expr} ) @@ -194,9 +188,7 @@ def test_extra_derived_basic(self, product_class): self._insert(product_class, "F", 100.0, 10) results = product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) - ) + "triple_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 3)) } ) assert len(results) == 1 @@ -207,9 +199,7 @@ def test_extra_derived_conflict_raises(self, product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) - ) + "discounted_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.5)) } ) @@ -218,9 +208,7 @@ def test_derived_and_extra_derived_together(self, product_class): results = product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) - ) + "double_qty": lambda d: ArithmeticOperation(d, "mul", Column(d, "quantity"), Literal(d, 2)) } ) assert results[0].discounted_price == pytest.approx(36.0) @@ -335,9 +323,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] def test_use_column_no_conflict_different_names(self): @@ -347,9 +333,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py index 3c944830..4934b35f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class TestAsyncDerivedFieldDeclaration: @@ -20,9 +20,7 @@ async def test_descriptor_class_access(self, async_product_class): async def test_derived_field_with_static_expression(self, async_product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = async_product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) - ) + static_expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 2)) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -140,9 +138,7 @@ async def test_derived_dict_with_lambda(self, async_product_class): await self._insert(async_product_class, "D1", 100.0, 4) results = await async_product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) - ) + "my_discount": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.8)) } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -159,9 +155,7 @@ async def test_derived_dict_with_derived_field_instance(self, async_product_clas async def test_derived_dict_with_expression_object(self, async_product_class): await self._insert(async_product_class, "D3", 80.0, 5) dialect = async_product_class.backend().dialect - expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) - ) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = await async_product_class.find_all( derived={"half_price": expr} ) @@ -194,9 +188,7 @@ async def test_extra_derived_basic(self, async_product_class): await self._insert(async_product_class, "F", 100.0, 10) results = await async_product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) - ) + "triple_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 3)) } ) assert len(results) == 1 @@ -207,9 +199,7 @@ async def test_extra_derived_conflict_raises(self, async_product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): await async_product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) - ) + "discounted_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.5)) } ) @@ -218,9 +208,7 @@ async def test_derived_and_extra_derived_together(self, async_product_class): results = await async_product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) - ) + "double_qty": lambda d: ArithmeticOperation(d, "mul", Column(d, "quantity"), Literal(d, 2)) } ) assert results[0].discounted_price == pytest.approx(36.0) @@ -335,9 +323,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] async def test_use_column_no_conflict_different_names(self): @@ -347,9 +333,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py index 8db8dd9d..24a9002f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py @@ -767,7 +767,7 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): # ============================================================================= from rhosocial.activerecord.base import DerivedField -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class Product(ActiveRecord): @@ -779,11 +779,11 @@ class Product(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), )]] @@ -796,11 +796,11 @@ class AsyncProduct(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), )]] @@ -814,11 +814,11 @@ class ProductFormA(ActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ) @@ -831,11 +831,11 @@ class AsyncProductFormA(AsyncActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ) @@ -900,11 +900,11 @@ class ProductWithColumnAndAdapter(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ), UseAdapter(PriceToIntAdapter(), int)]] @@ -917,10 +917,10 @@ class AsyncProductWithColumnAndAdapter(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ), UseAdapter(PriceToIntAdapter(), int)]] diff --git a/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py index cd7486b2..955cab2c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py @@ -13,7 +13,7 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.base import DerivedField, FieldProxy -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal from rhosocial.activerecord.backend.expression.functions import ( json_extract_text, length, concat, coalesce ) @@ -159,7 +159,7 @@ class Post(ActiveRecord): # DerivedField: hotness score hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] # DerivedField (JSON): first tag from metadata @@ -254,7 +254,7 @@ class AsyncPost(AsyncActiveRecord): )]] hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] first_tag: ClassVar[Annotated[str, DerivedField( From 037bc6c75ef1e0e17c47d563c085f4c901448f4a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 17 Oct 2026 03:04:30 +0800 Subject: [PATCH 14/65] refactor(expression): build the aggregate through its class The standard aggregate is a real node now, so COUNT is built as CountFunction rather than through the user-defined-function escape hatch it shares spelling with. --- .../feature/backend/test_mysql_partition_operations.py | 4 ++-- .../backend/test_mysql_partition_strategy_operations.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index 5cf90181..1ae43fc5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -521,7 +521,7 @@ def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Op def _select_production_count_expression(dialect): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_PARTITION_TABLE), ) @@ -529,7 +529,7 @@ def _select_production_count_expression(dialect): def _select_archive_count_expression(dialect): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_ARCHIVE_TABLE), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index 6a77b8a0..dcb6635f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -246,7 +246,7 @@ def _select_payloads_expression(dialect, table_name: str): def _select_count_expression(dialect, table_name: str): return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", RowCountWildcard(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, table_name), ) From 8961ea1b2a2c3fa5887aa4207d9373d58b6e5c04 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 06:21:24 +0800 Subject: [PATCH 15/65] test(mysql): vendor the roundtrip harness the testsuite retired The testsuite's fix/column-constraint-gates branch dropped rhosocial.activerecord.testsuite.utils.expression in e05edb0 ("drop the round-trip harness the registry replaced") with the stated direction that round-trip coverage moves to the owning repository. This matrix imported that module, so collection failed locally and would fail in CI, which installs the testsuite at that branch. - Vendor the module (recovered from testsuite 4ef2551) as tests/.../feature/backend/expression_roundtrip_harness.py, sibling to the matrix; pytest's basedir insertion makes it importable without touching PYTHONPATH. Provenance recorded in the module header; fix drift here deliberately, do not re-import from the suite. - Adapt register_all to the split registry: core's serialization no longer exports register_expression_class. Classes now register into default_registry() via register_node; re-registering core's own auto-populated classes is a no-op, and the mysql classes are what the call adds. - Add register_enums_and_dataclasses(package) and call it for the mysql expression package: artifacts carry enum members under their short class name and the whitelist resolves that name, and core auto-populates only its own packages (core + sqlite + dummy), so the backend's vocabulary has to be registered beside its classes. Evidence: targeted matrix selection -k "insert or table_options or comment" -- 16 passed, exit 0 (collection previously failed). --- .../backend/expression_roundtrip_harness.py | 1135 +++++++++++++++++ .../backend/test_expression_roundtrip_all.py | 7 +- 2 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py new file mode 100644 index 00000000..69c4d842 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py @@ -0,0 +1,1135 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (MySQL-local copy). + +Vendored from the shared testsuite at +``python-activerecord-testsuite@4ef2551`` (formerly +``src/rhosocial/activerecord/testsuite/utils/expression.py``) when that suite's +``fix/column-constraint-gates`` branch dropped the module in ``e05edb0`` with +the direction "round-trip coverage moves to the owning repository": the suite +keeps the ActiveRecord-layer contracts and must not grow an opinion about +which expressions exist, so each backend now owns the harness its own matrix +runs on. Fix drift here deliberately; do not re-import it from the suite. + +This module is the test-only home (NOT shipped as part of the core library's +runtime API) for constructing expression instances and asserting they survive +all three serializations. + +After registering a class into the ExpressionRegistry, expression instances +must satisfy: + +- ``get_params()`` reflects every ``__init__`` parameter +- A round-trip through the three encoding channels returns a structurally + equivalent instance (``get_params()`` equal) +- When the chosen backend's dialect supports ``to_sql()``, the SQL keyword + rendering is identical +""" + +import collections.abc +import dataclasses +import inspect +import re +import typing +import warnings +from typing import Any, Callable, Dict, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraint`` are +# constraint actions, not relations, and a substring test would hand them a +# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + + +def _mentions_expression_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names an expression node, whatever it is called. + + This used to be a substring test for ``"Expression"``, which is a coupling + to the class *names*: renaming ``QueryExpression`` to ``QueryStatement`` + (规范 4.9: a statement is a statement) silently stopped matching, the + parameter fell through to the ``"x"`` fallback, and the failure surfaced + later as ``'str' object has no attribute 'to_sql'`` from inside a + formatter. The structural question -- does this annotation name a + ``BaseExpression`` subclass? -- does not care what the class is called. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + candidates = [raw] + if typing.get_origin(annotation) is not None: + candidates.extend(typing.get_args(annotation)) + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseExpression): + return True + return False + + +def _is_predicate_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names a predicate node, whatever it is called. + + Same structural question as :func:`_mentions_expression_class` answers for + expressions, asked of the predicate family: a rename inside the family must + not change what this heuristic builds either. + """ + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + for candidate in (raw, annotation): + if isinstance(candidate, type) and issubclass(candidate, SQLPredicate): + return True + return False + + +def _placeholder_for(param: inspect.Parameter, dialect: Any = None): + """Return a heuristic construction value for a required parameter.""" + annotation = param.annotation + if annotation is not inspect.Parameter.empty: + origin = typing.get_origin(annotation) + raw = annotation if origin is None else origin + if isinstance(annotation, str): + text = annotation + else: + text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container + if raw in (list, tuple, set): + return [] + if raw is dict: + return {} + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) + if raw is str or "str" in text: + return "x" + # A typed value expression wraps the call it renders, so the parameter + # wants a FunctionCall and not a bare name. This must be tested before + # the str branch above: "FunctionCall" contains no "str", but the + # fallback below returns "x" for it, which the wrapper then calls + # to_sql() on. + if "FunctionCall" in text: + return _function_call(dialect) + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType(dialect) + if raw is int: + return 1 + if raw is float: + return 1.0 + if raw is bool: + return True + # A predicate slot wants a predicate, not a value. SQLPredicate is a + # BaseExpression subclass like the values are, so the structural + # expression branch below hands it a Literal -- and the constructors + # that type a parameter as a predicate refuse a Literal on their + # isinstance check (core's DomainCheckConstraint.condition is the one + # in the matrix today). Asked structurally when the annotation + # resolves; the name test beside it covers forward references. + if (not isinstance(annotation, str) and _is_predicate_class(annotation, raw)) or ( + "Predicate" in text + ): + return _comparison(dialect) + # Asked structurally when the annotation resolves: what a class is + # called says nothing about what it is, and a rename must not change + # what this heuristic builds. The substring tests beside it remain for + # forward references, which introspection cannot resolve -- and they + # must list every name the library might grow, which is exactly why + # the structural test is the one that leads. + if ( + "Expression" in text + or "Subquery" in text + or "Statement" in text + or (not isinstance(annotation, str) and _mentions_expression_class(annotation, raw)) + ): + return _literal(dialect) + + name = param.name + if name in ("value",): + return 1 + if name in ("values", "columns", "args", "params", "predicates", "expressions"): + return [] + if name in ("left", "right") or "predicate" in name or "condition" in name: + return _comparison(dialect) + if "expression" in name or name in ("expr", "operand", "subquery", "query"): + return _literal(dialect) + return "x" + + +def _literal(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + The typed value expressions (``BooleanValue`` and its siblings) + hold a ``call`` and render it through ``to_sql()``. Handing them a string + builds an instance that cannot render, and the failure surfaces as + ``'str' object has no attribute 'to_sql'`` from inside the wrapper rather + than as a construction problem. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall + + return FunctionCall(dialect, "FUNC", niladic=True) + + +def _comparison(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), _literal(dialect)) + + +def _try_construct(cls, dialect): + """Construct an instance of cls with heuristic arguments; None on failure.""" + sig = inspect.signature(cls.__init__) + # DataType-family declares dialect as an optional trailing keyword. + has_dialect = "dialect" in sig.parameters + first_param = next((p for p in sig.parameters if p != "self"), None) + dialect_as_kw = first_param != "dialect" + args = [] + kwargs = {} + skipped_defaulted_positional = False + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + if not skipped_defaulted_positional: + args.append(_literal(dialect)) + continue + if param.default is not inspect.Parameter.empty: + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + skipped_defaulted_positional = True + continue + placeholder = _placeholder_for(param, dialect) + # placeholder None → default case; real value provided otherwise + if param.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[pname] = placeholder + else: + args.append(placeholder) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not has_dialect: + return cls(*args, **kwargs) + if dialect_as_kw: + return cls(*args, dialect=dialect, **kwargs) + return cls(dialect, *args, **kwargs) + except Exception: + return None + + +def special_constructors(): + """Explicit constructors for classes whose ``__init__`` rejects heuristic + placeholder values. Keyed by a suffix that matches the class's full + module.ClassName. Registered by name only — this is a static registry and + backends may register their own entries for backend-specific expressions. + + **A key that matches no class is dead code, and nothing says so.** + :func:`make_instance` dispatches on ``full_name.endswith(key)``, so + renaming a class turns its entry into a literal that matches nothing: the + class then falls through to :func:`_try_construct`, the source is reported + as ``"heuristic"`` instead of ``"special"``, and if the heuristic also + fails the class is silently reported as unconstructible rather than as + mis-registered. This is how ``"advanced_functions.JSONExpression"`` came to + match nothing at all once core split that one class into + ``JSONDocumentValue`` and ``JSONTextValue``. + + The failure is one-directional, which is what makes it dangerous: renaming + the key once breaks the match silently, and a *single* key covering both + new names is not expressible, because ``endswith`` compares a suffix. A + class that was split needs one entry per resulting class. + + :func:`register_special_constructor` writes to the module-level + ``_SPECIAL_REGISTRY``, which is what :func:`make_instance` reads. Note that + this function rebuilds its dict on every call, so a registration made + elsewhere is **not** visible to a caller that invokes + ``special_constructors()`` directly — core's expression-contract test does + exactly that, and therefore sees only the literals below. + """ + return { + "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.ddl_table.ColumnConstraint": _column_constraint, + "statements.ddl_table.ForeignKeyConstraint": _foreign_key_constraint, + "statements.ddl_table.TableConstraint": _table_constraint, + "statements.ddl_table.ColumnForeignKeyConstraint": _column_fk_constraint, + "statements.ddl_table.TableForeignKeyConstraint": _table_fk_constraint, + "statements.ddl_table.TablePrimaryKeyConstraint": _table_primary_key, + "statements.ddl_table.TableUniqueConstraint": _table_unique, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceStatement": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceStatement": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceStatement": _alter_sequence_expr, + "statements.ddl_type.CreateTypeStatement": _create_type_expr, + "statements.ddl_type.AlterTypeStatement": _alter_type_expr, + "statements.ddl_type.DropTypeStatement": _drop_type_expr, + "statements.ddl_domain.CreateDomainStatement": _create_domain_expr, + "statements.ddl_domain.AlterDomainStatement": _alter_domain_expr, + "statements.ddl_domain.DropDomainStatement": _drop_domain_expr, + "statements.ddl_function.CreateFunctionStatement": _create_function_expr, + "statements.ddl_function.DropFunctionStatement": _drop_function_expr, + "statements.ddl_schema.CreateSchemaStatement": _create_schema_expr, + "statements.ddl_schema.DropSchemaStatement": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseStatement": _create_database_expr, + "statements.ddl_database.DropDatabaseStatement": _drop_database_expr, + # core splits what was one JSONExpression into a document and a text + # class, so one key cannot cover both: endswith matches a suffix. + "advanced_functions.JSONDocumentValue": _json_document_expr, + "advanced_functions.JSONTextValue": _json_text_expr, + "query_parts.JoinClause": _join_expr, + "statements.ddl_partition.PartitionClause": _partition_clause, + "statements.dml.OnConflictClause": _on_conflict, + "statements.dml.UpdateStatement": _update_expr, + "statements.dml.InsertStatement": _insert_expr, + "statements.dml.DeleteStatement": _delete_expr, + "statements.ddl_alter.AlterTableStatement": _alter_table_expr, + "types.array.ArrayType": _array_type, + "statements.ddl_view.CreateViewStatement": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTable": _edge_table, + "graph.GraphTableExpression": _graph_table_expr, + "graph.CreatePropertyGraphStatement": _create_property_graph_expr, + "graph.DropPropertyGraphStatement": _drop_property_graph_expr, + "graph.AlterPropertyGraphStatement": _alter_property_graph_expr, + "datetime.ExtractExpression": _extract_expr, + "datetime.DatePartExpression": _datepart_expr, + "datetime.DateTruncExpression": _datetrunc_expr, + "datetime.IntervalExpression": _interval_expr, + "datetime.DateTimeDiffExpression": _datetime_diff_expr, + "datetime.DateTimeSubtractExpression": _datetime_sub_expr, + "datetime.DateTimeAddExpression": _datetime_add_expr, + } + + +def _column_definition(dialect): + from rhosocial.activerecord.backend.expression.statements import ColumnDefinition + from rhosocial.activerecord.backend.expression.types import IntegerType + + return ColumnDefinition(dialect, "col", IntegerType(dialect)) + + +# ---------------------------------------------------------------- constraint nodes +# A constraint is a clause plus the branch that says which kind it is, so the +# two levels need a branch each: the clause cannot be built without saying what +# it constrains, and the branches whose payload is a relation or a column list +# need one the heuristic guess has no way to supply. + + +def _column_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ) + + return ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)) + + +def _foreign_key_constraint(dialect): + """The construction sugar over a table-level FOREIGN KEY branch. + + Same reason as the branch itself: what it points at has to be a real + ``Table``, which a guessed placeholder cannot be. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ForeignKeyConstraint, + ) + + return ForeignKeyConstraint( + dialect, + columns=["col"], + foreign_key_table=Table(dialect, "parent"), + foreign_key_columns=["id"], + ) + + +def _table_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableUniqueConstraint, + ) + + return TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["col"])) + + +def _column_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ) + + return ColumnForeignKeyConstraint(dialect, (Table(dialect, "parent"), ["id"])) + + +def _table_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + TableForeignKeyConstraint, + ) + + return TableForeignKeyConstraint(dialect, ["col"], (Table(dialect, "parent"), ["id"])) + + +def _table_primary_key(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TablePrimaryKeyConstraint, + ) + + return TablePrimaryKeyConstraint(dialect, ["col"]) + + +def _table_unique(dialect): + from rhosocial.activerecord.backend.expression.statements import TableUniqueConstraint + + return TableUniqueConstraint(dialect, ["col"]) + + +def _json_document_expr(dialect): + """``->``: a JSON *document*, reached by path. + + ``path`` and ``operation`` keep their defaults, so this renders exactly + what the pre-split single ``JSONExpression`` factory rendered. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONDocumentValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONDocumentValue(dialect, Literal(dialect, '{"a": 1}'), path="$.a") + + +def _json_text_expr(dialect): + """``->>``: the *text* of a JSON path. + + ``JSONTextValue`` declares no ``__init__`` of its own, so it inherits + the document class's -- including ``operation="->"``. The default is + therefore inherited too, and this factory has to say ``->>`` explicitly. + Leaving it off would hand the contract test a ``JSONTextValue`` whose + ``operation`` says ``->``, which is the one thing the class exists to + distinguish. + + The rendered SQL is unaffected either way: the formatters branch on + ``expr.operation``, not on the class, and dispatch is on ``format_method``, + which both classes share. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONTextValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONTextValue( + dialect, Literal(dialect, '{"a": 1}'), path="$.a", operation="->>" + ) + + +def _values_source(dialect): + """A ``VALUES`` clause with one row of one column. + + ``ValuesSource`` rejects an empty ``values_list``, and the heuristic filler + returns ``[]`` for any ``list``-origin annotation -- which is why this class + needs an explicit constructor at all. ``[]`` would also be the wrong thing + to satisfy it with: a ``VALUES`` clause with no rows is not an INSERT + source, and the zero-row spelling already has its own class, + ``DefaultValuesSource``, sitting next to this one. + """ + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ValuesSource + + return ValuesSource(dialect, [[Literal(dialect, 1)]]) + + +def _array_type(dialect): + """An array of integers -- one dimension. + + ``ArrayType`` requires an ``element_type`` and raises ``TypeError`` without + one ("an array with no element type is not a type"). Two things stop the + heuristic filler from reaching that requirement, and neither is fixable by + giving the parameter a default: + + * the filler skips defaulted parameters, so it calls ``ArrayType(dialect)`` + with nothing else; and + * ``dialect`` must come first and ``element_type`` must follow it, so a + *required* ``element_type`` after a defaulted ``dialect`` is not + expressible in Python at all. + + Making the parameter required therefore cannot help, and a default of + ``None`` is already what core declares. So the element type has to be + supplied here, as a concrete ``DataType``. + """ + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + IntegerType, + ) + + return ArrayType(dialect, element_type=IntegerType(dialect)) + + +def _sequence(dialect): + """A sequence, by kind.""" + from rhosocial.activerecord.backend.expression.objects import Sequence + + return Sequence(dialect, "s") + + +def _create_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + CreateSequenceStatement, + ) + + return CreateSequenceStatement(dialect, _sequence(dialect)) + + +def _drop_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + DropSequenceStatement, + ) + + return DropSequenceStatement(dialect, _sequence(dialect)) + + +def _alter_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceStatement, + ) + + return AlterSequenceStatement(dialect, _sequence(dialect)) + + +def _create_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeDefinition + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + CreateTypeStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateTypeStatement( + dialect, Type(dialect, "t"), _DummyTypeDefinition(dialect, IntegerType(dialect)) + ) + + +def _alter_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeStatement, + ) + + return AlterTypeStatement( + dialect, Type(dialect, "t"), [_DummyTypeAlterAction(dialect, "t2")] + ) + + +def _drop_type_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeStatement, + ) + + return DropTypeStatement(dialect, Type(dialect, "t")) + + +def _create_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + CreateDomainStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateDomainStatement(dialect, Domain(dialect, "d"), IntegerType(dialect)) + + +def _alter_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainStatement, + SetDomainDefaultAction, + ) + + return AlterDomainStatement( + dialect, Domain(dialect, "d"), [SetDomainDefaultAction(dialect, Literal(dialect, 1))] + ) + + +def _drop_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DropDomainStatement, + ) + + return DropDomainStatement(dialect, Domain(dialect, "d")) + + +def _create_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + CreateFunctionStatement, + ) + + return CreateFunctionStatement(dialect, Function(dialect, "f")) + + +def _drop_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionStatement, + ) + + return DropFunctionStatement(dialect, Function(dialect, "f")) + + +def _create_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaStatement, + ) + + return CreateSchemaStatement(dialect, Schema(dialect, "s")) + + +def _drop_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaStatement, + ) + + return DropSchemaStatement(dialect, Schema(dialect, "s")) + + +def _create_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseStatement, + ) + + return CreateDatabaseStatement(dialect, Database(dialect, "d")) + + +def _drop_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + DropDatabaseStatement, + ) + + return DropDatabaseStatement(dialect, Database(dialect, "d")) + + +def _node_table(dialect): + """A graph vertex table.""" + from rhosocial.activerecord.backend.expression.graph import VertexTable + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return VertexTable(dialect, NodeTable(dialect, "n")) + + +def _edge_table(dialect): + """A graph edge table declaration.""" + from rhosocial.activerecord.backend.expression.graph import EdgeTable + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTable(dialect, EdgeTableObject(dialect, "e"), ["s"], ["d"]) + + +def _graph_vertex(dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return GraphVertex(dialect, "v", NodeTable(dialect, "n")) + + +def _graph_edge(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + GraphEdge, + GraphEdgeDirection, + ) + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return GraphEdge(dialect, "e", EdgeTableObject(dialect, "e"), + GraphEdgeDirection.RIGHT) + + +def _graph_table_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + ColumnsClause, + GraphColumn, + GraphTableExpression, + MatchClause, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return GraphTableExpression( + dialect, + PropertyGraph(dialect, "g"), + MatchClause( + dialect, + _graph_vertex(dialect), + _graph_edge(dialect), + _graph_vertex(dialect), + ), + ColumnsClause(dialect, GraphColumn("v", "name")), + ) + + +def _create_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return CreatePropertyGraphStatement( + dialect, PropertyGraph(dialect, "g"), [_node_table(dialect)] + ) + + +def _drop_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + DropPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return DropPropertyGraphStatement(dialect, PropertyGraph(dialect, "g")) + + +def _alter_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return AlterPropertyGraphStatement( + dialect, + PropertyGraph(dialect, "g"), + "ADD", + "VERTEX TABLES", + vertex_tables=[_node_table(dialect)], + ) + + +def _table(dialect, name="t"): + """The catalogue object a statement acts on.""" + from rhosocial.activerecord.backend.expression.objects import Table + + return Table(dialect, name) + + +def _relation(dialect, annotation): + """The object an annotation asks for, chosen by the kind it names. + + The kind is not decoration: CREATE VIEW will not accept a Table, and DROP + MATERIALIZED VIEW will not accept a View. Longest name first, because + ``MaterializedView`` contains ``View`` and the more specific kind is the one + an annotation naming it means. + """ + import rhosocial.activerecord.backend.expression.objects as objects + + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + "Sequence", "Index", "View", "Table", "Type", "Domain", + "Function", "Trigger", "Schema", "Database"): + if re.search(rf"\b{kind}\b", annotation): + return getattr(objects, kind)(dialect, "t") + return _table(dialect) + + +def _source(dialect, name="t"): + """A row source over the object a statement acts on. + + A statement that *names* a relation takes the object; a statement that + *reads rows from* one takes a source over it. The two are not + interchangeable, so they get their own helper rather than one that guesses + which position it is filling. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + return NamedRelationRef(dialect, Table(dialect, name)) + + +def _join_expr(dialect): + from rhosocial.activerecord.backend.expression.query_parts import JoinClause + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.core import Column + + return JoinClause( + dialect, + left_table=_source(dialect, "a"), + right_table=_source(dialect, "b"), + condition=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")), + ) + + +def _partition_clause(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, + ) + + return PartitionClause(dialect, method=PartitionStrategy.RANGE, keys=[Column(dialect, "created_at")]) + + +def _on_conflict(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import OnConflictClause + + return OnConflictClause(dialect, do_nothing=True, conflict_target=["id"]) + + +def _insert_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ( + InsertStatement, + ValuesSource, + ) + + return InsertStatement(dialect, into=_table(dialect), source=ValuesSource(dialect, [[Literal(dialect, 1)]])) + + +def _delete_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import DeleteStatement + + return DeleteStatement(dialect, tables=_table(dialect)) + + +def _update_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import UpdateStatement + + return UpdateStatement(dialect, table=_table(dialect), assignments={"a": Literal(dialect, 1)}) + + +def _alter_table_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumn, + AlterTableStatement, + DropColumn, + ) + + column = _column_definition(dialect) + return AlterTableStatement( + dialect, + table=_table(dialect, "t"), + actions=[DropColumn(dialect, "a"), AddColumn(dialect, column=column)], + ) + + +def _create_view_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.objects import View + from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewStatement + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=_source(dialect, "t")) + return CreateViewStatement(dialect, view=View(dialect, "v"), query=query) + + +def _extract_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ExtractExpression + + return ExtractExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datepart_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatePartExpression + + return DatePartExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datetrunc_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DateTruncExpression + + return DateTruncExpression(dialect, "year", Column(dialect, "created_at")) + + +def _interval_expr(dialect): + from rhosocial.activerecord.backend.expression.datetime import IntervalExpression + + return IntervalExpression(dialect, 7, "day") + + +def _datetime_diff_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DateTimeDiffExpression + + return DateTimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + + +def _datetime_sub_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DateTimeSubtractExpression, + IntervalExpression, + ) + + return DateTimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +def _datetime_add_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DateTimeAddExpression, + IntervalExpression, + ) + + return DateTimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +_SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() + + +def register_special_constructor(fqn_suffix: str, factory: Callable) -> None: + """Register an explicit constructor for an expression class. + + ``fqn_suffix`` must match the tail of the class's fully qualified name + (e.g. ``"json.MySQLJSONObjectExpression"``). ``factory(dialect)`` must + return an expression instance. + """ + _SPECIAL_REGISTRY[fqn_suffix] = factory + + +def make_instance(cls: Type[BaseExpression], dialect: Any): + """Best-effort construction for an expression class. + + Returns (instance, source) where source is "special", "heuristic", or + (None, "failed"). + """ + full_name = f"{cls.__module__}.{cls.__name__}" + special = next((fn for s, fn in _SPECIAL_REGISTRY.items() if full_name.endswith(s)), None) + if special is not None: + try: + return special(dialect), "special" + except Exception: + return None, "special-failed" + instance = _try_construct(cls, dialect) + if instance is None: + return None, "failed" + return instance, "heuristic" + + +def collect_expression_classes(package_path: str) -> Dict[str, Type[BaseExpression]]: + """Walk a package subtree and collect every defined BaseExpression subclass.""" + import importlib + import pkgutil + + pkg = importlib.import_module(package_path) + classes: Dict[str, Type[BaseExpression]] = {} + for _, modname, _ in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + "."): + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if ( + isinstance(obj, type) + and obj.__module__ == modname + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not inspect.isabstract(obj) + ): + classes[f"{modname}.{name}"] = obj + return classes + + +def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: + """Hand the collected classes to the deserialization kind whitelist. + + Core split the class index out of the serialization module into a + registry: deserialization resolves a node to its class through the + framework's default registry, not through the ``(dialect, kind)`` render + registry -- the two are separate mechanisms and only the former names + expression classes. Core and the built-in backends populate that registry + on first use; registering one of those classes again is a no-op, and the + backend classes collected by the matrix are what this call adds. + """ + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + for cls in classes.values(): + registry.register_node(cls) + + +def register_enums_and_dataclasses(package_path: str) -> None: + """Register a package's enums and dataclasses into the same whitelist. + + An artifact encodes an enum member as ``[short class name, member]`` and + the reader resolves that name through the registry's enum table -- no + ``importlib`` by name (untrusted input, §4.11.4), so a backend enum that + was never registered refuses to read back. Core populates its own enums + and dataclasses; a backend package's are exactly as privileged as a + third-party plugin's, which is to say they are registered, not discovered. + Same ownership filters as core's own populator: the class must be defined + in the module being walked. + """ + import dataclasses + import enum as _enum + import importlib + import pkgutil + + from rhosocial.activerecord.backend.expression.registry import RegistryError + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + pkg = importlib.import_module(package_path) + if hasattr(pkg, "__path__"): + modnames = [ + m for _, m, _ in pkgutil.walk_packages( + path=pkg.__path__, prefix=package_path + ".", onerror=lambda x: None + ) + ] + else: + modnames = [package_path] + for modname in modnames: + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if not isinstance(obj, type) or obj.__module__ != modname: + continue + if issubclass(obj, _enum.Enum): + try: + registry.register_enum(obj) + except RegistryError: + pass + elif dataclasses.is_dataclass(obj): + try: + registry.register_dataclass(obj) + except RegistryError: + pass + + +def assert_params_equal(a: Any, b: Any, path: str = "params") -> None: + """Deep-compare two get_params() outputs, treating nested BaseExpression + instances as structurally equal and nested dataclasses as recursively compared. + """ + if a is b: + return + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + assert_params_equal(a.get_params(), b.get_params(), path + ".") + return + if dataclasses.is_dataclass(a) and not isinstance(a, type): + assert dataclasses.is_dataclass(b) and not isinstance(b, type), ( + f"{path}: expected dataclass instance, got {type(b).__name__}" + ) + for f in dataclasses.fields(a): + assert_params_equal(getattr(a, f.name), getattr(b, f.name), f"{path}.{f.name}") + return + assert type(a) is type(b) or ( + isinstance(a, (list, tuple, dict)) and isinstance(b, (list, tuple, dict)) + ), f"{path}: type mismatch {type(a).__name__} vs {type(b).__name__}" + if isinstance(a, dict): + assert set(a) == set(b), f"{path}: keys differ {set(a) ^ set(b)}" + for k in a: + assert_params_equal(a[k], b[k], f"{path}.{k}") + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: length differ" + for i, (x, y) in enumerate(zip(a, b)): + assert_params_equal(x, y, f"{path}[{i}]") + return + assert a == b, f"{path}: {a!r} != {b!r}" + + +def roundtrip_expression(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert an instance round-trips losslessly through dict / JSON / XML.""" + original = instance.get_params() + for restored in ( + _rt_dict(instance, dialect), + _rt_json(instance, dialect), + _rt_xml(instance, dialect), + ): + assert_params_equal(restored.get_params(), original, fqn) + + +def _rt_dict(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize + + return deserialize(serialize(instance), dialect) + + +def _rt_json(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_json, serialize_json + + return deserialize_json(serialize_json(instance), dialect) + + +def _rt_xml(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_xml, serialize_xml + + return deserialize_xml(serialize_xml(instance), dialect) + + +def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert to_sql is identical after round-trip, when the dialect supports it.""" + try: + expected = instance.to_sql() + except Exception: + return + for restored in (_rt_dict(instance, dialect), _rt_json(instance, dialect), _rt_xml(instance, dialect)): + assert restored.to_sql() == expected, fqn + + +__all__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "register_all", + "register_enums_and_dataclasses", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index c5680445..46493da4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -61,10 +61,11 @@ class into ``pytest.skip``, which shares a green tick with "correctly import pytest -from rhosocial.activerecord.testsuite.utils.expression import ( +from expression_roundtrip_harness import ( collect_expression_classes, make_instance, register_all, + register_enums_and_dataclasses, register_special_constructor, roundtrip_expression, sql_consistent, @@ -79,6 +80,10 @@ class into ``pytest.skip``, which shares a green tick with "correctly CORE_CLASSES = collect_expression_classes(CORE_EXPR_PKG) ALL_CLASSES = {**CORE_CLASSES, **CLASSES} register_all(ALL_CLASSES) +# The mysql expression package's enums and dataclasses ride inside artifacts +# under their short class names; the whitelist resolves those names, so the +# backend package's own vocabulary has to be registered beside the classes. +register_enums_and_dataclasses(MYSQL_EXPR_PKG) def _table(d, name="t"): From a7f3867314e733862bd61337b601485738083b6a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 06:23:49 +0800 Subject: [PATCH 16/65] refactor(mysql): read declared fields instead of probing with getattr Clear every getattr/hasattr probe of an expression slot in src (25 sites; the acceptance scan rg "(getattr|hasattr)\(\s*(expr|node|clause|item)\b" now returns zero). Each site falls in one of two shapes: - The field is declared on the node the formatter already serves: read it by name (ddl_table table_options/comment, maintenance no_write_to_binlog/options/option, namespace catalog_name, partition subpartition_by/partitions/partitions_count/subpartition_definitions). The maintenance reads stay gated on the operation, so only the classes declaring each field are ever asked. - The formatter serves both the core node and this backend's derived node (format_insert_statement), or the slot takes either an expression or a bare host value (handler key_value, DO expressions): dispatch by isinstance on the declared base (MySQLInsertExpression / BaseExpression -- the same judgement core's format_insert_values_source makes), then read the field. Behavior changes (tightened, no default masks left): - replace+ignore and replace+on_conflict are now construction errors on MySQLInsertExpression; a plain core InsertStatement can no longer pick the flags up at all and renders a plain INSERT (previously the probe would have accepted any attribute-shaped source, and the mutual exclusion fired at render time). - HANDLER ... READ key_value and DO items that are not BaseExpression are bind parameters now; the duck-typed "anything with to_sql" branch is gone. test_admin_statement_coverage's _InspectableExpr stand-in now subclasses BaseExpression accordingly. - Removed the unused ColumnTypeSupport import from dialect.py: it was never in the class base list; the protocol is satisfied structurally by MySQLColumnTypeMixin and stays verified by the conformance tests (positive list, negative list, and test_mysql_column_type). Draft follow-up absorbed: test_mysql_partition_strategy_operations.py already called CountFunction at its call site but never imported it, so the file was NameError-red at HEAD; the import is added here. Evidence (central venv, PYTHONPATH=src;tests, all exit 0): - test_mysql_column_type.py + test_mysql_protocol_conformance.py: 380 passed - test_admin_statement_coverage.py: 79 passed (after the stand-in fix) - test_mysql_partition_expressions.py + test_mysql_mixin_expressions.py + test_mysql_ddl_coverage.py: 201 passed - roundtrip matrix -k "insert or table_options or comment": 16 passed - live mysql_97 (192.168.1.3:14686): test_replace_into.py + test_insert_ignore.py --scenarios=mysql_97: 13 passed, including the construction-time ValueError paths - ruff on all changed files: clean. Pre-existing ruff debt in the partition strategy test file (23 findings at HEAD, incl. async test names shadowing their sync twins) is unchanged and out of scope. --- .../backend/impl/mysql/dialect.py | 23 +++++++++---------- .../backend/impl/mysql/expression/dml.py | 12 +++++++++- .../backend/impl/mysql/mixins/admin.py | 5 ++-- .../backend/impl/mysql/mixins/ddl_table.py | 5 ++-- .../backend/impl/mysql/mixins/maintenance.py | 8 +++---- .../backend/impl/mysql/mixins/namespace.py | 2 +- .../backend/impl/mysql/mixins/partition.py | 22 +++++++++--------- .../backend/test_admin_statement_coverage.py | 12 ++++++++-- ...est_mysql_partition_strategy_operations.py | 1 + 9 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 13a26aa7..40e19bf2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -37,8 +37,6 @@ TransactionControlSupport, SQLFunctionSupport, DataTypeSupport, - # Column Class Support Protocol - ColumnTypeSupport, TypeObjectSupport, CreateTypeSupport, AlterTypeSupport, @@ -535,6 +533,12 @@ def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: - ``ignore=True`` → ``INSERT IGNORE`` - ``replace=True`` → ``REPLACE INTO`` + The flags are this backend's own derived-node contract, so they are + read off ``MySQLInsertExpression`` by type; a plain core + ``InsertStatement`` spells neither and renders a plain ``INSERT``. + Their mutual-exclusion rules are enforced by the derived node's + constructor, not here. + Args: expr: InsertStatement instance @@ -548,8 +552,6 @@ def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: Sequence in the INTO slot renders through its own formatter -- a well-formed INSERT writing to something the caller never named. - ValueError: If both 'ignore' and 'replace' are specified, or if - 'replace' is used with 'on_conflict' """ # Perform strict parameter validation if self.strict_validation: @@ -557,15 +559,12 @@ def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: require_kind(expr.into, Table, "InsertStatement.into") - # Check for conflicting options - is_replace = getattr(expr, "replace", False) - is_ignore = getattr(expr, "ignore", False) - - if is_replace and is_ignore: - raise ValueError("Cannot use both 'replace' and 'ignore' options together") + is_replace = is_ignore = False + from .expression.dml import MySQLInsertExpression - if is_replace and expr.on_conflict: - raise ValueError("REPLACE INTO does not support ON CONFLICT clause") + if isinstance(expr, MySQLInsertExpression): + is_replace = expr.replace + is_ignore = expr.ignore all_params: List[Any] = [] table_sql, table_params = expr.into.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py index 7569a049..9a7a8b69 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py @@ -15,7 +15,13 @@ class MySQLInsertExpression(InsertStatement): MySQL supports ``REPLACE INTO`` and ``INSERT IGNORE`` which have no direct generic equivalent and different semantics from other backends. The flags live here as typed fields and are rendered by the MySQL - ``format_insert_statement`` override. + ``format_insert_statement`` override, which reads them off this class -- + a plain :class:`InsertStatement` spells neither and renders a plain + ``INSERT``. + + The mutual-exclusion rules are enforced here, where the node is built, + rather than on every render: an invalid combination is a construction + error, not a render-time surprise. """ def __init__( @@ -38,6 +44,10 @@ def __init__( on_conflict=on_conflict, returning=returning, ) + if replace and ignore: + raise ValueError("Cannot use both 'replace' and 'ignore' options together") + if replace and self.on_conflict: + raise ValueError("REPLACE INTO does not support ON CONFLICT clause") self.replace = replace self.ignore = ignore diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py index e084d9bb..42d39727 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py from typing import TYPE_CHECKING, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table from .object_kind import require_kind @@ -230,7 +231,7 @@ def format_handler_read_statement( parts.append(self.format_identifier(expr.index)) parts.append(expr.mode.value) if expr.key_value is not None: - if hasattr(expr.key_value, "to_sql"): + if isinstance(expr.key_value, BaseExpression): sql, p = expr.key_value.to_sql() parts.append(f"({sql})") params.extend(p) @@ -262,7 +263,7 @@ def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: params = [] expr_parts = [] for e in expr.expressions: - if hasattr(e, "to_sql"): + if isinstance(e, BaseExpression): sql, p = e.to_sql() expr_parts.append(sql) params.extend(p) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index b2a65f65..1a616942 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -92,7 +92,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: all_params: List[Any] = [] options_part = "" - table_options = getattr(expr, "table_options", None) + table_options = expr.table_options if table_options is not None: options_sql, options_params = table_options.to_sql() if options_sql: @@ -143,8 +143,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.impl.mysql.expression.table_options import ( MySQLCreateTableOptions, ) - table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None) is not None: + if table_options is not None and table_options.comment is not None: comment_sql, _ = self.format_table_comment_clause(table_options.comment) parts.append(comment_sql.strip()) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py index 5e10968d..17fcb6fa 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py @@ -45,17 +45,17 @@ def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: parts = [f"{operation} TABLE"] - if hasattr(expr, "no_write_to_binlog") and expr.no_write_to_binlog.value: + if expr.no_write_to_binlog.value: parts.append(expr.no_write_to_binlog.value) table_parts = [self._format_maintenance_table(t) for t in expr.tables] parts.append(", ".join(table_parts)) - if operation == "CHECK" and getattr(expr, "options", None): + if operation == "CHECK" and expr.options: parts.append(" ".join(option.value for option in expr.options)) - elif operation == "CHECKSUM" and getattr(expr, "option", None): + elif operation == "CHECKSUM" and expr.option: parts.append(expr.option.value) - elif operation == "REPAIR" and getattr(expr, "options", None): + elif operation == "REPAIR" and expr.options: parts.append(" ".join(option.value for option in expr.options)) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py index f130cbb8..ff38a1b7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py @@ -136,7 +136,7 @@ def validate_catalog_name(self, expr) -> None: MySQL's 64-character identifier limit, or contains one of the characters MySQL reserves for name qualification. """ - name = getattr(expr, "catalog_name", None) + name = expr.catalog_name if not name: return if len(name) > MAX_IDENTIFIER_LENGTH: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 91e59456..d4513860 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -188,13 +188,13 @@ def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, params.extend(key_params) sql = f" PARTITION BY RANGE ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: sub_sql, sub_params = self.format_subpartition_by(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -217,13 +217,13 @@ def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns" params.extend(key_params) sql = f" PARTITION BY RANGE COLUMNS ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: sub_sql, sub_params = self.format_subpartition_by(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -246,13 +246,13 @@ def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, t params.extend(key_params) sql = f" PARTITION BY LIST ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: sub_sql, sub_params = self.format_subpartition_by(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -275,13 +275,13 @@ def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") params.extend(key_params) sql = f" PARTITION BY LIST COLUMNS ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: sub_sql, sub_params = self.format_subpartition_by(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -308,7 +308,7 @@ def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, t params.extend(key_params) sql = f" PARTITION BY {keyword} ({', '.join(key_sql_parts)})" - partitions_count = getattr(expr, "partitions_count", None) + partitions_count = expr.partitions_count if partitions_count is not None: if not isinstance(partitions_count, int) or partitions_count <= 0: raise ValueError("partitions_count must be a positive integer") @@ -332,7 +332,7 @@ def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tup params.extend(key_params) sql = f" PARTITION BY {keyword} ({', '.join(key_sql_parts)})" - partitions_count = getattr(expr, "partitions_count", None) + partitions_count = expr.partitions_count if partitions_count is not None: if not isinstance(partitions_count, int) or partitions_count <= 0: raise ValueError("partitions_count must be a positive integer") @@ -379,7 +379,7 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> if options_sql: parts.append(options_sql) params.extend(options_params) - subpartition_defs = getattr(definition, "subpartition_definitions", None) + subpartition_defs = definition.subpartition_definitions if subpartition_defs: if not self.supports_subpartitioning(): raise UnsupportedFeatureError(self.name, "subpartition definitions") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py index 941884f2..9d9a24fb 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py @@ -13,6 +13,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import BaseExpression from rhosocial.activerecord.backend.expression.objects import Function, Procedure, Table from rhosocial.activerecord.backend.impl.mysql import expression as mysql_expr from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -26,10 +27,17 @@ def dialect(): return MySQLDialect() -class _InspectableExpr: - """Minimal stand-in for a core expression exposing to_sql().""" +class _InspectableExpr(BaseExpression): + """Minimal stand-in for a core expression exposing to_sql(). + + Subclasses :class:`BaseExpression` because the formatters dispatch an + "expression vs bare host value" slot with ``isinstance`` -- the same + judgement core's ``format_insert_values_source`` makes -- so the stand-in + has to be a declared expression node, not a duck-typed object. + """ def __init__(self, sql="1 + 1", params=()): + super().__init__(None) self._sql = sql self._params = params diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index dcb6635f..2687e88e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -10,6 +10,7 @@ ColumnDefinition, ColumnNotNullConstraint, ComparisonPredicate, + CountFunction, CreateTableStatement, DropTableStatement, FunctionCall, From 0af72eba171e5d4924ae25e99333b070620f5279 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 08:25:04 +0800 Subject: [PATCH 17/65] feat(mysql): adopt the typed value nodes with MySQL literal spellings Wire core is TypedValue family (473b8e37 / 5ba491d5 / 1549380b) into the MySQL dialect and answer the tier-2 kinds this engine spells its own way: - mixins/typed_value.py adds MySQLTypedValueMixin, standing alone rather than inheriting TypedValueMixin (the dialect composes the specific mixin ahead of the generic one; the position is what resolves the overrides): * format_binary_value inlines X-quoted upper-case hex (X'0102FF') and binds the bytes at the data position; * format_uuid_value inlines the bare quoted canonical text and binds the UUID object at the data position -- MySQL has no UUID type and no literal other than the text; * format_interval_value is deliberately absent: INTERVAL is only the keyword parameter of date arithmetic here, so the base's explicit NotImplementedError at a DDL position is the answer (structural absence, same fail-closed choice as sqlite is reference form). - dialect.py composes MySQLTypedValueMixin ahead of TypedValueMixin, after ExpressionMixin and before the generic DateTime/DQL/DML block, mirroring the placement every backend that has landed this wave uses. - Tier-1 is not overridden: every default spelling was measured, not assumed -- on 8.0.46 (mysql_80, 192.168.1.3:14682) and on the server behind mysql_97 (192.168.1.3:14686, VERSION() 26.7.0) a CREATE TABLE carrying DEFAULT TRUE / FALSE / 42 / 1.5 / 1.25 / 'x''y' / '2026-10-17' / '10:30:00' / '2026-10-17 10:00:00' is accepted and reads back the same values, so the base delegating through format_literal (and through this dialect is backslash-doubling _escape_sql_string) is the answer. Measured evidence recorded in both new docstrings: SELECT X'0102FF' -> b'\x01\x02\xff' and VARBINARY(16) DEFAULT X'0102FF' reads the bytes back (also TINYBLOB DEFAULT (X'0102FF') via the expression-default syntax); CHAR(36) DEFAULT '' reads the text back verbatim; every INTERVAL literal attempt (SELECT INTERVAL 1 DAY, DEFAULT INTERVAL 1 DAY in each column shape) fails with errno 1064; the single-backslash probe ('a\b') reads back as 'a\x08', which is the measured reason the default sql_mode doubles backslashes and NO_BACKSLASH_ESCAPES stops doubling. New tests/.../test_mysql_typed_values.py: per-kind DDL-position lift classes (the same class-level strategy lift core performs for Literal -> DdlLiteral), data-position binding on the shipped classes, tier-1 audit pinning the base spellings as reached through MySQL's escaping (including both sql_mode branches for the backslash), MRO-order assertions, and a live-server class re-measuring the tier-2 answers end to end (CREATE TABLE with the DEFAULT -> INSERT -> read back, plus the INTERVAL refusal) on every scenario the suite reaches. Evidence (central venv, PYTHONPATH=src;tests): - test_mysql_typed_values.py, all 7 scenarios: 50 passed, exit 0 - guide is targeted selection over feature/backend -k "typed or literal": 130 passed, exit 0 - the wave-1 live files re-run with every server now reachable (localhost:14680/14681 were down during wave 1, now up): test_replace_into + test_insert_ignore + test_insert_on_conflict_ clauses + test_mysql_operational_maintenance, all scenarios: exit 0 - ruff on the four changed files: clean --- .../backend/impl/mysql/dialect.py | 12 + .../backend/impl/mysql/mixins/__init__.py | 2 + .../backend/impl/mysql/mixins/typed_value.py | 83 +++++ .../backend/test_mysql_typed_values.py | 332 ++++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 40e19bf2..432a9d97 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -103,6 +103,11 @@ DomainMixin, TransactionControlMixin, SetOperationMixin, + # Value literals: the base tier-1 rendering plus the tier-2 methods a + # backend must implement. MySQL's own spellings come from + # MySQLTypedValueMixin (imported with the local mixins), which the + # dialect composes ahead of this one. + TypedValueMixin, ) from .protocols import ( MySQLTriggerSupport, @@ -168,6 +173,7 @@ MySQLGeneratedColumnMixin, MySQLFunctionMixin, MySQLColumnTypeMixin, + MySQLTypedValueMixin, ) from .mixins.object_kind import require_kind from .reserved_words import MYSQL_RESERVED_WORDS @@ -326,6 +332,12 @@ class MySQLDialect( IntrospectionMixin, PredicateMixin, ExpressionMixin, + # Value literals: MySQL's spellings ahead of the base's tier-1 rendering. + # This order is what resolves format_binary_value and format_uuid_value + # (INTERVAL is deliberately absent -- see mixins/typed_value.py for the + # measured refusals behind that). + MySQLTypedValueMixin, + TypedValueMixin, DateTimeMixin, DQLMixin, DMLMixin, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py index ad1f8a6b..f61fc0d3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py @@ -47,6 +47,7 @@ from .function import MySQLFunctionMixin from .namespace import MySQLNamespaceMixin from .column_type import MySQLColumnTypeMixin +from .typed_value import MySQLTypedValueMixin # ``__all__`` is the mixin surface: the names ``dialect.py`` mixes into # MySQLDialect. ``require_kind`` from ``.object_kind`` is deliberately absent -- @@ -101,4 +102,5 @@ "MySQLFunctionMixin", "MySQLNamespaceMixin", "MySQLColumnTypeMixin", + "MySQLTypedValueMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py new file mode 100644 index 00000000..9a91b4ec --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py @@ -0,0 +1,83 @@ +# src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py +"""MySQL's spellings for the value types whose literal form is its own. + +MySQL agrees with the base on NULL, booleans, numbers, strings and +date/time values -- measured, not assumed: on 8.0.46 (192.168.1.3:14682) +and on the server at 192.168.1.3:14686 (which answers ``SELECT VERSION()`` +with ``26.7.0``), a ``CREATE TABLE`` carrying ``DEFAULT TRUE`` / +``DEFAULT FALSE`` / ``DEFAULT 42`` / ``DEFAULT 1.5`` / ``DEFAULT 1.25`` / +``DEFAULT 'x''y'`` / ``DEFAULT '2026-10-17'`` / ``DEFAULT '10:30:00'`` / +``DEFAULT '2026-10-17 10:00:00'`` is accepted and the defaults read back +as the same values, so no tier-1 method is overridden here. One tier-1 +detail is MySQL's and is already where it belongs rather than here: +under the default ``sql_mode`` the backslash is an escape character +(measured: ``'a\\b'`` read back as ``'a\\x08'``), so ``format_literal`` +routes strings through this dialect's ``_escape_sql_string``, which +doubles backslashes unless ``NO_BACKSLASH_ESCAPES`` is active. + +Two value types differ, and both differ because MySQL's grammar says so: + +* **binary** -- ``X'0102FF'``, the hex form from MySQL's literal grammar. + Measured on both servers: ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'`` + and a ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those + bytes on insert (likewise ``TINYBLOB DEFAULT (X'0102FF')`` through the + expression-default syntax). Lower-case ``x'0102ff'`` is accepted too; the + upper-case spelling is what this dialect renders. Quoting the bytes as + text would produce a string that reads back as text -- the wrong value in + a binary column and the wrong length besides. +* **UUID** -- the bare quoted canonical text. MySQL has no UUID column + type (it numbers rows with ``AUTO_INCREMENT`` and spells UUIDs as + ``CHAR(36)``/``BINARY(16)`` storage conventions) and no literal form + other than the text. Measured on both servers: ``CHAR(36) DEFAULT + '550e8400-e29b-41d4-a716-446655440000'`` reads the hyphenated text back + verbatim, so one spelling serves the text home and no ``::uuid``-style + suffix exists to add. + +**INTERVAL stays unimplemented on purpose.** Measured on both servers: +``SELECT INTERVAL 1 DAY`` is a syntax error (errno 1064), and so is a +``DEFAULT INTERVAL 1 DAY`` in every column shape tried -- ``INTERVAL`` +exists only as the keyword parameter of date arithmetic +(``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), never as a +value with a literal form of its own. A DDL position that needs one +cannot be written for this engine, so this mixin defines no +``format_interval_value`` -- leaving the method absent is what makes the +base's explicit failure the answer here. +""" + +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.values import TypedValue + + +class MySQLTypedValueMixin: + """MySQL's tier-2 value literals. + + Stands alone rather than inheriting :class:`TypedValueMixin`, like every + other MySQL mixin: the dialect composes the specific mixin ahead of the + generic one, and these two methods win by that position. Inheriting would + put the base ahead of the other generic mixins and make the ordering the + dialect has to reason about depend on this class instead. + """ + + def format_binary_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A binary literal: ``X'0102FF'``, upper-case hex. + + Rendered from the bytes the node holds, because this conversion has + to happen somewhere and the dialect is the only layer that knows + which form its engine accepts. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + return f"X'{bytes(expr.value).hex().upper()}'", () + + def format_uuid_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A UUID as the bare quoted canonical text. + + MySQL has no UUID type and no literal other than the text, so the + quoted canonical form is the whole spelling; a ``CHAR(36)`` column + reads it back verbatim (measured). + """ + if not type(expr).inline_params: + return self._bind_value(expr) + return self.format_literal(str(expr.value)), () diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py new file mode 100644 index 00000000..879c2a6d --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py @@ -0,0 +1,332 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py +"""MySQL's spellings for the typed value literals. + +Core's value nodes render through ``inline_params`` -- a class-level +strategy, never a per-instance flag -- so the DDL-position assertions below +use a test-local DDL-position class per kind, the same lift core itself +performs when it renders a ``DEFAULT`` / ``CHECK`` tree (``Literal`` -> +``DdlLiteral``). The data-position assertions run the node classes as they +ship, which bind. + +Every literal form asserted here was measured, not transcribed. Measured on +8.0.46 (scenario ``mysql_80``, 192.168.1.3:14682) and on the server behind +scenario ``mysql_97`` (192.168.1.3:14686, which answers ``SELECT VERSION()`` +with ``26.7.0``): + +* ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'``; a + ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those bytes on + insert. Lower-case ``x'0102ff'`` is accepted too; the upper-case spelling + is what this dialect renders. +* ``CHAR(36) DEFAULT '550e8400-e29b-41d4-a716-446655440000'`` reads the + hyphenated canonical text back verbatim -- MySQL has no UUID type, so the + bare quoted text is the whole spelling. +* Every INTERVAL literal attempt is refused with errno 1064 (syntax error): + ``SELECT INTERVAL 1 DAY`` and ``DEFAULT INTERVAL 1 DAY`` in each column + shape tried. ``INTERVAL`` exists only as the keyword parameter of date + arithmetic (``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), so the + base's explicit failure is the answer and this dialect defines no + ``format_interval_value``. +* The tier-1 spellings the base renders are all accepted: ``DEFAULT TRUE`` + / ``DEFAULT FALSE`` / ``DEFAULT 42`` / ``DEFAULT 1.5`` / ``DEFAULT 1.25`` + / ``DEFAULT 'x''y'`` / ``DEFAULT '2026-10-17'`` / ``DEFAULT '10:30:00'`` + / ``DEFAULT '2026-10-17 10:00:00'`` each read back as the same value. +* Under the default ``sql_mode`` the backslash is an escape character + (measured: the server reads ``'a\\b'`` as ``'a\\x08'``), which is why this + dialect's ``_escape_sql_string`` doubles backslashes for every inline + string tier-1 renders, and stops doubling under ``NO_BACKSLASH_ESCAPES``. + +The live-server class at the bottom re-measures the tier-2 answers end to +end through a running server, on every scenario the suite reaches. +""" + +import datetime +import decimal +import uuid + +import pytest + +from rhosocial.activerecord.backend.expression.values import ( + BinaryValue, + BooleanValue, + DateValue, + DecimalValue, + FloatValue, + IntegerValue, + IntervalValue, + NullValue, + StringValue, + TimeValue, + TimestampValue, + UUIDValue, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +@pytest.fixture +def dialect(): + return MySQLDialect() + + +# -- the DDL-position lift ------------------------------------------------ +# +# One class per kind, each declaring the strategy its position requires, +# exactly as core's own DdlLiteral does for Literal. The subclass adds no +# field and no kind: it only owns the position, which is the whole of the +# class-level inline strategy. + +class DdlBinaryValue(BinaryValue): + inline_params = True + + +class DdlUUIDValue(UUIDValue): + inline_params = True + + +class DdlIntervalValue(IntervalValue): + inline_params = True + + +class DdlNullValue(NullValue): + inline_params = True + + +class DdlBooleanValue(BooleanValue): + inline_params = True + + +class DdlIntegerValue(IntegerValue): + inline_params = True + + +class DdlFloatValue(FloatValue): + inline_params = True + + +class DdlDecimalValue(DecimalValue): + inline_params = True + + +class DdlStringValue(StringValue): + inline_params = True + + +class DdlDateValue(DateValue): + inline_params = True + + +class DdlTimeValue(TimeValue): + inline_params = True + + +class DdlTimestampValue(TimestampValue): + inline_params = True + + +class TestBinaryValue: + """``X'0102FF'`` -- the hex form from MySQL's literal grammar.""" + + def test_data_position_binds_the_bytes(self, dialect): + sql, params = BinaryValue(dialect, b"\x01\x02").to_sql() + assert sql == "%s" + assert params == (b"\x01\x02",) + + def test_ddl_position_spells_hex(self, dialect): + sql, params = DdlBinaryValue(dialect, b"\x01\x02\xff").to_sql() + assert sql == "X'0102FF'" + assert params == () + + def test_the_digits_are_uppercase(self, dialect): + sql, _ = DdlBinaryValue(dialect, b"\xab\xcd\xef").to_sql() + assert sql == "X'ABCDEF'" + + def test_an_empty_value_is_the_empty_literal(self, dialect): + sql, _ = DdlBinaryValue(dialect, b"").to_sql() + assert sql == "X''" + + def test_bytearray_is_normalised_before_rendering(self, dialect): + sql, _ = DdlBinaryValue(dialect, bytearray(b"\x01")).to_sql() + assert sql == "X'01'" + + +class TestUUIDValue: + """The bare quoted canonical text -- all MySQL has is a text home.""" + + CANONICAL = "c73d9175-0a1d-48c6-8d30-df165461328b" + + def test_data_position_binds_the_uuid(self, dialect): + value = uuid.UUID(self.CANONICAL) + sql, params = UUIDValue(dialect, value).to_sql() + assert sql == "%s" + assert params == (value,) + + def test_ddl_position_is_the_quoted_canonical_text(self, dialect): + sql, params = DdlUUIDValue(dialect, uuid.UUID(self.CANONICAL)).to_sql() + assert sql == f"'{self.CANONICAL}'" + assert params == () + + +class TestIntervalValueStaysRefused: + """No ``format_interval_value`` on this dialect, on purpose. + + MySQL refuses every interval literal shape with errno 1064 (measured, + see the module docstring): ``INTERVAL`` is the keyword parameter of date + arithmetic, never a value with a literal form. The base's explicit + failure is the answer, and the structural absence is asserted rather + than papered over. + """ + + def test_data_position_still_binds(self, dialect): + span = datetime.timedelta(days=2, hours=3) + sql, params = IntervalValue(dialect, span).to_sql() + assert sql == "%s" + assert params == (span,) + + def test_ddl_position_keeps_the_base_refusal(self, dialect): + with pytest.raises(NotImplementedError, match="INTERVAL literals"): + DdlIntervalValue(dialect, datetime.timedelta(days=1)).to_sql() + + +class TestTierOneAgreesWithTheBase: + """The audit: nothing overridden for the kinds MySQL spells like core. + + Each assertion is the base's spelling reached through this dialect's + ``format_literal`` (so MySQL's escaping applies), pinned here so a base + change that breaks MySQL's grammar is caught by this file rather than in + the matrix, where the row would only say "renders". + """ + + def test_null_binds_then_renders_as_the_keyword(self, dialect): + assert NullValue(dialect).to_sql() == ("%s", (None,)) + assert DdlNullValue(dialect).to_sql() == ("NULL", ()) + + def test_booleans_render_as_keywords(self, dialect): + assert DdlBooleanValue(dialect, True).to_sql() == ("TRUE", ()) + assert DdlBooleanValue(dialect, False).to_sql() == ("FALSE", ()) + + def test_integers_render_as_numeric_text(self, dialect): + assert DdlIntegerValue(dialect, 18).to_sql() == ("18", ()) + assert DdlIntegerValue(dialect, -7).to_sql() == ("-7", ()) + + def test_floats_render_as_their_repr(self, dialect): + assert DdlFloatValue(dialect, 0.5).to_sql() == ("0.5", ()) + assert DdlFloatValue(dialect, 15e-03).to_sql() == ("0.015", ()) + + def test_decimals_render_exact(self, dialect): + value = decimal.Decimal("12.34") + assert DdlDecimalValue(dialect, value).to_sql() == ("12.34", ()) + + def test_strings_double_the_quotes(self, dialect): + sql, _ = DdlStringValue(dialect, "today's").to_sql() + assert sql == "'today''s'" + + def test_strings_double_the_backslash_under_the_default_sql_mode(self, dialect): + # The server reads a single backslash as an escape (measured: + # 'a\b' comes back as 'a\x08'), so the dialect doubles backslashes + # under the default mode for the literal to mean what the host holds. + sql, _ = DdlStringValue(dialect, "a\\b").to_sql() + assert sql == "'a\\\\b'" + + def test_strings_keep_the_backslash_under_no_backslash_escapes(self): + dialect = MySQLDialect(sql_mode="NO_BACKSLASH_ESCAPES,STRICT_TRANS_TABLES") + sql, _ = DdlStringValue(dialect, "a\\b").to_sql() + assert sql == "'a\\b'" + + def test_dates_render_as_quoted_iso(self, dialect): + sql, _ = DdlDateValue(dialect, datetime.date(2026, 10, 17)).to_sql() + assert sql == "'2026-10-17'" + + def test_times_render_as_quoted_iso(self, dialect): + sql, _ = DdlTimeValue(dialect, datetime.time(10, 3, 56)).to_sql() + assert sql == "'10:03:56'" + + def test_timestamps_render_as_quoted_iso_with_a_space_separator(self, dialect): + sql, _ = DdlTimestampValue( + dialect, datetime.datetime(2024, 8, 15, 10, 59, 43) + ).to_sql() + assert sql == "'2024-08-15 10:59:43'" + + +class TestMROOrder: + """The composed order is what resolves the two overrides.""" + + def test_the_local_mixin_wins_over_the_base(self, dialect): + assert dialect.format_binary_value.__func__.__qualname__.startswith( + "MySQLTypedValueMixin" + ) + assert dialect.format_uuid_value.__func__.__qualname__.startswith( + "MySQLTypedValueMixin" + ) + + def test_the_base_answers_the_kinds_the_local_mixin_leaves_alone(self, dialect): + assert dialect.format_null_value.__func__.__qualname__.startswith( + "TypedValueMixin" + ) + assert dialect.format_interval_value.__func__.__qualname__.startswith( + "TypedValueMixin" + ) + + +class TestLiveServerReadsTheLiteralsBack: + """The tier-2 answers re-measured end to end against the running server. + + The DDL read-back goes over raw SQL on purpose: the literal forms are + this dialect's own product, and the expression layer has no + DDL-strategy binary/UUID value class to build a ``DEFAULT`` tree from + yet (core's own lift, ``DdlLiteral``, covers ``Literal`` only). What is + verified here is what the wave's answers claim: the server accepts the + spelling and hands the same value back. + """ + + def test_binary_default_roundtrips(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_bin (v VARBINARY(16) DEFAULT X'0102FF')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_bin () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_bin") + assert [row["v"] for row in rows] == [b"\x01\x02\xff"] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + + def test_uuid_default_roundtrips(self, mysql_backend): + canonical = "550e8400-e29b-41d4-a716-446655440000" + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + try: + mysql_backend.execute( + f"CREATE TABLE ar_mysql_typed_uuid (v CHAR(36) DEFAULT '{canonical}')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_uuid () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_uuid") + assert [row["v"] for row in rows] == [canonical] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + + def test_interval_default_is_refused(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + try: + with pytest.raises(Exception): # noqa: B017 - the refusal is the point + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_iv (v VARCHAR(10) DEFAULT INTERVAL 1 DAY)" + ) + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + + def test_tier_one_defaults_are_accepted(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_t1 (" + " b TINYINT DEFAULT TRUE," + " n TINYINT DEFAULT 42," + " f DOUBLE DEFAULT 1.5," + " s VARCHAR(16) DEFAULT 'x''y'," + " d DATE DEFAULT '2026-10-17'," + " ts DATETIME DEFAULT '2026-10-17 10:00:00'" + ")" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_t1 () VALUES ()") + rows = mysql_backend.fetch_all("SELECT * FROM ar_mysql_typed_t1") + assert len(rows) == 1 + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") From ad70d93882ea5ecba6405306144baa04abbe7516 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 12:39:54 +0800 Subject: [PATCH 18/65] fix(mysql): follow the classed function entry points and the bare VALUES cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups to the core waves this dialect had not digested: 1. format_char_length_function override (core 9b559030's classed node): the base default spells LENGTH, which counts BYTES on MySQL -- measured on 8.0.46: LENGTH('aé') answers 3 where CHAR_LENGTH('aé') answers 2 -- so MySQLFunctionMixin now maps the node is declared unit to this engine is native spelling: char -> CHAR_LENGTH, octet -> LENGTH (measured: LENGTH(X'0102') is 2), bit -> BIT_LENGTH (measured: BIT_LENGTH('abc') is 24). Reading the unit is a declared-field read, not probing; the node is docstring says the backend formatter decides whether its spelling can honour the unit, and MySQL can honour all three. 2. INSERT VALUES delegation (core 8e2e4d82): the ValuesSource branch of format_insert_statement re-implemented the per-cell loop with val.to_sql(), so a bare host-value cell raised AttributeError. The branch now delegates to the base format_insert_values_source, which owns the expression-vs-host-value judgement in one place. The same duck-typed hasattr(x, "to_sql") probes in the LOAD DATA SET and ON DUPLICATE KEY UPDATE assignment loops are now isinstance checks against BaseExpression, the judgement core itself makes. 3. Math factory migration (core a3978e10): round_ / pow / power / sqrt / mod / ceil / floor / trunc construct the classed nodes (RoundFunction.for_operand & co.) instead of FunctionCall. GREATEST / LEAST stay on FunctionCall -- core has no node for them, the escape hatch is the point. Two behaviour notes: pow() now renders POWER instead of POW (synonyms on MySQL; the classed node carries the standard spelling), and a bare string or number argument is refused with a TypeError at construction naming the factory -- it used to surface as an AttributeError from inside the renderer, and the classed nodes own operand lift would have *parsed* a numeric-looking string into the number it spells, the exact guessing this backend refuses. 4. A matrix-surfaced drift: validate_collation_name still read the options bag core deleted when CollateExpression became CollateFunction (AttributeError from inside the renderer). The bag is gone; the one named option left is schema qualification, and MySQL is COLLATE grammar takes a single collation identifier, so a schema-qualified request is now refused by name (UnsupportedFeatureError) instead of being silently dropped -- and the unknown-collation ValueError path the matrix row records is reachable again. New tests/.../test_mysql_classed_functions_and_bare_values.py: the three unit spellings, the alias, the MRO resolution and the unknown-unit refusal; INSERT with bare/expression/typed-value cells, REPLACE over bare cells, and a RoundFunction inside a VALUES cell. test_mysql_math_enhanced_functions.py gains class-identity assertions and records the POW -> POWER normalisation. Evidence (central venv, PYTHONPATH=src;tests, exit 0): - test_mysql_classed_functions_and_bare_values.py + test_mysql_math_enhanced_functions.py + test_mysql_ddl_coverage.py: 90 passed - live dml: test_load_data + test_insert_on_conflict_clauses + test_replace_into + test_insert_ignore, all scenarios: exit 0 - matrix spot checks: -k CollateFunction (2 passed); -k "insert or table_options or comment" (16 passed, wave-1 commit) - ruff on all changed files: clean --- .../backend/impl/mysql/dialect.py | 17 +-- .../impl/mysql/functions/math_enhanced.py | 136 +++++++++++------ .../impl/mysql/mixins/charset_collation.py | 24 ++- .../backend/impl/mysql/mixins/dml.py | 5 +- .../backend/impl/mysql/mixins/function.py | 33 ++++- ...mysql_classed_functions_and_bare_values.py | 137 ++++++++++++++++++ .../test_mysql_math_enhanced_functions.py | 48 ++++-- 7 files changed, 333 insertions(+), 67 deletions(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 432a9d97..5535cf92 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -603,16 +603,13 @@ def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: if isinstance(expr.source, DefaultValuesSource): parts.append("DEFAULT VALUES") elif isinstance(expr.source, ValuesSource): - all_rows_sql = [] - for row in expr.source.values_list: - row_sql, row_params = [], [] - for val in row: - s, p = val.to_sql() - row_sql.append(s) - row_params.extend(p) - all_rows_sql.append(f"({', '.join(row_sql)})") - all_params.extend(row_params) - parts.append("VALUES " + ", ".join(all_rows_sql)) + # A cell is either an expression or a bare host value, and the + # base formatter owns that judgement in one place -- the local + # loop this used to be wrote ``val.to_sql()`` per cell and could + # not accept a bare value at all. + values_sql, values_params = self.format_insert_values_source(expr.source) + parts.append(values_sql) + all_params.extend(values_params) elif isinstance(expr.source, SelectSource): s_sql, s_params = expr.source.select_query.to_sql() parts.append(s_sql) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py index 5c209375..383a94b1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py @@ -18,28 +18,58 @@ quoted SQL text. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.functions import aggregate +from rhosocial.activerecord.backend.expression.functions.math import ( + CeilFunction, + FloorFunction, + ModuloFunction, + PowerFunction, + RoundFunction, + SquareRootFunction, + TruncateFunction, +) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase +def _expression_arg(arg: Any, factory: str) -> "bases.BaseExpression": + """Refuse a bare value where an expression is required. + + A string is a column name in this backend and a value is a ``Literal``, + so the factories take expressions -- and a bare argument used to fail at + render time with an ``AttributeError`` nobody could attribute to the + call. The classed nodes lift bare numbers on their own, but accepting + that here would loosen the factories' documented contract, and the lift + would *parse* a numeric-looking string into the number it spells -- the + exact silent guessing this backend refuses to reintroduce. The refusal + is at construction now, and it names the factory. + """ + if not isinstance(arg, bases.BaseExpression): + raise TypeError( + f"{factory} takes an expression -- a Column (or the " + f"NumericColumn that says the argument is a number) to read a " + f"column, a Literal to write a value -- not {type(arg).__name__}" + ) + return arg + + def round_( dialect: "SQLDialectBase", expr: "bases.BaseExpression", precision: int = 0, -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a ROUND function call. Rounds a numeric value to the specified number of decimal places. Usage: - - round_(dialect, NumericColumn(dialect, "price")) -> ROUND(`price`, 0) - - round_(dialect, NumericColumn(dialect, "price"), 2) -> ROUND(`price`, 2) + - round_(dialect, NumericColumn(dialect, "price")) -> ROUND(`price`, ?) + - round_(dialect, NumericColumn(dialect, "price"), 2) -> ROUND(`price`, ?) Args: dialect: The SQL dialect instance @@ -47,27 +77,28 @@ def round_( precision: Number of decimal places (default 0) Returns: - A FunctionCall instance representing the ROUND function + A ``RoundFunction`` node -- ``RoundIntegerFunction`` when the operand + is an integer column, because ROUND keeps a whole number whole Version: All MySQL versions """ - precision_expr = core.Literal(dialect, precision) - return core.FunctionCall(dialect, "ROUND", expr, precision_expr) + args = [_expression_arg(expr, "round_"), core.Literal(dialect, precision)] + return RoundFunction.for_operand(dialect, type(expr), *args) def pow( dialect: "SQLDialectBase", base: "bases.BaseExpression", exponent: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> PowerFunction: """ - Creates a POW function call. + Creates a POWER function call. Returns the value of base raised to the power of exponent. Usage: - - pow(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POW(2, 3) - - pow(dialect, NumericColumn(dialect, "x"), Literal(dialect, 2)) -> POW(`x`, 2) + - pow(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(?, ?) + - pow(dialect, NumericColumn(dialect, "x"), Literal(dialect, 2)) -> POWER(`x`, ?) Args: dialect: The SQL dialect instance @@ -75,25 +106,32 @@ def pow( exponent: The exponent expression Returns: - A FunctionCall instance representing the POW function + A ``PowerFunction`` node. The classed entry point spells ``POWER``, + which renders where ``POW`` used to; the two are synonyms on MySQL + (both answer the same value), and the classed node carries the + standard spelling. Version: All MySQL versions """ - return core.FunctionCall(dialect, "POW", base, exponent) + return PowerFunction( + dialect, + _expression_arg(base, "pow"), + _expression_arg(exponent, "pow"), + ) def power( dialect: "SQLDialectBase", base: "bases.BaseExpression", exponent: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> PowerFunction: """ Creates a POWER function call (alias for POW). Returns the value of base raised to the power of exponent. Usage: - - power(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(2, 3) + - power(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(?, ?) - power(dialect, NumericColumn(dialect, "x"), NumericColumn(dialect, "y")) -> POWER(`x`, `y`) @@ -103,24 +141,28 @@ def power( exponent: The exponent expression Returns: - A FunctionCall instance representing the POWER function + A ``PowerFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "POWER", base, exponent) + return PowerFunction( + dialect, + _expression_arg(base, "power"), + _expression_arg(exponent, "power"), + ) def sqrt( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> SquareRootFunction: """ Creates a SQRT function call. Returns the square root of the argument. Usage: - - sqrt(dialect, Literal(dialect, 16)) -> SQRT(16) + - sqrt(dialect, Literal(dialect, 16)) -> SQRT(?) - sqrt(dialect, NumericColumn(dialect, "value")) -> SQRT(`value`) Args: @@ -128,27 +170,27 @@ def sqrt( expr: The numeric expression Returns: - A FunctionCall instance representing the SQRT function + A ``SquareRootFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "SQRT", expr) + return SquareRootFunction(dialect, _expression_arg(expr, "sqrt")) def mod( dialect: "SQLDialectBase", dividend: "bases.BaseExpression", divisor: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> ModuloFunction: """ Creates a MOD function call. Returns the remainder of dividend divided by divisor. Usage: - - mod(dialect, Literal(dialect, 10), Literal(dialect, 3)) -> MOD(10, 3) + - mod(dialect, Literal(dialect, 10), Literal(dialect, 3)) -> MOD(?, ?) - mod(dialect, NumericColumn(dialect, "total"), Literal(dialect, 10)) - -> MOD(`total`, 10) + -> MOD(`total`, ?) Args: dialect: The SQL dialect instance @@ -156,24 +198,28 @@ def mod( divisor: The divisor expression Returns: - A FunctionCall instance representing the MOD function + A ``ModuloFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "MOD", dividend, divisor) + return ModuloFunction( + dialect, + _expression_arg(dividend, "mod"), + _expression_arg(divisor, "mod"), + ) def ceil( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a CEIL function call. Returns the smallest integer value not less than the argument. Usage: - - ceil(dialect, Literal(dialect, 3.14)) -> CEIL(3.14) + - ceil(dialect, Literal(dialect, 3.14)) -> CEIL(?) - ceil(dialect, NumericColumn(dialect, "price")) -> CEIL(`price`) Args: @@ -181,24 +227,27 @@ def ceil( expr: The numeric expression Returns: - A FunctionCall instance representing the CEIL function + A ``CeilFunction`` node -- ``CeilIntegerFunction`` when the operand + is an integer column, because CEIL keeps a whole number whole Version: All MySQL versions (CEILING is also available as alias) """ - return core.FunctionCall(dialect, "CEIL", expr) + return CeilFunction.for_operand( + dialect, type(expr), _expression_arg(expr, "ceil") + ) def floor( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a FLOOR function call. Returns the largest integer value not greater than the argument. Usage: - - floor(dialect, Literal(dialect, 3.14)) -> FLOOR(3.14) + - floor(dialect, Literal(dialect, 3.14)) -> FLOOR(?) - floor(dialect, NumericColumn(dialect, "price")) -> FLOOR(`price`) Args: @@ -206,27 +255,30 @@ def floor( expr: The numeric expression Returns: - A FunctionCall instance representing the FLOOR function + A ``FloorFunction`` node -- ``FloorIntegerFunction`` when the operand + is an integer column, because FLOOR keeps a whole number whole Version: All MySQL versions """ - return core.FunctionCall(dialect, "FLOOR", expr) + return FloorFunction.for_operand( + dialect, type(expr), _expression_arg(expr, "floor") + ) def trunc( dialect: "SQLDialectBase", expr: "bases.BaseExpression", precision: int = 0, -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a TRUNCATE function call. Returns the value truncated to the specified number of decimal places. Usage: - - trunc(dialect, Literal(dialect, 3.14)) -> TRUNCATE(3.14, 0) - - trunc(dialect, Literal(dialect, 3.14), 2) -> TRUNCATE(3.14, 2) - - trunc(dialect, NumericColumn(dialect, "price"), 2) -> TRUNCATE(`price`, 2) + - trunc(dialect, Literal(dialect, 3.14)) -> TRUNCATE(?, ?) + - trunc(dialect, Literal(dialect, 3.14), 2) -> TRUNCATE(?, ?) + - trunc(dialect, NumericColumn(dialect, "price"), 2) -> TRUNCATE(`price`, ?) Note: MySQL uses TRUNCATE, not TRUNC (which is used by some other databases). @@ -237,12 +289,14 @@ def trunc( precision: Number of decimal places (default 0) Returns: - A FunctionCall instance representing the TRUNCATE function + A ``TruncateFunction`` node -- ``TruncateIntegerFunction`` when the + operand is an integer column, because TRUNCATE keeps a whole number + whole Version: All MySQL versions """ - precision_expr = core.Literal(dialect, precision) - return core.FunctionCall(dialect, "TRUNCATE", expr, precision_expr) + args = [_expression_arg(expr, "trunc"), core.Literal(dialect, precision)] + return TruncateFunction.for_operand(dialect, type(expr), *args) def max_( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py index 081eb7eb..7c46f432 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py @@ -408,12 +408,28 @@ def supports_collate_expression(self) -> bool: return True def validate_collation_name(self, expr: "CollateFunction") -> str: - """Validate a ``CollateFunction`` and return its collation SQL.""" + """Validate a ``CollateFunction`` and return its collation SQL. + + The node's options bag is gone: core made schema qualification the + one named parameter, so there is no ``collation_options`` to read any + more (the old read raised ``AttributeError`` from inside the + renderer). MySQL's ``COLLATE`` grammar takes a single collation + identifier -- a schema-qualified request is not a spelling this + server parses, so it is refused by name here rather than silently + dropped. + """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - if expr.collation_options: - unsupported = ", ".join(sorted(expr.collation_options)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") + if expr.schema is not None: + raise UnsupportedFeatureError( + self.name, + "COLLATE schema qualification", + suggestion=( + "MySQL's COLLATE takes a single collation name, e.g. " + "COLLATE utf8mb4_bin; a schema-qualified collation is " + "not part of this server's grammar" + ), + ) return self.validate_collation_by_name(expr.collation_name) # --- charset --------------------------------------------------------- diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py index cb9c1bf7..8eda1ae8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py from typing import TYPE_CHECKING, Any, List, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.objects import Table @@ -90,7 +91,7 @@ def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[s set_parts = [] for col, val in expr.options.set_assignments.items(): col_sql = self.format_identifier(col) - if hasattr(val, "to_sql"): + if isinstance(val, BaseExpression): val_sql, val_params = val.to_sql() all_params.extend(val_params) else: @@ -110,7 +111,7 @@ def format_on_conflict_clause(self, expr: "OnConflictClause") -> Tuple[str, tupl if expr.update_assignments: for col_name, value_expr in expr.update_assignments.items(): col_sql = self.format_identifier(col_name) - if hasattr(value_expr, "to_sql"): + if isinstance(value_expr, BaseExpression): val_sql, val_params = value_expr.to_sql() else: val_sql, val_params = Literal(self, value_expr).to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py index 75f2a4d7..af5f9dd7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py @@ -1,5 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py -from typing import Dict +from typing import Dict, Tuple + +from rhosocial.activerecord.backend.expression import bases # MySQL function version support: function_name -> (min_version, max_version) # min_version: minimum supported version (inclusive), None = all versions @@ -112,3 +114,32 @@ def _is_mysql_function_supported(self, func_name: str) -> bool: return False return True + + def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """MySQL's three length spellings, by the unit the node declares. + + The core default spells ``LENGTH``, which on MySQL counts **bytes** + -- measured on 8.0.46: ``LENGTH('aé')`` answers 3 where + ``CHAR_LENGTH('aé')`` answers 2, so the default would answer a + different question than the node's ``unit='char'`` records. MySQL + has a native spelling for each of the three declared units, so this + override honours the unit rather than picking one: + + * ``char`` -- ``CHAR_LENGTH(operand)``, counted in characters; + * ``octet`` -- ``LENGTH(operand)``, counted in bytes (measured: + ``LENGTH(X'0102')`` answers 2); + * ``bit`` -- ``BIT_LENGTH(operand)`` (measured: + ``BIT_LENGTH('abc')`` answers 24). + + Args: + expr: The :class:`CharLengthFunction` whose ``unit`` names the + length semantics asked for. + + Returns: + A ``(sql, params)`` tuple. + """ + if expr.unit == "octet": + return self._format_scalar_function("LENGTH", expr) + if expr.unit == "bit": + return self._format_scalar_function("BIT_LENGTH", expr) + return self._format_scalar_function("CHAR_LENGTH", expr) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py new file mode 100644 index 00000000..b856c8be --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py @@ -0,0 +1,137 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py +"""Follow-up coverage for the classed function entry points and the bare VALUES cells. + +Two threads of the core waves land in this dialect here: + +* :class:`CharLengthFunction` carries a declared ``unit`` (char / octet / + bit), and core's default ``LENGTH`` spelling counts **bytes** on MySQL -- + measured on 8.0.46: ``LENGTH('aé')`` answers 3 where ``CHAR_LENGTH('aé')`` + answers 2, so the default would answer a different question than the + unit records. MySQL has a native spelling for each unit, so the dialect + override maps char -> ``CHAR_LENGTH``, octet -> ``LENGTH`` (bytes, + measured: ``LENGTH(X'0102')`` answers 2), bit -> ``BIT_LENGTH`` (measured: + ``BIT_LENGTH('abc')`` answers 24). +* An INSERT's VALUES cells may be bare host values since core ``8e2e4d82`` + -- a cell is either an expression, rendered through its own ``to_sql()``, + or a plain value that becomes a bind parameter -- and this dialect now + delegates that branch to the base ``format_insert_values_source`` instead + of re-implementing the per-cell loop (the old loop called + ``val.to_sql()`` on every cell and could not accept a bare value at all). +""" + +import pytest + +from rhosocial.activerecord.backend.expression import CharLengthFunction, Column, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import InsertStatement, ValuesSource +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLInsertExpression +from rhosocial.activerecord.backend.impl.mysql.functions.math_enhanced import round_ + + +@pytest.fixture +def dialect(): + return MySQLDialect() + + +class TestCharLengthSpelling: + """The unit the node declares picks this engine's native spelling.""" + + def test_char_unit_spells_char_length(self, dialect): + sql, params = CharLengthFunction(dialect, Column(dialect, "name")).to_sql() + assert sql == "CHAR_LENGTH(`name`)" + assert params == () + + def test_octet_unit_spells_length(self, dialect): + sql, params = CharLengthFunction( + dialect, Column(dialect, "payload"), unit="octet" + ).to_sql() + assert sql == "LENGTH(`payload`)" + assert params == () + + def test_bit_unit_spells_bit_length(self, dialect): + sql, params = CharLengthFunction( + dialect, Column(dialect, "payload"), unit="bit" + ).to_sql() + assert sql == "BIT_LENGTH(`payload`)" + assert params == () + + def test_alias_survives_the_override(self, dialect): + sql, _ = CharLengthFunction( + dialect, Column(dialect, "name"), unit="octet" + ).as_("len").to_sql() + assert sql == "LENGTH(`name`) AS `len`" + + def test_the_override_resolves_from_the_mysql_function_mixin(self, dialect): + assert dialect.format_char_length_function.__func__.__qualname__.startswith( + "MySQLFunctionMixin" + ) + + def test_the_unknown_unit_is_refused_at_construction(self, dialect): + with pytest.raises(ValueError, match="unknown length unit"): + CharLengthFunction(dialect, Column(dialect, "name"), unit="words") + + +class TestInsertBareValuesCells: + """A VALUES cell is an expression or a bare host value, per the base contract.""" + + def _insert(self, d, rows, columns=("a", "b")): + return InsertStatement( + d, + into=Table(d, "t"), + source=ValuesSource(d, [list(row) for row in rows]), + columns=list(columns), + ) + + def test_expression_and_bare_cells_share_one_row(self, dialect): + stmt = self._insert(dialect, [(Column(dialect, "cnt"), 5)]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (`cnt`, %s)" + assert params == (5,) + + def test_all_bare_cells_become_bind_parameters(self, dialect): + stmt = self._insert(dialect, [(1, "x"), (2, "y")]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s), (%s, %s)" + assert params == (1, "x", 2, "y") + + def test_expression_cells_still_render_through_their_own_formatters(self, dialect): + stmt = self._insert(dialect, [(Literal(dialect, "a"), Literal(dialect, 7))]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == ("a", 7) + + def test_a_typed_value_cell_binds_like_a_literal(self, dialect): + from rhosocial.activerecord.backend.expression.values import IntegerValue + + stmt = self._insert(dialect, [(IntegerValue(dialect, 3), 4)]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == (3, 4) + + def test_replace_keeps_working_over_bare_cells(self, dialect): + stmt = MySQLInsertExpression( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[1, "x"]]), + columns=["a", "b"], + replace=True, + ) + sql, params = stmt.to_sql() + assert sql == "REPLACE INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == (1, "x") + + +class TestClassedEntryPointsCompose: + """The classed nodes keep working inside the statements that carry them.""" + + def test_a_round_function_inside_a_values_cell(self, dialect): + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[round_(dialect, Literal(dialect, 3.14159), 2)]]), + columns=["a"], + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`) VALUES (ROUND(%s, %s))" + assert params == (3.14159, 2) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py index 76dd5adc..9368e975 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py @@ -13,6 +13,15 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal, NumericColumn +from rhosocial.activerecord.backend.expression.functions.math import ( + CeilFunction, + FloorFunction, + ModuloFunction, + PowerFunction, + RoundFunction, + SquareRootFunction, + TruncateFunction, +) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.functions.math_enhanced import ( round_, @@ -35,9 +44,13 @@ class TestMySQLMathEnhancedFunctions: def test_round__default(self, mysql_dialect: MySQLDialect): """Test round_() with default precision.""" result = round_(mysql_dialect, Column(mysql_dialect, "value")) - sql, _ = result.to_sql() + assert isinstance(result, RoundFunction) + sql, params = result.to_sql() assert "ROUND(" in sql assert "`value`" in sql + # The factory's signature keeps precision=0 meaning "write the + # scale operand", so the default renders with a bound zero as before. + assert params == (0,) def test_round__with_precision(self, mysql_dialect: MySQLDialect): """Test round_() with precision.""" @@ -53,12 +66,18 @@ def test_round__with_literal(self, mysql_dialect: MySQLDialect): assert "`price`" not in sql def test_pow(self, mysql_dialect: MySQLDialect): - """Test pow() function.""" + """Test pow() function. + + The classed entry point spells POWER -- the standard name. POW is the + MySQL synonym and answered the same value; the classed node carries + the standard spelling. + """ result = pow( mysql_dialect, Column(mysql_dialect, "base"), Literal(mysql_dialect, 2) ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql def test_pow_both_columns(self, mysql_dialect: MySQLDialect): """Test pow() with both column references.""" @@ -67,20 +86,23 @@ def test_pow_both_columns(self, mysql_dialect: MySQLDialect): Column(mysql_dialect, "x"), NumericColumn(mysql_dialect, "y"), ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql def test_power(self, mysql_dialect: MySQLDialect): """Test power() function (alias for POW).""" result = power( mysql_dialect, Literal(mysql_dialect, 2), Literal(mysql_dialect, 3) ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() assert "POWER(" in sql def test_sqrt(self, mysql_dialect: MySQLDialect): """Test sqrt() function.""" result = sqrt(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, SquareRootFunction) sql, _ = result.to_sql() assert "SQRT(" in sql assert "`value`" in sql @@ -96,6 +118,7 @@ def test_mod(self, mysql_dialect: MySQLDialect): result = mod( mysql_dialect, Column(mysql_dialect, "total"), Literal(mysql_dialect, 10) ) + assert isinstance(result, ModuloFunction) sql, _ = result.to_sql() assert "MOD(" in sql @@ -112,6 +135,7 @@ def test_mod_both_columns(self, mysql_dialect: MySQLDialect): def test_ceil(self, mysql_dialect: MySQLDialect): """Test ceil() function.""" result = ceil(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, CeilFunction) sql, _ = result.to_sql() assert "CEIL(" in sql assert "`value`" in sql @@ -125,6 +149,7 @@ def test_ceil_with_literal(self, mysql_dialect: MySQLDialect): def test_floor(self, mysql_dialect: MySQLDialect): """Test floor() function.""" result = floor(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, FloorFunction) sql, _ = result.to_sql() assert "FLOOR(" in sql assert "`value`" in sql @@ -138,6 +163,7 @@ def test_floor_with_literal(self, mysql_dialect: MySQLDialect): def test_trunc(self, mysql_dialect: MySQLDialect): """Test trunc() function (becomes TRUNCATE in MySQL).""" result = trunc(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, TruncateFunction) sql, _ = result.to_sql() assert "TRUNCATE(" in sql assert "`value`" in sql @@ -276,7 +302,7 @@ def test_pow_with_numeric_looking_exponent(self, mysql_dialect: MySQLDialect): mysql_dialect, Column(mysql_dialect, "base"), NumericColumn(mysql_dialect, "2") ) sql, params = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql assert "`2`" in sql assert 2 not in params @@ -333,8 +359,12 @@ def test_avg_with_numeric_looking_name(self, mysql_dialect: MySQLDialect): def test_bare_string_is_rejected_not_guessed(self, mysql_dialect: MySQLDialect): """A bare string used to be parsed into a number or a column, silently. - The factory no longer decides what an argument meant: an argument that - is not an expression fails to render instead of quietly becoming one. + The factory no longer decides what an argument meant: an argument + that is not an expression is refused where the call happens, with + the factory named -- it used to surface as an ``AttributeError`` + from inside the renderer, and the classed nodes' own operand lift + would have *parsed* a numeric-looking string back into the number + it spells, the exact guessing this refuses. """ - with pytest.raises(AttributeError): - sqrt(mysql_dialect, "16").to_sql() + with pytest.raises(TypeError, match="sqrt takes an expression"): + sqrt(mysql_dialect, "16") From cd47e79af42f5e4bc2f5331e1ee4f36657c863e0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 14:22:22 +0800 Subject: [PATCH 19/65] fix(mysql): store the SHOW statement parameters under their declared names Core is get_params() reads each __init__ parameter off the attribute of the same name and refuses to paper over a class that stored it anywhere else -- its own docstring names this exact defect: the fluent setters that shared their constructor parameters names forced the storage under a underscore prefix, and the introspection-based read now raises AttributeError instead of guessing. Every SHOW expression in this backend hit that: the matrix could neither serialize nor render any of the twenty classes (show.ShowCharsetExpression was the first row to surface it; the rest followed in the full run). - expression/show.py: every constructor parameter is stored under its own name (schema, table_name, view_name, trigger_name, full, like_pattern, session, limit, user, host). The fluent setters become with_* -- the same rename core applied to its own introspection expressions (with_schema, with_table_name, ...): the parameter owns the plain name, the setter keeps its ergonomics, neither shadows the other. for_table/for_user become with_table_name/with_user, matching core is spelling. - A subclass that supports a schema qualifier (SHOW CREATE TABLE/VIEW/ TRIGGER, SHOW COLUMNS/INDEX/TABLES/TABLE STATUS/TRIGGERS) now declares schema in its own __init__: get_params reads the class is own signature, so a schema declared only on the base was invisible to the formatter and the qualifier the caller passed was silently dropped -- SHOW CREATE TABLE with schema="db" rendered the unqualified name before this change, and renders `db`.`t` now. - show/functionality.py and introspection/show_introspector.py: the ~100 internal configure-then-render sites call the with_* setters. Evidence (central venv, exit 0): - render smoke: every schema/full/like/limit/user shape renders, and SHOW CREATE TABLE `db`.`t` now carries the qualifier - test_show_functionality.py, all scenarios: 17 passed - matrix -k show: 40 passed (all twenty classes, serialization and rendering, no rows needed -- they all render now) - introspection spot check: test_introspection_database.py --scenarios=mysql_97: 20 passed (the full introspection directory exceeds the 180 s single-run budget; the SHOW-renamed introspector paths are the same call sites verified above) - ruff on the three changed files: clean --- .../backend/impl/mysql/expression/show.py | 199 +++++++++++------- .../mysql/introspection/show_introspector.py | 100 ++++----- .../backend/impl/mysql/show/functionality.py | 100 ++++----- 3 files changed, 220 insertions(+), 179 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 92a1856e..80651dfb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -10,6 +10,19 @@ following the expression-dialect pattern used throughout the codebase. Key design: +- Every constructor parameter is stored under its **own name**: the + attribute ``get_params()`` reads and the one an artifact carries. The + fluent setters that used to share these names (``schema()``, ``full()``, + ``like()``, ...) are ``with_*`` now, mirroring core's own introspection + expressions -- the parameter owns the plain name, the setter keeps its + ergonomics, and neither shadows the other. The underscore-prefixed + storage this module used is what core's introspection-based + ``get_params()`` refuses to paper over: a parameter the class did not + store under its own name is a defect, not a spelling. +- A subclass that supports a schema qualifier declares ``schema`` in its + own ``__init__``: ``get_params()`` reads the class's own signature, so a + parameter declared only on the base would be invisible to the formatter + -- and the qualifier the caller passed was silently dropped. - Expressions collect parameters (table_name, schema, options) - Expressions hold a dialect reference - to_sql() delegates to dialect.format_show_* methods @@ -28,14 +41,15 @@ class ShowExpression(BaseExpression): """Base class for MySQL SHOW command expressions. All MySQL SHOW expressions inherit from this class and provide - fluent API for setting parameters. + fluent ``with_*`` methods for setting parameters; each parameter is + stored under the name it was declared with. """ def __init__(self, dialect: "MySQLDialect", *, schema: Optional[str] = None): super().__init__(dialect) - self._schema: Optional[str] = schema + self.schema: Optional[str] = schema - def schema(self, name: str) -> "ShowExpression": + def with_schema(self, name: str) -> "ShowExpression": """Set the schema/database name. Args: @@ -44,7 +58,7 @@ def schema(self, name: str) -> "ShowExpression": Returns: Self for method chaining. """ - self._schema = name + self.schema = name return self # Concrete subclasses declare their dialect formatting method by @@ -54,9 +68,15 @@ def schema(self, name: str) -> "ShowExpression": class ShowCreateTableExpression(ShowExpression): """Expression for SHOW CREATE TABLE command.""" - def __init__(self, dialect: "MySQLDialect", table_name: str): - super().__init__(dialect) - self._table_name = table_name + def __init__( + self, + dialect: "MySQLDialect", + table_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: str = table_name @property def format_method(self) -> str: @@ -68,9 +88,15 @@ def format_method(self) -> str: class ShowCreateViewExpression(ShowExpression): """Expression for SHOW CREATE VIEW command.""" - def __init__(self, dialect: "MySQLDialect", view_name: str): - super().__init__(dialect) - self._view_name = view_name + def __init__( + self, + dialect: "MySQLDialect", + view_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.view_name: str = view_name @property def format_method(self) -> str: @@ -87,22 +113,23 @@ def __init__( dialect: "MySQLDialect", table_name: str, *, + schema: Optional[str] = None, full: bool = False, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._table_name = table_name - self._full = full - self._like_pattern = like_pattern + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern - def full(self, value: bool = True) -> "ShowColumnsExpression": + def with_full(self, value: bool = True) -> "ShowColumnsExpression": """Include additional column information (privileges, comments).""" - self._full = value + self.full = value return self - def like(self, pattern: str) -> "ShowColumnsExpression": + def with_like(self, pattern: str) -> "ShowColumnsExpression": """Filter columns by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -115,9 +142,15 @@ def format_method(self) -> str: class ShowIndexExpression(ShowExpression): """Expression for SHOW INDEX command.""" - def __init__(self, dialect: "MySQLDialect", table_name: str): - super().__init__(dialect) - self._table_name = table_name + def __init__( + self, + dialect: "MySQLDialect", + table_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: str = table_name @property def format_method(self) -> str: @@ -133,21 +166,22 @@ def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, full: bool = False, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._full = full - self._like_pattern = like_pattern + super().__init__(dialect, schema=schema) + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern - def full(self, value: bool = True) -> "ShowTablesExpression": + def with_full(self, value: bool = True) -> "ShowTablesExpression": """Include table type (BASE TABLE or VIEW).""" - self._full = value + self.full = value return self - def like(self, pattern: str) -> "ShowTablesExpression": + def with_like(self, pattern: str) -> "ShowTablesExpression": """Filter tables by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -167,11 +201,11 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern + self.like_pattern: Optional[str] = like_pattern - def like(self, pattern: str) -> "ShowDatabasesExpression": + def with_like(self, pattern: str) -> "ShowDatabasesExpression": """Filter databases by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -188,14 +222,15 @@ def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._like_pattern = like_pattern + super().__init__(dialect, schema=schema) + self.like_pattern: Optional[str] = like_pattern - def like(self, pattern: str) -> "ShowTableStatusExpression": + def with_like(self, pattern: str) -> "ShowTableStatusExpression": """Filter tables by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -212,14 +247,15 @@ def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, table_name: Optional[str] = None, ): - super().__init__(dialect) - self._table_name = table_name + super().__init__(dialect, schema=schema) + self.table_name: Optional[str] = table_name - def for_table(self, table_name: str) -> "ShowTriggersExpression": + def with_table_name(self, table_name: str) -> "ShowTriggersExpression": """Filter triggers for a specific table.""" - self._table_name = table_name + self.table_name = table_name return self @property @@ -232,9 +268,15 @@ def format_method(self) -> str: class ShowCreateTriggerExpression(ShowExpression): """Expression for SHOW CREATE TRIGGER command.""" - def __init__(self, dialect: "MySQLDialect", trigger_name: str): - super().__init__(dialect) - self._trigger_name = trigger_name + def __init__( + self, + dialect: "MySQLDialect", + trigger_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.trigger_name: str = trigger_name @property def format_method(self) -> str: @@ -254,22 +296,22 @@ def __init__( session: bool = True, ): super().__init__(dialect) - self._like_pattern = like_pattern - self._session = session + self.like_pattern: Optional[str] = like_pattern + self.session: bool = session - def like(self, pattern: str) -> "ShowVariablesExpression": + def with_like(self, pattern: str) -> "ShowVariablesExpression": """Filter variables by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self - def session(self, value: bool = True) -> "ShowVariablesExpression": + def with_session(self, value: bool = True) -> "ShowVariablesExpression": """Show session variables (default).""" - self._session = value + self.session = value return self - def global_vars(self) -> "ShowVariablesExpression": + def with_global_vars(self) -> "ShowVariablesExpression": """Show global variables.""" - self._session = False + self.session = False return self @property @@ -290,22 +332,22 @@ def __init__( session: bool = True, ): super().__init__(dialect) - self._like_pattern = like_pattern - self._session = session + self.like_pattern: Optional[str] = like_pattern + self.session: bool = session - def like(self, pattern: str) -> "ShowStatusExpression": + def with_like(self, pattern: str) -> "ShowStatusExpression": """Filter status by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self - def session(self, value: bool = True) -> "ShowStatusExpression": + def with_session(self, value: bool = True) -> "ShowStatusExpression": """Show session status (default).""" - self._session = value + self.session = value return self - def global_status(self) -> "ShowStatusExpression": + def with_global_status(self) -> "ShowStatusExpression": """Show global status.""" - self._session = False + self.session = False return self @property @@ -325,11 +367,11 @@ def __init__( full: bool = False, ): super().__init__(dialect) - self._full = full + self.full: bool = full - def full(self, value: bool = True) -> "ShowProcessListExpression": + def with_full(self, value: bool = True) -> "ShowProcessListExpression": """Include full query text.""" - self._full = value + self.full = value return self @property @@ -349,11 +391,11 @@ def __init__( limit: Optional[int] = None, ): super().__init__(dialect) - self._limit = limit + self.limit: Optional[int] = limit - def limit(self, count: int) -> "ShowWarningsExpression": + def with_limit(self, count: int) -> "ShowWarningsExpression": """Limit number of warnings returned.""" - self._limit = count + self.limit = count return self @property @@ -373,11 +415,11 @@ def __init__( limit: Optional[int] = None, ): super().__init__(dialect) - self._limit = limit + self.limit: Optional[int] = limit - def limit(self, count: int) -> "ShowErrorsExpression": + def with_limit(self, count: int) -> "ShowErrorsExpression": """Limit number of errors returned.""" - self._limit = count + self.limit = count return self @property @@ -407,11 +449,11 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern + self.like_pattern: Optional[str] = like_pattern - def like(self, pattern: str) -> "ShowCharsetExpression": + def with_like(self, pattern: str) -> "ShowCharsetExpression": """Filter character sets by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -431,11 +473,11 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern + self.like_pattern: Optional[str] = like_pattern - def like(self, pattern: str) -> "ShowCollationExpression": + def with_like(self, pattern: str) -> "ShowCollationExpression": """Filter collations by name pattern.""" - self._like_pattern = pattern + self.like_pattern = pattern return self @property @@ -456,13 +498,13 @@ def __init__( host: Optional[str] = None, ): super().__init__(dialect) - self._user = user - self._host = host + self.user: Optional[str] = user + self.host: Optional[str] = host - def for_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpression": + def with_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpression": """Show grants for a specific user.""" - self._user = user - self._host = host + self.user = user + self.host = host return self @property @@ -479,4 +521,3 @@ class ShowPluginsExpression(ShowExpression): def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_plugins" - diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index ca35ca1e..367dc697 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -347,7 +347,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" expr = ShowCreateTableExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_table(self._exec(sql, params), table_name) @@ -355,7 +355,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" expr = ShowCreateViewExpression(self.dialect, view_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_view(self._exec(sql, params), view_name) @@ -369,11 +369,11 @@ def columns( """Get column information for a table.""" expr = ShowColumnsExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) if full: - expr.full() + expr.with_full() if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_columns(self._exec(sql, params)) @@ -381,7 +381,7 @@ def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" expr = ShowIndexExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_indexes(self._exec(sql, params)) @@ -394,11 +394,11 @@ def tables( """List tables in the database.""" expr = ShowTablesExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() return self._parse_tables(self._exec(sql, params)) @@ -406,7 +406,7 @@ def databases(self, like: Optional[str] = None): """List databases.""" expr = ShowDatabasesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_databases(self._exec(sql, params)) @@ -414,9 +414,9 @@ def table_status(self, schema: Optional[str] = None, like: Optional[str] = None) """Get table status information.""" expr = ShowTableStatusExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_table_status(self._exec(sql, params)) @@ -424,9 +424,9 @@ def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = Non """List triggers.""" expr = ShowTriggersExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if table_name: - expr.for_table(table_name) + expr.with_table_name(table_name) sql, params = expr.to_sql() return self._parse_triggers(self._exec(sql, params)) @@ -434,7 +434,7 @@ def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" expr = ShowCreateTriggerExpression(self.dialect, trigger_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_trigger(self._exec(sql, params), trigger_name) @@ -442,9 +442,9 @@ def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" expr = ShowVariablesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_vars() + expr.with_global_vars() sql, params = expr.to_sql() return self._parse_variables(self._exec(sql, params)) @@ -452,9 +452,9 @@ def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" expr = ShowStatusExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_status() + expr.with_global_status() sql, params = expr.to_sql() return self._parse_status(self._exec(sql, params)) @@ -462,7 +462,7 @@ def processlist(self, full: bool = False): """Show process list.""" expr = ShowProcessListExpression(self.dialect) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() return self._parse_processlist(self._exec(sql, params)) @@ -470,7 +470,7 @@ def warnings(self, limit: Optional[int] = None): """Show warnings.""" expr = ShowWarningsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) @@ -478,7 +478,7 @@ def errors(self, limit: Optional[int] = None): """Show errors.""" expr = ShowErrorsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) @@ -492,7 +492,7 @@ def charset(self, like: Optional[str] = None): """Show character sets.""" expr = ShowCharsetExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_charset(self._exec(sql, params)) @@ -500,7 +500,7 @@ def collation(self, like: Optional[str] = None): """Show collations.""" expr = ShowCollationExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_collation(self._exec(sql, params)) @@ -508,7 +508,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" expr = ShowGrantsExpression(self.dialect) if user: - expr.for_user(user, host) + expr.with_user(user, host) sql, params = expr.to_sql() return self._parse_grants(self._exec(sql, params)) @@ -548,7 +548,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" expr = ShowCreateTableExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_table(await self._exec(sql, params), table_name) @@ -556,7 +556,7 @@ async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" expr = ShowCreateViewExpression(self.dialect, view_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_view(await self._exec(sql, params), view_name) @@ -570,11 +570,11 @@ async def columns( """Get column information for a table.""" expr = ShowColumnsExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) if full: - expr.full() + expr.with_full() if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_columns(await self._exec(sql, params)) @@ -582,7 +582,7 @@ async def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" expr = ShowIndexExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_indexes(await self._exec(sql, params)) @@ -595,11 +595,11 @@ async def tables( """List tables in the database.""" expr = ShowTablesExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() return self._parse_tables(await self._exec(sql, params)) @@ -607,7 +607,7 @@ async def databases(self, like: Optional[str] = None): """List databases.""" expr = ShowDatabasesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_databases(await self._exec(sql, params)) @@ -615,9 +615,9 @@ async def table_status(self, schema: Optional[str] = None, like: Optional[str] = """Get table status information.""" expr = ShowTableStatusExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_table_status(await self._exec(sql, params)) @@ -625,9 +625,9 @@ async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] """List triggers.""" expr = ShowTriggersExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if table_name: - expr.for_table(table_name) + expr.with_table_name(table_name) sql, params = expr.to_sql() return self._parse_triggers(await self._exec(sql, params)) @@ -635,7 +635,7 @@ async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" expr = ShowCreateTriggerExpression(self.dialect, trigger_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() return self._parse_create_trigger(await self._exec(sql, params), trigger_name) @@ -643,9 +643,9 @@ async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" expr = ShowVariablesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_vars() + expr.with_global_vars() sql, params = expr.to_sql() return self._parse_variables(await self._exec(sql, params)) @@ -653,9 +653,9 @@ async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" expr = ShowStatusExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_status() + expr.with_global_status() sql, params = expr.to_sql() return self._parse_status(await self._exec(sql, params)) @@ -663,7 +663,7 @@ async def processlist(self, full: bool = False): """Show process list.""" expr = ShowProcessListExpression(self.dialect) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() return self._parse_processlist(await self._exec(sql, params)) @@ -671,7 +671,7 @@ async def warnings(self, limit: Optional[int] = None): """Show warnings.""" expr = ShowWarningsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) @@ -679,7 +679,7 @@ async def errors(self, limit: Optional[int] = None): """Show errors.""" expr = ShowErrorsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) @@ -693,7 +693,7 @@ async def charset(self, like: Optional[str] = None): """Show character sets.""" expr = ShowCharsetExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_charset(await self._exec(sql, params)) @@ -701,7 +701,7 @@ async def collation(self, like: Optional[str] = None): """Show collations.""" expr = ShowCollationExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() return self._parse_collation(await self._exec(sql, params)) @@ -709,7 +709,7 @@ async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" expr = ShowGrantsExpression(self.dialect) if user: - expr.for_user(user, host) + expr.with_user(user, host) sql, params = expr.to_sql() return self._parse_grants(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py index ecb4a84d..5cecd363 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py @@ -384,7 +384,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): """ expr = ShowCreateTableExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_table_result(result, table_name) @@ -403,7 +403,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): """ expr = ShowCreateViewExpression(self.dialect, view_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_view_result(result, view_name) @@ -430,11 +430,11 @@ def columns( """ expr = ShowColumnsExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) if full: - expr.full() + expr.with_full() if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -454,7 +454,7 @@ def indexes(self, table_name: str, schema: Optional[str] = None): """ expr = ShowIndexExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_indexes_result(result) @@ -479,11 +479,11 @@ def tables( """ expr = ShowTablesExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -502,7 +502,7 @@ def databases(self, like: Optional[str] = None): """ expr = ShowDatabasesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -522,9 +522,9 @@ def table_status(self, schema: Optional[str] = None, like: Optional[str] = None) """ expr = ShowTableStatusExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -544,9 +544,9 @@ def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = Non """ expr = ShowTriggersExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if table_name: - expr.for_table(table_name) + expr.with_table_name(table_name) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -564,7 +564,7 @@ def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """ expr = ShowCreateTriggerExpression(self.dialect, trigger_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -584,9 +584,9 @@ def variables(self, like: Optional[str] = None, session: bool = True): """ expr = ShowVariablesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_vars() + expr.with_global_vars() sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -606,9 +606,9 @@ def status(self, like: Optional[str] = None, session: bool = True): """ expr = ShowStatusExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_status() + expr.with_global_status() sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -627,7 +627,7 @@ def processlist(self, full: bool = False): """ expr = ShowProcessListExpression(self.dialect) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -646,7 +646,7 @@ def warnings(self, limit: Optional[int] = None): """ expr = ShowWarningsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -663,7 +663,7 @@ def errors(self, limit: Optional[int] = None): """ expr = ShowErrorsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -695,7 +695,7 @@ def charset(self, like: Optional[str] = None): """ expr = ShowCharsetExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -714,7 +714,7 @@ def collation(self, like: Optional[str] = None): """ expr = ShowCollationExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -734,7 +734,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): """ expr = ShowGrantsExpression(self.dialect) if user: - expr.for_user(user, host) + expr.with_user(user, host) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -780,7 +780,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): """Async version of create_table.""" expr = ShowCreateTableExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_table_result(result, table_name) @@ -791,7 +791,7 @@ async def create_view(self, view_name: str, schema: Optional[str] = None): """Async version of create_view.""" expr = ShowCreateViewExpression(self.dialect, view_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_view_result(result, view_name) @@ -808,11 +808,11 @@ async def columns( """Async version of columns.""" expr = ShowColumnsExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) if full: - expr.full() + expr.with_full() if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_columns_result(result) @@ -823,7 +823,7 @@ async def indexes(self, table_name: str, schema: Optional[str] = None): """Async version of indexes.""" expr = ShowIndexExpression(self.dialect, table_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_indexes_result(result) @@ -839,11 +839,11 @@ async def tables( """Async version of tables.""" expr = ShowTablesExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_tables_result(result) @@ -854,7 +854,7 @@ async def databases(self, like: Optional[str] = None): """Async version of databases.""" expr = ShowDatabasesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_databases_result(result) @@ -865,9 +865,9 @@ async def table_status(self, schema: Optional[str] = None, like: Optional[str] = """Async version of table_status.""" expr = ShowTableStatusExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_table_status_result(result) @@ -878,9 +878,9 @@ async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] """Async version of triggers.""" expr = ShowTriggersExpression(self.dialect) if schema: - expr.schema(schema) + expr.with_schema(schema) if table_name: - expr.for_table(table_name) + expr.with_table_name(table_name) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_triggers_result(result) @@ -889,7 +889,7 @@ async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Async version of create_trigger.""" expr = ShowCreateTriggerExpression(self.dialect, trigger_name) if schema: - expr.schema(schema) + expr.with_schema(schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_trigger_result(result, trigger_name) @@ -900,9 +900,9 @@ async def variables(self, like: Optional[str] = None, session: bool = True): """Async version of variables.""" expr = ShowVariablesExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_vars() + expr.with_global_vars() sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_variables_result(result) @@ -913,9 +913,9 @@ async def status(self, like: Optional[str] = None, session: bool = True): """Async version of status.""" expr = ShowStatusExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) if not session: - expr.global_status() + expr.with_global_status() sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_status_result(result) @@ -926,7 +926,7 @@ async def processlist(self, full: bool = False): """Async version of processlist.""" expr = ShowProcessListExpression(self.dialect) if full: - expr.full() + expr.with_full() sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_processlist_result(result) @@ -937,7 +937,7 @@ async def warnings(self, limit: Optional[int] = None): """Async version of warnings.""" expr = ShowWarningsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_warnings_result(result) @@ -946,7 +946,7 @@ async def errors(self, limit: Optional[int] = None): """Async version of errors.""" expr = ShowErrorsExpression(self.dialect) if limit is not None: - expr.limit(limit) + expr.with_limit(limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_errors_result(result) @@ -966,7 +966,7 @@ async def charset(self, like: Optional[str] = None): """Async version of charset.""" expr = ShowCharsetExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_charset_result(result) @@ -977,7 +977,7 @@ async def collation(self, like: Optional[str] = None): """Async version of collation.""" expr = ShowCollationExpression(self.dialect) if like: - expr.like(like) + expr.with_like(like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_collation_result(result) @@ -988,7 +988,7 @@ async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Async version of grants.""" expr = ShowGrantsExpression(self.dialect) if user: - expr.for_user(user, host) + expr.with_user(user, host) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_grants_result(result) From ddbd2fdd3a37e9012f35e9cc8d47372f93564071 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 06:32:25 +0800 Subject: [PATCH 20/65] refactor(mysql): follow the ALTER action renames the core settled Core 5a613f3c named each ALTER TABLE action class by what it is (AddColumn -> AddColumnAction, ModifyColumn -> ModifyColumnAction, AlterConstraint -> AlterConstraintAction, ChangeColumn -> ChangeColumnAction among the twelve) and dropped the four alias names that never defined anything. Every callsite in this repository follows the new names: - src: expression/alter_column.py (MySQLAddColumn derives AddColumnAction), protocols.py, mixins/column.py, mixins/constraint.py, examples/ddl/alter_table.py - tests: the vendored roundtrip harness, the matrix, and the constraint-switch, ddl-improvements, mixin-expression and ddl-coverage files The matrix's six constraint-action rows collapse to two: the alias names (AlterConstraintAction as an alias, AlterTableConstraint, ValidateConstraintAction as an alias, ValidateTableConstraint) no longer exist as separate registry names, so only the two classes themselves are left to register. The vendored harness's register_enums_and_dataclasses is dropped in the same collection repair: the registry the core settled has no enum or dataclass table (an enum member rides as the JSON scalar its kind names), so the old registration calls no longer resolve. The format entry points (format_add_column_action and siblings) already carried the action suffix and are unchanged. Migration surface: 13 old names scanned, 25 callsite hits across 11 files renamed, 0 left (rg-verified). --- .../impl/mysql/examples/ddl/alter_table.py | 6 +- .../impl/mysql/expression/alter_column.py | 10 +-- .../backend/impl/mysql/mixins/column.py | 8 +-- .../backend/impl/mysql/mixins/constraint.py | 4 +- .../backend/impl/mysql/protocols.py | 12 ++-- .../backend/expression_roundtrip_harness.py | 64 ++----------------- .../backend/test_expression_roundtrip_all.py | 26 ++------ .../backend/test_mysql_constraint_switches.py | 14 ++-- .../backend/test_mysql_ddl_coverage.py | 8 +-- .../backend/test_mysql_ddl_improvements.py | 18 +++--- .../backend/test_mysql_mixin_expressions.py | 40 ++++++------ 11 files changed, 72 insertions(+), 138 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py index ca5a11a4..e809cd01 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py @@ -97,10 +97,10 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import AlterTableStatement # noqa: E402 -from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumn # noqa: E402 +from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumnAction # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -add_col_action = AddColumn( +add_col_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( name="email", @@ -121,7 +121,7 @@ backend.execute(sql, params) print("Column email added successfully") -add_age_action = AddColumn( +add_age_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( "age", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py index 82073fd3..fa4abc1e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py @@ -6,15 +6,15 @@ * ``AFTER `` — place the new column immediately after ``column``. -It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumn``) and is -rendered by the MySQL ``format_add_column_action`` override. MySQL does -**not** share it with any other backend. +It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumnAction``) +and is rendered by the MySQL ``format_add_column_action`` override. MySQL +does **not** share it with any other backend. """ from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.statements import ( - AddColumn, + AddColumnAction, ColumnDefinition, ) @@ -27,7 +27,7 @@ ] -class MySQLAddColumn(AddColumn): +class MySQLAddColumn(AddColumnAction): """A MySQL ``ADD COLUMN`` action extending the generic one. Adds the MySQL-only ``after`` positional clause. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py index 0081584d..aa6da085 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ModifyColumn, - ChangeColumn, + ChangeColumnAction, + ModifyColumnAction, ) @@ -17,7 +17,7 @@ def supports_modify_column(self) -> bool: def supports_change_column(self) -> bool: return True - def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tuple]: + def format_modify_column_action(self, action: "ModifyColumnAction") -> Tuple[str, tuple]: """Format MODIFY COLUMN action for ALTER TABLE.""" col_sql, col_params = self.format_column_definition(action.column) sql = f"MODIFY COLUMN {col_sql}" @@ -27,7 +27,7 @@ def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tupl sql += " FIRST" return sql, col_params - def format_change_column_action(self, action: "ChangeColumn") -> Tuple[str, tuple]: + def format_change_column_action(self, action: "ChangeColumnAction") -> Tuple[str, tuple]: """Format CHANGE COLUMN action for ALTER TABLE.""" col_sql, col_params = self.format_column_definition(action.column) sql = f"CHANGE COLUMN {self.format_identifier(action.old_name)} {col_sql}" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index a9f784ed..403f19f9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterConstraint, + AlterConstraintAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, @@ -399,7 +399,7 @@ def supports_alter_constraint_enforced(self) -> bool: return self.version >= (8, 0, 16) def format_alter_constraint_action( - self, action: "AlterConstraint" + self, action: "AlterConstraintAction" ) -> Tuple[str, tuple]: """``ALTER CONSTRAINT`` addresses CHECK only on this server. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index b2dd42bc..d908e118 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -15,8 +15,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import OnConflictClause from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ModifyColumn, - ChangeColumn, + ChangeColumnAction, + ModifyColumnAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableLikeStatement, @@ -1583,22 +1583,22 @@ def supports_change_column(self) -> bool: """Whether CHANGE COLUMN is supported.""" ... - def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tuple]: + def format_modify_column_action(self, action: "ModifyColumnAction") -> Tuple[str, tuple]: """Format MODIFY COLUMN action for ALTER TABLE. Args: - action: ModifyColumn action instance + action: ModifyColumnAction action instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_change_column_action(self, action: "ChangeColumn") -> Tuple[str, tuple]: + def format_change_column_action(self, action: "ChangeColumnAction") -> Tuple[str, tuple]: """Format CHANGE COLUMN action for ALTER TABLE. Args: - action: ChangeColumn action instance + action: ChangeColumnAction action instance Returns: Tuple of (SQL string, parameters tuple) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py index 69c4d842..e3bd43b2 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py @@ -36,9 +36,9 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression # Annotation fragments that mean "this parameter names a relation object". -# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraint`` are -# constraint actions, not relations, and a substring test would hand them a -# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraintAction`` +# are constraint actions, not relations, and a substring test would hand them +# a Table. ``SchemaObject`` is the base every catalogue object shares, so an # annotation naming it wants an object too. _MENTIONS_RELATION = re.compile( r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" @@ -849,16 +849,16 @@ def _update_expr(dialect): def _alter_table_expr(dialect): from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, + AddColumnAction, AlterTableStatement, - DropColumn, + DropColumnAction, ) column = _column_definition(dialect) return AlterTableStatement( dialect, table=_table(dialect, "t"), - actions=[DropColumn(dialect, "a"), AddColumn(dialect, column=column)], + actions=[DropColumnAction(dialect, "a"), AddColumnAction(dialect, column=column)], ) @@ -1001,57 +1001,6 @@ def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: registry.register_node(cls) -def register_enums_and_dataclasses(package_path: str) -> None: - """Register a package's enums and dataclasses into the same whitelist. - - An artifact encodes an enum member as ``[short class name, member]`` and - the reader resolves that name through the registry's enum table -- no - ``importlib`` by name (untrusted input, §4.11.4), so a backend enum that - was never registered refuses to read back. Core populates its own enums - and dataclasses; a backend package's are exactly as privileged as a - third-party plugin's, which is to say they are registered, not discovered. - Same ownership filters as core's own populator: the class must be defined - in the module being walked. - """ - import dataclasses - import enum as _enum - import importlib - import pkgutil - - from rhosocial.activerecord.backend.expression.registry import RegistryError - from rhosocial.activerecord.backend.expression.serialization import default_registry - - registry = default_registry() - pkg = importlib.import_module(package_path) - if hasattr(pkg, "__path__"): - modnames = [ - m for _, m, _ in pkgutil.walk_packages( - path=pkg.__path__, prefix=package_path + ".", onerror=lambda x: None - ) - ] - else: - modnames = [package_path] - for modname in modnames: - try: - mod = importlib.import_module(modname) - except Exception: - continue - for name in dir(mod): - obj = getattr(mod, name, None) - if not isinstance(obj, type) or obj.__module__ != modname: - continue - if issubclass(obj, _enum.Enum): - try: - registry.register_enum(obj) - except RegistryError: - pass - elif dataclasses.is_dataclass(obj): - try: - registry.register_dataclass(obj) - except RegistryError: - pass - - def assert_params_equal(a: Any, b: Any, path: str = "params") -> None: """Deep-compare two get_params() outputs, treating nested BaseExpression instances as structurally equal and nested dataclasses as recursively compared. @@ -1128,7 +1077,6 @@ def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: "collect_expression_classes", "make_instance", "register_all", - "register_enums_and_dataclasses", "register_special_constructor", "roundtrip_expression", "sql_consistent", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 46493da4..5d20b184 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -65,7 +65,6 @@ class into ``pytest.skip``, which shares a green tick with "correctly collect_expression_classes, make_instance, register_all, - register_enums_and_dataclasses, register_special_constructor, roundtrip_expression, sql_consistent, @@ -80,10 +79,6 @@ class into ``pytest.skip``, which shares a green tick with "correctly CORE_CLASSES = collect_expression_classes(CORE_EXPR_PKG) ALL_CLASSES = {**CORE_CLASSES, **CLASSES} register_all(ALL_CLASSES) -# The mysql expression package's enums and dataclasses ride inside artifacts -# under their short class names; the whitelist resolves those names, so the -# backend package's own vocabulary has to be registered beside the classes. -register_enums_and_dataclasses(MYSQL_EXPR_PKG) def _table(d, name="t"): @@ -403,20 +398,11 @@ def partition_maintenance(d, cls_name): # :data:`_DECLARED_GAP`, which is where a class that constructs but does not # render belongs. _UNBUILDABLE = { - "statements.ddl_alter.AlterConstraint": + "statements.ddl_alter.AlterConstraintAction": "constraint_name defaults to None and the constructor rejects it, so " "the generic placeholder is never reached.", - "statements.ddl_alter.AlterConstraintAction": - "same shape as AlterConstraint: an abstract constraint action with no " - "constructible default.", - "statements.ddl_alter.AlterTableConstraint": - "same shape as AlterConstraint.", - "statements.ddl_alter.ValidateConstraint": - "constraint_name defaults to None and the constructor rejects it.", "statements.ddl_alter.ValidateConstraintAction": - "same shape as ValidateConstraint.", - "statements.ddl_alter.ValidateTableConstraint": - "same shape as ValidateConstraint.", + "constraint_name defaults to None and the constructor rejects it.", "statements.ddl_domain.AddDomainCheckAction": "check must be a DomainCheckConstraint instance; the generic " "constructor supplies a string.", @@ -893,22 +879,22 @@ def partition_maintenance(d, cls_name): "renames must be a non-empty list of (old, new) Table pairs; the " "generic constructor supplies an empty list.", ), - "statements.ddl_alter.AddColumn": ( + "statements.ddl_alter.AddColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_alter.AddTableConstraint": ( + "statements.ddl_alter.AddTableConstraintAction": ( "AttributeError", "constraint must be a TableConstraint; the generic constructor supplies " "a string.", ), - "statements.ddl_alter.ChangeColumn": ( + "statements.ddl_alter.ChangeColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_alter.ModifyColumn": ( + "statements.ddl_alter.ModifyColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py index e3b079e6..9fc2d6d7 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_constraint_switches.py @@ -129,10 +129,10 @@ def test_alter_constraint_addresses_check_only(self): formatter narrows to CHECK by name instead of sending a statement the server rejects. """ - from rhosocial.activerecord.backend.expression import AlterConstraint + from rhosocial.activerecord.backend.expression import AlterConstraintAction dialect = _dialect() - sql, _ = AlterConstraint( + sql, _ = AlterConstraintAction( dialect, constraint_name="ck", not_enforced=True, @@ -141,7 +141,7 @@ def test_alter_constraint_addresses_check_only(self): assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" with pytest.raises(UnsupportedFeatureError, match="3941"): - AlterConstraint( + AlterConstraintAction( dialect, constraint_name="fk", not_enforced=True, @@ -151,7 +151,7 @@ def test_alter_constraint_addresses_check_only(self): def test_alter_constraint_normalises_a_string_kind_at_construction(self): """A string kind is the constructor's to normalise, not the probe's. - ``AlterConstraint`` declares ``str`` in its ``constraint_kind`` + ``AlterConstraintAction`` declares ``str`` in its ``constraint_kind`` union and normalises at construction, so a spelling that matches a branch reaches the formatter as the branch *class* -- and the formatter, not the probe, is what narrows it to CHECK on this @@ -169,9 +169,9 @@ def test_alter_constraint_normalises_a_string_kind_at_construction(self): ``ValueError``; the expression layer owns that mapping and this backend has no say in it. """ - from rhosocial.activerecord.backend.expression import AlterConstraint + from rhosocial.activerecord.backend.expression import AlterConstraintAction - sql, _ = AlterConstraint( + sql, _ = AlterConstraintAction( _dialect(), constraint_name="ck", not_enforced=True, @@ -180,7 +180,7 @@ def test_alter_constraint_normalises_a_string_kind_at_construction(self): assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" with pytest.raises(ValueError): - AlterConstraint( + AlterConstraintAction( _dialect(), constraint_name="ck", not_enforced=True, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py index b51d6242..d04f1e14 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py @@ -17,7 +17,7 @@ import pytest from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, + AlterColumnAction, AlterTableStatement, ColumnAlterOperation, ) @@ -105,18 +105,18 @@ class TestAlterColumnDefault: """Test ALTER TABLE ... ALTER COLUMN {SET DEFAULT | DROP DEFAULT}.""" def test_set_default_string(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `col` SET DEFAULT 'ABC'" in sql assert params == () def test_set_default_integer(self, dialect): - action = AlterColumn(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) + action = AlterColumnAction(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) sql, _ = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `num` SET DEFAULT 5" in sql def test_drop_default(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `col` DROP DEFAULT" in sql assert params == () diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index 21535aaf..0bb16efc 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -20,10 +20,10 @@ TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddTableConstraint, - AlterConstraint, - DropTableConstraint, - ValidateConstraint, + AddTableConstraintAction, + AlterConstraintAction, + DropTableConstraintAction, + ValidateConstraintAction, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption @@ -256,7 +256,7 @@ def test_drop_constraint_if_exists_is_declined_and_refused_by_name(self): """ dialect = self._dialect() assert dialect.supports_drop_constraint_if_exists() is False - action = DropTableConstraint(dialect, "uq_content", if_exists=True) + action = DropTableConstraintAction(dialect, "uq_content", if_exists=True) with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): action.to_sql() @@ -266,14 +266,14 @@ def test_novalidate_is_declined_and_refused_by_name(self): assert dialect.supports_constraint_novalidate() is False constraint = self._check(dialect, validation=ConstraintValidation.NOVALIDATE) with pytest.raises(UnsupportedFeatureError, match="NOT VALID"): - AddTableConstraint(dialect, constraint).to_sql() + AddTableConstraintAction(dialect, constraint).to_sql() def test_validate_constraint_is_declined_and_refused_by_name(self): """``VALIDATE CONSTRAINT`` is a parser error (``1064 ... near 'VALIDATE``).""" dialect = self._dialect() assert dialect.supports_validate_constraint() is False with pytest.raises(UnsupportedFeatureError, match="VALIDATE CONSTRAINT"): - ValidateConstraint(dialect, "ck").to_sql() + ValidateConstraintAction(dialect, "ck").to_sql() def test_alter_constraint_enforced_is_declared_for_check_only(self): """``ALTER CONSTRAINT [NOT] ENFORCED`` is the one form the server takes. @@ -295,14 +295,14 @@ def test_alter_constraint_enforced_is_declared_for_check_only(self): def test_alter_constraint_enforced_renders_for_check_only(self): """The rendering follows the probe: CHECK renders, FOREIGN KEY refuses.""" dialect = self._dialect() - sql, params = AlterConstraint( + sql, params = AlterConstraintAction( dialect, "ck", not_enforced=True, constraint_kind=TableCheckConstraint ).to_sql() assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" assert params == () with pytest.raises(UnsupportedFeatureError, match="ENFORCED"): - AlterConstraint( + AlterConstraintAction( dialect, "fk", enforced=True, constraint_kind=TableForeignKeyConstraint ).to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 8461393a..f691cbd4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -19,12 +19,12 @@ ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, + AddColumnAction, AlterTableStatement, - ChangeColumn, - DropColumn, - DropTableConstraint, - ModifyColumn, + ChangeColumnAction, + DropColumnAction, + DropTableConstraintAction, + ModifyColumnAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( CreateTriggerStatement, @@ -734,7 +734,7 @@ class TestMySQLColumnModificationExpressions: def test_modify_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), ) @@ -747,7 +747,7 @@ def test_modify_column(self): def test_modify_column_first(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), first=True, @@ -761,7 +761,7 @@ def test_modify_column_first(self): def test_modify_column_after(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), after_column="id", @@ -775,7 +775,7 @@ def test_modify_column_after(self): def test_change_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="username", column=ColumnDefinition(dialect, "login_name", VarCharType(dialect, 150)), @@ -788,7 +788,7 @@ def test_change_column(self): def test_change_column_first(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="name", column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), @@ -803,7 +803,7 @@ def test_change_column_first(self): def test_change_column_after(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="name", column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), @@ -818,7 +818,7 @@ def test_change_column_after(self): def test_modify_column_with_constraints(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", @@ -839,13 +839,13 @@ class TestMySQLAlterTableModifierGuards: """MySQL does not support any IF [NOT] EXISTS qualifiers on ALTER TABLE. Requesting them via ``if_not_exists=True`` / ``if_exists=True`` on - ``AddColumn`` / ``DropColumn`` / ``DropTableConstraint`` must raise - ``UnsupportedFeatureError`` instead of emitting invalid SQL. + ``AddColumnAction`` / ``DropColumnAction`` / ``DropTableConstraintAction`` + must raise ``UnsupportedFeatureError`` instead of emitting invalid SQL. """ def test_add_column_if_not_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = AddColumn( + action = AddColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), if_not_exists=True, @@ -855,13 +855,13 @@ def test_add_column_if_not_exists_raises(self): def test_drop_column_if_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = DropColumn(dialect=dialect, column_name="email", if_exists=True) + action = DropColumnAction(dialect=dialect, column_name="email", if_exists=True) with pytest.raises(UnsupportedFeatureError): action.to_sql() def test_drop_table_constraint_if_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = DropTableConstraint( + action = DropTableConstraintAction( dialect=dialect, constraint_name="fk_users_email", if_exists=True ) with pytest.raises(UnsupportedFeatureError): @@ -870,7 +870,7 @@ def test_drop_table_constraint_if_exists_raises(self): def test_default_qualifiers_keep_behavior(self): dialect = MySQLDialect(version=(8, 0, 0)) - add_action = AddColumn( + add_action = AddColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), ) @@ -878,12 +878,12 @@ def test_default_qualifiers_keep_behavior(self): assert "ADD COLUMN" in add_sql assert "IF NOT EXISTS" not in add_sql - drop_action = DropColumn(dialect=dialect, column_name="email") + drop_action = DropColumnAction(dialect=dialect, column_name="email") drop_sql, _ = drop_action.to_sql() assert "DROP COLUMN" in drop_sql assert "IF EXISTS" not in drop_sql - drop_constraint = DropTableConstraint( + drop_constraint = DropTableConstraintAction( dialect=dialect, constraint_name="fk_users_email" ) drop_constraint_sql, _ = drop_constraint.to_sql() From 6ce2ea21f86b766bcadc6b84926197a5ea403fc4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 06:37:43 +0800 Subject: [PATCH 21/65] refactor(mysql): follow the literal architecture the core settled Core 239678e5 gave a literal its job and nothing else: the TypedValueMixin tier and the twelve *Value classes are gone, replaced by LiteralKind plus the one Literal(dialect, value, inline=) node whose flag answers bind-or-text per position. This dialect follows: - mixins/literal.py replaces mixins/typed_value.py. MySQLLiteralValueMixin overrides format_literal -- the single entry point for writing a value as SQL text -- with MySQL's two tier-2 spellings only: bytes become X'0102FF' (upper-case hex) and a UUID becomes the quoted canonical text. Everything else falls through to the base's three-tier construction; timedelta gets no branch, so the base's explicit TypeError stays the answer -- measured on both servers, INTERVAL exists only as the keyword parameter of date arithmetic (DATE_ADD's), never as a literal form, and leaving the method without that branch is the structural refusal. The mixin stands first in the dialect's base list, ahead of SQLDialectBase, because that is the one base defining format_literal: a mixin placed after it never wins. The TypedValueMixin wiring is gone from dialect.py and mixins/__init__.py. - format_char_length_function stops reading expr.unit: the base's contract says the unit records the semantics the caller asked for and never enters the SQL, and the octet and bit questions have their own dedicated nodes whose defaults (OCTET_LENGTH / BIT_LENGTH) this engine accepts as-is. The override spells CHAR_LENGTH because the base's LENGTH counts bytes here (measured on 8.0.46: LENGTH('ae-acute') answers 3 where CHAR_LENGTH answers 2). - test_mysql_literal_values.py replaces test_mysql_typed_values.py. The old class-level inline strategy (Ddl*Value subclasses setting inline_params) is gone with its shape: a DDL position now says inline=True per node, and the same measured spellings are asserted through the per-position flag. The live-server read-back class keeps its measured records verbatim -- the architecture turn changes none of the answers. The classed-functions file's VALUES-cell case follows: a Literal cell binds behind the same placeholder a bare host value gets. - Two test call sites that composed the retired DdlLiteral lift now carry the per-position flag: a DEFAULT value clause marks its Literal inline itself, and a CHECK condition's literals say inline=True each. Measured records are preserved unchanged: every spelling was measured on 8.0.46 (192.168.1.3:14682) and on the 26.7.0 server at 192.168.1.3:14686. Evidence: test_mysql_literal_values.py green (exit 0); -k literal across feature/backend green (127 passed, exit 0); the two DdlLiteral sites green (15 passed, exit 0); ruff clean on every changed file (the 39 findings in three legacy files are byte-identical at HEAD and untouched here). --- .../backend/impl/mysql/dialect.py | 19 +- .../backend/impl/mysql/mixins/__init__.py | 4 +- .../backend/impl/mysql/mixins/function.py | 38 +-- .../backend/impl/mysql/mixins/literal.py | 75 ++++++ .../backend/impl/mysql/mixins/typed_value.py | 83 ------- .../backend/ddl/test_ddl_source_collection.py | 8 +- ...mysql_classed_functions_and_bare_values.py | 58 +++-- ...values.py => test_mysql_literal_values.py} | 225 ++++++------------ .../test_mysql_type_domain_negative.py | 9 +- 9 files changed, 208 insertions(+), 311 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py delete mode 100644 src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py rename tests/rhosocial/activerecord_mysql_test/feature/backend/{test_mysql_typed_values.py => test_mysql_literal_values.py} (55%) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 5535cf92..c7780183 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -103,11 +103,6 @@ DomainMixin, TransactionControlMixin, SetOperationMixin, - # Value literals: the base tier-1 rendering plus the tier-2 methods a - # backend must implement. MySQL's own spellings come from - # MySQLTypedValueMixin (imported with the local mixins), which the - # dialect composes ahead of this one. - TypedValueMixin, ) from .protocols import ( MySQLTriggerSupport, @@ -173,7 +168,7 @@ MySQLGeneratedColumnMixin, MySQLFunctionMixin, MySQLColumnTypeMixin, - MySQLTypedValueMixin, + MySQLLiteralValueMixin, ) from .mixins.object_kind import require_kind from .reserved_words import MYSQL_RESERVED_WORDS @@ -186,6 +181,12 @@ class MySQLDialect( + # MySQL's tier-2 value literals (bytes X'...' and the quoted UUID text) + # override format_literal, and this is the one base that defines it, so + # the mixin is placed ahead of SQLDialectBase: a mixin placed after the + # class it overrides never wins. Everything else about this mixin is the + # base's own tiering, which super() reaches. + MySQLLiteralValueMixin, SQLDialectBase, # MySQL's own namespace rules first, and before NamespaceMixin for the C3 # reason: MySQLNamespaceMixin overrides supports_catalog, @@ -332,12 +333,6 @@ class MySQLDialect( IntrospectionMixin, PredicateMixin, ExpressionMixin, - # Value literals: MySQL's spellings ahead of the base's tier-1 rendering. - # This order is what resolves format_binary_value and format_uuid_value - # (INTERVAL is deliberately absent -- see mixins/typed_value.py for the - # measured refusals behind that). - MySQLTypedValueMixin, - TypedValueMixin, DateTimeMixin, DQLMixin, DMLMixin, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py index f61fc0d3..87d95d25 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py @@ -47,7 +47,7 @@ from .function import MySQLFunctionMixin from .namespace import MySQLNamespaceMixin from .column_type import MySQLColumnTypeMixin -from .typed_value import MySQLTypedValueMixin +from .literal import MySQLLiteralValueMixin # ``__all__`` is the mixin surface: the names ``dialect.py`` mixes into # MySQLDialect. ``require_kind`` from ``.object_kind`` is deliberately absent -- @@ -102,5 +102,5 @@ "MySQLFunctionMixin", "MySQLNamespaceMixin", "MySQLColumnTypeMixin", - "MySQLTypedValueMixin", + "MySQLLiteralValueMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py index af5f9dd7..7a9a4e52 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py @@ -116,30 +116,18 @@ def _is_mysql_function_supported(self, func_name: str) -> bool: return True def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """MySQL's three length spellings, by the unit the node declares. - - The core default spells ``LENGTH``, which on MySQL counts **bytes** - -- measured on 8.0.46: ``LENGTH('aé')`` answers 3 where - ``CHAR_LENGTH('aé')`` answers 2, so the default would answer a - different question than the node's ``unit='char'`` records. MySQL - has a native spelling for each of the three declared units, so this - override honours the unit rather than picking one: - - * ``char`` -- ``CHAR_LENGTH(operand)``, counted in characters; - * ``octet`` -- ``LENGTH(operand)``, counted in bytes (measured: - ``LENGTH(X'0102')`` answers 2); - * ``bit`` -- ``BIT_LENGTH(operand)`` (measured: - ``BIT_LENGTH('abc')`` answers 24). - - Args: - expr: The :class:`CharLengthFunction` whose ``unit`` names the - length semantics asked for. - - Returns: - A ``(sql, params)`` tuple. + """MySQL's character-length spelling: ``CHAR_LENGTH(operand)``. + + The base spells ``LENGTH``, and on MySQL that answers a different + question: measured on 8.0.46 (192.168.1.3:14682), + ``LENGTH('aé')`` is 3 -- the byte count, é being two bytes -- while + ``CHAR_LENGTH('aé')`` is 2, the characters, so the default would + answer a different question than this node asks. + + The node's ``unit`` records the semantics the caller asked for and + never enters the SQL, exactly as the base's contract says; the + octet and bit semantics have their own dedicated nodes, whose + defaults (``OCTET_LENGTH`` / ``BIT_LENGTH``) are spellings MySQL + accepts as-is. """ - if expr.unit == "octet": - return self._format_scalar_function("LENGTH", expr) - if expr.unit == "bit": - return self._format_scalar_function("BIT_LENGTH", expr) return self._format_scalar_function("CHAR_LENGTH", expr) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py new file mode 100644 index 00000000..95567bca --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py @@ -0,0 +1,75 @@ +# src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py +"""MySQL's tier-2 value literals. + +``SQLDialectBase.format_literal`` is the single entry point for writing a +value as SQL text, and it resolves in three tiers: the portable scalars it +knows, the types a backend spells for themselves, and the +literal-construction protocol. This module is MySQL's second tier, and it +carries the two spellings measured on the live servers (8.0.46 at +192.168.1.3:14682, and the server at 192.168.1.3:14686 which answers +``SELECT VERSION()`` with ``26.7.0``): + +* ``bytes`` becomes ``X'0102FF'``, the hex form from MySQL's literal + grammar. ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'`` and a + ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those bytes on + insert (likewise ``TINYBLOB DEFAULT (X'0102FF')`` through the + expression-default syntax). Lower-case ``x'0102ff'`` is accepted too, and + upper-case is what this dialect renders. Quoting the bytes as text would + read back as text -- the wrong value in a binary column and the wrong + length besides. +* ``UUID`` becomes the bare quoted canonical text. MySQL has no UUID + column type (it numbers rows with ``AUTO_INCREMENT`` and spells UUIDs as + ``CHAR(36)``/``BINARY(16)`` storage conventions) and no literal form other + than the text: measured, ``CHAR(36) DEFAULT '550e8400-e29b-41d4-a716-446655440000'`` + reads the hyphenated text back verbatim. + +**INTERVAL stays unimplemented on purpose.** ``timedelta`` has no branch +here, so the base's explicit refusal is the answer: measured on both +servers, ``SELECT INTERVAL 1 DAY`` is a syntax error (errno 1064) and so is +a ``DEFAULT INTERVAL 1 DAY`` in every column shape tried. ``INTERVAL`` +exists only as the keyword parameter of date arithmetic +(``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), never as a value +with a literal form of its own, and this module adds no spelling rather +than inventing one. + +The tier-1 forms (NULL / booleans / numbers / strings / dates / times / +datetimes) are the base's, reached through this dialect's backslash-doubling +``_escape_sql_string``: measured on both servers as ``DEFAULT`` values +accepted by ``CREATE TABLE`` (``TRUE`` / ``FALSE`` / ``42`` / ``1.5`` / +``1.25`` / ``'x''y'`` / ``'2026-10-17'`` / ``'10:30:00'`` / +``'2026-10-17 10:00:00'``), each reading back unchanged -- so none of them +is overridden here. +""" + +import uuid +from typing import Any + + +class MySQLLiteralValueMixin: + """MySQL's tier-2 branches of :meth:`format_literal`. + + Stands alone rather than inheriting the base, and the dialect composes + it ahead of :class:`SQLDialectBase` -- the one base that defines + ``format_literal`` -- because a mixin placed after the class it + overrides never wins. + """ + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, MySQL's spelling where it has one. + + Args: + value: The host value to write into the statement. + + Returns: + The escaped literal text, without a bind parameter. + + Raises: + TypeError: The base implementation refuses a type MySQL has no + literal form for -- ``timedelta`` among them, which is the + measured answer rather than a gap. + """ + if isinstance(value, (bytes, bytearray)): + return f"X'{bytes(value).hex().upper()}'" + if isinstance(value, uuid.UUID): + return self.format_literal(str(value)) + return super().format_literal(value) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py deleted file mode 100644 index 9a91b4ec..00000000 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py +++ /dev/null @@ -1,83 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/mysql/mixins/typed_value.py -"""MySQL's spellings for the value types whose literal form is its own. - -MySQL agrees with the base on NULL, booleans, numbers, strings and -date/time values -- measured, not assumed: on 8.0.46 (192.168.1.3:14682) -and on the server at 192.168.1.3:14686 (which answers ``SELECT VERSION()`` -with ``26.7.0``), a ``CREATE TABLE`` carrying ``DEFAULT TRUE`` / -``DEFAULT FALSE`` / ``DEFAULT 42`` / ``DEFAULT 1.5`` / ``DEFAULT 1.25`` / -``DEFAULT 'x''y'`` / ``DEFAULT '2026-10-17'`` / ``DEFAULT '10:30:00'`` / -``DEFAULT '2026-10-17 10:00:00'`` is accepted and the defaults read back -as the same values, so no tier-1 method is overridden here. One tier-1 -detail is MySQL's and is already where it belongs rather than here: -under the default ``sql_mode`` the backslash is an escape character -(measured: ``'a\\b'`` read back as ``'a\\x08'``), so ``format_literal`` -routes strings through this dialect's ``_escape_sql_string``, which -doubles backslashes unless ``NO_BACKSLASH_ESCAPES`` is active. - -Two value types differ, and both differ because MySQL's grammar says so: - -* **binary** -- ``X'0102FF'``, the hex form from MySQL's literal grammar. - Measured on both servers: ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'`` - and a ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those - bytes on insert (likewise ``TINYBLOB DEFAULT (X'0102FF')`` through the - expression-default syntax). Lower-case ``x'0102ff'`` is accepted too; the - upper-case spelling is what this dialect renders. Quoting the bytes as - text would produce a string that reads back as text -- the wrong value in - a binary column and the wrong length besides. -* **UUID** -- the bare quoted canonical text. MySQL has no UUID column - type (it numbers rows with ``AUTO_INCREMENT`` and spells UUIDs as - ``CHAR(36)``/``BINARY(16)`` storage conventions) and no literal form - other than the text. Measured on both servers: ``CHAR(36) DEFAULT - '550e8400-e29b-41d4-a716-446655440000'`` reads the hyphenated text back - verbatim, so one spelling serves the text home and no ``::uuid``-style - suffix exists to add. - -**INTERVAL stays unimplemented on purpose.** Measured on both servers: -``SELECT INTERVAL 1 DAY`` is a syntax error (errno 1064), and so is a -``DEFAULT INTERVAL 1 DAY`` in every column shape tried -- ``INTERVAL`` -exists only as the keyword parameter of date arithmetic -(``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), never as a -value with a literal form of its own. A DDL position that needs one -cannot be written for this engine, so this mixin defines no -``format_interval_value`` -- leaving the method absent is what makes the -base's explicit failure the answer here. -""" - -from typing import TYPE_CHECKING, Tuple - -if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.values import TypedValue - - -class MySQLTypedValueMixin: - """MySQL's tier-2 value literals. - - Stands alone rather than inheriting :class:`TypedValueMixin`, like every - other MySQL mixin: the dialect composes the specific mixin ahead of the - generic one, and these two methods win by that position. Inheriting would - put the base ahead of the other generic mixins and make the ordering the - dialect has to reason about depend on this class instead. - """ - - def format_binary_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A binary literal: ``X'0102FF'``, upper-case hex. - - Rendered from the bytes the node holds, because this conversion has - to happen somewhere and the dialect is the only layer that knows - which form its engine accepts. - """ - if not type(expr).inline_params: - return self._bind_value(expr) - return f"X'{bytes(expr.value).hex().upper()}'", () - - def format_uuid_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A UUID as the bare quoted canonical text. - - MySQL has no UUID type and no literal other than the text, so the - quoted canonical form is the whole spelling; a ``CHAR(36)`` column - reads it back verbatim (measured). - """ - if not type(expr).inline_params: - return self._bind_value(expr) - return self.format_literal(str(expr.value)), () diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py index 080d108c..089a9885 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -5,7 +5,7 @@ except ImportError: from typing_extensions import Annotated, Optional -from rhosocial.activerecord.backend.expression.core import Column, DdlLiteral +from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnGeneratedConstraint, @@ -58,9 +58,9 @@ SQL_TYPE = MySQLIntType(unsigned=True) FALLBACK_SQL_TYPE = MySQLLongTextType() SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) -# A DEFAULT takes no bind parameter, so the value is a DdlLiteral: whether a -# literal inlines is decided by its class, never per instance. -DEFAULT_VALUE = DdlLiteral(DIALECT, "new") +# A DEFAULT takes no bind parameter: the value clause marks the literal +# inline itself, so a plain Literal is the value here. +DEFAULT_VALUE = Literal(DIALECT, "new") DEFAULT_CONSTRAINT = UseConstraint( ColumnDefaultConstraint, name="df_status_default", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py index b856c8be..9794f88d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py @@ -3,14 +3,14 @@ Two threads of the core waves land in this dialect here: -* :class:`CharLengthFunction` carries a declared ``unit`` (char / octet / - bit), and core's default ``LENGTH`` spelling counts **bytes** on MySQL -- - measured on 8.0.46: ``LENGTH('aé')`` answers 3 where ``CHAR_LENGTH('aé')`` - answers 2, so the default would answer a different question than the - unit records. MySQL has a native spelling for each unit, so the dialect - override maps char -> ``CHAR_LENGTH``, octet -> ``LENGTH`` (bytes, - measured: ``LENGTH(X'0102')`` answers 2), bit -> ``BIT_LENGTH`` (measured: - ``BIT_LENGTH('abc')`` answers 24). +* :class:`CharLengthFunction` asks the characters question, and core's + default ``LENGTH`` spelling counts **bytes** on MySQL -- measured on + 8.0.46: ``LENGTH('aé')`` answers 3 where ``CHAR_LENGTH('aé')`` answers + 2, so the default would answer a different question than this node asks. + The override spells ``CHAR_LENGTH``; the node's ``unit`` never enters + the SQL (the octet and bit semantics have their own dedicated nodes, + whose defaults -- ``OCTET_LENGTH`` / ``BIT_LENGTH`` -- are spellings + MySQL accepts as-is). * An INSERT's VALUES cells may be bare host values since core ``8e2e4d82`` -- a cell is either an expression, rendered through its own ``to_sql()``, or a plain value that becomes a bind parameter -- and this dialect now @@ -35,32 +35,27 @@ def dialect(): class TestCharLengthSpelling: - """The unit the node declares picks this engine's native spelling.""" + """The characters question, spelled the way MySQL answers it.""" - def test_char_unit_spells_char_length(self, dialect): + def test_the_default_unit_spells_char_length(self, dialect): sql, params = CharLengthFunction(dialect, Column(dialect, "name")).to_sql() assert sql == "CHAR_LENGTH(`name`)" assert params == () - def test_octet_unit_spells_length(self, dialect): - sql, params = CharLengthFunction( - dialect, Column(dialect, "payload"), unit="octet" - ).to_sql() - assert sql == "LENGTH(`payload`)" - assert params == () - - def test_bit_unit_spells_bit_length(self, dialect): - sql, params = CharLengthFunction( - dialect, Column(dialect, "payload"), unit="bit" - ).to_sql() - assert sql == "BIT_LENGTH(`payload`)" - assert params == () + def test_the_unit_never_enters_the_sql(self, dialect): + # The node records which length semantics the caller asked for, and + # the octet and bit semantics have their own dedicated nodes + # (OctetLengthFunction / BitLengthFunction); this entry point answers + # the characters question whatever the record says. + for unit in ("char", "octet", "bit"): + sql, _ = CharLengthFunction( + dialect, Column(dialect, "name"), unit=unit + ).to_sql() + assert sql == "CHAR_LENGTH(`name`)" def test_alias_survives_the_override(self, dialect): - sql, _ = CharLengthFunction( - dialect, Column(dialect, "name"), unit="octet" - ).as_("len").to_sql() - assert sql == "LENGTH(`name`) AS `len`" + sql, _ = CharLengthFunction(dialect, Column(dialect, "name")).as_("len").to_sql() + assert sql == "CHAR_LENGTH(`name`) AS `len`" def test_the_override_resolves_from_the_mysql_function_mixin(self, dialect): assert dialect.format_char_length_function.__func__.__qualname__.startswith( @@ -101,10 +96,11 @@ def test_expression_cells_still_render_through_their_own_formatters(self, dialec assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" assert params == ("a", 7) - def test_a_typed_value_cell_binds_like_a_literal(self, dialect): - from rhosocial.activerecord.backend.expression.values import IntegerValue - - stmt = self._insert(dialect, [(IntegerValue(dialect, 3), 4)]) + def test_a_literal_cell_binds_like_a_bare_one(self, dialect): + # Core collapsed the value kinds into one Literal node that owns the + # binding decision; at a data position it binds behind the same + # placeholder a bare host value gets. + stmt = self._insert(dialect, [(Literal(dialect, 3), 4)]) sql, params = stmt.to_sql() assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" assert params == (3, 4) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py similarity index 55% rename from tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py rename to tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py index 879c2a6d..510eec31 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py @@ -1,31 +1,31 @@ -# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_typed_values.py -"""MySQL's spellings for the typed value literals. - -Core's value nodes render through ``inline_params`` -- a class-level -strategy, never a per-instance flag -- so the DDL-position assertions below -use a test-local DDL-position class per kind, the same lift core itself -performs when it renders a ``DEFAULT`` / ``CHECK`` tree (``Literal`` -> -``DdlLiteral``). The data-position assertions run the node classes as they -ship, which bind. - -Every literal form asserted here was measured, not transcribed. Measured on -8.0.46 (scenario ``mysql_80``, 192.168.1.3:14682) and on the server behind -scenario ``mysql_97`` (192.168.1.3:14686, which answers ``SELECT VERSION()`` -with ``26.7.0``): +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py +"""MySQL's spellings for the value literals. + +Core's one literal node owns the binding decision: ``Literal(d, value)`` +binds the host value behind a placeholder, and ``Literal(d, value, +inline=True)`` -- what a DDL syntax position passes, the one that accepts +no bind parameters -- writes the value as SQL text through the dialect's +controlled literal construction. MySQL's own spellings live in the second +tier of that construction, ``MySQLLiteralValueMixin.format_literal``, and +everything else falls through to the base. + +Every literal form asserted here was measured, not transcribed. Measured +on 8.0.46 (scenario ``mysql_80``, 192.168.1.3:14682) and on the server +behind scenario ``mysql_97`` (192.168.1.3:14686, which answers +``SELECT VERSION()`` with ``26.7.0``): * ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'``; a ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those bytes on insert. Lower-case ``x'0102ff'`` is accepted too; the upper-case spelling is what this dialect renders. * ``CHAR(36) DEFAULT '550e8400-e29b-41d4-a716-446655440000'`` reads the - hyphenated canonical text back verbatim -- MySQL has no UUID type, so the - bare quoted text is the whole spelling. -* Every INTERVAL literal attempt is refused with errno 1064 (syntax error): - ``SELECT INTERVAL 1 DAY`` and ``DEFAULT INTERVAL 1 DAY`` in each column - shape tried. ``INTERVAL`` exists only as the keyword parameter of date - arithmetic (``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), so the - base's explicit failure is the answer and this dialect defines no - ``format_interval_value``. + hyphenated canonical text back verbatim -- MySQL has no UUID type, so + the bare quoted text is the whole spelling. +* Every INTERVAL literal attempt is refused with errno 1064 (syntax + error): ``SELECT INTERVAL 1 DAY`` and ``DEFAULT INTERVAL 1 DAY`` in each + column shape tried. ``INTERVAL`` exists only as the keyword parameter of + date arithmetic, so a ``timedelta`` has no branch in this dialect's + ``format_literal`` and the base's explicit ``TypeError`` is the answer. * The tier-1 spellings the base renders are all accepted: ``DEFAULT TRUE`` / ``DEFAULT FALSE`` / ``DEFAULT 42`` / ``DEFAULT 1.5`` / ``DEFAULT 1.25`` / ``DEFAULT 'x''y'`` / ``DEFAULT '2026-10-17'`` / ``DEFAULT '10:30:00'`` @@ -45,20 +45,7 @@ import pytest -from rhosocial.activerecord.backend.expression.values import ( - BinaryValue, - BooleanValue, - DateValue, - DecimalValue, - FloatValue, - IntegerValue, - IntervalValue, - NullValue, - StringValue, - TimeValue, - TimestampValue, - UUIDValue, -) +from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -67,123 +54,68 @@ def dialect(): return MySQLDialect() -# -- the DDL-position lift ------------------------------------------------ -# -# One class per kind, each declaring the strategy its position requires, -# exactly as core's own DdlLiteral does for Literal. The subclass adds no -# field and no kind: it only owns the position, which is the whole of the -# class-level inline strategy. - -class DdlBinaryValue(BinaryValue): - inline_params = True - - -class DdlUUIDValue(UUIDValue): - inline_params = True - - -class DdlIntervalValue(IntervalValue): - inline_params = True - - -class DdlNullValue(NullValue): - inline_params = True - - -class DdlBooleanValue(BooleanValue): - inline_params = True - - -class DdlIntegerValue(IntegerValue): - inline_params = True - - -class DdlFloatValue(FloatValue): - inline_params = True - - -class DdlDecimalValue(DecimalValue): - inline_params = True - - -class DdlStringValue(StringValue): - inline_params = True - - -class DdlDateValue(DateValue): - inline_params = True - - -class DdlTimeValue(TimeValue): - inline_params = True - - -class DdlTimestampValue(TimestampValue): - inline_params = True - - -class TestBinaryValue: +class TestBinarySpelling: """``X'0102FF'`` -- the hex form from MySQL's literal grammar.""" def test_data_position_binds_the_bytes(self, dialect): - sql, params = BinaryValue(dialect, b"\x01\x02").to_sql() + sql, params = Literal(dialect, b"\x01\x02").to_sql() assert sql == "%s" assert params == (b"\x01\x02",) - def test_ddl_position_spells_hex(self, dialect): - sql, params = DdlBinaryValue(dialect, b"\x01\x02\xff").to_sql() + def test_inline_position_spells_hex(self, dialect): + sql, params = Literal(dialect, b"\x01\x02\xff", inline=True).to_sql() assert sql == "X'0102FF'" assert params == () def test_the_digits_are_uppercase(self, dialect): - sql, _ = DdlBinaryValue(dialect, b"\xab\xcd\xef").to_sql() + sql, _ = Literal(dialect, b"\xab\xcd\xef", inline=True).to_sql() assert sql == "X'ABCDEF'" def test_an_empty_value_is_the_empty_literal(self, dialect): - sql, _ = DdlBinaryValue(dialect, b"").to_sql() + sql, _ = Literal(dialect, b"", inline=True).to_sql() assert sql == "X''" - def test_bytearray_is_normalised_before_rendering(self, dialect): - sql, _ = DdlBinaryValue(dialect, bytearray(b"\x01")).to_sql() + def test_bytearray_is_spelled_from_the_bytes_it_holds(self, dialect): + sql, _ = Literal(dialect, bytearray(b"\x01"), inline=True).to_sql() assert sql == "X'01'" -class TestUUIDValue: +class TestUUIDSpelling: """The bare quoted canonical text -- all MySQL has is a text home.""" CANONICAL = "c73d9175-0a1d-48c6-8d30-df165461328b" def test_data_position_binds_the_uuid(self, dialect): value = uuid.UUID(self.CANONICAL) - sql, params = UUIDValue(dialect, value).to_sql() + sql, params = Literal(dialect, value).to_sql() assert sql == "%s" assert params == (value,) - def test_ddl_position_is_the_quoted_canonical_text(self, dialect): - sql, params = DdlUUIDValue(dialect, uuid.UUID(self.CANONICAL)).to_sql() + def test_inline_position_is_the_quoted_canonical_text(self, dialect): + sql, params = Literal(dialect, uuid.UUID(self.CANONICAL), inline=True).to_sql() assert sql == f"'{self.CANONICAL}'" assert params == () -class TestIntervalValueStaysRefused: - """No ``format_interval_value`` on this dialect, on purpose. +class TestIntervalStaysRefused: + """No ``timedelta`` branch in this dialect's ``format_literal``, on purpose. MySQL refuses every interval literal shape with errno 1064 (measured, - see the module docstring): ``INTERVAL`` is the keyword parameter of date - arithmetic, never a value with a literal form. The base's explicit - failure is the answer, and the structural absence is asserted rather - than papered over. + see the module docstring): ``INTERVAL`` is the keyword parameter of + date arithmetic, never a value with a literal form. The base's explicit + ``TypeError`` is the answer, and the structural absence is asserted + rather than papered over. """ def test_data_position_still_binds(self, dialect): span = datetime.timedelta(days=2, hours=3) - sql, params = IntervalValue(dialect, span).to_sql() + sql, params = Literal(dialect, span).to_sql() assert sql == "%s" assert params == (span,) - def test_ddl_position_keeps_the_base_refusal(self, dialect): - with pytest.raises(NotImplementedError, match="INTERVAL literals"): - DdlIntervalValue(dialect, datetime.timedelta(days=1)).to_sql() + def test_inline_position_keeps_the_base_refusal(self, dialect): + with pytest.raises(TypeError, match="has no inline SQL literal form"): + Literal(dialect, datetime.timedelta(days=1), inline=True).to_sql() class TestTierOneAgreesWithTheBase: @@ -191,90 +123,83 @@ class TestTierOneAgreesWithTheBase: Each assertion is the base's spelling reached through this dialect's ``format_literal`` (so MySQL's escaping applies), pinned here so a base - change that breaks MySQL's grammar is caught by this file rather than in - the matrix, where the row would only say "renders". + change that breaks MySQL's grammar is caught by this file rather than + in the matrix, where the row would only say "renders". """ def test_null_binds_then_renders_as_the_keyword(self, dialect): - assert NullValue(dialect).to_sql() == ("%s", (None,)) - assert DdlNullValue(dialect).to_sql() == ("NULL", ()) + assert Literal(dialect, None).to_sql() == ("%s", (None,)) + assert Literal(dialect, None, inline=True).to_sql() == ("NULL", ()) def test_booleans_render_as_keywords(self, dialect): - assert DdlBooleanValue(dialect, True).to_sql() == ("TRUE", ()) - assert DdlBooleanValue(dialect, False).to_sql() == ("FALSE", ()) + assert Literal(dialect, True, inline=True).to_sql() == ("TRUE", ()) + assert Literal(dialect, False, inline=True).to_sql() == ("FALSE", ()) def test_integers_render_as_numeric_text(self, dialect): - assert DdlIntegerValue(dialect, 18).to_sql() == ("18", ()) - assert DdlIntegerValue(dialect, -7).to_sql() == ("-7", ()) + assert Literal(dialect, 18, inline=True).to_sql() == ("18", ()) + assert Literal(dialect, -7, inline=True).to_sql() == ("-7", ()) def test_floats_render_as_their_repr(self, dialect): - assert DdlFloatValue(dialect, 0.5).to_sql() == ("0.5", ()) - assert DdlFloatValue(dialect, 15e-03).to_sql() == ("0.015", ()) + assert Literal(dialect, 0.5, inline=True).to_sql() == ("0.5", ()) + assert Literal(dialect, 15e-03, inline=True).to_sql() == ("0.015", ()) def test_decimals_render_exact(self, dialect): value = decimal.Decimal("12.34") - assert DdlDecimalValue(dialect, value).to_sql() == ("12.34", ()) + assert Literal(dialect, value, inline=True).to_sql() == ("12.34", ()) def test_strings_double_the_quotes(self, dialect): - sql, _ = DdlStringValue(dialect, "today's").to_sql() + sql, _ = Literal(dialect, "today's", inline=True).to_sql() assert sql == "'today''s'" def test_strings_double_the_backslash_under_the_default_sql_mode(self, dialect): # The server reads a single backslash as an escape (measured: # 'a\b' comes back as 'a\x08'), so the dialect doubles backslashes # under the default mode for the literal to mean what the host holds. - sql, _ = DdlStringValue(dialect, "a\\b").to_sql() + sql, _ = Literal(dialect, "a\\b", inline=True).to_sql() assert sql == "'a\\\\b'" def test_strings_keep_the_backslash_under_no_backslash_escapes(self): dialect = MySQLDialect(sql_mode="NO_BACKSLASH_ESCAPES,STRICT_TRANS_TABLES") - sql, _ = DdlStringValue(dialect, "a\\b").to_sql() + sql, _ = Literal(dialect, "a\\b", inline=True).to_sql() assert sql == "'a\\b'" def test_dates_render_as_quoted_iso(self, dialect): - sql, _ = DdlDateValue(dialect, datetime.date(2026, 10, 17)).to_sql() + sql, _ = Literal(dialect, datetime.date(2026, 10, 17), inline=True).to_sql() assert sql == "'2026-10-17'" def test_times_render_as_quoted_iso(self, dialect): - sql, _ = DdlTimeValue(dialect, datetime.time(10, 3, 56)).to_sql() + sql, _ = Literal(dialect, datetime.time(10, 3, 56), inline=True).to_sql() assert sql == "'10:03:56'" - def test_timestamps_render_as_quoted_iso_with_a_space_separator(self, dialect): - sql, _ = DdlTimestampValue( - dialect, datetime.datetime(2024, 8, 15, 10, 59, 43) + def test_datetimes_render_as_quoted_iso_with_a_space_separator(self, dialect): + sql, _ = Literal( + dialect, datetime.datetime(2024, 8, 15, 10, 59, 43), inline=True ).to_sql() assert sql == "'2024-08-15 10:59:43'" class TestMROOrder: - """The composed order is what resolves the two overrides.""" + """The composed order is what resolves the tier-2 spellings.""" def test_the_local_mixin_wins_over_the_base(self, dialect): - assert dialect.format_binary_value.__func__.__qualname__.startswith( - "MySQLTypedValueMixin" - ) - assert dialect.format_uuid_value.__func__.__qualname__.startswith( - "MySQLTypedValueMixin" + assert dialect.format_literal.__func__.__qualname__.startswith( + "MySQLLiteralValueMixin" ) - def test_the_base_answers_the_kinds_the_local_mixin_leaves_alone(self, dialect): - assert dialect.format_null_value.__func__.__qualname__.startswith( - "TypedValueMixin" - ) - assert dialect.format_interval_value.__func__.__qualname__.startswith( - "TypedValueMixin" - ) + def test_inline_literals_route_through_the_local_mixin(self, dialect): + # format_literal_expression hands inline=True literals to + # format_literal, so the MRO test and the rendering test agree. + sql, _ = Literal(dialect, b"\x01", inline=True).to_sql() + assert sql == "X'01'" class TestLiveServerReadsTheLiteralsBack: """The tier-2 answers re-measured end to end against the running server. The DDL read-back goes over raw SQL on purpose: the literal forms are - this dialect's own product, and the expression layer has no - DDL-strategy binary/UUID value class to build a ``DEFAULT`` tree from - yet (core's own lift, ``DdlLiteral``, covers ``Literal`` only). What is - verified here is what the wave's answers claim: the server accepts the - spelling and hands the same value back. + this dialect's own product, and what is verified here is what the + wave's answers claim -- the server accepts the spelling and hands the + same value back. """ def test_binary_default_roundtrips(self, mysql_backend): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py index 1f59ae3b..046faba4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py @@ -23,7 +23,7 @@ TypeObjectSupport, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import BaseExpression, DdlLiteral +from rhosocial.activerecord.backend.expression import BaseExpression, Literal from rhosocial.activerecord.backend.expression.statements import ( AlterDomainStatement, AlterTypeStatement, @@ -122,9 +122,10 @@ def _type_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: def _domain_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: - # A CHECK expression accepts no bind parameter either, so the bound value - # is a DdlLiteral rather than a data-position Literal. - condition = DomainValueExpression(dialect) > DdlLiteral(dialect, 0) + # A CHECK expression accepts no bind parameter either, so every literal + # in the tree says ``inline=True`` itself; a binding literal here would + # be refused at render time rather than silently inlined. + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline=True) check = DomainCheckConstraint(dialect, condition) return ( CreateDomainStatement(dialect, Domain(dialect, "positive"), IntegerType(dialect)), From f3b3a6664e7f0ee968da89da0fe232c3fe4ef43c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 09:42:44 +0800 Subject: [PATCH 22/65] fix(mysql): carry the params the artifact can spell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core 679d9817 made the artifact plain JSON and the parameter sequence the only way a value travels, and the matrix run against it exposed four shapes in this backend that no artifact can spell. - expression/json.py: MySQLJSONObjectExpression drops its **kwargs. A parameter whose identity depends on how many extras arrived has no artifact spelling (§4.11.4) -- the deserializer matches parameters by name and refuses the kind outright. The mapping and the pair iterable are the two shapes that name every member; both are taken through the one declared `data` parameter now, and the kwargs test call site follows the pair-list shape. - Twenty backend-owned enums become `str` enums with `__str__ = str.__str__` (admin, load_xml, maintenance, partition, column, json_duality_view, optimizer_hint, table_options, charset_collation). A plain Enum is neither a JSON scalar nor a node, so serialization refused every instance that carried one; a str member *is* the word the artifact carries. - The classes whose formatters read `.value` accept the word and store the member (KILL target, FLUSH/RESET options, LOAD XML selectors, maintenance selectors and option lists, SUBPARTITION BY strategy) -- §10: wide entry, normalised storage, so a restored instance renders what the original rendered. The word a vocabulary does not name is now refused at construction (MySQLResetExpression becomes unbuildable by the generic constructor; MySQLSubpartitionClause refuses with ValueError where it refused with TypeError before). - mixins/partition.py: format_partition_clause renders the clause PartitionClause actually declares -- method and keys. It used to route every method spelling into the format_partition_by_* entries, which read `subpartition_by` / `partitions` off nodes that do not declare them; the six MySQLPartitionBy* subclasses now name their own entries and the base clause renders `PARTITION BY ()`. - expression/load_data.py: LoadDataOptions becomes a clause node. A constructor parameter is a scalar or an expression instance (§10) -- those are the two forms an artifact can carry, and a dataclass is neither; the statement's parameters now carry a nested load_data_options node and the test call sites pass the dialect. Evidence (central venv, PYTHONPATH=src;tests, exit 0): 200 tests across test_mysql_mixin_expressions.py, test_json_expressions.py, test_json_functions.py, test_mysql_partition_expressions.py; smoke: every touched class round-trips all three channels and renders, and the JSON object renders JSON_OBJECT with its params. ruff on the changed files: error set identical to HEAD (all remaining findings pre-exist). --- .../backend/impl/mysql/expression/admin.py | 36 +++++++-- .../backend/impl/mysql/expression/column.py | 10 ++- .../backend/impl/mysql/expression/json.py | 25 +++--- .../mysql/expression/json_duality_view.py | 5 +- .../impl/mysql/expression/load_data.py | 77 ++++++++++++++----- .../backend/impl/mysql/expression/load_xml.py | 17 +++- .../impl/mysql/expression/maintenance.py | 35 +++++++-- .../impl/mysql/expression/optimizer_hint.py | 5 +- .../impl/mysql/expression/partition.py | 54 ++++++++++--- .../impl/mysql/expression/table_options.py | 5 +- .../impl/mysql/mixins/charset_collation.py | 15 +++- .../backend/impl/mysql/mixins/partition.py | 41 ++++++---- .../feature/backend/dml/test_load_data.py | 20 ++--- .../feature/backend/test_json_expressions.py | 14 +++- .../backend/test_mysql_mixin_expressions.py | 16 ++-- .../test_mysql_partition_expressions.py | 19 ++++- 16 files changed, 291 insertions(+), 103 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py index 268d633a..377c0a95 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py @@ -30,9 +30,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class FlushOption(Enum): +class FlushOption(str, Enum): """FLUSH statement options.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + TABLES = "TABLES" LOGS = "LOGS" PRIVILEGES = "PRIVILEGES" @@ -53,25 +56,34 @@ class FlushOption(Enum): SLAVE = "SLAVE" -class ResetOption(Enum): +class ResetOption(str, Enum): """RESET statement options.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + MASTER = "MASTER" REPLICA = "REPLICA" SLAVE = "SLAVE" PERSIST = "PERSIST" -class KillTarget(Enum): +class KillTarget(str, Enum): """KILL target selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + CONNECTION = "CONNECTION" QUERY = "QUERY" -class HandlerReadMode(Enum): +class HandlerReadMode(str, Enum): """HANDLER READ modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + FIRST = "FIRST" NEXT = "NEXT" PREV = "PREV" @@ -89,7 +101,10 @@ def __init__( no_write_to_binlog: bool = False, ): super().__init__(dialect) - self.options: List[FlushOption] = list(options) + # Members or the words they spell (§10: wide entry, normalised + # storage) -- the formatter reads ``.value``, so a raw string would + # only render on the constructing side of a round-trip. + self.options: List[FlushOption] = [FlushOption(o) for o in options] self.no_write_to_binlog: bool = no_write_to_binlog def validate(self, strict: bool = True) -> None: @@ -114,7 +129,10 @@ def __init__( option: ResetOption, ): super().__init__(dialect) - self.option: ResetOption = option + # The member or the word it spells: the formatter reads ``.value``. + # A word this enum does not name is refused here, where the caller + # can still see which option was meant. + self.option: ResetOption = ResetOption(option) @property def format_method(self) -> str: @@ -432,8 +450,12 @@ def __init__( target: KillTarget = KillTarget.CONNECTION, ): super().__init__(dialect) + # The member or the word it spells: an artifact carries the word, so a + # read must not need the enum object to get back. The formatter reads + # ``.value``, which only the member has -- a stored raw string would + # render the pre-round-trip instance and refuse the restored one. self.processlist_id: int = processlist_id - self.target: KillTarget = target + self.target: KillTarget = KillTarget(target) @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py index cbc0a901..ccc8e6b3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py @@ -34,16 +34,22 @@ ] -class MySQLColumnFormat(Enum): +class MySQLColumnFormat(str, Enum): """MySQL NDB ``COLUMN_FORMAT`` values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + FIXED = "FIXED" DYNAMIC = "DYNAMIC" DEFAULT = "DEFAULT" -class MySQLColumnStorage(Enum): +class MySQLColumnStorage(str, Enum): """MySQL NDB ``STORAGE`` values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + DISK = "DISK" MEMORY = "MEMORY" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py index 8fe76661..19f92532 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py @@ -63,7 +63,16 @@ class MySQLJSONObjectExpression(AliasableMixin, SQLValue): Example: >>> expr = MySQLJSONObjectExpression(dialect, {'name': 'Alice', 'age': 30}) OR - >>> expr = MySQLJSONObjectExpression(dialect, ('name', 'Alice'), ('age', 30)) + >>> expr = MySQLJSONObjectExpression(dialect, [('name', 'Alice'), ('age', 30)]) + + ``data`` is the one parameter the pairs come from: a mapping, or an + iterable of ``(key, value)`` pairs. The class used to also accept the + pairs as ``**kwargs`` -- ``name="Alice"`` -- but a parameter whose + identity depends on how many extras arrived has no artifact spelling + (§4.11.4): the deserializer matches parameters by name, and a reader + would have to be told which names happened to arrive. The mapping and + the pair list are the two shapes that name every member, so they are + the two the constructor takes. """ def __init__( @@ -72,20 +81,10 @@ def __init__( data: Any = None, *, alias: Optional[str] = None, - **kwargs: Any, ): super().__init__(dialect) - self.data = data # keep raw for get_params() introspection - self.kwargs: Dict[str, Any] = dict(kwargs) - if data is not None and kwargs: - pairs = self._convert_to_pairs(data) + self._convert_to_pairs(kwargs) - elif data is not None: - pairs = self._convert_to_pairs(data) - elif kwargs: - pairs = self._convert_to_pairs(kwargs) - else: - pairs = [] - self.pairs = pairs + self.data = data + self.pairs = self._convert_to_pairs(data) if data is not None else [] self.alias = alias def _convert_to_pairs(self, data: Any) -> List[tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py index f609ed0c..ad742ae0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py @@ -22,8 +22,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class DualityViewDMLTag(Enum): +class DualityViewDMLTag(str, Enum): """DML permission tags for JSON_DUALITY_OBJECT.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + INSERT = "INSERT" UPDATE = "UPDATE" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py index 6ac0a9aa..fe2ee70f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py @@ -6,7 +6,6 @@ MySQL's LOAD DATA INFILE functionality. """ -from dataclasses import dataclass from typing import Any, Dict, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -16,9 +15,17 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -@dataclass -class LoadDataOptions: - """Options for LOAD DATA INFILE statement. +class LoadDataOptions(BaseExpression): + """Options for LOAD DATA INFILE statement, as a node. + + A clause node rather than a plain dataclass: ``options`` is a + constructor parameter of :class:`MySQLLoadDataExpression`, and a + parameter is either a language-neutral scalar or an expression + instance (§10) -- those are the two forms an artifact can carry, and a + dataclass is neither. Being a node is also what makes the bundle + readable back: the artifact carries a nested ``load_data_options`` + node and the reader rebuilds it by name, exactly like any other + clause. Attributes: local: Use LOCAL keyword (client-side file) @@ -35,18 +42,52 @@ class LoadDataOptions: set_assignments: SET clause for column transformations """ - local: bool = True - replace: bool = False - ignore: bool = False - character_set: Optional[str] = None - fields_terminated_by: Optional[str] = None - fields_enclosed_by: Optional[str] = None - fields_escaped_by: Optional[str] = None - lines_starting_by: Optional[str] = None - lines_terminated_by: Optional[str] = None - ignore_lines: Optional[int] = None - column_list: Optional[List[str]] = None - set_assignments: Optional[Dict[str, Any]] = None + def __init__( + self, + dialect: "SQLDialectBase", + *, + local: bool = True, + replace: bool = False, + ignore: bool = False, + character_set: Optional[str] = None, + fields_terminated_by: Optional[str] = None, + fields_enclosed_by: Optional[str] = None, + fields_escaped_by: Optional[str] = None, + lines_starting_by: Optional[str] = None, + lines_terminated_by: Optional[str] = None, + ignore_lines: Optional[int] = None, + column_list: Optional[List[str]] = None, + set_assignments: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.local: bool = local + self.replace: bool = replace + self.ignore: bool = ignore + self.character_set: Optional[str] = character_set + self.fields_terminated_by: Optional[str] = fields_terminated_by + self.fields_enclosed_by: Optional[str] = fields_enclosed_by + self.fields_escaped_by: Optional[str] = fields_escaped_by + self.lines_starting_by: Optional[str] = lines_starting_by + self.lines_terminated_by: Optional[str] = lines_terminated_by + self.ignore_lines: Optional[int] = ignore_lines + self.column_list: Optional[List[str]] = ( + list(column_list) if column_list is not None else None + ) + self.set_assignments: Optional[Dict[str, Any]] = ( + dict(set_assignments) if set_assignments is not None else None + ) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression. + + No dialect defines a standalone entry for this clause: the LOAD + DATA statement's own formatter renders the options inline, reading + the fields declared here. The clause travels as a node so the + statement's parameters can carry it, not because it renders + standing alone. + """ + return "format_load_data_options" class MySQLLoadDataExpression(BaseExpression): @@ -75,12 +116,12 @@ def __init__( Supplying an object rather than a bare string is what makes ``LOAD DATA ... INTO TABLE `other_db`.`users``` expressible; a string can only ever mean the session's current database. - options: Load options (default: LoadDataOptions()) + options: Load options (default: LoadDataOptions(dialect)) """ super().__init__(dialect) self.file_path = file_path self.table = table - self.options = options or LoadDataOptions() + self.options = options or LoadDataOptions(dialect) def validate(self, strict: bool = True) -> None: """Validate expression parameters. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py index cd2c7cb7..bb1a3463 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py @@ -21,17 +21,23 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class LoadXMLPriority(Enum): +class LoadXMLPriority(str, Enum): """LOW_PRIORITY / CONCURRENT selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" LOW_PRIORITY = "LOW_PRIORITY" CONCURRENT = "CONCURRENT" -class LoadXMLConflictMode(Enum): +class LoadXMLConflictMode(str, Enum): """REPLACE / IGNORE selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" REPLACE = "REPLACE" IGNORE = "IGNORE" @@ -80,8 +86,11 @@ def __init__( self.file_path: str = file_path self.table: "Table" = table self.local: bool = local - self.priority: LoadXMLPriority = priority - self.conflict_mode: LoadXMLConflictMode = conflict_mode + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. + self.priority: LoadXMLPriority = LoadXMLPriority(priority) + self.conflict_mode: LoadXMLConflictMode = LoadXMLConflictMode(conflict_mode) self.character_set: Optional[str] = character_set self.rows_identified_by: Optional[str] = rows_identified_by self.ignore_count: Optional[int] = ignore_count diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py index b68adbb9..e0324ac9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py @@ -21,17 +21,23 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class NoWriteToBinlogOption(Enum): +class NoWriteToBinlogOption(str, Enum): """NO_WRITE_TO_BINLOG / LOCAL synonym selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" NO_WRITE_TO_BINLOG = "NO_WRITE_TO_BINLOG" LOCAL = "LOCAL" -class CheckTableOption(Enum): +class CheckTableOption(str, Enum): """CHECK TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + FOR_UPGRADE = "FOR UPGRADE" QUICK = "QUICK" FAST = "FAST" @@ -40,16 +46,22 @@ class CheckTableOption(Enum): CHANGED = "CHANGED" -class ChecksumTableOption(Enum): +class ChecksumTableOption(str, Enum): """CHECKSUM TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + QUICK = "QUICK" EXTENDED = "EXTENDED" -class RepairTableOption(Enum): +class RepairTableOption(str, Enum): """REPAIR TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + QUICK = "QUICK" EXTENDED = "EXTENDED" USE_FRM = "USE_FRM" @@ -80,7 +92,12 @@ def __init__( ): super().__init__(dialect) self.tables: List[RelationObject] = list(tables) - self.no_write_to_binlog: NoWriteToBinlogOption = no_write_to_binlog + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. + self.no_write_to_binlog: NoWriteToBinlogOption = NoWriteToBinlogOption( + no_write_to_binlog + ) def validate(self, strict: bool = True) -> None: """Validate table list. @@ -136,7 +153,7 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.options: List[CheckTableOption] = list(options or []) + self.options: List[CheckTableOption] = [CheckTableOption(o) for o in (options or [])] @property def format_method(self) -> str: @@ -162,7 +179,9 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.option: Optional[ChecksumTableOption] = option + self.option: Optional[ChecksumTableOption] = ( + ChecksumTableOption(option) if option is not None else None + ) @property def format_method(self) -> str: @@ -195,7 +214,7 @@ def __init__( tables, no_write_to_binlog=no_write_to_binlog, ) - self.options: List[RepairTableOption] = list(options or []) + self.options: List[RepairTableOption] = [RepairTableOption(o) for o in (options or [])] @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py index 44a7f212..a5f31ba4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py @@ -18,8 +18,11 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -class OptimizerHintType(Enum): +class OptimizerHintType(str, Enum): """Types of MySQL optimizer hints.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + SET_VAR = "SET_VAR" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py index a6926a0b..8fdb6088 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py @@ -18,9 +18,12 @@ from rhosocial.activerecord.backend.expression.objects import Table -class MySQLPartitionStrategy(Enum): +class MySQLPartitionStrategy(str, Enum): """MySQL table partitioning strategies supported by MySQLPartitionMixin.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + RANGE = "RANGE" RANGE_COLUMNS = "RANGE COLUMNS" LIST = "LIST" @@ -31,13 +34,16 @@ class MySQLPartitionStrategy(Enum): LINEAR_KEY = "LINEAR KEY" -class MySQLSubpartitionStrategy(Enum): +class MySQLSubpartitionStrategy(str, Enum): """MySQL subpartitioning strategies. MySQL restricts subpartitioning to HASH and KEY (and their LINEAR variants) only. RANGE and LIST cannot be used for subpartitioning. """ + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + HASH = "HASH" KEY = "KEY" LINEAR_HASH = "LINEAR HASH" @@ -107,7 +113,7 @@ class MySQLSubpartitionClause(BaseExpression): override the template for each parent partition. Raises: - TypeError: if strategy is not a MySQLSubpartitionStrategy. + ValueError: if strategy is not a MySQLSubpartitionStrategy word. ValueError: if count is provided but is not a positive integer. """ @@ -121,14 +127,14 @@ def __init__( definitions: Optional[Sequence[MySQLSubpartitionDefinition]] = None, ): super().__init__(dialect) - if not isinstance(strategy, MySQLSubpartitionStrategy): - raise TypeError( - "strategy must be a MySQLSubpartitionStrategy value, " - f"got {type(strategy).__name__}" - ) + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. A word this enum does not + # name is refused here, where the caller can still see which + # strategy was meant. + self.strategy = MySQLSubpartitionStrategy(strategy) if count is not None and (not isinstance(count, int) or count <= 0): raise ValueError("count must be a positive integer when provided") - self.strategy = strategy self.expression = expression self.count = count self.definitions = list(definitions) if definitions else None @@ -255,6 +261,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_range" + class MySQLPartitionByRangeColumns(MySQLPartitionClause): """MySQL PARTITION BY RANGE COLUMNS expression. @@ -279,6 +290,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_range_columns" + class MySQLPartitionByList(MySQLPartitionClause): """MySQL PARTITION BY LIST expression. @@ -303,6 +319,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_list" + class MySQLPartitionByListColumns(MySQLPartitionClause): """MySQL PARTITION BY LIST COLUMNS expression. @@ -327,6 +348,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_list_columns" + class MySQLPartitionByHash(MySQLPartitionClause): """MySQL PARTITION BY HASH expression.""" @@ -344,6 +370,11 @@ def __init__( self.partitions_count = partitions_count self.linear = linear + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_hash" + class MySQLPartitionByKey(MySQLPartitionClause): """MySQL PARTITION BY KEY expression. @@ -372,6 +403,11 @@ def __init__( self.partitions_count = partitions_count self.linear = linear + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_by_key" + class MySQLPartitionTableExpression(BaseExpression): """Base for every ``ALTER TABLE ... `` statement. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py index 3ac11b73..e7d37e6d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py @@ -28,8 +28,11 @@ ] -class MySQLRowFormat(Enum): +class MySQLRowFormat(str, Enum): """MySQL ``ROW_FORMAT`` table option values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + DEFAULT = "DEFAULT" DYNAMIC = "DYNAMIC" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py index 7c46f432..d8eb1fc4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py @@ -24,8 +24,11 @@ ] -class MySQLCharset(Enum): +class MySQLCharset(str, Enum): """MySQL character sets for table/column ``CHARACTER SET``.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + ARMSCII8 = "armscii8" ASCII = "ascii" @@ -71,8 +74,11 @@ class MySQLCharset(Enum): UTF32 = "utf32" -class MySQLStorageEngine(Enum): +class MySQLStorageEngine(str, Enum): """Built-in MySQL storage engines for ``ENGINE=``.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + INNODB = "InnoDB" MYISAM = "MyISAM" @@ -89,8 +95,11 @@ class MySQLStorageEngine(Enum): TEMPORARY = "TEMPORARY" -class MySQLCollation(Enum): +class MySQLCollation(str, Enum): """MySQL collations for expression-level COLLATE.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + # Built-in pseudo collation BINARY = "binary" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index d4513860..d1006246 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -5,6 +5,10 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( + MySQLPartitionStrategy, +) + from .object_kind import require_kind if TYPE_CHECKING: # pragma: no cover @@ -154,26 +158,35 @@ def supports_repair_partition(self) -> bool: return True def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: - """Format MySQL PARTITION BY clause from a PartitionClause expression.""" + """Format the clause shape ``PARTITION BY ()``. + + This is the contract :class:`~...statements.ddl_partition. + PartitionClause` declares -- method and keys, nothing else -- so it + is all that is read here. Named partitions, ``VALUES`` boundaries + and subpartitioning are fields the MySQL ``MySQLPartitionBy*`` + subclasses declare, and each of those classes names its own entry + (``format_partition_by_range`` and siblings); routing a base clause + into one of those entries used to read ``subpartition_by`` off a + node that does not declare it. + """ if not self.supports_table_partitioning(): raise UnsupportedFeatureError(self.name, "table partitioning") if not self.supports_partitioned_table_creation(): raise UnsupportedFeatureError(self.name, "partitioned table creation") method = expr.method.upper() - if method == "RANGE": - return self.format_partition_by_range(expr) - if method == "RANGE COLUMNS": - return self.format_partition_by_range_columns(expr) - if method == "LIST": - return self.format_partition_by_list(expr) - if method == "LIST COLUMNS": - return self.format_partition_by_list_columns(expr) - if method in {"HASH", "LINEAR HASH"}: - return self.format_partition_by_hash(expr) - if method in {"KEY", "LINEAR KEY"}: - return self.format_partition_by_key(expr) - raise ValueError("Invalid MySQL partition method") + if not any( + method == strategy.value for strategy in MySQLPartitionStrategy + ): + raise ValueError("Invalid MySQL partition method") + + key_sql_parts = [] + params: List[Any] = [] + for key in expr.keys: + key_sql, key_params = key.to_sql() + key_sql_parts.append(key_sql) + params.extend(key_params) + return f" PARTITION BY {method} ({', '.join(key_sql_parts)})", tuple(params) def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: """Format PARTITION BY RANGE with optional subpartitioning.""" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py index cef9f796..327044e0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py @@ -69,7 +69,7 @@ def test_load_data_expression_local(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True), + options=LoadDataOptions(mysql_backend.dialect, local=True), ) sql, params = expr.to_sql() @@ -81,7 +81,7 @@ def test_load_data_expression_fields_terminated(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, fields_terminated_by=","), + options=LoadDataOptions(mysql_backend.dialect, local=True, fields_terminated_by=","), ) sql, params = expr.to_sql() @@ -93,7 +93,7 @@ def test_load_data_expression_ignore_lines(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, ignore_lines=1), + options=LoadDataOptions(mysql_backend.dialect, local=True, ignore_lines=1), ) sql, params = expr.to_sql() @@ -105,7 +105,7 @@ def test_load_data_expression_replace_mode(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, replace=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, replace=True), ) sql, params = expr.to_sql() @@ -117,7 +117,7 @@ def test_load_data_expression_ignore_mode(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, ignore=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, ignore=True), ) sql, params = expr.to_sql() @@ -130,7 +130,7 @@ def test_load_data_replace_and_ignore_mutually_exclusive(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, replace=True, ignore=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, replace=True, ignore=True), ) with pytest.raises(ValueError, match="Cannot use both REPLACE and IGNORE"): @@ -142,7 +142,7 @@ def test_load_data_expression_with_columns(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, column_list=["name", "email", "age"]), + options=LoadDataOptions(mysql_backend.dialect, local=True, column_list=["name", "email", "age"]), ) sql, params = expr.to_sql() @@ -154,7 +154,7 @@ def test_load_data_expression_with_lines_terminated(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, lines_terminated_by="\n"), + options=LoadDataOptions(mysql_backend.dialect, local=True, lines_terminated_by="\n"), ) sql, params = expr.to_sql() @@ -166,7 +166,7 @@ def test_load_data_expression_character_set(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, character_set="utf8mb4"), + options=LoadDataOptions(mysql_backend.dialect, local=True, character_set="utf8mb4"), ) sql, params = expr.to_sql() @@ -178,7 +178,7 @@ def test_load_data_expression_full_csv(self, mysql_backend): dialect=mysql_backend.dialect, file_path="/tmp/data.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions( + options=LoadDataOptions(mysql_backend.dialect, local=True, fields_terminated_by=",", fields_enclosed_by='"', diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py index 3e64ce26..4a0290e4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py @@ -67,15 +67,23 @@ def test_json_object_basic(self): assert "name" in params assert "age" in params - def test_json_object_with_kwargs(self): - """Test JSON object creation with keyword arguments.""" + def test_json_object_from_pairs(self): + """Test JSON object creation from an iterable of pairs. + + The constructor used to also take the pairs as ``**kwargs``, but a + parameter whose identity depends on how many extras arrived has no + artifact spelling (§4.11.4); the mapping and the pair list are the + two shapes that name every member, and both name them up front. + """ dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLJSONObjectExpression(dialect, name="John", age=30).as_("obj") + expr = MySQLJSONObjectExpression(dialect, [("name", "John"), ("age", 30)]).as_("obj") sql, params = expr.to_sql() assert "JSON_OBJECT" in sql assert "AS `obj`" in sql + assert "John" in params + assert 30 in params class TestMySQLJSONArrayExpression: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index f691cbd4..6a3008bd 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -139,7 +139,7 @@ def test_load_data_qualified_database(self): def test_load_data_local_replace(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(local=True, replace=True) + options = LoadDataOptions(dialect, local=True, replace=True) expr = MySQLLoadDataExpression( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) @@ -148,7 +148,7 @@ def test_load_data_local_replace(self): def test_load_data_ignore(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(local=True, ignore=True) + options = LoadDataOptions(dialect, local=True, ignore=True) expr = MySQLLoadDataExpression( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) @@ -157,7 +157,7 @@ def test_load_data_ignore(self): def test_load_data_full_options(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions( + options = LoadDataOptions(dialect, local=True, character_set="utf8mb4", fields_terminated_by=",", @@ -184,7 +184,7 @@ def test_load_data_full_options(self): def test_load_data_validate_replaces_ignore_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(replace=True, ignore=True) + options = LoadDataOptions(dialect, replace=True, ignore=True) expr = MySQLLoadDataExpression( dialect, file_path="data.csv", table=Table(dialect, "t"), options=options ) @@ -1023,8 +1023,14 @@ def test_subpartition_clause_negative_count(self): ) def test_subpartition_clause_wrong_type(self): + """A word the strategy vocabulary does not name is refused. + + The entry is wide -- the member or the word it spells (§10) -- so the + refusal is a ``ValueError`` from the enum the constructor normalises + through, naming the word it cannot place. + """ dialect = MySQLDialect(version=(8, 0, 0)) - with pytest.raises(TypeError): + with pytest.raises(ValueError): MySQLSubpartitionClause( dialect, strategy="INVALID", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py index 9e52dbf1..c0370a88 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py @@ -499,10 +499,21 @@ def test_subpartition_definition_rejects_empty_name(dialect): MySQLSubpartitionDefinition(name="") -def test_subpartition_clause_rejects_invalid_strategy(dialect): - """Subpartition clause should reject non-MySQLSubpartitionStrategy values.""" - with pytest.raises(TypeError, match="must be a MySQLSubpartitionStrategy"): - MySQLSubpartitionClause(dialect, strategy="HASH") # type: ignore[arg-type] +def test_subpartition_clause_accepts_strategy_word(dialect): + """The word the artifact carries is a valid entry, and storage is the member. + + The constructor used to refuse the plain word outright, which made the + serialized form of its own parameter unreadable (§10: the entry accepts + the member or the word it spells, and stores the member). + """ + clause = MySQLSubpartitionClause(dialect, strategy="HASH") # type: ignore[arg-type] + assert clause.strategy is MySQLSubpartitionStrategy.HASH + + +def test_subpartition_clause_rejects_unknown_strategy_word(dialect): + """A word the vocabulary does not name is refused, naming the word.""" + with pytest.raises(ValueError, match="INVALID"): + MySQLSubpartitionClause(dialect, strategy="INVALID") # type: ignore[arg-type] def test_subpartition_clause_rejects_invalid_count(dialect): From a973e8541f9603be82244d008492544828f21990 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 10:09:07 +0800 Subject: [PATCH 23/65] test(mysql): reconcile the roundtrip matrix with the plain-JSON artifact Reconciliation baseline: core 679d9817 (a node's kind its identity and its payload plain JSON). Full matrix file, 538 collected classes across core's expression package and this backend's, every table exact in both directions, exit 0. Dedicated constructors (the generic filler cannot know these shapes, so each carries an explicit factory and a reason row): - the string-function family whose operands are SQLValue forward references (ChrFunction, SpaceFunction, LeftFunction, RightFunction, OverlayExpression, SubstringExpression) -- the filler hands plain strings where value nodes belong, and Left/Right/Substring compare a Literal's value against a bound the string cannot survive on the way back; - the two extract spellings (ExtractFieldExpression, _ExtractBase) whose field must name a DateTimeField member or its word. Table adjudication, each row against the live dialect: - _UNBUILDABLE: MySQLResetExpression joins -- the constructor now normalises its option through the enum and refuses the filler's guess at construction. ValidateConstraintAction, EnumType and CustomType rows retire -- all three construct since the annotation-inheritance wave. AlterConstraintAction's reason now names the keyword-only constraint_kind. - _NOT_RENDERED: the eleven declared-result annotation classes join -- format_declared_result reads expr.source.alias, an optional field a Literal does not declare (core defect, named per row); ColumnGeneratedConstraint joins (ValueError on a string expression slot). MySQLResetExpression leaves for _UNBUILDABLE. - _DECLARED_GAP: graph.EdgeTableDefinition replaces the stale graph.EdgeTable row (core renamed the class); load_data.LoadDataOptions joins -- the clause is a node so the statement's parameters can carry it, and its rendering is inline in the statement's own formatter. - _ROUNDTRIP_DEFECT, a new table with the same shape and the same bidirectional exactness the other tables have: classes whose artifact round-trip does not deliver an equivalent instance for a reason the core worktree owns. Nineteen rows, each naming the defect: DateTimeField/IntervalUnit/XML enums carrying no `__str__ = str.__str__` (the XML channel writes EnumName.MEMBER and the read refuses it), two plain core enums the value vocabulary cannot write (ReferentialAction, GeneratedColumnType), constructors that refuse or mis-store their own artifact's word (CreateTableCloneStatement, CreateTriggerStatement, ForUpdateClause, GraphEdge), two dataclass parameters with no artifact form (GraphColumn, ViewOptions), and Subquery binding a JSON array where it constructed a tuple. The row is the accounting, not the fix: when core lands a fix the row goes stale and the exactness test says to delete it. Also: test_constructor_tables_are_exact reads the vendored harness now -- the testsuite module it imported was retired when round-trip coverage moved to the owning repositories. Evidence (central venv, PYTHONPATH=src;tests): the full test_expression_roundtrip_all.py run, exit 0; ruff on the file: clean. --- .../backend/test_expression_roundtrip_all.py | 411 +++++++++++++++++- 1 file changed, 393 insertions(+), 18 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 5d20b184..53a1606e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -207,6 +207,36 @@ def _table(d, name="t"): "predicates.ILikePredicate": "column is annotated Any, so the generic " "constructor supplies a plain string where a value node belongs and the " "instance cannot render.", + # The string-function family whose operands are ``SQLValue`` forward + # references: the generic guess hands plain strings where value nodes + # belong, and the classes compare a Literal's value against a bound + # (``count.value < 0``) that a string cannot survive on the way back. + # The factories hand real value nodes. + "functions.string.ChrFunction": "code is a SQLValue forward reference, so " + "the generic constructor supplies a plain string where a value node " + "belongs.", + "functions.string.SpaceFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string where a value node " + "belongs.", + "functions.string.LeftFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string whose second " + "construction trips the n >= 0 comparison.", + "functions.string.RightFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string whose second " + "construction trips the n >= 0 comparison.", + "functions.string.OverlayExpression": "target / replacement / start are " + "SQLValue forward references, so the generic constructor supplies plain " + "strings where value nodes belong.", + "functions.string.SubstringExpression": "target / start are SQLValue " + "forward references, so the generic constructor supplies plain strings " + "whose second construction trips the start >= 1 comparison.", + # The two extract spellings: ``field`` reaches ``normalize_datetime_field`` + # at construction, and the generic guess is not a word the vocabulary + # names. + "datetime.ExtractFieldExpression": "field must name a DateTimeField member " + "or its word; the generic constructor supplies \"x\".", + "datetime._ExtractBase": "field must name a DateTimeField member or its " + "word; the generic constructor supplies \"x\".", # The two nodes whose operation is a closed set of *words*, validated at # construction. The generic constructor guesses a string for an unannotated # ``op`` and the node refuses it, so the factories hand a real word form. @@ -285,6 +315,18 @@ def partition_maintenance(d, cls_name): from rhosocial.activerecord.backend.expression.statements.dql import ( QueryStatement, ) + from rhosocial.activerecord.backend.expression.datetime import ( + ExtractFieldExpression, + _ExtractBase, + ) + from rhosocial.activerecord.backend.expression.functions.string import ( + ChrFunction, + LeftFunction, + OverlayExpression, + RightFunction, + SpaceFunction, + SubstringExpression, + ) factories = { "predicates.LikePredicate": @@ -306,6 +348,24 @@ def partition_maintenance(d, cls_name): d, select=[Column(d, "id")], from_=Table(d, "other") ), ), + "functions.string.ChrFunction": lambda d: ChrFunction(d, Literal(d, 65)), + "functions.string.SpaceFunction": lambda d: SpaceFunction(d, Literal(d, 3)), + "functions.string.LeftFunction": + lambda d: LeftFunction(d, Literal(d, "abcdef"), Literal(d, 3)), + "functions.string.RightFunction": + lambda d: RightFunction(d, Literal(d, "abcdef"), Literal(d, 3)), + "functions.string.OverlayExpression": + lambda d: OverlayExpression( + d, Literal(d, "abcdef"), Literal(d, "XY"), Literal(d, 2), Literal(d, 3) + ), + "functions.string.SubstringExpression": + lambda d: SubstringExpression( + d, Literal(d, "abcdef"), Literal(d, 1), Literal(d, 3) + ), + "datetime.ExtractFieldExpression": + lambda d: ExtractFieldExpression(d, "YEAR", Column(d, "created_at")), + "datetime._ExtractBase": + lambda d: _ExtractBase(d, "YEAR", Column(d, "created_at")), "match_against.MySQLMatchAgainstExpression": lambda d: MySQLMatchAgainstExpression(d, columns=["title"], search_string="x"), "json.MySQLJSONObjectExpression": @@ -398,19 +458,19 @@ def partition_maintenance(d, cls_name): # :data:`_DECLARED_GAP`, which is where a class that constructs but does not # render belongs. _UNBUILDABLE = { + # RESET names its option through the enum the constructor normalises + # through, so the generic guess ("x") is refused at construction rather + # than at render time. + "admin.MySQLResetExpression": + "option must be a ResetOption member or its word; the constructor " + "normalises through the enum and refuses \"x\" at construction.", "statements.ddl_alter.AlterConstraintAction": - "constraint_name defaults to None and the constructor rejects it, so " - "the generic placeholder is never reached.", - "statements.ddl_alter.ValidateConstraintAction": - "constraint_name defaults to None and the constructor rejects it.", + "constraint_kind is a required keyword-only parameter naming the " + "branch class, and constraint_name defaults to None and is refused; " + "the generic placeholder reaches neither.", "statements.ddl_domain.AddDomainCheckAction": "check must be a DomainCheckConstraint instance; the generic " "constructor supplies a string.", - "types.enum_.EnumType": - "values defaults to None and EnumType requires a non-empty list.", - "types.custom.CustomType": - "raw defaults to '' and CustomType requires a SQL type name; dialect " - "comes first, so a required raw behind that default is not expressible.", "types.MySQLVarBinaryType": "length defaults to None and the class requires a length, so no " "signature makes it required at the filler's call site.", @@ -472,6 +532,16 @@ def partition_maintenance(d, cls_name): "the class is unreachable from any rendering path in this backend -- " "nothing in src or tests constructs it.", ), + # --- The clause travels as a node; its rendering is inline. ----------- + "load_data.LoadDataOptions": ( + "format_load_data_options", + "DECLARED BUT NOT RENDERED. The LOAD DATA statement's own formatter " + "renders the options inline, reading the fields this clause declares; " + "no dialect defines a standing-alone entry for it. The clause is a " + "node because a constructor parameter is a scalar or an expression " + "instance (§10) and a dataclass is neither -- not because it renders " + "by itself.", + ), # --- SQL/PGQ: property graphs are not a MySQL feature. ---------------- "graph.AlterPropertyGraphStatement": ( "format_alter_property_graph_statement", @@ -490,9 +560,11 @@ def partition_maintenance(d, cls_name): "format_drop_property_graph_statement", "SQL/PGQ: no format_drop_property_graph_statement.", ), - "graph.EdgeTable": ( - "format_edge_table", - "SQL/PGQ edge table; no format_edge_table.", + "graph.EdgeTableDefinition": ( + "format_edge_table_definition", + "SQL/PGQ edge table definition; no format_edge_table_definition. The " + "row was pinned as graph.EdgeTable / format_edge_table until core " + "renamed the declaration class.", ), "graph.GraphTableExpression": ( "format_graph_table_expression", @@ -786,10 +858,72 @@ def partition_maintenance(d, cls_name): "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLResetExpression": ( + # --- The declared-result annotations render through a formatter that + # --- reads an optional field not every node declares. ---------------- + # ``format_declared_result`` (a core dialect mixin) reads + # ``expr.source.alias`` directly. ``alias`` is an optional field and + # not every node declares it -- ``Literal`` has none -- so any + # annotation over a bare literal is refused with AttributeError from + # inside the renderer. §11.2 spells the expectation: an optional field + # is a class attribute with a default, and a formatter reads declared + # fields. These rows name the defect so the table says to come back + # when the formatter or the base declares it. + "declared._Annotation": ( "AttributeError", - "option must be a ResetOption member; the generic constructor supplies " - "a string.", + "format_declared_result reads expr.source.alias; the annotation's " + "source is a Literal, which declares no alias. The same defect sits " + "under every row below -- one entry per class because the table is " + "keyed by class.", + ), + "declared.ArrayAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.BinaryAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.BooleanAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.IntegerAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.JSONAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.NumberAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.TemporalAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.TextAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.UUIDAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.XMLAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", ), "alter_column.MySQLAddColumn": ( "AttributeError", @@ -899,6 +1033,13 @@ def partition_maintenance(d, cls_name): "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), + "statements.ddl_table.ColumnGeneratedConstraint": ( + "ValueError", + "The generic constructor supplies a plain string for the generated " + "expression, and the constraint refuses to render an expression slot " + "that cannot render. The class round-trips its enum through no " + "artifact either -- see _ROUNDTRIP_DEFECT.", + ), "statements.ddl_trigger.CreateTriggerStatement": ( "AttributeError", "timing must be a TriggerTiming member; the generic constructor " @@ -932,6 +1073,165 @@ def partition_maintenance(d, cls_name): ), } +# Classes that build, whose round-trip through the artifact does not deliver +# an equivalent instance -- whether the parameter comparison catches it or the +# re-render after the trip does -- for a reason that lives outside this +# repository. Keyed by fqn suffix; the value is +# ``(exception type name, reason)`` and both are asserted, so a row cannot +# quietly start describing a different failure. +# +# The serialization half of this matrix has no other escape hatch, and it +# needs exactly one: every buildable class must survive all three channels, +# and these classes cannot, because a *core* class breaks the trip -- an enum +# whose spelling the XML channel mangles, an enum whose member the value +# vocabulary refuses to write, a constructor that refuses the word its own +# artifact carries, or a parameter whose type has no artifact form at all. +# The owner is the core worktree; each reason names the defect and the class +# it sits in, and the exactness test below makes a core fix delete its own +# row: once the class round-trips, a row left behind is a failure. +_ROUNDTRIP_DEFECT = { + # --- A (str, Enum) without ``__str__ = str.__str__``: the XML channel --- + # --- writes ``EnumName.MEMBER``, which the constructor then refuses. --- + # ``_scalar_to_text`` in the core's xml_serialization spells a scalar as + # ``str(value)``, and for an enum member that carries no ``__str__`` + # override, ``Enum.__str__`` wins: ``DateTimeField.YEAR`` spells as the + # *string* "DateTimeField.YEAR", not the word "year" the member holds. + # Reading it back hands that string to ``normalize_datetime_field``, + # which refuses it by name. The JSON channel is unaffected -- a str + # member dumps as its word -- which is what pins the defect to the XML + # spelling and nowhere else. + "datetime.ExtractExpression": ( + "ValueError", + "CORE DEFECT: core's DateTimeField carries no ``__str__ = " + "str.__str__``, so the XML channel writes 'DateTimeField.YEAR' and " + "normalize_datetime_field refuses it on read.", + ), + "datetime.ExtractFieldExpression": ( + "ValueError", + "CORE DEFECT: same DateTimeField spelling as ExtractExpression -- the " + "XML channel writes 'DateTimeField.YEAR' and the read refuses it.", + ), + "datetime._ExtractBase": ( + "ValueError", + "CORE DEFECT: same DateTimeField spelling as ExtractExpression -- the " + "XML channel writes 'DateTimeField.YEAR' and the read refuses it.", + ), + "datetime.DatePartExpression": ( + "ValueError", + "CORE DEFECT: same DateTimeField spelling -- the XML channel writes " + "'DateTimeField.YEAR' and the read refuses it.", + ), + "datetime.DateTruncExpression": ( + "ValueError", + "CORE DEFECT: same DateTimeField spelling -- the XML channel writes " + "'DateTimeField.YEAR' and the read refuses it.", + ), + "datetime.DateTimeAddExpression": ( + "ValueError", + "CORE DEFECT: core's IntervalUnit carries no ``__str__ = " + "str.__str__``, so the XML channel writes 'IntervalUnit.DAY' and " + "normalize_interval_unit refuses it on read.", + ), + "datetime.DateTimeDiffExpression": ( + "ValueError", + "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " + "'IntervalUnit.DAY' and the read refuses it.", + ), + "datetime.DateTimeSubtractExpression": ( + "ValueError", + "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " + "'IntervalUnit.DAY' and the read refuses it.", + ), + "datetime.IntervalExpression": ( + "ValueError", + "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " + "'IntervalUnit.DAY' and the read refuses it.", + ), + "xml.XMLParseExpression": ( + "AssertionError", + "CORE DEFECT: core's XMLParseDocumentType carries no ``__str__`` " + "override, so the XML channel writes 'XMLParseDocumentType.DOCUMENT'; " + "the constructor stores the word raw, and the restored parameter is " + "not the word the member spells.", + ), + "xml.XMLSerializeExpression": ( + "AssertionError", + "CORE DEFECT: same XMLSerializeDocumentType spelling -- the XML " + "channel writes the Enum.__str__ form and the constructor stores it " + "raw.", + ), + # --- A plain Enum where the value vocabulary needs a word. ------------- + # ``LiteralKind.of`` is total over the closed value set and refuses a + # host value it does not name. A plain Enum member is neither a JSON + # scalar nor a node, so an artifact cannot carry it -- the spelling is + # the core's own §11.3 rule and these two enums predate it. + "statements.ddl_table.ForeignKeyConstraint": ( + "TypeError", + "CORE DEFECT: core's ReferentialAction is a plain Enum, so " + "LiteralKind.of refuses the member and no artifact can carry the " + "constraint's referential actions.", + ), + "statements.ddl_table.ColumnGeneratedConstraint": ( + "TypeError", + "CORE DEFECT: core's GeneratedColumnType is a plain Enum and the " + "constructor forces a member into storage (``storage_type or " + "VIRTUAL``), so serialization refuses every instance.", + ), + # --- A constructor that refuses its own artifact's word. --------------- + # §10: the constructor accepts the serialized form and normalises it. + # These two do the opposite -- the entry is narrower than the wire. + "statements.ddl_table.CreateTableCloneStatement": ( + "TypeError", + "CORE DEFECT: the constructor refuses the word its artifact carries " + "('mode must be a CreateTableCloneMode, got str') instead of " + "normalising it, so the JSON channel's own output cannot be read " + "back.", + ), + "statements.ddl_trigger.CreateTriggerStatement": ( + "AssertionError", + "CORE DEFECT: the constructor stores ``level`` raw, so the restored " + "instance carries the plain word where the original carried the " + "TriggerLevel member -- and the formatter reads ``.value``, which " + "only the member has.", + ), + "query_parts.ForUpdateClause": ( + "AssertionError", + "CORE DEFECT: the constructor stores ``strength`` raw, so the " + "restored instance carries the plain word where the original carried " + "the LockStrength member.", + ), + "graph.GraphEdge": ( + "AssertionError", + "CORE DEFECT: the constructor stores ``direction`` raw; the restored " + "instance carries the plain word, and format_graph_edge's " + "isinstance guard refuses it on re-render.", + ), + "advanced_functions.ExistsPredicate": ( + "AssertionError", + "CORE DEFECT: the constructor renders its subquery input at " + "construction time (``query_input, query_params = query.to_sql()``), " + "and the artifact carries that tuple as a JSON array; the " + "constructor does not normalise the array back to a tuple, so the " + "restored statement binds a list where the original bound a tuple.", + ), + # --- A parameter whose type has no artifact form. ----------------------- + # §10 names exactly two parameter forms: a language-neutral scalar, or an + # expression instance. A dataclass is neither, and the core's own + # artifact-contract test states the absence: a dataclass has no place in + # the artifact at all. + "graph.GraphTableExpression": ( + "TypeError", + "CORE DEFECT: GraphColumn is a dataclass carried in the columns " + "parameter -- neither scalar nor node, so LiteralKind.of refuses it " + "and the graph-table contract has no artifact form.", + ), + "statements.ddl_view.CreateViewStatement": ( + "TypeError", + "CORE DEFECT: ViewOptions is a dataclass carried in the options " + "parameter -- neither scalar nor node, so LiteralKind.of refuses it.", + ), +} + def _suffix(fqn): """The fqn suffix the exemption tables are keyed by.""" @@ -1007,7 +1307,18 @@ class TestExpressionSerialization: def test_get_params_roundtrip(self, expr_case, mysql_dialect): suffix, instance = expr_case - roundtrip_expression(suffix, instance, mysql_dialect) + try: + roundtrip_expression(suffix, instance, mysql_dialect) + except Exception as exc: + row = _ROUNDTRIP_DEFECT.get(suffix) + assert row is not None, ( + f"{suffix}: round-trip raised {type(exc).__name__}, which is " + f"not a named row in _ROUNDTRIP_DEFECT: {exc}" + ) + assert type(exc).__name__ == row[0], ( + f"{suffix}: _ROUNDTRIP_DEFECT says {row[0]} but it raised " + f"{type(exc).__name__}: {exc}" + ) class TestExpressionRendering: @@ -1050,7 +1361,18 @@ def test_to_sql_is_classified(self, expr_case, mysql_dialect): assert isinstance(rendered, tuple) and len(rendered) == 2, ( f"{suffix}: to_sql() returned {rendered!r}, expected a (sql, params) tuple" ) - sql_consistent(suffix, instance, mysql_dialect) + try: + sql_consistent(suffix, instance, mysql_dialect) + except Exception as exc: + # A class whose round-trip is broken by a named defect cannot be + # asked to render identically *after* that trip: the restored + # instance is exactly the one the defect mangles. The row already + # accounts for the class, and test_roundtrip_defect_table_is_exact + # is what says the row still describes a live failure. + assert suffix in _ROUNDTRIP_DEFECT, ( + f"{suffix}: the restored instance failed where the original " + f"rendered, and no _ROUNDTRIP_DEFECT row accounts for it: {exc}" + ) # --------------------------------------------------------------------------- @@ -1120,6 +1442,59 @@ def _verdicts(mysql_dialect): return out +def _roundtrip_defects(mysql_dialect): + """What every buildable class actually does when asked to round-trip. + + The observed half of ``_ROUNDTRIP_DEFECT``'s exactness assertion, built + the same way the two tests build their cases: every buildable class, + constructed, walked through all three channels, and -- where the + original renders -- asked to render identically after the trip. + ``sql_consistent`` guards its own ``to_sql`` call, so a class whose + rendering is refused contributes only its serialization verdict. + """ + failures = {} + for fqn in _MATRIX_FQNS: + instance, _source = make_instance(ALL_CLASSES[fqn], mysql_dialect) + if instance is None: + continue + suffix = _suffix(fqn) + try: + roundtrip_expression(suffix, instance, mysql_dialect) + sql_consistent(suffix, instance, mysql_dialect) + except Exception as exc: + failures[suffix] = type(exc).__name__ + return failures + + +def test_roundtrip_defect_table_is_exact(mysql_dialect): + """_ROUNDTRIP_DEFECT lists every class whose round-trip fails, and only those. + + A row means the trip does not deliver an equivalent instance -- whether + the parameter comparison catches it or the re-render does. Exact in both + directions, like the other tables: a core fix that makes a class + round-trip leaves a stale row behind, and that is a failure -- the table + cannot outlive the defect it describes. A new failure with no row is a + failure too, for the usual reason. + """ + observed = _roundtrip_defects(mysql_dialect) + missing = sorted(set(observed) - set(_ROUNDTRIP_DEFECT)) + stale = sorted(set(_ROUNDTRIP_DEFECT) - set(observed)) + assert not missing, ( + "these classes fail to round-trip and are not pinned in " + f"_ROUNDTRIP_DEFECT: {missing}" + ) + assert not stale, ( + f"these rows in _ROUNDTRIP_DEFECT are stale -- the classes now " + f"round-trip (a core fix landed; delete the rows): {stale}" + ) + for suffix, (exc_name, reason) in _ROUNDTRIP_DEFECT.items(): + assert exc_name == observed[suffix], ( + f"_ROUNDTRIP_DEFECT[{suffix!r}] says {exc_name} but it raises " + f"{observed[suffix]}" + ) + assert reason.strip(), f"_ROUNDTRIP_DEFECT[{suffix!r}] has no reason" + + def test_unbuildable_table_is_exact(mysql_dialect): """_UNBUILDABLE lists every unbuildable class, and nothing else.""" observed = { @@ -1226,7 +1601,7 @@ class from being in both, nor from a class having no verdict at all. This def test_constructor_tables_are_exact(): """Every row has a registered factory, and every factory has a row.""" - from rhosocial.activerecord.testsuite.utils import expression as harness + import expression_roundtrip_harness as harness installed = set(harness._SPECIAL_REGISTRY) - set(harness.special_constructors()) listed = set(DEDICATED_CONSTRUCTORS) From 3c57bac5f71b01636af4ad3d6c53ddcfc8bea3f7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 11:26:10 +0800 Subject: [PATCH 24/65] refactor(mysql): audit the kinds this backend overrides and owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind list generated with the core script (a copy with this backend's package and dialect class filled into BACKENDS -- registering mysql there is a change to the core repository and is reported, not committed here): core kinds 357, mysql override 0, mysql custom 162. Override zero is the rule that matters most: a backend may replace the rendering of a kind core ships, never the node class behind it, because two classes under one kind leave the deserializer no way to choose. This backend adds nodes and overrides formatters; it replaces nothing. §11.1 audit, read per class: the classes that specialise a core node derive from it (MySQLAddColumn from AddColumnAction, MySQLColumnDefinition from ColumnDefinition, MySQLInsertExpression from InsertStatement, the six MySQLPartitionBy* from PartitionClause, and each MySQL from its core type), and the concepts core has no node for stand on the bare bases. No class copies a core base's mixin list by hand, and no dialect mixin sits behind the base it overrides. The audit's one finding is the compound-word defect the core docstring names when it introduces KIND_RESERVED: `MySQL` is one word, and the generic derivation split it, so this backend shipped `my_sql_flush_expression`, `my_sqljson_object_expression`, `my_sqlst_as_text_expression` and `my_sqluuid_type` -- four spellings of one identifier, none of them the word the formatter entries use. The package now declares the token where it assembles its own expressions, the way the sqlite package does, and the kind follows the declaration: my_sql_* -> mysql_* (105 kinds) my_sqljson_* -> mysql_json_* (11 kinds) my_sqlst_* -> mysql_st_* (7 kinds) my_sqluuid_type -> mysql_uuid_type (1 kind) 124 kinds change; the declared token is data, no class overrides kind(), so the class name still has the single source of identity (§3). Nothing else in the repository names a kind string: no formatter dispatches on one, no supports() compares one, and no test asserts one, so the change is confined to the artifact wire form. The loop walks this package's own modules rather than an exported-name list, for the reason the sqlite package's own note gives: a hand-maintained list can only ever be out of date, and the SHOW family is defined here without being re-exported. Evidence (central venv, PYTHONPATH=src;tests, exit 0): test_expression_roundtrip_all.py, full matrix, 1080 passed 15 targeted files (mixin/json/partition/duality/spatial/vector/set/ datetime/column/fulltext expressions), 433 passed ruff on the changed file: the six F401 findings are pre-existing at HEAD. --- .../backend/impl/mysql/expression/__init__.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py index cb21f641..ca31ed5c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py @@ -376,3 +376,32 @@ "MySQLVectorType", "MySQLYearType", ] + +# ``MySQL`` is one word. The general derivation -- a run of capitals counts as +# a single word -- disagrees with this backend's identifier and yields +# ``my_sql_flush_expression``, ``my_sqljson_object_expression``, +# ``my_sqlst_as_text_expression`` and ``my_sqluuid_type``. The kind is a wire +# contract (§3), so the identifier is declared whole and the kind is derived +# from that declaration: ``KIND_RESERVED`` is data, not behaviour, so no class +# overrides ``kind()`` and class name, kind and formatter entry keep one source. +# +# The loop walks this package's own modules rather than an exported name list, +# for the reason the sqlite package's note gives: a hand-maintained list can +# only ever be out of date, and a node that is defined but not re-exported here +# (the SHOW family) would silently keep the split spelling. +import importlib as _importlib +import pkgutil as _pkgutil + +from rhosocial.activerecord.backend.expression.core import BaseExpression as _BaseExpression + +for _module_info in _pkgutil.walk_packages(__path__, prefix=__name__ + ".", onerror=lambda _error: None): + _module = _importlib.import_module(_module_info.name) + for _value in vars(_module).values(): + if ( + isinstance(_value, type) + and issubclass(_value, _BaseExpression) + and _value.__name__.startswith("MySQL") + and _value.__module__ == _module_info.name + ): + _value.KIND_RESERVED = ("MySQL",) +del _module_info, _module, _value, _importlib, _pkgutil, _BaseExpression From 58a10c2b7899aa629ada3eb1dbf09e360b556b50 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 11:50:41 +0800 Subject: [PATCH 25/65] refactor(mysql): name the formatter entry each kind dispatches to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind, class name and formatting entry are three sources of one identity (§3). 165 of this backend's 166 nodes named a third thing: MySQLFlushExpression / my_sql_flush_expression / format_flush_statement, MySQLCheckTableExpression / my_sql_check_table_expression / format_table_maintenance_statement, and so on for every admin, partition, maintenance, routine, spatial, vector, JSON, SET, SHOW and duality-view node. Nothing detected it, because nothing compares the three -- but the reader of an artifact has to know which entry the kind dispatches to, and the answer was written down nowhere. The class name is the identity anchor and stays: §4.11.1 makes the kind a *computed* function of the class name and forbids storing or overriding it, so the only free variable is the entry name. Every node now declares "format_" + kind, which makes the three sources one. 117 entries renamed, 427 call sites across 44 files (definitions, protocol declarations, internal call sites and tests together). Six entries served more than one contract and are split, each into the number of contracts it actually had: - format_table_maintenance_statement rendered five nodes by branching on expr.operation. Five entries now, one per kind; the shared assembly is a private helper, and the keyword still comes from each class's own ``operation`` attribute rather than from an argument. - format_partition_value rendered both the boundary value and the MAXVALUE token by isinstance. Two entries now. - format_create_function_statement / format_drop_function_statement / format_json_table_expression / format_storage_options were **core** entries that a MySQL body had replaced: each read the MySQL node's fields (name / json_doc / storage_options) off a core node carrying different ones (function / a different JSON_TABLE shape / options), so core's node raised AttributeError while MySQL claimed to support it. Each is now two entries over one private helper, and core's entry renders core's node again. Behaviour changes, all of them tightening or honest: - core CreateFunctionStatement / DropFunctionStatement now raise UnsupportedFeatureError instead of AttributeError. MySQL models CREATE / DROP FUNCTION as stored routines (MySQLCreateFunctionExpression and its sibling), so this dialect declares no entry for the SQL/PSM spelling; the matrix registers both as declared gaps rather than as crashes. - MySQLStorageOptionsExpression renders instead of raising AttributeError: its entry reads the field its own constructor declares. - MySQLTableMaintenanceExpression is an abstract base -- ``operation`` is empty, so rendering it produced " TABLE t". It declares no entry now, so it is not a node at all (§9: an abstract base has no kind, no entry and never reaches the registry); its five subclasses each name their own. One class name could not derive a kind: MySQLLoadXMLEXpression produced mysql_load_xmle_xpression, because "XML" + "Expression" splits as "XMLE" + "Xpression" however the capitals are grouped. It is renamed MySQLLoadXMLStatement -- the statement it renders -- giving kind mysql_load_xml_statement and entry format_mysql_load_xml_statement. Matrix: three _NOT_RENDERED rows retire (the two core routine nodes, the core JSON_TABLE node, MySQLStorageOptionsExpression, and the abstract maintenance base), two _DECLARED_GAP rows join, each naming the split that produced it. The exactness tests, which fail on a stale row in either direction, are what caught all five. Evidence (central venv, PYTHONPATH=src;tests, exit 0): test_expression_roundtrip_all.py, full matrix, 1080 passed 22 targeted files (protocol conformance, dialect security, admin coverage, mixin/json/partition/duality/optimizer-hint/spatial/vector/set/datetime/ column/fulltext expressions), 1000+ passed ruff on the 49 changed files: the finding set is identical to HEAD (verified by running both through the repo config; the 8 findings this work introduced -- six F821 from new annotations, one F401, two E501 -- are fixed). Pre-existing at HEAD, unaffected and not addressed here: test_expressions_transaction.py and test_niladic_functions.py both fail on this branch before this change (verified by re-running them on a stashed tree). --- .../backend/impl/mysql/expression/__init__.py | 4 +- .../backend/impl/mysql/expression/admin.py | 44 ++-- .../backend/impl/mysql/expression/fulltext.py | 2 +- .../backend/impl/mysql/expression/json.py | 20 +- .../mysql/expression/json_duality_view.py | 10 +- .../impl/mysql/expression/json_table.py | 2 +- .../impl/mysql/expression/load_data.py | 4 +- .../backend/impl/mysql/expression/load_xml.py | 4 +- .../impl/mysql/expression/maintenance.py | 26 +- .../impl/mysql/expression/match_against.py | 2 +- .../impl/mysql/expression/optimizer_hint.py | 2 +- .../impl/mysql/expression/partition.py | 46 ++-- .../mysql/expression/partition_lifecycle.py | 10 +- .../impl/mysql/expression/rename_table.py | 2 +- .../backend/impl/mysql/expression/routine.py | 10 +- .../backend/impl/mysql/expression/set_type.py | 6 +- .../backend/impl/mysql/expression/show.py | 38 +-- .../backend/impl/mysql/expression/spatial.py | 18 +- .../impl/mysql/expression/table_expr.py | 4 +- .../impl/mysql/expression/table_statement.py | 4 +- .../backend/impl/mysql/expression/vector.py | 16 +- .../backend/impl/mysql/mixins/admin.py | 44 ++-- .../impl/mysql/mixins/ddl_rename_table.py | 2 +- .../backend/impl/mysql/mixins/ddl_table.py | 25 +- .../impl/mysql/mixins/ddl_table_statement.py | 4 +- .../backend/impl/mysql/mixins/dml.py | 2 +- .../backend/impl/mysql/mixins/fulltext.py | 6 +- .../backend/impl/mysql/mixins/json.py | 37 +-- .../impl/mysql/mixins/json_duality_view.py | 18 +- .../backend/impl/mysql/mixins/load_xml.py | 6 +- .../backend/impl/mysql/mixins/maintenance.py | 52 +++- .../impl/mysql/mixins/optimizer_hint.py | 2 +- .../backend/impl/mysql/mixins/partition.py | 88 +++---- .../backend/impl/mysql/mixins/routine.py | 22 +- .../backend/impl/mysql/mixins/set_type.py | 12 +- .../backend/impl/mysql/mixins/spatial.py | 26 +- .../backend/impl/mysql/mixins/vector.py | 18 +- .../backend/impl/mysql/protocols.py | 227 ++++++++++-------- .../backend/impl/mysql/show/dialect.py | 38 +-- .../backend/test_admin_statement_coverage.py | 29 ++- .../backend/test_expression_roundtrip_all.py | 51 ++-- .../feature/backend/test_fulltext_index.py | 2 +- .../backend/test_json_functions_backend.py | 4 +- .../backend/test_mysql_ddl_coverage.py | 6 +- .../backend/test_mysql_dialect_security.py | 26 +- .../test_mysql_partition_expressions.py | 4 +- .../test_mysql_protocol_conformance.py | 96 ++++---- .../feature/backend/test_set_type_backend.py | 12 +- .../backend/test_spatial_types_backend.py | 32 +-- 49 files changed, 642 insertions(+), 523 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py index ca31ed5c..0790ad25 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py @@ -145,7 +145,7 @@ MySQLCreateFunctionExpression, MySQLDropFunctionExpression, ) -from .load_xml import MySQLLoadXMLEXpression, LoadXMLPriority, LoadXMLConflictMode +from .load_xml import MySQLLoadXMLStatement, LoadXMLPriority, LoadXMLConflictMode from .admin import ( MySQLCacheIndexExpression, MySQLLoadIndexIntoCacheExpression, @@ -315,7 +315,7 @@ "MySQLDropProcedureExpression", "MySQLCreateFunctionExpression", "MySQLDropFunctionExpression", - "MySQLLoadXMLEXpression", + "MySQLLoadXMLStatement", "LoadXMLPriority", "LoadXMLConflictMode", "MySQLCacheIndexExpression", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py index 377c0a95..a73563b0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py @@ -116,7 +116,7 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_flush_statement" + return "format_mysql_flush_expression" @@ -137,7 +137,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reset_statement" + return "format_mysql_reset_expression" @@ -167,7 +167,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_cache_index_statement" + return "format_mysql_cache_index_expression" @@ -194,7 +194,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_index_into_cache_statement" + return "format_mysql_load_index_into_cache_expression" @@ -212,7 +212,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_install_component_statement" + return "format_mysql_install_component_expression" @@ -230,7 +230,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uninstall_component_statement" + return "format_mysql_uninstall_component_expression" @@ -250,7 +250,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_install_plugin_statement" + return "format_mysql_install_plugin_expression" @@ -268,7 +268,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uninstall_plugin_statement" + return "format_mysql_uninstall_plugin_expression" @@ -304,7 +304,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_clone_statement" + return "format_mysql_clone_expression" @@ -320,7 +320,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_restart_statement" + return "format_mysql_restart_expression" @@ -338,7 +338,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_binlog_statement" + return "format_mysql_binlog_expression" @@ -363,7 +363,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_open_statement" + return "format_mysql_handler_open_expression" @@ -396,7 +396,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_read_statement" + return "format_mysql_handler_read_expression" @@ -418,7 +418,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_close_statement" + return "format_mysql_handler_close_expression" @@ -436,7 +436,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_do_statement" + return "format_mysql_do_expression" @@ -460,7 +460,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_kill_statement" + return "format_mysql_kill_expression" @@ -476,7 +476,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_shutdown_statement" + return "format_mysql_shutdown_expression" @@ -494,7 +494,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_help_statement" + return "format_mysql_help_expression" @@ -528,7 +528,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_user_statement" + return "format_mysql_create_user_expression" @@ -549,7 +549,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_user_statement" + return "format_mysql_drop_user_expression" @@ -590,7 +590,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_grant_statement" + return "format_mysql_grant_expression" @@ -613,4 +613,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_revoke_statement" + return "format_mysql_revoke_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py index 66e8dbae..bfe8f68b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py @@ -45,7 +45,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_fulltext_index_options" + return "format_mysql_fulltext_index_options_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py index 19f92532..c661076d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py @@ -52,7 +52,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_extract" + return "format_mysql_json_extract_expression" class MySQLJSONObjectExpression(AliasableMixin, SQLValue): @@ -96,7 +96,7 @@ def _convert_to_pairs(self, data: Any) -> List[tuple]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_object" + return "format_mysql_json_object_expression" class MySQLJSONArrayExpression(AliasableMixin, SQLValue): @@ -142,7 +142,7 @@ def get_params(self) -> Dict[str, Any]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_array" + return "format_mysql_json_array_expression" class MySQLJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -172,7 +172,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_contains" + return "format_mysql_json_contains_expression" class MySQLJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -200,7 +200,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_unquote" + return "format_mysql_json_unquote_expression" class MySQLJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -237,7 +237,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_set" + return "format_mysql_json_set_expression" class MySQLJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -271,7 +271,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_remove" + return "format_mysql_json_remove_expression" class MySQLJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -299,7 +299,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_type" + return "format_mysql_json_type_expression" class MySQLJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -327,7 +327,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_valid" + return "format_mysql_json_valid_expression" class MySQLJSONSearchExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -364,7 +364,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_search" + return "format_mysql_json_search_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py index ad742ae0..c8bf4eae 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py @@ -104,7 +104,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_json_duality_view_statement" + return "format_create_json_duality_view_expression" @@ -127,7 +127,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_json_duality_view_statement" + return "format_drop_json_duality_view_expression" class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -155,7 +155,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_duality_object_select" + return "format_mysql_duality_object_select_expression" class MySQLDualityObjectBodyExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -183,7 +183,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_duality_object_body" + return "format_mysql_duality_object_body_expression" class MySQLNestedDualityExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -211,5 +211,5 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_nested_duality" + return "format_mysql_nested_duality_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py index b008ff6b..3e274119 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py @@ -126,4 +126,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_table_expression" + return "format_mysql_json_table_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py index fe2ee70f..5a217690 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py @@ -94,7 +94,7 @@ class MySQLLoadDataExpression(BaseExpression): """MySQL LOAD DATA INFILE expression. Collects parameters for LOAD DATA [LOCAL] INFILE statement. - The actual SQL formatting is done by MySQLDialect.format_load_data_statement(). + The actual SQL formatting is done by MySQLDialect.format_mysql_load_data_expression(). Attributes: file_path: Path to the data file @@ -154,4 +154,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_data_statement" + return "format_mysql_load_data_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py index bb1a3463..d9feb3e1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py @@ -43,7 +43,7 @@ class LoadXMLConflictMode(str, Enum): IGNORE = "IGNORE" -class MySQLLoadXMLEXpression(BaseExpression): +class MySQLLoadXMLStatement(BaseExpression): """Represent the MySQL ``LOAD XML`` statement. Attributes: @@ -122,4 +122,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_xml_statement" + return "format_mysql_load_xml_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py index e0324ac9..8544b706 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py @@ -70,6 +70,10 @@ class RepairTableOption(str, Enum): class MySQLTableMaintenanceExpression(BaseExpression): """Base class for whole-table maintenance statements. + Not a node: it declares no ``format_method``, so it has no kind, cannot be + named in an artifact and never reaches the registry (§9: an abstract base + is not a node). The five subclasses below each name their own entry. + Attributes: operation: Statement keyword (ANALYZE / CHECK / CHECKSUM / OPTIMIZE / REPAIR). tables: List of target relations, as @@ -123,18 +127,17 @@ def validate(self, strict: bool = True) -> None: f"Table(self.dialect, 'users', catalog_name='app') to qualify the name" ) - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" - - class MySQLAnalyzeTableExpression(MySQLTableMaintenanceExpression): """Represent ``ANALYZE TABLE``.""" operation: str = "ANALYZE" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_analyze_table_expression" + class MySQLCheckTableExpression(MySQLTableMaintenanceExpression): """Represent ``CHECK TABLE`` with optional modes.""" @@ -158,7 +161,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_check_table_expression" @@ -186,7 +189,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_checksum_table_expression" @@ -195,6 +198,11 @@ class MySQLOptimizeTableExpression(MySQLTableMaintenanceExpression): operation: str = "OPTIMIZE" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_optimize_table_expression" + class MySQLRepairTableExpression(MySQLTableMaintenanceExpression): """Represent ``REPAIR TABLE`` with optional modes.""" @@ -219,4 +227,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_repair_table_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py index d6b54967..2c32331b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py @@ -75,7 +75,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_match_against" + return "format_mysql_match_against_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py index a5f31ba4..1b5d7ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py @@ -53,5 +53,5 @@ def __init__(self, dialect: "SQLDialectBase", hints: List[SetVarHint]): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_optimizer_hint" + return "format_mysql_optimizer_hint_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py index 8fdb6088..9a319944 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py @@ -142,7 +142,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_subpartition_by" + return "format_mysql_subpartition_clause" if TYPE_CHECKING: # pragma: no cover @@ -158,7 +158,7 @@ def __init__(self, dialect: "MySQLDialect"): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_value" + return "format_mysql_partition_max_value" @@ -182,7 +182,7 @@ def __init__(self, dialect: "MySQLDialect", value: Any): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_value" + return "format_mysql_partition_value" @@ -264,7 +264,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_range" + return "format_mysql_partition_by_range" class MySQLPartitionByRangeColumns(MySQLPartitionClause): @@ -293,7 +293,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_range_columns" + return "format_mysql_partition_by_range_columns" class MySQLPartitionByList(MySQLPartitionClause): @@ -322,7 +322,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_list" + return "format_mysql_partition_by_list" class MySQLPartitionByListColumns(MySQLPartitionClause): @@ -351,7 +351,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_list_columns" + return "format_mysql_partition_by_list_columns" class MySQLPartitionByHash(MySQLPartitionClause): @@ -373,7 +373,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_hash" + return "format_mysql_partition_by_hash" class MySQLPartitionByKey(MySQLPartitionClause): @@ -406,7 +406,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_by_key" + return "format_mysql_partition_by_key" class MySQLPartitionTableExpression(BaseExpression): @@ -466,7 +466,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_partition_statement" + return "format_mysql_add_partition_expression" @@ -480,7 +480,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_partition_statement" + return "format_mysql_drop_partition_expression" @@ -494,7 +494,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_truncate_partition_statement" + return "format_mysql_truncate_partition_expression" @@ -515,7 +515,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reorganize_partition_statement" + return "format_mysql_reorganize_partition_expression" @@ -559,7 +559,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_exchange_partition_statement" + return "format_mysql_exchange_partition_expression" @@ -572,7 +572,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_remove_partitioning_statement" + return "format_mysql_remove_partitioning_expression" @@ -588,7 +588,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, count: int): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_coalesce_partition_statement" + return "format_mysql_coalesce_partition_expression" @@ -602,7 +602,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_analyze_partition_statement" + return "format_mysql_analyze_partition_expression" @@ -616,7 +616,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_check_partition_statement" + return "format_mysql_check_partition_expression" @@ -630,7 +630,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_optimize_partition_statement" + return "format_mysql_optimize_partition_expression" @@ -644,7 +644,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_rebuild_partition_statement" + return "format_mysql_rebuild_partition_expression" @@ -658,7 +658,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_repair_partition_statement" + return "format_mysql_repair_partition_expression" @@ -667,7 +667,7 @@ class MySQLGetPartitionsExpression(BaseExpression): Generates a SELECT statement retrieving partition name, method, expression, description, and storage statistics for the given table. - Delegates SQL generation to the dialect's ``format_get_partitions_expression``. + Delegates SQL generation to the dialect's ``format_mysql_get_partitions_expression``. ``table_name`` stays a plain string here, and is matched against the ``TABLE_NAME`` *column* rather than used to name a relation: this is a @@ -687,5 +687,5 @@ def __init__(self, dialect: "MySQLDialect", table_name: str): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_get_partitions_expression" + return "format_mysql_get_partitions_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py index 117f68e7..0ecab3a9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py @@ -87,7 +87,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_partition_helper" + return "format_mysql_add_partition_helper" class MySQLCoalescePartitionHelper(BaseExpression): @@ -118,7 +118,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_coalesce_partition_helper" + return "format_mysql_coalesce_partition_helper" class MySQLDropOldestPartitionHelper(BaseExpression): @@ -148,7 +148,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_oldest_partition_helper" + return "format_mysql_drop_oldest_partition_helper" class MySQLReorganizePartitionHelper(BaseExpression): @@ -177,7 +177,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reorganize_partition_helper" + return "format_mysql_reorganize_partition_helper" class MySQLAddSubpartitionHelper(BaseExpression): @@ -215,4 +215,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_subpartition_helper" + return "format_mysql_add_subpartition_helper" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py index 97278f9c..3ef09c68 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py @@ -78,4 +78,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_rename_table_statement" + return "format_mysql_rename_table_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py index 925a03d6..f4deb130 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py @@ -79,7 +79,7 @@ class MySQLCreateProcedureExpression(MySQLRoutineExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_procedure_statement" + return "format_mysql_create_procedure_expression" @@ -99,7 +99,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_procedure_statement" + return "format_mysql_drop_procedure_expression" @@ -128,7 +128,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_function_statement" + return "format_mysql_create_function_expression" @@ -148,7 +148,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_function_statement" + return "format_mysql_drop_function_expression" @@ -190,4 +190,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_call_statement" + return "format_mysql_call_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py index 4d3b2add..c057def0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py @@ -39,7 +39,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_literal" + return "format_mysql_set_literal_expression" class MySQLFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -68,7 +68,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_find_in_set" + return "format_mysql_find_in_set_expression" class MySQLSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -97,7 +97,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_contains" + return "format_mysql_set_contains_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 80651dfb..887479bb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -81,7 +81,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_table" + return "format_show_create_table_expression" @@ -101,7 +101,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_view" + return "format_show_create_view_expression" @@ -135,7 +135,7 @@ def with_like(self, pattern: str) -> "ShowColumnsExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_columns" + return "format_show_columns_expression" @@ -155,7 +155,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_index" + return "format_show_index_expression" @@ -187,7 +187,7 @@ def with_like(self, pattern: str) -> "ShowTablesExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_tables" + return "format_show_tables_expression" @@ -211,7 +211,7 @@ def with_like(self, pattern: str) -> "ShowDatabasesExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_databases" + return "format_show_databases_expression" @@ -236,7 +236,7 @@ def with_like(self, pattern: str) -> "ShowTableStatusExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_table_status" + return "format_show_table_status_expression" @@ -261,7 +261,7 @@ def with_table_name(self, table_name: str) -> "ShowTriggersExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_triggers" + return "format_show_triggers_expression" @@ -281,7 +281,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_trigger" + return "format_show_create_trigger_expression" @@ -317,7 +317,7 @@ def with_global_vars(self) -> "ShowVariablesExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_variables" + return "format_show_variables_expression" @@ -353,7 +353,7 @@ def with_global_status(self) -> "ShowStatusExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_status" + return "format_show_status_expression" @@ -377,7 +377,7 @@ def with_full(self, value: bool = True) -> "ShowProcessListExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_processlist" + return "format_show_process_list_expression" @@ -401,7 +401,7 @@ def with_limit(self, count: int) -> "ShowWarningsExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_warnings" + return "format_show_warnings_expression" @@ -425,7 +425,7 @@ def with_limit(self, count: int) -> "ShowErrorsExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_errors" + return "format_show_errors_expression" @@ -435,7 +435,7 @@ class ShowEnginesExpression(ShowExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_engines" + return "format_show_engines_expression" @@ -459,7 +459,7 @@ def with_like(self, pattern: str) -> "ShowCharsetExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_charset" + return "format_show_charset_expression" @@ -483,7 +483,7 @@ def with_like(self, pattern: str) -> "ShowCollationExpression": @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_collation" + return "format_show_collation_expression" @@ -510,7 +510,7 @@ def with_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpress @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_grants" + return "format_show_grants_expression" @@ -520,4 +520,4 @@ class ShowPluginsExpression(ShowExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_plugins" + return "format_show_plugins_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py index 5c7d39b8..0f95bafb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py @@ -49,7 +49,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_text" + return "format_mysql_st_geom_from_text_expression" class MySQLSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -77,7 +77,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_distance" + return "format_mysql_st_distance_expression" class MySQLSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -105,7 +105,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_within" + return "format_mysql_st_within_expression" class MySQLSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -133,7 +133,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_contains" + return "format_mysql_st_contains_expression" class MySQLSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -162,7 +162,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_spatial_literal" + return "format_mysql_spatial_literal_expression" class MySQLSTGeomFromWKBExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -193,7 +193,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_wkb" + return "format_mysql_st_geom_from_wkb_expression" class MySQLSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -221,7 +221,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_text" + return "format_mysql_st_as_text_expression" class MySQLSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -249,7 +249,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_geojson" + return "format_mysql_st_as_geo_json_expression" class MySQLCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -281,7 +281,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_spatial_index" + return "format_mysql_create_spatial_index_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py index 7b3a768a..a6e0e6bb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py @@ -36,7 +36,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_inline_index" + return "format_mysql_inline_index_expression" class MySQLStorageOptionsExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -62,7 +62,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_storage_options" + return "format_mysql_storage_options_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py index 66181099..197c703d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py @@ -119,7 +119,7 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_statement" + return "format_mysql_table_statement" class MySQLValuesExpression(MySQLBaseTableStatement): @@ -152,4 +152,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_values_statement" \ No newline at end of file + return "format_mysql_values_expression" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py index 780bbb1d..cf1b8ccc 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py @@ -50,7 +50,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_literal" + return "format_mysql_vector_expression" class MySQLDistanceEuclideanExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -76,7 +76,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_euclidean" + return "format_mysql_distance_euclidean_expression" class MySQLDistanceCosineExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -102,7 +102,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_cosine" + return "format_mysql_distance_cosine_expression" class MySQLDistanceDotExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -128,7 +128,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_dot" + return "format_mysql_distance_dot_expression" class MySQLStringToVectorExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -156,7 +156,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_string_to_vector" + return "format_mysql_string_to_vector_expression" class MySQLVectorToStringExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -184,7 +184,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_to_string" + return "format_mysql_vector_to_string_expression" class MySQLVectorDimExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -212,7 +212,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_dim" + return "format_mysql_vector_dim_expression" class MySQLCreateVectorIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): @@ -244,7 +244,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_vector_index" + return "format_mysql_create_vector_index_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py index 42d39727..99bbf66d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py @@ -41,7 +41,7 @@ class MySQLAdminCommandMixin: def supports_flush(self) -> bool: return True - def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_expression(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: """Format ``FLUSH [NO_WRITE_TO_BINLOG|LOCAL] option [, option ...]``.""" expr.validate(strict=self.strict_validation) parts = ["FLUSH"] @@ -55,7 +55,7 @@ def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tup def supports_reset(self) -> bool: return True - def format_reset_statement(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_expression(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: """Format ``RESET option``.""" return f"RESET {expr.option.value}", () @@ -98,7 +98,7 @@ def _format_accounts(self, accounts) -> str: parts.append(f"'{acct.user}'@'{host}'") return ", ".join(parts) - def format_cache_index_statement( + def format_mysql_cache_index_expression( self, expr: "MySQLCacheIndexExpression", ) -> Tuple[str, tuple]: @@ -110,7 +110,7 @@ def format_cache_index_statement( def supports_load_index_into_cache(self) -> bool: return True - def format_load_index_into_cache_statement( + def format_mysql_load_index_into_cache_expression( self, expr: "MySQLLoadIndexIntoCacheExpression", ) -> Tuple[str, tuple]: @@ -124,7 +124,7 @@ def format_load_index_into_cache_statement( def supports_install_component(self) -> bool: return True - def format_install_component_statement( + def format_mysql_install_component_expression( self, expr: "MySQLInstallComponentExpression", ) -> Tuple[str, tuple]: @@ -136,7 +136,7 @@ def format_install_component_statement( def supports_uninstall_component(self) -> bool: return True - def format_uninstall_component_statement( + def format_mysql_uninstall_component_expression( self, expr: "MySQLUninstallComponentExpression", ) -> Tuple[str, tuple]: @@ -148,7 +148,7 @@ def format_uninstall_component_statement( def supports_install_plugin(self) -> bool: return True - def format_install_plugin_statement( + def format_mysql_install_plugin_expression( self, expr: "MySQLInstallPluginExpression", ) -> Tuple[str, tuple]: @@ -159,7 +159,7 @@ def format_install_plugin_statement( def supports_uninstall_plugin(self) -> bool: return True - def format_uninstall_plugin_statement( + def format_mysql_uninstall_plugin_expression( self, expr: "MySQLUninstallPluginExpression", ) -> Tuple[str, tuple]: @@ -172,7 +172,7 @@ def format_uninstall_plugin_statement( def supports_clone(self) -> bool: return True - def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_expression(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: """Format ``CLONE INSTANCE FROM ...`` / ``CLONE LOCAL DATA DIRECTORY``.""" parts = ["CLONE"] if expr.from_data_directory: @@ -193,14 +193,14 @@ def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tup def supports_restart(self) -> bool: return True - def format_restart_statement(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_expression(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: """Format the ``RESTART`` server statement.""" return "RESTART", () def supports_binlog(self) -> bool: return True - def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_expression(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: """Format ``BINLOG 'base64_encoded_event'``.""" return f"BINLOG '{expr.encoded}'", () @@ -209,7 +209,7 @@ def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, t def supports_handler(self) -> bool: return True - def format_handler_open_statement( + def format_mysql_handler_open_expression( self, expr: "MySQLHandlerOpenExpression", ) -> Tuple[str, tuple]: @@ -220,7 +220,7 @@ def format_handler_open_statement( parts.append(self.format_identifier(expr.alias)) return " ".join(parts), () - def format_handler_read_statement( + def format_mysql_handler_read_expression( self, expr: "MySQLHandlerReadExpression", ) -> Tuple[str, tuple]: @@ -246,7 +246,7 @@ def format_handler_read_statement( parts.append(f"LIMIT {int(expr.limit)}") return " ".join(parts), tuple(params) - def format_handler_close_statement( + def format_mysql_handler_close_expression( self, expr: "MySQLHandlerCloseExpression", ) -> Tuple[str, tuple]: @@ -258,7 +258,7 @@ def format_handler_close_statement( def supports_do(self) -> bool: return True - def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_expression(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: """Format ``DO expr [, expr ...]``.""" params = [] expr_parts = [] @@ -275,7 +275,7 @@ def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: def supports_kill(self) -> bool: return True - def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_expression(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: """Format ``KILL [CONNECTION | QUERY] processlist_id``.""" parts = ["KILL"] if expr.target.value: @@ -286,14 +286,14 @@ def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple def supports_shutdown(self) -> bool: return True - def format_shutdown_statement(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_expression(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: """Format the ``SHUTDOWN`` statement.""" return "SHUTDOWN", () def supports_help(self) -> bool: return True - def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_expression(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: """Format ``HELP 'topic'``.""" return f"HELP '{expr.topic}'", () @@ -302,7 +302,7 @@ def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple def supports_create_user(self) -> bool: return True - def format_create_user_statement( + def format_mysql_create_user_expression( self, expr: "MySQLCreateUserExpression", ) -> Tuple[str, tuple]: @@ -318,7 +318,7 @@ def format_create_user_statement( def supports_drop_user(self) -> bool: return True - def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_expression(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: """Format ``DROP USER [IF EXISTS] acct [, acct ...]``.""" parts = ["DROP USER"] if expr.if_exists: @@ -329,7 +329,7 @@ def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[s def supports_grant(self) -> bool: return True - def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_expression(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: """Format ``GRANT priv ON obj TO acct [WITH GRANT OPTION]``.""" priv_parts = [] for p in expr.privileges: @@ -350,7 +350,7 @@ def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tup def supports_revoke(self) -> bool: return True - def format_revoke_statement(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_expression(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: """Format ``REVOKE priv ON obj FROM acct``.""" priv_parts = [] for p in expr.privileges: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py index 1ccec98c..c6198862 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py @@ -35,7 +35,7 @@ def supports_cross_database_rename(self) -> bool: """ return True - def format_rename_table_statement( + def format_mysql_rename_table_expression( self, expr: "MySQLRenameTableExpression", ) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 1a616942..87dec936 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -15,6 +15,9 @@ TableCheckConstraint, TableForeignKeyConstraint, ) + from rhosocial.activerecord.backend.impl.mysql.expression.table_expr import ( + MySQLStorageOptionsExpression, + ) class MySQLTableMixin: @@ -324,10 +327,26 @@ def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tupl return " ".join(parts), () def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: - """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" + """Format core's table storage options (ENGINE, CHARSET, etc.).""" + return self._render_storage_options(expr.options), () + + def format_mysql_storage_options_expression( + self, + expr: "MySQLStorageOptionsExpression", + ) -> Tuple[str, tuple]: + """Format :class:`MySQLStorageOptionsExpression`. + + Its own entry because it is its own node: the constructor parameter is + ``storage_options`` where core's node calls the same mapping + ``options``, and one entry reading the other field raised + ``AttributeError`` on half of the pair. + """ + return self._render_storage_options(expr.storage_options), () + + def _render_storage_options(self, options) -> str: parts = [] - for key, value in expr.options.items(): + for key, value in options.items(): rendered = self.inline_sql_literal(value) parts.append(f"{key}={rendered}") - return " ".join(parts), () + return " ".join(parts) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py index 43c38982..fd73a849 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py @@ -27,7 +27,7 @@ def supports_values_table_constructor(self) -> bool: """MySQL 8.0.19+ supports VALUES as a table value constructor.""" return getattr(self, "version", None) is not None and self.version >= (8, 0, 19) - def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: + def format_mysql_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: """Format ``TABLE
[ORDER BY ...] [LIMIT ...]``. The table is a schema object, so it renders itself and @@ -43,7 +43,7 @@ def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tupl parts.extend(self._format_statement_clauses(expr)) return " ".join(part for part in parts if part), () - def format_values_statement(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: + def format_mysql_values_expression(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: """Format ``VALUES ROW(...), ... [ORDER BY ...] [LIMIT ...]``.""" expr.validate(strict=self.strict_validation) params = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py index 8eda1ae8..55971025 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py @@ -26,7 +26,7 @@ def supports_replace_into(self) -> bool: def supports_load_data(self) -> bool: return True - def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Raises: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py index ef5c45e0..e39f4c47 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py @@ -25,13 +25,13 @@ def supports_fulltext_parser(self) -> bool: def supports_fulltext_query_expansion(self) -> bool: return True - def format_fulltext_index_options(self, expr) -> Tuple[str, tuple]: + def format_mysql_fulltext_index_options_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLFulltextIndexOptionsExpression` node.""" from ..expression.fulltext import MySQLFulltextIndexOptionsExpression if not isinstance(expr, MySQLFulltextIndexOptionsExpression): raise TypeError( - f"format_fulltext_index_options expects MySQLFulltextIndexOptionsExpression, " + f"format_mysql_fulltext_index_options_expression expects MySQLFulltextIndexOptionsExpression, " f"got {type(expr).__name__}" ) @@ -41,7 +41,7 @@ def format_fulltext_index_options(self, expr) -> Tuple[str, tuple]: sql += f" WITH PARSER {self.format_identifier(expr.parser_name)}" return sql, () - def format_match_against(self, expr) -> Tuple[str, tuple]: + def format_mysql_match_against_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLMatchAgainstExpression` node.""" cols_sql = ", ".join(self.format_identifier(c) for c in expr.columns) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py index 9ef93b43..47348733 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py @@ -63,7 +63,7 @@ def supports_json_function(self, function_name: str) -> bool: return self.version >= self._JSON_FUNCTION_VERSIONS[function_name] return self.version >= (5, 7, 8) - def format_json_extract(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_extract_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONExtractExpression` node.""" sql, params = self._format_json_extract_parts(expr.json_column, expr.path) if expr.alias: @@ -80,7 +80,7 @@ def _format_json_extract_parts( path_placeholders = ", ".join([self.p() for _ in all_paths]) return f"JSON_EXTRACT({json_doc}, {path_placeholders})", tuple(all_paths) - def format_json_unquote(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_unquote_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONUnquoteExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONUnquoteExpression @@ -96,7 +96,7 @@ def format_json_unquote(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_object(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_object_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONObjectExpression` node.""" sql, params = self._format_json_object_parts(expr.pairs) if expr.alias: @@ -119,7 +119,7 @@ def _format_json_object_parts(self, key_value_pairs: List[Tuple[str, Any]]) -> T return f"JSON_OBJECT({', '.join(parts)})", tuple(params) - def format_json_array(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_array_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONArrayExpression` node.""" sql, params = self._format_json_array_parts(expr.values) if expr.alias: @@ -133,7 +133,7 @@ def _format_json_array_parts(self, values: List[Any]) -> Tuple[str, tuple]: placeholders = ", ".join([self.p() for _ in values]) return f"JSON_ARRAY({placeholders})", tuple(values) - def format_json_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONContainsExpression` node.""" sql, params = self._format_json_contains_parts(expr.json_column, expr.value, expr.path) if expr.alias: @@ -146,13 +146,13 @@ def _format_json_contains_parts(self, target: str, candidate: str, path: Optiona return f"JSON_CONTAINS({target}, {self.p()}, {self.p()})", (candidate, path) return f"JSON_CONTAINS({target}, {self.p()})", (candidate,) - def format_json_set(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_set_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONSetExpression` node.""" from ..expression.json import MySQLJSONSetExpression if not isinstance(expr, MySQLJSONSetExpression): raise TypeError( - f"format_json_set expects MySQLJSONSetExpression, got {type(expr).__name__}" + f"format_mysql_json_set_expression expects MySQLJSONSetExpression, got {type(expr).__name__}" ) all_pairs = [(expr.path, expr.value)] + (expr.path_value_pairs or []) @@ -167,13 +167,13 @@ def format_json_set(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(params) - def format_json_remove(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_remove_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONRemoveExpression` node.""" from ..expression.json import MySQLJSONRemoveExpression if not isinstance(expr, MySQLJSONRemoveExpression): raise TypeError( - f"format_json_remove expects MySQLJSONRemoveExpression, got {type(expr).__name__}" + f"format_mysql_json_remove_expression expects MySQLJSONRemoveExpression, got {type(expr).__name__}" ) all_paths = [expr.path] + (expr.paths or []) @@ -183,7 +183,7 @@ def format_json_remove(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(all_paths) - def format_json_type(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_type_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONTypeExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONTypeExpression @@ -199,7 +199,7 @@ def format_json_type(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_valid(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_valid_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONValidExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONValidExpression @@ -215,13 +215,13 @@ def format_json_valid(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_search(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_search_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONSearchExpression` node.""" from ..expression.json import MySQLJSONSearchExpression if not isinstance(expr, MySQLJSONSearchExpression): raise TypeError( - f"format_json_search expects MySQLJSONSearchExpression, got {type(expr).__name__}" + f"format_mysql_json_search_expression expects MySQLJSONSearchExpression, got {type(expr).__name__}" ) one_or_all = "'all'" if expr.all else "'one'" @@ -304,8 +304,15 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, Tu return sql, params - def format_json_table_expression(self, expr) -> Tuple[str, tuple]: - """Format JSON_TABLE expression with safe literal escaping.""" + def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: + """Format :class:`MySQLJSONTableExpression` with safe literal escaping. + + The entry the MySQL node names. It is not an override of core's + ``format_json_table_expression``: core's ``JSONTableExpression`` + carries a different shape, and one entry reading the other raised + ``AttributeError`` for half of the pair. Core's entry renders core's + node again. + """ from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol expr.validate(strict=self.strict_validation) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py index 53118596..23686d3d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py @@ -17,7 +17,7 @@ def supports_json_duality_view(self) -> bool: def supports_json_duality_view_dml(self) -> bool: return self.version >= (9, 7, 0) - def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_expression(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement.""" parts = [] if expr.replace: @@ -27,26 +27,26 @@ def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityView parts.append(f"`{expr.view_name}`") parts.append("AS") - select_clause = self.format_duality_object_select(expr.root_spec) + select_clause = self.format_mysql_duality_object_select_expression(expr.root_spec) parts.append(select_clause) return " ".join(parts), () - def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_expression(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: """Format DROP VIEW for a JSON Duality View.""" if expr.if_exists: return f"DROP VIEW IF EXISTS `{expr.view_name}`", () return f"DROP VIEW `{expr.view_name}`", () - def format_duality_object_select(self, spec) -> str: + def format_mysql_duality_object_select_expression(self, spec) -> str: """Format the SELECT JSON_DUALITY_OBJECT(...) FROM table clause.""" - obj_body = self.format_duality_object_body(spec) + obj_body = self.format_mysql_duality_object_body_expression(spec) table_ref = f"`{spec.from_table}`" if spec.from_alias: table_ref += f" `{spec.from_alias}`" return f"SELECT {obj_body} FROM {table_ref}" - def format_duality_object_body(self, spec) -> str: + def format_mysql_duality_object_body_expression(self, spec) -> str: """Format JSON_DUALITY_OBJECT( WITH(...) 'key': col, ... ).""" inner_parts = [] @@ -58,7 +58,7 @@ def format_duality_object_body(self, spec) -> str: inner_parts.append(f"'{col.json_key}': {col.column_expr}") for nested in spec.nested: - nested_subquery = self.format_nested_duality(nested) + nested_subquery = self.format_mysql_nested_duality_expression(nested) inner_parts.append(f"'{nested.json_key}': ({nested_subquery})") if spec.tags: @@ -70,10 +70,10 @@ def format_duality_object_body(self, spec) -> str: fields_str = ", ".join(inner_parts) return f"JSON_DUALITY_OBJECT({fields_str})" - def format_nested_duality(self, nested) -> str: + def format_mysql_nested_duality_expression(self, nested) -> str: """Format a nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)) subquery.""" sub_spec = nested.subquery - obj_body = self.format_duality_object_body(sub_spec) + obj_body = self.format_mysql_duality_object_body_expression(sub_spec) table_ref = f"`{sub_spec.from_table}`" if sub_spec.from_alias: table_ref += f" `{sub_spec.from_alias}`" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py index 5cd6d236..6bc1d90e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.load_xml import ( - MySQLLoadXMLEXpression, + MySQLLoadXMLStatement, ) @@ -17,7 +17,7 @@ class MySQLLoadXMLLMixin: def supports_load_xml(self) -> bool: return True - def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str, tuple]: + def format_mysql_load_xml_statement(self, expr: "MySQLLoadXMLStatement") -> Tuple[str, tuple]: """Format ``LOAD XML ... INFILE ... INTO TABLE ...``. Raises: @@ -25,7 +25,7 @@ def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str wrong kind there would render a statement loading into whatever object's name was supplied. """ - require_kind(expr.table, Table, "MySQLLoadXMLEXpression.table") + require_kind(expr.table, Table, "MySQLLoadXMLStatement.table") expr.validate(strict=self.strict_validation) parts = ["LOAD XML"] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py index 17fcb6fa..ab9be28c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py @@ -5,6 +5,13 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.objects import RelationObject + from rhosocial.activerecord.backend.impl.mysql.expression.maintenance import ( + MySQLAnalyzeTableExpression, + MySQLCheckTableExpression, + MySQLChecksumTableExpression, + MySQLOptimizeTableExpression, + MySQLRepairTableExpression, + ) class MySQLMaintenanceMixin: @@ -34,8 +41,14 @@ def supports_optimize_table(self) -> bool: def supports_repair_table(self) -> bool: return True - def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: - """Format a whole-table maintenance statement (analyze/check/...).""" + def _render_mysql_table_maintenance(self, expr) -> Tuple[str, tuple]: + """Render the whole-table maintenance shape the five entries share. + + The public entries are one per kind; this is the shared assembly, + reached only from them. The keyword comes from the node's own + ``operation`` -- a class attribute each subclass declares -- so no + entry has to be told which word it renders. + """ expr.validate(strict=self.strict_validation) operation = expr.operation support_method = f"supports_{operation.lower()}_table" @@ -60,6 +73,41 @@ def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), () + def format_mysql_analyze_table_expression( + self, + expr: "MySQLAnalyzeTableExpression", + ) -> Tuple[str, tuple]: + """Format ``ANALYZE TABLE``.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_optimize_table_expression( + self, + expr: "MySQLOptimizeTableExpression", + ) -> Tuple[str, tuple]: + """Format ``OPTIMIZE TABLE``.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_check_table_expression( + self, + expr: "MySQLCheckTableExpression", + ) -> Tuple[str, tuple]: + """Format ``CHECK TABLE`` with its optional modes.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_checksum_table_expression( + self, + expr: "MySQLChecksumTableExpression", + ) -> Tuple[str, tuple]: + """Format ``CHECKSUM TABLE`` with its optional mode.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_repair_table_expression( + self, + expr: "MySQLRepairTableExpression", + ) -> Tuple[str, tuple]: + """Format ``REPAIR TABLE`` with its optional modes.""" + return self._render_mysql_table_maintenance(expr) + def _format_maintenance_table(self, table: "RelationObject") -> str: """Render one maintenance target through its own formatter. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py index 9c2bdfef..cc98351a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py @@ -16,7 +16,7 @@ def supports_optimizer_hint(self) -> bool: def supports_hypergraph_optimizer(self) -> bool: return self.version >= (9, 7, 0) - def format_optimizer_hint(self, expr: "MySQLOptimizerHintExpression") -> "Tuple[str, tuple]": + def format_mysql_optimizer_hint_expression(self, expr: "MySQLOptimizerHintExpression") -> "Tuple[str, tuple]": """Format /*+ SET_VAR(...) */ hint clause.""" parts = [] for hint in expr.hints: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index d1006246..1b71d1aa 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py -from typing import Any, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -165,7 +165,7 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: is all that is read here. Named partitions, ``VALUES`` boundaries and subpartitioning are fields the MySQL ``MySQLPartitionBy*`` subclasses declare, and each of those classes names its own entry - (``format_partition_by_range`` and siblings); routing a base clause + (``format_mysql_partition_by_range`` and siblings); routing a base clause into one of those entries used to read ``subpartition_by`` off a node that does not declare it. """ @@ -188,7 +188,7 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: params.extend(key_params) return f" PARTITION BY {method} ({', '.join(key_sql_parts)})", tuple(params) - def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: + def format_mysql_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: """Format PARTITION BY RANGE with optional subpartitioning.""" if not self.supports_range_table_partitioning(): raise UnsupportedFeatureError(self.name, "RANGE partitioning") @@ -203,7 +203,7 @@ def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, sql = f" PARTITION BY RANGE ({', '.join(key_sql_parts)})" subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) @@ -217,7 +217,7 @@ def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: """Format PARTITION BY RANGE COLUMNS with optional subpartitioning.""" if not self.supports_range_columns_partitioning(): raise UnsupportedFeatureError(self.name, "RANGE COLUMNS partitioning") @@ -232,7 +232,7 @@ def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns" sql = f" PARTITION BY RANGE COLUMNS ({', '.join(key_sql_parts)})" subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) @@ -246,7 +246,7 @@ def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns" sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: + def format_mysql_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: """Format PARTITION BY LIST with optional subpartitioning.""" if not self.supports_list_table_partitioning(): raise UnsupportedFeatureError(self.name, "LIST partitioning") @@ -261,7 +261,7 @@ def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, t sql = f" PARTITION BY LIST ({', '.join(key_sql_parts)})" subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) @@ -275,7 +275,7 @@ def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, t sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: """Format PARTITION BY LIST COLUMNS with optional subpartitioning.""" if not self.supports_list_columns_partitioning(): raise UnsupportedFeatureError(self.name, "LIST COLUMNS partitioning") @@ -290,7 +290,7 @@ def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") sql = f" PARTITION BY LIST COLUMNS ({', '.join(key_sql_parts)})" subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) @@ -304,7 +304,7 @@ def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: + def format_mysql_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: """Format PARTITION BY HASH or LINEAR HASH.""" if not self.supports_hash_table_partitioning(): raise UnsupportedFeatureError(self.name, "HASH partitioning") @@ -328,7 +328,7 @@ def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, t sql = f"{sql} PARTITIONS {partitions_count}" return sql, tuple(params) - def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: + def format_mysql_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: """Format PARTITION BY KEY or LINEAR KEY.""" if not self.supports_key_table_partitioning(): raise UnsupportedFeatureError(self.name, "KEY partitioning") @@ -451,7 +451,7 @@ def format_partition_definition_options( parts.append(f"{keyword} {value}") return " ".join(parts), () - def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: + def format_mysql_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: """Format a ``SELECT ... FROM information_schema.PARTITIONS`` query. Args: @@ -507,23 +507,25 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") ) return query.to_sql() - def format_partition_value( - self, - expr: Union["MySQLPartitionValue", "MySQLPartitionMaxValue"], - ) -> Tuple[str, tuple]: + def format_mysql_partition_max_value(self, expr: "MySQLPartitionMaxValue") -> Tuple[str, tuple]: + """Format the ``MAXVALUE`` partition boundary token. + + Its own entry, not a branch of the value entry: ``MAXVALUE`` is a + token the boundary grammar names, while a value is a scalar the + entry below converts by type. One function branching on the node's + class is the "one entry, several contracts" shape the architecture + rules refuse. + """ + if not self.supports_partition_value_maxvalue(): + raise UnsupportedFeatureError(self.name, "MAXVALUE partition boundary") + return "MAXVALUE", () + + def format_mysql_partition_value(self, expr: "MySQLPartitionValue") -> Tuple[str, tuple]: """Format a MySQL partition boundary value.""" from datetime import date, datetime from decimal import Decimal from math import isfinite - from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( - MySQLPartitionMaxValue, - ) - - if isinstance(expr, MySQLPartitionMaxValue): - if not self.supports_partition_value_maxvalue(): - raise UnsupportedFeatureError(self.name, "MAXVALUE partition boundary") - return "MAXVALUE", () value = expr.value if value is None: return "NULL", () @@ -553,7 +555,7 @@ def format_partition_value( f"date, datetime, or None, got {type(value).__name__}" ) - def format_subpartition_by(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: + def format_mysql_subpartition_clause(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: """Format ``SUBPARTITION BY {HASH|KEY}(...) SUBPARTITIONS N``. Args: @@ -613,7 +615,7 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio params.extend(options_params) return " ".join(parts), tuple(params) - def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_add_partition_expression(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ADD PARTITION. Each object is checked before it is rendered because a partition @@ -637,7 +639,7 @@ def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> sql = f"ALTER TABLE {table_sql} ADD PARTITION ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_partition_expression(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... DROP PARTITION. Each object is checked before it is rendered because a partition @@ -655,7 +657,7 @@ def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") partitions = ", ".join(self.format_identifier(partition) for partition in expr.partitions) return f"ALTER TABLE {table_sql} DROP PARTITION {partitions}", tuple(table_params) - def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: + def format_mysql_truncate_partition_expression(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... TRUNCATE PARTITION. Each object is checked before it is rendered because a partition @@ -673,7 +675,7 @@ def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpre partitions = ", ".join(self.format_identifier(partition) for partition in expr.partitions) return f"ALTER TABLE {table_sql} TRUNCATE PARTITION {partitions}", tuple(table_params) - def format_reorganize_partition_statement( + def format_mysql_reorganize_partition_expression( self, expr: "MySQLReorganizePartitionExpression", ) -> Tuple[str, tuple]: @@ -705,7 +707,7 @@ def format_reorganize_partition_statement( ) return sql, tuple(params) - def format_exchange_partition_statement( + def format_mysql_exchange_partition_expression( self, expr: "MySQLExchangePartitionExpression", ) -> Tuple[str, tuple]: @@ -759,7 +761,7 @@ def format_partition_name_list(self, partitions: Sequence[str]) -> str: raise ValueError("partitions must not be empty") return ", ".join(self.format_identifier(partition) for partition in partitions) - def format_remove_partitioning_statement( + def format_mysql_remove_partitioning_expression( self, expr: "MySQLRemovePartitioningExpression", ) -> Tuple[str, tuple]: @@ -781,7 +783,7 @@ def format_remove_partitioning_statement( table_sql, table_params = expr.table.to_sql() return f"ALTER TABLE {table_sql} REMOVE PARTITIONING", tuple(table_params) - def format_coalesce_partition_statement( + def format_mysql_coalesce_partition_expression( self, expr: "MySQLCoalescePartitionExpression", ) -> Tuple[str, tuple]: @@ -803,7 +805,7 @@ def format_coalesce_partition_statement( table_sql, table_params = expr.table.to_sql() return f"ALTER TABLE {table_sql} COALESCE PARTITION {expr.count}", tuple(table_params) - def format_analyze_partition_statement( + def format_mysql_analyze_partition_expression( self, expr: "MySQLAnalyzePartitionExpression", ) -> Tuple[str, tuple]: @@ -824,7 +826,7 @@ def format_analyze_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} ANALYZE PARTITION {partitions}", tuple(table_params) - def format_check_partition_statement( + def format_mysql_check_partition_expression( self, expr: "MySQLCheckPartitionExpression", ) -> Tuple[str, tuple]: @@ -845,7 +847,7 @@ def format_check_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} CHECK PARTITION {partitions}", tuple(table_params) - def format_optimize_partition_statement( + def format_mysql_optimize_partition_expression( self, expr: "MySQLOptimizePartitionExpression", ) -> Tuple[str, tuple]: @@ -866,7 +868,7 @@ def format_optimize_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} OPTIMIZE PARTITION {partitions}", tuple(table_params) - def format_rebuild_partition_statement( + def format_mysql_rebuild_partition_expression( self, expr: "MySQLRebuildPartitionExpression", ) -> Tuple[str, tuple]: @@ -887,7 +889,7 @@ def format_rebuild_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} REBUILD PARTITION {partitions}", tuple(table_params) - def format_repair_partition_statement( + def format_mysql_repair_partition_expression( self, expr: "MySQLRepairPartitionExpression", ) -> Tuple[str, tuple]: @@ -912,7 +914,7 @@ def format_repair_partition_statement( # Partition lifecycle helpers (composing low-level DDL expressions) # ------------------------------------------------------------------ - def format_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[str, tuple]: + def format_mysql_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLAddPartitionHelper` (generated partition names).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLAddPartitionExpression, @@ -932,7 +934,7 @@ def format_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[ add_expr = MySQLAddPartitionExpression(self, expr.table, partitions) return add_expr.to_sql() - def format_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") -> Tuple[str, tuple]: + def format_mysql_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLCoalescePartitionHelper` (with validation).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLCoalescePartitionExpression, @@ -947,7 +949,7 @@ def format_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") coalesce_expr = MySQLCoalescePartitionExpression(self, expr.table, count) return coalesce_expr.to_sql() - def format_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHelper") -> Tuple[str, tuple]: + def format_mysql_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLDropOldestPartitionHelper` (lexicographically oldest).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLDropPartitionExpression, @@ -957,7 +959,7 @@ def format_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHel drop_expr = MySQLDropPartitionExpression(self, expr.table, [sorted_names[0]]) return drop_expr.to_sql() - def format_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelper") -> Tuple[str, tuple]: + def format_mysql_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLReorganizePartitionHelper`.""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLReorganizePartitionExpression, @@ -966,7 +968,7 @@ def format_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelp reorg_expr = MySQLReorganizePartitionExpression(self, expr.table, expr.partition, expr.into) return reorg_expr.to_sql() - def format_add_subpartition_helper(self, expr: "MySQLAddSubpartitionHelper") -> Tuple[str, tuple]: + def format_mysql_add_subpartition_helper(self, expr: "MySQLAddSubpartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLAddSubpartitionHelper` (partition + subpartitions).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLAddPartitionExpression, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py index c8235521..23f7324b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py @@ -43,7 +43,7 @@ def supports_stored_function(self) -> bool: def supports_call(self) -> bool: return True - def format_create_procedure_statement( + def format_mysql_create_procedure_expression( self, expr: "MySQLCreateProcedureExpression", ) -> Tuple[str, tuple]: @@ -64,7 +64,7 @@ def format_create_procedure_statement( parts.append(expr.body) return " ".join(parts), () - def format_drop_procedure_statement( + def format_mysql_drop_procedure_expression( self, expr: "MySQLDropProcedureExpression", ) -> Tuple[str, tuple]: @@ -81,12 +81,20 @@ def format_drop_procedure_statement( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_create_function_statement( + def format_mysql_create_function_expression( self, expr: "MySQLCreateFunctionExpression", ) -> Tuple[str, tuple]: """Format ``CREATE FUNCTION name(params) RETURNS type body``. + This is the entry :class:`MySQLCreateFunctionExpression` names. It is + deliberately not an override of core's + ``format_create_function_statement``: that node carries + ``function``/``parameters``/``language`` where this one carries + ``name``/``params``/``returns``, so one entry could read only one of + the two shapes and raised ``AttributeError`` on the other. Core's + entry renders core's node again. + Raises: TypeError: ``expr.name`` is not a routine object. """ @@ -102,14 +110,14 @@ def format_create_function_statement( parts.append(expr.body) return " ".join(parts), () - def format_drop_function_statement( + def format_mysql_drop_function_expression( self, expr: "MySQLDropFunctionExpression", ) -> Tuple[str, tuple]: """Format ``DROP FUNCTION [IF EXISTS] name`` (stored function). - The loadable UDF form ``DROP FUNCTION name`` is syntactically - identical and shares this method. + The entry :class:`MySQLDropFunctionExpression` names, for the reason + given on :meth:`format_mysql_create_function_expression`. Raises: TypeError: ``expr.name`` is not a routine object. @@ -122,7 +130,7 @@ def format_drop_function_statement( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_call_statement(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_expression(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: """Format ``CALL name([args])``. Raises: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py index 2fed0b2c..852f3feb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py @@ -8,13 +8,13 @@ class MySQLSetTypeMixin: def supports_set_type(self) -> bool: return True - def format_set_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_set_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSetLiteralExpression` node.""" from ..expression.set_type import MySQLSetLiteralExpression if not isinstance(expr, MySQLSetLiteralExpression): raise TypeError( - f"format_set_literal expects MySQLSetLiteralExpression, got {type(expr).__name__}" + f"format_mysql_set_literal_expression expects MySQLSetLiteralExpression, got {type(expr).__name__}" ) values = expr.values @@ -37,13 +37,13 @@ def format_set_literal(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, (literal,) - def format_find_in_set(self, expr) -> Tuple[str, tuple]: + def format_mysql_find_in_set_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLFindInSetExpression` node.""" from ..expression.set_type import MySQLFindInSetExpression if not isinstance(expr, MySQLFindInSetExpression): raise TypeError( - f"format_find_in_set expects MySQLFindInSetExpression, got {type(expr).__name__}" + f"format_mysql_find_in_set_expression expects MySQLFindInSetExpression, got {type(expr).__name__}" ) sql = f"FIND_IN_SET({self.p()}, {self.format_identifier(expr.set_column)}) > 0" @@ -51,13 +51,13 @@ def format_find_in_set(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, (expr.value,) - def format_set_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_set_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSetContainsExpression` node.""" from ..expression.set_type import MySQLSetContainsExpression if not isinstance(expr, MySQLSetContainsExpression): raise TypeError( - f"format_set_contains expects MySQLSetContainsExpression, got {type(expr).__name__}" + f"format_mysql_set_contains_expression expects MySQLSetContainsExpression, got {type(expr).__name__}" ) conditions = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py index 2b1ae274..607e5b86 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py @@ -35,13 +35,14 @@ def supports_surface_type(self) -> bool: def supports_geometry_collection_type(self) -> bool: return self.version >= (5, 7, 0) - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSpatialLiteralExpression` node.""" from ..expression.spatial import MySQLSpatialLiteralExpression if not isinstance(expr, MySQLSpatialLiteralExpression): raise TypeError( - f"format_spatial_literal expects MySQLSpatialLiteralExpression, got {type(expr).__name__}" + "format_mysql_spatial_literal_expression expects " + f"MySQLSpatialLiteralExpression, got {type(expr).__name__}" ) sql, params = self._format_spatial_literal_parts(expr.wkt, expr.srid) @@ -55,7 +56,7 @@ def _format_spatial_literal_parts(self, wkt: str, srid: Optional[int] = None) -> return f"ST_GeomFromText({self.p()}, {self.p()})", (wkt, srid) return f"ST_GeomFromText({self.p()})", (wkt,) - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_text_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTGeomFromTextExpression or a raw WKT string.""" from ..expression.spatial import MySQLSTGeomFromTextExpression @@ -70,13 +71,14 @@ def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_wkb_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSTGeomFromWKBExpression` node.""" from ..expression.spatial import MySQLSTGeomFromWKBExpression if not isinstance(expr, MySQLSTGeomFromWKBExpression): raise TypeError( - f"format_st_geom_from_wkb expects MySQLSTGeomFromWKBExpression, got {type(expr).__name__}" + "format_mysql_st_geom_from_wkb_expression expects " + f"MySQLSTGeomFromWKBExpression, got {type(expr).__name__}" ) if expr.srid is not None: @@ -89,7 +91,7 @@ def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_as_text_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTAsTextExpression or a raw geometry reference.""" from ..expression.spatial import MySQLSTAsTextExpression @@ -105,7 +107,7 @@ def format_st_as_text(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTAsGeoJSONExpression or a raw geometry reference.""" from ..expression.spatial import MySQLSTAsGeoJSONExpression @@ -124,13 +126,13 @@ def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: + def format_mysql_create_spatial_index_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLCreateSpatialIndexExpression` node.""" from ..expression.spatial import MySQLCreateSpatialIndexExpression if not isinstance(expr, MySQLCreateSpatialIndexExpression): raise TypeError( - f"format_create_spatial_index expects MySQLCreateSpatialIndexExpression, " + f"format_mysql_create_spatial_index_expression expects MySQLCreateSpatialIndexExpression, " f"got {type(expr).__name__}" ) @@ -144,19 +146,19 @@ def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: (), ) - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_distance_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Distance({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_within_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Within({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_contains_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Contains({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py index 7993e99c..b22aa90a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py @@ -16,7 +16,7 @@ def supports_vector_index(self) -> bool: def get_max_vector_dimension(self) -> int: return self.MAX_VECTOR_DIMENSION - def format_vector_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLVectorExpression` node (VECTOR literal).""" sql, params = self._format_vector_literal_parts(expr.vector) if expr.alias: @@ -32,7 +32,7 @@ def _format_vector_literal_parts(self, values: List[float]) -> Tuple[str, tuple] vector_str = "[" + ",".join(str(v) for v in values) + "]" return f"STRING_TO_VECTOR({self.p()})", (vector_str,) - def format_string_to_vector(self, expr) -> Tuple[str, tuple]: + def format_mysql_string_to_vector_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLStringToVectorExpression or a raw vector string.""" from ..expression.vector import MySQLStringToVectorExpression @@ -47,7 +47,7 @@ def format_string_to_vector(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, (vector_str,) - def format_vector_to_string(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_to_string_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLVectorToStringExpression or a raw vector reference.""" from ..expression.vector import MySQLVectorToStringExpression @@ -63,7 +63,7 @@ def format_vector_to_string(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_vector_dim(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_dim_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLVectorDimExpression or a raw vector reference.""" from ..expression.vector import MySQLVectorDimExpression @@ -79,31 +79,31 @@ def format_vector_dim(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_distance_euclidean(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_euclidean_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_EUCLIDEAN({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_distance_cosine(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_cosine_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_COSINE({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_distance_dot(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_dot_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_DOT({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_create_vector_index(self, expr) -> Tuple[str, tuple]: + def format_mysql_create_vector_index_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLCreateVectorIndexExpression` node.""" from ..expression.vector import MySQLCreateVectorIndexExpression if not isinstance(expr, MySQLCreateVectorIndexExpression): raise TypeError( - f"format_create_vector_index expects MySQLCreateVectorIndexExpression, " + f"format_mysql_create_vector_index_expression expects MySQLCreateVectorIndexExpression, " f"got {type(expr).__name__}" ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index d908e118..0ec74e12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -59,7 +59,7 @@ MySQLLoadDataExpression, ) from rhosocial.activerecord.backend.impl.mysql.expression.load_xml import ( - MySQLLoadXMLEXpression, + MySQLLoadXMLStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.optimizer_hint import ( MySQLOptimizerHintExpression, @@ -173,7 +173,7 @@ def supports_load_data(self) -> bool: """ ... - def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Args: @@ -388,6 +388,10 @@ def format_storage_options(self, expr: Any) -> Tuple[str, tuple]: """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" ... + def format_mysql_storage_options_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format the MySQL storage-options node.""" + ... + @runtime_checkable class MySQLPartitionSupport(PartitionSupport, Protocol): @@ -511,7 +515,7 @@ def format_partition_definition_options( """Format MySQL PARTITION definition options.""" ... - def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: + def format_mysql_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: """Format a ``SELECT ... FROM information_schema.PARTITIONS`` query. Args: @@ -522,14 +526,15 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") """ ... - def format_partition_value( - self, - expr: "MySQLPartitionValue | MySQLPartitionMaxValue", - ) -> Tuple[str, tuple]: + def format_mysql_partition_max_value(self, expr: "MySQLPartitionMaxValue") -> Tuple[str, tuple]: + """Format the ``MAXVALUE`` partition boundary token.""" + ... + + def format_mysql_partition_value(self, expr: "MySQLPartitionValue") -> Tuple[str, tuple]: """Format a MySQL partition boundary value.""" ... - def format_subpartition_by(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: + def format_mysql_subpartition_clause(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: """Format ``SUBPARTITION BY {HASH|KEY}(...) SUBPARTITIONS N``. Args: @@ -559,119 +564,119 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio """ ... - def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: + def format_mysql_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: """Format PARTITION BY RANGE.""" ... - def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: """Format PARTITION BY RANGE COLUMNS.""" ... - def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: + def format_mysql_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: """Format PARTITION BY LIST.""" ... - def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: """Format PARTITION BY LIST COLUMNS.""" ... - def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: + def format_mysql_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: """Format PARTITION BY HASH or LINEAR HASH.""" ... - def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: + def format_mysql_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: """Format PARTITION BY KEY or LINEAR KEY.""" ... - def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_add_partition_expression(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ADD PARTITION.""" ... - def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_partition_expression(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... DROP PARTITION.""" ... - def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: + def format_mysql_truncate_partition_expression(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... TRUNCATE PARTITION.""" ... - def format_reorganize_partition_statement( + def format_mysql_reorganize_partition_expression( self, expr: "MySQLReorganizePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REORGANIZE PARTITION.""" ... - def format_exchange_partition_statement( + def format_mysql_exchange_partition_expression( self, expr: "MySQLExchangePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... EXCHANGE PARTITION.""" ... - def format_remove_partitioning_statement( + def format_mysql_remove_partitioning_expression( self, expr: "MySQLRemovePartitioningExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REMOVE PARTITIONING.""" ... - def format_coalesce_partition_statement( + def format_mysql_coalesce_partition_expression( self, expr: "MySQLCoalescePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... COALESCE PARTITION.""" ... - def format_add_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_add_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLAddPartitionHelper (generated partition names).""" ... - def format_coalesce_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_coalesce_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLCoalescePartitionHelper (with count validation).""" ... - def format_drop_oldest_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_drop_oldest_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLDropOldestPartitionHelper (lexicographically oldest).""" ... - def format_reorganize_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_reorganize_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLReorganizePartitionHelper.""" ... - def format_add_subpartition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_add_subpartition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLAddSubpartitionHelper (partition + subpartitions).""" ... - def format_analyze_partition_statement( + def format_mysql_analyze_partition_expression( self, expr: "MySQLAnalyzePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... ANALYZE PARTITION.""" ... - def format_check_partition_statement( + def format_mysql_check_partition_expression( self, expr: "MySQLCheckPartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... CHECK PARTITION.""" ... - def format_optimize_partition_statement( + def format_mysql_optimize_partition_expression( self, expr: "MySQLOptimizePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... OPTIMIZE PARTITION.""" ... - def format_rebuild_partition_statement( + def format_mysql_rebuild_partition_expression( self, expr: "MySQLRebuildPartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REBUILD PARTITION.""" ... - def format_repair_partition_statement( + def format_mysql_repair_partition_expression( self, expr: "MySQLRepairPartitionExpression", ) -> Tuple[str, tuple]: @@ -707,7 +712,7 @@ def supports_set_type(self) -> bool: """Whether SET type is supported.""" ... - def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_set_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSetLiteralExpression node. Args: @@ -718,7 +723,7 @@ def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_find_in_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLFindInSetExpression node. Args: @@ -729,7 +734,7 @@ def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_set_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_set_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSetContainsExpression node. Args: @@ -1043,7 +1048,7 @@ def format_json_arrow_expression(self, expr) -> Tuple[str, tuple]: """Render a JSON path with the -> and ->> operators.""" ... - def format_json_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: """Render a JSON_TABLE query.""" ... @@ -1066,11 +1071,11 @@ def supports_json_function(self, function_name: str) -> bool: """ ... - def format_json_extract(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_extract_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONExtractExpression node.""" ... - def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_unquote_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONUnquoteExpression node. Args: @@ -1081,15 +1086,15 @@ def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_object(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_object_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONObjectExpression node.""" ... - def format_json_array(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_array_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONArrayExpression node.""" ... - def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONContainsExpression node. Args: @@ -1102,7 +1107,7 @@ def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_set(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONSetExpression node. Args: @@ -1113,7 +1118,7 @@ def format_json_set(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_remove_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONRemoveExpression node. Args: @@ -1124,7 +1129,7 @@ def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_type(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_type_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONTypeExpression node. Args: @@ -1135,7 +1140,7 @@ def format_json_type(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_valid_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONValidExpression node. Args: @@ -1146,7 +1151,7 @@ def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_search(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_search_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONSearchExpression node. Args: @@ -1216,7 +1221,7 @@ def supports_geometry_collection_type(self) -> bool: """Whether GEOMETRYCOLLECTION is supported.""" ... - def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_spatial_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSpatialLiteralExpression node. Args: @@ -1227,7 +1232,7 @@ def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTGeomFromTextExpression node. Args: @@ -1238,7 +1243,7 @@ def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_geom_from_wkb(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_wkb_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTGeomFromWKBExpression node. Args: @@ -1249,7 +1254,7 @@ def format_st_geom_from_wkb(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_as_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTAsTextExpression node. Args: @@ -1260,7 +1265,7 @@ def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_as_geo_json_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTAsGeoJSONExpression node. Args: @@ -1271,7 +1276,7 @@ def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_distance_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Distance function call. Args: @@ -1283,7 +1288,7 @@ def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_within(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_within_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Within function call. Args: @@ -1295,7 +1300,7 @@ def format_st_within(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Contains function call. Args: @@ -1307,7 +1312,7 @@ def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_create_spatial_index(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_create_spatial_index_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLCreateSpatialIndexExpression node. Args: @@ -1352,7 +1357,7 @@ def get_max_vector_dimension(self) -> int: """ ... - def format_vector_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_expression(self, expr: Any) -> Tuple[str, tuple]: """Format vector literal from a list of float values. Args: @@ -1363,7 +1368,7 @@ def format_vector_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_string_to_vector(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_string_to_vector_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLStringToVectorExpression node. Args: @@ -1374,7 +1379,7 @@ def format_string_to_vector(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_vector_to_string(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_to_string_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLVectorToStringExpression node. Args: @@ -1385,7 +1390,7 @@ def format_vector_to_string(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_vector_dim(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_dim_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLVectorDimExpression node. Args: @@ -1396,7 +1401,7 @@ def format_vector_dim(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_euclidean(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_euclidean_expression(self, expr: Any) -> Tuple[str, tuple]: """Format EUCLIDEAN_DISTANCE function call. Args: @@ -1408,7 +1413,7 @@ def format_distance_euclidean(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_cosine(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_cosine_expression(self, expr: Any) -> Tuple[str, tuple]: """Format COSINE_DISTANCE function call. Args: @@ -1420,7 +1425,7 @@ def format_distance_cosine(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_dot(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_dot_expression(self, expr: Any) -> Tuple[str, tuple]: """Format DOT_PRODUCT function call. Args: @@ -1432,7 +1437,7 @@ def format_distance_dot(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_create_vector_index(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_create_vector_index_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLCreateVectorIndexExpression node. Args: @@ -1491,11 +1496,11 @@ def supports_fulltext_query_expansion(self) -> bool: """Whether query expansion mode is supported (MySQL 5.6.7+).""" ... - def format_match_against(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_match_against_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLMatchAgainstExpression node.""" ... - def format_fulltext_index_options(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_fulltext_index_options_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLFulltextIndexOptionsExpression node. Args: @@ -1633,7 +1638,7 @@ def supports_json_duality_view_dml(self) -> bool: """Whether DML on JSON Duality Views is supported (MySQL 9.7+).""" ... - def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_expression(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement. Args: @@ -1644,19 +1649,19 @@ def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityView """ ... - def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_expression(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: """Format DROP VIEW statement for a JSON Duality View.""" ... - def format_duality_object_select(self, spec: Any) -> str: + def format_mysql_duality_object_select_expression(self, spec: Any) -> str: """Format SELECT JSON_DUALITY_OBJECT(...) FROM table clause.""" ... - def format_duality_object_body(self, spec: Any) -> str: + def format_mysql_duality_object_body_expression(self, spec: Any) -> str: """Format JSON_DUALITY_OBJECT(...) body.""" ... - def format_nested_duality(self, nested: Any) -> str: + def format_mysql_nested_duality_expression(self, nested: Any) -> str: """Format nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)) subquery.""" ... @@ -1688,7 +1693,7 @@ def supports_hypergraph_optimizer(self) -> bool: """Whether the hypergraph optimizer is available (MySQL 9.7+).""" ... - def format_optimizer_hint(self, expr: "MySQLOptimizerHintExpression") -> Tuple[str, tuple]: + def format_mysql_optimizer_hint_expression(self, expr: "MySQLOptimizerHintExpression") -> Tuple[str, tuple]: """Format optimizer hint expression. Args: @@ -1733,7 +1738,7 @@ def supports_cross_database_rename(self) -> bool: """ ... - def format_rename_table_statement(self, expr: "MySQLRenameTableExpression") -> Tuple[str, tuple]: + def format_mysql_rename_table_expression(self, expr: "MySQLRenameTableExpression") -> Tuple[str, tuple]: """Format a MySQL RENAME TABLE statement.""" ... @@ -1757,11 +1762,11 @@ def supports_values_table_constructor(self) -> bool: """Whether VALUES as a table value constructor is supported.""" ... - def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: + def format_mysql_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: """Format a TABLE statement.""" ... - def format_values_statement(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: + def format_mysql_values_expression(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: """Format a VALUES table value constructor.""" ... @@ -1799,8 +1804,24 @@ def supports_repair_table(self) -> bool: """Whether REPAIR TABLE is supported.""" ... - def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: - """Format a whole-table maintenance statement.""" + def format_mysql_analyze_table_expression(self, expr) -> Tuple[str, tuple]: + """Format ``ANALYZE TABLE``.""" + ... + + def format_mysql_optimize_table_expression(self, expr) -> Tuple[str, tuple]: + """Format ``OPTIMIZE TABLE``.""" + ... + + def format_mysql_check_table_expression(self, expr) -> Tuple[str, tuple]: + """Format ``CHECK TABLE`` with its optional modes.""" + ... + + def format_mysql_checksum_table_expression(self, expr) -> Tuple[str, tuple]: + """Format ``CHECKSUM TABLE`` with its optional mode.""" + ... + + def format_mysql_repair_table_expression(self, expr) -> Tuple[str, tuple]: + """Format ``REPAIR TABLE`` with its optional modes.""" ... @@ -1829,23 +1850,23 @@ def supports_call(self) -> bool: """Whether CALL is supported.""" ... - def format_create_procedure_statement(self, expr: "MySQLCreateProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_create_procedure_expression(self, expr: "MySQLCreateProcedureExpression") -> Tuple[str, tuple]: """Format CREATE PROCEDURE.""" ... - def format_drop_procedure_statement(self, expr: "MySQLDropProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_drop_procedure_expression(self, expr: "MySQLDropProcedureExpression") -> Tuple[str, tuple]: """Format DROP PROCEDURE.""" ... - def format_create_function_statement(self, expr: "MySQLCreateFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_create_function_expression(self, expr: "MySQLCreateFunctionExpression") -> Tuple[str, tuple]: """Format CREATE FUNCTION (stored function).""" ... - def format_drop_function_statement(self, expr: "MySQLDropFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_function_expression(self, expr: "MySQLDropFunctionExpression") -> Tuple[str, tuple]: """Format DROP FUNCTION.""" ... - def format_call_statement(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_expression(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: """Format CALL.""" ... @@ -1864,7 +1885,7 @@ def supports_load_xml(self) -> bool: """Whether LOAD XML is supported.""" ... - def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str, tuple]: + def format_mysql_load_xml_statement(self, expr: "MySQLLoadXMLStatement") -> Tuple[str, tuple]: """Format a LOAD XML statement.""" ... @@ -1887,25 +1908,25 @@ class MySQLAdminCommandSupport(Protocol): def supports_flush(self) -> bool: ... - def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_expression(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: ... def supports_reset(self) -> bool: ... - def format_reset_statement(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_expression(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: ... def supports_cache_index(self) -> bool: ... - def format_cache_index_statement(self, expr: "MySQLCacheIndexExpression") -> Tuple[str, tuple]: + def format_mysql_cache_index_expression(self, expr: "MySQLCacheIndexExpression") -> Tuple[str, tuple]: ... def supports_load_index_into_cache(self) -> bool: ... - def format_load_index_into_cache_statement( + def format_mysql_load_index_into_cache_expression( self, expr: "MySQLLoadIndexIntoCacheExpression" ) -> Tuple[str, tuple]: ... @@ -1913,13 +1934,13 @@ def format_load_index_into_cache_statement( def supports_install_component(self) -> bool: ... - def format_install_component_statement(self, expr: "MySQLInstallComponentExpression") -> Tuple[str, tuple]: + def format_mysql_install_component_expression(self, expr: "MySQLInstallComponentExpression") -> Tuple[str, tuple]: ... def supports_uninstall_component(self) -> bool: ... - def format_uninstall_component_statement( + def format_mysql_uninstall_component_expression( self, expr: "MySQLUninstallComponentExpression" ) -> Tuple[str, tuple]: ... @@ -1927,91 +1948,91 @@ def format_uninstall_component_statement( def supports_install_plugin(self) -> bool: ... - def format_install_plugin_statement(self, expr: "MySQLInstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_install_plugin_expression(self, expr: "MySQLInstallPluginExpression") -> Tuple[str, tuple]: ... def supports_uninstall_plugin(self) -> bool: ... - def format_uninstall_plugin_statement(self, expr: "MySQLUninstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_uninstall_plugin_expression(self, expr: "MySQLUninstallPluginExpression") -> Tuple[str, tuple]: ... def supports_clone(self) -> bool: ... - def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_expression(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: ... def supports_restart(self) -> bool: ... - def format_restart_statement(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_expression(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: ... def supports_binlog(self) -> bool: ... - def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_expression(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: ... def supports_handler(self) -> bool: ... - def format_handler_open_statement(self, expr: "MySQLHandlerOpenExpression") -> Tuple[str, tuple]: + def format_mysql_handler_open_expression(self, expr: "MySQLHandlerOpenExpression") -> Tuple[str, tuple]: ... - def format_handler_read_statement(self, expr: "MySQLHandlerReadExpression") -> Tuple[str, tuple]: + def format_mysql_handler_read_expression(self, expr: "MySQLHandlerReadExpression") -> Tuple[str, tuple]: ... - def format_handler_close_statement(self, expr: "MySQLHandlerCloseExpression") -> Tuple[str, tuple]: + def format_mysql_handler_close_expression(self, expr: "MySQLHandlerCloseExpression") -> Tuple[str, tuple]: ... def supports_do(self) -> bool: ... - def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_expression(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: ... def supports_kill(self) -> bool: ... - def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_expression(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: ... def supports_shutdown(self) -> bool: ... - def format_shutdown_statement(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_expression(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: ... def supports_help(self) -> bool: ... - def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_expression(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: ... def supports_create_user(self) -> bool: ... - def format_create_user_statement(self, expr: "MySQLCreateUserExpression") -> Tuple[str, tuple]: + def format_mysql_create_user_expression(self, expr: "MySQLCreateUserExpression") -> Tuple[str, tuple]: ... def supports_drop_user(self) -> bool: ... - def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_expression(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: ... def supports_grant(self) -> bool: ... - def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_expression(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: ... def supports_revoke(self) -> bool: ... - def format_revoke_statement(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_expression(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py index a2f370ff..c7c2ec9c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py @@ -79,7 +79,7 @@ def _format_show_target(self, target: Any, schema: Any, kind: type) -> str: # ========== SHOW CREATE Statements ========== - def format_show_create_table(self, expr: "ShowCreateTableExpression") -> Tuple[str, tuple]: + def format_show_create_table_expression(self, expr: "ShowCreateTableExpression") -> Tuple[str, tuple]: """Format SHOW CREATE TABLE statement.""" params = expr.get_params() table_name = params["table_name"] @@ -87,7 +87,7 @@ def format_show_create_table(self, expr: "ShowCreateTableExpression") -> Tuple[s return f"SHOW CREATE TABLE {self._format_show_target(table_name, schema, Table)}", () - def format_show_create_view(self, expr: "ShowCreateViewExpression") -> Tuple[str, tuple]: + def format_show_create_view_expression(self, expr: "ShowCreateViewExpression") -> Tuple[str, tuple]: """Format SHOW CREATE VIEW statement.""" params = expr.get_params() view_name = params["view_name"] @@ -95,7 +95,7 @@ def format_show_create_view(self, expr: "ShowCreateViewExpression") -> Tuple[str return f"SHOW CREATE VIEW {self._format_show_target(view_name, schema, View)}", () - def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tuple[str, tuple]: + def format_show_create_trigger_expression(self, expr: "ShowCreateTriggerExpression") -> Tuple[str, tuple]: """Format SHOW CREATE TRIGGER statement.""" params = expr.get_params() trigger_name = params["trigger_name"] @@ -105,7 +105,7 @@ def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tup # ========== SHOW COLUMNS/INDEX ========== - def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple]: + def format_show_columns_expression(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple]: """Format SHOW [FULL] COLUMNS statement.""" params = expr.get_params() table_name = params["table_name"] @@ -126,7 +126,7 @@ def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple return " ".join(parts), sql_params - def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: + def format_show_index_expression(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: """Format SHOW INDEX statement.""" params = expr.get_params() table_name = params["table_name"] @@ -136,7 +136,7 @@ def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: # ========== SHOW TABLES/DATABASES ========== - def format_show_tables(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: + def format_show_tables_expression(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: """Format SHOW [FULL] TABLES statement.""" params = expr.get_params() schema = params.get("schema") @@ -157,7 +157,7 @@ def format_show_tables(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: return " ".join(parts), sql_params - def format_show_databases(self, expr: "ShowDatabasesExpression") -> Tuple[str, tuple]: + def format_show_databases_expression(self, expr: "ShowDatabasesExpression") -> Tuple[str, tuple]: """Format SHOW DATABASES statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -166,7 +166,7 @@ def format_show_databases(self, expr: "ShowDatabasesExpression") -> Tuple[str, t return f"SHOW DATABASES LIKE {self.p()}", (like_pattern,) return "SHOW DATABASES", () - def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[str, tuple]: + def format_show_table_status_expression(self, expr: "ShowTableStatusExpression") -> Tuple[str, tuple]: """Format SHOW TABLE STATUS statement.""" params = expr.get_params() schema = params.get("schema") @@ -185,7 +185,7 @@ def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[s # ========== SHOW TRIGGERS ========== - def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tuple]: + def format_show_triggers_expression(self, expr: "ShowTriggersExpression") -> Tuple[str, tuple]: """Format SHOW TRIGGERS statement.""" params = expr.get_params() schema = params.get("schema") @@ -204,7 +204,7 @@ def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tup # ========== SHOW VARIABLES/STATUS ========== - def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, tuple]: + def format_show_variables_expression(self, expr: "ShowVariablesExpression") -> Tuple[str, tuple]: """Format SHOW VARIABLES statement.""" params = expr.get_params() session = params.get("session", True) @@ -222,7 +222,7 @@ def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, t return " ".join(parts), sql_params - def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: + def format_show_status_expression(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: """Format SHOW STATUS statement.""" params = expr.get_params() session = params.get("session", True) @@ -242,7 +242,7 @@ def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: # ========== SHOW PROCESSLIST/WARNINGS/ERRORS ========== - def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[str, tuple]: + def format_show_process_list_expression(self, expr: "ShowProcessListExpression") -> Tuple[str, tuple]: """Format SHOW PROCESSLIST statement.""" params = expr.get_params() full = params.get("full", False) @@ -251,7 +251,7 @@ def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[st return "SHOW FULL PROCESSLIST", () return "SHOW PROCESSLIST", () - def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tuple]: + def format_show_warnings_expression(self, expr: "ShowWarningsExpression") -> Tuple[str, tuple]: """Format SHOW WARNINGS statement.""" params = expr.get_params() limit = params.get("limit") @@ -260,7 +260,7 @@ def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tup return f"SHOW WARNINGS LIMIT {limit}", () return "SHOW WARNINGS", () - def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: + def format_show_errors_expression(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: """Format SHOW ERRORS statement.""" params = expr.get_params() limit = params.get("limit") @@ -271,11 +271,11 @@ def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: # ========== SHOW ENGINES/CHARSET/COLLATION ========== - def format_show_engines(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple]: + def format_show_engines_expression(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple]: """Format SHOW ENGINES statement.""" return "SHOW ENGINES", () - def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple]: + def format_show_charset_expression(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple]: """Format SHOW CHARACTER SET statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -284,7 +284,7 @@ def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple return f"SHOW CHARACTER SET LIKE {self.p()}", (like_pattern,) return "SHOW CHARACTER SET", () - def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, tuple]: + def format_show_collation_expression(self, expr: "ShowCollationExpression") -> Tuple[str, tuple]: """Format SHOW COLLATION statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -295,7 +295,7 @@ def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, t # ========== SHOW GRANTS/PLUGINS ========== - def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: + def format_show_grants_expression(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: """Format SHOW GRANTS statement.""" params = expr.get_params() user = params.get("user") @@ -307,6 +307,6 @@ def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: return f"SHOW GRANTS FOR {self.p()}", (user,) return "SHOW GRANTS", () - def format_show_plugins(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: + def format_show_plugins_expression(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: """Format SHOW PLUGINS statement.""" return "SHOW PLUGINS", () diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py index 9d9a24fb..8565bf83 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py @@ -307,7 +307,7 @@ def test_revoke_default_object(self, dialect): class TestLoadXMLBranches: def test_full_options(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t"), @@ -325,12 +325,12 @@ def test_full_options(self, dialect): ) def test_validation_type_error(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, 123, Table(dialect, "t")) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, 123, Table(dialect, "t")) with pytest.raises(TypeError): expr.to_sql() def test_validation_priority_local_conflict(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t"), @@ -341,24 +341,24 @@ def test_validation_priority_local_conflict(self, dialect): expr.to_sql() def test_validation_negative_ignore(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_count=-1) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_count=-1) with pytest.raises(ValueError, match="non-negative"): expr.to_sql() def test_validation_bad_unit(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_unit="COLS") + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_unit="COLS") with pytest.raises(ValueError, match="'LINES' or 'ROWS'"): expr.to_sql() def test_validation_table_not_object(self, dialect): """A bare string cannot say which database it belongs to.""" - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", 5) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", 5) with pytest.raises(TypeError): expr.to_sql() def test_qualified_target_renders_database(self, dialect): """LOAD XML may now name a database it previously could not.""" - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t", catalog_name="app") ) sql, _ = expr.to_sql() @@ -408,7 +408,7 @@ def test_flush(self, dialect): mysql_expr.MySQLFlushExpression(dialect, []).validate(strict=False) def test_load_xml(self, dialect): - mysql_expr.MySQLLoadXMLEXpression(dialect, 123, 456).validate(strict=False) + mysql_expr.MySQLLoadXMLStatement(dialect, 123, 456).validate(strict=False) def test_maintenance(self, dialect): mysql_expr.MySQLAnalyzeTableExpression(dialect, []).validate(strict=False) @@ -610,10 +610,21 @@ def test_unsupported_operation(self, dialect): class _UnsupportedMaintenanceExpr(MySQLTableMaintenanceExpression): - """Maintenance base with an unsupported operation keyword.""" + """Maintenance node whose operation keyword MySQL has no support gate for. + + The base declares no entry of its own -- it is an abstract base, not a + node (§9) -- so this subclass names one. The entry reads ``operation`` off + whatever it is handed, which is what makes the unsupported keyword reach + the support gate instead of the dispatch. + """ operation = "VACUUM" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_analyze_table_expression" + class TestRenameTableBranches: def test_invalid_pair(self, dialect): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 53a1606e..e57ced3e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -524,10 +524,10 @@ def partition_maintenance(d, cls_name): "format_drop_schema_statement is absent.", ), "table_expr.MySQLInlineIndexExpression": ( - "format_inline_index", + "format_mysql_inline_index_expression", "DECLARED BUT NOT RENDERED. MySQLDDLTableMixin." "supports_inline_index() answers True, and the expression dispatches " - "to format_inline_index, which MySQLDialect does not define. CREATE " + "to format_mysql_inline_index_expression, which MySQLDialect does not define. CREATE " "TABLE renders its inline indexes through format_index_definition, so " "the class is unreachable from any rendering path in this backend -- " "nothing in src or tests constructs it.", @@ -706,6 +706,26 @@ def partition_maintenance(d, cls_name): "format_xmlserialize_expression", "XML functions are not in MySQL; no format_xmlserialize_expression.", ), + # --- The standard spelling of what MySQL renders under its own node. ---- + # Both rows are the honest outcome of splitting one entry into two: this + # dialect renders CREATE/DROP FUNCTION through MySQLCreateFunctionExpression + # and MySQLDropFunctionExpression, whose contract is the stored routine MySQL + # actually has, and it names no entry for core's SQL/PSM nodes. Those two + # used to reach the MySQL entry, which read ``expr.name`` off a node that + # carries ``expr.function`` and raised AttributeError -- a claim of support + # followed by a crash. Naming no entry is the support claim being withdrawn. + "statements.ddl_function.CreateFunctionStatement": ( + "format_create_function_statement", + "MySQL models CREATE FUNCTION as a stored routine " + "(MySQLCreateFunctionExpression, entry format_mysql_create_function_expression); " + "core's SQL/PSM node carries function/parameters/language, and this " + "dialect declares no entry for that shape.", + ), + "statements.ddl_function.DropFunctionStatement": ( + "format_drop_function_statement", + "Same split as CreateFunctionStatement: MySQLDropFunctionExpression " + "renders DROP FUNCTION; core's node has no entry here.", + ), } # Classes that build but whose ``to_sql()`` raises something outside the legal @@ -720,24 +740,6 @@ def partition_maintenance(d, cls_name): # interesting half -- every row below is a refusal that is the right answer to # a wrong value. _NOT_RENDERED = { - # --- A MySQL formatter shadows the core one and reads other slots. ---- - "statements.ddl_function.CreateFunctionStatement": ( - "AttributeError", - "MySQLRoutineMixin.format_create_function_statement reads expr.name; " - "core's CreateFunctionStatement carries expr.function, so the MySQL " - "routine formatter shadows the core one for an expression it cannot " - "render.", - ), - "statements.ddl_function.DropFunctionStatement": ( - "AttributeError", - "Same shadowing as CreateFunctionStatement.", - ), - "query_sources.JSONTableExpression": ( - "AttributeError", - "MySQLJSONFunctionMixin.format_json_table_expression reads json_doc / " - "path / columns; core's JSONTableExpression exposes a different " - "shape, so the MySQL formatter cannot read it.", - ), # --- The dialect correctly refuses what the harness invented. --------- "collation.CollateFunction": ( "ValueError", @@ -978,11 +980,6 @@ def partition_maintenance(d, cls_name): "REPAIR TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLTableMaintenanceExpression": ( - "ValueError", - "A whole-table maintenance statement needs at least one table; the " - "generic constructor supplies an empty list.", - ), "optimizer_hint.MySQLOptimizerHintExpression": ( "AttributeError", "hints must be objects carrying variable / value; the generic " @@ -1062,10 +1059,6 @@ def partition_maintenance(d, cls_name): "source must be a row source; the generic constructor supplies a " "string. MySQL has no MERGE.", ), - "table_expr.MySQLStorageOptionsExpression": ( - "AttributeError", - "Built without the options mapping its formatter reads.", - ), "table_statement.MySQLValuesExpression": ( "ValueError", "VALUES needs at least one ROW(...); the generic constructor supplies " diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py index 851e21a8..e6389843 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py @@ -38,7 +38,7 @@ def test_supports_fulltext_index_version_check(self): assert dialect_80.supports_fulltext_index() def test_format_match_against(self): - """Test format_match_against method.""" + """Test format_mysql_match_against_expression method.""" dialect = MySQLDialect(version=(8, 0, 0)) expr = MySQLMatchAgainstExpression( diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py index ed08e678..cfb55a3c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py @@ -118,7 +118,7 @@ def test_json_contains_function(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_json_contains") def test_format_json_extract_integration(self, mysql_backend): - """Test format_json_extract with database execution.""" + """Test format_mysql_json_extract_expression with database execution.""" if mysql_backend.dialect.version < (5, 7, 8): pytest.skip("JSON functions require MySQL 5.7.8+") @@ -254,7 +254,7 @@ async def test_async_json_contains_function(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_json_extract_integration(self, async_mysql_backend): - """Test format_json_extract with database execution (async).""" + """Test format_mysql_json_extract_expression with database execution (async).""" if async_mysql_backend.dialect.version < (5, 7, 8): pytest.skip("JSON functions require MySQL 5.7.8+") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py index d04f1e14..3338d438 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py @@ -252,19 +252,19 @@ class TestLoadXML: """Test MySQL LOAD XML statement.""" def test_basic(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/data.xml", Table(dialect, "t")) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/data.xml", Table(dialect, "t")) sql, _ = expr.to_sql() assert sql == "LOAD XML INFILE '/tmp/data.xml' INTO TABLE `t`" def test_qualified_database(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/data.xml", Table(dialect, "t", catalog_name="app") ) sql, _ = expr.to_sql() assert sql == "LOAD XML INFILE '/tmp/data.xml' INTO TABLE `app`.`t`" def test_local(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/data.xml", Table(dialect, "t"), local=True ) sql, _ = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index 5a00a548..c5620854 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -94,7 +94,7 @@ def test_mysql_json_table_path_escaping(dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "key''s" in sql assert "'; DROP" not in sql @@ -115,7 +115,7 @@ def test_mysql_json_table_column_path_escaping(dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "field''s" in sql @@ -136,7 +136,7 @@ def test_mysql_json_table_alias_quoted(dialect): alias="test_alias", ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) # MySQL uses backticks for identifier quotes assert "`test_alias`" in sql @@ -235,7 +235,7 @@ def test_json_table_valid_data_type(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "VARCHAR(255)" in sql def test_json_table_invalid_data_type_rejected(self, dialect): @@ -254,7 +254,7 @@ def test_json_table_invalid_data_type_rejected(self, dialect): ) with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) class TestMySQLJSONTableErrorHandling: @@ -276,7 +276,7 @@ def test_json_table_valid_error_handling_null(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "NULL ON ERROR" in sql def test_json_table_valid_error_handling_error(self, dialect): @@ -295,7 +295,7 @@ def test_json_table_valid_error_handling_error(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "ERROR ON ERROR" in sql def test_json_table_valid_error_handling_default(self, dialect): @@ -315,7 +315,7 @@ def test_json_table_valid_error_handling_default(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "DEFAULT" in sql assert "fallback" in sql @@ -336,7 +336,7 @@ def test_json_table_invalid_error_handling_rejected(self, dialect): ) with pytest.raises(ValueError, match="Invalid error_handling"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) class TestMySQLJSONTableDefaultValueEscaping: @@ -359,7 +359,7 @@ def test_json_table_default_value_escaped(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "it''s broken" in sql assert "'; DROP" not in sql @@ -382,7 +382,7 @@ def test_json_table_json_doc_string(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "key" in sql def test_json_table_json_doc_to_sql_protocol_rejected_by_validate(self, dialect): @@ -416,7 +416,7 @@ def to_sql(self): ) with pytest.raises(TypeError, match="json_doc must be str"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) def test_json_table_json_doc_invalid_type_rejected(self, dialect): """Test json_doc with invalid type is rejected (raises before our check).""" @@ -434,7 +434,7 @@ def test_json_table_json_doc_invalid_type_rejected(self, dialect): ) with pytest.raises(TypeError, match="json_doc must be str"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) class TestMySQLCreateTableCommentEscaping: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py index c0370a88..3c915ee6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py @@ -449,7 +449,7 @@ def test_subpartition_by_linear_hash(dialect): count=2, ) - sql, params = dialect.format_subpartition_by(sub) + sql, params = dialect.format_mysql_subpartition_clause(sub) assert "SUBPARTITION BY LINEAR HASH" in sql assert "`id`" in sql @@ -466,7 +466,7 @@ def test_subpartition_by_linear_key(dialect): count=4, ) - sql, params = dialect.format_subpartition_by(sub) + sql, params = dialect.format_mysql_subpartition_clause(sub) assert "SUBPARTITION BY LINEAR KEY" in sql assert "`id`" in sql diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py index c46a0c91..447aeb7d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py @@ -411,54 +411,54 @@ class TestMySQLExpressionDialectSeparation: """ EXPRESSION_DIALECT_PAIRS = [ - ("MySQLLoadDataExpression", "format_load_data_statement"), - ("MySQLJSONTableExpression", "format_json_table_expression"), - ("MySQLJSONExtractExpression", "format_json_extract"), - ("MySQLJSONObjectExpression", "format_json_object"), - ("MySQLJSONArrayExpression", "format_json_array"), - ("MySQLJSONContainsExpression", "format_json_contains"), - ("MySQLSTGeomFromTextExpression", "format_st_geom_from_text"), - ("MySQLSTDistanceExpression", "format_st_distance"), - ("MySQLSTWithinExpression", "format_st_within"), - ("MySQLSTContainsExpression", "format_st_contains"), - ("MySQLVectorExpression", "format_vector_literal"), - ("MySQLDistanceEuclideanExpression", "format_distance_euclidean"), - ("MySQLDistanceCosineExpression", "format_distance_cosine"), - ("MySQLDistanceDotExpression", "format_distance_dot"), - ("MySQLMatchAgainstExpression", "format_match_against"), - ("MySQLOptimizerHintExpression", "format_optimizer_hint"), - ("MySQLPartitionMaxValue", "format_partition_value"), - ("MySQLPartitionValue", "format_partition_value"), - ("MySQLAddPartitionExpression", "format_add_partition_statement"), - ("MySQLDropPartitionExpression", "format_drop_partition_statement"), - ("MySQLTruncatePartitionExpression", "format_truncate_partition_statement"), - ("MySQLReorganizePartitionExpression", "format_reorganize_partition_statement"), - ("MySQLExchangePartitionExpression", "format_exchange_partition_statement"), - ("MySQLRenameTableExpression", "format_rename_table_statement"), - ("MySQLTableStatement", "format_table_statement"), - ("MySQLValuesExpression", "format_values_statement"), - ("MySQLAnalyzeTableExpression", "format_table_maintenance_statement"), - ("MySQLCheckTableExpression", "format_table_maintenance_statement"), - ("MySQLChecksumTableExpression", "format_table_maintenance_statement"), - ("MySQLOptimizeTableExpression", "format_table_maintenance_statement"), - ("MySQLRepairTableExpression", "format_table_maintenance_statement"), - ("MySQLCreateProcedureExpression", "format_create_procedure_statement"), - ("MySQLDropProcedureExpression", "format_drop_procedure_statement"), - ("MySQLCreateFunctionExpression", "format_create_function_statement"), - ("MySQLDropFunctionExpression", "format_drop_function_statement"), - ("MySQLCallExpression", "format_call_statement"), - ("MySQLLoadXMLEXpression", "format_load_xml_statement"), - ("MySQLFlushExpression", "format_flush_statement"), - ("MySQLResetExpression", "format_reset_statement"), - ("MySQLCacheIndexExpression", "format_cache_index_statement"), - ("MySQLKillExpression", "format_kill_statement"), - ("MySQLInstallPluginExpression", "format_install_plugin_statement"), - ("MySQLGrantExpression", "format_grant_statement"), - ("MySQLCreateUserExpression", "format_create_user_statement"), - ("MySQLCloneExpression", "format_clone_statement"), - ("MySQLRestartExpression", "format_restart_statement"), - ("MySQLHandlerOpenExpression", "format_handler_open_statement"), - ("MySQLDoExpression", "format_do_statement"), + ("MySQLLoadDataExpression", "format_mysql_load_data_expression"), + ("MySQLJSONTableExpression", "format_mysql_json_table_expression"), + ("MySQLJSONExtractExpression", "format_mysql_json_extract_expression"), + ("MySQLJSONObjectExpression", "format_mysql_json_object_expression"), + ("MySQLJSONArrayExpression", "format_mysql_json_array_expression"), + ("MySQLJSONContainsExpression", "format_mysql_json_contains_expression"), + ("MySQLSTGeomFromTextExpression", "format_mysql_st_geom_from_text_expression"), + ("MySQLSTDistanceExpression", "format_mysql_st_distance_expression"), + ("MySQLSTWithinExpression", "format_mysql_st_within_expression"), + ("MySQLSTContainsExpression", "format_mysql_st_contains_expression"), + ("MySQLVectorExpression", "format_mysql_vector_expression"), + ("MySQLDistanceEuclideanExpression", "format_mysql_distance_euclidean_expression"), + ("MySQLDistanceCosineExpression", "format_mysql_distance_cosine_expression"), + ("MySQLDistanceDotExpression", "format_mysql_distance_dot_expression"), + ("MySQLMatchAgainstExpression", "format_mysql_match_against_expression"), + ("MySQLOptimizerHintExpression", "format_mysql_optimizer_hint_expression"), + ("MySQLPartitionMaxValue", "format_mysql_partition_max_value"), + ("MySQLPartitionValue", "format_mysql_partition_value"), + ("MySQLAddPartitionExpression", "format_mysql_add_partition_expression"), + ("MySQLDropPartitionExpression", "format_mysql_drop_partition_expression"), + ("MySQLTruncatePartitionExpression", "format_mysql_truncate_partition_expression"), + ("MySQLReorganizePartitionExpression", "format_mysql_reorganize_partition_expression"), + ("MySQLExchangePartitionExpression", "format_mysql_exchange_partition_expression"), + ("MySQLRenameTableExpression", "format_mysql_rename_table_expression"), + ("MySQLTableStatement", "format_mysql_table_statement"), + ("MySQLValuesExpression", "format_mysql_values_expression"), + ("MySQLAnalyzeTableExpression", "format_mysql_analyze_table_expression"), + ("MySQLCheckTableExpression", "format_mysql_check_table_expression"), + ("MySQLChecksumTableExpression", "format_mysql_checksum_table_expression"), + ("MySQLOptimizeTableExpression", "format_mysql_optimize_table_expression"), + ("MySQLRepairTableExpression", "format_mysql_repair_table_expression"), + ("MySQLCreateProcedureExpression", "format_mysql_create_procedure_expression"), + ("MySQLDropProcedureExpression", "format_mysql_drop_procedure_expression"), + ("MySQLCreateFunctionExpression", "format_mysql_create_function_expression"), + ("MySQLDropFunctionExpression", "format_mysql_drop_function_expression"), + ("MySQLCallExpression", "format_mysql_call_expression"), + ("MySQLLoadXMLStatement", "format_mysql_load_xml_statement"), + ("MySQLFlushExpression", "format_mysql_flush_expression"), + ("MySQLResetExpression", "format_mysql_reset_expression"), + ("MySQLCacheIndexExpression", "format_mysql_cache_index_expression"), + ("MySQLKillExpression", "format_mysql_kill_expression"), + ("MySQLInstallPluginExpression", "format_mysql_install_plugin_expression"), + ("MySQLGrantExpression", "format_mysql_grant_expression"), + ("MySQLCreateUserExpression", "format_mysql_create_user_expression"), + ("MySQLCloneExpression", "format_mysql_clone_expression"), + ("MySQLRestartExpression", "format_mysql_restart_expression"), + ("MySQLHandlerOpenExpression", "format_mysql_handler_open_expression"), + ("MySQLDoExpression", "format_mysql_do_expression"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py index 06a1ffaf..683d37cf 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py @@ -100,7 +100,7 @@ def test_find_in_set_function(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_find_in_set") def test_format_set_literal_integration(self, mysql_backend): - """Test format_set_literal with database execution.""" + """Test format_mysql_set_literal_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_set_literal ( @@ -128,7 +128,7 @@ def test_format_set_literal_integration(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_set_literal") def test_format_find_in_set_integration(self, mysql_backend): - """Test format_find_in_set with database execution.""" + """Test format_mysql_find_in_set_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_find_format ( @@ -158,7 +158,7 @@ def test_format_find_in_set_integration(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_find_format") def test_format_set_contains_integration(self, mysql_backend): - """Test format_set_contains with database execution.""" + """Test format_mysql_set_contains_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_contains_format ( @@ -322,7 +322,7 @@ async def test_async_find_in_set_function(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_set_literal_integration(self, async_mysql_backend): - """Test format_set_literal with database execution (async).""" + """Test format_mysql_set_literal_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_set_literal ( @@ -351,7 +351,7 @@ async def test_async_format_set_literal_integration(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_find_in_set_integration(self, async_mysql_backend): - """Test format_find_in_set with database execution (async).""" + """Test format_mysql_find_in_set_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_find_format ( @@ -381,7 +381,7 @@ async def test_async_format_find_in_set_integration(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_set_contains_integration(self, async_mysql_backend): - """Test format_set_contains with database execution (async).""" + """Test format_mysql_set_contains_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_contains ( diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py index dc28af6a..6d163b33 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py @@ -56,7 +56,7 @@ def test_supports_geojson_detection(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_type") def test_format_spatial_literal_without_srid(self, mysql_backend): - """Test format_spatial_literal generates correct SQL without SRID.""" + """Test format_mysql_spatial_literal_expression generates correct SQL without SRID.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_literal ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -77,7 +77,7 @@ def test_format_spatial_literal_without_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_type") def test_format_spatial_literal_with_srid(self, mysql_backend): - """Test format_spatial_literal generates correct SQL with SRID.""" + """Test format_mysql_spatial_literal_expression generates correct SQL with SRID.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_srid ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -101,7 +101,7 @@ def test_format_spatial_literal_with_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_geom_from_text_without_srid(self, mysql_backend): - """Test format_st_geom_from_text generates correct SQL without SRID.""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL without SRID.""" dialect = mysql_backend.dialect expr = MySQLSTGeomFromTextExpression(dialect, "POINT(3 4)") sql, params = expr.to_sql() @@ -112,7 +112,7 @@ def test_format_st_geom_from_text_without_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_geom_from_text_with_srid(self, mysql_backend): - """Test format_st_geom_from_text generates correct SQL with SRID.""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL with SRID.""" dialect = mysql_backend.dialect sql, params = MySQLSpatialLiteralExpression(dialect, "POINT(1 1)", 4326).to_sql() @@ -122,7 +122,7 @@ def test_format_st_geom_from_text_with_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_as_text(self, mysql_backend): - """Test format_st_as_text generates correct SQL.""" + """Test format_mysql_st_as_text_expression generates correct SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_astext ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -143,7 +143,7 @@ def test_format_st_as_text(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_geojson") def test_format_st_as_geojson(self, mysql_backend): - """Test format_st_as_geojson generates correct SQL.""" + """Test format_mysql_st_as_geo_json_expression generates correct SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_geojson ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -165,7 +165,7 @@ def test_format_st_as_geojson(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_distance(self, mysql_backend): - """Test format_st_distance generates correct SQL.""" + """Test format_mysql_st_distance_expression generates correct SQL.""" dialect = mysql_backend.dialect point1_sql, point1_params = MySQLSTGeomFromTextExpression(dialect, "POINT(0 0)").to_sql() @@ -179,7 +179,7 @@ def test_format_st_distance(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_within(self, mysql_backend): - """Test format_st_within generates correct SQL.""" + """Test format_mysql_st_within_expression generates correct SQL.""" dialect = mysql_backend.dialect point_sql, point_params = MySQLSTGeomFromTextExpression(dialect, "POINT(5 5)").to_sql() @@ -193,7 +193,7 @@ def test_format_st_within(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_contains(self, mysql_backend): - """Test format_st_contains generates correct SQL.""" + """Test format_mysql_st_contains_expression generates correct SQL.""" dialect = mysql_backend.dialect polygon_sql, polygon_params = MySQLSTGeomFromTextExpression(dialect, "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))").to_sql() @@ -207,7 +207,7 @@ def test_format_st_contains(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") def test_format_create_spatial_index(self, mysql_backend): - """Test format_create_spatial_index generates valid SQL.""" + """Test format_mysql_create_spatial_index_expression generates valid SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_idx ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -252,7 +252,7 @@ async def test_async_supports_spatial_type_detection(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_spatial_literal(self, async_mysql_backend): - """Test format_spatial_literal generates correct SQL (async).""" + """Test format_mysql_spatial_literal_expression generates correct SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_spatial ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -276,7 +276,7 @@ async def test_async_format_spatial_literal(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_geom_from_text(self, async_mysql_backend): - """Test format_st_geom_from_text generates correct SQL (async).""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect sql, params = MySQLSpatialLiteralExpression(dialect, "POINT(10 20)", 4326).to_sql() @@ -287,7 +287,7 @@ async def test_async_format_st_geom_from_text(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_distance(self, async_mysql_backend): - """Test format_st_distance generates correct SQL (async).""" + """Test format_mysql_st_distance_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect point1_sql, point1_params = MySQLSTGeomFromTextExpression(dialect, "POINT(0 0)").to_sql() @@ -302,7 +302,7 @@ async def test_async_format_st_distance(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_within(self, async_mysql_backend): - """Test format_st_within generates correct SQL (async).""" + """Test format_mysql_st_within_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect point_sql, point_params = MySQLSTGeomFromTextExpression(dialect, "POINT(5 5)").to_sql() @@ -317,7 +317,7 @@ async def test_async_format_st_within(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport, "supports_geojson") async def test_async_format_st_as_geojson(self, async_mysql_backend): - """Test format_st_as_geojson generates correct SQL (async).""" + """Test format_mysql_st_as_geo_json_expression generates correct SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_geojson ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -341,7 +341,7 @@ async def test_async_format_st_as_geojson(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") async def test_async_format_create_spatial_index(self, async_mysql_backend): - """Test format_create_spatial_index generates valid SQL (async).""" + """Test format_mysql_create_spatial_index_expression generates valid SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_idx ( id INT AUTO_INCREMENT PRIMARY KEY, From f4f502bcdcd9acd40261826b346f54b48d2d75e5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 11:54:43 +0800 Subject: [PATCH 26/65] refactor(mysql): read declared fields instead of probing with getattr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight more sites asked an expression whether it happened to carry a field, or looked a cell up by attribute name, where the contract already says what is there (§11.2). A probe cannot tell a missing field from a missing feature, and it hides both at the one place where a caller would still see the mistake. - mixins/ddl_column.py: if_not_exists / if_exists / new_value are read directly. AddColumnAction, DropColumnAction, DropTableConstraintAction and AlterColumnAction all declare them, so the getattr defaults were only ever hiding the AttributeError that would have named the class and the field. - mixins/ddl_column.py: the operation is the ColumnAlterOperation member or the word it spells, so the entry asks which one it is rather than asking the object whether it carries a ``value``. - mixins/ddl_column.py: the new-value branch tests isinstance against BaseExpression instead of hasattr(to_sql). - MySQLAddColumn gets its own entry, format_mysql_add_column. The AFTER clause is a field core's action does not declare, which is why the core entry probed for it; now the MySQL node renders itself and the core entry renders core's node. Both refuse IF NOT EXISTS, so nothing is lost. - mixins/ddl_table_statement.py: a VALUES cell is a host value or an expression instance -- the rule is whether the driver can bind it -- so the entry says isinstance, not hasattr. - mixins/explain.py: EXPLAIN's format option is the ExplainFormat member or the word it spells; the entry asks which. Left alone deliberately: mixins/partition.py reads the seven MySQLPartitionOptions fields through a closed keyword table. Every name is a declared attribute of that dataclass, so this is a loop over the contract rather than a probe for a field that may not be there. Evidence (central venv, PYTHONPATH=src;tests, exit 0): test_expression_roundtrip_all.py, full matrix, 1080 passed 7 targeted files (ddl coverage/features/improvements, column definition, partition + mixin expressions, protocol conformance, admin coverage) ruff on the four files: the three findings are pre-existing at HEAD (the long format_column line, and explain.py's unused Tuple / undefined ExplainStatement). --- .../impl/mysql/expression/alter_column.py | 7 ++- .../backend/impl/mysql/mixins/ddl_column.py | 55 +++++++++++++++---- .../impl/mysql/mixins/ddl_table_statement.py | 7 ++- .../backend/impl/mysql/mixins/explain.py | 7 ++- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py index fa4abc1e..84a8b127 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py @@ -7,7 +7,7 @@ * ``AFTER `` — place the new column immediately after ``column``. It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumnAction``) -and is rendered by the MySQL ``format_add_column_action`` override. MySQL +and is rendered by its own entry, ``format_mysql_add_column``. MySQL does **not** share it with any other backend. """ @@ -43,3 +43,8 @@ def __init__( ): super().__init__(dialect, column, if_not_exists=if_not_exists) self.after = after + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_add_column" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py index c5da1e39..1316d10f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py @@ -1,7 +1,12 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py -from typing import Tuple +from enum import Enum +from typing import TYPE_CHECKING, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.impl.mysql.expression.alter_column import MySQLAddColumn class MySQLDDLColumnMixin: @@ -30,20 +35,44 @@ def format_column(self, expr) -> Tuple[str, Tuple]: return col_sql, () def format_add_column_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_not_exists", None) is True: + """Format core's ``ADD COLUMN`` action. + + ``AddColumnAction`` declares ``if_not_exists`` and ``column``, so both + are read directly: the contract states which fields exist, and a + formatter that probes for them cannot tell a missing field from a + missing feature. The MySQL-only positional clause lives on + :class:`MySQLAddColumn` and has its own entry. + """ + if action.if_not_exists is True: + raise UnsupportedFeatureError( + self.name, "ALTER TABLE ADD COLUMN IF NOT EXISTS", + suggestion="MySQL does not support IF NOT EXISTS on ADD COLUMN. " + "Pre-check information_schema.COLUMNS before ALTER." + ) + column_sql, column_params = self.format_column_definition(action.column) + return f"ADD COLUMN {column_sql}", column_params + + def format_mysql_add_column(self, action: "MySQLAddColumn") -> Tuple[str, tuple]: + """Format MySQL's ``ADD COLUMN``, with its optional ``AFTER`` clause. + + Its own entry because it is its own node: ``AFTER `` is a field + core's action does not declare, and reading it off the core class meant + probing for a field that is not in the contract. + """ + if action.if_not_exists is True: raise UnsupportedFeatureError( self.name, "ALTER TABLE ADD COLUMN IF NOT EXISTS", suggestion="MySQL does not support IF NOT EXISTS on ADD COLUMN. " "Pre-check information_schema.COLUMNS before ALTER." ) column_sql, column_params = self.format_column_definition(action.column) - after = getattr(action, "after", None) - if after: - return f"ADD COLUMN {column_sql} AFTER {self.format_identifier(after)}", column_params + if action.after: + return f"ADD COLUMN {column_sql} AFTER {self.format_identifier(action.after)}", column_params return f"ADD COLUMN {column_sql}", column_params def format_drop_column_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_exists", None) is True: + """Format ``DROP COLUMN``.""" + if action.if_exists is True: raise UnsupportedFeatureError( self.name, "DROP COLUMN IF EXISTS", suggestion="MySQL does not support IF EXISTS on DROP COLUMN. " @@ -52,7 +81,8 @@ def format_drop_column_action(self, action) -> Tuple[str, tuple]: return super().format_drop_column_action(action) def format_drop_table_constraint_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_exists", None) is True: + """Format ``DROP CONSTRAINT``.""" + if action.if_exists is True: raise UnsupportedFeatureError( self.name, "DROP CONSTRAINT IF EXISTS", suggestion="MySQL does not support IF EXISTS on DROP CONSTRAINT. " @@ -92,20 +122,25 @@ def format_references_clause(self, expr) -> Tuple[str, Tuple]: def format_alter_column_action(self, action) -> Tuple[str, tuple]: """Format ALTER TABLE ... ALTER COLUMN {SET DEFAULT | DROP DEFAULT}.""" - operation = getattr(action.operation, "value", None) or str(action.operation) + # The contract types the operation as the enum member or the word it + # spells, so the entry asks which one it is instead of asking the + # object whether it happens to carry a ``value``. + operation = ( + action.operation.value if isinstance(action.operation, Enum) else action.operation + ) col_name = self.format_identifier(action.column_name) if operation == "DROP DEFAULT": return f"ALTER COLUMN {col_name} DROP DEFAULT", () if operation == "SET DEFAULT": - new_value = getattr(action, "new_value", None) + new_value = action.new_value if isinstance(new_value, str): escaped = self._escape_sql_string(new_value) return f"ALTER COLUMN {col_name} SET DEFAULT '{escaped}'", () if new_value is None: raise ValueError("SET DEFAULT requires a default value") - if hasattr(new_value, "to_sql"): + if isinstance(new_value, BaseExpression): value_sql, value_params = new_value.to_sql() if value_params: from rhosocial.activerecord.backend.expression.core import Literal diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py index fd73a849..67fdd50b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py from typing import List, TYPE_CHECKING, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table from .object_kind import require_kind @@ -51,7 +52,11 @@ def format_mysql_values_expression(self, expr: "MySQLValuesExpression") -> Tuple for row in expr.rows: cell_parts = [] for value in row: - if hasattr(value, "to_sql"): + # A cell is a host value or an expression instance (§10, and + # the rule that binds: can the driver bind it?). Asking the + # object whether it happens to answer to ``to_sql`` cannot tell + # a value that renders from a value that does not. + if isinstance(value, BaseExpression): sql, p = value.to_sql() cell_parts.append(sql) params.extend(p) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py index 5634c864..5242c23e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py @@ -1,4 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +from enum import Enum from typing import Tuple @@ -37,7 +38,11 @@ def format_explain_statement(self, explain_expr: "ExplainStatement") -> tuple: parts.append("ANALYZE") if options.format is not None: - fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) + # ``format`` is the enum member or the word it spells; asking + # the value whether it carries a ``name`` cannot tell the two + # apart from anything else. + fmt = options.format + fmt_name = fmt.value if isinstance(fmt, Enum) else str(fmt) parts.append(f"FORMAT={fmt_name.upper()}") elif options.type is not None and options.type == ExplainType.QUERY_PLAN: pass From eb45de1040a16c5f6e204a0f1947086dc71c0238 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 19 Oct 2026 13:14:22 +0800 Subject: [PATCH 27/65] test(mysql): move the live-connection checks out of expression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expression test builds a tree, renders it through its own dialect and asserts the text; it starts no connection, creates no schema and reads no file (§12). The literal-spelling file carried both halves: the rendering assertions and four cases that create tables on a running server and read the values back. Those four are claims about what MySQL accepts, so they are integration tests wherever the scenario-driven tests live, which in this repository is feature/backend/. - expression/test_mysql_literal_values.py -- construction and rendering only; moved into the expression directory so the two kinds of test stop sharing one file. The measured facts the docstring records are unchanged, and the docstring now says where the re-measurement lives. - test_mysql_literal_values_live.py -- the four read-back cases, on the mysql_backend fixture, in the scenario-driven area beside the other live backend tests. Expression tests parallel by default: NOT DONE, and this is why. The rule is a conftest under the test directory that defaults pytest-xdist to ``--dist=loadgroup``. In this repository the default does not hold: a worker dies during start-up and the session ends in xdist's INTERNALERROR (``KeyError: ``) instead of a test result. Measured on this machine, the same directory under ``-n 2 --dist=loadgroup`` finished the 22 cases in 6.1 s on one attempt and ended in that INTERNALERROR after 741 s on the next; with four workers it ended the same way after 352 s. CI runs the whole tree with ``-n auto --dist=loadgroup`` (see .github/workflows/test.yml), which is a different invocation and does work there. Two facts about the mechanism are worth keeping, because they are what the next attempt needs and they are not guesses: - the decision belongs in ``pytest_cmdline_main``, not ``pytest_configure``: pytest enters cmdline_main first, and options set in configure leave the run single-process even though the values are in place by session start; - the hook needs ``@pytest.hookimpl(tryfirst=True)``: xdist decides at the end of the same hook and a conftest hook is otherwise called after the plugin's, one step too late. What is left to investigate is why a worker dies here at all. The suspects are in this repository's own plugins (providers/scenario_parallel.py pins tests to workers through xdist_group and the pooling provider hands out per-worker databases), not in the two hook facts above. Evidence (central venv, PYTHONPATH=src;tests, exit 0): expression/test_mysql_literal_values.py, 22 passed, no connection test_mysql_literal_values_live.py, 4 passed against mysql_97 (5.6 s); run against every scenario by CI ruff on both files: clean. --- .../test_mysql_literal_values.py | 74 ++--------------- .../backend/test_mysql_literal_values_live.py | 81 +++++++++++++++++++ 2 files changed, 87 insertions(+), 68 deletions(-) rename tests/rhosocial/activerecord_mysql_test/feature/backend/{ => expression}/test_mysql_literal_values.py (72%) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py similarity index 72% rename from tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py rename to tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py index 510eec31..80f384da 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py @@ -1,5 +1,5 @@ -# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values.py -"""MySQL's spellings for the value literals. +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py +"""MySQL's spellings for the value literals -- construction and rendering only. Core's one literal node owns the binding decision: ``Literal(d, value)`` binds the host value behind a placeholder, and ``Literal(d, value, @@ -35,8 +35,10 @@ dialect's ``_escape_sql_string`` doubles backslashes for every inline string tier-1 renders, and stops doubling under ``NO_BACKSLASH_ESCAPES``. -The live-server class at the bottom re-measures the tier-2 answers end to -end through a running server, on every scenario the suite reaches. +The measurements are re-measured end to end against a running server by +``../test_mysql_literal_values_live.py``: an expression test starts no +connection and asserts what this dialect renders (§12), and the re-measurement +needs a server, so it lives with the scenario-driven tests. """ import datetime @@ -191,67 +193,3 @@ def test_inline_literals_route_through_the_local_mixin(self, dialect): # format_literal, so the MRO test and the rendering test agree. sql, _ = Literal(dialect, b"\x01", inline=True).to_sql() assert sql == "X'01'" - - -class TestLiveServerReadsTheLiteralsBack: - """The tier-2 answers re-measured end to end against the running server. - - The DDL read-back goes over raw SQL on purpose: the literal forms are - this dialect's own product, and what is verified here is what the - wave's answers claim -- the server accepts the spelling and hands the - same value back. - """ - - def test_binary_default_roundtrips(self, mysql_backend): - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") - try: - mysql_backend.execute( - "CREATE TABLE ar_mysql_typed_bin (v VARBINARY(16) DEFAULT X'0102FF')" - ) - mysql_backend.execute("INSERT INTO ar_mysql_typed_bin () VALUES ()") - rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_bin") - assert [row["v"] for row in rows] == [b"\x01\x02\xff"] - finally: - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") - - def test_uuid_default_roundtrips(self, mysql_backend): - canonical = "550e8400-e29b-41d4-a716-446655440000" - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") - try: - mysql_backend.execute( - f"CREATE TABLE ar_mysql_typed_uuid (v CHAR(36) DEFAULT '{canonical}')" - ) - mysql_backend.execute("INSERT INTO ar_mysql_typed_uuid () VALUES ()") - rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_uuid") - assert [row["v"] for row in rows] == [canonical] - finally: - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") - - def test_interval_default_is_refused(self, mysql_backend): - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") - try: - with pytest.raises(Exception): # noqa: B017 - the refusal is the point - mysql_backend.execute( - "CREATE TABLE ar_mysql_typed_iv (v VARCHAR(10) DEFAULT INTERVAL 1 DAY)" - ) - finally: - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") - - def test_tier_one_defaults_are_accepted(self, mysql_backend): - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") - try: - mysql_backend.execute( - "CREATE TABLE ar_mysql_typed_t1 (" - " b TINYINT DEFAULT TRUE," - " n TINYINT DEFAULT 42," - " f DOUBLE DEFAULT 1.5," - " s VARCHAR(16) DEFAULT 'x''y'," - " d DATE DEFAULT '2026-10-17'," - " ts DATETIME DEFAULT '2026-10-17 10:00:00'" - ")" - ) - mysql_backend.execute("INSERT INTO ar_mysql_typed_t1 () VALUES ()") - rows = mysql_backend.fetch_all("SELECT * FROM ar_mysql_typed_t1") - assert len(rows) == 1 - finally: - mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py new file mode 100644 index 00000000..f7a70633 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py @@ -0,0 +1,81 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py +"""The live half of the literal-spelling verification. + +The tier-2 answers this wave recorded -- ``X'0102FF'`` for binary, the bare +quoted canonical text for a UUID, no literal form at all for an INTERVAL -- +are claims about what a server accepts and hands back. Asserting them needs a +running server, so they live here, in the scenario-driven part of the suite, +and not in the expression tests beside them: an expression test starts no +connection, builds a tree and checks what the dialect renders (§12). What is +verified here is the round trip through MySQL itself, on every scenario the +suite reaches. + +The rendering assertions -- the half that needs no connection -- stay in +``expression/test_mysql_literal_values.py``. +""" + +import pytest + + +class TestLiveServerReadsTheLiteralsBack: + """The tier-2 answers re-measured end to end against the running server. + + The DDL read-back goes over raw SQL on purpose: the literal forms are + this dialect's own product, and what is verified here is what the + wave's answers claim -- the server accepts the spelling and hands the + same value back. + """ + + def test_binary_default_roundtrips(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_bin (v VARBINARY(16) DEFAULT X'0102FF')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_bin () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_bin") + assert [row["v"] for row in rows] == [b"\x01\x02\xff"] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + + def test_uuid_default_roundtrips(self, mysql_backend): + canonical = "550e8400-e29b-41d4-a716-446655440000" + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + try: + mysql_backend.execute( + f"CREATE TABLE ar_mysql_typed_uuid (v CHAR(36) DEFAULT '{canonical}')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_uuid () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_uuid") + assert [row["v"] for row in rows] == [canonical] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + + def test_interval_default_is_refused(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + try: + with pytest.raises(Exception): # noqa: B017 - the refusal is the point + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_iv (v VARCHAR(10) DEFAULT INTERVAL 1 DAY)" + ) + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + + def test_tier_one_defaults_are_accepted(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_t1 (" + " b TINYINT DEFAULT TRUE," + " n TINYINT DEFAULT 42," + " f DOUBLE DEFAULT 1.5," + " s VARCHAR(16) DEFAULT 'x''y'," + " d DATE DEFAULT '2026-10-17'," + " ts DATETIME DEFAULT '2026-10-17 10:00:00'" + ")" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_t1 () VALUES ()") + rows = mysql_backend.fetch_all("SELECT * FROM ar_mysql_typed_t1") + assert len(rows) == 1 + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") \ No newline at end of file From 4b8f3ccb793ce652c7da80c2068c9253acf4f89b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 00:34:59 +0800 Subject: [PATCH 28/65] fix(mysql): reconcile the roundtrip matrix with the comparison and lock waves The matrix ran against a core three refactors older than the current HEAD (550ae740), and rows were stale in opposite directions: - _ROUNDTRIP_DEFECT carried query_parts.ForUpdateClause, but the core constructor now normalises strength, so the class round-trips and the row is deleted. The exactness assertion names the stale row itself. - predicates.ComparisonPredicate could not be built: op is validated at construction by normalize_comparison_operator and the generic guess hands it the fallback string 'x'. It now gets a dedicated constructor supplying the ComparisonOperator.EQ member, pinned in _EXPRESSION_CONTRACT_CONSTRUCTORS beside the sibling word-validated nodes (ArithmeticOperation, SetOperation), so both verdict tests and the two buildable-case tests pass instead of skipping. Evidence: full matrix file locally, exit 0 (was 3 failed / 2 errors); ruff clean on the changed file. --- .../backend/test_expression_roundtrip_all.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index e57ced3e..765e0f04 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -237,7 +237,7 @@ def _table(d, name="t"): "or its word; the generic constructor supplies \"x\".", "datetime._ExtractBase": "field must name a DateTimeField member or its " "word; the generic constructor supplies \"x\".", - # The two nodes whose operation is a closed set of *words*, validated at + # The three nodes whose operation is a closed set of *words*, validated at # construction. The generic constructor guesses a string for an unannotated # ``op`` and the node refuses it, so the factories hand a real word form. "operators.ArithmeticOperation": "op is validated against the wire word " @@ -246,6 +246,10 @@ def _table(d, name="t"): "query_sources.SetOperation": "op is validated against the wire word " "forms (union / union_all / intersect / except); the generic constructor " "supplies \"x\", which is not one of them.", + "predicates.ComparisonPredicate": "op is validated at construction by " + "normalize_comparison_operator, which accepts a ComparisonOperator member, " + "a wire word, or a SQL character; the generic constructor supplies \"x\", " + "which is none of them, so the factory hands ComparisonOperator.EQ.", } #: Every class with a dedicated constructor, in one mapping, so @@ -308,6 +312,8 @@ def partition_maintenance(d, cls_name): from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonOperator, + ComparisonPredicate, ILikePredicate, LikePredicate, ) @@ -333,6 +339,10 @@ def partition_maintenance(d, cls_name): lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), "predicates.ILikePredicate": lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + "predicates.ComparisonPredicate": + lambda d: ComparisonPredicate( + d, ComparisonOperator.EQ, Column(d, "a"), Literal(d, 1) + ), "operators.ArithmeticOperation": lambda d: ArithmeticOperation( d, "add", Column(d, "a"), Column(d, "b") @@ -1187,12 +1197,6 @@ def partition_maintenance(d, cls_name): "TriggerLevel member -- and the formatter reads ``.value``, which " "only the member has.", ), - "query_parts.ForUpdateClause": ( - "AssertionError", - "CORE DEFECT: the constructor stores ``strength`` raw, so the " - "restored instance carries the plain word where the original carried " - "the LockStrength member.", - ), "graph.GraphEdge": ( "AssertionError", "CORE DEFECT: the constructor stores ``direction`` raw; the restored " From e4c44f30fd8ee682e6b4006d3f2c2b7296176732 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 00:52:09 +0800 Subject: [PATCH 29/65] fix(mysql): write the locking clause from the strength phrase, not the wire word The core's ForUpdateClause now normalises strength at construction to a LockStrength member whose .phrase carries the SQL spelling, while the member's .value stays the language-neutral wire word. This backend's override wrote the bare wire word, so every locking clause rendered as "update" / "share" without the FOR prefix -- malformed SQL that the server rejects with 1064 near 'update', which is what the CI's eager-loading legs hit (6 live failures) and what the six rendering tests asserted against. The rewrite follows the current node contract: - the clause text is strength.phrase, the wire word never enters SQL; - OF columns accept both a plain string (rendered through format_identifier) and an expression node (rendered through to_sql), as the node's of_columns: Optional[List[Union[str, BaseExpression]]] declares -- the old override called to_sql() on everything and could not take a bare column name; - the NOWAIT / SKIP LOCKED / FOR SHARE version gates (MySQL 8.0+) are kept, and an unsupported strength still fails explicitly. Evidence: TestMySQLLockingExpressions green (6 failed -> 0); the live eager-loading ForUpdate legs (sync + async, both modifier files) run clean against the reachable scenarios (mysql_97/94/92/84/80) -- exit 0. The mysql_57/56 params error at connector setup in this environment (no local 5.x server on ports 14680/14681), which predates the change. ruff clean on the changed file. --- .../backend/impl/mysql/mixins/locking.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py index 076c1009..7c8ab40f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py @@ -15,7 +15,14 @@ def supports_for_update_skip_locked(self) -> bool: return self.version >= (8, 0, 0) def format_for_update_clause(self, clause) -> Tuple[str, tuple]: - """Format MySQL-specific FOR UPDATE / FOR SHARE clause.""" + """Format MySQL-specific FOR UPDATE / FOR SHARE clause. + + The node normalises ``strength`` to a :class:`LockStrength` member, + so the clause text is written from the member's ``phrase``: the wire + value stays the language-neutral strength word (``update`` / ``share``) + and the SQL spelling (``FOR UPDATE`` / ``FOR SHARE``) belongs to the + dialect, not to the artifact. + """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import LockStrength @@ -31,11 +38,14 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: self.name, f"{strength.value} (unsupported lock strength)" ) - sql_parts = [strength.value] + sql_parts = [strength.phrase] if clause.of_columns: of_parts = [] for col in clause.of_columns: + if isinstance(col, str): + of_parts.append(self.format_identifier(col)) + continue col_sql, col_params = col.to_sql() of_parts.append(col_sql) all_params.extend(col_params) From 931b8ed6faeb70474fe8aee4056313c9f5dc7814 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:04:30 +0800 Subject: [PATCH 30/65] fix(mysql): compose the DDL-position values inline in the two tests that bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both failures are the core's deliberate inline enforcement (rules §4.3 / §13 landing), not a rendering defect: a DDL position accepts no bind parameters, and the core refuses rather than inlining on the caller's behalf. Confirmed against the current HEAD (550ae740): - format_default_value_clause (ddl_column.py) raises "DEFAULT value must render without bind parameters" whenever the value still binds; DefaultValueClause marks a caller-built Literal inline at construction, and wraps a bare host value -- but passes any other expression node through unchanged, so the precision inside a composed node stays the caller's to declare. - the CHECK condition tree likewise requires each value node written inline=True by the composer of the tree. The two tests are the callers here, and both predate the enforcement: - test_check_constraint now composes the comparison's value as Literal(dialect, 0, inline=True). - test_ddl_default_current_timestamp_with_precision now passes the precision as Literal(dialect, 6, inline=True) through the current_timestamp factory (the node's _precision accepts an expression as-is), and its assertion changes from the bind spelling CURRENT_TIMESTAMP(%s) / (?) to the inline spelling CURRENT_TIMESTAMP(6). Evidence: both files green over the reachable scenarios (mysql_97/94/92/84/80) -- exit 0, including the live-DB DDL round trip (create table, introspect column, drop). The mysql_57/56 params error at connector setup in this environment (no local 5.x server on ports 14680/14681), which predates the change. ruff: 37 -> 36 findings on the two files (the reformatted check line lost one); the remaining 36 are the file's pre-existing long-line baseline. --- .../feature/backend/test_mysql_ddl_features.py | 2 +- .../feature/backend/test_niladic_functions.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py index c68b9b8c..571d6bd5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py @@ -547,7 +547,7 @@ def test_check_constraint(self): ] check = TableConstraint( dialect, - branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, ">=", Column(dialect, "age"), Literal(dialect, 0) )) + branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, ">=", Column(dialect, "age"), Literal(dialect, 0, inline=True) )) ) expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=[check], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py index bf187d7f..c4bac354 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py @@ -203,6 +203,8 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): """DEFAULT CURRENT_TIMESTAMP(6) (with precision) works in MySQL DDL.""" + from rhosocial.activerecord.backend.expression.core import Literal + dialect = mysql_backend.dialect table_name = "test_niladic_ddl_3" @@ -213,7 +215,7 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): dialect=dialect, table=Table(dialect, table_name), columns=[ - ColumnDefinition(dialect, + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ @@ -226,14 +228,19 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): "ts", TimestampType(dialect, 6), constraints=[ - ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect, 6))), + # A DEFAULT position accepts no bind parameters, so the + # precision literal is composed inline (core §4.3): the + # node's factory takes the expression as-is, and only a + # bind-inlined tree survives format_default_value_clause. + ColumnConstraint(dialect, branch=ColumnDefaultConstraint( + dialect, current_timestamp(dialect, Literal(dialect, 6, inline=True)))), ], ), ], if_not_exists=True, ) sql, params = create.to_sql() - assert "CURRENT_TIMESTAMP(%s)" in sql or "CURRENT_TIMESTAMP(?)" in sql + assert "CURRENT_TIMESTAMP(6)" in sql try: mysql_backend.execute(sql, params) From b637e9d7850fbe063b6ffb188c72b1f44df232c8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:11:41 +0800 Subject: [PATCH 31/65] fix(mysql): construct CustomType under the keyword-only raw signature The core moved the type-name validation into CustomType's constructor (types/custom.py): raw is keyword-only and validated there rather than in each dialect's formatter, because a type name lands in a position that cannot take a bound parameter. This backend's parse_type still spelled it positionally -- CustomType(self, stripped) -- which is the TypeError the CI's type-protocol legs hit: "takes from 1 to 2 positional arguments but 3 were given", six tests over the two call sites (the VARBINARY-without-length fallback and the end-of-family fallback in mixins/types.py). Both sites now spell CustomType(self, raw=stripped). The name validation accepts every string the tests exercise (CHARACTER VARYING(10), FIXED(10,2), CLOB, BYTEA, SOMETHING_ELSE, VARBINARY -- verified against validate_type_name), so parse_type keeps answering CustomType for words the framework does not model, unchanged. Evidence: the six failing tests pass (exit 0) and the whole test_mysql_type_protocol.py file is green (the ParseRoundTripSweep class excluded pending the testsuite import fix). ruff on the changed file: same single pre-existing F541 baseline finding as before the change, none added. --- src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py index 9ea6d986..611625f0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py @@ -1213,7 +1213,7 @@ def parse_type(self, raw: str) -> DataType: # guessed at, so a hand-written string gets the same answer a # constructor call would. if length is None: - return CustomType(self, stripped) + return CustomType(self, raw=stripped) from ..expression.types import MySQLVarBinaryType return MySQLVarBinaryType(self, length) # CHAR / VARCHAR @@ -1319,7 +1319,7 @@ def parse_type(self, raw: str) -> DataType: return MySQLVectorType(self, dim) # Fallback: an honest "I do not model this type" rather than a guess. - return CustomType(self, stripped) + return CustomType(self, raw=stripped) # ------------------------------------------------------------------ # DataTypeSupport — cross-backend type suggestions From d937a40e41d206523d25bdc74dee5545c523eb63 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:18:07 +0800 Subject: [PATCH 32/65] fix(mysql): compose collate over a Literal with the factory, not a chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The value nodes carry no operation surface any more (rules §4.3: a literal is a value and nothing else, and compositions such as an explicit COLLATE are their own node), so Literal(...).collate(...) is an AttributeError -- the chain lives on SQLValue, which a Column is and a Literal is not. The Literal test now composes the CollateFunction through the collate() factory over the literal node, which renders the same SQL and the same binding: (%s COLLATE utf8mb4_0900_ai_ci, ('Alice',)). The schema-qualified refusal moved with it. MySQL's validate_collation_name now reads the node's named schema parameter and refuses it explicitly: UnsupportedFeatureError: 'MySQL' dialect does not support COLLATE schema qualification. Suggestion: MySQL's COLLATE takes a single collation name, e.g. COLLATE utf8mb4_bin; ... That is the improved explicit failure (named feature plus an actionable suggestion, replacing the old options-bag message), so the test's assertion follows it: same construction, same raises, regex updated to name the feature. The five Column tests are untouched -- a Column is an SQLValue and keeps the chain. Evidence: whole file green over the reachable scenarios (mysql_97/94/92/84/80), the two live-DB collate-executes legs included -- exit 0; ruff clean on the changed file. --- .../feature/query/mysql/test_collation_expression.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py index 20e7009b..6c9e0379 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py @@ -5,7 +5,9 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.collation import collate from rhosocial.activerecord.backend.impl.mysql.mixins import MySQLCollation from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -70,7 +72,10 @@ def test_column_collate_generates_sql(self, dialect): assert params == () def test_literal_collate_preserves_parameter_binding(self, dialect): - expr = Literal(dialect, "Alice").collate(MySQLCollation.UTF8MB4_0900_AI_CI) + # A Literal is a value node and carries no operation surface of its + # own (rules §4.3), so COLLATE is composed with the collate factory + # over it rather than as a chain on the literal. + expr = collate(Literal(dialect, "Alice"), MySQLCollation.UTF8MB4_0900_AI_CI) sql, params = expr.to_sql() @@ -80,7 +85,9 @@ def test_literal_collate_preserves_parameter_binding(self, dialect): def test_rejects_schema_qualified_collation(self, dialect): expr = Column(dialect, "name").collate("utf8mb4_bin", schema="public") - with pytest.raises(Exception, match="COLLATE options: schema"): + with pytest.raises( + UnsupportedFeatureError, match="COLLATE schema qualification" + ): expr.to_sql() def test_rejects_unsupported_collation(self, dialect): From 76e7e55f1d8e8b1eb588aab722cd0594b92b95b5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:31:17 +0800 Subject: [PATCH 33/65] fix(mysql): call the transaction setters by their current names The core renamed the transaction nodes' fluent setters: the setter for the isolation level is now with_isolation_level(), and the old fluent name is a stored constructor parameter (None until set). A test still calling expr.isolation_level(level) therefore read that attribute -- an Optional[IsolationLevel] defaulting to None -- and called it, which is the TypeError: 'NoneType' object is not callable the CI hit in both the Begin and the Set classes (11 tests across the isolation-level cases). read_only() and read_write() kept their names and are untouched. Checked the whole tree for the same stale call shape: these five call sites in test_expressions_transaction.py were the only ones. The other isolation_level spellings in the tree are the current API -- the transaction manager's property assignment and the mixin's params.get("isolation_level") -- and both still work. The mixin overrides themselves needed no change: they read get_params(), which reads the stored constructor parameters under their own names, and with_isolation_level() stores under exactly that name (verified: get_params() reports the level after the setter call, and both formatters render it -- START TRANSACTION READ ONLY excludes the level, SET TRANSACTION ISOLATION LEVEL SERIALIZABLE includes it). Evidence: the whole test_expressions_transaction.py file, exit 0 (was 11 failed); ruff clean on the changed file. --- .../backend/expression/test_expressions_transaction.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py index c78514e6..8a04130b 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py @@ -44,7 +44,7 @@ def test_begin_with_isolation_level_returns_only_start(self, mysql_dialect): only returns the START TRANSACTION statement. """ expr = BeginTransactionStatement(mysql_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level in START TRANSACTION assert sql == "START TRANSACTION" @@ -76,7 +76,7 @@ def test_begin_with_isolation_and_read_only(self, mysql_dialect): for READ ONLY mode. """ expr = BeginTransactionStatement(mysql_dialect) - expr.isolation_level(IsolationLevel.READ_COMMITTED).read_only() + expr.with_isolation_level(IsolationLevel.READ_COMMITTED).read_only() sql, params = expr.to_sql() # Only READ ONLY mode is included, isolation level is NOT assert sql == "START TRANSACTION READ ONLY" @@ -97,7 +97,7 @@ def test_begin_with_isolation_returns_start_transaction(self, mysql_dialect, lev MySQL requires separate SET TRANSACTION ISOLATION LEVEL statement. """ expr = BeginTransactionStatement(mysql_dialect) - expr.isolation_level(level) + expr.with_isolation_level(level) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level assert sql == "START TRANSACTION" @@ -114,7 +114,7 @@ class TestMySQLSetTransactionExpression: def test_set_isolation_level(self, mysql_dialect): """Test SET TRANSACTION ISOLATION LEVEL.""" expr = SetTransactionExpression(mysql_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert sql == "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" assert params == () @@ -131,7 +131,7 @@ def test_set_isolation_level(self, mysql_dialect): def test_all_isolation_levels(self, mysql_dialect, level, expected_name): """Test all isolation levels in SET TRANSACTION.""" expr = SetTransactionExpression(mysql_dialect) - expr.isolation_level(level) + expr.with_isolation_level(level) sql, params = expr.to_sql() assert expected_name in sql assert params == () From 35018b458ce505061644243b1b2da92ce7572511 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:39:59 +0800 Subject: [PATCH 34/65] fix(mysql): vendor the parse round-trip sweep beside the roundtrip harness The testsuite's shared harness is gone: it deleted testsuite/utils/expression.py (e05edb0, round-trip coverage moves to the owning repository), and its parse_contract.py imports that deleted module, so the import from it dies too -- in the venv, both utils.expression and utils.parse_contract raise ModuleNotFoundError. The sweep therefore cannot be imported from the suite at all, and the option of pointing the import at a fixed parse_contract is not available. Following this repository's own vendored-harness precedent (eb45de1), parse_roundtrip_failures is now vendored into expression_roundtrip_harness.py. It is the suite's sweep with its make_instance dependency resolved to the harness's own make_instance (the same function the matrix's dedicated constructors register through), and the two scope decisions carried over verbatim: a class the registry cannot build or cannot render belongs to the matrix's lists, and a CustomType answer is never blessed for a rendered word. The test's class docstring already describes the sweep as "the shared sweep over this backend's round-trip registry"; the import now names the vendored module beside the matrix that registry comes from. With the import alive the sweep runs for the first time in CI, and it names one gap the dead import had been hiding: the core's EnumType renders ENUM('x') exactly as MySQL does, but the server's declaration also carries a charset and a collation, so parse_type answers the backend's own MySQLEnumType -- which carries those fields -- and re-renders the identical string. That is the documented-widening pattern, not a defect, so it earns a row in WIDENING_ANSWERS with the reason. Evidence: TestParseRoundTripSweep exit 0; the whole test_mysql_type_protocol.py file exit 0; no remaining testsuite.utils.expression / parse_contract references repo-wide; ruff clean on both changed files. --- .../backend/expression_roundtrip_harness.py | 135 +++++++++++++++++- .../backend/test_mysql_type_protocol.py | 10 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py index e3bd43b2..6d824c8f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py @@ -31,7 +31,7 @@ import re import typing import warnings -from typing import Any, Callable, Dict, Type +from typing import Any, Callable, Dict, Optional, Type from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -1072,10 +1072,143 @@ def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: assert restored.to_sql() == expected, fqn +def parse_roundtrip_failures( + dialect: Any, + registry: Dict[str, Any], + *, + widening: Optional[Dict[str, Any]] = None, + skip: Any = (), +) -> Any: + """Sweep *registry* and return the parse round-trip failures as text. + + Every dialect's ``parse_type`` is the introspection inverse of its + ``format_data_type_`` renderings, and two invariants make a + dialect coherent: + + **String stability** -- parsing a rendering and re-rendering the + answer must produce the identical string. Whatever ``parse`` answers, + its answer must name the same storage the server holds, or the schema + differ would report a change no one made. + + **Class honesty** -- the answer is either the declared instance + itself (``parsed == declared``, the round-trip-equal case) or the + *documented* answer class for that declaration, recorded in the + owning backend's test file with its reason. + + This is the vendored equivalent of the testsuite's shared sweep + (``testsuite/utils/parse_contract.py``), which the testsuite deleted + together with its round-trip harness when round-trip coverage moved + to the owning repository. The two decisions in it that outlived that + move are unchanged: a class the registry cannot construct or cannot + render belongs to the round-trip matrix's own lists, and a + ``CustomType`` answer is never blessed for a rendered word. + + Args: + dialect: the dialect whose ``parse_type`` is under test. + registry: a mapping of fully-qualified name to class, as the + backend's round-trip module keeps it (``REGISTERED`` or + ``ALL_CLASSES``). Only concrete ``DataType`` subclasses are + swept. + widening: the backend's documented answer table, + ``{declared class name: answer}``. An entry here asserts the + parse answers with exactly that class and that the answer + re-renders the identical string, **unless** the value is a + ``(class name, expected string)`` tuple: that form is for the + documented answers whose re-render deliberately *differs*. + skip: class names to leave out of the sweep entirely, in addition + to the built-in ``CustomType`` skip. + + Returns: + A list of human-readable failures; empty is the contract met. + """ + from rhosocial.activerecord.backend.expression.types import CustomType, DataType + + widening = dict(widening or {}) + skipped = set(skip) | {"CustomType"} + failures = [] + for fqn in sorted(registry): + cls = registry[fqn] + if not (isinstance(cls, type) and issubclass(cls, DataType)): + continue + if cls is DataType or cls is CustomType: + continue + if cls.__name__ in skipped: + continue + instance, source = make_instance(cls, dialect) + if instance is None: + continue # the matrix's unbuildable list owns this class + try: + rendered, _params = instance.to_sql() + except Exception: + continue # the matrix's pinned non-renders own this class + try: + parsed = dialect.parse_type(rendered) + except Exception as exc: + failures.append( + "%s renders %r but parse_type raises %s: %s" + % (cls.__name__, rendered, type(exc).__name__, exc) + ) + continue + if isinstance(parsed, CustomType): + failures.append( + "%s renders %r but parse_type answers CustomType -- a " + "dialect must recognise its own renderings" + % (cls.__name__, rendered) + ) + continue + expected_entry = widening.get(cls.__name__) + if expected_entry is None: + if parsed != instance: + failures.append( + "%s renders %r but parse_type answers %r -- neither " + "the declaration nor a documented widening" + % (cls.__name__, rendered, parsed) + ) + continue + if isinstance(expected_entry, tuple): + expected, expected_restring = expected_entry + else: + expected, expected_restring = expected_entry, None + if type(parsed).__name__ != expected: + failures.append( + "%s renders %r; the documented answer is %s but " + "parse_type answers %s" + % (cls.__name__, rendered, expected, type(parsed).__name__) + ) + continue + try: + re_rendered, _ = parsed.to_sql() + except Exception as exc: + failures.append( + "%s renders %r; the documented answer %s re-renders with " + "%s: %s" + % (cls.__name__, rendered, expected, type(exc).__name__, exc) + ) + continue + if expected_restring is not None: + if re_rendered != expected_restring: + failures.append( + "%s renders %r; the documented answer %s re-renders " + "%r but the table says %r" + % (cls.__name__, rendered, expected, + re_rendered, expected_restring) + ) + elif re_rendered != rendered: + failures.append( + "%s renders %r but the documented answer %s re-renders " + "%r -- the strings must name the same storage, or the " + "table entry must record the documented re-render as a " + "(class, string) tuple" + % (cls.__name__, rendered, expected, re_rendered) + ) + return failures + + __all__ = [ "assert_params_equal", "collect_expression_classes", "make_instance", + "parse_roundtrip_failures", "register_all", "register_special_constructor", "roundtrip_expression", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index 1f9c6548..20af3625 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -1581,6 +1581,11 @@ class TestParseRoundTripSweep: "TimeTzType": "TimeType", "TimestampTzType": "TimestampType", "JsonBType": "JsonType", + # The core's generic enum renders ``ENUM('x')`` the same way MySQL + # does, but the server's declaration also carries a charset and a + # collation, so the parse answers the backend's own class -- which + # carries those two fields -- and it re-renders the identical string. + "EnumType": "MySQLEnumType", } #: CAST targets, not column types: ``CAST(x AS SIGNED)`` is the only @@ -1609,7 +1614,10 @@ def registry(self): return registry def test_every_rendered_type_parses_back_coherently(self, dialect, registry): - from rhosocial.activerecord.testsuite.utils.parse_contract import ( + # The sweep is vendored beside the matrix (the testsuite deleted its + # shared harness with round-trip coverage moving to each backend); + # make_instance is the same function the matrix registers with. + from expression_roundtrip_harness import ( parse_roundtrip_failures, ) From 4d42f895abb24757afc3a818009a3d655911f7f0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 20 Oct 2026 01:47:37 +0800 Subject: [PATCH 35/65] fix(mysql): import CountFunction in the partition operations tests The classed-function wave moved the aggregate entry points to real classes, and the sister file test_mysql_partition_strategy_operations.py was updated for it while this one was missed: its two exchange-expired-year partition cases call CountFunction(dialect, RowCountWildcard(dialect)) with the name never imported, which is the NameError the CI's partition legs hit (sync and async classes). The import block now carries CountFunction beside ComparisonPredicate, matching the sister file's ordering. Per the same lesson, the whole repo was audited for this class of miss rather than only the reported file: ruff --select F821 over the tests tree reports nothing of this kind any more (the two F821 findings the old state of this file carried are gone), and the remaining baseline findings -- two f-string assertion messages referencing an undefined 'clause' in the transaction-isolation-effect files and the py312 fixture's type-parameter syntax -- predate this round and are not missed imports of the renamed families. Evidence: before, NameError on both cases at line 524 (reproduced by stashing the fix); after, the two cases pass over every reachable scenario (mysql_97/94/92/84/80) and the whole file is green (exit 0). ruff on the file: 32 -> 30 findings; the 30 remaining are the file's pre-existing long-line and blind-exception baseline. --- .../feature/backend/test_mysql_partition_operations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index 1ae43fc5..b11f3019 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -13,6 +13,7 @@ ColumnDefinition, ColumnNotNullConstraint, ComparisonPredicate, + CountFunction, CreateTableStatement, DropTableStatement, FunctionCall, From 282b374778d95ce1f30818ae01cb45134ece6aa9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 00:22:11 +0800 Subject: [PATCH 36/65] fix(mysql): pass the LIKE operator as its enum, not the SQL keyword The LIKE family's operator became an enum (core f2bdf386): the parameter is LikeOperator, the keyword is a spelling the dialect owns, and the constructor coerces words and keywords alike (core 4774c502) -- but the wire form is the member. These call sites still passed the SQL keyword as a string, so they built whichever shape the coercion happened to produce; they now pass LikeOperator.LIKE, which is what the artifact carries. Verified: the LIKE predicate renders on this backend's dialect through the new enum (left LIKE placeholder, the pattern bound); ruff on the touched files matches the repository baseline. --- .../backend/impl/mysql/examples/query/predicate.py | 3 ++- .../feature/backend/test_expression_roundtrip_all.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py index 2965d9c7..0339d8d2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py @@ -99,6 +99,7 @@ WhereClause, ) from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 + LikeOperator, ComparisonPredicate, LikePredicate, InPredicate, @@ -122,7 +123,7 @@ dialect, condition=LikePredicate( dialect, - "LIKE", + LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "A%"), ), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 765e0f04..818992c4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -315,6 +315,7 @@ def partition_maintenance(d, cls_name): ComparisonOperator, ComparisonPredicate, ILikePredicate, + LikeOperator, LikePredicate, ) from rhosocial.activerecord.backend.expression.query_sources import SetOperation @@ -336,7 +337,7 @@ def partition_maintenance(d, cls_name): factories = { "predicates.LikePredicate": - lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), + lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), "predicates.ILikePredicate": lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), "predicates.ComparisonPredicate": From ed70783aed02be4d08c12c0060a887e90970f77f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 03:14:46 +0800 Subject: [PATCH 37/65] refactor(mysql): build introspection queries from nodes, not text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `format_*_query` in `mixins/introspection.py` assembled its statement as a string: a `"SELECT ..."` prefix, `sql +=` fragments and f-strings for the predicates. The query then existed only as text -- uninspectable, and one interpolation away from meaning something else. All eight formatters are now expression trees: * the projection is a list of `Column` nodes, in the same order; * `from_` is a `Table` carrying `information_schema` in the catalog slot (MySQL's only namespace level is the database; `schema_name` would be refused rather than silently dropped); * filters are `ComparisonPredicate` / `InPredicate` / `LogicalPredicate`, and the foreign-key query is a `JoinClause` whose ON predicate is written out instead of folded into a string; * ordering is `OrderByClause`. What stayed bound and what became inline follows the original exactly, so each parameter tuple is byte-identical: the caller's schema / table / view / trigger names remain bind parameters, while the engine's own exclusion list (`information_schema`, `performance_schema`, `mysql`, `sys`) and the `BASE TABLE` type literal stay inline, as they were. One shape, `_information_schema_query`, holds the clause structure the eight formatters share; each supplies only relation, projection, conjunction and ordering. Baseline diff (14 representative cases, before versus after; both captured to mysql-intro-baseline.json / mysql-intro-after.json): projection order, aliases, parameter count and parameter order are identical in all 14. The only differences are the ones the guide allows -- identifier quoting (the concatenated form wrote bare names, the dialect now quotes them) and the form of two predicates: `x NOT IN (...)` renders as `NOT (x IN (...))` from the NOT node, and `t a` renders as `t AS a` from the relation reference. Each is the same predicate and the same source. The queries were then run against live servers, so this is a measured equivalence rather than a textual one: 8.0.46, 8.4.11 and 26.7.0 return the same rows and the same column labels through `backend.introspector` (tables, columns, indexes, foreign keys, views, view info, triggers, database info). `expression/system.py` is new and holds the two questions a backend asks about itself: `SELECT VERSION()` and the ping's `SELECT 1`. `get_server_version()` and `ping()` in `backend.py` / `async_backend.py` now build them as nodes instead of passing text to the cursor. The status introspector's own call sites -- which include the same `SELECT VERSION()` -- are migrated in the next commit, together with the SHOW family they interleave with in one file. Tests: new `test_mysql_introspection_queries.py` pins projection order, parameter order and which filters bind versus inline; it is render-only and opens no connection (org rules §12). Live: `introspection/ -k mysql_80` 59 passed, 1 skipped; `test_cursor_pollution.py -k "mysql_80 or mysql_84"` passed. --- .../impl/mysql/backend/async_backend.py | 8 +- .../backend/impl/mysql/backend/backend.py | 8 +- .../impl/mysql/examples/query/union.py | 3 +- .../backend/impl/mysql/expression/system.py | 54 +++ .../impl/mysql/mixins/introspection.py | 389 ++++++++++++++---- .../test_mysql_introspection_queries.py | 185 +++++++++ 6 files changed, 565 insertions(+), 82 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/expression/system.py create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py index 329e7cd4..4dd3798a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py @@ -365,8 +365,10 @@ async def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: + from ..expression.system import version_query + cursor = await self._get_cursor() - await cursor.execute("SELECT VERSION()") + await cursor.execute(*version_query(self.dialect).to_sql()) version_row = await cursor.fetchone() version_str = version_row[0] if version_row else "8.0.0" @@ -433,8 +435,10 @@ async def ping(self, reconnect: bool = True) -> bool: # reconnect=True: verify connection with SELECT 1 try: + from ..expression.system import ping_query + cursor = await self._get_cursor() - await cursor.execute("SELECT 1") + await cursor.execute(*ping_query(self.dialect).to_sql()) await cursor.fetchone() await cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py b/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py index 199efa80..27bc604d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py @@ -351,8 +351,10 @@ def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: + from ..expression.system import version_query + cursor = self._get_cursor() - cursor.execute("SELECT VERSION()") + cursor.execute(*version_query(self.dialect).to_sql()) version_row = cursor.fetchone() version_str = version_row[0] if version_row else "8.0.0" @@ -419,8 +421,10 @@ def ping(self, reconnect: bool = True) -> bool: # reconnect=True: verify connection with SELECT 1 try: + from ..expression.system import ping_query + cursor = self._get_cursor() - cursor.execute("SELECT 1") + cursor.execute(*ping_query(self.dialect).to_sql()) cursor.fetchone() cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py index 3d31e019..61c42924 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py @@ -147,4 +147,5 @@ # 1. Use SetOperation with op='union' / op='union_all'; the dialect writes the # SQL spelling and gates it behind its own capability probes # 2. UNION removes duplicates, UNION ALL keeps all rows -# 3. Can add ORDER BY using query1.with_order_by() +# 3. ORDER BY is a clause on the statement, not a setter on the query: +# QueryStatement(..., order_by=OrderByClause(dialect, [...])) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py new file mode 100644 index 00000000..d6e2cc12 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py @@ -0,0 +1,54 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/system.py +"""MySQL server queries that ask the engine a question, as expression nodes. + +These are the statements a MySQL backend runs to learn about *itself* rather +than about a user's schema: the server version, whether the connection still +answers, and the session's own account. They are built here, once, because both +the backend and the status introspector ask them -- and because a caller that +writes ``"SELECT VERSION()"`` as text has bypassed the expression layer, leaving +the command as a string the framework cannot inspect. + +Each builder returns a node; the caller renders it with ``to_sql()``. Nothing +here executes anything. +""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + +__all__ = ["current_user_query", "ping_query", "version_query"] + + +def version_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT VERSION()`` -- the server's own version string. + + No alias: the column label MySQL returns for the bare call is what callers + read back, and naming it here would rename it for them. + """ + return QueryStatement(dialect, select=[FunctionCall(dialect, "VERSION")]) + + +def ping_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT 1`` -- the cheapest question a live connection can answer. + + The literal is inline so the statement carries no parameters at all: a ping + that needs the driver to bind something tests the driver as well as the + connection. + """ + return QueryStatement(dialect, select=[Literal(dialect, 1, inline=True)]) + + +def current_user_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT CURRENT_USER`` -- the account this session authenticated as. + + Core's node spells the niladic form, so the column label is + ``CURRENT_USER`` rather than ``CURRENT_USER()``. + """ + from rhosocial.activerecord.backend.expression.functions.system import CurrentUserFunction + + return QueryStatement(dialect, select=[CurrentUserFunction(dialect)]) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py index 3f459d73..cbc7129c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py -from typing import List, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.introspection.types import IntrospectionScope @@ -15,6 +15,11 @@ ) +#: MySQL's own metadata databases. A caller asking for "user tables" does not +#: mean these four, so they are excluded unless system objects were requested. +SYSTEM_SCHEMAS = ("information_schema", "performance_schema", "mysql", "sys") + + class MySQLIntrospectionMixin: """MySQL introspection capability declaration and query formatting. @@ -31,6 +36,12 @@ class MySQLIntrospectionMixin: → Dialect.format_*_query() [this mixin] → Returns SQL and parameters + Every query here is an expression tree, not assembled text: a projection of + :class:`~...expression.core.Column` nodes, a relation, a conjunction of + predicates and an ordering. What a query *asks* is therefore a structure the + rest of the framework can inspect, and this module writes no clause at all -- + the dialect renders each node. + MySQL supports all introspection features via information_schema. """ @@ -84,143 +95,367 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== + def _information_schema_query( + self, + relation: str, + projections: "list", + conditions: "list", + order_by: "Optional[list]" = None, + source: "Any" = None, + ) -> Tuple[str, tuple]: + """Build a SELECT over one ``information_schema`` relation. + + Each formatter below supplies only what varies -- the relation, the + projection, the conjunction and the ordering -- and the shape lives + here. The relation is a :class:`Table` carrying its database in the + catalog slot, because MySQL's only namespace level is the database and + putting ``information_schema`` in ``schema_name`` would be refused + rather than silently dropped. + + Args: + relation: The ``information_schema`` relation being read. + projections: The select list, in order. + conditions: Conditions to AND together. + order_by: The ordering expressions, or ``None`` for no ORDER BY. + source: A pre-built FROM source (a join, for the foreign-key + query), or ``None`` to read *relation* directly. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + LogicalPredicate, + OrderByClause, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + kwargs: "dict" = { + "select": projections, + "from_": ( + source + if source is not None + else Table(self, relation, catalog_name="information_schema") + ), + "where": WhereClause(self, LogicalPredicate(self, "AND", *conditions)), + } + if order_by is not None: + kwargs["order_by"] = OrderByClause(self, order_by) + return QueryStatement(self, **kwargs).to_sql() + def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[str, tuple]: """Format database information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() schema = params.get("schema", "") - sql = ( - "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME " - "FROM information_schema.SCHEMATA " - f"WHERE SCHEMA_NAME = {self.p()}" + + return self._information_schema_query( + "SCHEMATA", + [ + Column(self, "DEFAULT_CHARACTER_SET_NAME"), + Column(self, "DEFAULT_COLLATION_NAME"), + ], + [ComparisonPredicate(self, "eq", Column(self, "SCHEMA_NAME"), Literal(self, schema))], ) - return (sql, (schema,)) def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tuple]: """Format table list query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, + LogicalPredicate, + ) + params = expr.get_params() schema = params.get("schema", "") include_views = params.get("include_views", True) include_system = params.get("include_system", False) table_type = params.get("table_type") - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + # The bound schema value comes first, so the parameter tuple keeps the + # order the concatenated form produced. + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)) + ] if not include_system: - conditions.append("TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')") + # The exclusion list is literal-inline: it is a fact about the + # engine, not caller data, so it never becomes a bind parameter. + conditions.append( + LogicalPredicate( + self, + "NOT", + InPredicate( + self, + Column(self, "TABLE_SCHEMA"), + Literal(self, SYSTEM_SCHEMAS, inline=True), + ), + ) + ) if not include_views: - conditions.append("TABLE_TYPE = 'BASE TABLE'") + conditions.append( + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ) + ) if table_type: - conditions.append(f"TABLE_TYPE = {self.p()}") - sql_params.append(table_type) - - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT, TABLE_ROWS, " - "DATA_LENGTH, AUTO_INCREMENT, CREATE_TIME, UPDATE_TIME " - f"FROM information_schema.TABLES WHERE {where}" + conditions.append( + ComparisonPredicate( + self, "eq", Column(self, "TABLE_TYPE"), Literal(self, table_type) + ) + ) + + return self._information_schema_query( + "TABLES", + [ + Column(self, "TABLE_NAME"), + Column(self, "TABLE_TYPE"), + Column(self, "TABLE_COMMENT"), + Column(self, "TABLE_ROWS"), + Column(self, "DATA_LENGTH"), + Column(self, "AUTO_INCREMENT"), + Column(self, "CREATE_TIME"), + Column(self, "UPDATE_TIME"), + ], + conditions, ) - return (sql, tuple(sql_params)) def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, tuple]: """Format column information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, " - "DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, " - "COLUMN_TYPE, COLUMN_KEY, EXTRA, COLUMN_COMMENT, " - "CHARACTER_SET_NAME, COLLATION_NAME " - "FROM information_schema.COLUMNS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()} " - "ORDER BY ORDINAL_POSITION" + + return self._information_schema_query( + "COLUMNS", + [ + Column(self, "COLUMN_NAME"), + Column(self, "ORDINAL_POSITION"), + Column(self, "COLUMN_DEFAULT"), + Column(self, "IS_NULLABLE"), + Column(self, "DATA_TYPE"), + Column(self, "CHARACTER_MAXIMUM_LENGTH"), + Column(self, "NUMERIC_PRECISION"), + Column(self, "NUMERIC_SCALE"), + Column(self, "COLUMN_TYPE"), + Column(self, "COLUMN_KEY"), + Column(self, "EXTRA"), + Column(self, "COLUMN_COMMENT"), + Column(self, "CHARACTER_SET_NAME"), + Column(self, "COLLATION_NAME"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name)), + ], + [Column(self, "ORDINAL_POSITION")], ) - return (sql, (schema, table_name)) def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tuple]: """Format index information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, " - "INDEX_TYPE, SUB_PART, NULLABLE " - "FROM information_schema.STATISTICS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()} " - "ORDER BY INDEX_NAME, SEQ_IN_INDEX" + + return self._information_schema_query( + "STATISTICS", + [ + Column(self, "INDEX_NAME"), + Column(self, "NON_UNIQUE"), + Column(self, "SEQ_IN_INDEX"), + Column(self, "COLUMN_NAME"), + Column(self, "INDEX_TYPE"), + Column(self, "SUB_PART"), + Column(self, "NULLABLE"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name)), + ], + [Column(self, "INDEX_NAME"), Column(self, "SEQ_IN_INDEX")], ) - return (sql, (schema, table_name)) def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, tuple]: """Format foreign key information query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + IsNullPredicate, + JoinClause, + Literal, + LogicalPredicate, + ) + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources.relation import NamedRelationRef + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION, " - "kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME, " - "rc.UPDATE_RULE, rc.DELETE_RULE " - "FROM information_schema.KEY_COLUMN_USAGE kcu " - "JOIN information_schema.REFERENTIAL_CONSTRAINTS rc " - " ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME " - " AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA " - f"WHERE kcu.TABLE_SCHEMA = {self.p()} AND kcu.TABLE_NAME = {self.p()} " - " AND kcu.REFERENCED_TABLE_NAME IS NOT NULL " - "ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION" + + usage = NamedRelationRef( + self, + Table(self, "KEY_COLUMN_USAGE", catalog_name="information_schema"), + alias="kcu", + ) + constraints = NamedRelationRef( + self, + Table(self, "REFERENTIAL_CONSTRAINTS", catalog_name="information_schema"), + alias="rc", + ) + join = JoinClause( + self, + left_table=usage, + right_table=constraints, + join_type="JOIN", + condition=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + "eq", + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "CONSTRAINT_NAME", table="rc"), + ), + ComparisonPredicate( + self, + "eq", + Column(self, "CONSTRAINT_SCHEMA", table="kcu"), + Column(self, "CONSTRAINT_SCHEMA", table="rc"), + ), + ), + ) + + return self._information_schema_query( + "KEY_COLUMN_USAGE", + [ + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "COLUMN_NAME", table="kcu"), + Column(self, "ORDINAL_POSITION", table="kcu"), + Column(self, "REFERENCED_TABLE_NAME", table="kcu"), + Column(self, "REFERENCED_COLUMN_NAME", table="kcu"), + Column(self, "UPDATE_RULE", table="rc"), + Column(self, "DELETE_RULE", table="rc"), + ], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA", table="kcu"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME", table="kcu"), Literal(self, table_name) + ), + # A usage row without a referenced table is a plain index + # column, not a foreign key: the predicate says so in the tree. + IsNullPredicate(self, Column(self, "REFERENCED_TABLE_NAME", table="kcu"), is_not=True), + ], + [ + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "ORDINAL_POSITION", table="kcu"), + ], + source=join, ) - return (sql, (schema, table_name)) def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple]: """Format view list query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, + LogicalPredicate, + ) + params = expr.get_params() schema = params.get("schema", "") include_system = params.get("include_system", False) - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)) + ] if not include_system: - conditions.append("TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')") - - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - f"FROM information_schema.VIEWS WHERE {where} " - "ORDER BY TABLE_NAME" + conditions.append( + LogicalPredicate( + self, + "NOT", + InPredicate( + self, + Column(self, "TABLE_SCHEMA"), + Literal(self, SYSTEM_SCHEMAS, inline=True), + ), + ) + ) + + return self._information_schema_query( + "VIEWS", + [ + Column(self, "TABLE_NAME"), + Column(self, "VIEW_DEFINITION"), + Column(self, "CHECK_OPTION"), + Column(self, "IS_UPDATABLE"), + ], + conditions, + [Column(self, "TABLE_NAME")], ) - return (sql, tuple(sql_params)) def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple]: """Format single view information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() view_name = params.get("view_name", "") schema = params.get("schema", "") - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - "FROM information_schema.VIEWS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()}" + + return self._information_schema_query( + "VIEWS", + [ + Column(self, "TABLE_NAME"), + Column(self, "VIEW_DEFINITION"), + Column(self, "CHECK_OPTION"), + Column(self, "IS_UPDATABLE"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, view_name)), + ], ) - return (sql, (schema, view_name)) def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: """Format trigger list query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name") schema = params.get("schema", "") - conditions = [f"TRIGGER_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TRIGGER_SCHEMA"), Literal(self, schema)) + ] if table_name: - conditions.append(f"EVENT_OBJECT_TABLE = {self.p()}") - sql_params.append(table_name) - - where = " AND ".join(conditions) - sql = ( - "SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, " - "ACTION_TIMING, ACTION_STATEMENT, CREATED " - f"FROM information_schema.TRIGGERS WHERE {where} " - "ORDER BY TRIGGER_NAME" + conditions.append( + ComparisonPredicate( + self, "eq", Column(self, "EVENT_OBJECT_TABLE"), Literal(self, table_name) + ) + ) + + return self._information_schema_query( + "TRIGGERS", + [ + Column(self, "TRIGGER_NAME"), + Column(self, "EVENT_MANIPULATION"), + Column(self, "EVENT_OBJECT_TABLE"), + Column(self, "ACTION_TIMING"), + Column(self, "ACTION_STATEMENT"), + Column(self, "CREATED"), + ], + conditions, + [Column(self, "TRIGGER_NAME")], ) - return (sql, tuple(sql_params)) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py new file mode 100644 index 00000000..eec21b68 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py @@ -0,0 +1,185 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py +"""The introspection SELECTs, rendered from their nodes. + +These are render-only assertions (org rules §12): a dialect is constructed, a +node is built, and the SQL is read back. No connection is opened. + +What is pinned here is the part of each query that a caller depends on and +that a rewrite can silently change: the **projection order** (the parsers below +read columns positionally in places), the **parameter order** (schema before +name, so ``(schema, table_name)``), and whether a filter is bound or inline. +The identifier quoting, on the other hand, is deliberately not pinned +character for character -- it is the dialect's to choose, and these tests say +only what the query *asks*. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoExpression, + DatabaseInfoExpression, + ForeignKeyExpression, + IndexInfoExpression, + TableListExpression, + TriggerListExpression, + ViewInfoExpression, + ViewListExpression, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +@pytest.fixture +def dialect(): + return MySQLDialect(version=(8, 0, 0)) + + +class TestDatabaseInfo: + def test_projection_and_bound_schema(self, dialect): + sql, params = DatabaseInfoExpression(dialect, schema="app").to_sql() + assert sql == ( + "SELECT `DEFAULT_CHARACTER_SET_NAME`, `DEFAULT_COLLATION_NAME` " + "FROM `information_schema`.`SCHEMATA` WHERE `SCHEMA_NAME` = %s" + ) + assert params == ("app",) + + def test_a_missing_schema_is_bound_as_null(self, dialect): + _, params = DatabaseInfoExpression(dialect).to_sql() + assert params == (None,) + + +class TestTableList: + """The relation is ``information_schema.TABLES`` with the filters as nodes.""" + + def test_default_excludes_the_engine_s_own_schemas(self, dialect): + sql, params = TableListExpression(dialect, schema="app").to_sql() + assert sql.startswith( + "SELECT `TABLE_NAME`, `TABLE_TYPE`, `TABLE_COMMENT`, `TABLE_ROWS`, " + "`DATA_LENGTH`, `AUTO_INCREMENT`, `CREATE_TIME`, `UPDATE_TIME` " + "FROM `information_schema`.`TABLES` WHERE `TABLE_SCHEMA` = %s" + ) + # The exclusion list is a fact about the engine, not caller data, so it + # is inline and the parameter tuple stays at one entry. + assert "'information_schema', 'performance_schema', 'mysql', 'sys'" in sql + assert params == ("app",) + + def test_include_system_drops_the_exclusion(self, dialect): + sql, params = TableListExpression(dialect, schema="app", include_system=True).to_sql() + assert "NOT" not in sql + assert params == ("app",) + + def test_without_views_is_an_inline_type_filter(self, dialect): + sql, params = TableListExpression(dialect, schema="app", include_views=False).to_sql() + assert "`TABLE_TYPE` = 'BASE TABLE'" in sql + assert params == ("app",) + + def test_explicit_table_type_is_bound_last(self, dialect): + sql, params = TableListExpression(dialect, schema="app", table_type="VIEW").to_sql() + assert sql.endswith("`TABLE_TYPE` = %s") + assert params == ("app", "VIEW") + + +class TestColumnAndIndexInfo: + def test_columns_projection_order_and_ordering(self, dialect): + sql, params = ColumnInfoExpression(dialect, "users", schema="app").to_sql() + assert sql == ( + "SELECT `COLUMN_NAME`, `ORDINAL_POSITION`, `COLUMN_DEFAULT`, `IS_NULLABLE`, " + "`DATA_TYPE`, `CHARACTER_MAXIMUM_LENGTH`, `NUMERIC_PRECISION`, `NUMERIC_SCALE`, " + "`COLUMN_TYPE`, `COLUMN_KEY`, `EXTRA`, `COLUMN_COMMENT`, " + "`CHARACTER_SET_NAME`, `COLLATION_NAME` " + "FROM `information_schema`.`COLUMNS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s " + "ORDER BY `ORDINAL_POSITION`" + ) + assert params == ("app", "users") + + def test_indexes_projection_and_ordering(self, dialect): + sql, params = IndexInfoExpression(dialect, "users", schema="app").to_sql() + assert sql == ( + "SELECT `INDEX_NAME`, `NON_UNIQUE`, `SEQ_IN_INDEX`, `COLUMN_NAME`, " + "`INDEX_TYPE`, `SUB_PART`, `NULLABLE` " + "FROM `information_schema`.`STATISTICS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s " + "ORDER BY `INDEX_NAME`, `SEQ_IN_INDEX`" + ) + assert params == ("app", "users") + + +class TestForeignKey: + """Two relations joined, filtered to rows that actually reference a table.""" + + def test_join_projection_and_predicates(self, dialect): + sql, params = ForeignKeyExpression(dialect, "orders", schema="app").to_sql() + assert sql == ( + "SELECT `kcu`.`CONSTRAINT_NAME`, `kcu`.`COLUMN_NAME`, `kcu`.`ORDINAL_POSITION`, " + "`kcu`.`REFERENCED_TABLE_NAME`, `kcu`.`REFERENCED_COLUMN_NAME`, " + "`rc`.`UPDATE_RULE`, `rc`.`DELETE_RULE` " + "FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` " + "JOIN `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc` " + "ON `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` " + "AND `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` " + "WHERE `kcu`.`TABLE_SCHEMA` = %s AND `kcu`.`TABLE_NAME` = %s " + "AND `kcu`.`REFERENCED_TABLE_NAME` IS NOT NULL " + "ORDER BY `kcu`.`CONSTRAINT_NAME`, `kcu`.`ORDINAL_POSITION`" + ) + assert params == ("app", "orders") + + +class TestViews: + def test_view_list_has_the_system_exclusion_and_ordering(self, dialect): + sql, params = ViewListExpression(dialect, schema="app").to_sql() + assert sql.startswith( + "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " + "FROM `information_schema`.`VIEWS` WHERE `TABLE_SCHEMA` = %s" + ) + assert "'information_schema', 'performance_schema', 'mysql', 'sys'" in sql + assert sql.endswith("ORDER BY `TABLE_NAME`") + assert params == ("app",) + + def test_view_list_with_system_objects(self, dialect): + sql, params = ViewListExpression(dialect, schema="app", include_system=True).to_sql() + assert "NOT" not in sql + assert params == ("app",) + + def test_view_info_names_one_view(self, dialect): + sql, params = ViewInfoExpression(dialect, "v_users", schema="app").to_sql() + assert sql == ( + "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " + "FROM `information_schema`.`VIEWS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s" + ) + assert params == ("app", "v_users") + + +class TestTriggers: + def test_list_all(self, dialect): + sql, params = TriggerListExpression(dialect, schema="app").to_sql() + assert sql == ( + "SELECT `TRIGGER_NAME`, `EVENT_MANIPULATION`, `EVENT_OBJECT_TABLE`, " + "`ACTION_TIMING`, `ACTION_STATEMENT`, `CREATED` " + "FROM `information_schema`.`TRIGGERS` " + "WHERE `TRIGGER_SCHEMA` = %s ORDER BY `TRIGGER_NAME`" + ) + assert params == ("app",) + + def test_list_for_one_table_binds_the_name_last(self, dialect): + sql, params = TriggerListExpression(dialect, schema="app", table_name="users").to_sql() + assert sql.endswith("`EVENT_OBJECT_TABLE` = %s ORDER BY `TRIGGER_NAME`") + assert params == ("app", "users") + + +class TestNoStatementTextSurvives: + """The formatters build nodes; they do not assemble text. + + The concatenated forms this replaced were ``sql = "SELECT ..."`` plus + ``+=``; if one comes back, this fails rather than waiting for a reviewer to + notice a quoted keyword in a formatter. + """ + + def test_formatters_hold_no_sql_text(self): + import inspect + + from rhosocial.activerecord.backend.impl.mysql.mixins import introspection + + source = inspect.getsource(introspection.MySQLIntrospectionMixin) + for keyword in ('"SELECT ', '"FROM ', '"WHERE ', '"ORDER BY ', "sql += "): + assert keyword not in source, f"{keyword!r} is statement text, not a node" From f5971cb6c4643142b5b8e139601b1059f5f8a1ca Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 03:15:48 +0800 Subject: [PATCH 38/65] refactor(mysql): render SHOW through its own expressions The SHOW family already had nodes and formatters, but three things broke the rule that an expression is complete when it is constructed: 1. **Fluent setters.** `with_like`, `with_full`, `with_session`, `with_global_vars`, `with_global_status`, `with_limit`, `with_user`, `with_table_name` and `with_schema` filled in fields after the fact. A parameter that can be changed after construction is a second, invisible constructor -- and it is what `get_params()` cannot see. All nine are gone; every parameter is a constructor parameter (`ShowStatusExpression(d, like_pattern="Ssl_version", session=False)`). 2. **Callers still spelling the command.** `introspection/status_introspector.py` ran 23 SHOW commands as text (`execute("SHOW STATUS LIKE 'Ssl_version'")`), and `mixins/concurrency.py` did the same through a raw cursor for `max_connections`. Both now go through a node: `self._backend.execute(*self._show_variables(like="log_bin"))`. The two files also had their `SELECT` call sites migrated, since they interleave. 3. **Three commands had no node.** `SHOW BINARY LOGS`, `SHOW MASTER STATUS` and `SHOW SLAVE STATUS` were executed as text because there was nothing to render them with. Each is now its own node with its own formatter -- a separate command is a separate class, not a flag on a neighbour. These are implemented in this repository and share no code with the MariaDB backend. `show/functionality.py` and `introspection/show_introspector.py` (both halves, sync and async) pass every parameter at construction; 50 `.with_*` call sites became constructor arguments in each. One real defect surfaced while exercising the methods against a live server: `show_introspector.py` parsed `Index_type` with `row.get("Index_type", row.get("INDEX_TYPE"), "BTREE")` -- three positional arguments to `dict.get`, which raises `TypeError` on the first lookup, so `show.indexes()` had never returned rows. Fixed to the `or` chain its sibling module already used. Verification: every SHOW node renders as before (`SHOW CREATE TABLE `app`.`t``, `SHOW FULL COLUMNS FROM `t` LIKE %s`, `SHOW GLOBAL VARIABLES LIKE %s`, `SHOW WARNINGS LIMIT 10`, ...) with the same parameter tuples. Live on 8.0.46, all 34 `backend.introspector.show` methods return rows, and the status introspector passes on 8.0.46, 8.4.11, 9.2, 9.4 and 26.7.0 in both sync and async form. `SHOW MASTER STATUS` / `SHOW SLAVE STATUS` are refused with errno 1064 from 8.4 onwards, as the MySQL server intends -- the version-gated `performance_schema` branch is the one those servers take. New `test_mysql_show_statements.py` asserts that no fluent setter survives, that every node rebuilds from its own `get_params()` into the same SQL, and that the inline-versus-bound split matches the grammar (`LIMIT n` inline, `LIKE %s` bound). Render-only, no connection. `rg '\.with_[a-z_]+\(' src` is now zero. --- .../backend/impl/mysql/expression/show.py | 201 +++------ .../mysql/introspection/show_introspector.py | 174 ++------ .../introspection/status_introspector.py | 403 +++++++++++++----- .../backend/impl/mysql/mixins/concurrency.py | 16 +- .../backend/impl/mysql/show/__init__.py | 6 + .../backend/impl/mysql/show/dialect.py | 26 ++ .../backend/impl/mysql/show/functionality.py | 188 ++------ .../expression/test_mysql_show_statements.py | 285 +++++++++++++ 8 files changed, 759 insertions(+), 540 deletions(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 887479bb..92283684 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -10,15 +10,15 @@ following the expression-dialect pattern used throughout the codebase. Key design: -- Every constructor parameter is stored under its **own name**: the - attribute ``get_params()`` reads and the one an artifact carries. The - fluent setters that used to share these names (``schema()``, ``full()``, - ``like()``, ...) are ``with_*`` now, mirroring core's own introspection - expressions -- the parameter owns the plain name, the setter keeps its - ergonomics, and neither shadows the other. The underscore-prefixed - storage this module used is what core's introspection-based - ``get_params()`` refuses to paper over: a parameter the class did not - store under its own name is a defect, not a spelling. +- **Every parameter is a constructor parameter.** There is no fluent builder: + an instance is complete the moment ``get_params()`` reads it back, so the + artifact is the constructor call and nothing else. The ``with_like`` / + ``with_session`` / ``with_global_status`` setters that used to fill these + same fields in afterwards are gone -- a parameter that can be changed after + construction is a second, invisible constructor. +- Each parameter is stored under its **own name**, which is what + ``get_params()`` reads. A parameter the class did not store under its own + name is a defect, not a spelling. - A subclass that supports a schema qualifier declares ``schema`` in its own ``__init__``: ``get_params()`` reads the class's own signature, so a parameter declared only on the base would be invisible to the formatter @@ -40,27 +40,16 @@ class ShowExpression(BaseExpression): """Base class for MySQL SHOW command expressions. - All MySQL SHOW expressions inherit from this class and provide - fluent ``with_*`` methods for setting parameters; each parameter is - stored under the name it was declared with. + All MySQL SHOW expressions inherit from this class. Parameters are + declared by each ``__init__`` -- that signature is the wire contract, so a + parameter is always passed where the node is built rather than filled in + afterwards. """ def __init__(self, dialect: "MySQLDialect", *, schema: Optional[str] = None): super().__init__(dialect) self.schema: Optional[str] = schema - def with_schema(self, name: str) -> "ShowExpression": - """Set the schema/database name. - - Args: - name: Schema or database name. - - Returns: - Self for method chaining. - """ - self.schema = name - return self - # Concrete subclasses declare their dialect formatting method by # overriding the `format_method` property (inherited from BaseExpression). @@ -84,7 +73,6 @@ def format_method(self) -> str: return "format_show_create_table_expression" - class ShowCreateViewExpression(ShowExpression): """Expression for SHOW CREATE VIEW command.""" @@ -104,7 +92,6 @@ def format_method(self) -> str: return "format_show_create_view_expression" - class ShowColumnsExpression(ShowExpression): """Expression for SHOW [FULL] COLUMNS command.""" @@ -122,23 +109,12 @@ def __init__( self.full: bool = full self.like_pattern: Optional[str] = like_pattern - def with_full(self, value: bool = True) -> "ShowColumnsExpression": - """Include additional column information (privileges, comments).""" - self.full = value - return self - - def with_like(self, pattern: str) -> "ShowColumnsExpression": - """Filter columns by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_columns_expression" - class ShowIndexExpression(ShowExpression): """Expression for SHOW INDEX command.""" @@ -158,7 +134,6 @@ def format_method(self) -> str: return "format_show_index_expression" - class ShowTablesExpression(ShowExpression): """Expression for SHOW [FULL] TABLES command.""" @@ -174,23 +149,12 @@ def __init__( self.full: bool = full self.like_pattern: Optional[str] = like_pattern - def with_full(self, value: bool = True) -> "ShowTablesExpression": - """Include table type (BASE TABLE or VIEW).""" - self.full = value - return self - - def with_like(self, pattern: str) -> "ShowTablesExpression": - """Filter tables by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_tables_expression" - class ShowDatabasesExpression(ShowExpression): """Expression for SHOW DATABASES command.""" @@ -203,18 +167,12 @@ def __init__( super().__init__(dialect) self.like_pattern: Optional[str] = like_pattern - def with_like(self, pattern: str) -> "ShowDatabasesExpression": - """Filter databases by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_databases_expression" - class ShowTableStatusExpression(ShowExpression): """Expression for SHOW TABLE STATUS command.""" @@ -228,18 +186,12 @@ def __init__( super().__init__(dialect, schema=schema) self.like_pattern: Optional[str] = like_pattern - def with_like(self, pattern: str) -> "ShowTableStatusExpression": - """Filter tables by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_table_status_expression" - class ShowTriggersExpression(ShowExpression): """Expression for SHOW TRIGGERS command.""" @@ -253,18 +205,12 @@ def __init__( super().__init__(dialect, schema=schema) self.table_name: Optional[str] = table_name - def with_table_name(self, table_name: str) -> "ShowTriggersExpression": - """Filter triggers for a specific table.""" - self.table_name = table_name - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_triggers_expression" - class ShowCreateTriggerExpression(ShowExpression): """Expression for SHOW CREATE TRIGGER command.""" @@ -284,9 +230,8 @@ def format_method(self) -> str: return "format_show_create_trigger_expression" - class ShowVariablesExpression(ShowExpression): - """Expression for SHOW VARIABLES command.""" + """Expression for SHOW [GLOBAL] VARIABLES command.""" def __init__( self, @@ -299,30 +244,14 @@ def __init__( self.like_pattern: Optional[str] = like_pattern self.session: bool = session - def with_like(self, pattern: str) -> "ShowVariablesExpression": - """Filter variables by name pattern.""" - self.like_pattern = pattern - return self - - def with_session(self, value: bool = True) -> "ShowVariablesExpression": - """Show session variables (default).""" - self.session = value - return self - - def with_global_vars(self) -> "ShowVariablesExpression": - """Show global variables.""" - self.session = False - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_variables_expression" - class ShowStatusExpression(ShowExpression): - """Expression for SHOW STATUS command.""" + """Expression for SHOW [GLOBAL] STATUS command.""" def __init__( self, @@ -335,30 +264,14 @@ def __init__( self.like_pattern: Optional[str] = like_pattern self.session: bool = session - def with_like(self, pattern: str) -> "ShowStatusExpression": - """Filter status by name pattern.""" - self.like_pattern = pattern - return self - - def with_session(self, value: bool = True) -> "ShowStatusExpression": - """Show session status (default).""" - self.session = value - return self - - def with_global_status(self) -> "ShowStatusExpression": - """Show global status.""" - self.session = False - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_status_expression" - class ShowProcessListExpression(ShowExpression): - """Expression for SHOW PROCESSLIST command.""" + """Expression for SHOW [FULL] PROCESSLIST command.""" def __init__( self, @@ -369,18 +282,12 @@ def __init__( super().__init__(dialect) self.full: bool = full - def with_full(self, value: bool = True) -> "ShowProcessListExpression": - """Include full query text.""" - self.full = value - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_process_list_expression" - class ShowWarningsExpression(ShowExpression): """Expression for SHOW WARNINGS command.""" @@ -393,18 +300,12 @@ def __init__( super().__init__(dialect) self.limit: Optional[int] = limit - def with_limit(self, count: int) -> "ShowWarningsExpression": - """Limit number of warnings returned.""" - self.limit = count - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_warnings_expression" - class ShowErrorsExpression(ShowExpression): """Expression for SHOW ERRORS command.""" @@ -417,18 +318,12 @@ def __init__( super().__init__(dialect) self.limit: Optional[int] = limit - def with_limit(self, count: int) -> "ShowErrorsExpression": - """Limit number of errors returned.""" - self.limit = count - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_errors_expression" - class ShowEnginesExpression(ShowExpression): """Expression for SHOW ENGINES command.""" @@ -438,7 +333,6 @@ def format_method(self) -> str: return "format_show_engines_expression" - class ShowCharsetExpression(ShowExpression): """Expression for SHOW CHARACTER SET command.""" @@ -451,18 +345,12 @@ def __init__( super().__init__(dialect) self.like_pattern: Optional[str] = like_pattern - def with_like(self, pattern: str) -> "ShowCharsetExpression": - """Filter character sets by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_charset_expression" - class ShowCollationExpression(ShowExpression): """Expression for SHOW COLLATION command.""" @@ -475,18 +363,12 @@ def __init__( super().__init__(dialect) self.like_pattern: Optional[str] = like_pattern - def with_like(self, pattern: str) -> "ShowCollationExpression": - """Filter collations by name pattern.""" - self.like_pattern = pattern - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_collation_expression" - class ShowGrantsExpression(ShowExpression): """Expression for SHOW GRANTS command.""" @@ -501,19 +383,12 @@ def __init__( self.user: Optional[str] = user self.host: Optional[str] = host - def with_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpression": - """Show grants for a specific user.""" - self.user = user - self.host = host - return self - @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_grants_expression" - class ShowPluginsExpression(ShowExpression): """Expression for SHOW PLUGINS command.""" @@ -521,3 +396,47 @@ class ShowPluginsExpression(ShowExpression): def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_show_plugins_expression" + + +class ShowBinaryLogsExpression(ShowExpression): + """Expression for SHOW BINARY LOGS command. + + Its own node rather than a flag on another one: the command takes no + parameters at all, so what distinguishes it from its neighbours is the + command itself, which is exactly what a class names. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_binary_logs_expression" + + +class ShowMasterStatusExpression(ShowExpression): + """Expression for SHOW MASTER STATUS command. + + MySQL 8.4 removed this command in favour of + ``performance_schema.log_status``; the node still renders the command, + and the caller chooses by server version which of the two to execute. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_master_status_expression" + + +class ShowSlaveStatusExpression(ShowExpression): + """Expression for SHOW SLAVE STATUS command. + + MySQL 8.0.22 added ``SHOW REPLICA STATUS`` as the preferred spelling and + 9.0 removed ``SHOW SLAVE STATUS``; this node renders the older command, + because it is the one every server in this backend's supported range + accepts. A server that has dropped it is asked through + ``performance_schema`` instead. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_slave_status_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index 367dc697..34089b0c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -121,7 +121,7 @@ def _parse_indexes(rows: List[Dict]): sub_part=row.get("Sub_part", row.get("SUB_PART")), packed=row.get("Packed", row.get("PACKED")), null=row.get("Null", row.get("NULLABLE")), - index_type=row.get("Index_type", row.get("INDEX_TYPE"), "BTREE"), + index_type=row.get("Index_type") or row.get("INDEX_TYPE") or "BTREE", comment=row.get("Comment", row.get("INDEX_COMMENT")), index_comment=row.get("Index_comment", row.get("INDEX_COMMENT")), visible=row.get("Visible", row.get("IS_VISIBLE")), @@ -345,17 +345,13 @@ def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_table(self._exec(sql, params), table_name) def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_view(self._exec(sql, params), view_name) @@ -367,21 +363,15 @@ def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) - if full: - expr.with_full() - if like: - expr.with_like(like) + expr = ShowColumnsExpression( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() return self._parse_columns(self._exec(sql, params)) def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowIndexExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_indexes(self._exec(sql, params)) @@ -392,93 +382,61 @@ def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) - if full: - expr.with_full() + expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() return self._parse_tables(self._exec(sql, params)) def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowDatabasesExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_databases(self._exec(sql, params)) def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) + expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() return self._parse_table_status(self._exec(sql, params)) def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.with_schema(schema) - if table_name: - expr.with_table_name(table_name) + expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() return self._parse_triggers(self._exec(sql, params)) def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_trigger(self._exec(sql, params), trigger_name) def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_vars() + expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_variables(self._exec(sql, params)) def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_status() + expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_status(self._exec(sql, params)) def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.with_full() + expr = ShowProcessListExpression(self.dialect, full=full) sql, params = expr.to_sql() return self._parse_processlist(self._exec(sql, params)) def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowWarningsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowErrorsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) @@ -490,25 +448,19 @@ def engines(self): def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCharsetExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_charset(self._exec(sql, params)) def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCollationExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_collation(self._exec(sql, params)) def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.with_user(user, host) + expr = ShowGrantsExpression(self.dialect, user=user, host=host) sql, params = expr.to_sql() return self._parse_grants(self._exec(sql, params)) @@ -546,17 +498,13 @@ async def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: async def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_table(await self._exec(sql, params), table_name) async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_view(await self._exec(sql, params), view_name) @@ -568,21 +516,15 @@ async def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) - if full: - expr.with_full() - if like: - expr.with_like(like) + expr = ShowColumnsExpression( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() return self._parse_columns(await self._exec(sql, params)) async def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowIndexExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_indexes(await self._exec(sql, params)) @@ -593,93 +535,61 @@ async def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) - if full: - expr.with_full() + expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() return self._parse_tables(await self._exec(sql, params)) async def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowDatabasesExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_databases(await self._exec(sql, params)) async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) + expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() return self._parse_table_status(await self._exec(sql, params)) async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.with_schema(schema) - if table_name: - expr.with_table_name(table_name) + expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() return self._parse_triggers(await self._exec(sql, params)) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_trigger(await self._exec(sql, params), trigger_name) async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_vars() + expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_variables(await self._exec(sql, params)) async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_status() + expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_status(await self._exec(sql, params)) async def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.with_full() + expr = ShowProcessListExpression(self.dialect, full=full) sql, params = expr.to_sql() return self._parse_processlist(await self._exec(sql, params)) async def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowWarningsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) async def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowErrorsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) @@ -691,25 +601,19 @@ async def engines(self): async def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCharsetExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_charset(await self._exec(sql, params)) async def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCollationExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_collation(await self._exec(sql, params)) async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.with_user(user, host) + expr = ShowGrantsExpression(self.dialect, user=user, host=host) sql, params = expr.to_sql() return self._parse_grants(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py index e28ff00f..4162279f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py @@ -10,7 +10,7 @@ - AsyncMySQLStatusIntrospector: for asynchronous backends """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.introspection.status import ( StatusItem, @@ -142,6 +142,241 @@ def _get_vendor_name(self) -> str: """Get MySQL vendor name.""" return "MySQL" + # ------------------------------------------------------------------ # + # Statements as expression trees + # ------------------------------------------------------------------ # + # + # Every command this module runs is built here from a node, so the SQL is + # the dialect's to write and this module never spells a keyword. The SHOW + # family is this backend's own (``expression/show.py``); the SELECT family + # uses core's query and function nodes. + # + # It matters here rather than only in the formatters: a status introspector + # is a *caller*, and a caller that writes the SHOW command as an f-string + # has bypassed the expression layer entirely -- the command then exists only + # as text, uninspectable and one interpolation away from spelling something + # else. + + @property + def _dialect(self) -> Any: + """The dialect the command nodes are rendered with.""" + return self._backend.dialect + + def _show_variables(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: + """``SHOW [GLOBAL] VARIABLES [LIKE ...]`` as a node.""" + from ..expression.show import ShowVariablesExpression + + return ShowVariablesExpression(self._dialect, like_pattern=like, session=session).to_sql() + + def _show_status(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: + """``SHOW [GLOBAL] STATUS [LIKE ...]`` as a node.""" + from ..expression.show import ShowStatusExpression + + return ShowStatusExpression(self._dialect, like_pattern=like, session=session).to_sql() + + def _show_processlist(self, full: bool = False) -> Tuple[str, tuple]: + """``SHOW [FULL] PROCESSLIST`` as a node.""" + from ..expression.show import ShowProcessListExpression + + return ShowProcessListExpression(self._dialect, full=full).to_sql() + + def _show_binary_logs(self) -> Tuple[str, tuple]: + """``SHOW BINARY LOGS`` as a node.""" + from ..expression.show import ShowBinaryLogsExpression + + return ShowBinaryLogsExpression(self._dialect).to_sql() + + def _show_master_status(self) -> Tuple[str, tuple]: + """``SHOW MASTER STATUS`` as a node. + + Servers from 8.4 answer through ``performance_schema.log_status`` + instead; the caller picks by version, and this is the older branch. + """ + from ..expression.show import ShowMasterStatusExpression + + return ShowMasterStatusExpression(self._dialect).to_sql() + + def _show_slave_status(self) -> Tuple[str, tuple]: + """``SHOW SLAVE STATUS`` as a node (the branch for servers below 8.4).""" + from ..expression.show import ShowSlaveStatusExpression + + return ShowSlaveStatusExpression(self._dialect).to_sql() + + def _version_query(self) -> Tuple[str, tuple]: + """``SELECT VERSION()`` -- the server's own version string. + + Built by the shared node in ``expression/system.py``, which the backend + uses for the same question. No alias: the column label ``VERSION()`` is + what every consumer in this module reads back. + """ + from ..expression.system import version_query + + return version_query(self._dialect).to_sql() + + def _current_user_query(self) -> Tuple[str, tuple]: + """``SELECT CURRENT_USER`` -- the account this session authenticated as. + + Core's node renders the niladic spelling, without the parentheses the + concatenated form wrote, so the column label is ``CURRENT_USER`` -- + which is what the callers below read back. + """ + from ..expression.system import current_user_query + + return current_user_query(self._dialect).to_sql() + + def _log_status_query(self, with_gtid: bool = False) -> Tuple[str, tuple]: + """``SELECT ... FROM performance_schema.log_status``. + + The JSON paths stay literal-inline, exactly as the concatenated form + left them: the parameter tuple is empty either way. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + QueryStatement, + json_extract, + json_extract_text, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + local = Column(self._dialect, "LOCAL") + projection = [ + json_extract_text(self._dialect, local, "$.binary_log_file").as_("File"), + json_extract(self._dialect, local, "$.binary_log_position").as_("Position"), + ] + if with_gtid: + projection.append(json_extract_text(self._dialect, local, "$.gtid_executed").as_("Gtid")) + return QueryStatement( + self._dialect, + select=projection, + from_=Table(self._dialect, "log_status", catalog_name="performance_schema"), + ).to_sql() + + def _database_size_query(self) -> Tuple[str, tuple]: + """``SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.TABLES``.""" + from rhosocial.activerecord.backend.expression import ( + ArithmeticOperation, + Column, + ComparisonPredicate, + Literal, + QueryStatement, + SumFunction, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + total = SumFunction( + dialect, + ArithmeticOperation( + dialect, "add", Column(dialect, "DATA_LENGTH"), Column(dialect, "INDEX_LENGTH") + ), + ).as_("total_size") + return QueryStatement( + dialect, + select=[total], + from_=Table(dialect, "TABLES", catalog_name="information_schema"), + where=WhereClause( + dialect, + ComparisonPredicate( + dialect, + "eq", + Column(dialect, "TABLE_SCHEMA"), + Literal(dialect, self._backend.config.database), + ), + ), + ).to_sql() + + def _table_type_counts_query(self, db_names: List[str]) -> Tuple[str, tuple]: + """Table and view counts per database, grouped by type.""" + from rhosocial.activerecord.backend.expression import ( + Column, + CountFunction, + GroupByHavingClause, + InPredicate, + Literal, + QueryStatement, + RowCountWildcard, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + return QueryStatement( + dialect, + select=[ + Column(dialect, "TABLE_SCHEMA"), + Column(dialect, "TABLE_TYPE"), + CountFunction(dialect, RowCountWildcard(dialect)).as_("count"), + ], + from_=Table(dialect, "TABLES", catalog_name="information_schema"), + where=WhereClause( + dialect, + InPredicate( + dialect, Column(dialect, "TABLE_SCHEMA"), Literal(dialect, tuple(db_names)) + ), + ), + group_by_having=GroupByHavingClause( + dialect, group_by=[Column(dialect, "TABLE_SCHEMA"), Column(dialect, "TABLE_TYPE")] + ), + ).to_sql() + + def _users_query(self) -> Tuple[str, tuple]: + """``SELECT User, Host, Super_priv FROM mysql.user``.""" + from rhosocial.activerecord.backend.expression import Column, QueryStatement + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + return QueryStatement( + dialect, + select=[ + Column(dialect, "User"), + Column(dialect, "Host"), + Column(dialect, "Super_priv"), + ], + from_=Table(dialect, "user", catalog_name="mysql"), + ).to_sql() + + def _replication_connection_status_query(self, limit: Optional[int] = None) -> Tuple[str, tuple]: + """Channels whose replication connection is running (8.4+ servers).""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + LimitOffsetClause, + Literal, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + if limit is not None: + projection = [Column(dialect, "SERVICE_STATE")] + else: + projection = [ + Column(dialect, "CHANNEL_NAME"), + Column(dialect, "SOURCE_UUID"), + Column(dialect, "SERVICE_STATE"), + ] + kwargs: Dict[str, Any] = { + "select": projection, + "from_": Table( + dialect, "replication_connection_status", catalog_name="performance_schema" + ), + "where": WhereClause( + dialect, + ComparisonPredicate( + dialect, + "eq", + Column(dialect, "SERVICE_STATE"), + Literal(dialect, "ON", inline=True), + ), + ), + } + if limit is not None: + kwargs["limit_offset"] = LimitOffsetClause(dialect, limit=limit) + return QueryStatement(dialect, **kwargs).to_sql() + + def _parse_variable_value(self, value: Any) -> Any: """Parse variable value to appropriate Python type.""" if value is None: @@ -390,12 +625,7 @@ def get_storage_info(self) -> StorageInfo: # Get database size from information_schema if available total_size = None try: - result = self._backend.execute( - "SELECT SUM(data_length + index_length) as total_size " - "FROM information_schema.TABLES " - f"WHERE table_schema = {self._backend.dialect.p()}", - (self._backend.config.database,), - ) + result = self._backend.execute(*self._database_size_query()) if result and result.data: total_size = result.data[0].get("total_size") except Exception: @@ -421,15 +651,7 @@ def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - p = self._backend.dialect.p() - placeholders = ", ".join([p] * len(db_names)) - result = self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), - ) + result = self._backend.execute(*self._table_type_counts_query(db_names)) if result and result.data: for row in result.data: # MySQL returns column names in uppercase @@ -458,7 +680,7 @@ def list_users(self) -> List[UserInfo]: users = [] try: - result = self._backend.execute("SELECT User, Host, Super_priv FROM mysql.user", ()) + result = self._backend.execute(*self._users_query()) if result and result.data: for row in result.data: user = UserInfo( @@ -479,9 +701,9 @@ def get_session_info(self) -> SessionInfo: # Get current user try: - result = self._backend.execute("SELECT CURRENT_USER()", ()) + result = self._backend.execute(*self._current_user_query()) if result and result.data: - current_user = result.data[0].get("CURRENT_USER()") + current_user = result.data[0].get("CURRENT_USER") if current_user: # Parse user@host format parts = current_user.split("@") @@ -496,7 +718,7 @@ def get_session_info(self) -> SessionInfo: # Get SSL status try: - result = self._backend.execute("SHOW STATUS LIKE 'Ssl_version'", ()) + result = self._backend.execute(*self._show_status(like="Ssl_version")) if result and result.data: ssl_version = result.data[0].get("Value") if ssl_version: @@ -507,7 +729,7 @@ def get_session_info(self) -> SessionInfo: # Get SSL cipher try: - result = self._backend.execute("SHOW STATUS LIKE 'Ssl_cipher'", ()) + result = self._backend.execute(*self._show_status(like="Ssl_cipher")) if result and result.data: ssl_cipher = result.data[0].get("Value") if ssl_cipher: @@ -525,7 +747,7 @@ def get_innodb_info(self) -> InnoDBInfo: innodb = InnoDBInfo() # Get buffer pool info try: - result = self._backend.execute("SHOW STATUS LIKE 'Innodb_buffer_pool%'", ()) + result = self._backend.execute(*self._show_status(like="Innodb_buffer_pool%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -548,7 +770,7 @@ def get_innodb_info(self) -> InnoDBInfo: pass # Get InnoDB variables try: - result = self._backend.execute("SHOW VARIABLES LIKE 'innodb%'", ()) + result = self._backend.execute(*self._show_variables(like="innodb%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -567,7 +789,7 @@ def get_innodb_info(self) -> InnoDBInfo: pass # Get InnoDB row lock status try: - result = self._backend.execute("SHOW STATUS LIKE 'Innodb_row_lock%'", ()) + result = self._backend.execute(*self._show_status(like="Innodb_row_lock%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -609,7 +831,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: binary_log = BinaryLogInfo() # Check if binary logging is enabled try: - result = self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -623,7 +845,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: # Get binary log files (only if binary logging is enabled) if binary_log.log_enabled: try: - result = self._backend.execute("SHOW BINARY LOGS", ()) + result = self._backend.execute(*self._show_binary_logs()) if result and result.data: log_files = [] total_size = 0 @@ -645,20 +867,14 @@ def get_binary_log_info(self) -> BinaryLogInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") @@ -666,7 +882,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS - result = self._backend.execute("SHOW MASTER STATUS", ()) + result = self._backend.execute(*self._show_master_status()) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") @@ -675,7 +891,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: pass # Get GTID info try: - result = self._backend.execute("SHOW VARIABLES LIKE 'gtid_mode'", ()) + result = self._backend.execute(*self._show_variables(like="gtid_mode")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -684,7 +900,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: except Exception: pass try: - result = self._backend.execute("SHOW GLOBAL VARIABLES LIKE 'gtid_executed'", ()) + result = self._backend.execute(*self._show_variables(like="gtid_executed", session=False)) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -704,7 +920,7 @@ def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries.""" processes = [] try: - result = self._backend.execute("SHOW FULL PROCESSLIST", ()) + result = self._backend.execute(*self._show_processlist(full=True)) if result and result.data: for row in result.data: proc = ProcessInfo( @@ -727,7 +943,7 @@ def get_slow_query_info(self) -> SlowQueryInfo: slow_query = SlowQueryInfo() # Get slow query log status try: - result = self._backend.execute("SHOW VARIABLES LIKE 'slow_query_log%'", ()) + result = self._backend.execute(*self._show_variables(like="slow_query_log%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -748,7 +964,7 @@ def get_slow_query_info(self) -> SlowQueryInfo: pass # Get slow query count try: - result = self._backend.execute("SHOW STATUS LIKE 'Slow_queries'", ()) + result = self._backend.execute(*self._show_status(like="Slow_queries")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -764,7 +980,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl_info = MySQLReplicationInfo() # Get server_id try: - result = self._backend.execute("SHOW VARIABLES LIKE 'server_id'", ()) + result = self._backend.execute(*self._show_variables(like="server_id")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -776,7 +992,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: # First check if binary logging is enabled log_bin_enabled = False try: - result = self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: if row.get("Variable_name") == "log_bin": @@ -790,20 +1006,14 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema.log_status - result = self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: repl_info.is_master = True master_info = ReplicationMasterInfo() @@ -813,7 +1023,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl_info.master_info = master_info else: # MySQL < 9.0: use SHOW MASTER STATUS - result = self._backend.execute("SHOW MASTER STATUS", ()) + result = self._backend.execute(*self._show_master_status()) if result and result.data: repl_info.is_master = True master_info = ReplicationMasterInfo() @@ -836,7 +1046,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: try: # Get server version first (reuse if already fetched above) if "version_str" not in dir() or not version_str: - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) @@ -844,12 +1054,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema replication tables # Check replication connection status - result = self._backend.execute( - "SELECT CHANNEL_NAME, SOURCE_UUID, SERVICE_STATE " - "FROM performance_schema.replication_connection_status " - "WHERE SERVICE_STATE = 'ON'", - (), - ) + result = self._backend.execute(*self._replication_connection_status_query()) if result and result.data: repl_info.is_slave = True slave_info = ReplicationSlaveInfo() @@ -859,7 +1064,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl_info.slave_info = slave_info else: # MySQL < 9.0: use SHOW SLAVE STATUS - result = self._backend.execute("SHOW SLAVE STATUS", ()) + result = self._backend.execute(*self._show_slave_status()) if result and result.data: repl_info.is_slave = True slave_info = ReplicationSlaveInfo() @@ -1028,12 +1233,7 @@ async def get_storage_info(self) -> StorageInfo: total_size = None try: - result = await self._backend.execute( - "SELECT SUM(data_length + index_length) as total_size " - "FROM information_schema.TABLES " - f"WHERE table_schema = {self._backend.dialect.p()}", - (self._backend.config.database,), - ) + result = await self._backend.execute(*self._database_size_query()) if result and result.data: total_size = result.data[0].get("total_size") except Exception: @@ -1059,15 +1259,7 @@ async def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - p = self._backend.dialect.p() - placeholders = ", ".join([p] * len(db_names)) - result = await self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), - ) + result = await self._backend.execute(*self._table_type_counts_query(db_names)) if result and result.data: for row in result.data: # MySQL returns column names in uppercase @@ -1096,7 +1288,7 @@ async def list_users(self) -> List[UserInfo]: users = [] try: - result = await self._backend.execute("SELECT User, Host, Super_priv FROM mysql.user", ()) + result = await self._backend.execute(*self._users_query()) if result and result.data: for row in result.data: user = UserInfo( @@ -1116,9 +1308,9 @@ async def get_session_info(self) -> SessionInfo: # Get current user try: - result = await self._backend.execute("SELECT CURRENT_USER()", ()) + result = await self._backend.execute(*self._current_user_query()) if result and result.data: - current_user = result.data[0].get("CURRENT_USER()") + current_user = result.data[0].get("CURRENT_USER") if current_user: parts = current_user.split("@") session.user = parts[0] if parts else current_user @@ -1132,7 +1324,7 @@ async def get_session_info(self) -> SessionInfo: # Get SSL status try: - result = await self._backend.execute("SHOW STATUS LIKE 'Ssl_version'", ()) + result = await self._backend.execute(*self._show_status(like="Ssl_version")) if result and result.data: ssl_version = result.data[0].get("Value") if ssl_version: @@ -1143,7 +1335,7 @@ async def get_session_info(self) -> SessionInfo: # Get SSL cipher try: - result = await self._backend.execute("SHOW STATUS LIKE 'Ssl_cipher'", ()) + result = await self._backend.execute(*self._show_status(like="Ssl_cipher")) if result and result.data: ssl_cipher = result.data[0].get("Value") if ssl_cipher: @@ -1161,7 +1353,7 @@ async def get_innodb_info(self) -> InnoDBInfo: innodb = InnoDBInfo() # Get buffer pool info try: - result = await self._backend.execute("SHOW STATUS LIKE 'Innodb_buffer_pool%'", ()) + result = await self._backend.execute(*self._show_status(like="Innodb_buffer_pool%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1184,7 +1376,7 @@ async def get_innodb_info(self) -> InnoDBInfo: pass # Get InnoDB variables try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'innodb%'", ()) + result = await self._backend.execute(*self._show_variables(like="innodb%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1203,7 +1395,7 @@ async def get_innodb_info(self) -> InnoDBInfo: pass # Get InnoDB row lock status try: - result = await self._backend.execute("SHOW STATUS LIKE 'Innodb_row_lock%'", ()) + result = await self._backend.execute(*self._show_status(like="Innodb_row_lock%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1244,7 +1436,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: binary_log = BinaryLogInfo() # Check if binary logging is enabled try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = await self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1258,7 +1450,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: # Get binary log files (only if binary logging is enabled) if binary_log.log_enabled: try: - result = await self._backend.execute("SHOW BINARY LOGS", ()) + result = await self._backend.execute(*self._show_binary_logs()) if result and result.data: log_files = [] total_size = 0 @@ -1279,20 +1471,14 @@ async def get_binary_log_info(self) -> BinaryLogInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = await self._backend.execute("SELECT VERSION()", ()) + version_result = await self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = await self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = await self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") @@ -1300,7 +1486,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS - result = await self._backend.execute("SHOW MASTER STATUS", ()) + result = await self._backend.execute(*self._show_master_status()) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") @@ -1309,7 +1495,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: pass # Get GTID info try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'gtid_mode'", ()) + result = await self._backend.execute(*self._show_variables(like="gtid_mode")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1329,7 +1515,7 @@ async def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries.""" processes = [] try: - result = await self._backend.execute("SHOW FULL PROCESSLIST", ()) + result = await self._backend.execute(*self._show_processlist(full=True)) if result and result.data: for row in result.data: proc = ProcessInfo( @@ -1351,7 +1537,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: slow_query = SlowQueryInfo() # Get slow query log settings try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'slow_query_log%'", ()) + result = await self._backend.execute(*self._show_variables(like="slow_query_log%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1364,7 +1550,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: pass # Get long_query_time try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'long_query_time'", ()) + result = await self._backend.execute(*self._show_variables(like="long_query_time")) if result and result.data: for row in result.data: value = row.get("Value") @@ -1374,7 +1560,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: pass # Get slow query count try: - result = await self._backend.execute("SHOW STATUS LIKE 'Slow_queries'", ()) + result = await self._backend.execute(*self._show_status(like="Slow_queries")) if result and result.data: for row in result.data: value = row.get("Value") @@ -1389,7 +1575,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl = MySQLReplicationInfo() # Get server version first try: - version_result = await self._backend.execute("SELECT VERSION()", ()) + version_result = await self._backend.execute(*self._version_query()) version_str = version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" except Exception: version_str = "" @@ -1397,7 +1583,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: # Check if binary logging is enabled first log_bin_enabled = False try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = await self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: if row.get("Variable_name") == "log_bin": @@ -1411,12 +1597,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: try: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = await self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position " - "FROM performance_schema.log_status", - (), - ) + result = await self._backend.execute(*self._log_status_query()) if result and result.data: repl.is_master = True row = result.data[0] @@ -1424,7 +1605,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl.master_log_position = self._parse_variable_value(row.get("Position")) else: # MySQL < 8.4: use SHOW MASTER STATUS - result = await self._backend.execute("SHOW MASTER STATUS", ()) + result = await self._backend.execute(*self._show_master_status()) if result and result.data: repl.is_master = True row = result.data[0] @@ -1436,17 +1617,13 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: try: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema replication tables - result = await self._backend.execute( - "SELECT SERVICE_STATE FROM performance_schema.replication_connection_status " - "WHERE SERVICE_STATE = 'ON' LIMIT 1", - (), - ) + result = await self._backend.execute(*self._replication_connection_status_query(limit=1)) if result and result.data: repl.is_slave = True repl.slave_io_running = True else: # MySQL < 9.0: use SHOW SLAVE STATUS - result = await self._backend.execute("SHOW SLAVE STATUS", ()) + result = await self._backend.execute(*self._show_slave_status()) if result and result.data: repl.is_slave = True row = result.data[0] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py index 75e9d78f..6eca6608 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py @@ -18,8 +18,14 @@ def connect(self): def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: + from ..expression.show import ShowVariablesExpression + cursor = self._connection.cursor(dictionary=True) - cursor.execute("SHOW VARIABLES LIKE 'max_connections'") + cursor.execute( + *ShowVariablesExpression( + self.dialect, like_pattern="max_connections" + ).to_sql() + ) row = cursor.fetchone() cursor.close() @@ -56,8 +62,14 @@ async def connect(self): async def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: + from ..expression.show import ShowVariablesExpression + cursor = await self._connection.cursor(dictionary=True) - await cursor.execute("SHOW VARIABLES LIKE 'max_connections'") + await cursor.execute( + *ShowVariablesExpression( + self.dialect, like_pattern="max_connections" + ).to_sql() + ) row = await cursor.fetchone() await cursor.close() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py index 7ce40a50..65e77750 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py @@ -38,6 +38,9 @@ ShowCollationExpression, ShowGrantsExpression, ShowPluginsExpression, + ShowBinaryLogsExpression, + ShowMasterStatusExpression, + ShowSlaveStatusExpression, ) from .dialect import MySQLShowDialectMixin from .functionality import MySQLShowFunctionality, AsyncMySQLShowFunctionality @@ -65,6 +68,9 @@ "ShowCollationExpression", "ShowGrantsExpression", "ShowPluginsExpression", + "ShowBinaryLogsExpression", + "ShowMasterStatusExpression", + "ShowSlaveStatusExpression", # Dialect mixin "MySQLShowDialectMixin", # Functionality classes diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py index c7c2ec9c..0d1e48e3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py @@ -43,6 +43,9 @@ ShowCollationExpression, ShowGrantsExpression, ShowPluginsExpression, + ShowBinaryLogsExpression, + ShowMasterStatusExpression, + ShowSlaveStatusExpression, ) @@ -310,3 +313,26 @@ def format_show_grants_expression(self, expr: "ShowGrantsExpression") -> Tuple[s def format_show_plugins_expression(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: """Format SHOW PLUGINS statement.""" return "SHOW PLUGINS", () + + # ========== SHOW BINARY LOGS / REPLICATION STATUS ========== + # + # Each of these is a whole command with no parameters, so each has its own + # node and its own entry: a boolean on a neighbouring node would say "this + # is a different command" with something other than the class, which is what + # the class is for. + + def format_show_binary_logs_expression(self, expr: "ShowBinaryLogsExpression") -> Tuple[str, tuple]: + """Format SHOW BINARY LOGS statement.""" + return "SHOW BINARY LOGS", () + + def format_show_master_status_expression( + self, expr: "ShowMasterStatusExpression" + ) -> Tuple[str, tuple]: + """Format SHOW MASTER STATUS statement.""" + return "SHOW MASTER STATUS", () + + def format_show_slave_status_expression( + self, expr: "ShowSlaveStatusExpression" + ) -> Tuple[str, tuple]: + """Format SHOW SLAVE STATUS statement.""" + return "SHOW SLAVE STATUS", () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py index 5cecd363..bd47a69b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py @@ -7,7 +7,7 @@ for all SQL execution. The implementation: -- Creates expression objects with the dialect +- Creates expression objects with every parameter at construction - Calls expression.to_sql() to get SQL - Executes SQL via backend.execute() - Parses results into typed dataclasses @@ -382,9 +382,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): ShowCreateTableResult with table name and CREATE statement, or None if table doesn't exist. """ - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_table_result(result, table_name) @@ -401,9 +399,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): Returns: ShowCreateViewResult with view details, or None if view doesn't exist. """ - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_view_result(result, view_name) @@ -428,14 +424,9 @@ def columns( Returns: List of ShowColumnResult objects. """ - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) - if full: - expr.with_full() - if like: - expr.with_like(like) - + expr = ShowColumnsExpression( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_columns_result(result) @@ -452,9 +443,7 @@ def indexes(self, table_name: str, schema: Optional[str] = None): Returns: List of ShowIndexResult objects. """ - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowIndexExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_indexes_result(result) @@ -477,14 +466,7 @@ def tables( Returns: List of ShowTableResult objects. """ - expr = ShowTablesExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) - if full: - expr.with_full() - + expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_tables_result(result) @@ -500,10 +482,7 @@ def databases(self, like: Optional[str] = None): Returns: List of ShowDatabaseResult objects. """ - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.with_like(like) - + expr = ShowDatabasesExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_databases_result(result) @@ -520,12 +499,7 @@ def table_status(self, schema: Optional[str] = None, like: Optional[str] = None) Returns: List of ShowTableStatusResult objects. """ - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) - + expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_table_status_result(result) @@ -542,12 +516,7 @@ def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = Non Returns: List of ShowTriggerResult objects. """ - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.with_schema(schema) - if table_name: - expr.with_table_name(table_name) - + expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_triggers_result(result) @@ -562,10 +531,7 @@ def create_trigger(self, trigger_name: str, schema: Optional[str] = None): Returns: ShowCreateTriggerResult or None if not found. """ - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.with_schema(schema) - + expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_trigger_result(result, trigger_name) @@ -582,12 +548,7 @@ def variables(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowVariableResult objects. """ - expr = ShowVariablesExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_vars() - + expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_variables_result(result) @@ -604,12 +565,7 @@ def status(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowStatusResult objects. """ - expr = ShowStatusExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_status() - + expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_status_result(result) @@ -625,10 +581,7 @@ def processlist(self, full: bool = False): Returns: List of ShowProcessListResult objects. """ - expr = ShowProcessListExpression(self.dialect) - if full: - expr.with_full() - + expr = ShowProcessListExpression(self.dialect, full=full) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_processlist_result(result) @@ -644,10 +597,7 @@ def warnings(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) - + expr = ShowWarningsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_warnings_result(result) @@ -661,10 +611,7 @@ def errors(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) - + expr = ShowErrorsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_errors_result(result) @@ -693,10 +640,7 @@ def charset(self, like: Optional[str] = None): Returns: List of ShowCharsetResult objects. """ - expr = ShowCharsetExpression(self.dialect) - if like: - expr.with_like(like) - + expr = ShowCharsetExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_charset_result(result) @@ -712,10 +656,7 @@ def collation(self, like: Optional[str] = None): Returns: List of ShowCollationResult objects. """ - expr = ShowCollationExpression(self.dialect) - if like: - expr.with_like(like) - + expr = ShowCollationExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_collation_result(result) @@ -732,10 +673,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): Returns: List of ShowGrantResult objects. """ - expr = ShowGrantsExpression(self.dialect) - if user: - expr.with_user(user, host) - + expr = ShowGrantsExpression(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_grants_result(result) @@ -778,9 +716,7 @@ def __init__(self, backend: "AsyncMySQLBackend", version: Optional[Tuple[int, .. async def create_table(self, table_name: str, schema: Optional[str] = None): """Async version of create_table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_table_result(result, table_name) @@ -789,9 +725,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): async def create_view(self, view_name: str, schema: Optional[str] = None): """Async version of create_view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_view_result(result, view_name) @@ -806,13 +740,9 @@ async def columns( like: Optional[str] = None, ): """Async version of columns.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) - if full: - expr.with_full() - if like: - expr.with_like(like) + expr = ShowColumnsExpression( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_columns_result(result) @@ -821,9 +751,7 @@ async def columns( async def indexes(self, table_name: str, schema: Optional[str] = None): """Async version of indexes.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.with_schema(schema) + expr = ShowIndexExpression(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_indexes_result(result) @@ -837,13 +765,7 @@ async def tables( full: bool = False, ): """Async version of tables.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) - if full: - expr.with_full() + expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_tables_result(result) @@ -852,9 +774,7 @@ async def tables( async def databases(self, like: Optional[str] = None): """Async version of databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowDatabasesExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_databases_result(result) @@ -863,11 +783,7 @@ async def databases(self, like: Optional[str] = None): async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Async version of table_status.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.with_schema(schema) - if like: - expr.with_like(like) + expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_table_status_result(result) @@ -876,20 +792,14 @@ async def table_status(self, schema: Optional[str] = None, like: Optional[str] = async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """Async version of triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.with_schema(schema) - if table_name: - expr.with_table_name(table_name) + expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_triggers_result(result) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Async version of create_trigger.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.with_schema(schema) + expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_trigger_result(result, trigger_name) @@ -898,11 +808,7 @@ async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): async def variables(self, like: Optional[str] = None, session: bool = True): """Async version of variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_vars() + expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_variables_result(result) @@ -911,11 +817,7 @@ async def variables(self, like: Optional[str] = None, session: bool = True): async def status(self, like: Optional[str] = None, session: bool = True): """Async version of status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.with_like(like) - if not session: - expr.with_global_status() + expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_status_result(result) @@ -924,9 +826,7 @@ async def status(self, like: Optional[str] = None, session: bool = True): async def processlist(self, full: bool = False): """Async version of processlist.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.with_full() + expr = ShowProcessListExpression(self.dialect, full=full) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_processlist_result(result) @@ -935,18 +835,14 @@ async def processlist(self, full: bool = False): async def warnings(self, limit: Optional[int] = None): """Async version of warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowWarningsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_warnings_result(result) async def errors(self, limit: Optional[int] = None): """Async version of errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.with_limit(limit) + expr = ShowErrorsExpression(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_errors_result(result) @@ -964,9 +860,7 @@ async def engines(self): async def charset(self, like: Optional[str] = None): """Async version of charset.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCharsetExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_charset_result(result) @@ -975,9 +869,7 @@ async def charset(self, like: Optional[str] = None): async def collation(self, like: Optional[str] = None): """Async version of collation.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.with_like(like) + expr = ShowCollationExpression(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_collation_result(result) @@ -986,9 +878,7 @@ async def collation(self, like: Optional[str] = None): async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Async version of grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.with_user(user, host) + expr = ShowGrantsExpression(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_grants_result(result) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py new file mode 100644 index 00000000..2b8a30aa --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py @@ -0,0 +1,285 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py +"""MySQL's SHOW statements, rendered from their nodes. + +Two things are asserted here, and they are the two the wave asked for: + +* **Each SHOW command is a node with constructor parameters.** No fluent + setters remain, so an instance is complete the moment it is built and + ``get_params()`` is the whole artifact. Every case therefore also rebuilds + the node from its own parameters and requires the same SQL -- a parameter the + class forgot to store under its own name fails there rather than surviving as + a silently dropped filter. +* **The rendering is the dialect's.** These tests construct a dialect and + render; they open no connection (org rules §12). + +The parameter positions are part of the assertion: ``SHOW COLUMNS FROM t LIKE +%s`` binds the pattern, while ``SHOW WARNINGS LIMIT n`` inlines the count -- +the command grammar takes a literal there, so a placeholder would be a syntax +error rather than a parameter. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression.show import ( + ShowBinaryLogsExpression, + ShowCharsetExpression, + ShowCollationExpression, + ShowColumnsExpression, + ShowCreateTableExpression, + ShowCreateTriggerExpression, + ShowCreateViewExpression, + ShowDatabasesExpression, + ShowEnginesExpression, + ShowErrorsExpression, + ShowExpression, + ShowGrantsExpression, + ShowIndexExpression, + ShowMasterStatusExpression, + ShowPluginsExpression, + ShowProcessListExpression, + ShowSlaveStatusExpression, + ShowStatusExpression, + ShowTableStatusExpression, + ShowTablesExpression, + ShowTriggersExpression, + ShowVariablesExpression, + ShowWarningsExpression, +) + + +@pytest.fixture +def dialect(): + return MySQLDialect(version=(8, 0, 0)) + + +class TestConstructorOnly: + """Every parameter is a constructor parameter; nothing is filled in later.""" + + def test_no_fluent_setter_survives(self): + setters = [ + name + for name in dir(ShowExpression) + if name.startswith("with_") or name in ("schema", "like", "full") + ] + assert setters == [] + + @pytest.mark.parametrize( + "build", + [ + lambda d: ShowCreateTableExpression(d, "t"), + lambda d: ShowCreateViewExpression(d, "v"), + lambda d: ShowColumnsExpression(d, "t", full=True, like_pattern="id%"), + lambda d: ShowIndexExpression(d, "t"), + lambda d: ShowTablesExpression(d, full=True, like_pattern="t%"), + lambda d: ShowDatabasesExpression(d, like_pattern="a%"), + lambda d: ShowTableStatusExpression(d, like_pattern="t%"), + lambda d: ShowTriggersExpression(d, table_name="t"), + lambda d: ShowCreateTriggerExpression(d, "trg"), + lambda d: ShowVariablesExpression(d, like_pattern="max%"), + lambda d: ShowStatusExpression(d, like_pattern="Ssl_version", session=False), + lambda d: ShowProcessListExpression(d, full=True), + lambda d: ShowWarningsExpression(d, limit=3), + lambda d: ShowErrorsExpression(d, limit=3), + lambda d: ShowEnginesExpression(d), + lambda d: ShowCharsetExpression(d, like_pattern="utf%"), + lambda d: ShowCollationExpression(d, like_pattern="utf8%"), + lambda d: ShowGrantsExpression(d, user="root", host="localhost"), + lambda d: ShowPluginsExpression(d), + lambda d: ShowBinaryLogsExpression(d), + lambda d: ShowMasterStatusExpression(d), + lambda d: ShowSlaveStatusExpression(d), + ], + ids=lambda build: build(MySQLDialect(version=(8, 0, 0))).__class__.__name__, + ) + def test_params_rebuild_the_same_statement(self, dialect, build): + expr = build(dialect) + expected = expr.to_sql() + rebuilt = type(expr)(dialect, **expr.get_params()) + assert rebuilt.to_sql() == expected + + +class TestShowCreate: + """``SHOW CREATE ...`` names one object, database included.""" + + def test_unqualified_table(self, dialect): + sql, params = ShowCreateTableExpression(dialect, "users").to_sql() + assert sql == "SHOW CREATE TABLE `users`" + assert params == () + + def test_database_qualifies_the_table(self, dialect): + sql, _ = ShowCreateTableExpression(dialect, "users", schema="app").to_sql() + assert sql == "SHOW CREATE TABLE `app`.`users`" + + def test_view(self, dialect): + sql, _ = ShowCreateViewExpression(dialect, "v", schema="app").to_sql() + assert sql == "SHOW CREATE VIEW `app`.`v`" + + def test_trigger(self, dialect): + sql, _ = ShowCreateTriggerExpression(dialect, "trg").to_sql() + assert sql == "SHOW CREATE TRIGGER `trg`" + + +class TestShowColumnsAndIndex: + """The pattern is bound; FULL and the verb are spelled, not parameterised.""" + + def test_plain(self, dialect): + sql, params = ShowColumnsExpression(dialect, "users", schema="app").to_sql() + assert sql == "SHOW COLUMNS FROM `app`.`users`" + assert params == () + + def test_full_and_like(self, dialect): + sql, params = ShowColumnsExpression( + dialect, "users", full=True, like_pattern="id%" + ).to_sql() + assert sql == "SHOW FULL COLUMNS FROM `users` LIKE %s" + assert params == ("id%",) + + def test_index(self, dialect): + sql, params = ShowIndexExpression(dialect, "users", schema="app").to_sql() + assert sql == "SHOW INDEX FROM `app`.`users`" + assert params == () + + +class TestShowTablesAndDatabases: + def test_tables_full_like_and_schema(self, dialect): + sql, params = ShowTablesExpression( + dialect, schema="app", full=True, like_pattern="u%" + ).to_sql() + assert sql == "SHOW FULL TABLES FROM `app` LIKE %s" + assert params == ("u%",) + + def test_tables_without_filters(self, dialect): + assert ShowTablesExpression(dialect).to_sql() == ("SHOW TABLES", ()) + + def test_databases_like(self, dialect): + assert ShowDatabasesExpression(dialect, like_pattern="a%").to_sql() == ( + "SHOW DATABASES LIKE %s", + ("a%",), + ) + + def test_databases_without_pattern(self, dialect): + assert ShowDatabasesExpression(dialect).to_sql() == ("SHOW DATABASES", ()) + + def test_table_status(self, dialect): + assert ShowTableStatusExpression(dialect, schema="app", like_pattern="u%").to_sql() == ( + "SHOW TABLE STATUS FROM `app` LIKE %s", + ("u%",), + ) + + +class TestShowVariablesAndStatus: + """Session is the default; the GLOBAL word is the difference.""" + + def test_variables_session(self, dialect): + assert ShowVariablesExpression(dialect, like_pattern="max%").to_sql() == ( + "SHOW VARIABLES LIKE %s", + ("max%",), + ) + + def test_variables_global(self, dialect): + assert ShowVariablesExpression( + dialect, like_pattern="gtid_executed", session=False + ).to_sql() == ("SHOW GLOBAL VARIABLES LIKE %s", ("gtid_executed",)) + + def test_status_session(self, dialect): + assert ShowStatusExpression(dialect, like_pattern="Ssl_version").to_sql() == ( + "SHOW STATUS LIKE %s", + ("Ssl_version",), + ) + + def test_status_global(self, dialect): + assert ShowStatusExpression(dialect, like_pattern="Ssl_version", session=False).to_sql() == ( + "SHOW GLOBAL STATUS LIKE %s", + ("Ssl_version",), + ) + + +class TestShowTriggers: + """``SHOW TRIGGERS`` filters by table through LIKE, not through a clause.""" + + def test_all(self, dialect): + assert ShowTriggersExpression(dialect, schema="app").to_sql() == ( + "SHOW TRIGGERS FROM `app`", + (), + ) + + def test_one_table(self, dialect): + assert ShowTriggersExpression(dialect, schema="app", table_name="users").to_sql() == ( + "SHOW TRIGGERS FROM `app` LIKE %s", + ("users",), + ) + + +class TestLimitAndFull: + """The count is a literal: the grammar takes a number, not a bind slot.""" + + def test_processlist(self, dialect): + assert ShowProcessListExpression(dialect).to_sql() == ("SHOW PROCESSLIST", ()) + + def test_full_processlist(self, dialect): + assert ShowProcessListExpression(dialect, full=True).to_sql() == ( + "SHOW FULL PROCESSLIST", + (), + ) + + def test_warnings_limit_is_inline(self, dialect): + assert ShowWarningsExpression(dialect, limit=10).to_sql() == ( + "SHOW WARNINGS LIMIT 10", + (), + ) + + def test_errors_limit_is_inline(self, dialect): + assert ShowErrorsExpression(dialect, limit=5).to_sql() == ( + "SHOW ERRORS LIMIT 5", + (), + ) + + +class TestParameterless: + """Commands with no parameters at all -- each its own node.""" + + @pytest.mark.parametrize( + "node, expected", + [ + (ShowEnginesExpression, "SHOW ENGINES"), + (ShowPluginsExpression, "SHOW PLUGINS"), + (ShowBinaryLogsExpression, "SHOW BINARY LOGS"), + (ShowMasterStatusExpression, "SHOW MASTER STATUS"), + (ShowSlaveStatusExpression, "SHOW SLAVE STATUS"), + ], + ids=lambda value: getattr(value, "__name__", ""), + ) + def test_renders(self, dialect, node, expected): + assert node(dialect).to_sql() == (expected, ()) + + +class TestGrants: + def test_current_account(self, dialect): + assert ShowGrantsExpression(dialect).to_sql() == ("SHOW GRANTS", ()) + + def test_named_account(self, dialect): + assert ShowGrantsExpression(dialect, user="root", host="localhost").to_sql() == ( + "SHOW GRANTS FOR %s@%s", + ("root", "localhost"), + ) + + def test_user_without_host(self, dialect): + assert ShowGrantsExpression(dialect, user="root").to_sql() == ( + "SHOW GRANTS FOR %s", + ("root",), + ) + + +class TestCharsetAndCollation: + def test_charset(self, dialect): + assert ShowCharsetExpression(dialect, like_pattern="utf%").to_sql() == ( + "SHOW CHARACTER SET LIKE %s", + ("utf%",), + ) + + def test_collation(self, dialect): + assert ShowCollationExpression(dialect, like_pattern="utf8%").to_sql() == ( + "SHOW COLLATION LIKE %s", + ("utf8%",), + ) From b2237107b3982f74b8b923e9d4532a0584cb655d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 05:09:53 +0800 Subject: [PATCH 39/65] test(expression): drop the stale datetime pins and re-read the four that remain The core fix made every word-carrying enum spell its word, so the nine datetime classes the XML channel used to mangle now round-trip: their rows in _ROUNDTRIP_DEFECT are stale and are gone. The four rows that survive -- XMLParseExpression, XMLSerializeExpression, ForeignKeyConstraint and ColumnGeneratedConstraint -- fail for a different reason than the rows they replace. The spelling half is fixed; what is left is the read half. Those constructors store the parameter the artifact hands back without normalising it, so the value comes back as the plain word rather than the member. The rows now say that, and the exception they name is the AssertionError the parameter comparison actually raises. --- .../backend/test_expression_roundtrip_all.py | 109 +++++------------- 1 file changed, 31 insertions(+), 78 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 818992c4..e583d206 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -1094,92 +1094,45 @@ def partition_maintenance(d, cls_name): # it sits in, and the exactness test below makes a core fix delete its own # row: once the class round-trips, a row left behind is a failure. _ROUNDTRIP_DEFECT = { - # --- A (str, Enum) without ``__str__ = str.__str__``: the XML channel --- - # --- writes ``EnumName.MEMBER``, which the constructor then refuses. --- - # ``_scalar_to_text`` in the core's xml_serialization spells a scalar as - # ``str(value)``, and for an enum member that carries no ``__str__`` - # override, ``Enum.__str__`` wins: ``DateTimeField.YEAR`` spells as the - # *string* "DateTimeField.YEAR", not the word "year" the member holds. - # Reading it back hands that string to ``normalize_datetime_field``, - # which refuses it by name. The JSON channel is unaffected -- a str - # member dumps as its word -- which is what pins the defect to the XML - # spelling and nowhere else. - "datetime.ExtractExpression": ( - "ValueError", - "CORE DEFECT: core's DateTimeField carries no ``__str__ = " - "str.__str__``, so the XML channel writes 'DateTimeField.YEAR' and " - "normalize_datetime_field refuses it on read.", - ), - "datetime.ExtractFieldExpression": ( - "ValueError", - "CORE DEFECT: same DateTimeField spelling as ExtractExpression -- the " - "XML channel writes 'DateTimeField.YEAR' and the read refuses it.", - ), - "datetime._ExtractBase": ( - "ValueError", - "CORE DEFECT: same DateTimeField spelling as ExtractExpression -- the " - "XML channel writes 'DateTimeField.YEAR' and the read refuses it.", - ), - "datetime.DatePartExpression": ( - "ValueError", - "CORE DEFECT: same DateTimeField spelling -- the XML channel writes " - "'DateTimeField.YEAR' and the read refuses it.", - ), - "datetime.DateTruncExpression": ( - "ValueError", - "CORE DEFECT: same DateTimeField spelling -- the XML channel writes " - "'DateTimeField.YEAR' and the read refuses it.", - ), - "datetime.DateTimeAddExpression": ( - "ValueError", - "CORE DEFECT: core's IntervalUnit carries no ``__str__ = " - "str.__str__``, so the XML channel writes 'IntervalUnit.DAY' and " - "normalize_interval_unit refuses it on read.", - ), - "datetime.DateTimeDiffExpression": ( - "ValueError", - "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " - "'IntervalUnit.DAY' and the read refuses it.", - ), - "datetime.DateTimeSubtractExpression": ( - "ValueError", - "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " - "'IntervalUnit.DAY' and the read refuses it.", - ), - "datetime.IntervalExpression": ( - "ValueError", - "CORE DEFECT: same IntervalUnit spelling -- the XML channel writes " - "'IntervalUnit.DAY' and the read refuses it.", - ), + # --- A constructor that keeps the artifact's word instead of the ------- + # --- member, so the restored parameter is not the one that went out. --- + # §10: the constructor takes the serialized form and normalises it. These + # store what the artifact hands back, so a parameter that left as a member + # returns as a plain ``str`` -- and everything downstream that reads + # ``.value`` only finds it on the member. + # + # The four enum families below are the residue of a core fix that made the + # member *be* its word on the wire: the artifact now carries 'DOCUMENT', + # 'CASCADE', 'VIRTUAL' as a plain string, and the reader has nothing left + # to normalise with. The spelling half of the defect is gone; the + # normalisation half is not. "xml.XMLParseExpression": ( "AssertionError", - "CORE DEFECT: core's XMLParseDocumentType carries no ``__str__`` " - "override, so the XML channel writes 'XMLParseDocumentType.DOCUMENT'; " - "the constructor stores the word raw, and the restored parameter is " - "not the word the member spells.", + "CORE DEFECT: core's XMLParseExpression stores ``document_type`` raw, " + "so the word the artifact carries ('DOCUMENT') comes back as a str " + "where the original carried the XMLParseDocumentType member. The " + "spelling itself is now right -- the XML channel writes the word, not " + "'XMLParseDocumentType.DOCUMENT'.", ), "xml.XMLSerializeExpression": ( "AssertionError", - "CORE DEFECT: same XMLSerializeDocumentType spelling -- the XML " - "channel writes the Enum.__str__ form and the constructor stores it " - "raw.", - ), - # --- A plain Enum where the value vocabulary needs a word. ------------- - # ``LiteralKind.of`` is total over the closed value set and refuses a - # host value it does not name. A plain Enum member is neither a JSON - # scalar nor a node, so an artifact cannot carry it -- the spelling is - # the core's own §11.3 rule and these two enums predate it. + "CORE DEFECT: same raw-store of ``document_type`` in " + "XMLSerializeExpression -- the restored parameter is the word, not the " + "XMLSerializeDocumentType member.", + ), "statements.ddl_table.ForeignKeyConstraint": ( - "TypeError", - "CORE DEFECT: core's ReferentialAction is a plain Enum, so " - "LiteralKind.of refuses the member and no artifact can carry the " - "constraint's referential actions.", + "AssertionError", + "CORE DEFECT: core's ReferentialAction is a str enum now, so the " + "artifact carries 'CASCADE' -- and TableForeignKeyConstraint stores " + "``on_delete`` / ``on_update`` raw, so the restored constraint carries " + "the word where the original carried the member.", ), "statements.ddl_table.ColumnGeneratedConstraint": ( - "TypeError", - "CORE DEFECT: core's GeneratedColumnType is a plain Enum and the " - "constructor forces a member into storage (``storage_type or " - "VIRTUAL``), so serialization refuses every instance.", + "AssertionError", + "CORE DEFECT: core's GeneratedColumnType is a str enum now, so the " + "artifact carries 'VIRTUAL' -- but the constructor stores " + "``storage_type`` without normalising, so the restored constraint " + "carries the word where the original carried the member.", ), # --- A constructor that refuses its own artifact's word. --------------- # §10: the constructor accepts the serialized form and normalises it. From d54d56670107409d6a0db6e6b7e9efb655a1cfd7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 13:30:16 +0800 Subject: [PATCH 40/65] chore: ignore .worktree/, the branch toolchain directories --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 31784d7c..17684a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,9 @@ tests/config/mysql_scenarios.yml # Claude Code temporary files (local only) .claude/tmp/ .claude/plans/ -.claude/plan/ \ No newline at end of file +.claude/plan/ +# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A` +# from the repo root would commit each branch's whole toolchain -- the venv, the +# build directory and the test artifacts -- and the directory disappears when the +# branch merges, so the history would keep a second copy of the source tree. +.worktree/ From 32491feca6f3e9b18c398593b27bc8683128a8f8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 22 Oct 2026 22:49:36 +0800 Subject: [PATCH 41/65] fix(mysql): the dialect surface, and the type positions it decides The dialect's surface is two kinds of member: capability switches (``supports_*()``) and formatters (``format_*(self, expr)``). This change makes MySQL's code match that, and settles what a type position holds while it is there. **A formatter owns its production.** Core's dialect mixins held six private renderers serving families of thin ``format_*(self, expr)`` wrappers. The wrappers were formatters; the bodies were not, and being private they could not be overridden. MySQL's ``CHAR_LENGTH`` -- which core spelled through one of them -- now owns its production. **A type-name position holds a type.** The positions that carried a raw string guarded it with a character class in the base class, which neither matched what the engines accept nor kept the value inside its syntactic position. A position now holds a type node; a name the framework does not model becomes the framework's name node, whose name the grammar validates. **A type name is not the framework's to judge.** MySQL had a private character rule for type names, kept so that ``ENUM('a','b')`` -- which no framework-side grammar would let through -- could be rendered. Nothing in the codebase uses a quoted type name, so the rule guarded a case that does not exist while refusing the shapes that do matter. The JSON_TABLE column type is claimed instead: a node renders itself through the dialect's type model, and a name becomes the framework's name node. **The backend's own answers are the effective ones.** MySQL's mixin precedes the core mixin it overrides, and a test holds that in place. **The mixin list says what it is for.** The base-list convention is written down where the list is, and the function examples reach their constructors through ``functions as F``. --- .../backend/impl/mysql/dialect.py | 10 +- .../impl/mysql/examples/ddl/create_table.py | 6 +- .../impl/mysql/examples/insert/batch.py | 4 +- .../impl/mysql/examples/query/aggregate.py | 11 +-- .../impl/mysql/examples/query/constants.py | 23 ++--- .../impl/mysql/examples/query/subquery.py | 4 +- .../impl/mysql/expression/json_table.py | 8 +- .../backend/impl/mysql/mixins/constraint.py | 4 +- .../backend/impl/mysql/mixins/ddl_column.py | 2 +- .../backend/impl/mysql/mixins/ddl_table.py | 15 +-- .../backend/impl/mysql/mixins/ddl_view.py | 11 +++ .../backend/impl/mysql/mixins/dml.py | 10 +- .../backend/impl/mysql/mixins/function.py | 6 +- .../backend/impl/mysql/mixins/json.py | 33 ++++--- .../backend/impl/mysql/mixins/literal.py | 2 +- .../backend/impl/mysql/mixins/partition.py | 8 +- .../backend/impl/mysql/protocols.py | 10 ++ .../expression/test_mysql_literal_values.py | 2 +- .../backend/test_mysql_dialect_security.py | 96 ++++++++++++++----- .../backend/test_mysql_mixin_expressions.py | 2 +- .../test_mysql_protocol_conformance.py | 48 ++++++++++ 21 files changed, 221 insertions(+), 94 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index c7780183..90382c8d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -181,6 +181,14 @@ class MySQLDialect( + # The bases below come in bundles: a backend mixin, then the core bundle + # it stands in for. The backend's answer has to come first -- a mixin + # placed after the class it overrides never wins -- so a new mixin goes + # immediately before the core mixin it overrides, and the pairing is read + # off the list. Two positions sit outside that pairing and are called out + # where they occur: a mixin that overrides a method ``SQLDialectBase`` + # defines has to precede the base, and a mixin that subclasses a core + # mixin has to precede it too (C3: a subclass precedes its base). # MySQL's tier-2 value literals (bytes X'...' and the quoted UUID text) # override format_literal, and this is the one base that defines it, so # the mixin is placed ahead of SQLDialectBase: a mixin placed after the @@ -457,7 +465,7 @@ def create_schema_differ(self): return MySQLSchemaDiffer() - def _escape_sql_string(self, value: str) -> str: + def escape_sql_string(self, value: str) -> str: """Escape string for MySQL with backslash support. MySQL default SQL mode (STRICT_TRANS_TABLES) treats backslash as an diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py index a2e5ab22..a8de4b1e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py @@ -20,7 +20,7 @@ DropTableStatement, CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnDefaultConstraint, @@ -91,7 +91,7 @@ name="created_at", data_type="TIMESTAMP", constraints=[ - ColumnConstraint(None, branch=ColumnDefaultConstraint(None, current_timestamp(dialect))), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, F.current_timestamp(dialect))), ], ), ] @@ -144,5 +144,5 @@ # 1. Use ColumnConstraint with is_auto_increment=True for AUTO_INCREMENT # 2. MySQL table options (engine/charset) are typed on MySQLCreateTableOptions # 3. IndexDefinition creates inline indexes within CREATE TABLE -# 4. Use current_timestamp(dialect) for SQL niladic functions (no parentheses) +# 4. Use F.current_timestamp(dialect) for SQL niladic functions (no parentheses) # 5. Use introspector.get_columns() to verify table structure diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py index d40d974c..e18b7e73 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py @@ -12,7 +12,7 @@ CreateTableStatement, DropTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnDefaultConstraint, @@ -60,7 +60,7 @@ constraints=[ ColumnConstraint( None, - branch=ColumnDefaultConstraint(None, current_timestamp(dialect)) + branch=ColumnDefaultConstraint(None, F.current_timestamp(dialect)) ), ], ), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py index 11f1959d..bd2c0925 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py @@ -89,10 +89,7 @@ Column, GroupByHavingClause, ) -from rhosocial.activerecord.backend.expression.functions import ( # noqa: E402 - avg, - sum_, -) +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -102,8 +99,8 @@ dialect=dialect, select=[ Column(dialect, "product"), - sum_(dialect, Column(dialect, "quantity"), alias="total_qty"), - avg(dialect, Column(dialect, "price"), alias="avg_price"), + F.sum_(dialect, Column(dialect, "quantity"), alias="total_qty"), + F.avg(dialect, Column(dialect, "price"), alias="avg_price"), ], from_=Table(dialect, "sales"), group_by_having=GroupByHavingClause( @@ -112,7 +109,7 @@ having=ComparisonPredicate( dialect, ">", - sum_(dialect, Column(dialect, "quantity")), + F.sum_(dialect, Column(dialect, "quantity")), Literal(dialect, 10), ), ), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py index 24bea4f8..d7177aca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py @@ -35,22 +35,17 @@ # SECTION: SELECT CURRENT_TIMESTAMP # ============================================================ # Use factory functions for SQL statement-level constants. -# current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) +# F.current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) # which is the SQL:2003 standard niladic form, valid in both DDL DEFAULT # and SELECT contexts. MySQL also accepts CURRENT_TIMESTAMP() with parentheses. from rhosocial.activerecord.backend.expression import QueryStatement # noqa: E402 from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 -from rhosocial.activerecord.backend.expression.functions.datetime import ( # noqa: E402 - current_timestamp, - now, - current_date, - current_time, -) +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 query_ts = QueryStatement( dialect=dialect, - select=[current_timestamp(dialect)], + select=[F.current_timestamp(dialect)], ) sql, params = query_ts.to_sql() print(f"CURRENT_TIMESTAMP SQL: {sql}") @@ -62,7 +57,7 @@ # ============================================================ query_now = QueryStatement( dialect=dialect, - select=[now(dialect)], + select=[F.now(dialect)], ) sql, params = query_now.to_sql() print(f"NOW() SQL: {sql}") @@ -75,8 +70,8 @@ query_date = QueryStatement( dialect=dialect, select=[ - current_date(dialect), - current_time(dialect), + F.current_date(dialect), + F.current_time(dialect), ], ) sql, params = query_date.to_sql() @@ -92,8 +87,8 @@ query_multi = QueryStatement( dialect=dialect, select=[ - now(dialect).as_("current_time"), - current_date(dialect).as_("current_date"), + F.now(dialect).as_("current_time"), + F.current_date(dialect).as_("current_date"), FunctionCall(dialect, "DATABASE").as_("db_name"), FunctionCall(dialect, "VERSION").as_("db_version"), ], @@ -111,7 +106,7 @@ query_format = QueryStatement( dialect=dialect, select=[ - FunctionCall(dialect, "DATE_FORMAT", now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), + FunctionCall(dialect, "DATE_FORMAT", F.now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), ], ) sql, params = query_format.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py index 6d2b2f4a..38370350 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py @@ -127,13 +127,13 @@ WhereClause, Subquery, ) -from rhosocial.activerecord.backend.expression.functions import avg # noqa: E402 +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table subquery_query = QueryStatement( dialect=dialect, - select=[avg(dialect, Column(dialect, "salary"))], + select=[F.avg(dialect, Column(dialect, "salary"))], from_=Table(dialect, "employees"), ) sql, params = subquery_query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py index 3e274119..2fa5233c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py @@ -7,12 +7,13 @@ """ from dataclasses import dataclass -from typing import Any, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING, Union from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.types import DataType @dataclass @@ -21,7 +22,8 @@ class JSONTableColumn: Attributes: name: Column name - type: SQL data type (e.g., 'VARCHAR(255)', 'INT') + type: The column's type -- a type node (``VarCharType(dialect, 255)``) + or a type name, which this backend's own rule decides path: JSON path expression for the column value ordinality: If True, use FOR ORDINALITY (row number) exists: If True, use EXISTS (boolean for path existence) @@ -30,7 +32,7 @@ class JSONTableColumn: """ name: str - type: Optional[str] = None + type: Optional[Union[str, "DataType"]] = None path: Optional[str] = None ordinality: bool = False exists: bool = False diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index 403f19f9..5ddc3a73 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -538,7 +538,7 @@ def _require_constraint_enforcement(self, constraint) -> None: "clause parses and is discarded (measured on 26.7.0: 1064).", ) - def _format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + def format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: """Render the enforcement trait behind this backend's narrowing. The shared private hook takes the production, so it is shared by the @@ -550,7 +550,7 @@ def _format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: spelling when the branch has its own, earlier refusal to make. """ self._require_constraint_enforcement(constraint) - return super()._format_constraint_enforcement(constraint) + return super().format_constraint_enforcement(constraint) # Constraint deferral (the rendering half; the probe is above) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py index 1316d10f..7dec5a82 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py @@ -136,7 +136,7 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: if operation == "SET DEFAULT": new_value = action.new_value if isinstance(new_value, str): - escaped = self._escape_sql_string(new_value) + escaped = self.escape_sql_string(new_value) return f"ALTER COLUMN {col_name} SET DEFAULT '{escaped}'", () if new_value is None: raise ValueError("SET DEFAULT requires a default value") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 87dec936..107ee9ab 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -1,6 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py from typing import Any, List, TYPE_CHECKING, Tuple -import re from rhosocial.activerecord.backend.expression.objects import Table @@ -23,11 +22,6 @@ class MySQLTableMixin: """MySQL table DDL implementation.""" - @staticmethod - def _validate_data_type(data_type: str) -> bool: - """Validate data type string, allowing single quotes for MySQL ENUM types.""" - return bool(re.fullmatch(r"[A-Za-z0-9\s\(\),\']+", data_type)) - def supports_if_not_exists_table(self) -> bool: """Whether CREATE TABLE IF NOT EXISTS is supported.""" return True @@ -36,6 +30,15 @@ def supports_if_exists_table(self) -> bool: """Whether DROP TABLE IF EXISTS is supported.""" return True + + def supports_create_table_as_bind_parameters(self) -> bool: + """MySQL accepts bind parameters in a CTAS body. + + Measured on 9.7: ``CREATE TABLE t AS SELECT %s FROM s`` is accepted + and the table is created. MySQL's "parameters" are interpolated + client-side, so the value is baked into the statement's text. + """ + return True def supports_create_table_like(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py index b9b6c14f..5d255f30 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py @@ -25,6 +25,15 @@ def supports_temporary_view(self) -> bool: """Whether TEMPORARY views are supported.""" return True + + def supports_create_view_bind_parameters(self) -> bool: + """MySQL accepts bind parameters in a view body. + + Measured on 9.7: a view whose definition binds is accepted, because + MySQL's "parameters" are interpolated client-side and the value is + baked into the stored definition. + """ + return True def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" return True @@ -76,6 +85,8 @@ def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str parts.append(f"({cols})") query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_create_view_bind_parameters(): + self._refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py index 55971025..05cf9c34 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py @@ -62,20 +62,20 @@ def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> field_parts = [] if expr.options.fields_terminated_by is not None: - field_parts.append(f"TERMINATED BY '{self._escape_sql_string(expr.options.fields_terminated_by)}'") + field_parts.append(f"TERMINATED BY '{self.escape_sql_string(expr.options.fields_terminated_by)}'") if expr.options.fields_enclosed_by is not None: - field_parts.append(f"ENCLOSED BY '{self._escape_sql_string(expr.options.fields_enclosed_by)}'") + field_parts.append(f"ENCLOSED BY '{self.escape_sql_string(expr.options.fields_enclosed_by)}'") if expr.options.fields_escaped_by is not None: - field_parts.append(f"ESCAPED BY '{self._escape_sql_string(expr.options.fields_escaped_by)}'") + field_parts.append(f"ESCAPED BY '{self.escape_sql_string(expr.options.fields_escaped_by)}'") if field_parts: parts.append("FIELDS") parts.append(" ".join(field_parts)) line_parts = [] if expr.options.lines_starting_by is not None: - line_parts.append(f"STARTING BY '{self._escape_sql_string(expr.options.lines_starting_by)}'") + line_parts.append(f"STARTING BY '{self.escape_sql_string(expr.options.lines_starting_by)}'") if expr.options.lines_terminated_by is not None: - line_parts.append(f"TERMINATED BY '{self._escape_sql_string(expr.options.lines_terminated_by)}'") + line_parts.append(f"TERMINATED BY '{self.escape_sql_string(expr.options.lines_terminated_by)}'") if line_parts: parts.append("LINES") parts.append(" ".join(line_parts)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py index 7a9a4e52..9e969a7d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py @@ -130,4 +130,8 @@ def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str defaults (``OCTET_LENGTH`` / ``BIT_LENGTH``) are spellings MySQL accepts as-is. """ - return self._format_scalar_function("CHAR_LENGTH", expr) + operand_sql, operand_params = expr.operand.to_sql() + sql = f"CHAR_LENGTH({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py index 47348733..b379e104 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py @@ -258,7 +258,7 @@ def format_json_arrow_expression(self, expr: "JSONPathNode") -> Tuple[str, Tuple col_sql, col_params = self.format_identifier(str(expr.column)), () if expr.operation in ("->", "->>"): - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) sql = f"{col_sql}{expr.operation}'{escaped_path}'" params = col_params else: @@ -287,7 +287,7 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, Tu else: col_sql, col_params = self.format_identifier(str(expr.column)), () - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) if expr.operation == "->": sql = f"JSON_EXTRACT({col_sql}, '{escaped_path}')" @@ -314,6 +314,7 @@ def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: node again. """ from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol + from rhosocial.activerecord.backend.expression.types import DataType expr.validate(strict=self.strict_validation) @@ -323,7 +324,7 @@ def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: # if it's a ToSQLProtocol expression, use to_sql() for parameterized # queries; otherwise raise to prevent SQL injection. if isinstance(expr.json_doc, str): - parts.append(f"'{self._escape_sql_string(expr.json_doc)}'") + parts.append(f"'{self.escape_sql_string(expr.json_doc)}'") elif isinstance(expr.json_doc, ToSQLProtocol): json_sql, _ = expr.json_doc.to_sql() parts.append(json_sql) @@ -333,7 +334,7 @@ def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: ) parts.append(",") - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) parts.append(f"'{escaped_path}'") parts.append(" COLUMNS (") @@ -342,14 +343,17 @@ def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: if col.ordinality: column_parts.append(f"{self.format_identifier(col.name)} FOR ORDINALITY") elif col.exists: - escaped_col_path = self._escape_sql_string(col.path) if col.path else "" - column_parts.append(f"{self.format_identifier(col.name)} {col.type} EXISTS PATH '{escaped_col_path}'") + escaped_col_path = self.escape_sql_string(col.path) if col.path else "" + column_type, _ = DataType.as_type(self, col.type).to_sql() + column_parts.append( + f"{self.format_identifier(col.name)} {column_type} " + f"EXISTS PATH '{escaped_col_path}'" + ) else: - if not self._validate_data_type(col.type): - raise ValueError(f"Invalid data type: {col.type}") - col_def = f"{self.format_identifier(col.name)} {col.type}" + column_type, _ = DataType.as_type(self, col.type).to_sql() + col_def = f"{self.format_identifier(col.name)} {column_type}" if col.path: - escaped_col_path = self._escape_sql_string(col.path) + escaped_col_path = self.escape_sql_string(col.path) col_def += f" PATH '{escaped_col_path}'" if col.error_handling: valid_error_handling = {"NULL", "ERROR", "DEFAULT"} @@ -359,23 +363,24 @@ def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: f"Invalid error_handling: {col.error_handling}. Must be one of {valid_error_handling}" ) if error_handling_upper == "DEFAULT": - escaped_default = self._escape_sql_string(str(col.default_value)) + escaped_default = self.escape_sql_string(str(col.default_value)) col_def += f" DEFAULT '{escaped_default}' ON ERROR" else: col_def += f" {error_handling_upper} ON ERROR" column_parts.append(col_def) for nested in expr.nested_paths: - escaped_nested_path = self._escape_sql_string(nested.path) + escaped_nested_path = self.escape_sql_string(nested.path) nested_def = f"NESTED PATH '{escaped_nested_path}' COLUMNS (" nested_cols = [] for col in nested.columns: if col.ordinality: nested_cols.append(f"{self.format_identifier(col.name)} FOR ORDINALITY") else: - escaped_nested_col_path = self._escape_sql_string(col.path) if col.path else "" + escaped_nested_col_path = self.escape_sql_string(col.path) if col.path else "" + nested_type, _ = DataType.as_type(self, col.type).to_sql() nested_cols.append( - f"{self.format_identifier(col.name)} {col.type} PATH '{escaped_nested_col_path}'" + f"{self.format_identifier(col.name)} {nested_type} PATH '{escaped_nested_col_path}'" ) nested_def += ", ".join(nested_cols) + ")" if nested.alias: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py index 95567bca..911f5a46 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py @@ -34,7 +34,7 @@ The tier-1 forms (NULL / booleans / numbers / strings / dates / times / datetimes) are the base's, reached through this dialect's backslash-doubling -``_escape_sql_string``: measured on both servers as ``DEFAULT`` values +``escape_sql_string``: measured on both servers as ``DEFAULT`` values accepted by ``CREATE TABLE`` (``TRUE`` / ``FALSE`` / ``42`` / ``1.5`` / ``1.25`` / ``'x''y'`` / ``'2026-10-17'`` / ``'10:30:00'`` / ``'2026-10-17 10:00:00'``), each reading back unchanged -- so none of them diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 1b71d1aa..deed2274 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -443,7 +443,7 @@ def format_partition_definition_options( elif name in {"comment", "data_directory", "index_directory"}: if not isinstance(value, str): raise TypeError(f"{name} option must be a string") - escaped = self._escape_sql_string(value) + escaped = self.escape_sql_string(value) parts.append(f"{keyword} '{escaped}'") else: if not isinstance(value, int) or value < 0: @@ -542,13 +542,13 @@ def format_mysql_partition_value(self, expr: "MySQLPartitionValue") -> Tuple[str raise ValueError("partition value Decimal must be finite") return str(value), () if isinstance(value, datetime): - escaped = self._escape_sql_string(value.isoformat(sep=" ")) + escaped = self.escape_sql_string(value.isoformat(sep=" ")) return f"'{escaped}'", () if isinstance(value, date): - escaped = self._escape_sql_string(value.isoformat()) + escaped = self.escape_sql_string(value.isoformat()) return f"'{escaped}'", () if isinstance(value, str): - escaped = self._escape_sql_string(value) + escaped = self.escape_sql_string(value) return f"'{escaped}'", () raise TypeError( "partition value must be str, int, float, Decimal, " diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index 0ec74e12..981a6be7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -313,6 +313,16 @@ class MySQLTableSupport(TableObjectSupport, Protocol): - Various storage engines: MySQL 5.5+ """ + def supports_create_table_as_bind_parameters(self) -> bool: + """Whether this statement's body may carry bind parameters. + + Defaults to ``False``: a DDL statement carries no bind parameters + unless the dialect says it can, so a dialect that does not answer + refuses rather than emitting a statement the engine may reject. A + dialect whose engine accepts them returns ``True``. + """ + ... # pragma: no cover + def supports_create_table_like(self) -> bool: """Whether CREATE TABLE ... LIKE is supported. diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py index 80f384da..d9e8e855 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py @@ -32,7 +32,7 @@ / ``DEFAULT '2026-10-17 10:00:00'`` each read back as the same value. * Under the default ``sql_mode`` the backslash is an escape character (measured: the server reads ``'a\\b'`` as ``'a\\x08'``), which is why this - dialect's ``_escape_sql_string`` doubles backslashes for every inline + dialect's ``escape_sql_string`` doubles backslashes for every inline string tier-1 renders, and stops doubling under ``NO_BACKSLASH_ESCAPES``. The measurements are re-measured end to end against a running server by diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index c5620854..b4a13fe1 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -51,18 +51,54 @@ def test_mysql_format_column_definition_comment_string_escaping(dialect): assert "Comment with ''single quote''" in sql -def test_mysql_escape_sql_string(dialect): - """Test MySQL inherits _escape_sql_string.""" - result = dialect._escape_sql_string("Table's comment") +def test_mysqlescape_sql_string(dialect): + """Test MySQL inherits escape_sql_string.""" + result = dialect.escape_sql_string("Table's comment") assert result == "Table''s comment" -def test_mysql_validate_data_type(dialect): - """Test MySQL inherits _validate_data_type.""" - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("INT") - assert dialect._validate_data_type("BIGINT") - assert not dialect._validate_data_type("INT; DROP TABLE users--") +def test_mysql_has_no_type_name_rule_of_its_own(dialect): + """There is no per-backend character rule for a type name. + + The framework does not decide what an engine accepts: a name is checked + by the grammar, which is about what a name may *be*, and whether the type + exists is MySQL's answer to give. + """ + assert not hasattr(dialect, "_validate_data_type") + + +def test_mysqla_type_position_refuses_a_payload(dialect): + """A type name is a name, and the grammar is what says so. + + The backend has no character filter of its own any more: the type + position is guarded by the grammar, which is the framework's rule for + what a name may be, and the server decides whether the type exists. + """ + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression.statements import ( + AlterColumnAction, + ) + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + from rhosocial.activerecord.backend.expression.xml import ( + XMLSerializeExpression, + ) + + # A name is a name: the position takes it. + XMLSerializeExpression(dialect, Literal(dialect, "x"), "TEXT") + AlterColumnAction(dialect, "c", "SET DATA TYPE", new_value="TEXT") + + # A value that is not a name is refused, and refused before the + # statement is ever rendered. + with pytest.raises(InvalidTypeNameError): + XMLSerializeExpression( + dialect, Literal(dialect, "x"), "TEXT; DROP TABLE users--" + ) + with pytest.raises(InvalidTypeNameError): + AlterColumnAction( + dialect, "c", "SET DATA TYPE", new_value="TEXT; DROP TABLE users--" + ) def test_mysql_format_column_definition_data_type_validation(dialect): @@ -168,53 +204,53 @@ def test_mysql_format_cast_expression_rejects_injection(dialect): class TestMySQLEscapeSqlStringBackslash: - """Tests for MySQL _escape_sql_string with backslash escaping.""" + """Tests for MySQL escape_sql_string with backslash escaping.""" - def test_escape_sql_string_backslash_escaped(self, dialect): + def testescape_sql_string_backslash_escaped(self, dialect): """Test backslash is properly escaped in MySQL.""" - result = dialect._escape_sql_string("test\\value") + result = dialect.escape_sql_string("test\\value") assert "\\\\" in result - def test_escape_sql_string_backslash_and_quote(self, dialect): + def testescape_sql_string_backslash_and_quote(self, dialect): """Test both backslash and single quote are escaped.""" - result = dialect._escape_sql_string("test\\'value") + result = dialect.escape_sql_string("test\\'value") assert "\\\\" in result assert "''" in result - def test_escape_sql_string_preserves_others(self, dialect): + def testescape_sql_string_preserves_others(self, dialect): """Test other characters are preserved.""" - result = dialect._escape_sql_string('test"double"value') + result = dialect.escape_sql_string('test"double"value') assert 'test"double"value' in result -def test_escape_sql_string_no_backslash_escapes_mode(): +def testescape_sql_string_no_backslash_escapes_mode(): """Under NO_BACKSLASH_ESCAPES backslash is literal and NOT doubled.""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0), sql_mode="NO_BACKSLASH_ESCAPES") assert d.no_backslash_escapes is True # Backslash preserved (literal), quotes still doubled - assert d._escape_sql_string(r"C:\path") == r"C:\path" - assert d._escape_sql_string("it's") == "it''s" + assert d.escape_sql_string(r"C:\path") == r"C:\path" + assert d.escape_sql_string("it's") == "it''s" -def test_escape_sql_string_default_mode_doubles_backslash(): +def testescape_sql_string_default_mode_doubles_backslash(): """Under default SQL mode backslash is doubled, quotes doubled.""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0), sql_mode="STRICT_TRANS_TABLES") assert d.no_backslash_escapes is False - assert d._escape_sql_string(r"C:\path") == r"C:\\path" - assert d._escape_sql_string("it's") == "it''s" + assert d.escape_sql_string(r"C:\path") == r"C:\\path" + assert d.escape_sql_string("it's") == "it''s" -def test_escape_sql_string_mode_whitelist_default(): +def testescape_sql_string_mode_whitelist_default(): """Dialect defaults to STRICT_TRANS_TABLES (backslash escapes).""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0)) assert d.no_backslash_escapes is False - assert d._escape_sql_string(r"C:\path") == r"C:\\path" + assert d.escape_sql_string(r"C:\path") == r"C:\\path" class TestMySQLJSONTableTypeValidation: @@ -239,7 +275,15 @@ def test_json_table_valid_data_type(self, dialect): assert "VARCHAR(255)" in sql def test_json_table_invalid_data_type_rejected(self, dialect): - """Test invalid data type in JSON_TABLE column is rejected.""" + """A value that is not a type name is refused at the type position. + + The refusal comes from the grammar, so it names the value rather than + the position. + """ + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + expr = MySQLJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', @@ -253,7 +297,7 @@ def test_json_table_invalid_data_type_rejected(self, dialect): ], ) - with pytest.raises(ValueError, match="Invalid data type"): + with pytest.raises(InvalidTypeNameError): dialect.format_mysql_json_table_expression(expr) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 6a3008bd..68fd5b45 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -1063,7 +1063,7 @@ def test_partition_definition_with_comment(self): mixin.version = (8, 0, 0) from unittest.mock import MagicMock mixin.format_identifier = MagicMock(side_effect=lambda x: f"`{x}`") - mixin._escape_sql_string = MagicMock(side_effect=lambda x: x.replace("'", "\\'")) + mixin.escape_sql_string = MagicMock(side_effect=lambda x: x.replace("'", "\\'")) from rhosocial.activerecord.backend.impl.mysql.expression.partition import MySQLPartitionDefinition defn = MySQLPartitionDefinition( diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py index 447aeb7d..32044d11 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py @@ -334,6 +334,14 @@ def test_no_interface_overlap_between_protocols(self): assert len(members) > 0, f"Protocol {name} has no members defined" excluded_overlaps = { + + # The CTAS bind-parameter probe: the backend's table protocol + + # derives from the generic CTAS protocol, so both declare it. + + ('CreateTableAsSupport', 'MySQLTableSupport'), + + ('MySQLTableSupport', 'CreateTableAsSupport'), # Each pair below is a MySQL-specific protocol deliberately # restating part of a generic one, which is the whole point of a # backend protocol: MySQL's spelling and capability probes differ, @@ -648,3 +656,43 @@ def test_mixin_public_methods_are_declared_in_protocol(self, protocol, mixin): assert not undeclared, ( f"{mixin.__name__} implements these methods not declared in {protocol.__name__}: {undeclared}" ) + + +class TestTheBackendOwnAnswersAreTheEffectiveOnes: + """A backend mixin's method must resolve to a backend class. + + The other checks in this file are about the *declaration*: the mixin + declares the method, and the protocol declares it too. This one is + about the *resolution*. A core class that wins the MRO makes the + backend's implementation dead code, and the answer a caller gets is + core's rather than the backend's -- so the backend never says what it + means about itself, and nothing else in the suite notices: the + declaration is there either way. + + A protocol class is a declaration rather than an implementation (its + body is ``...``), so a core class may legitimately precede it; only + the mixins are held to this. + """ + + def test_no_backend_method_is_shadowed_by_a_core_class(self): + mro = type(mysql_dialect.MySQLDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.mysql" + shadowed = [] + for klass in mro: + if not klass.__module__.startswith(own): + continue + if not klass.__name__.endswith("Mixin"): + continue + for name in vars(klass): + if not name.startswith(("format_", "supports_", "get_")): + continue + if not callable(vars(klass)[name]): + continue + winner = next((k for k in mro if name in k.__dict__), None) + if winner is None or winner.__module__.startswith(own): + continue + shadowed.append(f"{klass.__name__}.{name} -> {winner.__name__}") + assert not shadowed, ( + "a core class wins these backend methods, so the backend's " + f"implementation is dead code: {shadowed}" + ) From 9a6606cee4baa8d90bd6f8b23aa559941fd9eac1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 22 Oct 2026 23:32:48 +0800 Subject: [PATCH 42/65] refactor(expression)!: follow core's statement rename Core renamed the four statement-shaped nodes it still called *Expression: TransactionExpression -> TransactionStatement SetTransactionExpression -> SetTransactionStatement SQLiteAttachExpression -> SQLiteAttachStatement SQLiteDetachExpression -> SQLiteDetachStatement This repository refers to them in its dialect signatures and its tests, so the same names follow here. Nothing changes but the names. --- .../backend/impl/mysql/mixins/transaction.py | 2 +- .../test_expressions_transaction.py | 22 +++++++++---------- .../backend/test_mysql_clause_pair_guard.py | 18 +++++++-------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py index 487d8d60..b61277bb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py @@ -114,7 +114,7 @@ def supports_savepoint(self) -> bool: """MySQL supports savepoints.""" return True - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION statement for MySQL.""" from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py index 8a04130b..fa0ba84d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py @@ -5,7 +5,7 @@ - Isolation level must be set BEFORE START TRANSACTION using SET TRANSACTION - START TRANSACTION can include READ ONLY / READ WRITE modes - The dialect's format_begin_transaction_statement() only returns START TRANSACTION -- SetTransactionExpression is used for isolation level settings +- SetTransactionStatement is used for isolation level settings """ import pytest @@ -16,7 +16,7 @@ RollbackStatement, SavepointStatement, ReleaseSavepointStatement, - SetTransactionExpression, + SetTransactionStatement, ) from rhosocial.activerecord.backend.transaction import IsolationLevel @@ -26,7 +26,7 @@ class TestMySQLBeginTransactionExpression: Note: MySQL does not support isolation level in START TRANSACTION. The dialect's supports_isolation_level_in_begin() returns False. - Use SetTransactionExpression to set isolation level before START TRANSACTION. + Use SetTransactionStatement to set isolation level before START TRANSACTION. """ def test_basic_begin(self, mysql_dialect): @@ -72,7 +72,7 @@ def test_begin_with_isolation_and_read_only(self, mysql_dialect): """Test START TRANSACTION with isolation level and READ ONLY. The isolation level is ignored by the dialect in START TRANSACTION. - Use SetTransactionExpression for isolation level, then BeginTransactionStatement + Use SetTransactionStatement for isolation level, then BeginTransactionStatement for READ ONLY mode. """ expr = BeginTransactionStatement(mysql_dialect) @@ -105,7 +105,7 @@ def test_begin_with_isolation_returns_start_transaction(self, mysql_dialect, lev class TestMySQLSetTransactionExpression: - """Tests for MySQL SetTransactionExpression. + """Tests for MySQL SetTransactionStatement. MySQL uses SET TRANSACTION ISOLATION LEVEL before START TRANSACTION to set the isolation level for the next transaction. @@ -113,7 +113,7 @@ class TestMySQLSetTransactionExpression: def test_set_isolation_level(self, mysql_dialect): """Test SET TRANSACTION ISOLATION LEVEL.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert sql == "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" @@ -130,7 +130,7 @@ def test_set_isolation_level(self, mysql_dialect): ) def test_all_isolation_levels(self, mysql_dialect, level, expected_name): """Test all isolation levels in SET TRANSACTION.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.with_isolation_level(level) sql, params = expr.to_sql() assert expected_name in sql @@ -138,7 +138,7 @@ def test_all_isolation_levels(self, mysql_dialect, level, expected_name): def test_set_read_only(self, mysql_dialect): """Test SET TRANSACTION READ ONLY.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.read_only() sql, params = expr.to_sql() assert sql == "SET TRANSACTION READ ONLY" @@ -146,7 +146,7 @@ def test_set_read_only(self, mysql_dialect): def test_set_read_write(self, mysql_dialect): """Test SET TRANSACTION READ WRITE.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.read_write() sql, params = expr.to_sql() assert sql == "SET TRANSACTION READ WRITE" @@ -249,7 +249,7 @@ def test_begin_refuses_wait_by_name(self, mysql_dialect, spelling): @pytest.mark.parametrize("spelling", ["wait", "no_wait"]) def test_set_refuses_wait_by_name(self, mysql_dialect, spelling): - expr = SetTransactionExpression(mysql_dialect, **{spelling: True}) + expr = SetTransactionStatement(mysql_dialect, **{spelling: True}) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() expected = "NO WAIT" if spelling == "no_wait" else "WAIT" @@ -260,6 +260,6 @@ def test_set_refuses_wait_by_name(self, mysql_dialect, spelling): def test_neither_spelling_renders_neither_token(self, mysql_dialect): begin, _ = BeginTransactionStatement(mysql_dialect).to_sql() - set_tx, _ = SetTransactionExpression(mysql_dialect).to_sql() + set_tx, _ = SetTransactionStatement(mysql_dialect).to_sql() assert begin == "START TRANSACTION" assert set_tx == "SET TRANSACTION" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py index e2ca8508..d212e848 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py @@ -52,7 +52,7 @@ from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.transaction import ( BeginTransactionStatement, - SetTransactionExpression, + SetTransactionStatement, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( @@ -202,8 +202,8 @@ def sql(self, dialect, **kwargs): b_error=r"NOT DEFERRABLE\b", ), PairCase( - "SetTransactionExpression.deferrable", - lambda d, **kw: SetTransactionExpression(d, **kw), + "SetTransactionStatement.deferrable", + lambda d, **kw: SetTransactionStatement(d, **kw), "deferrable", "not_deferrable", a_error=r"(? Date: Thu, 22 Oct 2026 23:36:27 +0800 Subject: [PATCH 43/65] refactor(show)!: SHOW CREATE TABLE is a statement ``ShowCreateTableExpression`` renders a whole ``SHOW CREATE TABLE`` statement, so it is a ``*Statement`` now. The dialect entry point keeps its name: the SHOW family's twenty formatters all end in ``_expression``, and renaming one of them alone would make it the odd one out. Nothing changes but the class name: no behaviour, no rendering, no signature. --- .../activerecord/backend/impl/mysql/expression/show.py | 2 +- .../backend/impl/mysql/introspection/show_introspector.py | 6 +++--- .../activerecord/backend/impl/mysql/show/__init__.py | 4 ++-- .../activerecord/backend/impl/mysql/show/dialect.py | 4 ++-- .../activerecord/backend/impl/mysql/show/functionality.py | 6 +++--- .../backend/expression/test_mysql_show_statements.py | 8 ++++---- .../feature/backend/test_mysql_partition_operations.py | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 92283684..5195be00 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -54,7 +54,7 @@ def __init__(self, dialect: "MySQLDialect", *, schema: Optional[str] = None): # overriding the `format_method` property (inherited from BaseExpression). -class ShowCreateTableExpression(ShowExpression): +class ShowCreateTableStatement(ShowExpression): """Expression for SHOW CREATE TABLE command.""" def __init__( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index 34089b0c..72466667 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -18,7 +18,7 @@ AsyncIntrospectorExecutor, ) from ..expression.show import ( - ShowCreateTableExpression, + ShowCreateTableStatement, ShowCreateViewExpression, ShowColumnsExpression, ShowIndexExpression, @@ -345,7 +345,7 @@ def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_table(self._exec(sql, params), table_name) @@ -498,7 +498,7 @@ async def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: async def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_table(await self._exec(sql, params), table_name) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py index 65e77750..676adc89 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py @@ -19,7 +19,7 @@ from ..expression.show import ( ShowExpression, - ShowCreateTableExpression, + ShowCreateTableStatement, ShowCreateViewExpression, ShowColumnsExpression, ShowIndexExpression, @@ -49,7 +49,7 @@ __all__ = [ # Expression classes "ShowExpression", - "ShowCreateTableExpression", + "ShowCreateTableStatement", "ShowCreateViewExpression", "ShowColumnsExpression", "ShowIndexExpression", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py index 0d1e48e3..c0a39983 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from ..expression.show import ( - ShowCreateTableExpression, + ShowCreateTableStatement, ShowCreateViewExpression, ShowColumnsExpression, ShowIndexExpression, @@ -82,7 +82,7 @@ def _format_show_target(self, target: Any, schema: Any, kind: type) -> str: # ========== SHOW CREATE Statements ========== - def format_show_create_table_expression(self, expr: "ShowCreateTableExpression") -> Tuple[str, tuple]: + def format_show_create_table_expression(self, expr: "ShowCreateTableStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TABLE statement.""" params = expr.get_params() table_name = params["table_name"] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py index bd47a69b..8ed4cddd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py @@ -16,7 +16,7 @@ from typing import Optional, Tuple, TYPE_CHECKING from ..expression.show import ( - ShowCreateTableExpression, + ShowCreateTableStatement, ShowCreateViewExpression, ShowColumnsExpression, ShowIndexExpression, @@ -382,7 +382,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): ShowCreateTableResult with table name and CREATE statement, or None if table doesn't exist. """ - expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_table_result(result, table_name) @@ -716,7 +716,7 @@ def __init__(self, backend: "AsyncMySQLBackend", version: Optional[Tuple[int, .. async def create_table(self, table_name: str, schema: Optional[str] = None): """Async version of create_table.""" - expr = ShowCreateTableExpression(self.dialect, table_name, schema=schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_table_result(result, table_name) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py index 2b8a30aa..83aae31b 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py @@ -26,7 +26,7 @@ class forgot to store under its own name fails there rather than surviving as ShowCharsetExpression, ShowCollationExpression, ShowColumnsExpression, - ShowCreateTableExpression, + ShowCreateTableStatement, ShowCreateTriggerExpression, ShowCreateViewExpression, ShowDatabasesExpression, @@ -67,7 +67,7 @@ def test_no_fluent_setter_survives(self): @pytest.mark.parametrize( "build", [ - lambda d: ShowCreateTableExpression(d, "t"), + lambda d: ShowCreateTableStatement(d, "t"), lambda d: ShowCreateViewExpression(d, "v"), lambda d: ShowColumnsExpression(d, "t", full=True, like_pattern="id%"), lambda d: ShowIndexExpression(d, "t"), @@ -103,12 +103,12 @@ class TestShowCreate: """``SHOW CREATE ...`` names one object, database included.""" def test_unqualified_table(self, dialect): - sql, params = ShowCreateTableExpression(dialect, "users").to_sql() + sql, params = ShowCreateTableStatement(dialect, "users").to_sql() assert sql == "SHOW CREATE TABLE `users`" assert params == () def test_database_qualifies_the_table(self, dialect): - sql, _ = ShowCreateTableExpression(dialect, "users", schema="app").to_sql() + sql, _ = ShowCreateTableStatement(dialect, "users", schema="app").to_sql() assert sql == "SHOW CREATE TABLE `app`.`users`" def test_view(self, dialect): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index b11f3019..8a38c8a0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -30,7 +30,7 @@ ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect -from rhosocial.activerecord.backend.impl.mysql.show import ShowCreateTableExpression +from rhosocial.activerecord.backend.impl.mysql.show import ShowCreateTableStatement from rhosocial.activerecord.backend.impl.mysql.expression import ( MySQLAddPartitionExpression, MySQLCoalescePartitionExpression, @@ -264,12 +264,12 @@ def _assert_base_partition_metadata(rows): def _show_create_table_sql(backend): - row = backend.fetch_one(*ShowCreateTableExpression(backend.dialect, PARTITION_TABLE).to_sql()) + row = backend.fetch_one(*ShowCreateTableStatement(backend.dialect, PARTITION_TABLE).to_sql()) return row.get("Create Table") or row.get("create table") async def _async_show_create_table_sql(backend): - row = await backend.fetch_one(*ShowCreateTableExpression(backend.dialect, PARTITION_TABLE).to_sql()) + row = await backend.fetch_one(*ShowCreateTableStatement(backend.dialect, PARTITION_TABLE).to_sql()) return row.get("Create Table") or row.get("create table") From 7e4a61b5c7673533076bb6518e26b051f3d275fd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 00:06:22 +0800 Subject: [PATCH 44/65] fix(test): drop the stale _NOT_RENDERED row for ColumnGeneratedConstraint The row said the class raises ValueError because the generic constructor supplies a plain string for the generated expression. Two things moved under it: the constructor now guesses a node, and the render-time refusal of an unrenderable slot was removed with the rest of the render-time refusals, so a string slot now surfaces as AttributeError from the node call itself rather than as a ValueError from the constraint. With a node the class renders, which is the bucket the matrix reports. --- .../feature/backend/test_expression_roundtrip_all.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index e583d206..59447646 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -1041,13 +1041,6 @@ def partition_maintenance(d, cls_name): "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_table.ColumnGeneratedConstraint": ( - "ValueError", - "The generic constructor supplies a plain string for the generated " - "expression, and the constraint refuses to render an expression slot " - "that cannot render. The class round-trips its enum through no " - "artifact either -- see _ROUNDTRIP_DEFECT.", - ), "statements.ddl_trigger.CreateTriggerStatement": ( "AttributeError", "timing must be a TriggerTiming member; the generic constructor " From 84d1deff255992f892006f1e6062bc712fb2f3e0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 18:06:10 +0800 Subject: [PATCH 45/65] refactor(expression)!: name statement nodes, kinds and formatters alike The MySQL statement nodes were named ``*Expression`` while their formatters already ended in ``_statement``, so the class name was the one place disagreeing. Fifty-nine nodes that render a whole statement -- FLUSH, RESET, HANDLER, the user and privilege statements, LOAD DATA, the table-maintenance statements, CREATE/DROP FUNCTION and PROCEDURE, CALL, and the SHOW family -- are now ``*Statement`` with a matching ``format_mysql_*_statement``. Kept as ``*Expression`` on purpose: the JSON, MATCH AGAINST, optimizer hint, SET literal, spatial-ST, vector and DUALITY VIEW body nodes are fragments that render inside a statement, and so are the ``ALTER TABLE ... PARTITION`` clauses. ``MySQLSetLiteralExpression`` carries a value, not a statement. ``Show*`` nodes carry no backend prefix in their kind, so their formatters are ``format_show_*_statement`` rather than ``format_mysql_show_*_statement``: the capability is named after the capability. --- docs/zh_CN/mysql_specific_features/dialect.md | 12 +- .../backend/impl/mysql/expression/__init__.py | 152 ++++++++--------- .../backend/impl/mysql/expression/admin.py | 88 +++++----- .../mysql/expression/json_duality_view.py | 8 +- .../impl/mysql/expression/load_data.py | 10 +- .../impl/mysql/expression/maintenance.py | 20 +-- .../impl/mysql/expression/rename_table.py | 4 +- .../backend/impl/mysql/expression/routine.py | 20 +-- .../backend/impl/mysql/expression/show.py | 86 +++++----- .../backend/impl/mysql/expression/spatial.py | 6 +- .../backend/impl/mysql/expression/vector.py | 6 +- .../mysql/introspection/show_introspector.py | 108 ++++++------ .../introspection/status_introspector.py | 24 +-- .../backend/impl/mysql/mixins/admin.py | 108 ++++++------ .../backend/impl/mysql/mixins/concurrency.py | 8 +- .../impl/mysql/mixins/ddl_rename_table.py | 10 +- .../backend/impl/mysql/mixins/ddl_table.py | 4 +- .../backend/impl/mysql/mixins/dml.py | 6 +- .../impl/mysql/mixins/introspection.py | 32 ++-- .../impl/mysql/mixins/json_duality_view.py | 8 +- .../backend/impl/mysql/mixins/maintenance.py | 30 ++-- .../backend/impl/mysql/mixins/routine.py | 44 ++--- .../backend/impl/mysql/mixins/spatial.py | 10 +- .../backend/impl/mysql/mixins/transaction.py | 2 +- .../backend/impl/mysql/mixins/vector.py | 10 +- .../backend/impl/mysql/protocols.py | 156 +++++++++--------- .../backend/impl/mysql/show/__init__.py | 84 +++++----- .../backend/impl/mysql/show/dialect.py | 90 +++++----- .../backend/impl/mysql/show/functionality.py | 108 ++++++------ tests/providers/fixtures/__init__.py | 2 +- tests/providers/fixtures/_common.py | 2 +- .../feature/backend/dml/test_load_data.py | 30 ++-- .../test_mysql_introspection_queries.py | 44 ++--- .../expression/test_mysql_show_statements.py | 144 ++++++++-------- .../backend/expression_roundtrip_harness.py | 18 +- .../mysql/test_json_duality_view_execution.py | 8 +- .../backend/test_admin_statement_coverage.py | 122 +++++++------- .../backend/test_expression_roundtrip_all.py | 88 +++++----- .../test_json_duality_view_expressions.py | 22 +-- .../backend/test_mysql_ddl_coverage.py | 42 ++--- .../backend/test_mysql_dialect_security.py | 10 +- .../backend/test_mysql_mixin_expressions.py | 42 ++--- .../test_mysql_protocol_conformance.py | 46 +++--- .../feature/backend/test_spatial_types.py | 8 +- .../backend/test_spatial_types_backend.py | 10 +- 45 files changed, 947 insertions(+), 945 deletions(-) diff --git a/docs/zh_CN/mysql_specific_features/dialect.md b/docs/zh_CN/mysql_specific_features/dialect.md index 839a3e62..b4fe786f 100644 --- a/docs/zh_CN/mysql_specific_features/dialect.md +++ b/docs/zh_CN/mysql_specific_features/dialect.md @@ -207,7 +207,7 @@ MySQL 方言支持标准化的日期时间间隔表达式: ```python from rhosocial.activerecord.backend.expression.datetime import ( ExtractExpression, DateTimeField, IntervalExpression, IntervalUnit, - DateTimeAddExpression, DateTimeSubtractExpression, DateTimeDiffExpression, + DatetimeAddExpression, DatetimeSubtractExpression, DatetimeDiffExpression, ) # EXTRACT @@ -219,17 +219,17 @@ interval = IntervalExpression(dialect, 7, IntervalUnit.DAY) # 生成: INTERVAL 7 DAY # 日期加法 -expr = DateTimeAddExpression(dialect, Column(dialect, "created_at"), +expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 30, IntervalUnit.DAY)) # 生成: DATE_ADD(`created_at`, INTERVAL 30 DAY) # 日期减法 -expr = DateTimeSubtractExpression(dialect, Column(dialect, "created_at"), +expr = DatetimeSubtractExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 7, IntervalUnit.DAY)) # 生成: DATE_SUB(`created_at`, INTERVAL 7 DAY) # 日期差 -expr = DateTimeDiffExpression(dialect, DateTimeField.DAY, +expr = DatetimeDiffExpression(dialect, DateTimeField.DAY, Column(dialect, "start"), Column(dialect, "end")) # 生成: TIMESTAMPDIFF(DAY, `start`, `end`) ``` @@ -264,12 +264,12 @@ MySQL 9.7+ 支持 JSON 二象性视图(JSON Duality View): ```python from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, DropJsonDualityViewStatement, DualityObjectSpec, DualityDMLTag, DualityColumnMapping, ) # 创建 JSON 二象性视图 -create = CreateJsonDualityViewExpression( +create = CreateJsonDualityViewStatement( dialect, view_name="user_details", root_spec=DualityObjectSpec( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py index 0790ad25..c5614b84 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py @@ -28,7 +28,7 @@ MySQLColumnDefinition, MySQLColumnOptions, ) -from .load_data import MySQLLoadDataExpression, LoadDataOptions +from .load_data import MySQLLoadDataStatement, LoadDataOptions from .json_table import MySQLJSONTableExpression, JSONTableColumn, NestedPath from .json import ( MySQLJSONExtractExpression, @@ -51,7 +51,7 @@ MySQLSTGeomFromWKBExpression, MySQLSTAsTextExpression, MySQLSTAsGeoJSONExpression, - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, ) from .vector import ( MySQLVectorExpression, @@ -61,7 +61,7 @@ MySQLStringToVectorExpression, MySQLVectorToStringExpression, MySQLVectorDimExpression, - MySQLCreateVectorIndexExpression, + MySQLCreateVectorIndexStatement, ) from .set_type import ( MySQLSetLiteralExpression, @@ -72,8 +72,8 @@ from .table_expr import MySQLInlineIndexExpression, MySQLStorageOptionsExpression from .match_against import MySQLMatchAgainstExpression, MatchAgainstMode from .json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityNestedMapping, @@ -125,56 +125,56 @@ MySQLDropOldestPartitionHelper, MySQLReorganizePartitionHelper, ) -from .rename_table import MySQLRenameTableExpression +from .rename_table import MySQLRenameTableStatement from .table_statement import MySQLTableStatement, MySQLValuesExpression from .maintenance import ( - MySQLAnalyzeTableExpression, - MySQLCheckTableExpression, - MySQLChecksumTableExpression, - MySQLOptimizeTableExpression, - MySQLRepairTableExpression, + MySQLAnalyzeTableStatement, + MySQLCheckTableStatement, + MySQLChecksumTableStatement, + MySQLOptimizeTableStatement, + MySQLRepairTableStatement, CheckTableOption, ChecksumTableOption, RepairTableOption, NoWriteToBinlogOption, ) from .routine import ( - MySQLCallExpression, - MySQLCreateProcedureExpression, - MySQLDropProcedureExpression, - MySQLCreateFunctionExpression, - MySQLDropFunctionExpression, + MySQLCallStatement, + MySQLCreateProcedureStatement, + MySQLDropProcedureStatement, + MySQLCreateFunctionStatement, + MySQLDropFunctionStatement, ) from .load_xml import MySQLLoadXMLStatement, LoadXMLPriority, LoadXMLConflictMode from .admin import ( - MySQLCacheIndexExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLFlushExpression, + MySQLCacheIndexStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLFlushStatement, FlushOption, - MySQLResetExpression, + MySQLResetStatement, ResetOption, - MySQLInstallComponentExpression, - MySQLUninstallComponentExpression, - MySQLInstallPluginExpression, - MySQLUninstallPluginExpression, - MySQLCloneExpression, - MySQLRestartExpression, - MySQLBinlogExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLHandlerCloseExpression, + MySQLInstallComponentStatement, + MySQLUninstallComponentStatement, + MySQLInstallPluginStatement, + MySQLUninstallPluginStatement, + MySQLCloneStatement, + MySQLRestartStatement, + MySQLBinlogStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLHandlerCloseStatement, HandlerReadMode, - MySQLDoExpression, - MySQLKillExpression, + MySQLDoStatement, + MySQLKillStatement, KillTarget, - MySQLShutdownExpression, - MySQLHelpExpression, + MySQLShutdownStatement, + MySQLHelpStatement, AccountSpec, GrantPrivilege, - MySQLCreateUserExpression, - MySQLDropUserExpression, - MySQLGrantExpression, - MySQLRevokeExpression, + MySQLCreateUserStatement, + MySQLDropUserStatement, + MySQLGrantStatement, + MySQLRevokeStatement, ) # DataType subclasses for DDL @@ -210,7 +210,7 @@ ) __all__ = [ - "MySQLLoadDataExpression", + "MySQLLoadDataStatement", "LoadDataOptions", "MySQLCreateTableOptions", "MySQLRowFormat", @@ -241,7 +241,7 @@ "MySQLSTGeomFromWKBExpression", "MySQLSTAsTextExpression", "MySQLSTAsGeoJSONExpression", - "MySQLCreateSpatialIndexExpression", + "MySQLCreateSpatialIndexStatement", "MySQLVectorExpression", "MySQLDistanceEuclideanExpression", "MySQLDistanceCosineExpression", @@ -249,7 +249,7 @@ "MySQLStringToVectorExpression", "MySQLVectorToStringExpression", "MySQLVectorDimExpression", - "MySQLCreateVectorIndexExpression", + "MySQLCreateVectorIndexStatement", "MySQLSetLiteralExpression", "MySQLFindInSetExpression", "MySQLSetContainsExpression", @@ -258,8 +258,8 @@ "MySQLStorageOptionsExpression", "MySQLMatchAgainstExpression", "MatchAgainstMode", - "CreateJsonDualityViewExpression", - "DropJsonDualityViewExpression", + "CreateJsonDualityViewStatement", + "DropJsonDualityViewStatement", "DualityObjectSpec", "DualityColumnMapping", "DualityNestedMapping", @@ -298,54 +298,54 @@ "MySQLSubpartitionStrategy", "MySQLSubpartitionDefinition", "MySQLSubpartitionClause", - "MySQLRenameTableExpression", + "MySQLRenameTableStatement", "MySQLTableStatement", "MySQLValuesExpression", - "MySQLAnalyzeTableExpression", - "MySQLCheckTableExpression", - "MySQLChecksumTableExpression", - "MySQLOptimizeTableExpression", - "MySQLRepairTableExpression", + "MySQLAnalyzeTableStatement", + "MySQLCheckTableStatement", + "MySQLChecksumTableStatement", + "MySQLOptimizeTableStatement", + "MySQLRepairTableStatement", "CheckTableOption", "ChecksumTableOption", "RepairTableOption", "NoWriteToBinlogOption", - "MySQLCallExpression", - "MySQLCreateProcedureExpression", - "MySQLDropProcedureExpression", - "MySQLCreateFunctionExpression", - "MySQLDropFunctionExpression", + "MySQLCallStatement", + "MySQLCreateProcedureStatement", + "MySQLDropProcedureStatement", + "MySQLCreateFunctionStatement", + "MySQLDropFunctionStatement", "MySQLLoadXMLStatement", "LoadXMLPriority", "LoadXMLConflictMode", - "MySQLCacheIndexExpression", - "MySQLLoadIndexIntoCacheExpression", - "MySQLFlushExpression", + "MySQLCacheIndexStatement", + "MySQLLoadIndexIntoCacheStatement", + "MySQLFlushStatement", "FlushOption", - "MySQLResetExpression", + "MySQLResetStatement", "ResetOption", - "MySQLInstallComponentExpression", - "MySQLUninstallComponentExpression", - "MySQLInstallPluginExpression", - "MySQLUninstallPluginExpression", - "MySQLCloneExpression", - "MySQLRestartExpression", - "MySQLBinlogExpression", - "MySQLHandlerOpenExpression", - "MySQLHandlerReadExpression", - "MySQLHandlerCloseExpression", + "MySQLInstallComponentStatement", + "MySQLUninstallComponentStatement", + "MySQLInstallPluginStatement", + "MySQLUninstallPluginStatement", + "MySQLCloneStatement", + "MySQLRestartStatement", + "MySQLBinlogStatement", + "MySQLHandlerOpenStatement", + "MySQLHandlerReadStatement", + "MySQLHandlerCloseStatement", "HandlerReadMode", - "MySQLDoExpression", - "MySQLKillExpression", + "MySQLDoStatement", + "MySQLKillStatement", "KillTarget", - "MySQLShutdownExpression", - "MySQLHelpExpression", + "MySQLShutdownStatement", + "MySQLHelpStatement", "AccountSpec", "GrantPrivilege", - "MySQLCreateUserExpression", - "MySQLDropUserExpression", - "MySQLGrantExpression", - "MySQLRevokeExpression", + "MySQLCreateUserStatement", + "MySQLDropUserStatement", + "MySQLGrantStatement", + "MySQLRevokeStatement", # DataType subclasses for DDL "MySQLBigIntType", "MySQLBinaryType", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py index a73563b0..4210b8b2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py @@ -90,7 +90,7 @@ class HandlerReadMode(str, Enum): LAST = "LAST" -class MySQLFlushExpression(BaseExpression): +class MySQLFlushStatement(BaseExpression): """Represent ``FLUSH`` with one or more options.""" def __init__( @@ -116,11 +116,11 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_flush_expression" + return "format_mysql_flush_statement" -class MySQLResetExpression(BaseExpression): +class MySQLResetStatement(BaseExpression): """Represent ``RESET`` with one option.""" def __init__( @@ -137,11 +137,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_reset_expression" + return "format_mysql_reset_statement" -class MySQLCacheIndexExpression(BaseExpression): +class MySQLCacheIndexStatement(BaseExpression): """Represent ``CACHE INDEX ... IN key_cache``.""" def __init__( @@ -167,11 +167,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_cache_index_expression" + return "format_mysql_cache_index_statement" -class MySQLLoadIndexIntoCacheExpression(BaseExpression): +class MySQLLoadIndexIntoCacheStatement(BaseExpression): """Represent ``LOAD INDEX INTO CACHE``.""" def __init__( @@ -194,11 +194,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_load_index_into_cache_expression" + return "format_mysql_load_index_into_cache_statement" -class MySQLInstallComponentExpression(BaseExpression): +class MySQLInstallComponentStatement(BaseExpression): """Represent ``INSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -212,11 +212,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_install_component_expression" + return "format_mysql_install_component_statement" -class MySQLUninstallComponentExpression(BaseExpression): +class MySQLUninstallComponentStatement(BaseExpression): """Represent ``UNINSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -230,11 +230,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_uninstall_component_expression" + return "format_mysql_uninstall_component_statement" -class MySQLInstallPluginExpression(BaseExpression): +class MySQLInstallPluginStatement(BaseExpression): """Represent ``INSTALL PLUGIN name SONAME 'library.so'``.""" def __init__( @@ -250,11 +250,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_install_plugin_expression" + return "format_mysql_install_plugin_statement" -class MySQLUninstallPluginExpression(BaseExpression): +class MySQLUninstallPluginStatement(BaseExpression): """Represent ``UNINSTALL PLUGIN name``.""" def __init__( @@ -268,11 +268,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_uninstall_plugin_expression" + return "format_mysql_uninstall_plugin_statement" -class MySQLCloneExpression(BaseExpression): +class MySQLCloneStatement(BaseExpression): """Represent ``CLONE INSTANCE FROM ...``. Attributes: @@ -304,11 +304,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_clone_expression" + return "format_mysql_clone_statement" -class MySQLRestartExpression(BaseExpression): +class MySQLRestartStatement(BaseExpression): """Represent the ``RESTART`` server statement.""" def __init__( @@ -320,11 +320,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_restart_expression" + return "format_mysql_restart_statement" -class MySQLBinlogExpression(BaseExpression): +class MySQLBinlogStatement(BaseExpression): """Represent ``BINLOG 'base64_encoded_event'``.""" def __init__( @@ -338,11 +338,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_binlog_expression" + return "format_mysql_binlog_statement" -class MySQLHandlerOpenExpression(BaseExpression): +class MySQLHandlerOpenStatement(BaseExpression): """Represent ``HANDLER table OPEN [AS alias]``. ``table`` is a @@ -363,11 +363,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_handler_open_expression" + return "format_mysql_handler_open_statement" -class MySQLHandlerReadExpression(BaseExpression): +class MySQLHandlerReadStatement(BaseExpression): """Represent ``HANDLER table READ {FIRST | NEXT}`` and variants. ``table`` is a @@ -396,11 +396,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_handler_read_expression" + return "format_mysql_handler_read_statement" -class MySQLHandlerCloseExpression(BaseExpression): +class MySQLHandlerCloseStatement(BaseExpression): """Represent ``HANDLER table CLOSE``. ``table`` is a @@ -418,11 +418,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_handler_close_expression" + return "format_mysql_handler_close_statement" -class MySQLDoExpression(BaseExpression): +class MySQLDoStatement(BaseExpression): """Represent ``DO expr [, expr ...]``.""" def __init__( @@ -436,11 +436,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_do_expression" + return "format_mysql_do_statement" -class MySQLKillExpression(BaseExpression): +class MySQLKillStatement(BaseExpression): """Represent ``KILL [CONNECTION | QUERY] processlist_id``.""" def __init__( @@ -460,11 +460,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_kill_expression" + return "format_mysql_kill_statement" -class MySQLShutdownExpression(BaseExpression): +class MySQLShutdownStatement(BaseExpression): """Represent the ``SHUTDOWN`` statement.""" def __init__( @@ -476,11 +476,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_shutdown_expression" + return "format_mysql_shutdown_statement" -class MySQLHelpExpression(BaseExpression): +class MySQLHelpStatement(BaseExpression): """Represent ``HELP 'topic'``.""" def __init__( @@ -494,7 +494,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_help_expression" + return "format_mysql_help_statement" @@ -509,7 +509,7 @@ def __repr__(self) -> str: # pragma: no cover return f"AccountSpec(user={self.user!r}, host={self.host!r})" -class MySQLCreateUserExpression(BaseExpression): +class MySQLCreateUserStatement(BaseExpression): """Represent ``CREATE USER [IF NOT EXISTS] 'user'@'host' [IDENTIFIED BY ...]``.""" def __init__( @@ -528,11 +528,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_create_user_expression" + return "format_mysql_create_user_statement" -class MySQLDropUserExpression(BaseExpression): +class MySQLDropUserStatement(BaseExpression): """Represent ``DROP USER [IF EXISTS] 'user'@'host' [, ...]``.""" def __init__( @@ -549,7 +549,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_drop_user_expression" + return "format_mysql_drop_user_statement" @@ -569,7 +569,7 @@ def __repr__(self) -> str: # pragma: no cover return f"GrantPrivilege(privilege={self.privilege!r}, columns={self.columns!r})" -class MySQLGrantExpression(BaseExpression): +class MySQLGrantStatement(BaseExpression): """Represent ``GRANT priv_list ON object TO account_list``.""" def __init__( @@ -590,11 +590,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_grant_expression" + return "format_mysql_grant_statement" -class MySQLRevokeExpression(BaseExpression): +class MySQLRevokeStatement(BaseExpression): """Represent ``REVOKE priv_list ON object FROM account_list``.""" def __init__( @@ -613,4 +613,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_revoke_expression" + return "format_mysql_revoke_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py index c8bf4eae..d5041416 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py @@ -80,7 +80,7 @@ class DualityObjectSpec: join_condition: Optional[str] = None -class CreateJsonDualityViewExpression(BaseExpression): +class CreateJsonDualityViewStatement(BaseExpression): """CREATE JSON RELATIONAL DUALITY VIEW expression (MySQL 9.7+). Attributes: @@ -104,11 +104,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_json_duality_view_expression" + return "format_create_json_duality_view_statement" -class DropJsonDualityViewExpression(BaseExpression): +class DropJsonDualityViewStatement(BaseExpression): """DROP VIEW expression for JSON Duality Views. Uses standard DROP VIEW syntax since MySQL has no special DROP for duality views. @@ -127,7 +127,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_json_duality_view_expression" + return "format_drop_json_duality_view_statement" class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValue): diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py index 5a217690..dd8573cf 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py @@ -2,7 +2,7 @@ """ MySQL-specific LOAD DATA expression. -This module provides MySQLLoadDataExpression and LoadDataOptions for +This module provides MySQLLoadDataStatement and LoadDataOptions for MySQL's LOAD DATA INFILE functionality. """ @@ -19,7 +19,7 @@ class LoadDataOptions(BaseExpression): """Options for LOAD DATA INFILE statement, as a node. A clause node rather than a plain dataclass: ``options`` is a - constructor parameter of :class:`MySQLLoadDataExpression`, and a + constructor parameter of :class:`MySQLLoadDataStatement`, and a parameter is either a language-neutral scalar or an expression instance (§10) -- those are the two forms an artifact can carry, and a dataclass is neither. Being a node is also what makes the bundle @@ -90,11 +90,11 @@ def format_method(self) -> str: return "format_load_data_options" -class MySQLLoadDataExpression(BaseExpression): +class MySQLLoadDataStatement(BaseExpression): """MySQL LOAD DATA INFILE expression. Collects parameters for LOAD DATA [LOCAL] INFILE statement. - The actual SQL formatting is done by MySQLDialect.format_mysql_load_data_expression(). + The actual SQL formatting is done by MySQLDialect.format_mysql_load_data_statement(). Attributes: file_path: Path to the data file @@ -154,4 +154,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_load_data_expression" + return "format_mysql_load_data_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py index 8544b706..725ec077 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py @@ -128,7 +128,7 @@ def validate(self, strict: bool = True) -> None: ) -class MySQLAnalyzeTableExpression(MySQLTableMaintenanceExpression): +class MySQLAnalyzeTableStatement(MySQLTableMaintenanceExpression): """Represent ``ANALYZE TABLE``.""" operation: str = "ANALYZE" @@ -136,10 +136,10 @@ class MySQLAnalyzeTableExpression(MySQLTableMaintenanceExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_analyze_table_expression" + return "format_mysql_analyze_table_statement" -class MySQLCheckTableExpression(MySQLTableMaintenanceExpression): +class MySQLCheckTableStatement(MySQLTableMaintenanceExpression): """Represent ``CHECK TABLE`` with optional modes.""" operation: str = "CHECK" @@ -161,11 +161,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_check_table_expression" + return "format_mysql_check_table_statement" -class MySQLChecksumTableExpression(MySQLTableMaintenanceExpression): +class MySQLChecksumTableStatement(MySQLTableMaintenanceExpression): """Represent ``CHECKSUM TABLE`` with optional mode.""" operation: str = "CHECKSUM" @@ -189,11 +189,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_checksum_table_expression" + return "format_mysql_checksum_table_statement" -class MySQLOptimizeTableExpression(MySQLTableMaintenanceExpression): +class MySQLOptimizeTableStatement(MySQLTableMaintenanceExpression): """Represent ``OPTIMIZE TABLE``.""" operation: str = "OPTIMIZE" @@ -201,10 +201,10 @@ class MySQLOptimizeTableExpression(MySQLTableMaintenanceExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_optimize_table_expression" + return "format_mysql_optimize_table_statement" -class MySQLRepairTableExpression(MySQLTableMaintenanceExpression): +class MySQLRepairTableStatement(MySQLTableMaintenanceExpression): """Represent ``REPAIR TABLE`` with optional modes.""" operation: str = "REPAIR" @@ -227,4 +227,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_repair_table_expression" + return "format_mysql_repair_table_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py index 3ef09c68..eb98ad81 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py @@ -23,7 +23,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLRenameTableExpression(BaseExpression): +class MySQLRenameTableStatement(BaseExpression): """Represent a MySQL ``RENAME TABLE ...`` atomic multi-table statement. Attributes: @@ -78,4 +78,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_rename_table_expression" + return "format_mysql_rename_table_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py index f4deb130..125f0a28 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py @@ -73,17 +73,17 @@ def validate(self, strict: bool = True) -> None: ) -class MySQLCreateProcedureExpression(MySQLRoutineExpression): +class MySQLCreateProcedureStatement(MySQLRoutineExpression): """Represent ``CREATE PROCEDURE``.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_create_procedure_expression" + return "format_mysql_create_procedure_statement" -class MySQLDropProcedureExpression(MySQLRoutineExpression): +class MySQLDropProcedureStatement(MySQLRoutineExpression): """Represent ``DROP PROCEDURE [IF EXISTS]``.""" def __init__( @@ -99,11 +99,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_drop_procedure_expression" + return "format_mysql_drop_procedure_statement" -class MySQLCreateFunctionExpression(MySQLRoutineExpression): +class MySQLCreateFunctionStatement(MySQLRoutineExpression): """Represent ``CREATE FUNCTION`` (stored function).""" def __init__( @@ -128,11 +128,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_create_function_expression" + return "format_mysql_create_function_statement" -class MySQLDropFunctionExpression(MySQLRoutineExpression): +class MySQLDropFunctionStatement(MySQLRoutineExpression): """Represent ``DROP FUNCTION [IF EXISTS]`` (stored function).""" def __init__( @@ -148,11 +148,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_drop_function_expression" + return "format_mysql_drop_function_statement" -class MySQLCallExpression(BaseExpression): +class MySQLCallStatement(BaseExpression): """Represent ``CALL procedure_name([args])``. Attributes: @@ -190,4 +190,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_call_expression" + return "format_mysql_call_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 5195be00..2dff1ce5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -70,10 +70,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_table_expression" + return "format_show_create_table_statement" -class ShowCreateViewExpression(ShowExpression): +class ShowCreateViewStatement(ShowExpression): """Expression for SHOW CREATE VIEW command.""" def __init__( @@ -89,10 +89,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_view_expression" + return "format_show_create_view_statement" -class ShowColumnsExpression(ShowExpression): +class ShowColumnsStatement(ShowExpression): """Expression for SHOW [FULL] COLUMNS command.""" def __init__( @@ -112,10 +112,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_columns_expression" + return "format_show_columns_statement" -class ShowIndexExpression(ShowExpression): +class ShowIndexStatement(ShowExpression): """Expression for SHOW INDEX command.""" def __init__( @@ -131,10 +131,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_index_expression" + return "format_show_index_statement" -class ShowTablesExpression(ShowExpression): +class ShowTablesStatement(ShowExpression): """Expression for SHOW [FULL] TABLES command.""" def __init__( @@ -152,10 +152,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_tables_expression" + return "format_show_tables_statement" -class ShowDatabasesExpression(ShowExpression): +class ShowDatabasesStatement(ShowExpression): """Expression for SHOW DATABASES command.""" def __init__( @@ -170,10 +170,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_databases_expression" + return "format_show_databases_statement" -class ShowTableStatusExpression(ShowExpression): +class ShowTableStatusStatement(ShowExpression): """Expression for SHOW TABLE STATUS command.""" def __init__( @@ -189,10 +189,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_table_status_expression" + return "format_show_table_status_statement" -class ShowTriggersExpression(ShowExpression): +class ShowTriggersStatement(ShowExpression): """Expression for SHOW TRIGGERS command.""" def __init__( @@ -208,10 +208,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_triggers_expression" + return "format_show_triggers_statement" -class ShowCreateTriggerExpression(ShowExpression): +class ShowCreateTriggerStatement(ShowExpression): """Expression for SHOW CREATE TRIGGER command.""" def __init__( @@ -227,10 +227,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_trigger_expression" + return "format_show_create_trigger_statement" -class ShowVariablesExpression(ShowExpression): +class ShowVariablesStatement(ShowExpression): """Expression for SHOW [GLOBAL] VARIABLES command.""" def __init__( @@ -247,10 +247,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_variables_expression" + return "format_show_variables_statement" -class ShowStatusExpression(ShowExpression): +class ShowStatusStatement(ShowExpression): """Expression for SHOW [GLOBAL] STATUS command.""" def __init__( @@ -267,10 +267,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_status_expression" + return "format_show_status_statement" -class ShowProcessListExpression(ShowExpression): +class ShowProcessListStatement(ShowExpression): """Expression for SHOW [FULL] PROCESSLIST command.""" def __init__( @@ -285,10 +285,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_process_list_expression" + return "format_show_process_list_statement" -class ShowWarningsExpression(ShowExpression): +class ShowWarningsStatement(ShowExpression): """Expression for SHOW WARNINGS command.""" def __init__( @@ -303,10 +303,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_warnings_expression" + return "format_show_warnings_statement" -class ShowErrorsExpression(ShowExpression): +class ShowErrorsStatement(ShowExpression): """Expression for SHOW ERRORS command.""" def __init__( @@ -321,19 +321,19 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_errors_expression" + return "format_show_errors_statement" -class ShowEnginesExpression(ShowExpression): +class ShowEnginesStatement(ShowExpression): """Expression for SHOW ENGINES command.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_engines_expression" + return "format_show_engines_statement" -class ShowCharsetExpression(ShowExpression): +class ShowCharsetStatement(ShowExpression): """Expression for SHOW CHARACTER SET command.""" def __init__( @@ -348,10 +348,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_charset_expression" + return "format_show_charset_statement" -class ShowCollationExpression(ShowExpression): +class ShowCollationStatement(ShowExpression): """Expression for SHOW COLLATION command.""" def __init__( @@ -366,10 +366,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_collation_expression" + return "format_show_collation_statement" -class ShowGrantsExpression(ShowExpression): +class ShowGrantsStatement(ShowExpression): """Expression for SHOW GRANTS command.""" def __init__( @@ -386,19 +386,19 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_grants_expression" + return "format_show_grants_statement" -class ShowPluginsExpression(ShowExpression): +class ShowPluginsStatement(ShowExpression): """Expression for SHOW PLUGINS command.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_plugins_expression" + return "format_show_plugins_statement" -class ShowBinaryLogsExpression(ShowExpression): +class ShowBinaryLogsStatement(ShowExpression): """Expression for SHOW BINARY LOGS command. Its own node rather than a flag on another one: the command takes no @@ -409,10 +409,10 @@ class ShowBinaryLogsExpression(ShowExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_binary_logs_expression" + return "format_show_binary_logs_statement" -class ShowMasterStatusExpression(ShowExpression): +class ShowMasterStatusStatement(ShowExpression): """Expression for SHOW MASTER STATUS command. MySQL 8.4 removed this command in favour of @@ -423,10 +423,10 @@ class ShowMasterStatusExpression(ShowExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_master_status_expression" + return "format_show_master_status_statement" -class ShowSlaveStatusExpression(ShowExpression): +class ShowSlaveStatusStatement(ShowExpression): """Expression for SHOW SLAVE STATUS command. MySQL 8.0.22 added ``SHOW REPLICA STATUS`` as the preferred spelling and @@ -439,4 +439,4 @@ class ShowSlaveStatusExpression(ShowExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_slave_status_expression" + return "format_show_slave_status_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py index 0f95bafb..106aab6d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py @@ -252,7 +252,7 @@ def format_method(self) -> str: return "format_mysql_st_as_geo_json_expression" -class MySQLCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): +class MySQLCreateSpatialIndexStatement(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE SPATIAL INDEX expression. Args: @@ -281,7 +281,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_create_spatial_index_expression" + return "format_mysql_create_spatial_index_statement" __all__ = [ @@ -293,5 +293,5 @@ def format_method(self) -> str: "MySQLSTGeomFromWKBExpression", "MySQLSTAsTextExpression", "MySQLSTAsGeoJSONExpression", - "MySQLCreateSpatialIndexExpression", + "MySQLCreateSpatialIndexStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py index cf1b8ccc..f18bc0ef 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py @@ -215,7 +215,7 @@ def format_method(self) -> str: return "format_mysql_vector_dim_expression" -class MySQLCreateVectorIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): +class MySQLCreateVectorIndexStatement(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE VECTOR INDEX expression. Args: @@ -244,7 +244,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_create_vector_index_expression" + return "format_mysql_create_vector_index_statement" __all__ = [ @@ -255,5 +255,5 @@ def format_method(self) -> str: "MySQLStringToVectorExpression", "MySQLVectorToStringExpression", "MySQLVectorDimExpression", - "MySQLCreateVectorIndexExpression", + "MySQLCreateVectorIndexStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index 72466667..70face00 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -19,24 +19,24 @@ ) from ..expression.show import ( ShowCreateTableStatement, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) @@ -351,7 +351,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_view(self._exec(sql, params), view_name) @@ -363,7 +363,7 @@ def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression( + expr = ShowColumnsStatement( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() @@ -371,7 +371,7 @@ def columns( def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name, schema=schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_indexes(self._exec(sql, params)) @@ -382,91 +382,91 @@ def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() return self._parse_tables(self._exec(sql, params)) def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect, like_pattern=like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_databases(self._exec(sql, params)) def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() return self._parse_table_status(self._exec(sql, params)) def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() return self._parse_triggers(self._exec(sql, params)) def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_trigger(self._exec(sql, params), trigger_name) def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_variables(self._exec(sql, params)) def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_status(self._exec(sql, params)) def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect, full=full) + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() return self._parse_processlist(self._exec(sql, params)) def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect, limit=limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect, limit=limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(self._exec(sql, params)) def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() return self._parse_engines(self._exec(sql, params)) def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect, like_pattern=like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_charset(self._exec(sql, params)) def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect, like_pattern=like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_collation(self._exec(sql, params)) def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect, user=user, host=host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() return self._parse_grants(self._exec(sql, params)) def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() return self._parse_plugins(self._exec(sql, params)) @@ -504,7 +504,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_view(await self._exec(sql, params), view_name) @@ -516,7 +516,7 @@ async def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression( + expr = ShowColumnsStatement( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() @@ -524,7 +524,7 @@ async def columns( async def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name, schema=schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() return self._parse_indexes(await self._exec(sql, params)) @@ -535,90 +535,90 @@ async def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() return self._parse_tables(await self._exec(sql, params)) async def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect, like_pattern=like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_databases(await self._exec(sql, params)) async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() return self._parse_table_status(await self._exec(sql, params)) async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() return self._parse_triggers(await self._exec(sql, params)) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() return self._parse_create_trigger(await self._exec(sql, params), trigger_name) async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_variables(await self._exec(sql, params)) async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() return self._parse_status(await self._exec(sql, params)) async def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect, full=full) + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() return self._parse_processlist(await self._exec(sql, params)) async def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect, limit=limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) async def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect, limit=limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() return self._parse_warnings(await self._exec(sql, params)) async def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() return self._parse_engines(await self._exec(sql, params)) async def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect, like_pattern=like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_charset(await self._exec(sql, params)) async def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect, like_pattern=like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() return self._parse_collation(await self._exec(sql, params)) async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect, user=user, host=host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() return self._parse_grants(await self._exec(sql, params)) async def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() return self._parse_plugins(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py index 4162279f..758e0b08 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py @@ -164,27 +164,27 @@ def _dialect(self) -> Any: def _show_variables(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: """``SHOW [GLOBAL] VARIABLES [LIKE ...]`` as a node.""" - from ..expression.show import ShowVariablesExpression + from ..expression.show import ShowVariablesStatement - return ShowVariablesExpression(self._dialect, like_pattern=like, session=session).to_sql() + return ShowVariablesStatement(self._dialect, like_pattern=like, session=session).to_sql() def _show_status(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: """``SHOW [GLOBAL] STATUS [LIKE ...]`` as a node.""" - from ..expression.show import ShowStatusExpression + from ..expression.show import ShowStatusStatement - return ShowStatusExpression(self._dialect, like_pattern=like, session=session).to_sql() + return ShowStatusStatement(self._dialect, like_pattern=like, session=session).to_sql() def _show_processlist(self, full: bool = False) -> Tuple[str, tuple]: """``SHOW [FULL] PROCESSLIST`` as a node.""" - from ..expression.show import ShowProcessListExpression + from ..expression.show import ShowProcessListStatement - return ShowProcessListExpression(self._dialect, full=full).to_sql() + return ShowProcessListStatement(self._dialect, full=full).to_sql() def _show_binary_logs(self) -> Tuple[str, tuple]: """``SHOW BINARY LOGS`` as a node.""" - from ..expression.show import ShowBinaryLogsExpression + from ..expression.show import ShowBinaryLogsStatement - return ShowBinaryLogsExpression(self._dialect).to_sql() + return ShowBinaryLogsStatement(self._dialect).to_sql() def _show_master_status(self) -> Tuple[str, tuple]: """``SHOW MASTER STATUS`` as a node. @@ -192,15 +192,15 @@ def _show_master_status(self) -> Tuple[str, tuple]: Servers from 8.4 answer through ``performance_schema.log_status`` instead; the caller picks by version, and this is the older branch. """ - from ..expression.show import ShowMasterStatusExpression + from ..expression.show import ShowMasterStatusStatement - return ShowMasterStatusExpression(self._dialect).to_sql() + return ShowMasterStatusStatement(self._dialect).to_sql() def _show_slave_status(self) -> Tuple[str, tuple]: """``SHOW SLAVE STATUS`` as a node (the branch for servers below 8.4).""" - from ..expression.show import ShowSlaveStatusExpression + from ..expression.show import ShowSlaveStatusStatement - return ShowSlaveStatusExpression(self._dialect).to_sql() + return ShowSlaveStatusStatement(self._dialect).to_sql() def _version_query(self) -> Tuple[str, tuple]: """``SELECT VERSION()`` -- the server's own version string. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py index 99bbf66d..2e2c81df 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py @@ -8,28 +8,28 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.admin import ( - MySQLCacheIndexExpression, - MySQLCloneExpression, - MySQLCreateUserExpression, - MySQLDropUserExpression, - MySQLFlushExpression, - MySQLGrantExpression, - MySQLHelpExpression, - MySQLInstallComponentExpression, - MySQLInstallPluginExpression, - MySQLKillExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLResetExpression, - MySQLRestartExpression, - MySQLRevokeExpression, - MySQLShutdownExpression, - MySQLBinlogExpression, - MySQLUninstallComponentExpression, - MySQLUninstallPluginExpression, - MySQLDoExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLHandlerCloseExpression, + MySQLCacheIndexStatement, + MySQLCloneStatement, + MySQLCreateUserStatement, + MySQLDropUserStatement, + MySQLFlushStatement, + MySQLGrantStatement, + MySQLHelpStatement, + MySQLInstallComponentStatement, + MySQLInstallPluginStatement, + MySQLKillStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLResetStatement, + MySQLRestartStatement, + MySQLRevokeStatement, + MySQLShutdownStatement, + MySQLBinlogStatement, + MySQLUninstallComponentStatement, + MySQLUninstallPluginStatement, + MySQLDoStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLHandlerCloseStatement, ) @@ -41,7 +41,7 @@ class MySQLAdminCommandMixin: def supports_flush(self) -> bool: return True - def format_mysql_flush_expression(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_statement(self, expr: "MySQLFlushStatement") -> Tuple[str, tuple]: """Format ``FLUSH [NO_WRITE_TO_BINLOG|LOCAL] option [, option ...]``.""" expr.validate(strict=self.strict_validation) parts = ["FLUSH"] @@ -55,7 +55,7 @@ def format_mysql_flush_expression(self, expr: "MySQLFlushExpression") -> Tuple[s def supports_reset(self) -> bool: return True - def format_mysql_reset_expression(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_statement(self, expr: "MySQLResetStatement") -> Tuple[str, tuple]: """Format ``RESET option``.""" return f"RESET {expr.option.value}", () @@ -98,9 +98,9 @@ def _format_accounts(self, accounts) -> str: parts.append(f"'{acct.user}'@'{host}'") return ", ".join(parts) - def format_mysql_cache_index_expression( + def format_mysql_cache_index_statement( self, - expr: "MySQLCacheIndexExpression", + expr: "MySQLCacheIndexStatement", ) -> Tuple[str, tuple]: """Format ``CACHE INDEX table [INDEX (...)] IN key_cache``.""" expr.validate(strict=self.strict_validation) @@ -110,9 +110,9 @@ def format_mysql_cache_index_expression( def supports_load_index_into_cache(self) -> bool: return True - def format_mysql_load_index_into_cache_expression( + def format_mysql_load_index_into_cache_statement( self, - expr: "MySQLLoadIndexIntoCacheExpression", + expr: "MySQLLoadIndexIntoCacheStatement", ) -> Tuple[str, tuple]: """Format ``LOAD INDEX INTO CACHE table [INDEX (...)]``.""" expr.validate(strict=self.strict_validation) @@ -124,9 +124,9 @@ def format_mysql_load_index_into_cache_expression( def supports_install_component(self) -> bool: return True - def format_mysql_install_component_expression( + def format_mysql_install_component_statement( self, - expr: "MySQLInstallComponentExpression", + expr: "MySQLInstallComponentStatement", ) -> Tuple[str, tuple]: """Format ``INSTALL COMPONENT 'name' [, 'name' ...]``.""" expr.validate(strict=self.strict_validation) @@ -136,9 +136,9 @@ def format_mysql_install_component_expression( def supports_uninstall_component(self) -> bool: return True - def format_mysql_uninstall_component_expression( + def format_mysql_uninstall_component_statement( self, - expr: "MySQLUninstallComponentExpression", + expr: "MySQLUninstallComponentStatement", ) -> Tuple[str, tuple]: """Format ``UNINSTALL COMPONENT 'name' [, 'name' ...]``.""" expr.validate(strict=self.strict_validation) @@ -148,9 +148,9 @@ def format_mysql_uninstall_component_expression( def supports_install_plugin(self) -> bool: return True - def format_mysql_install_plugin_expression( + def format_mysql_install_plugin_statement( self, - expr: "MySQLInstallPluginExpression", + expr: "MySQLInstallPluginStatement", ) -> Tuple[str, tuple]: """Format ``INSTALL PLUGIN name SONAME 'library.so'``.""" expr.validate(strict=self.strict_validation) @@ -159,9 +159,9 @@ def format_mysql_install_plugin_expression( def supports_uninstall_plugin(self) -> bool: return True - def format_mysql_uninstall_plugin_expression( + def format_mysql_uninstall_plugin_statement( self, - expr: "MySQLUninstallPluginExpression", + expr: "MySQLUninstallPluginStatement", ) -> Tuple[str, tuple]: """Format ``UNINSTALL PLUGIN name``.""" expr.validate(strict=self.strict_validation) @@ -172,7 +172,7 @@ def format_mysql_uninstall_plugin_expression( def supports_clone(self) -> bool: return True - def format_mysql_clone_expression(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_statement(self, expr: "MySQLCloneStatement") -> Tuple[str, tuple]: """Format ``CLONE INSTANCE FROM ...`` / ``CLONE LOCAL DATA DIRECTORY``.""" parts = ["CLONE"] if expr.from_data_directory: @@ -193,14 +193,14 @@ def format_mysql_clone_expression(self, expr: "MySQLCloneExpression") -> Tuple[s def supports_restart(self) -> bool: return True - def format_mysql_restart_expression(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_statement(self, expr: "MySQLRestartStatement") -> Tuple[str, tuple]: """Format the ``RESTART`` server statement.""" return "RESTART", () def supports_binlog(self) -> bool: return True - def format_mysql_binlog_expression(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_statement(self, expr: "MySQLBinlogStatement") -> Tuple[str, tuple]: """Format ``BINLOG 'base64_encoded_event'``.""" return f"BINLOG '{expr.encoded}'", () @@ -209,9 +209,9 @@ def format_mysql_binlog_expression(self, expr: "MySQLBinlogExpression") -> Tuple def supports_handler(self) -> bool: return True - def format_mysql_handler_open_expression( + def format_mysql_handler_open_statement( self, - expr: "MySQLHandlerOpenExpression", + expr: "MySQLHandlerOpenStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table OPEN [AS alias]``.""" parts = ["HANDLER", self._format_admin_table(expr.table), "OPEN"] @@ -220,9 +220,9 @@ def format_mysql_handler_open_expression( parts.append(self.format_identifier(expr.alias)) return " ".join(parts), () - def format_mysql_handler_read_expression( + def format_mysql_handler_read_statement( self, - expr: "MySQLHandlerReadExpression", + expr: "MySQLHandlerReadStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table READ ...``.""" params = [] @@ -246,9 +246,9 @@ def format_mysql_handler_read_expression( parts.append(f"LIMIT {int(expr.limit)}") return " ".join(parts), tuple(params) - def format_mysql_handler_close_expression( + def format_mysql_handler_close_statement( self, - expr: "MySQLHandlerCloseExpression", + expr: "MySQLHandlerCloseStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table CLOSE``.""" return f"HANDLER {self._format_admin_table(expr.table)} CLOSE", () @@ -258,7 +258,7 @@ def format_mysql_handler_close_expression( def supports_do(self) -> bool: return True - def format_mysql_do_expression(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_statement(self, expr: "MySQLDoStatement") -> Tuple[str, tuple]: """Format ``DO expr [, expr ...]``.""" params = [] expr_parts = [] @@ -275,7 +275,7 @@ def format_mysql_do_expression(self, expr: "MySQLDoExpression") -> Tuple[str, tu def supports_kill(self) -> bool: return True - def format_mysql_kill_expression(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_statement(self, expr: "MySQLKillStatement") -> Tuple[str, tuple]: """Format ``KILL [CONNECTION | QUERY] processlist_id``.""" parts = ["KILL"] if expr.target.value: @@ -286,14 +286,14 @@ def format_mysql_kill_expression(self, expr: "MySQLKillExpression") -> Tuple[str def supports_shutdown(self) -> bool: return True - def format_mysql_shutdown_expression(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_statement(self, expr: "MySQLShutdownStatement") -> Tuple[str, tuple]: """Format the ``SHUTDOWN`` statement.""" return "SHUTDOWN", () def supports_help(self) -> bool: return True - def format_mysql_help_expression(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_statement(self, expr: "MySQLHelpStatement") -> Tuple[str, tuple]: """Format ``HELP 'topic'``.""" return f"HELP '{expr.topic}'", () @@ -302,9 +302,9 @@ def format_mysql_help_expression(self, expr: "MySQLHelpExpression") -> Tuple[str def supports_create_user(self) -> bool: return True - def format_mysql_create_user_expression( + def format_mysql_create_user_statement( self, - expr: "MySQLCreateUserExpression", + expr: "MySQLCreateUserStatement", ) -> Tuple[str, tuple]: """Format ``CREATE USER [IF NOT EXISTS] acct [IDENTIFIED BY 'pwd']``.""" parts = ["CREATE USER"] @@ -318,7 +318,7 @@ def format_mysql_create_user_expression( def supports_drop_user(self) -> bool: return True - def format_mysql_drop_user_expression(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_statement(self, expr: "MySQLDropUserStatement") -> Tuple[str, tuple]: """Format ``DROP USER [IF EXISTS] acct [, acct ...]``.""" parts = ["DROP USER"] if expr.if_exists: @@ -329,7 +329,7 @@ def format_mysql_drop_user_expression(self, expr: "MySQLDropUserExpression") -> def supports_grant(self) -> bool: return True - def format_mysql_grant_expression(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_statement(self, expr: "MySQLGrantStatement") -> Tuple[str, tuple]: """Format ``GRANT priv ON obj TO acct [WITH GRANT OPTION]``.""" priv_parts = [] for p in expr.privileges: @@ -350,7 +350,7 @@ def format_mysql_grant_expression(self, expr: "MySQLGrantExpression") -> Tuple[s def supports_revoke(self) -> bool: return True - def format_mysql_revoke_expression(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_statement(self, expr: "MySQLRevokeStatement") -> Tuple[str, tuple]: """Format ``REVOKE priv ON obj FROM acct``.""" priv_parts = [] for p in expr.privileges: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py index 6eca6608..5cbba582 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py @@ -18,11 +18,11 @@ def connect(self): def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: - from ..expression.show import ShowVariablesExpression + from ..expression.show import ShowVariablesStatement cursor = self._connection.cursor(dictionary=True) cursor.execute( - *ShowVariablesExpression( + *ShowVariablesStatement( self.dialect, like_pattern="max_connections" ).to_sql() ) @@ -62,11 +62,11 @@ async def connect(self): async def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: - from ..expression.show import ShowVariablesExpression + from ..expression.show import ShowVariablesStatement cursor = await self._connection.cursor(dictionary=True) await cursor.execute( - *ShowVariablesExpression( + *ShowVariablesStatement( self.dialect, like_pattern="max_connections" ).to_sql() ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py index c6198862..3430a312 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.rename_table import ( - MySQLRenameTableExpression, + MySQLRenameTableStatement, ) @@ -35,9 +35,9 @@ def supports_cross_database_rename(self) -> bool: """ return True - def format_mysql_rename_table_expression( + def format_mysql_rename_table_statement( self, - expr: "MySQLRenameTableExpression", + expr: "MySQLRenameTableStatement", ) -> Tuple[str, tuple]: """Format a MySQL ``RENAME TABLE ...`` statement. @@ -61,10 +61,10 @@ def format_mysql_rename_table_expression( pairs = [] for position, (old_table, new_table) in enumerate(expr.renames): require_kind( - old_table, Table, f"MySQLRenameTableExpression.renames old side at position {position}" + old_table, Table, f"MySQLRenameTableStatement.renames old side at position {position}" ) require_kind( - new_table, Table, f"MySQLRenameTableExpression.renames new side at position {position}" + new_table, Table, f"MySQLRenameTableStatement.renames new side at position {position}" ) old_sql = old_table.to_sql()[0] new_sql = new_table.to_sql()[0] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 107ee9ab..3242f1e5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -141,7 +141,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: if isinstance(expr.storage_options, dict) else expr.storage_options ) - storage_sql, storage_params = self.format_storage_options(storage_expr) + storage_sql, storage_params = self.format_storage_options_expression(storage_expr) if storage_sql: parts.append(storage_sql) all_params.extend(storage_params) @@ -329,7 +329,7 @@ def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tupl parts.append(f"USING {idx_def.type}") return " ".join(parts), () - def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: + def format_storage_options_expression(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: """Format core's table storage options (ENGINE, CHARSET, etc.).""" return self._render_storage_options(expr.options), () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py index 05cf9c34..6e431f6f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import OnConflictClause from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) @@ -26,7 +26,7 @@ def supports_replace_into(self) -> bool: def supports_load_data(self) -> bool: return True - def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_statement(self, expr: "MySQLLoadDataStatement") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Raises: @@ -34,7 +34,7 @@ def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> wrong kind there would render a statement loading into whatever object's name was supplied. """ - require_kind(expr.table, Table, "MySQLLoadDataExpression.table") + require_kind(expr.table, Table, "MySQLLoadDataStatement.table") expr.validate(strict=self.strict_validation) all_params: List[Any] = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py index cbc7129c..9555809e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py @@ -4,14 +4,14 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.introspection.types import IntrospectionScope from rhosocial.activerecord.backend.expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, ) @@ -144,7 +144,7 @@ def _information_schema_query( kwargs["order_by"] = OrderByClause(self, order_by) return QueryStatement(self, **kwargs).to_sql() - def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[str, tuple]: + def format_database_info_query_statement(self, expr: "DatabaseInfoQueryStatement") -> Tuple[str, tuple]: """Format database information query.""" from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal @@ -160,7 +160,7 @@ def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[st [ComparisonPredicate(self, "eq", Column(self, "SCHEMA_NAME"), Literal(self, schema))], ) - def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tuple]: + def format_table_list_query_statement(self, expr: "TableListQueryStatement") -> Tuple[str, tuple]: """Format table list query.""" from rhosocial.activerecord.backend.expression import ( Column, @@ -226,7 +226,7 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup conditions, ) - def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, tuple]: + def format_column_info_query_statement(self, expr: "ColumnInfoQueryStatement") -> Tuple[str, tuple]: """Format column information query.""" from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal @@ -259,7 +259,7 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t [Column(self, "ORDINAL_POSITION")], ) - def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tuple]: + def format_index_info_query_statement(self, expr: "IndexInfoQueryStatement") -> Tuple[str, tuple]: """Format index information query.""" from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal @@ -285,7 +285,7 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup [Column(self, "INDEX_NAME"), Column(self, "SEQ_IN_INDEX")], ) - def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, tuple]: + def format_foreign_key_query_statement(self, expr: "ForeignKeyQueryStatement") -> Tuple[str, tuple]: """Format foreign key information query.""" from rhosocial.activerecord.backend.expression import ( Column, @@ -364,7 +364,7 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t source=join, ) - def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple]: + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: """Format view list query.""" from rhosocial.activerecord.backend.expression import ( Column, @@ -406,7 +406,7 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple [Column(self, "TABLE_NAME")], ) - def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple]: + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: """Format single view information query.""" from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal @@ -428,7 +428,7 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple ], ) - def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: + def format_trigger_list_query_statement(self, expr: "TriggerListQueryStatement") -> Tuple[str, tuple]: """Format trigger list query.""" from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py index 23686d3d..18be2039 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, ) @@ -17,7 +17,7 @@ def supports_json_duality_view(self) -> bool: def supports_json_duality_view_dml(self) -> bool: return self.version >= (9, 7, 0) - def format_create_json_duality_view_expression(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewStatement") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement.""" parts = [] if expr.replace: @@ -32,7 +32,7 @@ def format_create_json_duality_view_expression(self, expr: "CreateJsonDualityVie return " ".join(parts), () - def format_drop_json_duality_view_expression(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW for a JSON Duality View.""" if expr.if_exists: return f"DROP VIEW IF EXISTS `{expr.view_name}`", () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py index ab9be28c..03977008 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py @@ -6,11 +6,11 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.objects import RelationObject from rhosocial.activerecord.backend.impl.mysql.expression.maintenance import ( - MySQLAnalyzeTableExpression, - MySQLCheckTableExpression, - MySQLChecksumTableExpression, - MySQLOptimizeTableExpression, - MySQLRepairTableExpression, + MySQLAnalyzeTableStatement, + MySQLCheckTableStatement, + MySQLChecksumTableStatement, + MySQLOptimizeTableStatement, + MySQLRepairTableStatement, ) @@ -73,37 +73,37 @@ def _render_mysql_table_maintenance(self, expr) -> Tuple[str, tuple]: return " ".join(parts), () - def format_mysql_analyze_table_expression( + def format_mysql_analyze_table_statement( self, - expr: "MySQLAnalyzeTableExpression", + expr: "MySQLAnalyzeTableStatement", ) -> Tuple[str, tuple]: """Format ``ANALYZE TABLE``.""" return self._render_mysql_table_maintenance(expr) - def format_mysql_optimize_table_expression( + def format_mysql_optimize_table_statement( self, - expr: "MySQLOptimizeTableExpression", + expr: "MySQLOptimizeTableStatement", ) -> Tuple[str, tuple]: """Format ``OPTIMIZE TABLE``.""" return self._render_mysql_table_maintenance(expr) - def format_mysql_check_table_expression( + def format_mysql_check_table_statement( self, - expr: "MySQLCheckTableExpression", + expr: "MySQLCheckTableStatement", ) -> Tuple[str, tuple]: """Format ``CHECK TABLE`` with its optional modes.""" return self._render_mysql_table_maintenance(expr) - def format_mysql_checksum_table_expression( + def format_mysql_checksum_table_statement( self, - expr: "MySQLChecksumTableExpression", + expr: "MySQLChecksumTableStatement", ) -> Tuple[str, tuple]: """Format ``CHECKSUM TABLE`` with its optional mode.""" return self._render_mysql_table_maintenance(expr) - def format_mysql_repair_table_expression( + def format_mysql_repair_table_statement( self, - expr: "MySQLRepairTableExpression", + expr: "MySQLRepairTableStatement", ) -> Tuple[str, tuple]: """Format ``REPAIR TABLE`` with its optional modes.""" return self._render_mysql_table_maintenance(expr) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py index 23f7324b..e807ef9f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py @@ -7,11 +7,11 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.routine import ( - MySQLCallExpression, - MySQLCreateProcedureExpression, - MySQLDropProcedureExpression, - MySQLCreateFunctionExpression, - MySQLDropFunctionExpression, + MySQLCallStatement, + MySQLCreateProcedureStatement, + MySQLDropProcedureStatement, + MySQLCreateFunctionStatement, + MySQLDropFunctionStatement, ) @@ -43,9 +43,9 @@ def supports_stored_function(self) -> bool: def supports_call(self) -> bool: return True - def format_mysql_create_procedure_expression( + def format_mysql_create_procedure_statement( self, - expr: "MySQLCreateProcedureExpression", + expr: "MySQLCreateProcedureStatement", ) -> Tuple[str, tuple]: """Format ``CREATE PROCEDURE name(params) body``. @@ -55,7 +55,7 @@ def format_mysql_create_procedure_expression( ``strict_validation`` and can be switched off; a name of the wrong kind must not depend on a rendering strictness flag. """ - require_kind(expr.name, RoutineObject, "MySQLCreateProcedureExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCreateProcedureStatement.name") expr.validate(strict=self.strict_validation) parts = ["CREATE PROCEDURE", expr.name.to_sql()[0]] params = ", ".join(self._format_param(p) for p in expr.params) @@ -64,16 +64,16 @@ def format_mysql_create_procedure_expression( parts.append(expr.body) return " ".join(parts), () - def format_mysql_drop_procedure_expression( + def format_mysql_drop_procedure_statement( self, - expr: "MySQLDropProcedureExpression", + expr: "MySQLDropProcedureStatement", ) -> Tuple[str, tuple]: """Format ``DROP PROCEDURE [IF EXISTS] name``. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLDropProcedureExpression.name") + require_kind(expr.name, RoutineObject, "MySQLDropProcedureStatement.name") expr.validate(strict=self.strict_validation) parts = ["DROP PROCEDURE"] if expr.if_exists: @@ -81,13 +81,13 @@ def format_mysql_drop_procedure_expression( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_mysql_create_function_expression( + def format_mysql_create_function_statement( self, - expr: "MySQLCreateFunctionExpression", + expr: "MySQLCreateFunctionStatement", ) -> Tuple[str, tuple]: """Format ``CREATE FUNCTION name(params) RETURNS type body``. - This is the entry :class:`MySQLCreateFunctionExpression` names. It is + This is the entry :class:`MySQLCreateFunctionStatement` names. It is deliberately not an override of core's ``format_create_function_statement``: that node carries ``function``/``parameters``/``language`` where this one carries @@ -98,7 +98,7 @@ def format_mysql_create_function_expression( Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLCreateFunctionExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCreateFunctionStatement.name") expr.validate(strict=self.strict_validation) parts = ["CREATE FUNCTION", expr.name.to_sql()[0]] params = ", ".join(self._format_param(p) for p in expr.params) @@ -110,19 +110,19 @@ def format_mysql_create_function_expression( parts.append(expr.body) return " ".join(parts), () - def format_mysql_drop_function_expression( + def format_mysql_drop_function_statement( self, - expr: "MySQLDropFunctionExpression", + expr: "MySQLDropFunctionStatement", ) -> Tuple[str, tuple]: """Format ``DROP FUNCTION [IF EXISTS] name`` (stored function). - The entry :class:`MySQLDropFunctionExpression` names, for the reason - given on :meth:`format_mysql_create_function_expression`. + The entry :class:`MySQLDropFunctionStatement` names, for the reason + given on :meth:`format_mysql_create_function_statement`. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLDropFunctionExpression.name") + require_kind(expr.name, RoutineObject, "MySQLDropFunctionStatement.name") expr.validate(strict=self.strict_validation) parts = ["DROP FUNCTION"] if expr.if_exists: @@ -130,13 +130,13 @@ def format_mysql_drop_function_expression( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_mysql_call_expression(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_statement(self, expr: "MySQLCallStatement") -> Tuple[str, tuple]: """Format ``CALL name([args])``. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLCallExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCallStatement.name") expr.validate(strict=self.strict_validation) params = [] arg_parts = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py index 607e5b86..ae580716 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py @@ -126,13 +126,13 @@ def format_mysql_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_mysql_create_spatial_index_expression(self, expr) -> Tuple[str, tuple]: - """Format a :class:`MySQLCreateSpatialIndexExpression` node.""" - from ..expression.spatial import MySQLCreateSpatialIndexExpression + def format_mysql_create_spatial_index_statement(self, expr) -> Tuple[str, tuple]: + """Format a :class:`MySQLCreateSpatialIndexStatement` node.""" + from ..expression.spatial import MySQLCreateSpatialIndexStatement - if not isinstance(expr, MySQLCreateSpatialIndexExpression): + if not isinstance(expr, MySQLCreateSpatialIndexStatement): raise TypeError( - f"format_mysql_create_spatial_index_expression expects MySQLCreateSpatialIndexExpression, " + f"format_mysql_create_spatial_index_statement expects MySQLCreateSpatialIndexStatement, " f"got {type(expr).__name__}" ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py index b61277bb..b22aa7b9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py @@ -114,7 +114,7 @@ def supports_savepoint(self) -> bool: """MySQL supports savepoints.""" return True - def format_set_transaction(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION statement for MySQL.""" from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py index b22aa90a..c97391be 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py @@ -97,13 +97,13 @@ def format_mysql_distance_dot_expression(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_mysql_create_vector_index_expression(self, expr) -> Tuple[str, tuple]: - """Format a :class:`MySQLCreateVectorIndexExpression` node.""" - from ..expression.vector import MySQLCreateVectorIndexExpression + def format_mysql_create_vector_index_statement(self, expr) -> Tuple[str, tuple]: + """Format a :class:`MySQLCreateVectorIndexStatement` node.""" + from ..expression.vector import MySQLCreateVectorIndexStatement - if not isinstance(expr, MySQLCreateVectorIndexExpression): + if not isinstance(expr, MySQLCreateVectorIndexStatement): raise TypeError( - f"format_mysql_create_vector_index_expression expects MySQLCreateVectorIndexExpression, " + f"format_mysql_create_vector_index_statement expects MySQLCreateVectorIndexStatement, " f"got {type(expr).__name__}" ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index 981a6be7..6f85428a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -28,35 +28,35 @@ DropTriggerStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.admin import ( - MySQLCacheIndexExpression, - MySQLCloneExpression, - MySQLCreateUserExpression, - MySQLDoExpression, - MySQLDropUserExpression, - MySQLFlushExpression, - MySQLGrantExpression, - MySQLHelpExpression, - MySQLHandlerCloseExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLInstallComponentExpression, - MySQLInstallPluginExpression, - MySQLKillExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLResetExpression, - MySQLRestartExpression, - MySQLRevokeExpression, - MySQLShutdownExpression, - MySQLBinlogExpression, - MySQLUninstallComponentExpression, - MySQLUninstallPluginExpression, + MySQLCacheIndexStatement, + MySQLCloneStatement, + MySQLCreateUserStatement, + MySQLDoStatement, + MySQLDropUserStatement, + MySQLFlushStatement, + MySQLGrantStatement, + MySQLHelpStatement, + MySQLHandlerCloseStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLInstallComponentStatement, + MySQLInstallPluginStatement, + MySQLKillStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLResetStatement, + MySQLRestartStatement, + MySQLRevokeStatement, + MySQLShutdownStatement, + MySQLBinlogStatement, + MySQLUninstallComponentStatement, + MySQLUninstallPluginStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.load_xml import ( MySQLLoadXMLStatement, @@ -92,14 +92,14 @@ MySQLTruncatePartitionExpression, ) from rhosocial.activerecord.backend.impl.mysql.expression.rename_table import ( - MySQLRenameTableExpression, + MySQLRenameTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.routine import ( - MySQLCallExpression, - MySQLCreateFunctionExpression, - MySQLCreateProcedureExpression, - MySQLDropFunctionExpression, - MySQLDropProcedureExpression, + MySQLCallStatement, + MySQLCreateFunctionStatement, + MySQLCreateProcedureStatement, + MySQLDropFunctionStatement, + MySQLDropProcedureStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.table_statement import ( MySQLTableStatement, @@ -173,11 +173,11 @@ def supports_load_data(self) -> bool: """ ... - def format_mysql_load_data_expression(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_statement(self, expr: "MySQLLoadDataStatement") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Args: - expr: MySQLLoadDataExpression instance + expr: MySQLLoadDataStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -394,7 +394,7 @@ def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: """Format an inline INDEX definition inside CREATE TABLE.""" ... - def format_storage_options(self, expr: Any) -> Tuple[str, tuple]: + def format_storage_options_expression(self, expr: Any) -> Tuple[str, tuple]: """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" ... @@ -1322,11 +1322,11 @@ def format_mysql_st_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_mysql_create_spatial_index_expression(self, expr: Any) -> Tuple[str, tuple]: - """Format a MySQLCreateSpatialIndexExpression node. + def format_mysql_create_spatial_index_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format a MySQLCreateSpatialIndexStatement node. Args: - expr: MySQLCreateSpatialIndexExpression instance + expr: MySQLCreateSpatialIndexStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -1447,11 +1447,11 @@ def format_mysql_distance_dot_expression(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_mysql_create_vector_index_expression(self, expr: Any) -> Tuple[str, tuple]: - """Format a MySQLCreateVectorIndexExpression node. + def format_mysql_create_vector_index_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format a MySQLCreateVectorIndexStatement node. Args: - expr: MySQLCreateVectorIndexExpression instance + expr: MySQLCreateVectorIndexStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -1648,18 +1648,18 @@ def supports_json_duality_view_dml(self) -> bool: """Whether DML on JSON Duality Views is supported (MySQL 9.7+).""" ... - def format_create_json_duality_view_expression(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewStatement") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement. Args: - expr: CreateJsonDualityViewExpression instance + expr: CreateJsonDualityViewStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_json_duality_view_expression(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW statement for a JSON Duality View.""" ... @@ -1748,7 +1748,7 @@ def supports_cross_database_rename(self) -> bool: """ ... - def format_mysql_rename_table_expression(self, expr: "MySQLRenameTableExpression") -> Tuple[str, tuple]: + def format_mysql_rename_table_statement(self, expr: "MySQLRenameTableStatement") -> Tuple[str, tuple]: """Format a MySQL RENAME TABLE statement.""" ... @@ -1814,23 +1814,23 @@ def supports_repair_table(self) -> bool: """Whether REPAIR TABLE is supported.""" ... - def format_mysql_analyze_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_analyze_table_statement(self, expr) -> Tuple[str, tuple]: """Format ``ANALYZE TABLE``.""" ... - def format_mysql_optimize_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_optimize_table_statement(self, expr) -> Tuple[str, tuple]: """Format ``OPTIMIZE TABLE``.""" ... - def format_mysql_check_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_check_table_statement(self, expr) -> Tuple[str, tuple]: """Format ``CHECK TABLE`` with its optional modes.""" ... - def format_mysql_checksum_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_checksum_table_statement(self, expr) -> Tuple[str, tuple]: """Format ``CHECKSUM TABLE`` with its optional mode.""" ... - def format_mysql_repair_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_repair_table_statement(self, expr) -> Tuple[str, tuple]: """Format ``REPAIR TABLE`` with its optional modes.""" ... @@ -1860,23 +1860,23 @@ def supports_call(self) -> bool: """Whether CALL is supported.""" ... - def format_mysql_create_procedure_expression(self, expr: "MySQLCreateProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_create_procedure_statement(self, expr: "MySQLCreateProcedureStatement") -> Tuple[str, tuple]: """Format CREATE PROCEDURE.""" ... - def format_mysql_drop_procedure_expression(self, expr: "MySQLDropProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_drop_procedure_statement(self, expr: "MySQLDropProcedureStatement") -> Tuple[str, tuple]: """Format DROP PROCEDURE.""" ... - def format_mysql_create_function_expression(self, expr: "MySQLCreateFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_create_function_statement(self, expr: "MySQLCreateFunctionStatement") -> Tuple[str, tuple]: """Format CREATE FUNCTION (stored function).""" ... - def format_mysql_drop_function_expression(self, expr: "MySQLDropFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_function_statement(self, expr: "MySQLDropFunctionStatement") -> Tuple[str, tuple]: """Format DROP FUNCTION.""" ... - def format_mysql_call_expression(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_statement(self, expr: "MySQLCallStatement") -> Tuple[str, tuple]: """Format CALL.""" ... @@ -1918,131 +1918,131 @@ class MySQLAdminCommandSupport(Protocol): def supports_flush(self) -> bool: ... - def format_mysql_flush_expression(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_statement(self, expr: "MySQLFlushStatement") -> Tuple[str, tuple]: ... def supports_reset(self) -> bool: ... - def format_mysql_reset_expression(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_statement(self, expr: "MySQLResetStatement") -> Tuple[str, tuple]: ... def supports_cache_index(self) -> bool: ... - def format_mysql_cache_index_expression(self, expr: "MySQLCacheIndexExpression") -> Tuple[str, tuple]: + def format_mysql_cache_index_statement(self, expr: "MySQLCacheIndexStatement") -> Tuple[str, tuple]: ... def supports_load_index_into_cache(self) -> bool: ... - def format_mysql_load_index_into_cache_expression( - self, expr: "MySQLLoadIndexIntoCacheExpression" + def format_mysql_load_index_into_cache_statement( + self, expr: "MySQLLoadIndexIntoCacheStatement" ) -> Tuple[str, tuple]: ... def supports_install_component(self) -> bool: ... - def format_mysql_install_component_expression(self, expr: "MySQLInstallComponentExpression") -> Tuple[str, tuple]: + def format_mysql_install_component_statement(self, expr: "MySQLInstallComponentStatement") -> Tuple[str, tuple]: ... def supports_uninstall_component(self) -> bool: ... - def format_mysql_uninstall_component_expression( - self, expr: "MySQLUninstallComponentExpression" + def format_mysql_uninstall_component_statement( + self, expr: "MySQLUninstallComponentStatement" ) -> Tuple[str, tuple]: ... def supports_install_plugin(self) -> bool: ... - def format_mysql_install_plugin_expression(self, expr: "MySQLInstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_install_plugin_statement(self, expr: "MySQLInstallPluginStatement") -> Tuple[str, tuple]: ... def supports_uninstall_plugin(self) -> bool: ... - def format_mysql_uninstall_plugin_expression(self, expr: "MySQLUninstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_uninstall_plugin_statement(self, expr: "MySQLUninstallPluginStatement") -> Tuple[str, tuple]: ... def supports_clone(self) -> bool: ... - def format_mysql_clone_expression(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_statement(self, expr: "MySQLCloneStatement") -> Tuple[str, tuple]: ... def supports_restart(self) -> bool: ... - def format_mysql_restart_expression(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_statement(self, expr: "MySQLRestartStatement") -> Tuple[str, tuple]: ... def supports_binlog(self) -> bool: ... - def format_mysql_binlog_expression(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_statement(self, expr: "MySQLBinlogStatement") -> Tuple[str, tuple]: ... def supports_handler(self) -> bool: ... - def format_mysql_handler_open_expression(self, expr: "MySQLHandlerOpenExpression") -> Tuple[str, tuple]: + def format_mysql_handler_open_statement(self, expr: "MySQLHandlerOpenStatement") -> Tuple[str, tuple]: ... - def format_mysql_handler_read_expression(self, expr: "MySQLHandlerReadExpression") -> Tuple[str, tuple]: + def format_mysql_handler_read_statement(self, expr: "MySQLHandlerReadStatement") -> Tuple[str, tuple]: ... - def format_mysql_handler_close_expression(self, expr: "MySQLHandlerCloseExpression") -> Tuple[str, tuple]: + def format_mysql_handler_close_statement(self, expr: "MySQLHandlerCloseStatement") -> Tuple[str, tuple]: ... def supports_do(self) -> bool: ... - def format_mysql_do_expression(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_statement(self, expr: "MySQLDoStatement") -> Tuple[str, tuple]: ... def supports_kill(self) -> bool: ... - def format_mysql_kill_expression(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_statement(self, expr: "MySQLKillStatement") -> Tuple[str, tuple]: ... def supports_shutdown(self) -> bool: ... - def format_mysql_shutdown_expression(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_statement(self, expr: "MySQLShutdownStatement") -> Tuple[str, tuple]: ... def supports_help(self) -> bool: ... - def format_mysql_help_expression(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_statement(self, expr: "MySQLHelpStatement") -> Tuple[str, tuple]: ... def supports_create_user(self) -> bool: ... - def format_mysql_create_user_expression(self, expr: "MySQLCreateUserExpression") -> Tuple[str, tuple]: + def format_mysql_create_user_statement(self, expr: "MySQLCreateUserStatement") -> Tuple[str, tuple]: ... def supports_drop_user(self) -> bool: ... - def format_mysql_drop_user_expression(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_statement(self, expr: "MySQLDropUserStatement") -> Tuple[str, tuple]: ... def supports_grant(self) -> bool: ... - def format_mysql_grant_expression(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_statement(self, expr: "MySQLGrantStatement") -> Tuple[str, tuple]: ... def supports_revoke(self) -> bool: ... - def format_mysql_revoke_expression(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_statement(self, expr: "MySQLRevokeStatement") -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py index 676adc89..de9a4cdd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py @@ -20,27 +20,27 @@ from ..expression.show import ( ShowExpression, ShowCreateTableStatement, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, - ShowBinaryLogsExpression, - ShowMasterStatusExpression, - ShowSlaveStatusExpression, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, + ShowBinaryLogsStatement, + ShowMasterStatusStatement, + ShowSlaveStatusStatement, ) from .dialect import MySQLShowDialectMixin from .functionality import MySQLShowFunctionality, AsyncMySQLShowFunctionality @@ -50,27 +50,27 @@ # Expression classes "ShowExpression", "ShowCreateTableStatement", - "ShowCreateViewExpression", - "ShowColumnsExpression", - "ShowIndexExpression", - "ShowTablesExpression", - "ShowDatabasesExpression", - "ShowTableStatusExpression", - "ShowTriggersExpression", - "ShowCreateTriggerExpression", - "ShowVariablesExpression", - "ShowStatusExpression", - "ShowProcessListExpression", - "ShowWarningsExpression", - "ShowErrorsExpression", - "ShowEnginesExpression", - "ShowCharsetExpression", - "ShowCollationExpression", - "ShowGrantsExpression", - "ShowPluginsExpression", - "ShowBinaryLogsExpression", - "ShowMasterStatusExpression", - "ShowSlaveStatusExpression", + "ShowCreateViewStatement", + "ShowColumnsStatement", + "ShowIndexStatement", + "ShowTablesStatement", + "ShowDatabasesStatement", + "ShowTableStatusStatement", + "ShowTriggersStatement", + "ShowCreateTriggerStatement", + "ShowVariablesStatement", + "ShowStatusStatement", + "ShowProcessListStatement", + "ShowWarningsStatement", + "ShowErrorsStatement", + "ShowEnginesStatement", + "ShowCharsetStatement", + "ShowCollationStatement", + "ShowGrantsStatement", + "ShowPluginsStatement", + "ShowBinaryLogsStatement", + "ShowMasterStatusStatement", + "ShowSlaveStatusStatement", # Dialect mixin "MySQLShowDialectMixin", # Functionality classes diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py index c0a39983..38ab0099 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py @@ -25,27 +25,27 @@ if TYPE_CHECKING: from ..expression.show import ( ShowCreateTableStatement, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, - ShowBinaryLogsExpression, - ShowMasterStatusExpression, - ShowSlaveStatusExpression, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, + ShowBinaryLogsStatement, + ShowMasterStatusStatement, + ShowSlaveStatusStatement, ) @@ -82,7 +82,7 @@ def _format_show_target(self, target: Any, schema: Any, kind: type) -> str: # ========== SHOW CREATE Statements ========== - def format_show_create_table_expression(self, expr: "ShowCreateTableStatement") -> Tuple[str, tuple]: + def format_show_create_table_statement(self, expr: "ShowCreateTableStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TABLE statement.""" params = expr.get_params() table_name = params["table_name"] @@ -90,7 +90,7 @@ def format_show_create_table_expression(self, expr: "ShowCreateTableStatement") return f"SHOW CREATE TABLE {self._format_show_target(table_name, schema, Table)}", () - def format_show_create_view_expression(self, expr: "ShowCreateViewExpression") -> Tuple[str, tuple]: + def format_show_create_view_statement(self, expr: "ShowCreateViewStatement") -> Tuple[str, tuple]: """Format SHOW CREATE VIEW statement.""" params = expr.get_params() view_name = params["view_name"] @@ -98,7 +98,7 @@ def format_show_create_view_expression(self, expr: "ShowCreateViewExpression") - return f"SHOW CREATE VIEW {self._format_show_target(view_name, schema, View)}", () - def format_show_create_trigger_expression(self, expr: "ShowCreateTriggerExpression") -> Tuple[str, tuple]: + def format_show_create_trigger_statement(self, expr: "ShowCreateTriggerStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TRIGGER statement.""" params = expr.get_params() trigger_name = params["trigger_name"] @@ -108,7 +108,7 @@ def format_show_create_trigger_expression(self, expr: "ShowCreateTriggerExpressi # ========== SHOW COLUMNS/INDEX ========== - def format_show_columns_expression(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple]: + def format_show_columns_statement(self, expr: "ShowColumnsStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] COLUMNS statement.""" params = expr.get_params() table_name = params["table_name"] @@ -129,7 +129,7 @@ def format_show_columns_expression(self, expr: "ShowColumnsExpression") -> Tuple return " ".join(parts), sql_params - def format_show_index_expression(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: + def format_show_index_statement(self, expr: "ShowIndexStatement") -> Tuple[str, tuple]: """Format SHOW INDEX statement.""" params = expr.get_params() table_name = params["table_name"] @@ -139,7 +139,7 @@ def format_show_index_expression(self, expr: "ShowIndexExpression") -> Tuple[str # ========== SHOW TABLES/DATABASES ========== - def format_show_tables_expression(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: + def format_show_tables_statement(self, expr: "ShowTablesStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] TABLES statement.""" params = expr.get_params() schema = params.get("schema") @@ -160,7 +160,7 @@ def format_show_tables_expression(self, expr: "ShowTablesExpression") -> Tuple[s return " ".join(parts), sql_params - def format_show_databases_expression(self, expr: "ShowDatabasesExpression") -> Tuple[str, tuple]: + def format_show_databases_statement(self, expr: "ShowDatabasesStatement") -> Tuple[str, tuple]: """Format SHOW DATABASES statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -169,7 +169,7 @@ def format_show_databases_expression(self, expr: "ShowDatabasesExpression") -> T return f"SHOW DATABASES LIKE {self.p()}", (like_pattern,) return "SHOW DATABASES", () - def format_show_table_status_expression(self, expr: "ShowTableStatusExpression") -> Tuple[str, tuple]: + def format_show_table_status_statement(self, expr: "ShowTableStatusStatement") -> Tuple[str, tuple]: """Format SHOW TABLE STATUS statement.""" params = expr.get_params() schema = params.get("schema") @@ -188,7 +188,7 @@ def format_show_table_status_expression(self, expr: "ShowTableStatusExpression") # ========== SHOW TRIGGERS ========== - def format_show_triggers_expression(self, expr: "ShowTriggersExpression") -> Tuple[str, tuple]: + def format_show_triggers_statement(self, expr: "ShowTriggersStatement") -> Tuple[str, tuple]: """Format SHOW TRIGGERS statement.""" params = expr.get_params() schema = params.get("schema") @@ -207,7 +207,7 @@ def format_show_triggers_expression(self, expr: "ShowTriggersExpression") -> Tup # ========== SHOW VARIABLES/STATUS ========== - def format_show_variables_expression(self, expr: "ShowVariablesExpression") -> Tuple[str, tuple]: + def format_show_variables_statement(self, expr: "ShowVariablesStatement") -> Tuple[str, tuple]: """Format SHOW VARIABLES statement.""" params = expr.get_params() session = params.get("session", True) @@ -225,7 +225,7 @@ def format_show_variables_expression(self, expr: "ShowVariablesExpression") -> T return " ".join(parts), sql_params - def format_show_status_expression(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: + def format_show_status_statement(self, expr: "ShowStatusStatement") -> Tuple[str, tuple]: """Format SHOW STATUS statement.""" params = expr.get_params() session = params.get("session", True) @@ -245,7 +245,7 @@ def format_show_status_expression(self, expr: "ShowStatusExpression") -> Tuple[s # ========== SHOW PROCESSLIST/WARNINGS/ERRORS ========== - def format_show_process_list_expression(self, expr: "ShowProcessListExpression") -> Tuple[str, tuple]: + def format_show_process_list_statement(self, expr: "ShowProcessListStatement") -> Tuple[str, tuple]: """Format SHOW PROCESSLIST statement.""" params = expr.get_params() full = params.get("full", False) @@ -254,7 +254,7 @@ def format_show_process_list_expression(self, expr: "ShowProcessListExpression") return "SHOW FULL PROCESSLIST", () return "SHOW PROCESSLIST", () - def format_show_warnings_expression(self, expr: "ShowWarningsExpression") -> Tuple[str, tuple]: + def format_show_warnings_statement(self, expr: "ShowWarningsStatement") -> Tuple[str, tuple]: """Format SHOW WARNINGS statement.""" params = expr.get_params() limit = params.get("limit") @@ -263,7 +263,7 @@ def format_show_warnings_expression(self, expr: "ShowWarningsExpression") -> Tup return f"SHOW WARNINGS LIMIT {limit}", () return "SHOW WARNINGS", () - def format_show_errors_expression(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: + def format_show_errors_statement(self, expr: "ShowErrorsStatement") -> Tuple[str, tuple]: """Format SHOW ERRORS statement.""" params = expr.get_params() limit = params.get("limit") @@ -274,11 +274,11 @@ def format_show_errors_expression(self, expr: "ShowErrorsExpression") -> Tuple[s # ========== SHOW ENGINES/CHARSET/COLLATION ========== - def format_show_engines_expression(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple]: + def format_show_engines_statement(self, expr: "ShowEnginesStatement") -> Tuple[str, tuple]: """Format SHOW ENGINES statement.""" return "SHOW ENGINES", () - def format_show_charset_expression(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple]: + def format_show_charset_statement(self, expr: "ShowCharsetStatement") -> Tuple[str, tuple]: """Format SHOW CHARACTER SET statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -287,7 +287,7 @@ def format_show_charset_expression(self, expr: "ShowCharsetExpression") -> Tuple return f"SHOW CHARACTER SET LIKE {self.p()}", (like_pattern,) return "SHOW CHARACTER SET", () - def format_show_collation_expression(self, expr: "ShowCollationExpression") -> Tuple[str, tuple]: + def format_show_collation_statement(self, expr: "ShowCollationStatement") -> Tuple[str, tuple]: """Format SHOW COLLATION statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -298,7 +298,7 @@ def format_show_collation_expression(self, expr: "ShowCollationExpression") -> T # ========== SHOW GRANTS/PLUGINS ========== - def format_show_grants_expression(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: + def format_show_grants_statement(self, expr: "ShowGrantsStatement") -> Tuple[str, tuple]: """Format SHOW GRANTS statement.""" params = expr.get_params() user = params.get("user") @@ -310,7 +310,7 @@ def format_show_grants_expression(self, expr: "ShowGrantsExpression") -> Tuple[s return f"SHOW GRANTS FOR {self.p()}", (user,) return "SHOW GRANTS", () - def format_show_plugins_expression(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: + def format_show_plugins_statement(self, expr: "ShowPluginsStatement") -> Tuple[str, tuple]: """Format SHOW PLUGINS statement.""" return "SHOW PLUGINS", () @@ -321,18 +321,18 @@ def format_show_plugins_expression(self, expr: "ShowPluginsExpression") -> Tuple # is a different command" with something other than the class, which is what # the class is for. - def format_show_binary_logs_expression(self, expr: "ShowBinaryLogsExpression") -> Tuple[str, tuple]: + def format_show_binary_logs_statement(self, expr: "ShowBinaryLogsStatement") -> Tuple[str, tuple]: """Format SHOW BINARY LOGS statement.""" return "SHOW BINARY LOGS", () - def format_show_master_status_expression( - self, expr: "ShowMasterStatusExpression" + def format_show_master_status_statement( + self, expr: "ShowMasterStatusStatement" ) -> Tuple[str, tuple]: """Format SHOW MASTER STATUS statement.""" return "SHOW MASTER STATUS", () - def format_show_slave_status_expression( - self, expr: "ShowSlaveStatusExpression" + def format_show_slave_status_statement( + self, expr: "ShowSlaveStatusStatement" ) -> Tuple[str, tuple]: """Format SHOW SLAVE STATUS statement.""" return "SHOW SLAVE STATUS", () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py index 8ed4cddd..b1519351 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py @@ -17,24 +17,24 @@ from ..expression.show import ( ShowCreateTableStatement, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) if TYPE_CHECKING: @@ -399,7 +399,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): Returns: ShowCreateViewResult with view details, or None if view doesn't exist. """ - expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_view_result(result, view_name) @@ -424,7 +424,7 @@ def columns( Returns: List of ShowColumnResult objects. """ - expr = ShowColumnsExpression( + expr = ShowColumnsStatement( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() @@ -443,7 +443,7 @@ def indexes(self, table_name: str, schema: Optional[str] = None): Returns: List of ShowIndexResult objects. """ - expr = ShowIndexExpression(self.dialect, table_name, schema=schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_indexes_result(result) @@ -466,7 +466,7 @@ def tables( Returns: List of ShowTableResult objects. """ - expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_tables_result(result) @@ -482,7 +482,7 @@ def databases(self, like: Optional[str] = None): Returns: List of ShowDatabaseResult objects. """ - expr = ShowDatabasesExpression(self.dialect, like_pattern=like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_databases_result(result) @@ -499,7 +499,7 @@ def table_status(self, schema: Optional[str] = None, like: Optional[str] = None) Returns: List of ShowTableStatusResult objects. """ - expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_table_status_result(result) @@ -516,7 +516,7 @@ def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = Non Returns: List of ShowTriggerResult objects. """ - expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_triggers_result(result) @@ -531,7 +531,7 @@ def create_trigger(self, trigger_name: str, schema: Optional[str] = None): Returns: ShowCreateTriggerResult or None if not found. """ - expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_trigger_result(result, trigger_name) @@ -548,7 +548,7 @@ def variables(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowVariableResult objects. """ - expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_variables_result(result) @@ -565,7 +565,7 @@ def status(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowStatusResult objects. """ - expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_status_result(result) @@ -581,7 +581,7 @@ def processlist(self, full: bool = False): Returns: List of ShowProcessListResult objects. """ - expr = ShowProcessListExpression(self.dialect, full=full) + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_processlist_result(result) @@ -597,7 +597,7 @@ def warnings(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowWarningsExpression(self.dialect, limit=limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_warnings_result(result) @@ -611,7 +611,7 @@ def errors(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowErrorsExpression(self.dialect, limit=limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_errors_result(result) @@ -624,7 +624,7 @@ def engines(self): Returns: List of ShowEngineResult objects. """ - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_engines_result(result) @@ -640,7 +640,7 @@ def charset(self, like: Optional[str] = None): Returns: List of ShowCharsetResult objects. """ - expr = ShowCharsetExpression(self.dialect, like_pattern=like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_charset_result(result) @@ -656,7 +656,7 @@ def collation(self, like: Optional[str] = None): Returns: List of ShowCollationResult objects. """ - expr = ShowCollationExpression(self.dialect, like_pattern=like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_collation_result(result) @@ -673,7 +673,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): Returns: List of ShowGrantResult objects. """ - expr = ShowGrantsExpression(self.dialect, user=user, host=host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_grants_result(result) @@ -686,7 +686,7 @@ def plugins(self): Returns: List of ShowPluginResult objects. """ - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_plugins_result(result) @@ -725,7 +725,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): async def create_view(self, view_name: str, schema: Optional[str] = None): """Async version of create_view.""" - expr = ShowCreateViewExpression(self.dialect, view_name, schema=schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_view_result(result, view_name) @@ -740,7 +740,7 @@ async def columns( like: Optional[str] = None, ): """Async version of columns.""" - expr = ShowColumnsExpression( + expr = ShowColumnsStatement( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() @@ -751,7 +751,7 @@ async def columns( async def indexes(self, table_name: str, schema: Optional[str] = None): """Async version of indexes.""" - expr = ShowIndexExpression(self.dialect, table_name, schema=schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_indexes_result(result) @@ -765,7 +765,7 @@ async def tables( full: bool = False, ): """Async version of tables.""" - expr = ShowTablesExpression(self.dialect, schema=schema, full=full, like_pattern=like) + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_tables_result(result) @@ -774,7 +774,7 @@ async def tables( async def databases(self, like: Optional[str] = None): """Async version of databases.""" - expr = ShowDatabasesExpression(self.dialect, like_pattern=like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_databases_result(result) @@ -783,7 +783,7 @@ async def databases(self, like: Optional[str] = None): async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Async version of table_status.""" - expr = ShowTableStatusExpression(self.dialect, schema=schema, like_pattern=like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_table_status_result(result) @@ -792,14 +792,14 @@ async def table_status(self, schema: Optional[str] = None, like: Optional[str] = async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """Async version of triggers.""" - expr = ShowTriggersExpression(self.dialect, schema=schema, table_name=table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_triggers_result(result) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Async version of create_trigger.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name, schema=schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_trigger_result(result, trigger_name) @@ -808,7 +808,7 @@ async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): async def variables(self, like: Optional[str] = None, session: bool = True): """Async version of variables.""" - expr = ShowVariablesExpression(self.dialect, like_pattern=like, session=session) + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_variables_result(result) @@ -817,7 +817,7 @@ async def variables(self, like: Optional[str] = None, session: bool = True): async def status(self, like: Optional[str] = None, session: bool = True): """Async version of status.""" - expr = ShowStatusExpression(self.dialect, like_pattern=like, session=session) + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_status_result(result) @@ -826,7 +826,7 @@ async def status(self, like: Optional[str] = None, session: bool = True): async def processlist(self, full: bool = False): """Async version of processlist.""" - expr = ShowProcessListExpression(self.dialect, full=full) + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_processlist_result(result) @@ -835,14 +835,14 @@ async def processlist(self, full: bool = False): async def warnings(self, limit: Optional[int] = None): """Async version of warnings.""" - expr = ShowWarningsExpression(self.dialect, limit=limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_warnings_result(result) async def errors(self, limit: Optional[int] = None): """Async version of errors.""" - expr = ShowErrorsExpression(self.dialect, limit=limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_errors_result(result) @@ -851,7 +851,7 @@ async def errors(self, limit: Optional[int] = None): async def engines(self): """Async version of engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_engines_result(result) @@ -860,7 +860,7 @@ async def engines(self): async def charset(self, like: Optional[str] = None): """Async version of charset.""" - expr = ShowCharsetExpression(self.dialect, like_pattern=like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_charset_result(result) @@ -869,7 +869,7 @@ async def charset(self, like: Optional[str] = None): async def collation(self, like: Optional[str] = None): """Async version of collation.""" - expr = ShowCollationExpression(self.dialect, like_pattern=like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_collation_result(result) @@ -878,7 +878,7 @@ async def collation(self, like: Optional[str] = None): async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Async version of grants.""" - expr = ShowGrantsExpression(self.dialect, user=user, host=host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_grants_result(result) @@ -887,7 +887,7 @@ async def grants(self, user: Optional[str] = None, host: Optional[str] = None): async def plugins(self): """Async version of plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_plugins_result(result) diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py index 6629b6ae..9e365a6f 100644 --- a/tests/providers/fixtures/__init__.py +++ b/tests/providers/fixtures/__init__.py @@ -11,7 +11,7 @@ remain as the authoritative reference for what the expressions here must produce; they are simply no longer read at runtime by the providers. -Because the MySQL backend's ``format_storage_options`` emits quoted values +Because the MySQL backend's ``format_storage_options_expression`` emits quoted values (e.g. ``ENGINE='InnoDB'``) while the reference SQL files use the unquoted form, each factory routes its expression through :func:`to_mysql_ddl_sql` which post-processes the storage option clause to drop the quotes, producing diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 228e0657..3e7b895e 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/_common.py """Shared helpers for the MySQL DDL expression fixtures. -The MySQL backend's :meth:`format_storage_options` quotes string option +The MySQL backend's :meth:`format_storage_options_expression` quotes string option values (``ENGINE='InnoDB'``), while the authoritative ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` use the unquoted storage form. :func:`to_mysql_ddl_sql` post-processes the diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py index 327044e0..23ae9fbe 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py @@ -13,7 +13,7 @@ import pytest_asyncio from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.impl.mysql.expression import MySQLLoadDataExpression, LoadDataOptions +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLLoadDataStatement, LoadDataOptions class TestMySQLLoadData: @@ -39,8 +39,8 @@ def test_supports_load_data(self, mysql_backend): assert mysql_backend.dialect.supports_load_data() is True def test_load_data_expression_basic(self, mysql_backend): - """Test basic MySQLLoadDataExpression generation.""" - expr = MySQLLoadDataExpression( + """Test basic MySQLLoadDataStatement generation.""" + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -54,7 +54,7 @@ def test_load_data_expression_basic(self, mysql_backend): def test_load_data_expression_qualified_database(self, mysql_backend): """A database qualifier is expressible; a bare string could never carry one.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users", catalog_name="other_db"), @@ -65,7 +65,7 @@ def test_load_data_expression_qualified_database(self, mysql_backend): def test_load_data_expression_local(self, mysql_backend): """Test LOAD DATA LOCAL expression.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -77,7 +77,7 @@ def test_load_data_expression_local(self, mysql_backend): def test_load_data_expression_fields_terminated(self, mysql_backend): """Test LOAD DATA with custom field terminator.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -89,7 +89,7 @@ def test_load_data_expression_fields_terminated(self, mysql_backend): def test_load_data_expression_ignore_lines(self, mysql_backend): """Test LOAD DATA with IGNORE n LINES.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -101,7 +101,7 @@ def test_load_data_expression_ignore_lines(self, mysql_backend): def test_load_data_expression_replace_mode(self, mysql_backend): """Test LOAD DATA with REPLACE mode.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -113,7 +113,7 @@ def test_load_data_expression_replace_mode(self, mysql_backend): def test_load_data_expression_ignore_mode(self, mysql_backend): """Test LOAD DATA with IGNORE mode.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -126,7 +126,7 @@ def test_load_data_expression_ignore_mode(self, mysql_backend): def test_load_data_replace_and_ignore_mutually_exclusive(self, mysql_backend): """Test that REPLACE and IGNORE are mutually exclusive.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -138,7 +138,7 @@ def test_load_data_replace_and_ignore_mutually_exclusive(self, mysql_backend): def test_load_data_expression_with_columns(self, mysql_backend): """Test LOAD DATA with column list.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -150,7 +150,7 @@ def test_load_data_expression_with_columns(self, mysql_backend): def test_load_data_expression_with_lines_terminated(self, mysql_backend): """Test LOAD DATA with custom line terminator.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -162,7 +162,7 @@ def test_load_data_expression_with_lines_terminated(self, mysql_backend): def test_load_data_expression_character_set(self, mysql_backend): """Test LOAD DATA with character set.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -174,7 +174,7 @@ def test_load_data_expression_character_set(self, mysql_backend): def test_load_data_expression_full_csv(self, mysql_backend): """Test full LOAD DATA expression for CSV import.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/data.csv", table=Table(mysql_backend.dialect, "users"), @@ -218,7 +218,7 @@ async def test_table(self, async_mysql_backend): async def test_load_data_expression_async(self, async_mysql_backend, test_table): """Test async LOAD DATA expression generation.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=async_mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(async_mysql_backend.dialect, test_table), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py index eec21b68..d28064b9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py @@ -16,14 +16,14 @@ import pytest from rhosocial.activerecord.backend.expression.introspection import ( - ColumnInfoExpression, - DatabaseInfoExpression, - ForeignKeyExpression, - IndexInfoExpression, - TableListExpression, - TriggerListExpression, - ViewInfoExpression, - ViewListExpression, + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -35,7 +35,7 @@ def dialect(): class TestDatabaseInfo: def test_projection_and_bound_schema(self, dialect): - sql, params = DatabaseInfoExpression(dialect, schema="app").to_sql() + sql, params = DatabaseInfoQueryStatement(dialect, schema="app").to_sql() assert sql == ( "SELECT `DEFAULT_CHARACTER_SET_NAME`, `DEFAULT_COLLATION_NAME` " "FROM `information_schema`.`SCHEMATA` WHERE `SCHEMA_NAME` = %s" @@ -43,7 +43,7 @@ def test_projection_and_bound_schema(self, dialect): assert params == ("app",) def test_a_missing_schema_is_bound_as_null(self, dialect): - _, params = DatabaseInfoExpression(dialect).to_sql() + _, params = DatabaseInfoQueryStatement(dialect).to_sql() assert params == (None,) @@ -51,7 +51,7 @@ class TestTableList: """The relation is ``information_schema.TABLES`` with the filters as nodes.""" def test_default_excludes_the_engine_s_own_schemas(self, dialect): - sql, params = TableListExpression(dialect, schema="app").to_sql() + sql, params = TableListQueryStatement(dialect, schema="app").to_sql() assert sql.startswith( "SELECT `TABLE_NAME`, `TABLE_TYPE`, `TABLE_COMMENT`, `TABLE_ROWS`, " "`DATA_LENGTH`, `AUTO_INCREMENT`, `CREATE_TIME`, `UPDATE_TIME` " @@ -63,24 +63,24 @@ def test_default_excludes_the_engine_s_own_schemas(self, dialect): assert params == ("app",) def test_include_system_drops_the_exclusion(self, dialect): - sql, params = TableListExpression(dialect, schema="app", include_system=True).to_sql() + sql, params = TableListQueryStatement(dialect, schema="app", include_system=True).to_sql() assert "NOT" not in sql assert params == ("app",) def test_without_views_is_an_inline_type_filter(self, dialect): - sql, params = TableListExpression(dialect, schema="app", include_views=False).to_sql() + sql, params = TableListQueryStatement(dialect, schema="app", include_views=False).to_sql() assert "`TABLE_TYPE` = 'BASE TABLE'" in sql assert params == ("app",) def test_explicit_table_type_is_bound_last(self, dialect): - sql, params = TableListExpression(dialect, schema="app", table_type="VIEW").to_sql() + sql, params = TableListQueryStatement(dialect, schema="app", table_type="VIEW").to_sql() assert sql.endswith("`TABLE_TYPE` = %s") assert params == ("app", "VIEW") class TestColumnAndIndexInfo: def test_columns_projection_order_and_ordering(self, dialect): - sql, params = ColumnInfoExpression(dialect, "users", schema="app").to_sql() + sql, params = ColumnInfoQueryStatement(dialect, "users", schema="app").to_sql() assert sql == ( "SELECT `COLUMN_NAME`, `ORDINAL_POSITION`, `COLUMN_DEFAULT`, `IS_NULLABLE`, " "`DATA_TYPE`, `CHARACTER_MAXIMUM_LENGTH`, `NUMERIC_PRECISION`, `NUMERIC_SCALE`, " @@ -93,7 +93,7 @@ def test_columns_projection_order_and_ordering(self, dialect): assert params == ("app", "users") def test_indexes_projection_and_ordering(self, dialect): - sql, params = IndexInfoExpression(dialect, "users", schema="app").to_sql() + sql, params = IndexInfoQueryStatement(dialect, "users", schema="app").to_sql() assert sql == ( "SELECT `INDEX_NAME`, `NON_UNIQUE`, `SEQ_IN_INDEX`, `COLUMN_NAME`, " "`INDEX_TYPE`, `SUB_PART`, `NULLABLE` " @@ -108,7 +108,7 @@ class TestForeignKey: """Two relations joined, filtered to rows that actually reference a table.""" def test_join_projection_and_predicates(self, dialect): - sql, params = ForeignKeyExpression(dialect, "orders", schema="app").to_sql() + sql, params = ForeignKeyQueryStatement(dialect, "orders", schema="app").to_sql() assert sql == ( "SELECT `kcu`.`CONSTRAINT_NAME`, `kcu`.`COLUMN_NAME`, `kcu`.`ORDINAL_POSITION`, " "`kcu`.`REFERENCED_TABLE_NAME`, `kcu`.`REFERENCED_COLUMN_NAME`, " @@ -126,7 +126,7 @@ def test_join_projection_and_predicates(self, dialect): class TestViews: def test_view_list_has_the_system_exclusion_and_ordering(self, dialect): - sql, params = ViewListExpression(dialect, schema="app").to_sql() + sql, params = ViewListQueryStatement(dialect, schema="app").to_sql() assert sql.startswith( "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " "FROM `information_schema`.`VIEWS` WHERE `TABLE_SCHEMA` = %s" @@ -136,12 +136,12 @@ def test_view_list_has_the_system_exclusion_and_ordering(self, dialect): assert params == ("app",) def test_view_list_with_system_objects(self, dialect): - sql, params = ViewListExpression(dialect, schema="app", include_system=True).to_sql() + sql, params = ViewListQueryStatement(dialect, schema="app", include_system=True).to_sql() assert "NOT" not in sql assert params == ("app",) def test_view_info_names_one_view(self, dialect): - sql, params = ViewInfoExpression(dialect, "v_users", schema="app").to_sql() + sql, params = ViewInfoQueryStatement(dialect, "v_users", schema="app").to_sql() assert sql == ( "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " "FROM `information_schema`.`VIEWS` " @@ -152,7 +152,7 @@ def test_view_info_names_one_view(self, dialect): class TestTriggers: def test_list_all(self, dialect): - sql, params = TriggerListExpression(dialect, schema="app").to_sql() + sql, params = TriggerListQueryStatement(dialect, schema="app").to_sql() assert sql == ( "SELECT `TRIGGER_NAME`, `EVENT_MANIPULATION`, `EVENT_OBJECT_TABLE`, " "`ACTION_TIMING`, `ACTION_STATEMENT`, `CREATED` " @@ -162,7 +162,7 @@ def test_list_all(self, dialect): assert params == ("app",) def test_list_for_one_table_binds_the_name_last(self, dialect): - sql, params = TriggerListExpression(dialect, schema="app", table_name="users").to_sql() + sql, params = TriggerListQueryStatement(dialect, schema="app", table_name="users").to_sql() assert sql.endswith("`EVENT_OBJECT_TABLE` = %s ORDER BY `TRIGGER_NAME`") assert params == ("app", "users") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py index 83aae31b..594d70c8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py @@ -22,29 +22,29 @@ class forgot to store under its own name fails there rather than surviving as from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression.show import ( - ShowBinaryLogsExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowColumnsExpression, + ShowBinaryLogsStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowColumnsStatement, ShowCreateTableStatement, - ShowCreateTriggerExpression, - ShowCreateViewExpression, - ShowDatabasesExpression, - ShowEnginesExpression, - ShowErrorsExpression, + ShowCreateTriggerStatement, + ShowCreateViewStatement, + ShowDatabasesStatement, + ShowEnginesStatement, + ShowErrorsStatement, ShowExpression, - ShowGrantsExpression, - ShowIndexExpression, - ShowMasterStatusExpression, - ShowPluginsExpression, - ShowProcessListExpression, - ShowSlaveStatusExpression, - ShowStatusExpression, - ShowTableStatusExpression, - ShowTablesExpression, - ShowTriggersExpression, - ShowVariablesExpression, - ShowWarningsExpression, + ShowGrantsStatement, + ShowIndexStatement, + ShowMasterStatusStatement, + ShowPluginsStatement, + ShowProcessListStatement, + ShowSlaveStatusStatement, + ShowStatusStatement, + ShowTableStatusStatement, + ShowTablesStatement, + ShowTriggersStatement, + ShowVariablesStatement, + ShowWarningsStatement, ) @@ -68,27 +68,27 @@ def test_no_fluent_setter_survives(self): "build", [ lambda d: ShowCreateTableStatement(d, "t"), - lambda d: ShowCreateViewExpression(d, "v"), - lambda d: ShowColumnsExpression(d, "t", full=True, like_pattern="id%"), - lambda d: ShowIndexExpression(d, "t"), - lambda d: ShowTablesExpression(d, full=True, like_pattern="t%"), - lambda d: ShowDatabasesExpression(d, like_pattern="a%"), - lambda d: ShowTableStatusExpression(d, like_pattern="t%"), - lambda d: ShowTriggersExpression(d, table_name="t"), - lambda d: ShowCreateTriggerExpression(d, "trg"), - lambda d: ShowVariablesExpression(d, like_pattern="max%"), - lambda d: ShowStatusExpression(d, like_pattern="Ssl_version", session=False), - lambda d: ShowProcessListExpression(d, full=True), - lambda d: ShowWarningsExpression(d, limit=3), - lambda d: ShowErrorsExpression(d, limit=3), - lambda d: ShowEnginesExpression(d), - lambda d: ShowCharsetExpression(d, like_pattern="utf%"), - lambda d: ShowCollationExpression(d, like_pattern="utf8%"), - lambda d: ShowGrantsExpression(d, user="root", host="localhost"), - lambda d: ShowPluginsExpression(d), - lambda d: ShowBinaryLogsExpression(d), - lambda d: ShowMasterStatusExpression(d), - lambda d: ShowSlaveStatusExpression(d), + lambda d: ShowCreateViewStatement(d, "v"), + lambda d: ShowColumnsStatement(d, "t", full=True, like_pattern="id%"), + lambda d: ShowIndexStatement(d, "t"), + lambda d: ShowTablesStatement(d, full=True, like_pattern="t%"), + lambda d: ShowDatabasesStatement(d, like_pattern="a%"), + lambda d: ShowTableStatusStatement(d, like_pattern="t%"), + lambda d: ShowTriggersStatement(d, table_name="t"), + lambda d: ShowCreateTriggerStatement(d, "trg"), + lambda d: ShowVariablesStatement(d, like_pattern="max%"), + lambda d: ShowStatusStatement(d, like_pattern="Ssl_version", session=False), + lambda d: ShowProcessListStatement(d, full=True), + lambda d: ShowWarningsStatement(d, limit=3), + lambda d: ShowErrorsStatement(d, limit=3), + lambda d: ShowEnginesStatement(d), + lambda d: ShowCharsetStatement(d, like_pattern="utf%"), + lambda d: ShowCollationStatement(d, like_pattern="utf8%"), + lambda d: ShowGrantsStatement(d, user="root", host="localhost"), + lambda d: ShowPluginsStatement(d), + lambda d: ShowBinaryLogsStatement(d), + lambda d: ShowMasterStatusStatement(d), + lambda d: ShowSlaveStatusStatement(d), ], ids=lambda build: build(MySQLDialect(version=(8, 0, 0))).__class__.__name__, ) @@ -112,11 +112,11 @@ def test_database_qualifies_the_table(self, dialect): assert sql == "SHOW CREATE TABLE `app`.`users`" def test_view(self, dialect): - sql, _ = ShowCreateViewExpression(dialect, "v", schema="app").to_sql() + sql, _ = ShowCreateViewStatement(dialect, "v", schema="app").to_sql() assert sql == "SHOW CREATE VIEW `app`.`v`" def test_trigger(self, dialect): - sql, _ = ShowCreateTriggerExpression(dialect, "trg").to_sql() + sql, _ = ShowCreateTriggerStatement(dialect, "trg").to_sql() assert sql == "SHOW CREATE TRIGGER `trg`" @@ -124,45 +124,45 @@ class TestShowColumnsAndIndex: """The pattern is bound; FULL and the verb are spelled, not parameterised.""" def test_plain(self, dialect): - sql, params = ShowColumnsExpression(dialect, "users", schema="app").to_sql() + sql, params = ShowColumnsStatement(dialect, "users", schema="app").to_sql() assert sql == "SHOW COLUMNS FROM `app`.`users`" assert params == () def test_full_and_like(self, dialect): - sql, params = ShowColumnsExpression( + sql, params = ShowColumnsStatement( dialect, "users", full=True, like_pattern="id%" ).to_sql() assert sql == "SHOW FULL COLUMNS FROM `users` LIKE %s" assert params == ("id%",) def test_index(self, dialect): - sql, params = ShowIndexExpression(dialect, "users", schema="app").to_sql() + sql, params = ShowIndexStatement(dialect, "users", schema="app").to_sql() assert sql == "SHOW INDEX FROM `app`.`users`" assert params == () class TestShowTablesAndDatabases: def test_tables_full_like_and_schema(self, dialect): - sql, params = ShowTablesExpression( + sql, params = ShowTablesStatement( dialect, schema="app", full=True, like_pattern="u%" ).to_sql() assert sql == "SHOW FULL TABLES FROM `app` LIKE %s" assert params == ("u%",) def test_tables_without_filters(self, dialect): - assert ShowTablesExpression(dialect).to_sql() == ("SHOW TABLES", ()) + assert ShowTablesStatement(dialect).to_sql() == ("SHOW TABLES", ()) def test_databases_like(self, dialect): - assert ShowDatabasesExpression(dialect, like_pattern="a%").to_sql() == ( + assert ShowDatabasesStatement(dialect, like_pattern="a%").to_sql() == ( "SHOW DATABASES LIKE %s", ("a%",), ) def test_databases_without_pattern(self, dialect): - assert ShowDatabasesExpression(dialect).to_sql() == ("SHOW DATABASES", ()) + assert ShowDatabasesStatement(dialect).to_sql() == ("SHOW DATABASES", ()) def test_table_status(self, dialect): - assert ShowTableStatusExpression(dialect, schema="app", like_pattern="u%").to_sql() == ( + assert ShowTableStatusStatement(dialect, schema="app", like_pattern="u%").to_sql() == ( "SHOW TABLE STATUS FROM `app` LIKE %s", ("u%",), ) @@ -172,24 +172,24 @@ class TestShowVariablesAndStatus: """Session is the default; the GLOBAL word is the difference.""" def test_variables_session(self, dialect): - assert ShowVariablesExpression(dialect, like_pattern="max%").to_sql() == ( + assert ShowVariablesStatement(dialect, like_pattern="max%").to_sql() == ( "SHOW VARIABLES LIKE %s", ("max%",), ) def test_variables_global(self, dialect): - assert ShowVariablesExpression( + assert ShowVariablesStatement( dialect, like_pattern="gtid_executed", session=False ).to_sql() == ("SHOW GLOBAL VARIABLES LIKE %s", ("gtid_executed",)) def test_status_session(self, dialect): - assert ShowStatusExpression(dialect, like_pattern="Ssl_version").to_sql() == ( + assert ShowStatusStatement(dialect, like_pattern="Ssl_version").to_sql() == ( "SHOW STATUS LIKE %s", ("Ssl_version",), ) def test_status_global(self, dialect): - assert ShowStatusExpression(dialect, like_pattern="Ssl_version", session=False).to_sql() == ( + assert ShowStatusStatement(dialect, like_pattern="Ssl_version", session=False).to_sql() == ( "SHOW GLOBAL STATUS LIKE %s", ("Ssl_version",), ) @@ -199,13 +199,13 @@ class TestShowTriggers: """``SHOW TRIGGERS`` filters by table through LIKE, not through a clause.""" def test_all(self, dialect): - assert ShowTriggersExpression(dialect, schema="app").to_sql() == ( + assert ShowTriggersStatement(dialect, schema="app").to_sql() == ( "SHOW TRIGGERS FROM `app`", (), ) def test_one_table(self, dialect): - assert ShowTriggersExpression(dialect, schema="app", table_name="users").to_sql() == ( + assert ShowTriggersStatement(dialect, schema="app", table_name="users").to_sql() == ( "SHOW TRIGGERS FROM `app` LIKE %s", ("users",), ) @@ -215,22 +215,22 @@ class TestLimitAndFull: """The count is a literal: the grammar takes a number, not a bind slot.""" def test_processlist(self, dialect): - assert ShowProcessListExpression(dialect).to_sql() == ("SHOW PROCESSLIST", ()) + assert ShowProcessListStatement(dialect).to_sql() == ("SHOW PROCESSLIST", ()) def test_full_processlist(self, dialect): - assert ShowProcessListExpression(dialect, full=True).to_sql() == ( + assert ShowProcessListStatement(dialect, full=True).to_sql() == ( "SHOW FULL PROCESSLIST", (), ) def test_warnings_limit_is_inline(self, dialect): - assert ShowWarningsExpression(dialect, limit=10).to_sql() == ( + assert ShowWarningsStatement(dialect, limit=10).to_sql() == ( "SHOW WARNINGS LIMIT 10", (), ) def test_errors_limit_is_inline(self, dialect): - assert ShowErrorsExpression(dialect, limit=5).to_sql() == ( + assert ShowErrorsStatement(dialect, limit=5).to_sql() == ( "SHOW ERRORS LIMIT 5", (), ) @@ -242,11 +242,11 @@ class TestParameterless: @pytest.mark.parametrize( "node, expected", [ - (ShowEnginesExpression, "SHOW ENGINES"), - (ShowPluginsExpression, "SHOW PLUGINS"), - (ShowBinaryLogsExpression, "SHOW BINARY LOGS"), - (ShowMasterStatusExpression, "SHOW MASTER STATUS"), - (ShowSlaveStatusExpression, "SHOW SLAVE STATUS"), + (ShowEnginesStatement, "SHOW ENGINES"), + (ShowPluginsStatement, "SHOW PLUGINS"), + (ShowBinaryLogsStatement, "SHOW BINARY LOGS"), + (ShowMasterStatusStatement, "SHOW MASTER STATUS"), + (ShowSlaveStatusStatement, "SHOW SLAVE STATUS"), ], ids=lambda value: getattr(value, "__name__", ""), ) @@ -256,16 +256,16 @@ def test_renders(self, dialect, node, expected): class TestGrants: def test_current_account(self, dialect): - assert ShowGrantsExpression(dialect).to_sql() == ("SHOW GRANTS", ()) + assert ShowGrantsStatement(dialect).to_sql() == ("SHOW GRANTS", ()) def test_named_account(self, dialect): - assert ShowGrantsExpression(dialect, user="root", host="localhost").to_sql() == ( + assert ShowGrantsStatement(dialect, user="root", host="localhost").to_sql() == ( "SHOW GRANTS FOR %s@%s", ("root", "localhost"), ) def test_user_without_host(self, dialect): - assert ShowGrantsExpression(dialect, user="root").to_sql() == ( + assert ShowGrantsStatement(dialect, user="root").to_sql() == ( "SHOW GRANTS FOR %s", ("root",), ) @@ -273,13 +273,13 @@ def test_user_without_host(self, dialect): class TestCharsetAndCollation: def test_charset(self, dialect): - assert ShowCharsetExpression(dialect, like_pattern="utf%").to_sql() == ( + assert ShowCharsetStatement(dialect, like_pattern="utf%").to_sql() == ( "SHOW CHARACTER SET LIKE %s", ("utf%",), ) def test_collation(self, dialect): - assert ShowCollationExpression(dialect, like_pattern="utf8%").to_sql() == ( + assert ShowCollationStatement(dialect, like_pattern="utf8%").to_sql() == ( "SHOW COLLATION LIKE %s", ("utf8%",), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py index 6d824c8f..f1f2f5bf 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py @@ -341,9 +341,9 @@ class that was split needs one entry per resulting class. "datetime.DatePartExpression": _datepart_expr, "datetime.DateTruncExpression": _datetrunc_expr, "datetime.IntervalExpression": _interval_expr, - "datetime.DateTimeDiffExpression": _datetime_diff_expr, - "datetime.DateTimeSubtractExpression": _datetime_sub_expr, - "datetime.DateTimeAddExpression": _datetime_add_expr, + "datetime.DatetimeDiffExpression": _datetime_diff_expr, + "datetime.DatetimeSubtractExpression": _datetime_sub_expr, + "datetime.DatetimeAddExpression": _datetime_add_expr, } @@ -901,29 +901,29 @@ def _interval_expr(dialect): def _datetime_diff_expr(dialect): from rhosocial.activerecord.backend.expression.core import Column - from rhosocial.activerecord.backend.expression.datetime import DateTimeDiffExpression + from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression - return DateTimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + return DatetimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) def _datetime_sub_expr(dialect): from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.datetime import ( - DateTimeSubtractExpression, + DatetimeSubtractExpression, IntervalExpression, ) - return DateTimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + return DatetimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) def _datetime_add_expr(dialect): from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.datetime import ( - DateTimeAddExpression, + DatetimeAddExpression, IntervalExpression, ) - return DateTimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + return DatetimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) _SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py index 36c51853..42a86a80 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py @@ -8,8 +8,8 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.expression import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityViewDMLTag, @@ -42,7 +42,7 @@ def _product_spec(*columns, tags=()): def _create_products_view(backend, spec, *, replace=False): - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( backend.dialect, "products_dv", spec, @@ -163,7 +163,7 @@ def test_drop_view(self, duality_backend): backend = duality_backend _create_products_view(backend, _product_spec("_id")) - drop_expr = DropJsonDualityViewExpression(backend.dialect, "products_dv") + drop_expr = DropJsonDualityViewStatement(backend.dialect, "products_dv") backend.execute(*drop_expr.to_sql(), options=ExecutionOptions(stmt_type=StatementType.DDL)) result = _select_products_view_metadata(backend) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py index 8565bf83..3d38f3e9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py @@ -47,7 +47,7 @@ def to_sql(self): class TestFlushBranches: def test_no_write_to_binlog_multi_options(self, dialect): - expr = mysql_expr.MySQLFlushExpression( + expr = mysql_expr.MySQLFlushStatement( dialect, [mysql_expr.FlushOption.PRIVILEGES, mysql_expr.FlushOption.LOGS], no_write_to_binlog=True, @@ -56,14 +56,14 @@ def test_no_write_to_binlog_multi_options(self, dialect): assert sql == "FLUSH NO_WRITE_TO_BINLOG PRIVILEGES, LOGS" def test_empty_raises(self, dialect): - expr = mysql_expr.MySQLFlushExpression(dialect, []) + expr = mysql_expr.MySQLFlushStatement(dialect, []) with pytest.raises(ValueError, match="at least one option"): expr.to_sql() def test_non_strict_skips_validation(self, dialect): dialect.strict_validation = False try: - sql, _ = mysql_expr.MySQLFlushExpression(dialect, []).to_sql() + sql, _ = mysql_expr.MySQLFlushStatement(dialect, []).to_sql() assert sql == "FLUSH " finally: dialect.strict_validation = True @@ -71,7 +71,7 @@ def test_non_strict_skips_validation(self, dialect): class TestCacheIndex: def test_cache_index_without_indexes(self, dialect): - expr = mysql_expr.MySQLCacheIndexExpression( + expr = mysql_expr.MySQLCacheIndexStatement( dialect, [{"table": Table(dialect, "t1")}, {"table": Table(dialect, "t2", catalog_name="db")}], "shared", @@ -80,14 +80,14 @@ def test_cache_index_without_indexes(self, dialect): assert sql == "CACHE INDEX `t1`, `db`.`t2` IN `shared`" def test_cache_index_with_indexes(self, dialect): - expr = mysql_expr.MySQLCacheIndexExpression( + expr = mysql_expr.MySQLCacheIndexStatement( dialect, [{"table": Table(dialect, "t1"), "indexes": ["i1", "i2"]}], "shared" ) sql, _ = expr.to_sql() assert sql == "CACHE INDEX `t1` INDEX (`i1`, `i2`) IN `shared`" def test_load_index_into_cache(self, dialect): - expr = mysql_expr.MySQLLoadIndexIntoCacheExpression( + expr = mysql_expr.MySQLLoadIndexIntoCacheStatement( dialect, [{"table": Table(dialect, "t1"), "indexes": ["i1"]}] ) sql, _ = expr.to_sql() @@ -96,24 +96,24 @@ def test_load_index_into_cache(self, dialect): class TestComponentPlugin: def test_install_component_multi(self, dialect): - expr = mysql_expr.MySQLInstallComponentExpression(dialect, ["comp1", "comp2"]) + expr = mysql_expr.MySQLInstallComponentStatement(dialect, ["comp1", "comp2"]) sql, _ = expr.to_sql() assert sql == "INSTALL COMPONENT 'comp1', 'comp2'" def test_uninstall_component_multi(self, dialect): - expr = mysql_expr.MySQLUninstallComponentExpression(dialect, ["comp1", "comp2"]) + expr = mysql_expr.MySQLUninstallComponentStatement(dialect, ["comp1", "comp2"]) sql, _ = expr.to_sql() assert sql == "UNINSTALL COMPONENT 'comp1', 'comp2'" def test_uninstall_plugin(self, dialect): - expr = mysql_expr.MySQLUninstallPluginExpression(dialect, "p") + expr = mysql_expr.MySQLUninstallPluginStatement(dialect, "p") sql, _ = expr.to_sql() assert sql == "UNINSTALL PLUGIN `p`" class TestClone: def test_clone_instance_from(self, dialect): - expr = mysql_expr.MySQLCloneExpression( + expr = mysql_expr.MySQLCloneStatement( dialect, from_user="root", from_host="donor.example.com", @@ -127,46 +127,46 @@ def test_clone_instance_from(self, dialect): ) def test_clone_instance_default_port(self, dialect): - expr = mysql_expr.MySQLCloneExpression(dialect, from_user="u", from_host="h") + expr = mysql_expr.MySQLCloneStatement(dialect, from_user="u", from_host="h") sql, _ = expr.to_sql() assert sql == "CLONE INSTANCE FROM 'u'@'h' PORT = 3306" def test_clone_local(self, dialect): - expr = mysql_expr.MySQLCloneExpression( + expr = mysql_expr.MySQLCloneStatement( dialect, from_data_directory="/from", to_data_directory="/to" ) sql, _ = expr.to_sql() assert sql == "CLONE LOCAL DATA DIRECTORY '/from' TO DATA DIRECTORY = '/to'" def test_clone_local_no_dest(self, dialect): - expr = mysql_expr.MySQLCloneExpression(dialect, from_data_directory="/from") + expr = mysql_expr.MySQLCloneStatement(dialect, from_data_directory="/from") sql, _ = expr.to_sql() assert sql == "CLONE LOCAL DATA DIRECTORY '/from'" class TestInstanceCommands: def test_restart(self, dialect): - sql, _ = mysql_expr.MySQLRestartExpression(dialect).to_sql() + sql, _ = mysql_expr.MySQLRestartStatement(dialect).to_sql() assert sql == "RESTART" def test_binlog(self, dialect): - sql, _ = mysql_expr.MySQLBinlogExpression(dialect, "ZWNvZGVk").to_sql() + sql, _ = mysql_expr.MySQLBinlogStatement(dialect, "ZWNvZGVk").to_sql() assert sql == "BINLOG 'ZWNvZGVk'" class TestHandler: def test_open_no_alias(self, dialect): - expr = mysql_expr.MySQLHandlerOpenExpression(dialect, Table(dialect, "t")) + expr = mysql_expr.MySQLHandlerOpenStatement(dialect, Table(dialect, "t")) sql, _ = expr.to_sql() assert sql == "HANDLER `t` OPEN" def test_open_alias(self, dialect): - expr = mysql_expr.MySQLHandlerOpenExpression(dialect, Table(dialect, "t"), alias="a") + expr = mysql_expr.MySQLHandlerOpenStatement(dialect, Table(dialect, "t"), alias="a") sql, _ = expr.to_sql() assert sql == "HANDLER `t` OPEN AS `a`" def test_read_first_no_extras(self, dialect): - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t"), mysql_expr.HandlerReadMode.FIRST ) sql, params = expr.to_sql() @@ -174,7 +174,7 @@ def test_read_first_no_extras(self, dialect): assert params == () def test_read_index_key_value(self, dialect): - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t"), mysql_expr.HandlerReadMode.NEXT, @@ -188,7 +188,7 @@ def test_read_index_key_value(self, dialect): def test_read_where_limit_expr_key(self, dialect): where = _InspectableExpr("x > %s", (2,)) key = _InspectableExpr("3 + 4", ()) - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t", catalog_name="db"), mysql_expr.HandlerReadMode.LAST, @@ -201,20 +201,20 @@ def test_read_where_limit_expr_key(self, dialect): assert params == (2,) def test_close(self, dialect): - expr = mysql_expr.MySQLHandlerCloseExpression(dialect, Table(dialect, "t", catalog_name="db")) + expr = mysql_expr.MySQLHandlerCloseStatement(dialect, Table(dialect, "t", catalog_name="db")) sql, _ = expr.to_sql() assert sql == "HANDLER `db`.`t` CLOSE" class TestDo: def test_plain_values(self, dialect): - expr = mysql_expr.MySQLDoExpression(dialect, [1, "x"]) + expr = mysql_expr.MySQLDoStatement(dialect, [1, "x"]) sql, params = expr.to_sql() assert sql == "DO %s, %s" assert params == (1, "x") def test_expression_values(self, dialect): - expr = mysql_expr.MySQLDoExpression(dialect, [_InspectableExpr("1 + 1", ())]) + expr = mysql_expr.MySQLDoStatement(dialect, [_InspectableExpr("1 + 1", ())]) sql, params = expr.to_sql() assert sql == "DO 1 + 1" assert params == () @@ -222,23 +222,23 @@ def test_expression_values(self, dialect): class TestKillShutdownHelp: def test_kill_query(self, dialect): - sql, _ = mysql_expr.MySQLKillExpression( + sql, _ = mysql_expr.MySQLKillStatement( dialect, 42, mysql_expr.KillTarget.QUERY ).to_sql() assert sql == "KILL QUERY 42" def test_shutdown(self, dialect): - sql, _ = mysql_expr.MySQLShutdownExpression(dialect).to_sql() + sql, _ = mysql_expr.MySQLShutdownStatement(dialect).to_sql() assert sql == "SHUTDOWN" def test_help(self, dialect): - sql, _ = mysql_expr.MySQLHelpExpression(dialect, "CREATE TABLE").to_sql() + sql, _ = mysql_expr.MySQLHelpStatement(dialect, "CREATE TABLE").to_sql() assert sql == "HELP 'CREATE TABLE'" class TestAccountManagement: def test_create_user(self, dialect): - expr = mysql_expr.MySQLCreateUserExpression( + expr = mysql_expr.MySQLCreateUserStatement( dialect, [mysql_expr.AccountSpec("alice"), mysql_expr.AccountSpec("bob", "%.example.com")], if_not_exists=True, @@ -251,12 +251,12 @@ def test_create_user(self, dialect): ) def test_create_user_no_options(self, dialect): - expr = mysql_expr.MySQLCreateUserExpression(dialect, [mysql_expr.AccountSpec("u", "h")]) + expr = mysql_expr.MySQLCreateUserStatement(dialect, [mysql_expr.AccountSpec("u", "h")]) sql, _ = expr.to_sql() assert sql == "CREATE USER 'u'@'h'" def test_drop_user(self, dialect): - expr = mysql_expr.MySQLDropUserExpression( + expr = mysql_expr.MySQLDropUserStatement( dialect, [mysql_expr.AccountSpec("u", "h")], if_exists=True ) sql, _ = expr.to_sql() @@ -265,7 +265,7 @@ def test_drop_user(self, dialect): class TestGrantRevoke: def test_grant_with_columns_and_option(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [ mysql_expr.GrantPrivilege("SELECT", ["c1", "c2"]), @@ -281,14 +281,14 @@ def test_grant_with_columns_and_option(self, dialect): ) def test_grant_default_object(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [mysql_expr.GrantPrivilege("ALL PRIVILEGES")], [mysql_expr.AccountSpec("u")] ) sql, _ = expr.to_sql() assert sql == "GRANT ALL PRIVILEGES ON *.* TO 'u'@'%'" def test_revoke_with_columns(self, dialect): - expr = mysql_expr.MySQLRevokeExpression( + expr = mysql_expr.MySQLRevokeStatement( dialect, [mysql_expr.GrantPrivilege("SELECT", ["c1"])], [mysql_expr.AccountSpec("u", "h")], @@ -298,7 +298,7 @@ def test_revoke_with_columns(self, dialect): assert sql == "REVOKE SELECT (`c1`) ON db.t FROM 'u'@'h'" def test_revoke_default_object(self, dialect): - expr = mysql_expr.MySQLRevokeExpression( + expr = mysql_expr.MySQLRevokeStatement( dialect, [mysql_expr.GrantPrivilege("SELECT")], [mysql_expr.AccountSpec("u")] ) sql, _ = expr.to_sql() @@ -405,22 +405,22 @@ class TestNonStrictValidation: """strict=False short-circuits validate() in each expression.""" def test_flush(self, dialect): - mysql_expr.MySQLFlushExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLFlushStatement(dialect, []).validate(strict=False) def test_load_xml(self, dialect): mysql_expr.MySQLLoadXMLStatement(dialect, 123, 456).validate(strict=False) def test_maintenance(self, dialect): - mysql_expr.MySQLAnalyzeTableExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLAnalyzeTableStatement(dialect, []).validate(strict=False) def test_rename(self, dialect): - mysql_expr.MySQLRenameTableExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLRenameTableStatement(dialect, []).validate(strict=False) def test_routine(self, dialect): - mysql_expr.MySQLCreateProcedureExpression(dialect, 123).validate(strict=False) + mysql_expr.MySQLCreateProcedureStatement(dialect, 123).validate(strict=False) def test_call(self, dialect): - mysql_expr.MySQLCallExpression(dialect, 123).validate(strict=False) + mysql_expr.MySQLCallStatement(dialect, 123).validate(strict=False) def test_table_statement(self, dialect): mysql_expr.MySQLTableStatement(dialect, 5).validate(strict=False) @@ -433,14 +433,14 @@ class TestRoutineBranches: """Exercise stored-routine option and validation branches.""" def test_create_procedure_without_body_and_2tx(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp", catalog_name="db"), params=[("x", "INT")] ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `db`.`sp` (`x` INT)" def test_create_function_all_options(self, dialect): - expr = mysql_expr.MySQLCreateFunctionExpression( + expr = mysql_expr.MySQLCreateFunctionStatement( dialect, Function(dialect, "fn", catalog_name="db"), returns="INT", @@ -454,13 +454,13 @@ def test_create_function_all_options(self, dialect): ) def test_drop_function_if_exists(self, dialect): - sql, _ = mysql_expr.MySQLDropFunctionExpression( + sql, _ = mysql_expr.MySQLDropFunctionStatement( dialect, Function(dialect, "fn"), if_exists=True ).to_sql() assert sql == "DROP FUNCTION IF EXISTS `fn`" def test_call_with_none_and_expr(self, dialect): - expr = mysql_expr.MySQLCallExpression( + expr = mysql_expr.MySQLCallStatement( dialect, Procedure(dialect, "sp", catalog_name="db"), [None, _InspectableExpr("NOW()", ())], ) @@ -470,8 +470,8 @@ def test_call_with_none_and_expr(self, dialect): def test_routine_invalid_name(self, dialect): for cls, kw in ( - (mysql_expr.MySQLCreateProcedureExpression, {"body": "x"}), - (mysql_expr.MySQLCallExpression, {}), + (mysql_expr.MySQLCreateProcedureStatement, {"body": "x"}), + (mysql_expr.MySQLCallStatement, {}), ): expr = cls(dialect, 123, **kw) with pytest.raises(TypeError): @@ -485,7 +485,7 @@ def test_routine_tuple_form_is_refused(self, dialect): own ``validate``, and the formatter asks about the shared base class the two concrete kinds both derive from. """ - expr = mysql_expr.MySQLCreateProcedureExpression(dialect, ("db", "sp")) + expr = mysql_expr.MySQLCreateProcedureStatement(dialect, ("db", "sp")) with pytest.raises( TypeError, match=r"must be a RoutineObject, got tuple", @@ -493,7 +493,7 @@ def test_routine_tuple_form_is_refused(self, dialect): expr.to_sql() def test_call_tuple_form_is_refused(self, dialect): - expr = mysql_expr.MySQLCallExpression(dialect, ("db", "sp")) + expr = mysql_expr.MySQLCallStatement(dialect, ("db", "sp")) with pytest.raises( TypeError, match=r"must be a RoutineObject, got tuple", @@ -502,7 +502,7 @@ def test_call_tuple_form_is_refused(self, dialect): def test_routine_string_form_is_refused(self, dialect): """A bare string cannot say which database the routine lives in.""" - expr = mysql_expr.MySQLCreateProcedureExpression(dialect, "sp") + expr = mysql_expr.MySQLCreateProcedureStatement(dialect, "sp") with pytest.raises( TypeError, match=r"must be a RoutineObject, got str", @@ -523,7 +523,7 @@ def test_routine_guard_does_not_depend_on_strict_validation(self): """ loose = MySQLDialect() loose.strict_validation = False - expr = mysql_expr.MySQLCallExpression(loose, 123) + expr = mysql_expr.MySQLCallStatement(loose, 123) with pytest.raises(TypeError, match=r"must be a RoutineObject, got int"): expr.to_sql() @@ -564,14 +564,14 @@ def test_values_empty(self, dialect): class TestMaintenanceBranches: def test_checksum_option(self, dialect): - expr = mysql_expr.MySQLChecksumTableExpression( + expr = mysql_expr.MySQLChecksumTableStatement( dialect, [Table(dialect, "t1")], option=mysql_expr.ChecksumTableOption.QUICK ) sql, _ = expr.to_sql() assert sql == "CHECKSUM TABLE `t1` QUICK" def test_analyze_no_write_local_binlog(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1")], no_write_to_binlog=mysql_expr.NoWriteToBinlogOption.NO_WRITE_TO_BINLOG, @@ -581,25 +581,25 @@ def test_analyze_no_write_local_binlog(self, dialect): def test_qualified_tables(self, dialect): """The (schema, table) tuple is gone; an object carries the database.""" - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1", catalog_name="app"), Table(dialect, "t2")] ) sql, _ = expr.to_sql() assert sql == "ANALYZE TABLE `app`.`t1`, `t2`" def test_empty_tables(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, []) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, []) with pytest.raises(ValueError, match="at least one table"): expr.to_sql() def test_tuple_form_is_refused(self, dialect): """A 2-tuple used to be the only way to qualify a maintenance target.""" - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [("app", "t1")]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [("app", "t1")]) with pytest.raises(TypeError, match="relation objects"): expr.to_sql() def test_bad_table_type(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [123]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [123]) with pytest.raises(TypeError): expr.to_sql() @@ -623,23 +623,23 @@ class _UnsupportedMaintenanceExpr(MySQLTableMaintenanceExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_mysql_analyze_table_expression" + return "format_mysql_analyze_table_statement" class TestRenameTableBranches: def test_invalid_pair(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, [(Table(dialect, "a"),)]) + expr = mysql_expr.MySQLRenameTableStatement(dialect, [(Table(dialect, "a"),)]) with pytest.raises(ValueError): expr.to_sql() def test_non_object_names(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, [(1, 2)]) + expr = mysql_expr.MySQLRenameTableStatement(dialect, [(1, 2)]) with pytest.raises(TypeError): expr.to_sql() def test_cross_database_rename(self, dialect): """A rename may now span databases, which a string pair could not.""" - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "t", catalog_name="old_db"), Table(dialect, "t", catalog_name="new_db"))], ) @@ -650,21 +650,21 @@ def test_cross_database_rename(self, dialect): class TestRoutineParamBranches: def test_invalid_param_tuple(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("a", "x", "INT", "EXTRA")] ) with pytest.raises(ValueError, match="Invalid parameter"): expr.to_sql() def test_two_tuple_param(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("x", "INT")] ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `sp` (`x` INT)" def test_plain_string_param(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=["IN p INT"] ) sql, _ = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index 59447646..15f8462c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -472,7 +472,7 @@ def partition_maintenance(d, cls_name): # RESET names its option through the enum the constructor normalises # through, so the generic guess ("x") is refused at construction rather # than at render time. - "admin.MySQLResetExpression": + "admin.MySQLResetStatement": "option must be a ResetOption member or its word; the constructor " "normalises through the enum and refuses \"x\" at construction.", "statements.ddl_alter.AlterConstraintAction": @@ -614,7 +614,7 @@ def partition_maintenance(d, cls_name): ), # --- COMMENT ON is SQL-standard/PostgreSQL. --------------------------- "statements.ddl_comment.CommentOnStatement": ( - "format_comment_statement", + "format_comment_on_statement", "COMMENT ON is SQL-standard/PostgreSQL. MySQL spells a table comment " "as a CREATE TABLE option and a column comment inline, which " "format_column_definition already renders.", @@ -652,8 +652,8 @@ def partition_maintenance(d, cls_name): "XML functions are not in MySQL; no format_passing_clause.", ), "xml.XMLAggExpression": ( - "format_xmlagg_expression", - "XML functions are not in MySQL; no format_xmlagg_expression.", + "format_xml_agg_expression", + "XML functions are not in MySQL; no format_xml_agg_expression.", ), # The four that used to be pinned in _UNBUILDABLE, because the harness read # their ``Sequence[X]`` parameter as the catalogue object Sequence and could @@ -662,36 +662,36 @@ def partition_maintenance(d, cls_name): # exists. They were unreachable from the matrix before and are accounted for # here now, which is the outcome the _UNBUILDABLE rows asked for. "xml.XMLAttributesExpression": ( - "format_xmlattributes_expression", - "XML functions are not in MySQL; no format_xmlattributes_expression. " + "format_xml_attributes_expression", + "XML functions are not in MySQL; no format_xml_attributes_expression. " "This row was in _UNBUILDABLE until testsuite stopped reading " "attributes: Sequence[XMLAttribute] as the catalogue object Sequence.", ), "xml.XMLConcatExpression": ( - "format_xmlconcat_expression", - "XML functions are not in MySQL; no format_xmlconcat_expression. This " + "format_xml_concat_expression", + "XML functions are not in MySQL; no format_xml_concat_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading parts: " "Sequence[BaseExpression] as the catalogue object Sequence.", ), "xml.XMLForestExpression": ( - "format_xmlforest_expression", - "XML functions are not in MySQL; no format_xmlforest_expression. This " + "format_xml_forest_expression", + "XML functions are not in MySQL; no format_xml_forest_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading items: " "Sequence[XMLForestItem] as the catalogue object Sequence.", ), "xml.XMLTableExpression": ( - "format_xmltable_expression", - "XML functions are not in MySQL; no format_xmltable_expression. This " + "format_xml_table_expression", + "XML functions are not in MySQL; no format_xml_table_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading columns: " "Sequence[XMLTableColumn] as the catalogue object Sequence.", ), "xml.XMLCommentExpression": ( - "format_xmlcomment_expression", - "XML functions are not in MySQL; no format_xmlcomment_expression.", + "format_xml_comment_expression", + "XML functions are not in MySQL; no format_xml_comment_expression.", ), "xml.XMLElementExpression": ( - "format_xmlelement_expression", - "XML functions are not in MySQL; no format_xmlelement_expression.", + "format_xml_element_expression", + "XML functions are not in MySQL; no format_xml_element_expression.", ), "xml.XMLExistsPredicate": ( "format_xmlexists_predicate", @@ -702,25 +702,25 @@ def partition_maintenance(d, cls_name): "XML functions are not in MySQL; no format_xmlpi_expression.", ), "xml.XMLParseExpression": ( - "format_xmlparse_expression", - "XML functions are not in MySQL; no format_xmlparse_expression.", + "format_xml_parse_expression", + "XML functions are not in MySQL; no format_xml_parse_expression.", ), "xml.XMLQueryExpression": ( - "format_xmlquery_expression", - "XML functions are not in MySQL; no format_xmlquery_expression.", + "format_xml_query_expression", + "XML functions are not in MySQL; no format_xml_query_expression.", ), "xml.XMLRootExpression": ( - "format_xmlroot_expression", - "XML functions are not in MySQL; no format_xmlroot_expression.", + "format_xml_root_expression", + "XML functions are not in MySQL; no format_xml_root_expression.", ), "xml.XMLSerializeExpression": ( - "format_xmlserialize_expression", - "XML functions are not in MySQL; no format_xmlserialize_expression.", + "format_xml_serialize_expression", + "XML functions are not in MySQL; no format_xml_serialize_expression.", ), # --- The standard spelling of what MySQL renders under its own node. ---- # Both rows are the honest outcome of splitting one entry into two: this - # dialect renders CREATE/DROP FUNCTION through MySQLCreateFunctionExpression - # and MySQLDropFunctionExpression, whose contract is the stored routine MySQL + # dialect renders CREATE/DROP FUNCTION through MySQLCreateFunctionStatement + # and MySQLDropFunctionStatement, whose contract is the stored routine MySQL # actually has, and it names no entry for core's SQL/PSM nodes. Those two # used to reach the MySQL entry, which read ``expr.name`` off a node that # carries ``expr.function`` and raised AttributeError -- a claim of support @@ -728,13 +728,13 @@ def partition_maintenance(d, cls_name): "statements.ddl_function.CreateFunctionStatement": ( "format_create_function_statement", "MySQL models CREATE FUNCTION as a stored routine " - "(MySQLCreateFunctionExpression, entry format_mysql_create_function_expression); " + "(MySQLCreateFunctionStatement, entry format_mysql_create_function_statement); " "core's SQL/PSM node carries function/parameters/language, and this " "dialect declares no entry for that shape.", ), "statements.ddl_function.DropFunctionStatement": ( "format_drop_function_statement", - "Same split as CreateFunctionStatement: MySQLDropFunctionExpression " + "Same split as CreateFunctionStatement: MySQLDropFunctionStatement " "renders DROP FUNCTION; core's node has no entry here.", ), } @@ -758,27 +758,27 @@ def partition_maintenance(d, cls_name): "validate_collation_name refuses a collation it does not know. The " "refusal is the right answer to a wrong value.", ), - "routine.MySQLCallExpression": ( + "routine.MySQLCallStatement": ( "TypeError", "name must be a Procedure or Function object; the generic constructor " "supplies a string. Refusing is the object-kind guard working.", ), - "routine.MySQLCreateFunctionExpression": ( + "routine.MySQLCreateFunctionStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLCreateProcedureExpression": ( + "routine.MySQLCreateProcedureStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLDropFunctionExpression": ( + "routine.MySQLDropFunctionStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLDropProcedureExpression": ( + "routine.MySQLDropProcedureStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", @@ -851,22 +851,22 @@ def partition_maintenance(d, cls_name): "A window specification needs at least one of PARTITION BY / ORDER BY " "/ FRAME; the generic constructor supplies none.", ), - "admin.MySQLFlushExpression": ( + "admin.MySQLFlushStatement": ( "ValueError", "FLUSH needs at least one option; the generic constructor supplies an " "empty list.", ), - "admin.MySQLHandlerCloseExpression": ( + "admin.MySQLHandlerCloseStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLHandlerOpenExpression": ( + "admin.MySQLHandlerOpenStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLHandlerReadExpression": ( + "admin.MySQLHandlerReadStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", @@ -948,7 +948,7 @@ def partition_maintenance(d, cls_name): "Temporal options cannot be empty, and the generic constructor cannot " "know which one to pick.", ), - "json_duality_view.CreateJsonDualityViewExpression": ( + "json_duality_view.CreateJsonDualityViewStatement": ( "AttributeError", "The JSON duality view takes duality object specs; the generic " "constructor supplies strings.", @@ -966,27 +966,27 @@ def partition_maintenance(d, cls_name): "subquery must be a duality object select spec; the generic " "constructor supplies a string.", ), - "maintenance.MySQLAnalyzeTableExpression": ( + "maintenance.MySQLAnalyzeTableStatement": ( "ValueError", "ANALYZE TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLCheckTableExpression": ( + "maintenance.MySQLCheckTableStatement": ( "ValueError", "CHECK TABLE needs at least one table; the generic constructor supplies " "an empty list.", ), - "maintenance.MySQLChecksumTableExpression": ( + "maintenance.MySQLChecksumTableStatement": ( "ValueError", "CHECKSUM TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLOptimizeTableExpression": ( + "maintenance.MySQLOptimizeTableStatement": ( "ValueError", "OPTIMIZE TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLRepairTableExpression": ( + "maintenance.MySQLRepairTableStatement": ( "ValueError", "REPAIR TABLE needs at least one table; the generic constructor " "supplies an empty list.", @@ -1016,7 +1016,7 @@ def partition_maintenance(d, cls_name): "target_count must be below current_count; the generic constructor " "supplies 1 for both.", ), - "rename_table.MySQLRenameTableExpression": ( + "rename_table.MySQLRenameTableStatement": ( "ValueError", "renames must be a non-empty list of (old, new) Table pairs; the " "generic constructor supplies an empty list.", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py index 25d79d28..7ac471a6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py @@ -3,8 +3,8 @@ Tests for MySQL JSON Duality View expression classes (MySQL 9.7+). Tests SQL generation for: -- CreateJsonDualityViewExpression -- DropJsonDualityViewExpression +- CreateJsonDualityViewStatement +- DropJsonDualityViewStatement - DualityObjectSpec with various tag combinations - Nested duality objects """ @@ -12,8 +12,8 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityNestedMapping, @@ -55,7 +55,7 @@ def test_basic_read_only_view(self, dialect_97): from_table="users", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "users_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "users_dv", spec) sql, params = expr.to_sql() assert "CREATE JSON RELATIONAL DUALITY VIEW" in sql @@ -76,7 +76,7 @@ def test_writable_view_all_tags(self, dialect_97): ], from_table="orders", ) - expr = CreateJsonDualityViewExpression(dialect_97, "orders_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "orders_dv", spec) sql, params = expr.to_sql() assert "WITH(INSERT,UPDATE,DELETE)" in sql @@ -93,7 +93,7 @@ def test_partial_tags_insert_only(self, dialect_97): from_table="items", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "items_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "items_dv", spec) sql, _ = expr.to_sql() assert "WITH(INSERT)" in sql @@ -107,7 +107,7 @@ def test_or_replace(self, dialect_97): from_table="products", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "products_dv", spec, replace=True) + expr = CreateJsonDualityViewStatement(dialect_97, "products_dv", spec, replace=True) sql, _ = expr.to_sql() assert "CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW" in sql @@ -133,7 +133,7 @@ def test_nested_object(self, dialect_97): from_table="orders", from_alias="o", ) - expr = CreateJsonDualityViewExpression(dialect_97, "orders_full_dv", root_spec) + expr = CreateJsonDualityViewStatement(dialect_97, "orders_full_dv", root_spec) sql, params = expr.to_sql() assert "WITH(INSERT,UPDATE,DELETE)" in sql @@ -149,14 +149,14 @@ class TestDropJsonDualityViewExpression: """Test DROP VIEW SQL generation for duality views.""" def test_drop_view(self, dialect_97): - expr = DropJsonDualityViewExpression(dialect_97, "orders_dv") + expr = DropJsonDualityViewStatement(dialect_97, "orders_dv") sql, params = expr.to_sql() assert sql == "DROP VIEW `orders_dv`" assert params == () def test_drop_view_if_exists(self, dialect_97): - expr = DropJsonDualityViewExpression(dialect_97, "orders_dv", if_exists=True) + expr = DropJsonDualityViewStatement(dialect_97, "orders_dv", if_exists=True) sql, params = expr.to_sql() assert sql == "DROP VIEW IF EXISTS `orders_dv`" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py index 3338d438..e8e7dbfd 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py @@ -37,7 +37,7 @@ class TestRenameTable: """Test MySQL RENAME TABLE statement.""" def test_single_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "old_name"), Table(dialect, "new_name"))] ) sql, params = expr.to_sql() @@ -45,21 +45,21 @@ def test_single_rename(self, dialect): assert params == () def test_multi_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "a"), Table(dialect, "b")), (Table(dialect, "c"), Table(dialect, "d"))] ) sql, params = expr.to_sql() assert sql == "RENAME TABLE `a` TO `b`, `c` TO `d`" def test_cross_database_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "t", catalog_name="src"), Table(dialect, "t", catalog_name="dst"))] ) sql, _ = expr.to_sql() assert sql == "RENAME TABLE `src`.`t` TO `dst`.`t`" def test_empty_raises(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, []) + expr = mysql_expr.MySQLRenameTableStatement(dialect, []) with pytest.raises(ValueError, match="at least one"): expr.to_sql() @@ -126,13 +126,13 @@ class TestTableMaintenance: """Test MySQL whole-table maintenance statements.""" def test_analyze_table(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [Table(dialect, "t1"), Table(dialect, "t2")]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [Table(dialect, "t1"), Table(dialect, "t2")]) sql, params = expr.to_sql() assert sql == "ANALYZE TABLE `t1`, `t2`" assert params == () def test_check_table(self, dialect): - expr = mysql_expr.MySQLCheckTableExpression( + expr = mysql_expr.MySQLCheckTableStatement( dialect, [Table(dialect, "t1")], options=[mysql_expr.CheckTableOption.QUICK] ) sql, _ = expr.to_sql() @@ -140,26 +140,26 @@ def test_check_table(self, dialect): def test_checksum_table(self, dialect): """The (schema, table) tuple is replaced by the object's catalog slot.""" - expr = mysql_expr.MySQLChecksumTableExpression( + expr = mysql_expr.MySQLChecksumTableStatement( dialect, [Table(dialect, "t", catalog_name="db")] ) sql, _ = expr.to_sql() assert sql == "CHECKSUM TABLE `db`.`t`" def test_optimize_table(self, dialect): - expr = mysql_expr.MySQLOptimizeTableExpression(dialect, [Table(dialect, "t1")]) + expr = mysql_expr.MySQLOptimizeTableStatement(dialect, [Table(dialect, "t1")]) sql, _ = expr.to_sql() assert sql == "OPTIMIZE TABLE `t1`" def test_repair_table(self, dialect): - expr = mysql_expr.MySQLRepairTableExpression( + expr = mysql_expr.MySQLRepairTableStatement( dialect, [Table(dialect, "t1")], options=[mysql_expr.RepairTableOption.USE_FRM] ) sql, _ = expr.to_sql() assert sql == "REPAIR TABLE `t1` USE_FRM" def test_no_write_to_binlog(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1")], no_write_to_binlog=mysql_expr.NoWriteToBinlogOption.LOCAL ) sql, _ = expr.to_sql() @@ -180,37 +180,37 @@ class TestStoredRoutines: """Test MySQL stored procedure / function / CALL statements.""" def test_create_procedure(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("IN", "x", "INT")], body="BEGIN END" ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `sp` (IN `x` INT) BEGIN END" def test_drop_procedure(self, dialect): - expr = mysql_expr.MySQLDropProcedureExpression(dialect, Procedure(dialect, "sp"), if_exists=True) + expr = mysql_expr.MySQLDropProcedureStatement(dialect, Procedure(dialect, "sp"), if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP PROCEDURE IF EXISTS `sp`" def test_create_function(self, dialect): - expr = mysql_expr.MySQLCreateFunctionExpression( + expr = mysql_expr.MySQLCreateFunctionStatement( dialect, Function(dialect, "fn"), returns="INT", deterministic=True, body="RETURN 1" ) sql, _ = expr.to_sql() assert sql == "CREATE FUNCTION `fn` () RETURNS INT DETERMINISTIC RETURN 1" def test_drop_function(self, dialect): - expr = mysql_expr.MySQLDropFunctionExpression(dialect, Function(dialect, "fn")) + expr = mysql_expr.MySQLDropFunctionStatement(dialect, Function(dialect, "fn")) sql, _ = expr.to_sql() assert sql == "DROP FUNCTION `fn`" def test_call(self, dialect): - expr = mysql_expr.MySQLCallExpression(dialect, Procedure(dialect, "sp"), [1, "a"]) + expr = mysql_expr.MySQLCallStatement(dialect, Procedure(dialect, "sp"), [1, "a"]) sql, params = expr.to_sql() assert sql == "CALL `sp` (%s, %s)" assert params == (1, "a") def test_call_qualified_database(self, dialect): - expr = mysql_expr.MySQLCallExpression( + expr = mysql_expr.MySQLCallStatement( dialect, Procedure(dialect, "sp", catalog_name="app"), [1] ) sql, _ = expr.to_sql() @@ -275,27 +275,27 @@ class TestAdminCommands: """Test MySQL administrative utility commands.""" def test_flush(self, dialect): - expr = mysql_expr.MySQLFlushExpression(dialect, [mysql_expr.FlushOption.PRIVILEGES]) + expr = mysql_expr.MySQLFlushStatement(dialect, [mysql_expr.FlushOption.PRIVILEGES]) sql, _ = expr.to_sql() assert sql == "FLUSH PRIVILEGES" def test_reset(self, dialect): - expr = mysql_expr.MySQLResetExpression(dialect, mysql_expr.ResetOption.MASTER) + expr = mysql_expr.MySQLResetStatement(dialect, mysql_expr.ResetOption.MASTER) sql, _ = expr.to_sql() assert sql == "RESET MASTER" def test_kill(self, dialect): - expr = mysql_expr.MySQLKillExpression(dialect, 42) + expr = mysql_expr.MySQLKillStatement(dialect, 42) sql, _ = expr.to_sql() assert sql == "KILL CONNECTION 42" def test_install_plugin(self, dialect): - expr = mysql_expr.MySQLInstallPluginExpression(dialect, "p", "libp.so") + expr = mysql_expr.MySQLInstallPluginStatement(dialect, "p", "libp.so") sql, _ = expr.to_sql() assert sql == "INSTALL PLUGIN `p` SONAME 'libp.so'" def test_grant(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [mysql_expr.GrantPrivilege("SELECT")], [mysql_expr.AccountSpec("u", "localhost")], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index b4a13fe1..03ee97e9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -533,28 +533,30 @@ def test_create_table_comment_with_backslash(self, dialect): def test_storage_options_normal_key_and_value(dialect): """Normal storage option key is plain, string value is quoted and escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "InnoDB"})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"ENGINE": "InnoDB"})) assert "ENGINE='InnoDB'" in sql def test_storage_options_string_value_escaped(dialect): """String value with single quote is properly escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "It's"})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"ENGINE": "It's"})) assert "It''s" in sql def test_storage_options_int_value(dialect): """Integer value is not quoted.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"AUTO_INCREMENT": 1000})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"AUTO_INCREMENT": 1000})) assert "AUTO_INCREMENT=1000" in sql def test_storage_options_string_injection_value_escaped(dialect): """String value with injection payload is safely escaped inside quotes.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "x'; DROP TABLE t--"})) + sql, params = dialect.format_storage_options_expression( + StorageOptionsExpression(dialect, {"ENGINE": "x'; DROP TABLE t--"}) + ) assert "'x''; DROP TABLE t--'" in sql # The single quote inside is doubled, so the payload cannot break out assert sql.count("'") % 2 == 0, f"Unbalanced quotes: {sql}" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 68fd5b45..4e004df4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -37,7 +37,7 @@ from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( LoadDataOptions, - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) from rhosocial.activerecord.backend.expression import ( ForUpdateClause, @@ -87,7 +87,7 @@ MySQLVectorExpression, ) from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLFindInSetExpression, MySQLFulltextIndexOptionsExpression, MySQLJSONArrayExpression, @@ -119,7 +119,7 @@ class TestMySQLDMLOperationExpressions: def test_load_data_basic(self): dialect = MySQLDialect(version=(8, 0, 0)) dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/tmp/data.csv", table=Table(dialect, "users") ) sql, params = expr.to_sql() @@ -131,7 +131,7 @@ def test_load_data_basic(self): def test_load_data_qualified_database(self): """LOAD DATA can name a database it previously had no way to express.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products", catalog_name="warehouse") ) sql, _ = expr.to_sql() @@ -140,7 +140,7 @@ def test_load_data_qualified_database(self): def test_load_data_local_replace(self): dialect = MySQLDialect(version=(8, 0, 0)) options = LoadDataOptions(dialect, local=True, replace=True) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) sql, params = expr.to_sql() @@ -149,7 +149,7 @@ def test_load_data_local_replace(self): def test_load_data_ignore(self): dialect = MySQLDialect(version=(8, 0, 0)) options = LoadDataOptions(dialect, local=True, ignore=True) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) sql, params = expr.to_sql() @@ -168,7 +168,7 @@ def test_load_data_full_options(self): ignore_lines=1, set_assignments={"created_at": "NOW()"}, ) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/tmp/import.csv", table=Table(dialect, "logs"), options=options ) sql, params = expr.to_sql() @@ -185,7 +185,7 @@ def test_load_data_full_options(self): def test_load_data_validate_replaces_ignore_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) options = LoadDataOptions(dialect, replace=True, ignore=True) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "t"), options=options ) with pytest.raises(ValueError, match="Cannot use both REPLACE and IGNORE"): @@ -193,7 +193,7 @@ def test_load_data_validate_replaces_ignore_raises(self): def test_load_data_path_with_escape(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/path/with'quote.csv", table=Table(dialect, "t") ) sql, params = expr.to_sql() @@ -1126,7 +1126,7 @@ def test_st_geom_from_wkb(self): def test_create_spatial_index(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_spatial", "t", "geom") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_spatial", "t", "geom") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql assert "`idx_spatial`" in sql @@ -1135,7 +1135,7 @@ def test_create_spatial_index(self): def test_create_spatial_index_multi_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression( + expr = MySQLCreateSpatialIndexStatement( dialect, "idx_spatial", "t", "geom1, geom2" ) sql, params = expr.to_sql() @@ -1320,7 +1320,7 @@ class TestMySQLJsonDualityViewExpressions: def test_create_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityViewDMLTag, @@ -1333,7 +1333,7 @@ def test_create_duality_view(self): ], tags=[DualityViewDMLTag.INSERT, DualityViewDMLTag.UPDATE], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="my_view", root_spec=spec ) sql, params = expr.to_sql() @@ -1342,7 +1342,7 @@ def test_create_duality_view(self): def test_create_or_replace_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, ) @@ -1352,7 +1352,7 @@ def test_create_or_replace_duality_view(self): DualityColumnMapping(json_key="userId", column_expr="id"), ], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="my_view", root_spec=spec, replace=True ) sql, params = expr.to_sql() @@ -1361,18 +1361,18 @@ def test_create_or_replace_duality_view(self): def test_drop_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - DropJsonDualityViewExpression, + DropJsonDualityViewStatement, ) - expr = DropJsonDualityViewExpression(dialect=dialect, view_name="my_view") + expr = DropJsonDualityViewStatement(dialect=dialect, view_name="my_view") sql, params = expr.to_sql() assert sql == "DROP VIEW `my_view`" def test_drop_duality_view_if_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - DropJsonDualityViewExpression, + DropJsonDualityViewStatement, ) - expr = DropJsonDualityViewExpression( + expr = DropJsonDualityViewStatement( dialect=dialect, view_name="my_view", if_exists=True ) sql, params = expr.to_sql() @@ -1381,7 +1381,7 @@ def test_drop_duality_view_if_exists(self): def test_duality_view_with_nested(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityColumnMapping, DualityNestedMapping, DualityObjectSpec, @@ -1402,7 +1402,7 @@ def test_duality_view_with_nested(self): ], nested=[nested], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="user_view", root_spec=spec ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py index 32044d11..84a51a4c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py @@ -419,7 +419,7 @@ class TestMySQLExpressionDialectSeparation: """ EXPRESSION_DIALECT_PAIRS = [ - ("MySQLLoadDataExpression", "format_mysql_load_data_expression"), + ("MySQLLoadDataStatement", "format_mysql_load_data_statement"), ("MySQLJSONTableExpression", "format_mysql_json_table_expression"), ("MySQLJSONExtractExpression", "format_mysql_json_extract_expression"), ("MySQLJSONObjectExpression", "format_mysql_json_object_expression"), @@ -442,31 +442,31 @@ class TestMySQLExpressionDialectSeparation: ("MySQLTruncatePartitionExpression", "format_mysql_truncate_partition_expression"), ("MySQLReorganizePartitionExpression", "format_mysql_reorganize_partition_expression"), ("MySQLExchangePartitionExpression", "format_mysql_exchange_partition_expression"), - ("MySQLRenameTableExpression", "format_mysql_rename_table_expression"), + ("MySQLRenameTableStatement", "format_mysql_rename_table_statement"), ("MySQLTableStatement", "format_mysql_table_statement"), ("MySQLValuesExpression", "format_mysql_values_expression"), - ("MySQLAnalyzeTableExpression", "format_mysql_analyze_table_expression"), - ("MySQLCheckTableExpression", "format_mysql_check_table_expression"), - ("MySQLChecksumTableExpression", "format_mysql_checksum_table_expression"), - ("MySQLOptimizeTableExpression", "format_mysql_optimize_table_expression"), - ("MySQLRepairTableExpression", "format_mysql_repair_table_expression"), - ("MySQLCreateProcedureExpression", "format_mysql_create_procedure_expression"), - ("MySQLDropProcedureExpression", "format_mysql_drop_procedure_expression"), - ("MySQLCreateFunctionExpression", "format_mysql_create_function_expression"), - ("MySQLDropFunctionExpression", "format_mysql_drop_function_expression"), - ("MySQLCallExpression", "format_mysql_call_expression"), + ("MySQLAnalyzeTableStatement", "format_mysql_analyze_table_statement"), + ("MySQLCheckTableStatement", "format_mysql_check_table_statement"), + ("MySQLChecksumTableStatement", "format_mysql_checksum_table_statement"), + ("MySQLOptimizeTableStatement", "format_mysql_optimize_table_statement"), + ("MySQLRepairTableStatement", "format_mysql_repair_table_statement"), + ("MySQLCreateProcedureStatement", "format_mysql_create_procedure_statement"), + ("MySQLDropProcedureStatement", "format_mysql_drop_procedure_statement"), + ("MySQLCreateFunctionStatement", "format_mysql_create_function_statement"), + ("MySQLDropFunctionStatement", "format_mysql_drop_function_statement"), + ("MySQLCallStatement", "format_mysql_call_statement"), ("MySQLLoadXMLStatement", "format_mysql_load_xml_statement"), - ("MySQLFlushExpression", "format_mysql_flush_expression"), - ("MySQLResetExpression", "format_mysql_reset_expression"), - ("MySQLCacheIndexExpression", "format_mysql_cache_index_expression"), - ("MySQLKillExpression", "format_mysql_kill_expression"), - ("MySQLInstallPluginExpression", "format_mysql_install_plugin_expression"), - ("MySQLGrantExpression", "format_mysql_grant_expression"), - ("MySQLCreateUserExpression", "format_mysql_create_user_expression"), - ("MySQLCloneExpression", "format_mysql_clone_expression"), - ("MySQLRestartExpression", "format_mysql_restart_expression"), - ("MySQLHandlerOpenExpression", "format_mysql_handler_open_expression"), - ("MySQLDoExpression", "format_mysql_do_expression"), + ("MySQLFlushStatement", "format_mysql_flush_statement"), + ("MySQLResetStatement", "format_mysql_reset_statement"), + ("MySQLCacheIndexStatement", "format_mysql_cache_index_statement"), + ("MySQLKillStatement", "format_mysql_kill_statement"), + ("MySQLInstallPluginStatement", "format_mysql_install_plugin_statement"), + ("MySQLGrantStatement", "format_mysql_grant_statement"), + ("MySQLCreateUserStatement", "format_mysql_create_user_statement"), + ("MySQLCloneStatement", "format_mysql_clone_statement"), + ("MySQLRestartStatement", "format_mysql_restart_statement"), + ("MySQLHandlerOpenStatement", "format_mysql_handler_open_statement"), + ("MySQLDoStatement", "format_mysql_do_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py index 493a1a24..707ec204 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py @@ -12,7 +12,7 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLSpatialLiteralExpression, MySQLSTAsGeoJSONExpression, MySQLSTAsTextExpression, @@ -189,7 +189,7 @@ def test_format_create_spatial_index_supported(self): """Test CREATE SPATIAL INDEX (MySQL 5.7+).""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_location", "places", "geom") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_location", "places", "geom") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql @@ -203,7 +203,7 @@ def test_format_create_spatial_index_unsupported(self): dialect = MySQLDialect(version=(5, 5, 0)) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - MySQLCreateSpatialIndexExpression(dialect, "idx_location", "places", "geom").to_sql() + MySQLCreateSpatialIndexStatement(dialect, "idx_location", "places", "geom").to_sql() class TestAsyncSpatialTypeProtocol: @@ -248,7 +248,7 @@ async def test_async_format_create_spatial_index(self): """Test async version of CREATE SPATIAL INDEX formatting.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_geom", "table", "col") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_geom", "table", "col") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py index 6d163b33..978c8e8d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLSpatialLiteralExpression, MySQLSTAsGeoJSONExpression, MySQLSTAsTextExpression, @@ -207,7 +207,7 @@ def test_format_st_contains(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") def test_format_create_spatial_index(self, mysql_backend): - """Test format_mysql_create_spatial_index_expression generates valid SQL.""" + """Test format_mysql_create_spatial_index_statement generates valid SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_idx ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -217,7 +217,7 @@ def test_format_create_spatial_index(self, mysql_backend): """) dialect = mysql_backend.dialect - index_sql, params = MySQLCreateSpatialIndexExpression( + index_sql, params = MySQLCreateSpatialIndexStatement( dialect, "idx_location", "test_spatial_idx", "location" ).to_sql() @@ -341,7 +341,7 @@ async def test_async_format_st_as_geojson(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") async def test_async_format_create_spatial_index(self, async_mysql_backend): - """Test format_mysql_create_spatial_index_expression generates valid SQL (async).""" + """Test format_mysql_create_spatial_index_statement generates valid SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_idx ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -350,7 +350,7 @@ async def test_async_format_create_spatial_index(self, async_mysql_backend): """) dialect = async_mysql_backend.dialect - index_sql, _ = MySQLCreateSpatialIndexExpression( + index_sql, _ = MySQLCreateSpatialIndexStatement( dialect, "idx_loc", "test_async_idx", "location" ).to_sql() From 806c00b2fcaf164384ca7772ab831925aae8beea Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 18:06:11 +0800 Subject: [PATCH 46/65] refactor(backend)!: call the core helpers by their public names Core moved ten cross-package helpers out from behind a leading underscore: they are called from this backend, so the underscore was claiming a scope the names never had. Each old name had exactly one new name, checked against every public definition and call site in the ten repositories first, so nothing is shadowed by the rename. --- .../impl/mysql/introspection/introspector.py | 12 ++--- .../backend/impl/mysql/mixins/ddl_view.py | 2 +- .../backend/impl/mysql/mixins/types.py | 48 +++++++++---------- .../introspection/test_introspection_cache.py | 12 ++--- .../backend/test_mysql_type_protocol.py | 2 +- .../feature/basic/fixtures/models.py | 6 +-- .../feature/basic/fixtures/models_py310.py | 6 +-- .../feature/basic/fixtures/models_py311.py | 6 +-- .../feature/basic/fixtures/models_py312.py | 6 +-- 9 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index afe395ef..b412fbe1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -318,8 +318,8 @@ def status(self) -> SyncMySQLStatusIntrospector: # ------------------------------------------------------------------ # def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + key = self.make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self.get_cached(key) if cached is not None: return cached @@ -332,7 +332,7 @@ def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optio table.columns = self.list_columns(table_name, schema) table.indexes = self.list_indexes(table_name, schema) table.foreign_keys = self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self.set_cached(key, table) return table @@ -374,8 +374,8 @@ def status(self) -> AsyncMySQLStatusIntrospector: # ------------------------------------------------------------------ # async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + key = self.make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self.get_cached(key) if cached is not None: return cached @@ -388,5 +388,5 @@ async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> table.columns = await self.list_columns(table_name, schema) table.indexes = await self.list_indexes(table_name, schema) table.foreign_keys = await self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self.set_cached(key, table) return table diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py index 5d255f30..b47364f7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py @@ -86,7 +86,7 @@ def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str query_sql, query_params = expr.query.to_sql() if query_params and not self.supports_create_view_bind_parameters(): - self._refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") + self.refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py index 611625f0..6289e911 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py @@ -177,7 +177,7 @@ def format_data_type_mysql_tinyint(self, data_type: MySQLTinyIntType) -> Tuple[s rendered word is MySQL's own canonical one, since ``TINYINT`` is what the server reports back from ``SHOW CREATE TABLE``. """ - self._check_spelling(data_type, MySQLTinyIntType) + self.check_spelling(data_type, MySQLTinyIntType) return self._format_mysql_integer("TINYINT", data_type) def format_data_type_mysql_smallint(self, data_type: MySQLSmallIntType) -> Tuple[str, tuple]: @@ -186,7 +186,7 @@ def format_data_type_mysql_smallint(self, data_type: MySQLSmallIntType) -> Tuple ``SMALLINT`` and ``INT2`` are both MySQL words for the 2-byte type, so both are accepted and MySQL's own word is rendered. """ - self._check_spelling(data_type, MySQLSmallIntType) + self.check_spelling(data_type, MySQLSmallIntType) return self._format_mysql_integer("SMALLINT", data_type) def format_data_type_mysql_mediumint(self, data_type: MySQLMediumIntType) -> Tuple[str, tuple]: @@ -203,7 +203,7 @@ def format_data_type_mysql_mediumint(self, data_type: MySQLMediumIntType) -> Tup no core 3-byte concept to render or inherit, which is why this is a ``mysql_``-namespaced class rather than a spelling of an existing one. """ - self._check_spelling(data_type, MySQLMediumIntType) + self.check_spelling(data_type, MySQLMediumIntType) return self._format_mysql_integer("MEDIUMINT", data_type) def format_data_type_mysql_int(self, data_type: MySQLIntType) -> Tuple[str, tuple]: @@ -215,7 +215,7 @@ def format_data_type_mysql_int(self, data_type: MySQLIntType) -> Tuple[str, tupl column carrying ``UNSIGNED`` / ``ZEROFILL`` stays distinguishable from a plain ``integer`` in a schema diff — the word ``INT`` is the same. """ - self._check_spelling(data_type, MySQLIntType) + self.check_spelling(data_type, MySQLIntType) return self._format_mysql_integer("INT", data_type) def format_data_type_mysql_bigint(self, data_type: MySQLBigIntType) -> Tuple[str, tuple]: @@ -225,7 +225,7 @@ def format_data_type_mysql_bigint(self, data_type: MySQLBigIntType) -> Tuple[str manual's own table lists ``INT8``), so both are accepted and MySQL's canonical word is rendered. """ - self._check_spelling(data_type, MySQLBigIntType) + self.check_spelling(data_type, MySQLBigIntType) return self._format_mysql_integer("BIGINT", data_type) def format_data_type_mysql_tinyblob(self, data_type: MySQLTinyBlobType) -> Tuple[str, tuple]: @@ -235,22 +235,22 @@ def format_data_type_mysql_tinyblob(self, data_type: MySQLTinyBlobType) -> Tuple size variants inherit the core ``BlobType`` spelling list, and MySQL writes ``BLOB``, not ``BYTEA``, in every one of them. """ - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "TINYBLOB", () def format_data_type_mysql_blob(self, data_type: MySQLBlobType) -> Tuple[str, tuple]: """``BLOB`` — max 65,535 bytes. ``BYTEA`` refused; see ``mysql_tinyblob``.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "BLOB", () def format_data_type_mysql_mediumblob(self, data_type: MySQLMediumBlobType) -> Tuple[str, tuple]: """``MEDIUMBLOB`` — max 16,777,215 bytes. ``BYTEA`` refused.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "MEDIUMBLOB", () def format_data_type_mysql_longblob(self, data_type: MySQLLongBlobType) -> Tuple[str, tuple]: """``LONGBLOB`` — max 4,294,967,295 bytes. ``BYTEA`` refused.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "LONGBLOB", () def format_data_type_mysql_tinytext(self, data_type: MySQLTinyTextType) -> Tuple[str, tuple]: @@ -260,22 +260,22 @@ def format_data_type_mysql_tinytext(self, data_type: MySQLTinyTextType) -> Tuple MySQL has no CLOB in any of them, so ``clob`` is refused here exactly as it is for the unbounded ``text``. """ - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TINYTEXT", () def format_data_type_mysql_text(self, data_type: MySQLTextType) -> Tuple[str, tuple]: """``TEXT`` — max 65,535 bytes. ``CLOB`` refused; see ``mysql_tinytext``.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TEXT", () def format_data_type_mysql_mediumtext(self, data_type: MySQLMediumTextType) -> Tuple[str, tuple]: """``MEDIUMTEXT`` — max 16,777,215 bytes. ``CLOB`` refused.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "MEDIUMTEXT", () def format_data_type_mysql_longtext(self, data_type: MySQLLongTextType) -> Tuple[str, tuple]: """``LONGTEXT`` — max 4,294,967,295 bytes. ``CLOB`` refused.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "LONGTEXT", () def format_data_type_mysql_bit(self, data_type: MySQLBitType) -> Tuple[str, tuple]: @@ -435,7 +435,7 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, IntegerType) + self.check_spelling(data_type, IntegerType) self._check_unsigned(data_type) return (f"INT UNSIGNED" if data_type.unsigned else "INT"), () @@ -452,7 +452,7 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, BigIntType) + self.check_spelling(data_type, BigIntType) self._check_unsigned(data_type) return ("BIGINT UNSIGNED" if data_type.unsigned else "BIGINT"), () @@ -469,7 +469,7 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, SmallIntType) + self.check_spelling(data_type, SmallIntType) self._check_unsigned(data_type) return ("SMALLINT UNSIGNED" if data_type.unsigned else "SMALLINT"), () @@ -486,7 +486,7 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) self._check_unsigned(data_type) return ("TINYINT UNSIGNED" if data_type.unsigned else "TINYINT"), () @@ -508,7 +508,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: where the caller has no way to tell that from success. https://dev.mysql.com/doc/refman/8.4/en/string-type-syntax.html """ - self._check_spelling(data_type, ("varchar",)) + self.check_spelling(data_type, ("varchar",)) if data_type.length is None: raise ValueError( "MySQL's grammar is VARCHAR(M) with M mandatory, so a VarCharType " @@ -527,7 +527,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: spelling list too: this framework treats a character set as a *field*, not as a type.) """ - self._check_spelling(data_type, ("char",)) + self.check_spelling(data_type, ("char",)) return (f"CHAR({data_type.length})" if data_type.length is not None else "CHAR"), () def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: @@ -537,7 +537,7 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: still renders — a backend may write the concept under its own name but must not refuse the spelling every plain ``TextType(d)`` carries. """ - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TEXT", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: @@ -549,7 +549,7 @@ def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: why :class:`MySQLBitType` is a separate concept: a BIT string is not a boolean even at width 1. """ - self._check_spelling(data_type, BooleanType) + self.check_spelling(data_type, BooleanType) return "TINYINT(1)", () def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: @@ -665,7 +665,7 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: honoured rather than routed around. https://dev.mysql.com/doc/refman/9.7/en/numeric-type-syntax.html """ - self._check_spelling(data_type, DoubleType) + self.check_spelling(data_type, DoubleType) self._check_unsigned(data_type) return ("DOUBLE UNSIGNED" if data_type.unsigned else "DOUBLE"), () @@ -703,7 +703,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: out of range is still a ``ValueError`` and is still refused before any attribute is appended. """ - self._check_spelling(data_type, DecimalType) + self.check_spelling(data_type, DecimalType) if data_type.precision is not None and not 1 <= data_type.precision <= 65: raise ValueError( f"MySQL DECIMAL precision must be between 1 and 65, " @@ -742,7 +742,7 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: at all, so it is refused here. ``BLOB`` — the concept's default spelling, and the word MySQL writes — always renders. """ - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "BLOB", () def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py index 77e93991..1a8555ee 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py @@ -77,7 +77,7 @@ def test_invalidate_specific_scope(self, backend_with_tables): # Database cache should be cleared db_info2 = backend_with_tables.introspector.get_database_info() # Check internal cache was cleared for database scope - backend_with_tables.introspector._make_cache_key(IntrospectionScope.DATABASE) + backend_with_tables.introspector.make_cache_key(IntrospectionScope.DATABASE) # The new result should be cached now assert db_info2 is not None @@ -198,7 +198,7 @@ class TestCacheKeys: def test_cache_key_generation(self, mysql_backend_single): """Test that cache keys are generated correctly.""" - key = mysql_backend_single.introspector._make_cache_key( + key = mysql_backend_single.introspector.make_cache_key( IntrospectionScope.TABLE, "users", schema=mysql_backend_single.config.database ) @@ -207,7 +207,7 @@ def test_cache_key_generation(self, mysql_backend_single): def test_cache_key_with_extra(self, mysql_backend_single): """Test cache key with extra component.""" - key = mysql_backend_single.introspector._make_cache_key( + key = mysql_backend_single.introspector.make_cache_key( IntrospectionScope.TABLE, schema=mysql_backend_single.config.database, extra="True" ) @@ -216,9 +216,9 @@ def test_cache_key_with_extra(self, mysql_backend_single): def test_cache_key_uniqueness(self, mysql_backend_single): """Test that different parameters produce different keys.""" - key1 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.TABLE, "users") - key2 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.TABLE, "posts") - key3 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.COLUMN, "users") + key1 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.TABLE, "users") + key2 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.TABLE, "posts") + key3 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.COLUMN, "users") assert key1 != key2 assert key1 != key3 diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index 20af3625..f84dd45c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -1259,7 +1259,7 @@ def test_takes_no_spelling_and_does_not_inherit_the_four_byte_words(self): ``SPELLINGS`` is narrowed rather than left inherited: taking :class:`IntegerType` would otherwise advertise ``integer`` / ``int``, which MySQL does not accept for this width, and - ``_check_spelling(data_type, MySQLMediumIntType)`` would then hold a + ``check_spelling(data_type, MySQLMediumIntType)`` would then hold a MEDIUMINT to a word it is not. """ assert MySQLMediumIntType.SPELLINGS == ("mediumint",) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py index 24a9002f..9bc6eb95 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py @@ -488,7 +488,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) # Python 'str' can be converted to DB 'int' + self.register_type(str, int) # Python 'str' can be converted to DB 'int' def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: # The 'notes' attribute on the model is a str, which we convert to an int for the DB. @@ -532,7 +532,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py index 900034bb..3a7b6049 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py @@ -527,7 +527,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -572,7 +572,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -618,7 +618,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py index c348093f..5a6f6004 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py @@ -515,7 +515,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -556,7 +556,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -602,7 +602,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py index d0e8ce20..f0f74f59 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py @@ -502,7 +502,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -543,7 +543,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: From 413626c86a684f3dc06971cc00767dc9bcb2b784 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 19:11:31 +0800 Subject: [PATCH 47/65] fix(introspection): take the cache from the manager, not the mixin IntrospectorMixin no longer carries make_cache_key/get_cached/set_cached or its own cache state; the cache is now a component exposed as self._cache. Call the cache's key/get/set directly, keeping the same arguments and key semantics. Both MySQL introspectors now accept an injectable cache and forward it to the base. Cache tests were moved off the removed mixin attributes onto the cache component (ttl, lock). --- .../impl/mysql/introspection/introspector.py | 31 +++++++++++++------ .../introspection/test_introspection_cache.py | 22 ++++++------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index b412fbe1..7039b0a0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -32,6 +32,7 @@ SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, AsyncIntrospectorExecutor, @@ -294,8 +295,13 @@ class SyncMySQLIntrospector(MySQLIntrospectorMixin, SyncAbstractIntrospector): variables = backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: SyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: SyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[SyncShowIntrospector] = None self._status_instance: Optional[SyncMySQLStatusIntrospector] = None @@ -318,8 +324,8 @@ def status(self) -> SyncMySQLStatusIntrospector: # ------------------------------------------------------------------ # def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: - key = self.make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self.get_cached(key) + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -332,7 +338,7 @@ def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optio table.columns = self.list_columns(table_name, schema) table.indexes = self.list_indexes(table_name, schema) table.foreign_keys = self.list_foreign_keys(table_name, schema) - self.set_cached(key, table) + self._cache.set(key, table) return table @@ -350,8 +356,13 @@ class AsyncMySQLIntrospector(MySQLIntrospectorMixin, AsyncAbstractIntrospector): variables = await backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: AsyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: AsyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[AsyncShowIntrospector] = None self._status_instance: Optional[AsyncMySQLStatusIntrospector] = None @@ -374,8 +385,8 @@ def status(self) -> AsyncMySQLStatusIntrospector: # ------------------------------------------------------------------ # async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: - key = self.make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self.get_cached(key) + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -388,5 +399,5 @@ async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> table.columns = await self.list_columns(table_name, schema) table.indexes = await self.list_indexes(table_name, schema) table.foreign_keys = await self.list_foreign_keys(table_name, schema) - self.set_cached(key, table) + self._cache.set(key, table) return table diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py index 1a8555ee..674e3d44 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py @@ -77,7 +77,7 @@ def test_invalidate_specific_scope(self, backend_with_tables): # Database cache should be cleared db_info2 = backend_with_tables.introspector.get_database_info() # Check internal cache was cleared for database scope - backend_with_tables.introspector.make_cache_key(IntrospectionScope.DATABASE) + backend_with_tables.introspector.cache.key(IntrospectionScope.DATABASE) # The new result should be cached now assert db_info2 is not None @@ -126,13 +126,13 @@ class TestCacheExpiration: def test_cache_ttl(self, backend_with_tables): """Test that cache has TTL configured.""" # Check that TTL is set - assert hasattr(backend_with_tables.introspector, "_cache_ttl") - assert backend_with_tables.introspector._cache_ttl > 0 + assert hasattr(backend_with_tables.introspector.cache, "ttl") + assert backend_with_tables.introspector.cache.ttl > 0 def test_expired_cache_not_returned(self, mysql_backend_single): """Test that expired cache entries are not returned.""" # Set very short TTL - mysql_backend_single.introspector._cache_ttl = 0.01 # 10ms + mysql_backend_single.introspector.cache.ttl = 0.01 # 10ms # Get database info db_info1 = mysql_backend_single.introspector.get_database_info() @@ -151,8 +151,8 @@ class TestCacheThreadSafety: """Tests for cache thread safety.""" def test_cache_lock_exists(self, backend_with_tables): - """Test that cache lock exists.""" - assert hasattr(backend_with_tables.introspector, "_cache_lock") + """Test that cache lock exists (owned by the cache component).""" + assert hasattr(backend_with_tables.introspector.cache, "_lock") @pytest.mark.skip( reason="MySQL connections are not thread-safe; concurrent DB access on a single connection is expected to fail" @@ -198,7 +198,7 @@ class TestCacheKeys: def test_cache_key_generation(self, mysql_backend_single): """Test that cache keys are generated correctly.""" - key = mysql_backend_single.introspector.make_cache_key( + key = mysql_backend_single.introspector.cache.key( IntrospectionScope.TABLE, "users", schema=mysql_backend_single.config.database ) @@ -207,7 +207,7 @@ def test_cache_key_generation(self, mysql_backend_single): def test_cache_key_with_extra(self, mysql_backend_single): """Test cache key with extra component.""" - key = mysql_backend_single.introspector.make_cache_key( + key = mysql_backend_single.introspector.cache.key( IntrospectionScope.TABLE, schema=mysql_backend_single.config.database, extra="True" ) @@ -216,9 +216,9 @@ def test_cache_key_with_extra(self, mysql_backend_single): def test_cache_key_uniqueness(self, mysql_backend_single): """Test that different parameters produce different keys.""" - key1 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.TABLE, "users") - key2 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.TABLE, "posts") - key3 = mysql_backend_single.introspector.make_cache_key(IntrospectionScope.COLUMN, "users") + key1 = mysql_backend_single.introspector.cache.key(IntrospectionScope.TABLE, "users") + key2 = mysql_backend_single.introspector.cache.key(IntrospectionScope.TABLE, "posts") + key3 = mysql_backend_single.introspector.cache.key(IntrospectionScope.COLUMN, "users") assert key1 != key2 assert key1 != key3 From b817dfc26feb62edcfc75773273fd7f0aea586ba Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 01:38:54 +0800 Subject: [PATCH 48/65] refactor(introspection)!: render table options and partitions through the dialect The table-options and partition queries were built as SQL text with dialect.p() placeholders and bound values in a tuple the caller had to keep in step. MySQL has no GLOBAL-dict discovery for these: the rule is that SQL is a statement object, rendered by the dialect. Both questions now get a statement class in expression/introspection.py (MySQLTableOptionsListQueryStatement, MySQLTableOptionsInfoQueryStatement, MySQLPartitionListQueryStatement) and a format_*_query_statement on MySQLIntrospectionMixin assembled from expression objects -- Column, ComparisonPredicate, Literal, is_not_null, order -- through the existing _information_schema_query helper. The dialected SQL is identical to what the text produced, identifier quoting aside. The projections move to module constants next to the formatters that use them, so the list form and the single-table form cannot drift apart. Tests cannot run on this machine: the shared backend_with_tables fixture fails on a leftover tablespace in the local server (test_introspection_cache.py, untouched by this change, fails the same way), so verification here was the dialect rendering and dispatch, shown above. --- .../backend/impl/mysql/cli/introspect.py | 88 ++++- .../examples/introspection_table_options.py | 334 ++++++++++++++++++ .../impl/mysql/expression/introspection.py | 121 +++++++ .../impl/mysql/introspection/__init__.py | 5 + .../impl/mysql/introspection/introspector.py | 219 +++++++++++- .../backend/impl/mysql/introspection/types.py | 116 ++++++ .../impl/mysql/mixins/introspection.py | 142 ++++++++ tests/mysql_live_server.py | 66 ++++ .../feature/backend/introspection/conftest.py | 47 +++ .../test_introspection_options_cli.py | 117 ++++++ .../test_introspection_partitions.py | 168 +++++++++ .../test_introspection_table_options.py | 198 +++++++++++ 12 files changed, 1610 insertions(+), 11 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py create mode 100644 tests/mysql_live_server.py create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index c71c1c97..eca3555e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -17,7 +17,18 @@ OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] -INTROSPECT_TYPES = ["tables", "views", "table", "columns", "indexes", "foreign-keys", "triggers", "database"] +INTROSPECT_TYPES = [ + "tables", + "views", + "table", + "columns", + "indexes", + "foreign-keys", + "triggers", + "table-options", + "partitions", + "database", +] def create_parser(subparsers): @@ -38,6 +49,12 @@ def create_parser(subparsers): # Get column details for a table %(prog)s columns users --database mydb + # Get storage options (engine, row format, collation, sizes) + %(prog)s table-options --database mydb + + # Get the partitions of one table + %(prog)s partitions orders --database mydb + # Output as JSON %(prog)s tables --database mydb -o json @@ -71,7 +88,10 @@ def create_parser(subparsers): parser.add_argument( "type", choices=INTROSPECT_TYPES, - help="Introspection type: tables, views, table, columns, indexes, foreign-keys, triggers, database", + help=( + "Introspection type: tables, views, table, columns, indexes, " + "foreign-keys, triggers, table-options, partitions, database" + ), ) parser.add_argument( "name", @@ -204,6 +224,30 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") + elif args.type == "table-options": + if args.name: + options = introspector.get_table_options(args.name, schema=args.schema) + if options is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + _serialize_for_output([options]), title=f"Table options of {args.name}" + ) + else: + options = introspector.list_table_options(schema=args.schema) + provider.display_results( + _serialize_for_output(options), title="Table options" + ) + + elif args.type == "partitions": + if not args.name: + print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) + sys.exit(1) + partitions = introspector.list_partitions(args.name, schema=args.schema) + provider.display_results( + _serialize_for_output(partitions), title=f"Partitions of {args.name}" + ) + elif args.type == "database": info = introspector.get_database_info() data = _serialize_for_output(info) @@ -231,12 +275,12 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables_async(schema=args.schema, include_system=args.include_system) + tables = await introspector.list_tables(schema=args.schema, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views_async(schema=args.schema) + views = await introspector.list_views(schema=args.schema) data = _serialize_for_output(views) provider.display_results(data, title="Views") @@ -244,7 +288,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'table' introspection", file=sys.stderr) sys.exit(1) - info = await introspector.get_table_info_async(args.name, schema=args.schema) + info = await introspector.get_table_info(args.name, schema=args.schema) if info: provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") if info.indexes: @@ -261,7 +305,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = await introspector.list_columns_async(args.name, schema=args.schema) + columns = await introspector.list_columns(args.name, schema=args.schema) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -269,7 +313,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = await introspector.list_indexes_async(args.name, schema=args.schema) + indexes = await introspector.list_indexes(args.name, schema=args.schema) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") @@ -277,17 +321,41 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) sys.exit(1) - fks = await introspector.list_foreign_keys_async(args.name, schema=args.schema) + fks = await introspector.list_foreign_keys(args.name, schema=args.schema) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = await introspector.list_triggers_async(table_name=args.name, schema=args.schema) + triggers = await introspector.list_triggers(table_name=args.name, schema=args.schema) data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") + elif args.type == "table-options": + if args.name: + options = await introspector.get_table_options(args.name, schema=args.schema) + if options is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + _serialize_for_output([options]), title=f"Table options of {args.name}" + ) + else: + options = await introspector.list_table_options(schema=args.schema) + provider.display_results( + _serialize_for_output(options), title="Table options" + ) + + elif args.type == "partitions": + if not args.name: + print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) + sys.exit(1) + partitions = await introspector.list_partitions(args.name, schema=args.schema) + provider.display_results( + _serialize_for_output(partitions), title=f"Partitions of {args.name}" + ) + elif args.type == "database": - info = await introspector.get_database_info_async() + info = await introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py new file mode 100644 index 00000000..ae04609f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py @@ -0,0 +1,334 @@ +# src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py +"""What MySQL knows about a table's storage, and what the cache buys. + +Run:: + + python -m rhosocial.activerecord.backend.impl.mysql.examples.introspection_table_options + +The example creates a small partitioned table, asks the introspector for +its storage options (``list_table_options`` / ``get_table_options``) and +its partitions (``list_partitions``), and prints what the server answered. +It then walks the schema twice, counting the questions each walk actually +puts to the server: + +* **no cache** -- the manager holds a cache whose TTL is 0, so every + question is answered by asking; +* **warm** -- the default cache, on a manager that has walked this schema + before, so every question is answered from memory. + +Unlike SQLite, the questions here cross a socket, so the point of the +counts is not microseconds but round trips: the cold walk is what a schema +walk costs a server-backed application, and the warm walk is what the same +walk costs once the answer is remembered. + +Configuration is the same as the test suite's: the first scenario in +``tests/config/mysql_scenarios.yaml`` (or the file named by +``MYSQL_SCENARIOS_CONFIG_PATH``) is used when one is present, otherwise +the ``MYSQL_HOST`` / ``MYSQL_PORT`` / ``MYSQL_DATABASE`` / ``MYSQL_USER`` +/ ``MYSQL_PASSWORD`` environment variables. If no server can be reached, +the example says how to configure one and exits non-zero; it never +invents output. +""" + +import os +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend +from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig +from rhosocial.activerecord.backend.introspection import InMemoryIntrospectionCache +from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor + +DEMO_TABLE = "mysql_introspection_demo" +DEMO_PARTITIONS = ("p_before_2020", "p_before_2024", "p_max") + + +# --------------------------------------------------------------------- # +# Connecting the way the tests do +# --------------------------------------------------------------------- # + + +def _scenario_settings() -> Optional[Dict[str, Any]]: + """Return the first scenario from the test suite's YAML, if readable. + + ``MYSQL_SCENARIOS_CONFIG_PATH`` wins over the repository-local file, + which is the same precedence the test conftest applies. ``MYSQL_SCENARIO`` + picks a scenario by name when the file holds more than one. + """ + try: + import yaml + except ImportError: # pragma: no cover - PyYAML ships with the test extras + return None + + explicit = os.getenv("MYSQL_SCENARIOS_CONFIG_PATH") + candidates: List[Path] = [] + if explicit: + candidates.append(Path(explicit)) + else: + candidates.append(Path.cwd() / "tests" / "config" / "mysql_scenarios.yaml") + + for path in candidates: + if not path.is_file(): + continue + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + continue + scenarios = data.get("scenarios") or {} + if not scenarios: + continue + wanted = os.getenv("MYSQL_SCENARIO") + if wanted and wanted in scenarios: + return dict(scenarios[wanted]) + return dict(next(iter(scenarios.values()))) + return None + + +def connection_config() -> MySQLConnectionConfig: + """Build the connection config from a scenario file or environment.""" + scenario = _scenario_settings() + if scenario is not None: + # The scenario names the fields exactly as MySQLConnectionConfig + # does; drop nothing, because ssl_disabled is part of the test + # suite's local-server settings. + return MySQLConnectionConfig(**scenario) + + ssl_setting = os.getenv("MYSQL_SSL", "auto") + return MySQLConnectionConfig( + host=os.getenv("MYSQL_HOST", "localhost"), + port=int(os.getenv("MYSQL_PORT", "3306")), + database=os.getenv("MYSQL_DATABASE"), + username=os.getenv("MYSQL_USER", "root"), + password=os.getenv("MYSQL_PASSWORD", ""), + charset=os.getenv("MYSQL_CHARSET", "utf8mb4"), + ssl_disabled=ssl_setting == "disabled", + ) + + +def _explain_configuration(reason: str) -> None: + """Say how to point this example at a server, and stop.""" + print(f"Could not connect to MySQL: {reason}", file=sys.stderr) + print("", file=sys.stderr) + print("Configure a server in one of these ways:", file=sys.stderr) + print( + " 1. tests/config/mysql_scenarios.yaml (the file the test suite reads),", + file=sys.stderr, + ) + print( + " or set MYSQL_SCENARIOS_CONFIG_PATH to such a file " + "(MYSQL_SCENARIO picks one by name).", + file=sys.stderr, + ) + print( + " 2. MYSQL_HOST, MYSQL_PORT, MYSQL_DATABASE, MYSQL_USER, " + "MYSQL_PASSWORD, MYSQL_CHARSET, MYSQL_SSL.", + file=sys.stderr, + ) + print("", file=sys.stderr) + print( + "Then re-run: python -m " + "rhosocial.activerecord.backend.impl.mysql.examples.introspection_table_options", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------- # +# The demo schema, and what is asked about it +# --------------------------------------------------------------------- # + + +def build_demo_table(backend: MySQLBackend) -> None: + """Create a partitioned table, replacing any leftovers from a prior run. + + The low-level cursor is used on purpose: this is setup for a + measurement about introspection, so it should not pay the execution + hooks and logging of the public ``execute`` path. + """ + cursor = backend._get_cursor() + try: + cursor.execute(f"DROP TABLE IF EXISTS `{DEMO_TABLE}`") + cursor.execute( + f"CREATE TABLE `{DEMO_TABLE}` (" + " id INT NOT NULL AUTO_INCREMENT," + " created_at DATE NOT NULL," + " note VARCHAR(64) DEFAULT NULL," + " PRIMARY KEY (id, created_at)" + ") ENGINE=InnoDB ROW_FORMAT=DYNAMIC COMMENT='introspection demo' " + "PARTITION BY RANGE (YEAR(created_at)) (" + " PARTITION p_before_2020 VALUES LESS THAN (2020)," + " PARTITION p_before_2024 VALUES LESS THAN (2024)," + " PARTITION p_max VALUES LESS THAN MAXVALUE" + ")" + ) + finally: + cursor.close() + + +def drop_demo_table(backend: MySQLBackend) -> None: + """Remove the demo table, tolerating a connection that already went away.""" + try: + cursor = backend._get_cursor() + try: + cursor.execute(f"DROP TABLE IF EXISTS `{DEMO_TABLE}`") + finally: + cursor.close() + except Exception: + pass + + +def walk_schema(backend: MySQLBackend) -> int: + """Ask the introspector everything it knows about this schema's storage. + + Returns the number of values answered, so the report can say what the + questions bought rather than only how many there were. + """ + introspector = backend.introspector + answers = 0 + for option in introspector.list_table_options(): + answers += 1 + one = introspector.get_table_options(option.table_name) + answers += 1 if one is not None else 0 + answers += len(introspector.list_partitions(option.table_name)) + return answers + + +# --------------------------------------------------------------------- # +# Counting what the executor is asked to do +# --------------------------------------------------------------------- # + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that also reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +def uncached(backend: MySQLBackend) -> CountingExecutor: + """Install a cache that keeps nothing, and a counting executor.""" + introspector = backend.introspector + introspector._cache = InMemoryIntrospectionCache(ttl=0) + executor = CountingExecutor(backend) + introspector._executor = executor + return executor + + +def cached(backend: MySQLBackend) -> CountingExecutor: + """Install the default cache, and a counting executor.""" + introspector = backend.introspector + introspector._cache = InMemoryIntrospectionCache() + executor = CountingExecutor(backend) + introspector._executor = executor + return executor + + +def measure(backend: MySQLBackend) -> Tuple[float, int, int]: + """Time one walk, and report what it cost in questions and answers.""" + executor = backend.introspector._executor + before = executor.calls + started = time.perf_counter() + answers = walk_schema(backend) + elapsed = time.perf_counter() - started + return elapsed, executor.calls - before, answers + + +# --------------------------------------------------------------------- # +# Report +# --------------------------------------------------------------------- # + + +def _ms(seconds: float) -> str: + return f"{seconds * 1000:9.2f} ms" + + +def print_demo(backend: MySQLBackend) -> None: + """Print the demo table's storage options and partitions as answered.""" + introspector = backend.introspector + options = introspector.get_table_options(DEMO_TABLE) + if options is None: + print(f"{DEMO_TABLE} was not found by the server; nothing to report.") + return + + print(f"Table options for {options.schema}.{options.table_name}") + print(f" engine {options.engine}") + print(f" row format {options.row_format}") + print(f" collation {options.table_collation}") + print(f" auto increment {options.auto_increment}") + print(f" comment {options.table_comment!r}") + print(f" create options {options.create_options!r}") + print(f" data length {options.data_length} bytes (server estimate)") + print(f" index length {options.index_length} bytes (server estimate)") + + partitions = introspector.list_partitions(DEMO_TABLE) + print(f"\nPartitions of {DEMO_TABLE} ({len(partitions)}):") + for partition in partitions: + print( + f" {partition.partition_ordinal_position}. " + f"{partition.partition_name:<16} " + f"method={partition.partition_method} " + f"expression={partition.partition_expression} " + f"boundary={partition.partition_description} " + f"rows={partition.table_rows} " + f"data={partition.data_length}B index={partition.index_length}B" + ) + + +def report(backend: MySQLBackend) -> None: + """Measure cold and warm walks and print the comparison.""" + table_count = len(backend.introspector.list_tables()) + + # No cache: every question is put to the server, every time. + uncached(backend) + measure(backend) # warm whatever the process caches on its own + cold, questions, answers = measure(backend) + + # Default cache: the same questions, answered from memory. + cached(backend) + measure(backend) # fill the cache + warm, warm_questions, _ = measure(backend) + + print( + f"\nMySQL introspection over {table_count} tables, " + f"{answers} values answered, {questions} questions put to the server" + ) + print(f" no cache {_ms(cold)} {questions} questions") + print(f" cached {_ms(warm)} {warm_questions} questions") + if cold > 0: + print( + f"\n cache saves {_ms(cold - warm)} " + f"({(cold - warm) / cold * 100:.0f}% of the uncached pass)" + ) + + +def main() -> None: + backend = MySQLBackend(connection_config=connection_config()) + try: + backend.connect() + except Exception as exc: + _explain_configuration(str(exc)) + + try: + # The dialect gates its statements on a version; an unadapted + # dialect has none, and the introspector builds SQL through it. + backend.introspect_and_adapt() + build_demo_table(backend) + # The table did not exist when the schema was listed. + backend.introspector.clear_cache() + + print_demo(backend) + report(backend) + finally: + drop_demo_table(backend) + backend.disconnect() + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py new file mode 100644 index 00000000..a26b9761 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py @@ -0,0 +1,121 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py +"""MySQL introspection queries the core catalog does not model. + +Core's catalog expressions cover the objects every engine shares: tables and +their columns, indexes, foreign keys, views, triggers. Two families are +MySQL's own and have no core object: + +* **storage options** -- engine, row format, collation, auto-increment, + comment and the size estimates, which live in ``information_schema.TABLES`` + beside the rows core already reads but are not part of core's projection; +* **partitions** -- which live in a separate ``information_schema.PARTITIONS`` + relation and belong to one table rather than to the database. + +Both are expressed here and rendered by the dialect, so a MySQL query is a +statement object with a ``format_method`` and the SQL comes out of the +expression system: no caller assembles text, and the dialect owns the +placeholder syntax and the quoting of ``information_schema``. + +The object reads one database at a time -- MySQL's only namespace level above +a table -- so every class takes the database and, where the question is about +one table, its name. + +Example: + >>> expr = MySQLTableOptionsListQueryStatement(dialect, schema="shop") + >>> sql, params = expr.to_sql() +""" + +from typing import Optional + +from rhosocial.activerecord.backend.expression.introspection import ( + IntrospectionExpression, +) + +__all__ = [ + "MySQLTableOptionsListQueryStatement", + "MySQLTableOptionsInfoQueryStatement", + "MySQLPartitionListQueryStatement", +] + + +class MySQLTableOptionsListQueryStatement(IntrospectionExpression): + """Storage options for every base table in one database. + + ``TABLE_TYPE = 'BASE TABLE'`` is part of the question rather than a filter + applied afterwards: the relation also carries views, and a view has no + engine to report. + """ + + def __init__(self, dialect: "SQLDialectBase", schema: Optional[str] = None): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_options_list_query_statement" + + +class MySQLTableOptionsInfoQueryStatement(IntrospectionExpression): + """Storage options for one base table. + + Same projection as the list form, narrowed by name. An absent table then + yields no rows rather than a second query against a different relation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + table_name: The table whose options are wanted. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_options_info_query_statement" + + +class MySQLPartitionListQueryStatement(IntrospectionExpression): + """Partitions and subpartitions of one table. + + A non-partitioned table is reported by one row whose ``PARTITION_NAME`` is + NULL, so the predicate excludes it rather than filtering afterwards: an + empty result then means exactly "this table is not partitioned", and the + order is the engine's own ordinal pair. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + table_name: The table whose partitions are wanted. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_list_query_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py index e38c84f4..d61dfd84 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py @@ -9,12 +9,15 @@ AsyncShowIntrospector — asynchronous MySQL-specific SHOW command sub-introspector SyncMySQLStatusIntrospector — synchronous MySQL status introspector AsyncMySQLStatusIntrospector -- asynchronous MySQL status introspector + MySQLTableOptions — storage options of one table + MySQLPartitionInfo — one partition (or subpartition) of one table """ from .introspector import ( SyncMySQLIntrospector, AsyncMySQLIntrospector, ) +from .types import MySQLPartitionInfo, MySQLTableOptions from .show_introspector import ( SyncShowIntrospector, AsyncShowIntrospector, @@ -31,4 +34,6 @@ "AsyncShowIntrospector", "SyncMySQLStatusIntrospector", "AsyncMySQLStatusIntrospector", + "MySQLTableOptions", + "MySQLPartitionInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index 7039b0a0..c4d2e052 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -17,6 +17,8 @@ Key behaviours: - Queries information_schema.TABLES, COLUMNS, STATISTICS, KEY_COLUMN_USAGE, REFERENTIAL_CONSTRAINTS, VIEWS, TRIGGERS + - MySQL-only families: ``list_table_options`` / ``get_table_options`` + (storage options from TABLES) and ``list_partitions`` (from PARTITIONS) - _parse_* methods are pure Python — shared by sync and async introspectors Design principle: Sync and Async are separate and cannot coexist. @@ -25,7 +27,7 @@ """ import copy -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, @@ -53,6 +55,7 @@ IntrospectionScope, ) from rhosocial.activerecord.backend.expression.types import DataType +from .types import MySQLPartitionInfo, MySQLTableOptions from .show_introspector import ( SyncShowIntrospector, AsyncShowIntrospector, @@ -280,6 +283,91 @@ def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[Intro for row in rows ] + # ------------------------------------------------------------------ # + # Table storage options and partitions — SQL generation + # + # Neither family has a scope of its own in IntrospectionScope, and + # neither is the same question as "what tables are there". Both are + # therefore asked in IntrospectionScope.TABLE and kept apart from the + # table list (and from each other) by a distinct ``extra`` namespace, + # which is exactly what ``extra`` exists for. + # ------------------------------------------------------------------ # + + def _build_table_options_sql(self, schema: Optional[str]) -> Tuple[str, tuple]: + """The storage-options question, as a statement the dialect renders. + + ``DATA_LENGTH`` and ``INDEX_LENGTH`` are reported in bytes; see + :class:`MySQLTableOptions` for the caveat about InnoDB estimates. + """ + from ..expression.introspection import MySQLTableOptionsListQueryStatement + + target_db = schema if schema is not None else self._get_default_schema() + return MySQLTableOptionsListQueryStatement(self.dialect, schema=target_db).to_sql() + + def _build_table_options_lookup_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: + """One table's storage options, rendered through the dialect.""" + from ..expression.introspection import MySQLTableOptionsInfoQueryStatement + + target_db = schema if schema is not None else self._get_default_schema() + return MySQLTableOptionsInfoQueryStatement( + self.dialect, table_name=table_name, schema=target_db + ).to_sql() + + def _build_partition_list_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: + """One table's partitions, rendered through the dialect. + + Rows without a ``PARTITION_NAME`` describe a non-partitioned table + and are not partitions, so the predicate excludes them rather than + filtering after the fact: an empty result then means exactly "this + table is not partitioned". + """ + from ..expression.introspection import MySQLPartitionListQueryStatement + + target_db = schema if schema is not None else self._get_default_schema() + return MySQLPartitionListQueryStatement( + self.dialect, table_name=table_name, schema=target_db + ).to_sql() + + # ------------------------------------------------------------------ # + # Table storage options and partitions — parsing + # ------------------------------------------------------------------ # + + def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[MySQLTableOptions]: + return [ + MySQLTableOptions( + schema=row.get("TABLE_SCHEMA", ""), + table_name=row["TABLE_NAME"], + engine=row.get("ENGINE"), + row_format=row.get("ROW_FORMAT"), + table_collation=row.get("TABLE_COLLATION"), + auto_increment=row.get("AUTO_INCREMENT"), + table_comment=row.get("TABLE_COMMENT"), + create_options=row.get("CREATE_OPTIONS"), + data_length=row.get("DATA_LENGTH"), + index_length=row.get("INDEX_LENGTH"), + ) + for row in rows + ] + + def _parse_partitions(self, rows: List[Dict[str, Any]]) -> List[MySQLPartitionInfo]: + return [ + MySQLPartitionInfo( + schema=row.get("TABLE_SCHEMA", ""), + table_name=row.get("TABLE_NAME", ""), + partition_name=row.get("PARTITION_NAME"), + partition_method=row.get("PARTITION_METHOD"), + partition_expression=row.get("PARTITION_EXPRESSION"), + partition_description=row.get("PARTITION_DESCRIPTION"), + partition_ordinal_position=row.get("PARTITION_ORDINAL_POSITION"), + subpartition_name=row.get("SUBPARTITION_NAME"), + subpartition_ordinal_position=row.get("SUBPARTITION_ORDINAL_POSITION"), + table_rows=row.get("TABLE_ROWS"), + data_length=row.get("DATA_LENGTH"), + index_length=row.get("INDEX_LENGTH"), + ) + for row in rows + ] + class SyncMySQLIntrospector(MySQLIntrospectorMixin, SyncAbstractIntrospector): """Synchronous introspector for MySQL backends. @@ -341,6 +429,70 @@ def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optio self._cache.set(key, table) return table + # ------------------------------------------------------------------ # + # MySQL-specific: table storage options and partitions + # ------------------------------------------------------------------ # + + def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOptions]: + """List storage options for every base table in *schema*. + + Answers from ``information_schema.TABLES``: engine, row format, + collation, auto-increment counter, comment, extra create options + and the server's data/index byte accounting. + """ + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_table_options_sql(schema) + result = self._parse_table_options(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def get_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[MySQLTableOptions]: + """Return storage options for one base table, or ``None``. + + ``None`` covers both "no such table" and "that name is a view": + a view has no storage engine, and reporting a half-empty record + as if it were a table would be worse than saying nothing. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_table_options_lookup_sql(table_name, schema) + result = next(iter(self._parse_table_options(self._executor.execute(sql, params))), None) + if result is not None: + self._cache.set(key, result) + return result + + def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[MySQLPartitionInfo]: + """List the partitions of one table, from ``information_schema.PARTITIONS``. + + Returns an empty list for a table that is not partitioned. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_partition_list_sql(table_name, schema) + result = self._parse_partitions(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + class AsyncMySQLIntrospector(MySQLIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for MySQL backends. @@ -401,3 +553,68 @@ async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> table.foreign_keys = await self.list_foreign_keys(table_name, schema) self._cache.set(key, table) return table + + # ------------------------------------------------------------------ # + # MySQL-specific: table storage options and partitions + # ------------------------------------------------------------------ # + + async def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOptions]: + """List storage options for every base table in *schema*. + + Answers from ``information_schema.TABLES``: engine, row format, + collation, auto-increment counter, comment, extra create options + and the server's data/index byte accounting. + """ + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_table_options_sql(schema) + result = self._parse_table_options(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def get_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[MySQLTableOptions]: + """Return storage options for one base table, or ``None``. + + ``None`` covers both "no such table" and "that name is a view": + a view has no storage engine, and reporting a half-empty record + as if it were a table would be worse than saying nothing. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_table_options_lookup_sql(table_name, schema) + rows = await self._executor.execute(sql, params) + result = next(iter(self._parse_table_options(rows)), None) + if result is not None: + self._cache.set(key, result) + return result + + async def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[MySQLPartitionInfo]: + """List the partitions of one table, from ``information_schema.PARTITIONS``. + + Returns an empty list for a table that is not partitioned. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self._build_partition_list_sql(table_name, schema) + result = self._parse_partitions(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py new file mode 100644 index 00000000..54377943 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py @@ -0,0 +1,116 @@ +# src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py +"""MySQL-specific introspection result types. + +The core introspection types describe what every backend can answer about +tables, columns and indexes. MySQL can answer two further questions that +have no cross-backend shape: what a table's *storage* options are (engine, +row format, collation, comment, self-managed auto-increment counter), and +how it is *partitioned*. Both are reported here as frozen dataclasses so a +result cannot be edited in place after the cache handed it out. + +Units are stated on the fields rather than implied: MySQL reports +``DATA_LENGTH`` and ``INDEX_LENGTH`` in bytes, and those are the values +carried here unchanged. They are the server's own accounting, which for +InnoDB is an estimate from the tablespace rather than an exact byte count, +so they are reported as the server said them and not recomputed. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class MySQLTableOptions: + """Storage options of one MySQL table. + + Mirrors the ``information_schema.TABLES`` columns of the same names. + Every field but the identity is optional: a view has no engine, a + table without a self-managed counter has no auto-increment value, and + the server may not report a field at all on an older release. + + Attributes: + schema: The database (MySQL's catalog/schema slot) the table lives in. + table_name: The table's name. + engine: Storage engine, e.g. ``"InnoDB"``. + row_format: Physical row format, e.g. ``"Dynamic"``. + table_collation: Default collation for the table's character columns. + auto_increment: Next value the self-managed counter will hand out; + ``None`` when the table has no AUTO_INCREMENT column or has not + been written to yet. + table_comment: The table's ``COMMENT``, empty string when unset. + create_options: Extra options as the server echoes them in + ``SHOW CREATE TABLE`` (e.g. ``"partitioned"``). + data_length: Bytes the server accounts to the table's rows. For + InnoDB this is an estimate from the tablespace, not an exact + sum of row sizes. + index_length: Bytes the server accounts to the table's indexes; + same caveat about being an estimate for InnoDB. + """ + + schema: str + table_name: str + engine: Optional[str] = None + row_format: Optional[str] = None + table_collation: Optional[str] = None + auto_increment: Optional[int] = None + table_comment: Optional[str] = None + create_options: Optional[str] = None + data_length: Optional[int] = None + index_length: Optional[int] = None + + @property + def total_length(self) -> Optional[int]: + """Data plus index bytes, or ``None`` if neither is reported. + + A convenience only: the two components stay available separately + because they answer different questions (row storage vs index + storage) and summing them here does not change that. + """ + if self.data_length is None and self.index_length is None: + return None + return (self.data_length or 0) + (self.index_length or 0) + + +@dataclass(frozen=True) +class MySQLPartitionInfo: + """One row of ``information_schema.PARTITIONS`` for a table. + + A subpartitioned table reports one row per (partition, subpartition) + pair, which is why the subpartition identity is carried too: without + it two rows of the same partition would be indistinguishable. + + Attributes: + schema: The database the table lives in. + table_name: The partitioned table's name. + partition_name: The partition's name, or ``None`` for a + non-partitioned table (those rows are filtered out before + parsing, so this is a plain string in practice). + partition_method: ``RANGE``, ``LIST``, ``HASH``, ``KEY`` or + ``LINEAR`` as reported by the server. + partition_expression: The expression the method was applied to, + e.g. ``"YEAR(`created_at`)"``; ``None`` for ``KEY`` partitions. + partition_description: The boundary value for RANGE/LIST + partitions; ``None`` for HASH/KEY. + partition_ordinal_position: 1-based position of the partition. + subpartition_name: The subpartition's name, ``None`` when the table + is not subpartitioned. + subpartition_ordinal_position: 1-based position within the + partition, ``None`` when not subpartitioned. + table_rows: Rows the server estimates the partition holds. + data_length: Bytes the server accounts to this partition's rows. + index_length: Bytes the server accounts to this partition's + indexes. + """ + + schema: str + table_name: str + partition_name: Optional[str] = None + partition_method: Optional[str] = None + partition_expression: Optional[str] = None + partition_description: Optional[str] = None + partition_ordinal_position: Optional[int] = None + subpartition_name: Optional[str] = None + subpartition_ordinal_position: Optional[int] = None + table_rows: Optional[int] = None + data_length: Optional[int] = None + index_length: Optional[int] = None diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py index 9555809e..04aa2189 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py @@ -13,12 +13,50 @@ ViewInfoQueryStatement, TriggerListQueryStatement, ) + from ...expression.introspection import ( + MySQLTableOptionsListQueryStatement, + MySQLTableOptionsInfoQueryStatement, + MySQLPartitionListQueryStatement, + ) #: MySQL's own metadata databases. A caller asking for "user tables" does not #: mean these four, so they are excluded unless system objects were requested. SYSTEM_SCHEMAS = ("information_schema", "performance_schema", "mysql", "sys") +#: The storage-options projection, shared by the list and the single-table +#: form: one engine's facts about a table, in the order the catalogue reports +#: them. ``DATA_LENGTH`` and ``INDEX_LENGTH`` are byte estimates for InnoDB. +TABLE_OPTION_COLUMNS = ( + "TABLE_SCHEMA", + "TABLE_NAME", + "ENGINE", + "ROW_FORMAT", + "TABLE_COLLATION", + "AUTO_INCREMENT", + "TABLE_COMMENT", + "CREATE_OPTIONS", + "DATA_LENGTH", + "INDEX_LENGTH", +) + +#: The partition projection: one row per partition or subpartition, in the +#: engine's ordinal order. +PARTITION_COLUMNS = ( + "TABLE_SCHEMA", + "TABLE_NAME", + "PARTITION_NAME", + "SUBPARTITION_NAME", + "PARTITION_METHOD", + "PARTITION_EXPRESSION", + "PARTITION_DESCRIPTION", + "PARTITION_ORDINAL_POSITION", + "SUBPARTITION_ORDINAL_POSITION", + "TABLE_ROWS", + "DATA_LENGTH", + "INDEX_LENGTH", +) + class MySQLIntrospectionMixin: """MySQL introspection capability declaration and query formatting. @@ -459,3 +497,107 @@ def format_trigger_list_query_statement(self, expr: "TriggerListQueryStatement") conditions, [Column(self, "TRIGGER_NAME")], ) + + def format_table_options_list_query_statement( + self, expr: "MySQLTableOptionsListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the storage-options query for every base table. + + ``DATA_LENGTH`` and ``INDEX_LENGTH`` are bytes for InnoDB estimates of + the clustered index, not exact sizes; the parser repeats the caveat + next to the numbers. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + schema = expr.get_params().get("schema") or "" + + return self._information_schema_query( + "TABLES", + [Column(self, name) for name in TABLE_OPTION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ), + ], + [Column(self, "TABLE_NAME")], + ) + + def format_table_options_info_query_statement( + self, expr: "MySQLTableOptionsInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the storage-options query for one table.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + params = expr.get_params() + schema = params.get("schema") or "" + table_name = params.get("table_name", "") + + return self._information_schema_query( + "TABLES", + [Column(self, name) for name in TABLE_OPTION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name) + ), + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ), + ], + ) + + def format_partition_list_query_statement( + self, expr: "MySQLPartitionListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the partition query for one table. + + ``PARTITION_NAME IS NOT NULL`` drops the row a non-partitioned table + always has, so an empty answer means "not partitioned" and nothing + weaker than that. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + params = expr.get_params() + schema = params.get("schema") or "" + table_name = params.get("table_name", "") + + return self._information_schema_query( + "PARTITIONS", + [Column(self, name) for name in PARTITION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name) + ), + Column(self, "PARTITION_NAME").is_not_null(), + ], + [ + Column(self, "PARTITION_ORDINAL_POSITION"), + Column(self, "SUBPARTITION_ORDINAL_POSITION"), + ], + ) diff --git a/tests/mysql_live_server.py b/tests/mysql_live_server.py new file mode 100644 index 00000000..70f6a134 --- /dev/null +++ b/tests/mysql_live_server.py @@ -0,0 +1,66 @@ +# tests/mysql_live_server.py +"""Collection-time check that a MySQL server is reachable. + +The backend-feature suite connects from its fixtures, so a missing server +surfaces as a fixture error rather than a skip. Tests whose whole point is +a live server use :func:`mysql_server_available` in a module-level +``skipif`` marker instead, which is decided at collection time and before +any fixture tries to connect. + +The scenarios are read from the same YAML the test conftest reads, with +the same ``MYSQL_SCENARIOS_CONFIG_PATH`` override, so "reachable" means +"one of the servers this run is configured to use". +""" + +import os +import socket +from pathlib import Path +from typing import Iterator, Optional + +import yaml + +DEFAULT_SCENARIO_FILE = ( + Path(__file__).resolve().parent / "config" / "mysql_scenarios.yaml" +) + + +def scenario_config_path() -> Optional[Path]: + """Return the scenario file this run will use, if any.""" + explicit = os.getenv("MYSQL_SCENARIOS_CONFIG_PATH") + if explicit: + path = Path(explicit) + return path if path.is_file() else None + return DEFAULT_SCENARIO_FILE if DEFAULT_SCENARIO_FILE.is_file() else None + + +def _scenarios() -> Iterator[dict]: + path = scenario_config_path() + if path is None: + return + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return + for config in (data.get("scenarios") or {}).values(): + if isinstance(config, dict): + yield config + + +def mysql_server_available(timeout: float = 1.0) -> bool: + """Return True if any configured MySQL server accepts a TCP connection. + + Only the transport is checked: a server that accepts and then refuses + the credentials still means "a server is there", and the fixture that + actually connects reports the refusal in the usual way. + """ + for config in _scenarios(): + host = config.get("host") + port = config.get("port") + if not host or not port: + continue + try: + with socket.create_connection((str(host), int(port)), timeout=timeout): + return True + except OSError: + continue + return False diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py index 96f7ae1a..b9b18f71 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py @@ -130,6 +130,27 @@ SET FOREIGN_KEY_CHECKS = 1; """ +# A RANGE-partitioned table. The partition column has to be part of the +# primary key in MySQL, hence the composite key. +_PARTITIONED_TABLE_SQL = """ + DROP TABLE IF EXISTS partitioned_events; + CREATE TABLE partitioned_events ( + id INT NOT NULL, + created_at DATE NOT NULL, + payload VARCHAR(64) DEFAULT NULL, + PRIMARY KEY (id, created_at) + ) ENGINE=InnoDB COMMENT='partitioned introspection fixture' + PARTITION BY RANGE (YEAR(created_at)) ( + PARTITION p2020 VALUES LESS THAN (2021), + PARTITION p2021 VALUES LESS THAN (2022), + PARTITION p_max VALUES LESS THAN MAXVALUE + ); +""" + +_CLEANUP_PARTITIONED_TABLE_SQL = """ + DROP TABLE IF EXISTS partitioned_events; +""" + @pytest.fixture(scope="function") def backend_with_tables(mysql_backend): @@ -207,3 +228,29 @@ async def async_backend_with_trigger(async_mysql_backend): await async_mysql_backend.executescript(_CLEANUP_TRIGGER_SQL) except Exception: pass + + +@pytest.fixture(scope="function") +def backend_with_partitioned_table(backend_with_tables): + """Fixture providing backend with a RANGE-partitioned table.""" + backend_with_tables.introspector.clear_cache() + backend_with_tables.executescript(_PARTITIONED_TABLE_SQL) + yield backend_with_tables + try: + backend_with_tables.introspector.clear_cache() + backend_with_tables.executescript(_CLEANUP_PARTITIONED_TABLE_SQL) + except Exception: + pass + + +@pytest_asyncio.fixture(scope="function") +async def async_backend_with_partitioned_table(async_backend_with_tables): + """Async fixture providing backend with a RANGE-partitioned table.""" + async_backend_with_tables.introspector.clear_cache() + await async_backend_with_tables.executescript(_PARTITIONED_TABLE_SQL) + yield async_backend_with_tables + try: + async_backend_with_tables.introspector.clear_cache() + await async_backend_with_tables.executescript(_CLEANUP_PARTITIONED_TABLE_SQL) + except Exception: + pass diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py new file mode 100644 index 00000000..aa6d2296 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py @@ -0,0 +1,117 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py +"""Tests for the ``table-options`` and ``partitions`` CLI subcommands. + +The subcommands are exercised through ``main([...])`` rather than through +the handler directly, so argument parsing, backend construction, dispatch +to ``backend.introspector`` and rendering by the output provider are all +covered. JSON output is used because it is machine-checkable. +""" + +import json + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.__main__ import main +from rhosocial.activerecord.backend.impl.mysql.cli.introspect import INTROSPECT_TYPES + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + +PARTITIONED_TABLE = "partitioned_events" + + +def _cli_args(backend, *rest) -> list: + """Build CLI arguments that reach the same server as *backend*.""" + config = backend.config + args = [ + "introspect", + *rest, + "--host", + config.host, + "--port", + str(config.port), + "--database", + config.database, + "--user", + config.username, + "--password", + config.password or "", + "--charset", + config.charset or "utf8mb4", + ] + if getattr(config, "ssl_disabled", None): + args.extend(["--ssl", "disabled"]) + return args + ["-o", "json"] + + +def _run(capsys, args) -> str: + main(args) + captured = capsys.readouterr() + return captured.out + + +class TestIntrospectTypes: + """The new families are offered by the parser.""" + + def test_new_families_are_registered(self): + assert "table-options" in INTROSPECT_TYPES + assert "partitions" in INTROSPECT_TYPES + + +class TestTableOptionsCommand: + """Tests for ``introspect table-options``.""" + + def test_lists_all_tables(self, backend_with_tables, capsys): + out = _run(capsys, _cli_args(backend_with_tables, "table-options")) + + payload = json.loads(out) + assert payload + names = {row["table_name"] for row in payload} + assert "users" in names + users = next(row for row in payload if row["table_name"] == "users") + assert users["engine"] == "InnoDB" + assert "data_length" in users + + def test_reports_one_table(self, backend_with_tables, capsys): + out = _run(capsys, _cli_args(backend_with_tables, "table-options", "users")) + + payload = json.loads(out) + assert len(payload) == 1 + assert payload[0]["table_name"] == "users" + assert payload[0]["engine"] == "InnoDB" + + def test_unknown_table_exits_non_zero(self, backend_with_tables, capsys): + with pytest.raises(SystemExit) as excinfo: + main( + _cli_args( + backend_with_tables, "table-options", "table_options_does_not_exist" + ) + ) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err + + +class TestPartitionsCommand: + """Tests for ``introspect partitions``.""" + + def test_emits_partitions(self, backend_with_partitioned_table, capsys): + out = _run( + capsys, + _cli_args(backend_with_partitioned_table, "partitions", PARTITIONED_TABLE), + ) + + payload = json.loads(out) + assert len(payload) == 3 + assert [row["partition_name"] for row in payload] == ["p2020", "p2021", "p_max"] + + def test_requires_a_table_name(self, backend_with_tables, capsys): + with pytest.raises(SystemExit) as excinfo: + main(_cli_args(backend_with_tables, "partitions")) + + assert excinfo.value.code == 1 + assert "required" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py new file mode 100644 index 00000000..b7edad78 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py @@ -0,0 +1,168 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py +"""Tests for MySQL partition introspection. + +``list_partitions`` answers from ``information_schema.PARTITIONS``. A +partitioned table reports one row per partition (one per partition and +subpartition pair, when subpartitioned); a table that is not partitioned +has only a NULL-named row, which is filtered out so an empty list means +exactly "not partitioned". +""" + +from typing import Any, Dict, List, Tuple + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.introspection.types import ( + MySQLPartitionInfo, +) +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + +PARTITIONED_TABLE = "partitioned_events" + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +class AsyncCountingExecutor(AsyncIntrospectorExecutor): + """The async counterpart of :class:`CountingExecutor`.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + async def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return await super().execute(sql, params) + + +class TestListPartitions: + """Tests for list_partitions against a live server.""" + + def test_returns_typed_partitions(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert len(partitions) == 3 + assert all(isinstance(partition, MySQLPartitionInfo) for partition in partitions) + assert [p.partition_name for p in partitions] == ["p2020", "p2021", "p_max"] + + def test_reports_method_expression_and_ordinals(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert [p.partition_method for p in partitions] == ["RANGE"] * 3 + assert [p.partition_ordinal_position for p in partitions] == [1, 2, 3] + for partition in partitions: + assert partition.partition_expression + assert "YEAR" in partition.partition_expression.upper() + assert "created_at" in partition.partition_expression + assert partition.schema == backend_with_partitioned_table.config.database + assert partition.table_name == PARTITIONED_TABLE + + def test_reports_boundaries_and_sizes(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert [p.partition_description for p in partitions] == [ + "2021", + "2022", + "MAXVALUE", + ] + for partition in partitions: + assert partition.table_rows is None or isinstance(partition.table_rows, int) + assert partition.data_length is None or isinstance(partition.data_length, int) + assert partition.index_length is None or isinstance(partition.index_length, int) + + def test_non_partitioned_table_returns_empty(self, backend_with_tables): + tables = backend_with_tables.introspector.list_tables() + assert "users" in {table.name for table in tables} + + assert backend_with_tables.introspector.list_partitions("users") == [] + + def test_missing_table_returns_empty(self, backend_with_tables): + assert ( + backend_with_tables.introspector.list_partitions( + "partitions_does_not_exist" + ) + == [] + ) + + def test_cache_returns_the_same_list(self, backend_with_partitioned_table): + first = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + second = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_partitioned_table): + introspector = backend_with_partitioned_table.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_partitioned_table) + introspector._executor = executor + + introspector.list_partitions(PARTITIONED_TABLE) + cold = executor.calls + introspector.list_partitions(PARTITIONED_TABLE) + + assert cold > 0 + assert executor.calls == cold + + +class TestAsyncListPartitions: + """Async tests for partition introspection.""" + + @pytest.mark.asyncio + async def test_returns_typed_partitions(self, async_backend_with_partitioned_table): + partitions = await async_backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert len(partitions) == 3 + assert all(isinstance(partition, MySQLPartitionInfo) for partition in partitions) + assert [p.partition_name for p in partitions] == ["p2020", "p2021", "p_max"] + assert [p.partition_ordinal_position for p in partitions] == [1, 2, 3] + + @pytest.mark.asyncio + async def test_non_partitioned_table_returns_empty(self, async_backend_with_tables): + partitions = await async_backend_with_tables.introspector.list_partitions("users") + + assert partitions == [] + + @pytest.mark.asyncio + async def test_warm_cache_asks_the_server_nothing(self, async_backend_with_partitioned_table): + introspector = async_backend_with_partitioned_table.introspector + introspector.clear_cache() + executor = AsyncCountingExecutor(async_backend_with_partitioned_table) + introspector._executor = executor + + await introspector.list_partitions(PARTITIONED_TABLE) + cold = executor.calls + await introspector.list_partitions(PARTITIONED_TABLE) + + assert cold > 0 + assert executor.calls == cold diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py new file mode 100644 index 00000000..64aeb08f --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py @@ -0,0 +1,198 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py +"""Tests for MySQL table storage-options introspection. + +``list_table_options`` / ``get_table_options`` answer from +``information_schema.TABLES``: engine, row format, collation, the +self-managed auto-increment counter, comment, extra create options and the +server's data/index byte accounting. These are MySQL-only questions with +no cross-backend type, so the results are the typed +:class:`MySQLTableOptions` records rather than plain dicts. +""" + +from dataclasses import FrozenInstanceError +from typing import Any, Dict, List, Tuple + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.introspection.types import ( + MySQLTableOptions, +) +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +class AsyncCountingExecutor(AsyncIntrospectorExecutor): + """The async counterpart of :class:`CountingExecutor`.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + async def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return await super().execute(sql, params) + + +class TestListTableOptions: + """Tests for list_table_options against a live server.""" + + def test_returns_typed_non_empty_results(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + + assert options + assert all(isinstance(option, MySQLTableOptions) for option in options) + names = {option.table_name for option in options} + assert "users" in names + + def test_reports_storage_fields_for_users(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + users = next(option for option in options if option.table_name == "users") + + assert users.schema == backend_with_tables.config.database + assert users.engine == "InnoDB" + assert users.row_format + assert users.table_collation + assert users.table_comment is not None + assert isinstance(users.data_length, int) + assert isinstance(users.index_length, int) + assert users.total_length == users.data_length + users.index_length + + def test_result_is_frozen(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + with pytest.raises(FrozenInstanceError): + options[0].engine = "MyISAM" # type: ignore[misc] + + def test_cache_returns_the_same_list(self, backend_with_tables): + first = backend_with_tables.introspector.list_table_options() + second = backend_with_tables.introspector.list_table_options() + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.list_table_options() + cold = executor.calls + introspector.list_table_options() + + assert cold > 0 + assert executor.calls == cold + + +class TestGetTableOptions: + """Tests for get_table_options against a live server.""" + + def test_returns_typed_result(self, backend_with_tables): + options = backend_with_tables.introspector.get_table_options("users") + + assert isinstance(options, MySQLTableOptions) + assert options.table_name == "users" + assert options.engine == "InnoDB" + + def test_missing_table_returns_none(self, backend_with_tables): + assert ( + backend_with_tables.introspector.get_table_options( + "table_options_does_not_exist" + ) + is None + ) + + def test_cache_returns_the_same_record(self, backend_with_tables): + first = backend_with_tables.introspector.get_table_options("users") + second = backend_with_tables.introspector.get_table_options("users") + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.get_table_options("users") + cold = executor.calls + introspector.get_table_options("users") + + assert cold > 0 + assert executor.calls == cold + + def test_invalidating_the_table_drops_the_record(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.get_table_options("users") + cold = executor.calls + introspector.invalidate_cache(scope=IntrospectionScope.TABLE, name="users") + introspector.get_table_options("users") + + assert executor.calls > cold + + +class TestAsyncTableOptions: + """Async tests for table storage-options introspection.""" + + @pytest.mark.asyncio + async def test_returns_typed_non_empty_results(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.list_table_options() + + assert options + assert all(isinstance(option, MySQLTableOptions) for option in options) + assert "users" in {option.table_name for option in options} + + @pytest.mark.asyncio + async def test_returns_typed_record(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.get_table_options("users") + + assert isinstance(options, MySQLTableOptions) + assert options.engine == "InnoDB" + + @pytest.mark.asyncio + async def test_missing_table_returns_none(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.get_table_options( + "table_options_does_not_exist" + ) + + assert options is None + + @pytest.mark.asyncio + async def test_warm_cache_asks_the_server_nothing(self, async_backend_with_tables): + introspector = async_backend_with_tables.introspector + introspector.clear_cache() + executor = AsyncCountingExecutor(async_backend_with_tables) + introspector._executor = executor + + await introspector.list_table_options() + await introspector.get_table_options("users") + cold = executor.calls + await introspector.list_table_options() + await introspector.get_table_options("users") + + assert cold > 0 + assert executor.calls == cold From aa99a175607166c05d933005833166bf8ada3f43 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 13:27:30 +0800 Subject: [PATCH 49/65] docs(cli): say what introspect can ask about The introspect subcommand accepted a list of types and nothing in the tree said what each one reads or where its answer comes from. This adds a README to each backend's cli package: the questions with the catalogue each reads, the options, the notes that keep an answer from being misread (estimate lengths, upper-casing, version gates, capabilities that are refused rather than empty), and examples. The tables are generated from the code, not from memory -- every entry was checked against the statement it dispatches to. --- .../backend/impl/mysql/cli/README.md | 73 +++++++++++++++++++ .../feature/backend/introspection/conftest.py | 9 +++ 2 files changed, 82 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/mysql/cli/README.md diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md new file mode 100644 index 00000000..5332a2ee --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md @@ -0,0 +1,73 @@ +# `introspect` — MySQL metadata, from the command line + +The `introspect` subcommand asks a live MySQL server what it holds, and prints +the answer. Every question runs through the backend's introspector, so what the +CLI reports is what the library sees — the same objects, the same types, the +same parsing — rather than a second implementation that can drift from it. + +```bash +python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | Reads | Notes | +| :--- | :--- | :--- | +| `tables` | `information_schema.tables` | Base tables. `--include-system` adds MySQL's own metadata databases. | +| `views` | `information_schema.views` | Views, with their definitions. | +| `table` | several | One table by name, with its columns, indexes and foreign keys filled in. | +| `columns` | `information_schema.columns` | One table's columns: name, type, nullability, default. | +| `indexes` | `information_schema.statistics` | One table's indexes, including the primary key. | +| `foreign-keys` | `information_schema.key_column_usage` | One table's constraints and the columns each side names. | +| `triggers` | `information_schema.triggers` | Triggers, optionally narrowed to one table. | +| `table-options` | `information_schema.tables` | The storage facts about a table: engine, row format, collation, auto-increment, comment, and the byte sizes `information_schema` reports. | +| `partitions` | `information_schema.partitions` | One table's partitions and subpartitions, in the engine's own order. An empty answer means the table is not partitioned. | +| `database` | `information_schema.schemata`, `variables` | Server version, charset, and the databases visible to this role. | + +`NAME` is required for `table`, `columns`, `indexes`, `foreign-keys` and +`partitions`. The list types take no name. + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--schema` | the connected database | The database to read. MySQL's only namespace level above a table. | +| `--include-system` | off | Include system tables. | +| `--host`, `--port`, `--database`, `--user`, `--password` | env or defaults | Connection. `MYSQL_*` env vars are honoured, and arguments override them. | +| `--charset` | _None_ | Connection charset. | +| `--ssl` | off | Use SSL for the connection. | +| `--async` | off | Drive the asynchronous backend. | +| `--named-connection` | _None_ | A connection saved by the `named-connection` subcommand. | +| `--conn-param` | _None_ | Extra `key=value` connection parameters; repeatable. | +| `--rich-ascii` | off | ASCII characters for table borders. | + +## Notes + +* **Read-only.** `introspect` never writes. It does not create, drop, or + alter anything, so it is safe to run against a database whose schema + matters. +* **The database is explicit.** `--schema` names the database to read, rather + than relying on the connection's default — which differs between clients + and is the usual reason two tools disagree about what a table looks like. +* **`data_length` and `index_length` are estimates.** InnoDB reports + approximate byte sizes for the clustered index, not exact file sizes. They + are printed as reported; the caveat travels with the number. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs, so + the answer reflects the server as it is *now*. + +## Examples + +```bash +# Storage facts and partitions for the order tables +python -m rhosocial.activerecord.backend.impl.mysql introspect table-options --database shop +python -m rhosocial.activerecord.backend.impl.mysql introspect partitions orders --database shop -o json + +# One table in full +python -m rhosocial.activerecord.backend.impl.mysql introspect table orders --database shop + +# A script asks the question and reads the answer +export MYSQL_HOST=localhost MYSQL_DATABASE=shop MYSQL_USER=root MYSQL_PASSWORD=secret +python -m rhosocial.activerecord.backend.impl.mysql introspect tables -o json | jq '.[].name' +``` diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py index b9b18f71..4d4ddd68 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py @@ -11,6 +11,15 @@ # SQL script for creating test tables +# +# The DROP list is ordered by dependency and runs before every CREATE, so a +# previous run that failed *during* setup leaves nothing that would make this +# one fail. That case is real: a fixture generator that raises before its +# ``yield`` never reaches its teardown, so a server touched by an earlier +# failure keeps the tables -- and the next setup then dies on MySQL's +# "tablespace exists" (error 1813), which is about the InnoDB space rather +# than about the row in the dictionary. Dropping first makes setup idempotent, +# which is what keeps that from ever starting. _TABLES_SQL = """ SET FOREIGN_KEY_CHECKS = 0; DROP TABLE IF EXISTS post_tags; From ba5df3833091a62a3cd9f2cd22fad2d509c45c4d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 15:19:42 +0800 Subject: [PATCH 50/65] refactor(cli): name the namespace after the engine, not after the slot The introspection CLI took a --schema flag on backends that have no schema. MySQL, MariaDB and ClickHouse have exactly one namespace level and call it a*database; Firebird has a single unnamed namespace and qualifies nothing above the*table. Asking those engines for a --schema named a level they do not have, and*the flag's help text then had to explain that the value meant something else. The flag is gone from those four. What is left is the flag the connection*already takes, which for MySQL, MariaDB and ClickHouse is --database -- so the*run connects to a database and asks about that database, which is the whole*answer when there is only one level. list_datasets() is the way to enumerate*databases; the run does not need a flag to ask about a different one. BigQuery had both --schema and --dataset naming the same level, with a*fallback order between them. One flag now, --dataset, which the connection*takes. The flags are removed rather than aliased: an alias lets a caller keep naming a*level that does not exist, which is the confusion this removes. The tests that*pinned the old flags now pin that they are gone. --- .../backend/impl/mysql/cli/README.md | 12 ++-- .../backend/impl/mysql/cli/introspect.py | 57 ++++++++++--------- .../feature/backend/test_cli.py | 21 ++++--- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md index 5332a2ee..b4396e61 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md @@ -32,7 +32,7 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OP | Option | Default | Description | | :--- | :--- | :--- | | `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | -| `--schema` | the connected database | The database to read. MySQL's only namespace level above a table. | +| `--database` | required (env: `MYSQL_DATABASE`) | The database to connect to **and** the database to read. MySQL has one namespace level and calls it a database, so there is no second flag: the run connects to a database and asks about that database. | | `--include-system` | off | Include system tables. | | `--host`, `--port`, `--database`, `--user`, `--password` | env or defaults | Connection. `MYSQL_*` env vars are honoured, and arguments override them. | | `--charset` | _None_ | Connection charset. | @@ -47,9 +47,13 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OP * **Read-only.** `introspect` never writes. It does not create, drop, or alter anything, so it is safe to run against a database whose schema matters. -* **The database is explicit.** `--schema` names the database to read, rather - than relying on the connection's default — which differs between clients - and is the usual reason two tools disagree about what a table looks like. +* **There is no namespace flag, and that is deliberate.** MySQL has one + namespace level and calls it a database — `CREATE SCHEMA` is a synonym for + `CREATE DATABASE`, and nothing sits inside a database under a further name. + So `--database` is the whole answer: the run connects to a database and + asks about that database. There is no `--schema`, and it has not been left + as an alias, so a caller cannot end up asking about a level that does not + exist here. * **`data_length` and `index_length` are estimates.** InnoDB reports approximate byte sizes for the clustered index, not exact file sizes. They are printed as reported; the caveat travels with the number. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index eca3555e..51de4e77 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -1,5 +1,15 @@ # src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py -"""introspect subcommand - Database introspection.""" +"""introspect subcommand - Database introspection. + +There is no namespace flag here, and that is deliberate. MySQL has exactly one +namespace level and calls it a *database* -- ``CREATE SCHEMA`` is a synonym for +``CREATE DATABASE`` and nothing sits inside a database under a further name. So +``--database``, which the connection already takes, is the whole answer: the run +connects to a database and asks about that database. An earlier version accepted +``--schema`` as well, which named the same thing under a word MySQL does not use; +it is gone rather than aliased, so a caller cannot quietly end up asking about a +different level than the one it is connected to. +""" import argparse import asyncio @@ -99,11 +109,6 @@ def create_parser(subparsers): default=None, help="Table/view name (required for some types)", ) - parser.add_argument( - "--schema", - default=None, - help="Schema name (for databases that support schemas)", - ) parser.add_argument( "--include-system", action="store_true", @@ -169,12 +174,12 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): introspector = backend.introspector if args.type == "tables": - tables = introspector.list_tables(schema=args.schema, include_system=args.include_system) + tables = introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views(schema=args.schema) + views = introspector.list_views(schema=None) data = _serialize_for_output(views) provider.display_results(data, title="Views") @@ -182,7 +187,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): if not args.name: print("Error: Table name is required for 'table' introspection", file=sys.stderr) sys.exit(1) - info = introspector.get_table_info(args.name, schema=args.schema) + info = introspector.get_table_info(args.name, schema=None) if info: provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") if info.indexes: @@ -199,7 +204,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = introspector.list_columns(args.name, schema=args.schema) + columns = introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -207,7 +212,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = introspector.list_indexes(args.name, schema=args.schema) + indexes = introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") @@ -215,18 +220,18 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): if not args.name: print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) sys.exit(1) - fks = introspector.list_foreign_keys(args.name, schema=args.schema) + fks = introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = introspector.list_triggers(table_name=args.name, schema=args.schema) + triggers = introspector.list_triggers(table_name=args.name, schema=None) data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") elif args.type == "table-options": if args.name: - options = introspector.get_table_options(args.name, schema=args.schema) + options = introspector.get_table_options(args.name, schema=None) if options is None: print(f"Error: Table '{args.name}' not found", file=sys.stderr) sys.exit(1) @@ -234,7 +239,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): _serialize_for_output([options]), title=f"Table options of {args.name}" ) else: - options = introspector.list_table_options(schema=args.schema) + options = introspector.list_table_options(schema=None) provider.display_results( _serialize_for_output(options), title="Table options" ) @@ -243,7 +248,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): if not args.name: print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) sys.exit(1) - partitions = introspector.list_partitions(args.name, schema=args.schema) + partitions = introspector.list_partitions(args.name, schema=None) provider.display_results( _serialize_for_output(partitions), title=f"Partitions of {args.name}" ) @@ -275,12 +280,12 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables(schema=args.schema, include_system=args.include_system) + tables = await introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views(schema=args.schema) + views = await introspector.list_views(schema=None) data = _serialize_for_output(views) provider.display_results(data, title="Views") @@ -288,7 +293,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'table' introspection", file=sys.stderr) sys.exit(1) - info = await introspector.get_table_info(args.name, schema=args.schema) + info = await introspector.get_table_info(args.name, schema=None) if info: provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") if info.indexes: @@ -305,7 +310,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = await introspector.list_columns(args.name, schema=args.schema) + columns = await introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -313,7 +318,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = await introspector.list_indexes(args.name, schema=args.schema) + indexes = await introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") @@ -321,18 +326,18 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) sys.exit(1) - fks = await introspector.list_foreign_keys(args.name, schema=args.schema) + fks = await introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = await introspector.list_triggers(table_name=args.name, schema=args.schema) + triggers = await introspector.list_triggers(table_name=args.name, schema=None) data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") elif args.type == "table-options": if args.name: - options = await introspector.get_table_options(args.name, schema=args.schema) + options = await introspector.get_table_options(args.name, schema=None) if options is None: print(f"Error: Table '{args.name}' not found", file=sys.stderr) sys.exit(1) @@ -340,7 +345,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): _serialize_for_output([options]), title=f"Table options of {args.name}" ) else: - options = await introspector.list_table_options(schema=args.schema) + options = await introspector.list_table_options(schema=None) provider.display_results( _serialize_for_output(options), title="Table options" ) @@ -349,7 +354,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): if not args.name: print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) sys.exit(1) - partitions = await introspector.list_partitions(args.name, schema=args.schema) + partitions = await introspector.list_partitions(args.name, schema=None) provider.display_results( _serialize_for_output(partitions), title=f"Partitions of {args.name}" ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py index 41378bd2..bafad925 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py @@ -898,16 +898,21 @@ def test_handle_info_with_version(self, capsys): class TestCLIIntrospectArgParsing: """Tests for introspect argument parsing edge cases.""" - def test_introspect_with_schema(self): - """Test introspect with --schema option.""" + def test_introspect_has_no_namespace_flag(self): + """MySQL has one namespace level and calls it a database. + + ``--schema`` named the same level under a word MySQL does not + use, so it is gone: the run connects to a database with + ``--database`` and asks about that database. The flag must not + come back as an alias, because an alias lets a caller believe it + named a level that does not exist here. + """ from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - with patch.object(sys, "argv", ["mysql", "introspect", "tables", "--schema", "mydb"]): - args = parse_args() - - assert args.command == "introspect" - assert args.type == "tables" - assert args.schema == "mydb" + with patch.object( + sys, "argv", ["mysql", "introspect", "tables", "--schema", "mydb"] + ), pytest.raises(SystemExit): + parse_args() def test_introspect_with_include_system(self): """Test introspect with --include-system flag.""" From 89da55cf993c5fe85625e16e5a2b01ad73695e64 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 23:18:11 +0800 Subject: [PATCH 51/65] feat(cli): derive the introspect type list from the dialect The list ClickHouse offers in `--help` is now derived from the dialect rather than written out, with the same helper the other backends will use. The dialect is built without connecting: every family ClickHouse offers is available in the versions this backend supports, so a configured dialect answers the same as one adapted to a live server would. `table-engine` needed a capability to rely on, so one was declared beside the queries it describes. Tests: the cli and introspection suites pass. --- .../backend/impl/mysql/mixins/introspection.py | 7 +++++++ .../activerecord/backend/impl/mysql/mixins/partition.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py index 04aa2189..da41e36f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py @@ -83,6 +83,13 @@ class MySQLIntrospectionMixin: MySQL supports all introspection features via information_schema. """ + def supports_table_options_introspection(self) -> bool: + """MySQL answers for a table's storage options from ``information_schema.TABLES``. + + The capability for the ``introspect table-options`` family: engine, row + format, collation and the byte estimates the same view already reports.""" + return True + # ========== Capability Detection ========== def supports_introspection(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index deed2274..853c4765 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -72,6 +72,14 @@ def supports_key_table_partitioning(self) -> bool: def supports_subpartitioning(self) -> bool: """MySQL supports ``SUBPARTITION BY {HASH|KEY}`` since 5.1.""" + + def supports_partition_introspection(self) -> bool: + """MySQL answers for a table's partitions from ``information_schema.PARTITIONS``. + + The capability for the ``introspect partitions`` family. An empty answer + means the table is not partitioned, which is a different statement from + "this engine has no partitions" -- hence the switch.""" + return True return True def supports_range_columns_partitioning(self) -> bool: From 62513b4955338534a3ff749fcd9be93d915128e4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 25 Oct 2026 06:59:44 +0800 Subject: [PATCH 52/65] fix(partition): restore the return the inserted switch displaced A previous edit inserted supports_partition_introspection directly after supports_subpartitioning's docstring, pushing that method's return True into the new body. supports_subpartitioning then returned None, which reads as false: subpartitioning was silently refused, and the capability provider reported it as unsupported so the affected tests skipped instead of failing. --- .../activerecord/backend/impl/mysql/mixins/partition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 853c4765..eb6a94a4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -72,6 +72,7 @@ def supports_key_table_partitioning(self) -> bool: def supports_subpartitioning(self) -> bool: """MySQL supports ``SUBPARTITION BY {HASH|KEY}`` since 5.1.""" + return True def supports_partition_introspection(self) -> bool: """MySQL answers for a table's partitions from ``information_schema.PARTITIONS``. @@ -80,7 +81,6 @@ def supports_partition_introspection(self) -> bool: means the table is not partitioned, which is a different statement from "this engine has no partitions" -- hence the switch.""" return True - return True def supports_range_columns_partitioning(self) -> bool: return True From 4beecc3fb7ee57ea47700b667ebc816fda46cdf1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 25 Oct 2026 15:40:39 +0800 Subject: [PATCH 53/65] refactor(mysql)!: drop the underscores from the introspection contract The row parsers are this backend's implementation of the base introspection contract: the base declares them and every backend must supply them, so the leading underscore described a visibility the code never had. It is also how the next private helper gets justified, which is what this branch is trying to stop. default_schema becomes a property rather than a method, beside dialect and cache: it states a fact about the database, it does not perform an action. No aliases: the old names are gone. MySQL side: thirty row parsers across the information_schema, SHOW and status introspectors -- the shared families, the eighteen SHOW families, and the session variable and version helpers -- plus default_schema and the five call sites that read it. The test that calls the status parser follows. --- .../impl/mysql/introspection/introspector.py | 51 +++--- .../mysql/introspection/show_introspector.py | 114 ++++++------ .../introspection/status_introspector.py | 168 +++++++++--------- .../introspection/test_status_introspector.py | 16 +- 4 files changed, 175 insertions(+), 174 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index c4d2e052..f399740f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -12,14 +12,14 @@ - SQL generation: Delegated to MySQLIntrospectionMixin.format_*_query() methods in the Dialect layer via Expression.to_sql() - Query execution: Handled by IntrospectorExecutor - - Result parsing: _parse_* methods in this module (pure Python, no I/O) + - Result parsing: parse_* methods in this module (pure Python, no I/O) Key behaviours: - Queries information_schema.TABLES, COLUMNS, STATISTICS, KEY_COLUMN_USAGE, REFERENTIAL_CONSTRAINTS, VIEWS, TRIGGERS - MySQL-only families: ``list_table_options`` / ``get_table_options`` (storage options from TABLES) and ``list_partitions`` (from PARTITIONS) - - _parse_* methods are pure Python — shared by sync and async introspectors + - parse_* methods are pure Python — shared by sync and async introspectors Design principle: Sync and Async are separate and cannot coexist. - SyncMySQLIntrospector: for synchronous backends @@ -73,13 +73,14 @@ class MySQLIntrospectorMixin(IntrospectorMixin): from this mixin to share: - Default schema handling - MySQL version detection - - _parse_* implementations + - parse_* implementations SQL generation is delegated to the Dialect layer via Expression.to_sql() which calls MySQLIntrospectionMixin.format_*_query() methods. """ - def _get_default_schema(self) -> str: + @property + def default_schema(self) -> str: """Return the MySQL database name from the backend config.""" if hasattr(self._backend, "config") and hasattr(self._backend.config, "database"): return self._backend.config.database or "" @@ -93,10 +94,10 @@ def _get_version(self) -> tuple: # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: version = self._get_version() version_str = ".".join(str(v) for v in version) - db_name = self._get_default_schema() + db_name = self.default_schema db_row = rows[0] if rows else {} @@ -109,8 +110,8 @@ def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatab collation=db_row.get("DEFAULT_COLLATION_NAME"), ) - def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: - target_db = schema if schema is not None else self._get_default_schema() + def parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: + target_db = schema if schema is not None else self.default_schema table_type_map = { "BASE TABLE": TableType.BASE_TABLE, "VIEW": TableType.VIEW, @@ -134,7 +135,7 @@ def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> Li ) return tables - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table_name: str, @@ -169,7 +170,7 @@ def _parse_columns( ) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table_name: str, @@ -205,7 +206,7 @@ def _parse_indexes( ) return list(index_map.values()) - def _parse_foreign_keys( + def parse_foreign_keys( self, rows: List[Dict[str, Any]], table_name: str, @@ -241,7 +242,7 @@ def _parse_foreign_keys( fk_map[fk_name].referenced_columns.append(row.get("REFERENCED_COLUMN_NAME", "")) return list(fk_map.values()) - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: + def parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: return [ IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", "")), @@ -253,7 +254,7 @@ def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[Introspe for row in rows ] - def _parse_view_info( + def parse_view_info( self, rows: List[Dict[str, Any]], view_name: str, @@ -270,7 +271,7 @@ def _parse_view_info( is_updatable=row.get("IS_UPDATABLE") == "YES", ) - def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: + def parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: return [ IntrospectionTriggerInfo( name=row.get("TRIGGER_NAME", ""), @@ -301,14 +302,14 @@ def _build_table_options_sql(self, schema: Optional[str]) -> Tuple[str, tuple]: """ from ..expression.introspection import MySQLTableOptionsListQueryStatement - target_db = schema if schema is not None else self._get_default_schema() + target_db = schema if schema is not None else self.default_schema return MySQLTableOptionsListQueryStatement(self.dialect, schema=target_db).to_sql() def _build_table_options_lookup_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: """One table's storage options, rendered through the dialect.""" from ..expression.introspection import MySQLTableOptionsInfoQueryStatement - target_db = schema if schema is not None else self._get_default_schema() + target_db = schema if schema is not None else self.default_schema return MySQLTableOptionsInfoQueryStatement( self.dialect, table_name=table_name, schema=target_db ).to_sql() @@ -323,7 +324,7 @@ def _build_partition_list_sql(self, table_name: str, schema: Optional[str]) -> T """ from ..expression.introspection import MySQLPartitionListQueryStatement - target_db = schema if schema is not None else self._get_default_schema() + target_db = schema if schema is not None else self.default_schema return MySQLPartitionListQueryStatement( self.dialect, table_name=table_name, schema=target_db ).to_sql() @@ -332,7 +333,7 @@ def _build_partition_list_sql(self, table_name: str, schema: Optional[str]) -> T # Table storage options and partitions — parsing # ------------------------------------------------------------------ # - def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[MySQLTableOptions]: + def parse_table_options(self, rows: List[Dict[str, Any]]) -> List[MySQLTableOptions]: return [ MySQLTableOptions( schema=row.get("TABLE_SCHEMA", ""), @@ -349,7 +350,7 @@ def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[MySQLTableOpt for row in rows ] - def _parse_partitions(self, rows: List[Dict[str, Any]]) -> List[MySQLPartitionInfo]: + def parse_partitions(self, rows: List[Dict[str, Any]]) -> List[MySQLPartitionInfo]: return [ MySQLPartitionInfo( schema=row.get("TABLE_SCHEMA", ""), @@ -448,7 +449,7 @@ def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOpt return cached sql, params = self._build_table_options_sql(schema) - result = self._parse_table_options(self._executor.execute(sql, params)) + result = self.parse_table_options(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -469,7 +470,7 @@ def get_table_options( return cached sql, params = self._build_table_options_lookup_sql(table_name, schema) - result = next(iter(self._parse_table_options(self._executor.execute(sql, params))), None) + result = next(iter(self.parse_table_options(self._executor.execute(sql, params))), None) if result is not None: self._cache.set(key, result) return result @@ -489,7 +490,7 @@ def list_partitions( return cached sql, params = self._build_partition_list_sql(table_name, schema) - result = self._parse_partitions(self._executor.execute(sql, params)) + result = self.parse_partitions(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -573,7 +574,7 @@ async def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTa return cached sql, params = self._build_table_options_sql(schema) - result = self._parse_table_options(await self._executor.execute(sql, params)) + result = self.parse_table_options(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -595,7 +596,7 @@ async def get_table_options( sql, params = self._build_table_options_lookup_sql(table_name, schema) rows = await self._executor.execute(sql, params) - result = next(iter(self._parse_table_options(rows)), None) + result = next(iter(self.parse_table_options(rows)), None) if result is not None: self._cache.set(key, result) return result @@ -615,6 +616,6 @@ async def list_partitions( return cached sql, params = self._build_partition_list_sql(table_name, schema) - result = self._parse_partitions(await self._executor.execute(sql, params)) + result = self.parse_partitions(await self._executor.execute(sql, params)) self._cache.set(key, result) return result diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index 70face00..4c1d70dd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -47,7 +47,7 @@ class ShowMixin: from this mixin to share: - Dialect access - SQL generation helpers - - _parse_* static methods + - parse_* static methods """ @property @@ -60,7 +60,7 @@ def dialect(self): # ------------------------------------------------------------------ # @staticmethod - def _parse_create_table(rows: List[Dict], table_name: str): + def parse_create_table(rows: List[Dict], table_name: str): from ..show.types import ShowCreateTableResult if not rows: @@ -72,7 +72,7 @@ def _parse_create_table(rows: List[Dict], table_name: str): ) @staticmethod - def _parse_create_view(rows: List[Dict], view_name: str): + def parse_create_view(rows: List[Dict], view_name: str): from ..show.types import ShowCreateViewResult if not rows: @@ -86,7 +86,7 @@ def _parse_create_view(rows: List[Dict], view_name: str): ) @staticmethod - def _parse_columns(rows: List[Dict]): + def parse_columns(rows: List[Dict]): from ..show.types import ShowColumnResult columns = [] @@ -106,7 +106,7 @@ def _parse_columns(rows: List[Dict]): return columns @staticmethod - def _parse_indexes(rows: List[Dict]): + def parse_indexes(rows: List[Dict]): from ..show.types import ShowIndexResult return [ @@ -131,7 +131,7 @@ def _parse_indexes(rows: List[Dict]): ] @staticmethod - def _parse_tables(rows: List[Dict]): + def parse_tables(rows: List[Dict]): from ..show.types import ShowTableResult result = [] @@ -145,13 +145,13 @@ def _parse_tables(rows: List[Dict]): return result @staticmethod - def _parse_databases(rows: List[Dict]): + def parse_databases(rows: List[Dict]): from ..show.types import ShowDatabaseResult return [ShowDatabaseResult(name=row.get("Database")) for row in rows] @staticmethod - def _parse_table_status(rows: List[Dict]): + def parse_table_status(rows: List[Dict]): from ..show.types import ShowTableStatusResult return [ @@ -179,7 +179,7 @@ def _parse_table_status(rows: List[Dict]): ] @staticmethod - def _parse_triggers(rows: List[Dict]): + def parse_triggers(rows: List[Dict]): from ..show.types import ShowTriggerResult return [ @@ -200,7 +200,7 @@ def _parse_triggers(rows: List[Dict]): ] @staticmethod - def _parse_create_trigger(rows: List[Dict], trigger_name: str): + def parse_create_trigger(rows: List[Dict], trigger_name: str): from ..show.types import ShowCreateTriggerResult if not rows: @@ -215,19 +215,19 @@ def _parse_create_trigger(rows: List[Dict], trigger_name: str): ) @staticmethod - def _parse_variables(rows: List[Dict]): + def parse_variables(rows: List[Dict]): from ..show.types import ShowVariableResult return [ShowVariableResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_status(rows: List[Dict]): + def parse_status(rows: List[Dict]): from ..show.types import ShowStatusResult return [ShowStatusResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_processlist(rows: List[Dict]): + def parse_processlist(rows: List[Dict]): from ..show.types import ShowProcessListResult return [ @@ -245,7 +245,7 @@ def _parse_processlist(rows: List[Dict]): ] @staticmethod - def _parse_warnings(rows: List[Dict]): + def parse_warnings(rows: List[Dict]): from ..show.types import ShowWarningResult return [ @@ -253,7 +253,7 @@ def _parse_warnings(rows: List[Dict]): ] @staticmethod - def _parse_engines(rows: List[Dict]): + def parse_engines(rows: List[Dict]): from ..show.types import ShowEngineResult return [ @@ -268,7 +268,7 @@ def _parse_engines(rows: List[Dict]): ] @staticmethod - def _parse_charset(rows: List[Dict]): + def parse_charset(rows: List[Dict]): from ..show.types import ShowCharsetResult return [ @@ -282,7 +282,7 @@ def _parse_charset(rows: List[Dict]): ] @staticmethod - def _parse_collation(rows: List[Dict]): + def parse_collation(rows: List[Dict]): from ..show.types import ShowCollationResult return [ @@ -298,13 +298,13 @@ def _parse_collation(rows: List[Dict]): ] @staticmethod - def _parse_grants(rows: List[Dict]): + def parse_grants(rows: List[Dict]): from ..show.types import ShowGrantResult return [ShowGrantResult(grants=row.get("Grants for")) for row in rows] @staticmethod - def _parse_plugins(rows: List[Dict]): + def parse_plugins(rows: List[Dict]): from ..show.types import ShowPluginResult return [ @@ -347,13 +347,13 @@ def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(self._exec(sql, params), table_name) + return self.parse_create_table(self._exec(sql, params), table_name) def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(self._exec(sql, params), view_name) + return self.parse_create_view(self._exec(sql, params), view_name) def columns( self, @@ -367,13 +367,13 @@ def columns( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() - return self._parse_columns(self._exec(sql, params)) + return self.parse_columns(self._exec(sql, params)) def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(self._exec(sql, params)) + return self.parse_indexes(self._exec(sql, params)) def tables( self, @@ -384,91 +384,91 @@ def tables( """List tables in the database.""" expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() - return self._parse_tables(self._exec(sql, params)) + return self.parse_tables(self._exec(sql, params)) def databases(self, like: Optional[str] = None): """List databases.""" expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(self._exec(sql, params)) + return self.parse_databases(self._exec(sql, params)) def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() - return self._parse_table_status(self._exec(sql, params)) + return self.parse_table_status(self._exec(sql, params)) def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() - return self._parse_triggers(self._exec(sql, params)) + return self.parse_triggers(self._exec(sql, params)) def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(self._exec(sql, params), trigger_name) + return self.parse_create_trigger(self._exec(sql, params), trigger_name) def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_variables(self._exec(sql, params)) + return self.parse_variables(self._exec(sql, params)) def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_status(self._exec(sql, params)) + return self.parse_status(self._exec(sql, params)) def processlist(self, full: bool = False): """Show process list.""" expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(self._exec(sql, params)) + return self.parse_processlist(self._exec(sql, params)) def warnings(self, limit: Optional[int] = None): """Show warnings.""" expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def errors(self, limit: Optional[int] = None): """Show errors.""" expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def engines(self): """Show storage engines.""" expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(self._exec(sql, params)) + return self.parse_engines(self._exec(sql, params)) def charset(self, like: Optional[str] = None): """Show character sets.""" expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(self._exec(sql, params)) + return self.parse_charset(self._exec(sql, params)) def collation(self, like: Optional[str] = None): """Show collations.""" expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(self._exec(sql, params)) + return self.parse_collation(self._exec(sql, params)) def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(self._exec(sql, params)) + return self.parse_grants(self._exec(sql, params)) def plugins(self): """Show plugins.""" expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(self._exec(sql, params)) + return self.parse_plugins(self._exec(sql, params)) class AsyncShowIntrospector(ShowMixin): @@ -500,13 +500,13 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(await self._exec(sql, params), table_name) + return self.parse_create_table(await self._exec(sql, params), table_name) async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(await self._exec(sql, params), view_name) + return self.parse_create_view(await self._exec(sql, params), view_name) async def columns( self, @@ -520,13 +520,13 @@ async def columns( self.dialect, table_name, schema=schema, full=full, like_pattern=like ) sql, params = expr.to_sql() - return self._parse_columns(await self._exec(sql, params)) + return self.parse_columns(await self._exec(sql, params)) async def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(await self._exec(sql, params)) + return self.parse_indexes(await self._exec(sql, params)) async def tables( self, @@ -537,88 +537,88 @@ async def tables( """List tables in the database.""" expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() - return self._parse_tables(await self._exec(sql, params)) + return self.parse_tables(await self._exec(sql, params)) async def databases(self, like: Optional[str] = None): """List databases.""" expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(await self._exec(sql, params)) + return self.parse_databases(await self._exec(sql, params)) async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() - return self._parse_table_status(await self._exec(sql, params)) + return self.parse_table_status(await self._exec(sql, params)) async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() - return self._parse_triggers(await self._exec(sql, params)) + return self.parse_triggers(await self._exec(sql, params)) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(await self._exec(sql, params), trigger_name) + return self.parse_create_trigger(await self._exec(sql, params), trigger_name) async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_variables(await self._exec(sql, params)) + return self.parse_variables(await self._exec(sql, params)) async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_status(await self._exec(sql, params)) + return self.parse_status(await self._exec(sql, params)) async def processlist(self, full: bool = False): """Show process list.""" expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(await self._exec(sql, params)) + return self.parse_processlist(await self._exec(sql, params)) async def warnings(self, limit: Optional[int] = None): """Show warnings.""" expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def errors(self, limit: Optional[int] = None): """Show errors.""" expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def engines(self): """Show storage engines.""" expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(await self._exec(sql, params)) + return self.parse_engines(await self._exec(sql, params)) async def charset(self, like: Optional[str] = None): """Show character sets.""" expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(await self._exec(sql, params)) + return self.parse_charset(await self._exec(sql, params)) async def collation(self, like: Optional[str] = None): """Show collations.""" expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(await self._exec(sql, params)) + return self.parse_collation(await self._exec(sql, params)) async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(await self._exec(sql, params)) + return self.parse_grants(await self._exec(sql, params)) async def plugins(self): """Show plugins.""" expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(await self._exec(sql, params)) + return self.parse_plugins(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py index 758e0b08..dd3a9ab5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py @@ -377,7 +377,7 @@ def _replication_connection_status_query(self, limit: Optional[int] = None) -> T return QueryStatement(dialect, **kwargs).to_sql() - def _parse_variable_value(self, value: Any) -> Any: + def parse_variable_value(self, value: Any) -> Any: """Parse variable value to appropriate Python type.""" if value is None: return None @@ -390,7 +390,7 @@ def _parse_variable_value(self, value: Any) -> Any: return value return value - def _parse_version_string(self, version_str: str) -> tuple: + def parse_version_string(self, version_str: str) -> tuple: """Parse MySQL version string to (major, minor, patch) tuple. Examples: @@ -422,7 +422,7 @@ def _is_mysql_version_at_least(self, version_str: str, major: int, minor: int = Returns: True if version >= major.minor """ - parsed = self._parse_version_string(version_str) + parsed = self.parse_version_string(version_str) return parsed >= (major, minor, 0) def _create_status_item( @@ -437,7 +437,7 @@ def _create_status_item( """Create a StatusItem with parsed value.""" return StatusItem( name=name, - value=self._parse_variable_value(value), + value=self.parse_variable_value(value), category=category, description=description, unit=unit, @@ -607,11 +607,11 @@ def get_connection_info(self) -> ConnectionInfo: var_dict[var.variable_name] = var.value return ConnectionInfo( - active_count=self._parse_variable_value(status_dict.get("Threads_connected")), - max_connections=self._parse_variable_value(var_dict.get("max_connections")), - idle_count=self._parse_variable_value(status_dict.get("Threads_cached")), + active_count=self.parse_variable_value(status_dict.get("Threads_connected")), + max_connections=self.parse_variable_value(var_dict.get("max_connections")), + idle_count=self.parse_variable_value(status_dict.get("Threads_cached")), extra={ - "threads_running": self._parse_variable_value(status_dict.get("Threads_running")), + "threads_running": self.parse_variable_value(status_dict.get("Threads_running")), }, ) @@ -632,10 +632,10 @@ def get_storage_info(self) -> StorageInfo: pass return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), + total_size_bytes=self.parse_variable_value(total_size), extra={ "datadir": var_dict.get("datadir"), - "innodb_buffer_pool_size": self._parse_variable_value(var_dict.get("innodb_buffer_pool_size")), + "innodb_buffer_pool_size": self.parse_variable_value(var_dict.get("innodb_buffer_pool_size")), }, ) @@ -753,19 +753,19 @@ def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_buffer_pool_pages_total": - innodb.buffer_pool_pages_total = self._parse_variable_value(value) + innodb.buffer_pool_pages_total = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_data": - innodb.buffer_pool_pages_data = self._parse_variable_value(value) + innodb.buffer_pool_pages_data = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_dirty": - innodb.buffer_pool_pages_dirty = self._parse_variable_value(value) + innodb.buffer_pool_pages_dirty = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_free": - innodb.buffer_pool_pages_free = self._parse_variable_value(value) + innodb.buffer_pool_pages_free = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_read_requests": - innodb.buffer_pool_read_requests = self._parse_variable_value(value) + innodb.buffer_pool_read_requests = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_reads": - innodb.buffer_pool_reads = self._parse_variable_value(value) + innodb.buffer_pool_reads = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_wait_free": - innodb.buffer_pool_wait_free = self._parse_variable_value(value) + innodb.buffer_pool_wait_free = self.parse_variable_value(value) except Exception: pass # Get InnoDB variables @@ -776,15 +776,15 @@ def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "innodb_buffer_pool_size": - innodb.buffer_pool_size = self._parse_variable_value(value) + innodb.buffer_pool_size = self.parse_variable_value(value) elif key == "innodb_buffer_pool_instances": - innodb.buffer_pool_instances = self._parse_variable_value(value) + innodb.buffer_pool_instances = self.parse_variable_value(value) elif key == "innodb_log_file_size": - innodb.log_file_size = self._parse_variable_value(value) + innodb.log_file_size = self.parse_variable_value(value) elif key == "innodb_log_buffer_size": - innodb.log_buffer_size = self._parse_variable_value(value) + innodb.log_buffer_size = self.parse_variable_value(value) elif key == "innodb_lock_wait_timeout": - innodb.lock_wait_timeout = self._parse_variable_value(value) + innodb.lock_wait_timeout = self.parse_variable_value(value) except Exception: pass # Get InnoDB row lock status @@ -795,9 +795,9 @@ def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_row_lock_time": - innodb.row_lock_time = self._parse_variable_value(value) + innodb.row_lock_time = self.parse_variable_value(value) elif key == "Innodb_row_lock_waits": - innodb.row_lock_waits = self._parse_variable_value(value) + innodb.row_lock_waits = self.parse_variable_value(value) elif key == "Innodb_row_lock_time_avg": # Calculate average total_lock_time = innodb.row_lock_time or 0 @@ -805,23 +805,23 @@ def get_innodb_info(self) -> InnoDBInfo: avg_time = total_lock_time / lock_waits if lock_waits > 0 else 0 innodb.row_lock_time_avg = round(avg_time, 2) elif key == "Innodb_rows_read": - innodb.rows_read = self._parse_variable_value(value) + innodb.rows_read = self.parse_variable_value(value) elif key == "Innodb_rows_inserted": - innodb.rows_inserted = self._parse_variable_value(value) + innodb.rows_inserted = self.parse_variable_value(value) elif key == "Innodb_rows_updated": - innodb.rows_updated = self._parse_variable_value(value) + innodb.rows_updated = self.parse_variable_value(value) elif key == "Innodb_rows_deleted": - innodb.rows_deleted = self._parse_variable_value(value) + innodb.rows_deleted = self.parse_variable_value(value) elif key == "Innodb_data_reads": - innodb.data_reads = self._parse_variable_value(value) + innodb.data_reads = self.parse_variable_value(value) elif key == "Innodb_data_writes": - innodb.data_writes = self._parse_variable_value(value) + innodb.data_writes = self.parse_variable_value(value) elif key == "Innodb_os_log_fsyncs": - innodb.os_fsyncs = self._parse_variable_value(value) + innodb.os_fsyncs = self.parse_variable_value(value) elif key == "Innodb_os_file_reads": - innodb.os_file_reads = self._parse_variable_value(value) + innodb.os_file_reads = self.parse_variable_value(value) elif key == "Innodb_os_file_writes": - innodb.os_file_writes = self._parse_variable_value(value) + innodb.os_file_writes = self.parse_variable_value(value) except Exception: pass return innodb @@ -856,7 +856,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: # Get file size from the File_size column if available file_size = row.get("File_size") if file_size: - total_size += self._parse_variable_value(file_size) + total_size += self.parse_variable_value(file_size) binary_log.log_files = log_files binary_log.log_size_bytes = total_size except Exception: @@ -878,7 +878,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS @@ -886,7 +886,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Get GTID info @@ -959,7 +959,7 @@ def get_slow_query_info(self) -> SlowQueryInfo: elif key == "log_slow_admin_statements": slow_query.log_slow_admin_statements = str(value).lower() in ("on", "1") elif key == "min_examined_row_limit": - slow_query.min_examined_row_limit = self._parse_variable_value(value) + slow_query.min_examined_row_limit = self.parse_variable_value(value) except Exception: pass # Get slow query count @@ -970,7 +970,7 @@ def get_slow_query_info(self) -> SlowQueryInfo: key = row.get("Variable_name") value = row.get("Value") if key == "Slow_queries": - slow_query.slow_queries_count = self._parse_variable_value(value) + slow_query.slow_queries_count = self.parse_variable_value(value) except Exception: pass return slow_query @@ -985,7 +985,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: for row in result.data: key = row.get("Variable_name") if key == "server_id": - repl_info.server_id = self._parse_variable_value(row.get("Value")) + repl_info.server_id = self.parse_variable_value(row.get("Value")) except Exception: pass # Check if this is a master @@ -1019,7 +1019,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: master_info = ReplicationMasterInfo() row = result.data[0] master_info.binary_log_file = row.get("File") - master_info.binary_log_position = self._parse_variable_value(row.get("Position")) + master_info.binary_log_position = self.parse_variable_value(row.get("Position")) repl_info.master_info = master_info else: # MySQL < 9.0: use SHOW MASTER STATUS @@ -1029,7 +1029,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: master_info = ReplicationMasterInfo() row = result.data[0] master_info.binary_log_file = row.get("File") - master_info.binary_log_position = self._parse_variable_value(row.get("Position")) + master_info.binary_log_position = self.parse_variable_value(row.get("Position")) # Parse comma-separated list do_db = row.get("Binlog_Do_DB", "") if do_db: @@ -1070,22 +1070,22 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: slave_info = ReplicationSlaveInfo() row = result.data[0] slave_info.master_host = row.get("Master_Host") - slave_info.master_port = self._parse_variable_value(row.get("Master_Port")) + slave_info.master_port = self.parse_variable_value(row.get("Master_Port")) slave_info.master_user = row.get("Master_User") slave_info.slave_io_running = row.get("Slave_IO_Running") slave_info.slave_sql_running = row.get("Slave_SQL_Running") - slave_info.seconds_behind_master = self._parse_variable_value(row.get("Seconds_Behind_Master")) + slave_info.seconds_behind_master = self.parse_variable_value(row.get("Seconds_Behind_Master")) slave_info.master_log_file = row.get("Master_Log_File") - slave_info.read_master_log_pos = self._parse_variable_value(row.get("Read_Master_Log_Pos")) + slave_info.read_master_log_pos = self.parse_variable_value(row.get("Read_Master_Log_Pos")) slave_info.relay_master_log_file = row.get("Relay_Master_Log_File") slave_info.slave_io_state = row.get("Slave_IO_State") - slave_info.last_io_errno = self._parse_variable_value(row.get("Last_IO_Errno")) + slave_info.last_io_errno = self.parse_variable_value(row.get("Last_IO_Errno")) slave_info.last_io_error = row.get("Last_IO_Error") - slave_info.last_sql_errno = self._parse_variable_value(row.get("Last_SQL_Errno")) + slave_info.last_sql_errno = self.parse_variable_value(row.get("Last_SQL_Errno")) slave_info.last_sql_error = row.get("Last_SQL_Error") slave_info.relay_log_file = row.get("Relay_Log_File") - slave_info.relay_log_pos = self._parse_variable_value(row.get("Relay_Log_Pos")) - slave_info.exec_master_log_pos = self._parse_variable_value(row.get("Exec_Master_Log_Pos")) + slave_info.relay_log_pos = self.parse_variable_value(row.get("Relay_Log_Pos")) + slave_info.exec_master_log_pos = self.parse_variable_value(row.get("Exec_Master_Log_Pos")) repl_info.slave_info = slave_info except Exception: pass @@ -1216,11 +1216,11 @@ async def get_connection_info(self) -> ConnectionInfo: var_dict[var.variable_name] = var.value return ConnectionInfo( - active_count=self._parse_variable_value(status_dict.get("Threads_connected")), - max_connections=self._parse_variable_value(var_dict.get("max_connections")), - idle_count=self._parse_variable_value(status_dict.get("Threads_cached")), + active_count=self.parse_variable_value(status_dict.get("Threads_connected")), + max_connections=self.parse_variable_value(var_dict.get("max_connections")), + idle_count=self.parse_variable_value(status_dict.get("Threads_cached")), extra={ - "threads_running": self._parse_variable_value(status_dict.get("Threads_running")), + "threads_running": self.parse_variable_value(status_dict.get("Threads_running")), }, ) @@ -1240,10 +1240,10 @@ async def get_storage_info(self) -> StorageInfo: pass return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), + total_size_bytes=self.parse_variable_value(total_size), extra={ "datadir": var_dict.get("datadir"), - "innodb_buffer_pool_size": self._parse_variable_value(var_dict.get("innodb_buffer_pool_size")), + "innodb_buffer_pool_size": self.parse_variable_value(var_dict.get("innodb_buffer_pool_size")), }, ) @@ -1359,19 +1359,19 @@ async def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_buffer_pool_pages_total": - innodb.buffer_pool_pages_total = self._parse_variable_value(value) + innodb.buffer_pool_pages_total = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_data": - innodb.buffer_pool_pages_data = self._parse_variable_value(value) + innodb.buffer_pool_pages_data = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_dirty": - innodb.buffer_pool_pages_dirty = self._parse_variable_value(value) + innodb.buffer_pool_pages_dirty = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_free": - innodb.buffer_pool_pages_free = self._parse_variable_value(value) + innodb.buffer_pool_pages_free = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_read_requests": - innodb.buffer_pool_read_requests = self._parse_variable_value(value) + innodb.buffer_pool_read_requests = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_reads": - innodb.buffer_pool_reads = self._parse_variable_value(value) + innodb.buffer_pool_reads = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_wait_free": - innodb.buffer_pool_wait_free = self._parse_variable_value(value) + innodb.buffer_pool_wait_free = self.parse_variable_value(value) except Exception: pass # Get InnoDB variables @@ -1382,15 +1382,15 @@ async def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "innodb_buffer_pool_size": - innodb.buffer_pool_size = self._parse_variable_value(value) + innodb.buffer_pool_size = self.parse_variable_value(value) elif key == "innodb_buffer_pool_instances": - innodb.buffer_pool_instances = self._parse_variable_value(value) + innodb.buffer_pool_instances = self.parse_variable_value(value) elif key == "innodb_log_file_size": - innodb.log_file_size = self._parse_variable_value(value) + innodb.log_file_size = self.parse_variable_value(value) elif key == "innodb_log_buffer_size": - innodb.log_buffer_size = self._parse_variable_value(value) + innodb.log_buffer_size = self.parse_variable_value(value) elif key == "innodb_lock_wait_timeout": - innodb.lock_wait_timeout = self._parse_variable_value(value) + innodb.lock_wait_timeout = self.parse_variable_value(value) except Exception: pass # Get InnoDB row lock status @@ -1401,32 +1401,32 @@ async def get_innodb_info(self) -> InnoDBInfo: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_row_lock_time": - innodb.row_lock_time = self._parse_variable_value(value) + innodb.row_lock_time = self.parse_variable_value(value) elif key == "Innodb_row_lock_waits": - innodb.row_lock_waits = self._parse_variable_value(value) + innodb.row_lock_waits = self.parse_variable_value(value) elif key == "Innodb_row_lock_time_avg": total_lock_time = innodb.row_lock_time or 0 lock_waits = innodb.row_lock_waits or 1 avg_time = total_lock_time / lock_waits if lock_waits > 0 else 0 innodb.row_lock_time_avg = round(avg_time, 2) elif key == "Innodb_rows_read": - innodb.rows_read = self._parse_variable_value(value) + innodb.rows_read = self.parse_variable_value(value) elif key == "Innodb_rows_inserted": - innodb.rows_inserted = self._parse_variable_value(value) + innodb.rows_inserted = self.parse_variable_value(value) elif key == "Innodb_rows_updated": - innodb.rows_updated = self._parse_variable_value(value) + innodb.rows_updated = self.parse_variable_value(value) elif key == "Innodb_rows_deleted": - innodb.rows_deleted = self._parse_variable_value(value) + innodb.rows_deleted = self.parse_variable_value(value) elif key == "Innodb_data_reads": - innodb.data_reads = self._parse_variable_value(value) + innodb.data_reads = self.parse_variable_value(value) elif key == "Innodb_data_writes": - innodb.data_writes = self._parse_variable_value(value) + innodb.data_writes = self.parse_variable_value(value) elif key == "Innodb_os_log_fsyncs": - innodb.os_fsyncs = self._parse_variable_value(value) + innodb.os_fsyncs = self.parse_variable_value(value) elif key == "Innodb_os_file_reads": - innodb.os_file_reads = self._parse_variable_value(value) + innodb.os_file_reads = self.parse_variable_value(value) elif key == "Innodb_os_file_writes": - innodb.os_file_writes = self._parse_variable_value(value) + innodb.os_file_writes = self.parse_variable_value(value) except Exception: pass return innodb @@ -1460,7 +1460,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: log_files.append(log_file) file_size = row.get("File_size") if file_size: - total_size += self._parse_variable_value(file_size) + total_size += self.parse_variable_value(file_size) binary_log.log_files = log_files binary_log.log_size_bytes = total_size except Exception: @@ -1482,7 +1482,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS @@ -1490,7 +1490,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Get GTID info @@ -1565,7 +1565,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: for row in result.data: value = row.get("Value") if value: - slow_query.slow_queries_count = self._parse_variable_value(value) + slow_query.slow_queries_count = self.parse_variable_value(value) except Exception: pass return slow_query @@ -1602,7 +1602,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl.is_master = True row = result.data[0] repl.master_log_file = row.get("File") - repl.master_log_position = self._parse_variable_value(row.get("Position")) + repl.master_log_position = self.parse_variable_value(row.get("Position")) else: # MySQL < 8.4: use SHOW MASTER STATUS result = await self._backend.execute(*self._show_master_status()) @@ -1610,7 +1610,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl.is_master = True row = result.data[0] repl.master_log_file = row.get("File") - repl.master_log_position = self._parse_variable_value(row.get("Position")) + repl.master_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Check if server is configured as slave @@ -1632,7 +1632,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl.slave_master_host = row.get("Master_Host") repl.slave_master_port = row.get("Master_Port") repl.slave_relay_log_file = row.get("Relay_Log_File") - repl.slave_relay_log_pos = self._parse_variable_value(row.get("Relay_Log_Pos")) + repl.slave_relay_log_pos = self.parse_variable_value(row.get("Relay_Log_Pos")) repl.slave_last_error = row.get("Last_Error") except Exception: pass diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py index 52794e4d..380b1ed0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py @@ -226,18 +226,18 @@ class TestMySQLStatusIntrospectorMixin: """Tests for MySQLStatusIntrospectorMixin helper methods.""" def test_parse_variable_value_int(self, mysql_backend): - """Test _parse_variable_value handles integers.""" + """Test parse_variable_value handles integers.""" status = mysql_backend.introspector.status - result = status._parse_variable_value("42") + result = status.parse_variable_value("42") assert result == 42 assert isinstance(result, int) def test_parse_variable_value_str(self, mysql_backend): - """Test _parse_variable_value preserves non-integer strings.""" + """Test parse_variable_value preserves non-integer strings.""" status = mysql_backend.introspector.status - result = status._parse_variable_value("utf8mb4") + result = status.parse_variable_value("utf8mb4") assert result == "utf8mb4" assert isinstance(result, str) @@ -507,19 +507,19 @@ class TestAsyncMySQLStatusIntrospectorMixin: @pytest.mark.asyncio async def test_parse_variable_value_int(self, async_mysql_backend): - """Test _parse_variable_value handles integers.""" + """Test parse_variable_value handles integers.""" status = async_mysql_backend.introspector.status - result = status._parse_variable_value("42") + result = status.parse_variable_value("42") assert result == 42 assert isinstance(result, int) @pytest.mark.asyncio async def test_parse_variable_value_str(self, async_mysql_backend): - """Test _parse_variable_value preserves non-integer strings.""" + """Test parse_variable_value preserves non-integer strings.""" status = async_mysql_backend.introspector.status - result = status._parse_variable_value("utf8mb4") + result = status.parse_variable_value("utf8mb4") assert result == "utf8mb4" assert isinstance(result, str) From e7ac166b18fbddd1dcaac14892b6b4bdde680c20 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 25 Oct 2026 16:11:17 +0800 Subject: [PATCH 54/65] fix(mysql): declare the partitions family in the protocol that mirrors the mixin supports_partition_introspection answers the `introspect partitions` family, and it has been on MySQLPartitionMixin since the family was named. The protocol that mirrors that mixin method for method did not declare it, so the reverse-coverage guard -- every public mixin method must appear in its protocol -- failed the suite on every matrix entry. Declared beside the other partition switches, keeping the distinction the method itself makes: an empty answer means this table is not partitioned, not that MySQL knows nothing about partitions. That is why the family keeps a switch of its own next to core's supports_partition_metadata_introspection. --- .../activerecord/backend/impl/mysql/protocols.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index 6f85428a..88154dc8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -413,6 +413,17 @@ class MySQLPartitionSupport(PartitionSupport, Protocol): expressions and formatted by the methods declared here. """ + def supports_partition_introspection(self) -> bool: + """Whether the partitions of a table can be read back. + + The ``introspect partitions`` family, under the name the family uses + wherever it exists. An empty answer means the table is not + partitioned, which is a different statement from "this engine has no + partitions" -- hence a switch of its own, beside core's + ``supports_partition_metadata_introspection``. + """ + ... + def supports_range_columns_partitioning(self) -> bool: """Whether RANGE COLUMNS partitioning is supported.""" ... From 401a2d96d094aa069454a488ba7aac5da344eddb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 25 Oct 2026 22:13:45 +0800 Subject: [PATCH 55/65] refactor(mysql): the storage-option and partition questions are statement factories --- .../impl/mysql/introspection/introspector.py | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index f399740f..a33e17c5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -27,7 +27,14 @@ """ import copy -from typing import Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: # pragma: no cover + from ..expression.introspection import ( + MySQLPartitionListQueryStatement, + MySQLTableOptionsInfoQueryStatement, + MySQLTableOptionsListQueryStatement, + ) from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, @@ -285,17 +292,23 @@ def parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[Intros ] # ------------------------------------------------------------------ # - # Table storage options and partitions — SQL generation + # Table storage options and partitions — statement factories # # Neither family has a scope of its own in IntrospectionScope, and # neither is the same question as "what tables are there". Both are # therefore asked in IntrospectionScope.TABLE and kept apart from the # table list (and from each other) by a distinct ``extra`` namespace, # which is exactly what ``extra`` exists for. + # + # Each factory returns a statement object and renders nothing: the + # dialect's ``format_*_query_statement`` decides the SQL, so nothing + # here spells it and no helper is private. # ------------------------------------------------------------------ # - def _build_table_options_sql(self, schema: Optional[str]) -> Tuple[str, tuple]: - """The storage-options question, as a statement the dialect renders. + def table_options_list_statement( + self, *, schema: Optional[str] + ) -> "MySQLTableOptionsListQueryStatement": + """Return the statement that lists every base table's storage options. ``DATA_LENGTH`` and ``INDEX_LENGTH`` are reported in bytes; see :class:`MySQLTableOptions` for the caveat about InnoDB estimates. @@ -303,19 +316,23 @@ def _build_table_options_sql(self, schema: Optional[str]) -> Tuple[str, tuple]: from ..expression.introspection import MySQLTableOptionsListQueryStatement target_db = schema if schema is not None else self.default_schema - return MySQLTableOptionsListQueryStatement(self.dialect, schema=target_db).to_sql() + return MySQLTableOptionsListQueryStatement(self.dialect, schema=target_db) - def _build_table_options_lookup_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """One table's storage options, rendered through the dialect.""" + def table_options_info_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "MySQLTableOptionsInfoQueryStatement": + """Return the statement that reads one base table's storage options.""" from ..expression.introspection import MySQLTableOptionsInfoQueryStatement target_db = schema if schema is not None else self.default_schema return MySQLTableOptionsInfoQueryStatement( self.dialect, table_name=table_name, schema=target_db - ).to_sql() + ) - def _build_partition_list_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """One table's partitions, rendered through the dialect. + def partition_list_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "MySQLPartitionListQueryStatement": + """Return the statement that lists one table's partitions. Rows without a ``PARTITION_NAME`` describe a non-partitioned table and are not partitions, so the predicate excludes them rather than @@ -327,7 +344,7 @@ def _build_partition_list_sql(self, table_name: str, schema: Optional[str]) -> T target_db = schema if schema is not None else self.default_schema return MySQLPartitionListQueryStatement( self.dialect, table_name=table_name, schema=target_db - ).to_sql() + ) # ------------------------------------------------------------------ # # Table storage options and partitions — parsing @@ -448,7 +465,7 @@ def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOpt if cached is not None: return cached - sql, params = self._build_table_options_sql(schema) + sql, params = self.table_options_list_statement(schema=schema).to_sql() result = self.parse_table_options(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -469,7 +486,7 @@ def get_table_options( if cached is not None: return cached - sql, params = self._build_table_options_lookup_sql(table_name, schema) + sql, params = self.table_options_info_statement(table_name=table_name, schema=schema).to_sql() result = next(iter(self.parse_table_options(self._executor.execute(sql, params))), None) if result is not None: self._cache.set(key, result) @@ -489,7 +506,7 @@ def list_partitions( if cached is not None: return cached - sql, params = self._build_partition_list_sql(table_name, schema) + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_partitions(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -573,7 +590,7 @@ async def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTa if cached is not None: return cached - sql, params = self._build_table_options_sql(schema) + sql, params = self.table_options_list_statement(schema=schema).to_sql() result = self.parse_table_options(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -594,7 +611,7 @@ async def get_table_options( if cached is not None: return cached - sql, params = self._build_table_options_lookup_sql(table_name, schema) + sql, params = self.table_options_info_statement(table_name=table_name, schema=schema).to_sql() rows = await self._executor.execute(sql, params) result = next(iter(self.parse_table_options(rows)), None) if result is not None: @@ -615,7 +632,7 @@ async def list_partitions( if cached is not None: return cached - sql, params = self._build_partition_list_sql(table_name, schema) + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_partitions(await self._executor.execute(sql, params)) self._cache.set(key, result) return result From 8eeb8023df8c2ce8aa248c343b5e46eae5e9cb3b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 27 Oct 2026 01:41:28 +0800 Subject: [PATCH 56/65] feat(cli)!: the introspect table aggregate is gone "table" answered several catalogue questions at once -- the relation's row, its columns, its indexes and its foreign keys -- and printed them as three sections. Each of those is an item the introspector reports on its own and each already has a type, so the aggregate was a second way to ask for the same rows and the only way to get an unparseable answer: under "-o json" the three sections are three arrays one after another, which is not a document. What is left is one type per item: tables, views, columns, indexes, foreign-keys, triggers, table-options, partitions, database. --- .../backend/impl/mysql/cli/introspect.py | 37 +------------------ .../feature/backend/test_cli.py | 11 ++---- 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index 51de4e77..e156d6c3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -30,7 +30,6 @@ INTROSPECT_TYPES = [ "tables", "views", - "table", "columns", "indexes", "foreign-keys", @@ -99,7 +98,7 @@ def create_parser(subparsers): "type", choices=INTROSPECT_TYPES, help=( - "Introspection type: tables, views, table, columns, indexes, " + "Introspection type: tables, views, columns, indexes, " "foreign-keys, triggers, table-options, partitions, database" ), ) @@ -183,23 +182,6 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): data = _serialize_for_output(views) provider.display_results(data, title="Views") - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = introspector.get_table_info(args.name, schema=None) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) - else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) - elif args.type == "columns": if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) @@ -289,23 +271,6 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): data = _serialize_for_output(views) provider.display_results(data, title="Views") - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = await introspector.get_table_info(args.name, schema=None) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) - else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) - elif args.type == "columns": if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py index bafad925..135870c8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py @@ -103,14 +103,14 @@ def test_parse_args_introspect_subcommand(self): assert args.type == "tables" def test_parse_args_introspect_with_name(self): - """Test introspect subcommand with table name.""" + """Test introspect subcommand with an object name.""" from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - with patch.object(sys, "argv", ["mysql", "introspect", "table", "users"]): + with patch.object(sys, "argv", ["mysql", "introspect", "columns", "users"]): args = parse_args() assert args.command == "introspect" - assert args.type == "table" + assert args.type == "columns" assert args.name == "users" def test_parse_args_introspect_valid_types(self): @@ -928,11 +928,6 @@ def test_introspect_all_types_with_name(self): """Test introspect types that require name parameter.""" from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - # table type with name - with patch.object(sys, "argv", ["mysql", "introspect", "table", "users"]): - args = parse_args() - assert args.type == "table" - assert args.name == "users" # columns type with name with patch.object(sys, "argv", ["mysql", "introspect", "columns", "orders"]): From 8264f5e9a180e99fc202a1532deb0c50482396e0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 27 Oct 2026 11:07:57 +0800 Subject: [PATCH 57/65] fix(mysql): the introspect CLI asks the server what version it is `introspect database` crashed on a fresh run: the parser joined the backend's private `_version`, which is None until something asks the server, so the record could not be built at all. The CLI never called `introspect_and_adapt()` -- unlike the SQLite CLI -- so nothing had asked. Adapt after connecting, as the other MySQL subcommands already do, and have the parser read `IntrospectorMixin.server_version`, which never raises and never performs I/O. An unresolved version is reported as the literal "unknown" with `(0, 0, 0)` rather than a fabricated 8.0.0. The same review covers the capability predicates: `supports_*` now asks `version_at_least()`, so a probe answers "not confirmed" instead of raising before adaptation. Formatters that gate SQL keep the strict `version`, because a renderer must know what it is rendering for. Add a parser-level regression test for the database record and the table-kind mapping; it needs no server. --- .../backend/impl/mysql/cli/introspect.py | 7 + .../backend/impl/mysql/dialect.py | 2 +- .../impl/mysql/introspection/introspector.py | 32 +++-- .../backend/impl/mysql/mixins/constraint.py | 12 +- .../backend/impl/mysql/mixins/cte.py | 6 +- .../impl/mysql/mixins/ddl_table_statement.py | 4 +- .../backend/impl/mysql/mixins/explain.py | 6 +- .../backend/impl/mysql/mixins/fulltext.py | 4 +- .../backend/impl/mysql/mixins/function.py | 16 ++- .../impl/mysql/mixins/generated_column.py | 4 +- .../backend/impl/mysql/mixins/join.py | 2 +- .../backend/impl/mysql/mixins/json.py | 12 +- .../impl/mysql/mixins/json_duality_view.py | 4 +- .../backend/impl/mysql/mixins/locking.py | 6 +- .../impl/mysql/mixins/optimizer_hint.py | 4 +- .../backend/impl/mysql/mixins/partition.py | 2 +- .../impl/mysql/mixins/set_operation.py | 4 +- .../backend/impl/mysql/mixins/spatial.py | 16 +-- .../backend/impl/mysql/mixins/transaction.py | 4 +- .../backend/impl/mysql/mixins/trigger.py | 8 +- .../backend/impl/mysql/mixins/vector.py | 4 +- .../backend/impl/mysql/mixins/window.py | 4 +- .../test_introspection_database_parser.py | 128 ++++++++++++++++++ 23 files changed, 222 insertions(+), 69 deletions(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index e156d6c3..510ffe4e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -169,6 +169,12 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): """Handle introspect subcommand synchronously.""" try: backend.connect() + # Connect alone leaves the dialect unadapted. Everything below -- the + # database record's version, and any statement gated on a capability + # -- then has no version to answer from, so the run would report + # "unknown" at best and fail on a capability probe at worst. The + # SQLite CLI adapts after connecting for the same reason. + backend.introspect_and_adapt() introspector = backend.introspector @@ -258,6 +264,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): """Handle introspect subcommand asynchronously.""" try: await backend.connect() + await backend.introspect_and_adapt() introspector = backend.introspector diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 90382c8d..f37e70fd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -514,7 +514,7 @@ def format_identifier(self, identifier: str, need_quote: bool = True) -> str: def supports_json_arrow_operators(self) -> bool: """Check if MySQL version supports -> and ->> operators.""" - return self.version >= (5, 7, 9) + return self.version_at_least((5, 7, 9)) def supports_upsert(self) -> bool: """Whether UPSERT (ON DUPLICATE KEY UPDATE) is supported.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index a33e17c5..f9e6ff2a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -79,7 +79,7 @@ class MySQLIntrospectorMixin(IntrospectorMixin): Both SyncMySQLIntrospector and AsyncMySQLIntrospector inherit from this mixin to share: - Default schema handling - - MySQL version detection + - The version a database record reports (``server_version``) - parse_* implementations SQL generation is delegated to the Dialect layer via Expression.to_sql() @@ -93,25 +93,33 @@ def default_schema(self) -> str: return self._backend.config.database or "" return "" - def _get_version(self) -> tuple: - """Return the MySQL server version tuple from the backend.""" - return getattr(self._backend, "_version", (8, 0, 0)) - # ------------------------------------------------------------------ # # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: - version = self._get_version() - version_str = ".".join(str(v) for v in version) - db_name = self.default_schema - + """Turn the schema row into a database record. + + The version comes from :attr:`server_version`, not from the strict + ``dialect.version`` or a backend private: a parser has no connection + to ask, and refusing to describe a database until + ``introspect_and_adapt()`` has run would make the answer depend on a + step the caller did not need. An unresolved version is reported as + the literal ``"unknown"`` with ``(0, 0, 0)`` -- distinguishable from + a server that really is 0.0.0 -- rather than a fabricated ``8.0.0``. + + ``encoding`` and ``collation`` are the two columns the statement + selects. ``table_count`` and ``size_bytes`` are not among them: they + live in ``information_schema.TABLES``, so they stay ``None`` rather + than being guessed here. + """ + version = self.server_version db_row = rows[0] if rows else {} return IntrospectionDatabaseInfo( - name=db_name, - version=version_str, - version_tuple=version, + name=self.default_schema, + version=".".join(str(part) for part in version) if version else "unknown", + version_tuple=tuple(version) if version else (0, 0, 0), vendor="MySQL", encoding=db_row.get("DEFAULT_CHARACTER_SET_NAME"), collation=db_row.get("DEFAULT_COLLATION_NAME"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index 5ddc3a73..3bf378c4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -84,15 +84,15 @@ class MySQLConstraintMixin: def supports_invisible_index(self) -> bool: """Whether INVISIBLE indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_descending_index(self) -> bool: """Whether descending indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_functional_index(self) -> bool: """Whether functional (expression) indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) # Basic constraint types (SQL-86/SQL-92) @@ -202,7 +202,7 @@ def supports_check_constraint(self) -> bool: Below 8.0.16 the clause parses and is discarded, so the probe is version-gated rather than a flat ``True``. """ - return self.version >= (8, 0, 16) + return self.version_at_least((8, 0, 16)) def supports_add_constraint(self) -> bool: """Whether ``ALTER TABLE ADD CONSTRAINT`` is supported. @@ -396,7 +396,7 @@ def supports_alter_constraint_enforced(self) -> bool: MariaDB 13.1 has no ``ALTER CONSTRAINT`` at all and rejects ``[NOT] ENFORCED`` on every path, so this ``True`` is MySQL only. """ - return self.version >= (8, 0, 16) + return self.version_at_least((8, 0, 16)) def format_alter_constraint_action( self, action: "AlterConstraintAction" @@ -491,7 +491,7 @@ def supports_constraint_enforced(self) -> bool: MariaDB 13.1 rejects ``[NOT] ENFORCED`` outright on every path, so this ``True`` is MySQL only. """ - return self.version >= (8, 0, 16) + return self.version_at_least((8, 0, 16)) def _require_constraint_enforcement(self, constraint) -> None: """Narrow the ENFORCABLE kinds to CHECK for this server. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py index 6efcf6c7..207bd836 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py @@ -4,11 +4,11 @@ class MySQLCTEMixin: def supports_basic_cte(self) -> bool: """Basic CTEs are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_recursive_cte(self) -> bool: """Recursive CTEs are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_materialized_cte(self) -> bool: """Whether the CTE ``MATERIALIZED`` / ``NOT MATERIALIZED`` hint exists. @@ -23,4 +23,4 @@ def supports_materialized_cte(self) -> bool: def supports_unconditional_cte_order_by(self) -> bool: """ORDER BY inside a CTE definition requires CTE support (8.0.0+).""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py index 67fdd50b..638cc671 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py @@ -22,11 +22,11 @@ class MySQLTableStatementMixin: def supports_table_statement(self) -> bool: """MySQL 8.0.19+ supports the TABLE statement.""" - return getattr(self, "version", None) is not None and self.version >= (8, 0, 19) + return self.version_at_least((8, 0, 19)) def supports_values_table_constructor(self) -> bool: """MySQL 8.0.19+ supports VALUES as a table value constructor.""" - return getattr(self, "version", None) is not None and self.version >= (8, 0, 19) + return self.version_at_least((8, 0, 19)) def format_mysql_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: """Format ``TABLE
[ORDER BY ...] [LIMIT ...]``. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py index 5242c23e..5b262399 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py @@ -8,7 +8,7 @@ class MySQLExplainMixin: def supports_explain_analyze(self) -> bool: """Whether EXPLAIN ANALYZE is supported.""" - return self.version >= (8, 0, 18) + return self.version_at_least((8, 0, 18)) def supports_explain_format(self, format_type: str) -> bool: """Check if specific EXPLAIN format is supported.""" @@ -16,9 +16,9 @@ def supports_explain_format(self, format_type: str) -> bool: if format_type_upper == "TEXT": return True elif format_type_upper == "JSON": - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) elif format_type_upper == "TREE": - return self.version >= (8, 0, 16) + return self.version_at_least((8, 0, 16)) else: return False diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py index e39f4c47..a5b28d36 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py @@ -7,7 +7,7 @@ class MySQLFullTextSearchMixin: def supports_fulltext_index(self) -> bool: """Whether FULLTEXT index is supported (MySQL 5.6+ InnoDB).""" - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def supports_fulltext_search(self) -> bool: """Whether full-text search (MATCH ... AGAINST) is supported. @@ -20,7 +20,7 @@ def supports_fulltext_search(self) -> bool: return self.supports_fulltext_index() def supports_fulltext_parser(self) -> bool: - return self.version >= (5, 1, 0) + return self.version_at_least((5, 1, 0)) def supports_fulltext_query_expansion(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py index 9e969a7d..9d88c4ca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py @@ -100,17 +100,27 @@ def supports_functions(self) -> Dict[str, bool]: return result def _is_mysql_function_supported(self, func_name: str) -> bool: - """Check if a MySQL-specific function is supported based on version.""" + """Check if a MySQL-specific function is supported based on version. + + This is a capability check, so it answers before adaptation rather + than raising: a name with no version bound is available on every + server, and a version-bounded one is not claimed until a version is + known. ``supports_functions()`` reports the result as-is. + """ version_range = self._MYSQL_FUNCTION_VERSIONS.get(func_name) if version_range is None: return True + version = self.version_or_none + if version is None: + return False + min_version, max_version = version_range - if min_version is not None and self.version < min_version: + if min_version is not None and version < min_version: return False - if max_version is not None and self.version > max_version: + if max_version is not None and version > max_version: return False return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py index 1f735359..6b402b11 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py @@ -4,7 +4,7 @@ class MySQLGeneratedColumnMixin: def supports_generated_column(self) -> bool: """Whether generated (computed) columns are supported.""" - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_auto_increment_column(self) -> bool: """Whether the parameterless ``AUTO_INCREMENT`` column marker is supported. @@ -33,4 +33,4 @@ def supports_virtual_generated_columns(self) -> bool: def supports_default_column_value_expression(self) -> bool: """Whether DEFAULT column values can use expressions.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py index d0f23e35..bc30887e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py @@ -4,7 +4,7 @@ class MySQLJoinMixin: def supports_lateral_join(self) -> bool: """Whether LATERAL joins are supported.""" - return self.version >= (8, 0, 14) + return self.version_at_least((8, 0, 14)) def supports_right_join(self) -> bool: """RIGHT JOIN is supported.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py index b379e104..d38e9e21 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py @@ -40,7 +40,7 @@ class MySQLJSONFunctionMixin: } def supports_json_type(self) -> bool: - return self.version >= (5, 7, 8) + return self.version_at_least((5, 7, 8)) def supports_json_path(self) -> bool: """Whether a JSON path can be read on this server. @@ -50,18 +50,18 @@ def supports_json_path(self) -> bool: perfectly well and has no JSON column type, so gating on the type would refuse queries it answers. """ - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_json_merge_patch(self) -> bool: - return self.version >= (8, 0, 3) + return self.version_at_least((8, 0, 3)) def supports_json_table(self) -> bool: - return self.version >= (8, 0, 4) + return self.version_at_least((8, 0, 4)) def supports_json_function(self, function_name: str) -> bool: if function_name in self._JSON_FUNCTION_VERSIONS: - return self.version >= self._JSON_FUNCTION_VERSIONS[function_name] - return self.version >= (5, 7, 8) + return self.version_at_least(self._JSON_FUNCTION_VERSIONS[function_name]) + return self.version_at_least((5, 7, 8)) def format_mysql_json_extract_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONExtractExpression` node.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py index 18be2039..be0d5dc8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py @@ -12,10 +12,10 @@ class MySQLJsonDualityViewMixin: """MySQL JSON Duality View implementation (MySQL 9.7+).""" def supports_json_duality_view(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) def supports_json_duality_view_dml(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewStatement") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py index 7c8ab40f..08c17e79 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py @@ -6,13 +6,13 @@ class MySQLLockingMixin: """MySQL row-level locking mixin.""" def supports_for_share(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_for_update_nowait(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_for_update_skip_locked(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def format_for_update_clause(self, clause) -> Tuple[str, tuple]: """Format MySQL-specific FOR UPDATE / FOR SHARE clause. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py index cc98351a..7ddc937a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py @@ -11,10 +11,10 @@ class MySQLOptimizerHintMixin: """MySQL optimizer hint implementation.""" def supports_optimizer_hint(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_hypergraph_optimizer(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) def format_mysql_optimizer_hint_expression(self, expr: "MySQLOptimizerHintExpression") -> "Tuple[str, tuple]": """Format /*+ SET_VAR(...) */ hint clause.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index eb6a94a4..8d2b5af2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -138,7 +138,7 @@ def supports_exchange_partition_with_validation(self) -> bool: 5.6) reject it as a syntax error. See MySQL 5.7 release notes for "InnoDB online DDL" support of EXCHANGE PARTITION WITH VALIDATION. """ - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_exchange_partition_without_validation(self) -> bool: """Whether ``EXCHANGE PARTITION ... WITHOUT VALIDATION`` is accepted. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py index d72b9e41..f9970187 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py @@ -12,11 +12,11 @@ def supports_union_all(self) -> bool: def supports_intersect(self) -> bool: """INTERSECT is supported since MySQL 8.0.31.""" - return self.version >= (8, 0, 31) + return self.version_at_least((8, 0, 31)) def supports_except(self) -> bool: """EXCEPT is supported since MySQL 8.0.31.""" - return self.version >= (8, 0, 31) + return self.version_at_least((8, 0, 31)) def supports_set_operation_order_by(self) -> bool: """Set operations support ORDER BY.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py index ae580716..122a1085 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py @@ -12,28 +12,28 @@ def supports_spatial_type(self, type_name: str) -> bool: } if type_name.upper() not in valid_types: return False - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_spatial_index(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_geojson(self) -> bool: - return self.version >= (5, 7, 5) + return self.version_at_least((5, 7, 5)) def supports_geometry_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_point_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_curve_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_surface_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_geometry_collection_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def format_mysql_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSpatialLiteralExpression` node.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py index b22aa7b9..dab2f981 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py @@ -51,7 +51,7 @@ def _build_set_isolation_sql(self, level: IsolationLevel) -> Tuple[str, tuple]: def supports_transaction_mode(self) -> bool: """MySQL supports READ ONLY transactions (5.6.5+).""" - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) def supports_isolation_level_in_begin(self) -> bool: """MySQL does not support isolation level in START TRANSACTION.""" @@ -59,7 +59,7 @@ def supports_isolation_level_in_begin(self) -> bool: def supports_read_only_transaction(self) -> bool: """MySQL supports READ ONLY transactions (5.6.5+).""" - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) def supports_deferrable_transaction(self) -> bool: """MySQL does not support DEFERRABLE mode.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py index b144a27f..3d4d8c12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py @@ -17,15 +17,15 @@ class MySQLTriggerMixin: def supports_trigger(self) -> bool: """MySQL supports triggers since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_create_trigger(self) -> bool: """MySQL supports CREATE TRIGGER since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_drop_trigger(self) -> bool: """MySQL supports DROP TRIGGER since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_instead_of_trigger(self) -> bool: """MySQL does NOT support INSTEAD OF triggers.""" @@ -45,7 +45,7 @@ def supports_trigger_when(self) -> bool: def supports_trigger_if_not_exists(self) -> bool: """MySQL 5.7+ supports IF NOT EXISTS.""" - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement (MySQL syntax). diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py index c97391be..d0f5dc82 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py @@ -8,10 +8,10 @@ class MySQLVectorMixin: MAX_VECTOR_DIMENSION = 16384 def supports_vector_type(self) -> bool: - return self.version >= (9, 0, 0) + return self.version_at_least((9, 0, 0)) def supports_vector_index(self) -> bool: - return self.version >= (9, 0, 1) + return self.version_at_least((9, 0, 1)) def get_max_vector_dimension(self) -> int: return self.MAX_VECTOR_DIMENSION diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py index d9299594..3788e0dc 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py @@ -4,8 +4,8 @@ class MySQLWindowMixin: def supports_window_functions(self) -> bool: """Window functions are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_window_frame_clause(self) -> bool: """Whether window frame clauses (ROWS/RANGE) are supported, since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py new file mode 100644 index 00000000..1dbf3535 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py @@ -0,0 +1,128 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py +"""Parser-level tests for MySQL database and table introspection. + +``introspect database`` used to crash when the run had not adapted the +dialect: the parser joined the backend's private ``_version``, which is +``None`` until a server has been asked. These tests call the parsers with no +server at all -- the row the query would have returned is supplied directly +-- so they fail on the parser rather than on a missing database. +""" + +from types import SimpleNamespace + +from rhosocial.activerecord.backend.impl.mysql.introspection import ( + SyncMySQLIntrospector, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.introspection.types import TableType + + +class _NoServerBackend: + """The parts of a MySQL backend an introspector reads, and nothing else.""" + + def __init__(self, version=None, cached_version=None): + self.config = SimpleNamespace(database="shop") + self.dialect = MySQLDialect(version) + self._cached_version = cached_version + + def get_cached_server_version(self): + return self._cached_version + + +class _UnusedExecutor: + """An executor that fails loudly if a parser-level test asks for SQL.""" + + def execute(self, sql, params=()): + raise AssertionError("a parser-level test must not execute SQL") + + +def _introspector(**kwargs) -> SyncMySQLIntrospector: + return SyncMySQLIntrospector(_NoServerBackend(**kwargs), _UnusedExecutor()) + + +_SCHEMATA_ROW = { + "DEFAULT_CHARACTER_SET_NAME": "utf8mb4", + "DEFAULT_COLLATION_NAME": "utf8mb4_0900_ai_ci", +} + + +class TestParseDatabaseInfo: + """The record is built from the row and the version, never a private.""" + + def test_unadapted_version_is_reported_as_unknown(self): + """Before adaptation there is no version: say "unknown", not 8.0.0.""" + info = _introspector().parse_database_info([_SCHEMATA_ROW]) + + assert info.version == "unknown" + assert info.version_tuple == (0, 0, 0) + + def test_row_values_fill_the_rest_of_the_record(self): + info = _introspector().parse_database_info([_SCHEMATA_ROW]) + + assert info.name == "shop" + assert info.vendor == "MySQL" + assert info.encoding == "utf8mb4" + assert info.collation == "utf8mb4_0900_ai_ci" + # Not selected by the statement, so not invented here. + assert info.table_count is None + assert info.size_bytes is None + + def test_adapted_version_is_reported(self): + info = _introspector(version=(8, 0, 36)).parse_database_info([_SCHEMATA_ROW]) + + assert info.version == "8.0.36" + assert info.version_tuple == (8, 0, 36) + + def test_backend_cache_is_used_when_the_dialect_has_none(self): + """A connected-but-unadapted backend still knows what it measured.""" + info = _introspector(cached_version=(5, 7, 44)).parse_database_info( + [_SCHEMATA_ROW] + ) + + assert info.version == "5.7.44" + assert info.version_tuple == (5, 7, 44) + + def test_no_rows_leaves_the_row_derived_fields_empty(self): + info = _introspector(version=(8, 0, 36)).parse_database_info([]) + + assert info.name == "shop" + assert info.encoding is None + assert info.collation is None + + +class TestParseTables: + """The catalog's kind is carried through instead of being flattened.""" + + def test_table_kinds_are_reported_as_named(self): + rows = [ + {"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}, + {"TABLE_NAME": "user_summary", "TABLE_TYPE": "VIEW"}, + {"TABLE_NAME": "INNODB_METRICS", "TABLE_TYPE": "SYSTEM VIEW"}, + ] + + tables = _introspector().parse_tables(rows, schema=None) + + assert [table.name for table in tables] == [ + "users", + "user_summary", + "INNODB_METRICS", + ] + assert [table.table_type for table in tables] == [ + TableType.BASE_TABLE, + TableType.VIEW, + TableType.SYSTEM_TABLE, + ] + + def test_schema_defaults_to_the_connected_database(self): + rows = [{"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}] + + tables = _introspector().parse_tables(rows, schema=None) + + assert tables[0].schema == "shop" + + def test_an_explicit_schema_wins_over_the_default(self): + rows = [{"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}] + + tables = _introspector().parse_tables(rows, schema="archive") + + assert tables[0].schema == "archive" From 65db7e2ca4efb98ff88706df2c5e67a3404c006e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 27 Oct 2026 20:07:56 +0800 Subject: [PATCH 58/65] test(transaction): the local copy becomes a shim over the shared contract Core 5f000666 turned a second task's entry into an active outermost transaction from a UserWarning into a TransactionError. The shared suite followed (testsuite 3289103); this repository still carried its own full copy of the old test, so its matrix kept asserting a warning that no longer happens. Replace the copy with the shim the other backends already use: the fixtures already come from the testsuite's conftest in this directory, and the tests themselves should have one source. --- .../transaction/test_async_owner_task.py | 206 +----------------- 1 file changed, 7 insertions(+), 199 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py index baca4538..f149cff2 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py @@ -1,202 +1,10 @@ -# src/rhosocial/activerecord/testsuite/feature/basic/transaction/test_async_owner_task.py -"""Async contracts for `AsyncTransactionManager._owner_task` safety warning. - -`AsyncTransactionManager.transaction()` (python-activerecord -`backend/transaction.py:879-943`) tracks which asyncio task currently -owns an active outermost transaction. When a second task enters -`transaction()` on the same backend instance while another task holds -an active outermost transaction, it MUST emit `UserWarning` with a fixed -message text (line 906-915). - -The `_owner_task` lifecycle is bound to `backend.transaction_manager.transaction()` -only. `pool.transaction()` calls `begin_transaction()` / `commit_transaction()` -directly (sync_pool.py:574-576 / async_pool.py:613-615) and does NOT -participate in `_owner_task` tracking. Therefore these tests operate -directly on `backend.transaction_manager` rather than through -`pool.transaction()`. - -Coverage: -- T1 single task nested transaction does not emit warning -- T2 a different task entering while an outer is active emits warning -- T3 warning message text is stable (for log-grep filters) -- T4 owner_task is cleared after outermost transaction exits cleanly -- T5 same task re-entering after clean exit re-acquires ownership +# tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py """ -import asyncio -import warnings -from typing import Tuple - -import pytest - - -@pytest.fixture -async def mgr_and_backend(async_pool_and_model): - """Acquire a backend via pool.connection() and yield (backend, mgr). - - Mechanism note: we stub `_do_begin / _do_commit / _do_rollback / - _do_create_savepoint / _do_release_savepoint / _do_rollback_savepoint` - on the async transaction manager with no-ops, because the - `_owner_task` warning branch fires *before* `_do_begin()` is awaited - (transaction.py:905-915 runs synchronously at context-manager entry, - only then does `await self.begin()` execute). Stubbing the I/O layer - lets us exercise the pure state machine — including concurrent - multi-task execution on a real asyncio event loop — without dragging - in SQLite's thread-affinity rules, which otherwise cause spurious - "cannot start a transaction within a transaction" when a second - task reuses the same aiosqlite connection. - """ - pool, _model = async_pool_and_model - cm = pool.connection() - backend = await cm.__aenter__() - mgr = backend.transaction_manager - - async def _noop(*args, **kwargs): - return None - - mgr._do_begin = _noop - mgr._do_commit = _noop - mgr._do_rollback = _noop - mgr._do_create_savepoint = _noop - mgr._do_release_savepoint = _noop - mgr._do_rollback_savepoint = _noop - - try: - yield backend, mgr - finally: - try: - await cm.__aexit__(None, None, None) - except Exception: - pass - - -class TestAsyncOwnerTaskNoWarningSameTask: - async def test_same_task_nested_transaction_no_warning(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - async with mgr.transaction(): - pass - - async def test_same_task_reentry_after_clean_exit_no_warning(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - pass - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - pass - - -class TestAsyncOwnerTaskCrossTaskWarning: - async def test_cross_task_share_emits_user_warning(self, mgr_and_backend): - """A different task entering transaction() during an active outer - transaction owned by another task MUST emit UserWarning. - - Per backend/transaction.py:906-915, the branch fires when: - self._owner_task is not None and self._owner_task is not current_task - It does NOT raise, so the second task can still proceed - the - system is telling the developer to fix their code. - """ - _backend, mgr = mgr_and_backend +Async contract for AsyncTransactionManager._owner_task (MySQL backend). - outer_started = asyncio.Event() - proceed = asyncio.Event() - - async def owner(): - async with mgr.transaction(): - outer_started.set() - await proceed.wait() - - async def second_task(caught): - with warnings.catch_warnings(record=True) as captured: - warnings.simplefilter("always") - async with mgr.transaction(): - pass - caught.extend(captured) - - caught = [] - owner_task = asyncio.create_task(owner()) - second_t = asyncio.create_task(second_task(caught)) - # Let owner enter its outer transaction first. - await asyncio.wait_for(outer_started.wait(), timeout=5.0) - # Yield once so second task enters transaction() and emits warning. - await asyncio.sleep(0.05) - proceed.set() - await asyncio.gather(owner_task, second_t) - - user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] - assert user_warnings, "expected a UserWarning about shared backend" - - async def test_cross_task_warning_message_is_stable(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - - outer_started = asyncio.Event() - proceed = asyncio.Event() - - async def owner(): - async with mgr.transaction(): - outer_started.set() - await proceed.wait() - - async def second_task(caught): - with warnings.catch_warnings(record=True) as captured: - warnings.simplefilter("always") - async with mgr.transaction(): - pass - caught.extend(captured) - - caught = [] - owner_task = asyncio.create_task(owner()) - second_t = asyncio.create_task(second_task(caught)) - await asyncio.wait_for(outer_started.wait(), timeout=5.0) - await asyncio.sleep(0.05) - proceed.set() - await asyncio.gather(owner_task, second_t) - - msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] - assert msgs, "no UserWarning captured" - expected = ( - "Shared async backend instance detected: a different async task " - "already holds an active transaction on this backend. Concurrent " - "use of the same backend instance across tasks can lead to " - "transaction state inconsistency. Use separate backend instances " - "per task or ensure serialized access." - ) - assert msgs[0] == expected - - -class TestAsyncOwnerTaskLifecycle: - async def test_owner_task_cleared_after_outermost_exits(self, mgr_and_backend): - """After a clean outermost commit, _owner_task MUST reset to None. - - Per backend/transaction.py:941-943: - if not self.is_active: - self._owner_task = None - """ - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - assert mgr.is_active - async with mgr.transaction(): - pass - assert mgr._owner_task is None - - async def test_owner_task_cleared_after_outermost_rolls_back(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - with pytest.raises(RuntimeError): - async with mgr.transaction(): - async with mgr.transaction(): - raise RuntimeError("force rollback") - assert mgr._owner_task is None +Imports the shared tests: a second asyncio task entering ``transaction()`` +while another task owns an active outermost transaction is refused with +``TransactionError``, and ownership is released when the outermost one ends. +""" - async def test_owner_task_reacquired_by_same_task_after_clean_exit(self, mgr_and_backend): - """After clean reset, the same task re-entering does not warn and - re-acquires ownership (line 916-917).""" - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - async with mgr.transaction(): - pass - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - pass +from rhosocial.activerecord.testsuite.feature.basic.transaction.test_async_owner_task import * # noqa: F403 From 604f3eb33162aa1e1173ee8a7669c8d8f6c3dcc9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 30 Oct 2026 19:03:08 +0800 Subject: [PATCH 59/65] feat(cli): a catalogue family takes an optional name, and the type list names its switches introspect views NAME listed every view and ignored the name. The single-view question was already answerable -- get_view_info and its formatter were there -- so the command simply never asked it. A name is now what it says it is: optional for a family that can answer for one member, required for a table's columns/indexes/foreign-keys/partitions, and refused for the families that answer for the whole database. Refused rather than ignored, because the silent version is the worst of the three outcomes: introspect tables users succeeded, printed the whole catalogue, and read as though it had been narrowed. INTROSPECT_TYPES is now derived from _TYPE_CAPABILITIES, so a family cannot be offered without a switch saying it may be, and the gate runs after introspect_and_adapt() -- against the server in front of us, not the newest release this backend knows. A refusal exits 2, the code argparse already uses for 'there is no such type here', rather than 1, which now means the request failed while running. The per-branch if not args.name guards went with it: one rule table at the entry point, instead of eight copies that could disagree. --- .../backend/impl/mysql/cli/introspect.py | 178 +++++++++++++----- 1 file changed, 126 insertions(+), 52 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index 510ffe4e..13696ad2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -27,17 +27,44 @@ OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] -INTROSPECT_TYPES = [ - "tables", - "views", - "columns", - "indexes", - "foreign-keys", - "triggers", - "table-options", - "partitions", - "database", -] +#: The single place a type name maps to the dialect capability that decides +#: whether this backend can answer it. ``INTROSPECT_TYPES`` is derived from it, +#: so a family cannot be offered without a switch saying it may be -- and the +#: order below is the order the parser and ``--help`` print. +_TYPE_CAPABILITIES = { + "tables": "supports_table_introspection", + "views": "supports_view_introspection", + "columns": "supports_column_introspection", + "indexes": "supports_index_introspection", + "foreign-keys": "supports_foreign_key_introspection", + "triggers": "supports_trigger_introspection", + "table-options": "supports_table_options_introspection", + "partitions": "supports_partition_introspection", + "database": "supports_database_info", +} + +INTROSPECT_TYPES = list(_TYPE_CAPABILITIES) + +#: What the optional NAME positional means for each type. +#: +#: ``required`` -- the type asks about one table and cannot be answered without +#: it. ``optional`` -- a catalogue family that can also answer for one member, +#: so a given name narrows it (and a name that matches nothing is an error, not +#: an empty list). ``forbidden`` -- the family has no single-member question +#: here. ``tables`` must stay one: answering for one table would fill in its +#: columns, indexes and foreign keys, which is the aggregate this command +#: deliberately dropped. +_NAME_RULE = { + "tables": "forbidden", + "views": "optional", + "columns": "required", + "indexes": "required", + "foreign-keys": "required", + "triggers": "optional", + "table-options": "optional", + "partitions": "required", + "database": "forbidden", +} def create_parser(subparsers): @@ -49,17 +76,16 @@ def create_parser(subparsers): # List all tables in database %(prog)s tables --database mydb - # List all views + # List all views, or describe one of them %(prog)s views --database mydb - - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s table users --database mydb + %(prog)s views users_v --database mydb # Get column details for a table %(prog)s columns users --database mydb # Get storage options (engine, row format, collation, sizes) %(prog)s table-options --database mydb + %(prog)s table-options users --database mydb # Get the partitions of one table %(prog)s partitions orders --database mydb @@ -67,6 +93,10 @@ def create_parser(subparsers): # Output as JSON %(prog)s tables --database mydb -o json + # NAME is a table for columns/indexes/foreign-keys/partitions, the object + # itself for views/triggers/table-options, and is refused for + # tables/database -- those answer for the whole database. + # Using environment variables for connection export MYSQL_HOST=localhost MYSQL_DATABASE=mydb MYSQL_USER=root MYSQL_PASSWORD=secret %(prog)s tables @@ -106,7 +136,11 @@ def create_parser(subparsers): "name", nargs="?", default=None, - help="Table/view name (required for some types)", + help=( + "Object name: a table for columns/indexes/foreign-keys/partitions, " + "the object itself for views/triggers/table-options. Refused for " + "tables/database." + ), ) parser.add_argument( "--include-system", @@ -121,6 +155,8 @@ def handle(args): """Handle the introspect subcommand.""" provider = create_provider(args.output, ascii_borders=args.rich_ascii) + _validate_name(args) + named_conn = getattr(args, "named_connection", None) if not named_conn and not args.database: print("Error: --database is required for introspection", file=sys.stderr) @@ -165,6 +201,56 @@ def _serialize_for_output(obj: Any) -> Any: return str(obj) +def _validate_name(args) -> None: + """Refuse a NAME the type cannot use, before anything connects. + + A silently ignored argument is the worst of the three outcomes: the run + succeeds, prints a full listing, and reads as though it had been narrowed. + So the rule table above is applied here, once, instead of being left to + each branch to remember. + """ + rule = _NAME_RULE.get(args.type) + if rule is None: + return + if rule == "required" and not args.name: + print( + f"Error: Table name is required for '{args.type}' introspection", + file=sys.stderr, + ) + sys.exit(2) + if rule == "forbidden" and args.name: + print( + f"Error: '{args.type}' introspection answers for the whole database " + f"and takes no name", + file=sys.stderr, + ) + sys.exit(2) + + +def _require_capability(args, backend) -> None: + """Refuse a family this server cannot answer. + + Runs after ``introspect_and_adapt()``, so the switch answers for the server + in front of us rather than for the newest release this backend knows. A + stub standing in for the backend (the offline CLI tests) may carry no + dialect at all; a real one always does. + """ + switch = _TYPE_CAPABILITIES.get(args.type) + if switch is None: + return + dialect = getattr(backend, "dialect", None) + if dialect is None: + return + supported = getattr(dialect, switch, None) + if supported is None or supported(): + return + print( + f"Error: {args.type} introspection is not supported by this server", + file=sys.stderr, + ) + sys.exit(2) + + def _handle_introspect_sync(args, backend: MySQLBackend, provider): """Handle introspect subcommand synchronously.""" try: @@ -175,6 +261,7 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): # "unknown" at best and fail on a capability probe at worst. The # SQLite CLI adapts after connecting for the same reason. backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector @@ -184,30 +271,28 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views(schema=None) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") + if args.name: + view = introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") + else: + views = introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) columns = introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) indexes = introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) fks = introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") @@ -228,14 +313,9 @@ def _handle_introspect_sync(args, backend: MySQLBackend, provider): ) else: options = introspector.list_table_options(schema=None) - provider.display_results( - _serialize_for_output(options), title="Table options" - ) + provider.display_results(_serialize_for_output(options), title="Table options") elif args.type == "partitions": - if not args.name: - print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) - sys.exit(1) partitions = introspector.list_partitions(args.name, schema=None) provider.display_results( _serialize_for_output(partitions), title=f"Partitions of {args.name}" @@ -265,6 +345,7 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): try: await backend.connect() await backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector @@ -274,30 +355,28 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views(schema=None) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") + if args.name: + view = await introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") + else: + views = await introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) columns = await introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) indexes = await introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) fks = await introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") @@ -318,14 +397,9 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): ) else: options = await introspector.list_table_options(schema=None) - provider.display_results( - _serialize_for_output(options), title="Table options" - ) + provider.display_results(_serialize_for_output(options), title="Table options") elif args.type == "partitions": - if not args.name: - print("Error: Table name is required for 'partitions' introspection", file=sys.stderr) - sys.exit(1) partitions = await introspector.list_partitions(args.name, schema=None) provider.display_results( _serialize_for_output(partitions), title=f"Partitions of {args.name}" From bcfc34fd1e4e764a82488cc65047b532421de40d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 30 Oct 2026 19:03:15 +0800 Subject: [PATCH 60/65] test(cli): pin the type surface and what NAME means for each type Four assertions that would have caught the drift this commit's parent fixes, none of which needs a server: the type list and the capability table name the same families; no removed aggregate is offered; every switch named by the table exists on the dialect; and every declared type has a branch in both the sync and the async path (a type with no branch prints nothing and exits 0, which is the worst shape of all). The NAME rule gets its own cases, and the iews branch is exercised end to end through handle() with a stub backend: a named view is asked for by name, an unknown one exits 1. The partitions test now expects 2 for a missing table name, because that is a request that cannot be served rather than a run that failed. --- .../test_introspection_options_cli.py | 2 +- .../backend/test_introspect_surface.py | 192 ++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py index aa6d2296..de43a286 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py @@ -113,5 +113,5 @@ def test_requires_a_table_name(self, backend_with_tables, capsys): with pytest.raises(SystemExit) as excinfo: main(_cli_args(backend_with_tables, "partitions")) - assert excinfo.value.code == 1 + assert excinfo.value.code == 2 assert "required" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py new file mode 100644 index 00000000..9cf671cc --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py @@ -0,0 +1,192 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py +"""The introspect type surface: what the command offers, and what NAME means. + +No server is needed here. These tests cover the layer that drifted before: a +family can be offered by the parser while no branch answers it, and a NAME can +be accepted by the parser while no code uses it. Both are invisible to a +test that only talks to a live server, because the failure looks like an empty +answer rather than an error. +""" + +import argparse +import inspect + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.cli import introspect as introspect_cli +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +class _Args: + """The two attributes the rules look at, and nothing else.""" + + def __init__(self, type_: str, name: str | None = None) -> None: + self.type = type_ + self.name = name + + +class TestTheTypeSurface: + def test_the_type_list_is_the_capability_table(self): + assert list(introspect_cli._TYPE_CAPABILITIES) == introspect_cli.INTROSPECT_TYPES + + def test_no_removed_aggregate_is_offered(self): + assert "table" not in introspect_cli.INTROSPECT_TYPES + + def test_every_switch_exists_on_the_dialect(self): + for name, switch in introspect_cli._TYPE_CAPABILITIES.items(): + assert hasattr(MySQLDialect, switch), f"{name} names a switch that does not exist" + + def test_every_name_rule_covers_a_declared_type(self): + assert set(introspect_cli._NAME_RULE) == set(introspect_cli.INTROSPECT_TYPES) + + def test_help_lists_every_type(self, capsys): + parser = introspect_cli.create_parser(argparse.ArgumentParser().add_subparsers()) + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + out = capsys.readouterr().out + for name in introspect_cli.INTROSPECT_TYPES: + assert name in out + + def test_every_type_has_a_branch_in_both_paths(self): + """A type with no branch prints nothing and exits 0 -- the worst shape.""" + sync_source = inspect.getsource(introspect_cli._handle_introspect_sync) + async_source = inspect.getsource(introspect_cli._handle_introspect_async) + for name in introspect_cli.INTROSPECT_TYPES: + assert f'args.type == "{name}"' in sync_source, name + assert f'args.type == "{name}"' in async_source, name + + +class TestTheNameRule: + @pytest.mark.parametrize("type_", ["tables", "database"]) + def test_a_name_the_type_answers_whole_is_refused(self, type_, capsys): + with pytest.raises(SystemExit) as excinfo: + introspect_cli._validate_name(_Args(type_, "users")) + + assert excinfo.value.code == 2 + assert "takes no name" in capsys.readouterr().err + + @pytest.mark.parametrize("type_", ["columns", "indexes", "foreign-keys", "partitions"]) + def test_a_required_name_must_be_given(self, type_, capsys): + with pytest.raises(SystemExit) as excinfo: + introspect_cli._validate_name(_Args(type_)) + + assert excinfo.value.code == 2 + assert "required" in capsys.readouterr().err + + @pytest.mark.parametrize("type_", ["views", "triggers", "table-options"]) + def test_an_optional_name_may_be_given_or_not(self, type_): + introspect_cli._validate_name(_Args(type_)) + introspect_cli._validate_name(_Args(type_, "x")) + + +class TestTheCapabilityGate: + def test_a_refused_family_exits_two(self, capsys): + class _Dialect: + @staticmethod + def supports_trigger_introspection() -> bool: + return False + + class _Backend: + dialect = _Dialect() + + with pytest.raises(SystemExit) as excinfo: + introspect_cli._require_capability(_Args("triggers"), _Backend()) + + assert excinfo.value.code == 2 + assert "not supported by this server" in capsys.readouterr().err + + def test_a_stub_without_a_dialect_is_not_refused(self): + """The offline tests stand a stub in for the backend; it has no dialect.""" + introspect_cli._require_capability(_Args("triggers"), object()) + + +class TestTheViewsBranch: + """``views NAME`` answers for one view, and says so when there is no such view.""" + + def _args(self, name: str | None) -> argparse.Namespace: + return argparse.Namespace( + output="json", + rich_ascii=False, + type="views", + name=name, + database="mydb", + is_async=False, + named_connection=None, + include_system=False, + ) + + def test_a_named_view_is_asked_for_by_name(self, monkeypatch): + seen = {} + + class _View: + name = "users_v" + schema = "mydb" + definition = "CREATE VIEW users_v AS SELECT 1" + + class _Introspector: + @staticmethod + def get_view_info(view_name, schema=None): + seen["name"] = view_name + return _View() + + @staticmethod + def list_views(schema=None): # pragma: no cover - must not be called + raise AssertionError("the whole family was listed despite a name") + + class _Backend: + dialect = MySQLDialect() + _connection = None + + def __init__(self, **_kwargs): + self.introspector = _Introspector() + + def connect(self): + pass + + def introspect_and_adapt(self): + pass + + def disconnect(self): + pass + + monkeypatch.setattr(introspect_cli, "MySQLBackend", _Backend) + monkeypatch.setattr( + introspect_cli, "resolve_connection_config_from_args", lambda _args: None + ) + + introspect_cli.handle(self._args("users_v")) + + assert seen["name"] == "users_v" + + def test_an_unknown_view_exits_one(self, monkeypatch, capsys): + class _Introspector: + @staticmethod + def get_view_info(view_name, schema=None): + return None + + class _Backend: + dialect = MySQLDialect() + _connection = None + + def __init__(self, **_kwargs): + self.introspector = _Introspector() + + def connect(self): + pass + + def introspect_and_adapt(self): + pass + + def disconnect(self): + pass + + monkeypatch.setattr(introspect_cli, "MySQLBackend", _Backend) + monkeypatch.setattr( + introspect_cli, "resolve_connection_config_from_args", lambda _args: None + ) + + with pytest.raises(SystemExit) as excinfo: + introspect_cli.handle(self._args("nope")) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err From e1a94a1abe03579bd551e9d82353b05927adfde1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 30 Oct 2026 19:03:37 +0800 Subject: [PATCH 61/65] docs(cli): the introspect types, their names, and why SHOW stays in the library The type table still documented able, which the CLI stopped offering, and it never mentioned the two families that arrived with the storage-option work. NAME gets its own table, because one argument with three meanings is exactly what a reader needs told rather than inferred from examples. introspector.show is recorded where a reader will look for it: nineteen SHOW verbs reachable from the library and deliberately absent from the command line. Most of them are not catalogue metadata (variables, status, processlist, warnings), and the create_* ones return DDL text where columns/views/triggers already give the structured answer -- so adding them would not widen what the command can ask, it would only give the same facts a second door. --- .../mysql_specific_features/introspection.md | 27 +++++++------- .../mysql_specific_features/introspection.md | 27 +++++++------- .../backend/impl/mysql/cli/README.md | 36 +++++++++++++++---- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/docs/en_US/mysql_specific_features/introspection.md b/docs/en_US/mysql_specific_features/introspection.md index bd8963d6..47d3173f 100644 --- a/docs/en_US/mysql_specific_features/introspection.md +++ b/docs/en_US/mysql_specific_features/introspection.md @@ -457,8 +457,8 @@ Supports configuration via command-line arguments or environment variables: ### Querying Table Details ```bash -# Get complete table info (columns, indexes, foreign keys) -python -m rhosocial.activerecord.backend.impl.mysql introspect table users \ +# Describe one view +python -m rhosocial.activerecord.backend.impl.mysql introspect views users_v \ --database mydb --user root # Query only column information @@ -484,16 +484,19 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect triggers users \ ### Introspection Types -| Type | Description | Table Name Required | -|------|-------------|---------------------| -| `tables` | List all tables | No | -| `views` | List all views | No | -| `database` | Database information | No | -| `table` | Complete table details (columns, indexes, foreign keys) | Yes | -| `columns` | Column information | Yes | -| `indexes` | Index information | Yes | -| `foreign-keys` | Foreign key information | Yes | -| `triggers` | Trigger information | Optional | +| Type | Description | Name | +|------|-------------|------| +| `tables` | List all tables | not accepted | +| `views` | List all views, or describe one | optional (view) | +| `database` | Database information | not accepted | +| `columns` | Column information | required (table) | +| `indexes` | Index information | required (table) | +| `foreign-keys` | Foreign key information | required (table) | +| `triggers` | Trigger information | optional (table) | +| `table-options` | Engine, row format, collation and byte sizes of one table | optional (table) | +| `partitions` | One table's partitions | required (table) | + +A name the type cannot use is refused (exit 2) rather than ignored. ### Output Formats diff --git a/docs/zh_CN/mysql_specific_features/introspection.md b/docs/zh_CN/mysql_specific_features/introspection.md index 0ff33226..ec938aab 100644 --- a/docs/zh_CN/mysql_specific_features/introspection.md +++ b/docs/zh_CN/mysql_specific_features/introspection.md @@ -456,8 +456,8 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect tables \ ### 查询表详情 ```bash -# 获取表的完整信息(列、索引、外键) -python -m rhosocial.activerecord.backend.impl.mysql introspect table users \ +# 描述单个视图 +python -m rhosocial.activerecord.backend.impl.mysql introspect views users_v \ --database mydb --user root # 仅查询列信息 @@ -483,16 +483,19 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect triggers users \ ### 内省类型 -| 类型 | 说明 | 是否需要表名 | -|------|------|-------------| -| `tables` | 列出所有表 | 否 | -| `views` | 列出所有视图 | 否 | -| `database` | 数据库信息 | 否 | -| `table` | 表完整详情(列、索引、外键) | 是 | -| `columns` | 列信息 | 是 | -| `indexes` | 索引信息 | 是 | -| `foreign-keys` | 外键信息 | 是 | -| `triggers` | 触发器信息 | 可选 | +| 类型 | 说明 | 名称参数 | +|------|------|---------| +| `tables` | 列出所有表 | 不接受 | +| `views` | 列出所有视图,或描述单个视图 | 可选(视图名) | +| `database` | 数据库信息 | 不接受 | +| `columns` | 列信息 | 必填(表名) | +| `indexes` | 索引信息 | 必填(表名) | +| `foreign-keys` | 外键信息 | 必填(表名) | +| `triggers` | 触发器信息 | 可选(表名) | +| `table-options` | 单表的引擎、行格式、排序规则与字节数 | 可选(表名) | +| `partitions` | 单表的分区 | 必填(表名) | + +类型不接受名称参数时会报错(退出码 2),而不是静默忽略。 ### 输出格式 diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md index b4396e61..bd412455 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md @@ -14,18 +14,29 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OP | Type | Reads | Notes | | :--- | :--- | :--- | | `tables` | `information_schema.tables` | Base tables. `--include-system` adds MySQL's own metadata databases. | -| `views` | `information_schema.views` | Views, with their definitions. | -| `table` | several | One table by name, with its columns, indexes and foreign keys filled in. | +| `views` | `information_schema.views` | Views, with their definitions. `NAME` narrows it to one view. | | `columns` | `information_schema.columns` | One table's columns: name, type, nullability, default. | | `indexes` | `information_schema.statistics` | One table's indexes, including the primary key. | | `foreign-keys` | `information_schema.key_column_usage` | One table's constraints and the columns each side names. | | `triggers` | `information_schema.triggers` | Triggers, optionally narrowed to one table. | -| `table-options` | `information_schema.tables` | The storage facts about a table: engine, row format, collation, auto-increment, comment, and the byte sizes `information_schema` reports. | +| `table-options` | `information_schema.tables` | The storage facts about a table: engine, row format, collation, auto-increment, comment, and the byte sizes `information_schema` reports. `NAME` narrows it to one table. | | `partitions` | `information_schema.partitions` | One table's partitions and subpartitions, in the engine's own order. An empty answer means the table is not partitioned. | | `database` | `information_schema.schemata`, `variables` | Server version, charset, and the databases visible to this role. | -`NAME` is required for `table`, `columns`, `indexes`, `foreign-keys` and -`partitions`. The list types take no name. +### What `NAME` means + +`NAME` is one argument with three possible meanings, decided by the type: + +| Meaning | Types | +| :--- | :--- | +| A **table**, required | `columns`, `indexes`, `foreign-keys`, `partitions` | +| The **object itself**, optional — given, it answers for that one object and exits 1 if there is no such object | `views`, `triggers`, `table-options` | +| **Refused** — the type answers for the whole database | `tables`, `database` | + +A `NAME` a type cannot use is an error (exit 2), never a silently ignored +argument: a run that prints the whole catalogue while the caller believes it +asked about one object is the worst of the possible answers. + ## Options @@ -60,16 +71,27 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OP * **One question per invocation.** A CLI process is one connection that opens, answers, and closes — there is no cache carried between runs, so the answer reflects the server as it is *now*. +* **`introspector.show` is reachable from the library only, not from here.** + Its nineteen methods (`create_table`, `variables`, `status`, `processlist`, + `grants`, `engines`, …) are the raw `SHOW` verbs. Most of them are not + catalogue metadata at all — `variables` is server configuration, `status` + is running counters, `processlist` is current activity, `warnings` and + `errors` are this session's leftovers — and the `create_*` ones return DDL + text where `columns`, `views` and `triggers` already give the structured + answer to the same question. `introspect` asks *what the database holds*; + a second door onto the same facts would only raise the question of which + one is authoritative. ## Examples ```bash # Storage facts and partitions for the order tables python -m rhosocial.activerecord.backend.impl.mysql introspect table-options --database shop +python -m rhosocial.activerecord.backend.impl.mysql introspect table-options orders --database shop python -m rhosocial.activerecord.backend.impl.mysql introspect partitions orders --database shop -o json -# One table in full -python -m rhosocial.activerecord.backend.impl.mysql introspect table orders --database shop +# One view, with its definition +python -m rhosocial.activerecord.backend.impl.mysql introspect views orders_v --database shop # A script asks the question and reads the answer export MYSQL_HOST=localhost MYSQL_DATABASE=shop MYSQL_USER=root MYSQL_PASSWORD=secret From 4244a97705fdca3756cf5644826eba805719f82d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 30 Oct 2026 19:14:27 +0800 Subject: [PATCH 62/65] fix(cli): a missing required name is an incomplete invocation, not a refusal The name rule was enforced with exit 2 in both directions, which put a missing table name in the same bucket as a request the backend declines. It belongs with a missing --database instead: the run never became a question. So 2 keeps the two things this command newly refuses -- answering for a family the server cannot, and accepting a NAME the type has no use for -- and a missing required NAME keeps the code it has had all along, 1. That is also what the other seven backends do, so the rule now reads the same everywhere rather than depending on which CLI you are holding. --- .../activerecord/backend/impl/mysql/cli/introspect.py | 6 +++++- .../introspection/test_introspection_options_cli.py | 2 +- .../feature/backend/test_introspect_surface.py | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index 13696ad2..ffe1087a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -213,11 +213,15 @@ def _validate_name(args) -> None: if rule is None: return if rule == "required" and not args.name: + # The same code a missing ``--database`` gets: an incomplete + # invocation, not a request this backend refuses. 2 is reserved for + # the two things the CLI newly declines to do -- answer for a family + # the server cannot, and accept a NAME the type has no use for. print( f"Error: Table name is required for '{args.type}' introspection", file=sys.stderr, ) - sys.exit(2) + sys.exit(1) if rule == "forbidden" and args.name: print( f"Error: '{args.type}' introspection answers for the whole database " diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py index de43a286..aa6d2296 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py @@ -113,5 +113,5 @@ def test_requires_a_table_name(self, backend_with_tables, capsys): with pytest.raises(SystemExit) as excinfo: main(_cli_args(backend_with_tables, "partitions")) - assert excinfo.value.code == 2 + assert excinfo.value.code == 1 assert "required" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py index 9cf671cc..4cd6237e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py @@ -67,10 +67,15 @@ def test_a_name_the_type_answers_whole_is_refused(self, type_, capsys): @pytest.mark.parametrize("type_", ["columns", "indexes", "foreign-keys", "partitions"]) def test_a_required_name_must_be_given(self, type_, capsys): + """A missing required NAME is an incomplete invocation, not a refusal. + + It exits 1 like a missing ``--database``; 2 is reserved for a request + this command declines to serve. + """ with pytest.raises(SystemExit) as excinfo: introspect_cli._validate_name(_Args(type_)) - assert excinfo.value.code == 2 + assert excinfo.value.code == 1 assert "required" in capsys.readouterr().err @pytest.mark.parametrize("type_", ["views", "triggers", "table-options"]) From 40cd12e0b7adbc793b1c7fa4d729e1d77dcb891f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 30 Oct 2026 20:08:31 +0800 Subject: [PATCH 63/65] test(cli): the surface test spells Optional, so 3.8 and 3.9 can collect it `name: str | None` is PEP 604: fine under `from __future__ import annotations` or on 3.10+, a `TypeError: unsupported operand type(s) for |` at class-body evaluation on 3.8 and 3.9. This package still supports both, and the matrix runs them, so the file failed collection there before a single test ran -- the 3.8/3.9 jobs were the only two red after the pytest-benchmark fix. --- .../feature/backend/test_introspect_surface.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py index 4cd6237e..ec784d98 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py @@ -10,6 +10,7 @@ import argparse import inspect +from typing import Optional import pytest @@ -20,7 +21,7 @@ class _Args: """The two attributes the rules look at, and nothing else.""" - def __init__(self, type_: str, name: str | None = None) -> None: + def __init__(self, type_: str, name: Optional[str] = None) -> None: self.type = type_ self.name = name @@ -108,7 +109,7 @@ def test_a_stub_without_a_dialect_is_not_refused(self): class TestTheViewsBranch: """``views NAME`` answers for one view, and says so when there is no such view.""" - def _args(self, name: str | None) -> argparse.Namespace: + def _args(self, name: Optional[str]) -> argparse.Namespace: return argparse.Namespace( output="json", rich_ascii=False, From 1db54f7c0a825755c67e9d1f80854ddb84d91e68 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 31 Oct 2026 00:40:18 +0800 Subject: [PATCH 64/65] ci: install core from the release branch now that #127 has merged `fix/column-constraint-gates` was squashed into `release/v1.0.0.dev30` as `57439841`, so the branch reference this workflow used to resolve core through no longer names anything ahead of the release. Pointing at the release branch makes each run test what a user installs. The testsuite stays on `fix/column-constraint-gates` on purpose: its own branch carries the two `pytest_compat` shims that keep pytest 9.2 working (the benchmark fixture's `_nodeid` read and xdist's loadgroup suffix), and those are not on the testsuite's release branch yet. That line is repointed when the testsuite merges. --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d8bb4a1..4846a5e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,7 +57,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database @@ -252,7 +252,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database @@ -359,7 +359,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database From b990b7a3eecd931b5c8645b1fab57987d911a92d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 31 Oct 2026 13:21:46 +0800 Subject: [PATCH 65/65] docs(changelog): the fragments for #62 Named after the pull request once it had a number, which is the order the fragment check requires: 4 entries, one per kind of change. --- changelog.d/62.added.md | 1 + changelog.d/62.changed.md | 1 + changelog.d/62.fixed.md | 1 + changelog.d/62.internal.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog.d/62.added.md create mode 100644 changelog.d/62.changed.md create mode 100644 changelog.d/62.fixed.md create mode 100644 changelog.d/62.internal.md diff --git a/changelog.d/62.added.md b/changelog.d/62.added.md new file mode 100644 index 00000000..a403d5ec --- /dev/null +++ b/changelog.d/62.added.md @@ -0,0 +1 @@ +The introspect CLI offers one type per catalogue question and derives the list from `_TYPE_CAPABILITIES`, so a family cannot be listed without a dialect switch behind it; `views NAME` answers for one view. Typed value nodes with MySQL's own literal spellings, and `SHOW` rendered through its own expressions. diff --git a/changelog.d/62.changed.md b/changelog.d/62.changed.md new file mode 100644 index 00000000..6c43ea97 --- /dev/null +++ b/changelog.d/62.changed.md @@ -0,0 +1 @@ +Every catalogue question is a statement class plus a dialect `format_*_query_statement`, and the `SHOW` parameters are stored under their declared names. A `NAME` the type cannot use is refused with exit 2 instead of being ignored, and a missing required name exits 1. diff --git a/changelog.d/62.fixed.md b/changelog.d/62.fixed.md new file mode 100644 index 00000000..223a38f1 --- /dev/null +++ b/changelog.d/62.fixed.md @@ -0,0 +1 @@ +`introspect database` no longer crashes on a fresh session (the parser read a private version that no adaptation had set), the introspect command adapts after connecting, a non-boolean `unsigned` is refused where the type is rendered, and `LIKE` takes its operator enum rather than the SQL keyword. diff --git a/changelog.d/62.internal.md b/changelog.d/62.internal.md new file mode 100644 index 00000000..3fe85f3b --- /dev/null +++ b/changelog.d/62.internal.md @@ -0,0 +1 @@ +The expression round-trip harness is vendored beside the matrix rather than imported from the test suite, the expression area runs in parallel, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged. The test suite stays on its own branch reference until that branch merges.