diff --git a/.claude/architecture.md b/.claude/architecture.md index 940cd88c6..1d03b594d 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -316,14 +316,23 @@ data at construction time. String scanning of SQL is also a classic injection su mismatch between the scanner's assumptions (quote states, escape rules, placeholder characters) and the real dialect syntax silently corrupts the statement. -**The single exception — inline-literal resolution.** When a statement -(`requires_inline_literals() == True`, trusted developer-declared DDL) renders its literal -values inline, the dialect's `format_literal` produces **escaped, quoted atoms**; these are -concatenated directly during formatting and never require re-scanning the assembled SQL. -Because this exception deals with splicing raw values into SQL text, it is a **SQL-injection -hazard by nature**: developers must be highly alert that inline content is trusted-only and -never reachable by end users. `Literal.requires_inline_literals` / the statement-level switch -must default to `False` (bind parameters) for exactly this reason. +**The single exception — inline literals.** A value can be spliced into the SQL text +instead of bound, and the call site asks for that per value: +`Literal(dialect, value, inline=True)`. The dialect then produces **escaped, quoted atoms** +(`inline_sql_literal`), concatenated directly during formatting; nothing re-scans the +assembled SQL. + +Whether to inline is **the caller's decision, not the framework's**. Some engines reject a +statement that carries a bind parameter in a position with no room for one, and a statement +built for such an engine has to inline the values in those positions. That is why the switch +lives on the literal the caller constructs: the developer knows which engine the statement +is for, and the framework cannot infer it. + +The default is bind parameters (`inline=False`), because inlining is a **SQL-injection hazard +by nature**: it splices raw values into SQL text, so inline content must be trusted-only and +never reachable by end users. A formatter must not turn a bound value into text on its own +initiative, and must not refuse a statement merely because it binds where an engine would +prefer text — the caller's choice is the contract. **What happens if you break it (harm):** a scanner (e.g. walking the assembled SQL to replace `?` placeholders) mis-fires on placeholders that appear *inside* string literals, breaks the diff --git a/.claude/code_style.md b/.claude/code_style.md index 66e0b4fc1..439e13358 100644 --- a/.claude/code_style.md +++ b/.claude/code_style.md @@ -655,9 +655,9 @@ def transaction(backend: StorageBackend): - [ ] Expression-dialect separation maintained - [ ] Expression system module organization followed -### RawSQLPredicate / RawSQLExpression +### RawSQLPredicate / RawSQL -`RawSQLExpression` and `RawSQLPredicate` (`backend/expression/operators.py`) embed a raw SQL +`RawSQL` and `RawSQLPredicate` (`backend/expression/operators.py`) embed a raw SQL fragment directly into the query, bypassing the typed expression building mechanism. They are **used internally by the core query builder** and therefore are not categorically forbidden. @@ -677,7 +677,7 @@ SQL fragment strings relies on `RawSQLPredicate` under the hood. #### Security analysis -- **Values are still parameterized.** `RawSQLPredicate`/`RawSQLExpression` bind parameters via +- **Values are still parameterized.** `RawSQLPredicate`/`RawSQL` bind parameters via a separate params tuple, so values are always sent through placeholders and never naively concatenated. The SQL string itself, however, is embedded verbatim. - **The SQL fragment string is trusted input.** The security boundary is: the SQL structure diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md index ad96bb3aa..ed0b5d5a5 100644 --- a/.claude/commands/lint.md +++ b/.claude/commands/lint.md @@ -3,7 +3,22 @@ Run code linting with ruff Run code linting and formatting checks for the project. Execute the following commands: -1. ruff check src/ - Check code style -2. ruff format --check src/ - Check code formatting +1. `ruff check src/` — lint only. `[tool.ruff]` selects E/F/B at line-length 120. +2. Do NOT run `ruff format`. The project has no `[tool.ruff.format]` section, and + formatting is owned by black (`[tool.black]`, line-length 100) plus isort + (`profile = "black"`). `ruff format` would reformat at 120 and fight black. + +On Windows, use the wrapper, which resolves the virtualenv and locates ruff: + +```powershell +./scripts/dev.ps1 lint # ruff check src +./scripts/dev.ps1 lint src tests # explicit targets +./scripts/dev.ps1 fmt # ruff check --fix +./scripts/dev.ps1 fmt --with-black --with-isort +./scripts/dev.ps1 type # mypy src ([tool.mypy] pins python_version = 3.8) +``` + +`ruff`, `mypy`, `black` and `isort` are not installed by default; install them with +`uv tool install ruff mypy black isort` (or add to the virtualenv). Show any violations and suggest fixes. diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 83545e21e..37d0f03bc 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -1,12 +1,18 @@ Run the full test suite for rhosocial-activerecord. -Before running tests, ensure PYTHONPATH is set: -```bash -export PYTHONPATH=src # Linux/macOS -# OR -$env:PYTHONPATH="src" # PowerShell +Before running tests, ensure PYTHONPATH is set (note the `tests` half — it makes +`providers/registry.py` importable; see .claude/testing.md §2): + +```powershell +$env:PYTHONPATH="src;tests" # Windows PowerShell 7 ``` -Then run: pytest tests/ -v +On Windows, prefer the wrapper, which resolves the virtualenv and sets PYTHONPATH for you: + +```powershell +./scripts/dev.ps1 test # serial (AGENTS.md iron rule 1) +./scripts/dev.ps1 test -Parallel # opt-in, mirrors CI: -n auto --dist=loadgroup + plugin +./scripts/dev.ps1 collect # import check only, no tests executed +``` -Show test results and any failures. Focus on failing tests and suggest fixes. +Show the complete unfiltered output. Do not filter with grep or truncate with head/tail. diff --git a/.claude/rules/README.md b/.claude/rules/README.md index 4364105d6..72f8ab0ba 100644 --- a/.claude/rules/README.md +++ b/.claude/rules/README.md @@ -6,10 +6,13 @@ stands alone; read the one that concerns the change in front of you. | Rule | Covers | |---|---| | [expression-direction.md](expression-direction.md) | `expr -> format` and why the reverse direction is where injection and unreviewable inference live. What a factory signature may accept. | +| [expression-node-design.md](expression-node-design.md) | When a grammar construct is a clause node and when it is a parameter. One expression one formatter; counting paths rather than keywords; the four parameter shapes; why a constraint clause is two levels, not a class hierarchy. | | [column-and-result-types.md](column-and-result-types.md) | Designing column classes; the core / backend / user layering; stating a result type the source cannot settle, and why it is a mixin rather than a keyword. | | [operation-contracts.md](operation-contracts.md) | What each operation promises once the ten-backend survey measured it: the string boundary refusals, the rendering-name contracts, the temporal result types, the value-layer echo, and where version gates refuse. | | [static-analysis.md](static-analysis.md) | Making a checker actually check: `py.typed`, a configuration that loads, and the defects that opt out of checking silently. | | [anti-patterns.md](anti-patterns.md) | The catalogue, with what each one cost. Read this before adding a runtime lookup, a coercion helper, or a `__getattr__`. | +| [python-hazards.md](python-hazards.md) | Python spelling and measured surface for the organisation's language-neutral clauses on override order, reflection and enums (expression-dialect.md §11). | +| [introspection.md](introspection.md) | The four introspection rules: nobody spells SQL; core keeps the smallest set and does not arbitrate capability; each backend completes its own semantics; each CLI introspects one connection. | Two rules decide most of the rest: @@ -25,3 +28,4 @@ A third applies to the checks themselves, and it is the one most often skipped: afternoon — four version gates that could not say no, an argument inserted into the wrong call — were all in files that had been edited and not tested. Run the directory. + diff --git a/.claude/rules/expression-node-design.md b/.claude/rules/expression-node-design.md new file mode 100644 index 000000000..b9adaaa2d --- /dev/null +++ b/.claude/rules/expression-node-design.md @@ -0,0 +1,276 @@ +# Expression node design: when a construct is a clause and when it is a parameter + +## The question this answers + +Two things that look alike in a grammar diagram get modelled differently: one +becomes a node with its own formatter, the other becomes a parameter. Guessing +wrong is expensive in both directions — too coarse and every dialect grows a +special case; too fine and the core carries fields nine backends cannot use. + +The test is mechanical. Apply it to each production, recursively. + +Worked reference throughout: SQLite's +[`column-def`](https://sqlite.org/syntax/column-def.html), +[`column-constraint`](https://sqlite.org/syntax/column-constraint.html), +[`table-constraint`](https://sqlite.org/syntax/table-constraint.html), and the +[`WITH` clause](https://sqlite.org/lang_with.html). + +SQLite's syntax pages carry a `References:` field listing the sub-productions +each production actually depends on. That is the evidence for B1, and it is +worth reading before modelling anything. + +## A. One expression, one formatter + +| # | Rule | +|---|---| +| **A1** | Every expression has exactly one formatter. | +| **A2** | That formatter handles that expression **and its subclasses only**. No `Any`, no union, no "may also be a subclass of something else". | +| **A3** | The signature is exactly `(self, expr)`. `expr` is annotated with that expression's own class. No extra parameters — a helper needs a context is a sign the state belongs on an expression that does not exist yet. | +| **A4** | The return is `Tuple[str, tuple]`. A formatter returning bare `str` is doing half a job and forces every caller to re-wrap. | +| **A5** | Dialect formatters are public. A `_format_*` helper in a dialect mixin is a design mistake, not a visibility choice. | +| **A6** | Shared rendering logic lives on the **expression side**, never on the dialect. See "Where shared logic lives" below. | +| **A7** | A formatter lives in the same protocol as its capability switches, and protocols are grouped by feature group — one protocol per constraint level, one per alter-action family, one for the index seam. | + +A3 and A4 are what make A1 checkable. Measured: + +| Function | A1 | A2 | A3 | A4 | Disposition | +|---|---|---|---|---|---| +| `_format_constraint_enforcement(constraint: Any) -> str` | ✗ | ✗ | ✗ | ✗ | **delete**; enforcement is a production-level trait, rendered by the branch's own formatter | +| `_format_constraint_validation(constraint: Any) -> str` | ✗ | ✗ | ✗ | ✗ | **delete**, same | +| `_format_ddl_check_predicate(predicate: Any) -> str` | ✗ | ✗ | ✗ | ✗ | **delete**; the predicate belongs to the check branch | +| `_format_with_clause(ctes_sql: List[str], has_recursive: bool) -> str` | ✗ | ✗ | ✗ | ✗ | **delete**; no `WithExpression` node exists, which is the actual defect | +| `_format_in_value_list(expr: InPredicate, expr_sql, expr_params, literal)` | ✗ | ~ | ✗ | ✓ | **inline into `format_in_predicate`, then delete** — `expr` was correctly typed; the other three parameters are what disqualify it | +| `_format_xml_passing_clause(expressions: List[BaseExpression], mechanism)` | ✗ | ✗ | ✗ | ✓ | **introduce `PassingClause`**, then rename to `format_passing_clause` | + +The last row is the pattern for A3's failure mode: three XML expression classes +(`XMLQueryExpression`, `XMLExistsPredicate`, `XMLTableExpression`) hold the +identical pair `passing` + `passing_mechanism`, and no expression owns that +clause. The shared state has no home, so the formatter takes unpacked pieces. +`PASSING [ BY { VALUE | REF } ] { expr [ AS ident ] } [, ...]` is a named +production — by B1 it should already be a node. + +### Where shared logic lives + +The dialect surface has exactly two members: capability switches +(`supports_*()`, no arguments beyond `self`) and formatters (`format_*(self, +expr)`, A3). There is no third kind, and no name to invent for one. + +So when several expressions want the same rendering, the choice is: + +| Option | Shape | When it is right | +|---|---|---| +| **(a)** The node renders it | the production lives in the expression class; the dialect overrides `format_*(self, expr)` only where the spelling differs | the production belongs to the construct the node models | +| **(b)** The node carries the identity | the node declares *which* production it is (an enum, not a free string); one public `format_*(self, expr)` renders by that identity | the spelling is genuinely the dialect's to choose, and the node's job is to say what it means | + +What is *not* an option: a multi-parameter helper on the dialect. A formatter +that takes `name=` alongside `expr` is not a formatter — it is a rendering +routine whose caller decides the production, so the node that should own that +decision does not exist yet. **The extra parameter is the tell**: it is state +that belongs on a node, and A3 already says so ("a helper needs a context is a +sign the state belongs on an expression that does not exist yet"). + +Delegation is allowed where it stays inside the rule: one `format_*(self, expr)` +may call another `format_*(self, expr)`. It may not call a helper of its own +invention, because A5 and `architecture.md` §8 leave no place to put one, and a +helper no backend can override is a protocol leak by definition. + +The measured case that produced this section: six multi-parameter renderers +(`_format_function`, `_format_scalar_function`, `_format_aggregate_function`, +`_format_niladic_function`, `_format_optional_length_function`, +`_format_window_rank`) each served a family of thin `format_*(self, expr)` +wrappers. The wrappers were formatters; the bodies were not, and being private +they could not be overridden — which is the whole failure A5 names. The bodies +were folded back into the formatters that used them, and the six renderers +deleted. Twenty-five call sites of the same production became twenty-five +bodies, and that duplication is the accepted price: a formatter a backend can +override is worth more than a helper it cannot. + +The same shape appears as the split state of `format_column_fk_constraint`, which +takes the fat union and re-dispatches on `constraint_type` — the reason A1 was +violated in the first place. + +A2 is why a formatter may not take `Any`. If the same helper has to serve a +column constraint and a table constraint, the shared data is on the wrong node. + +A3, measured: `_format_constraint_enforcement(constraint: Any)` served both +`ColumnConstraint` and `TableConstraint`, and +`_format_with_clause(ctes_sql: List[str], has_recursive: bool)` took already +rendered SQL strings — so it corresponded to no expression, and no backend could +override it. Both were private. + +## B. Node or parameter + +| # | Rule | Evidence | +|---|---|---| +| **B1** | A named production, a complex subgraph, or anything the diagram marks `expr`, is a **node**. | `column-constraint References: conflict-clause, expr, foreign-key-clause, literal-value, signed-number` — 7 of its 9 branches carry a sub-production. Only `CONSTRAINT name` and `COLLATE collation-name` are bare. | +| **B2** | A **cycle** must be collapsed into one node, or it cannot be expressed at all. | `select-stmt` contains `common-table-expression`, which contains `select-stmt`. The recursion has to pass through the CTE node; flattened siblings have no way to point back at "my own item". | +| **B3** | Divergent **mandatory** paths are different variants, not a boolean. **Before calling them divergent, check whether one path is a superset of the others** — if it is, there is no divergence and they collapse to it. | See `DEFAULT` below. | +| **B4** | Parentheses are **syntax**. They are emitted by the formatter; they never enter the model. No `parenthesized: bool`. | | + +B1 makes "is it a node?" answerable without taste. `PRIMARY KEY [conflict-clause]` +is a node because it carries `conflict-clause`, not because it is long. + +## C. Count paths, not the nodes on a path + +**Ask first whether what follows is a switch or a payload.** The two answer +different questions, and counting paths only applies to one of them. + +| | Switch | Payload | +|---|---|---| +| Path shape | **several optional** paths | **one mandatory** slot | +| Each path is | a boolean state | a value | +| Parameters | **one per path** (C1) | **one**, whose type drives rendering | +| Example | `[ASC\|DESC]`, `[NOT] MATERIALIZED` | the parenthesised body of `DEFAULT` | + +So `DEFAULT ( expr ) | literal-value | signed-number` is **one parameter**, +not three: the alternatives are payloads, not switches. `[ASC|DESC]` is +**two**, because those are optional switch paths. + +A payload is unified as an expression. The caller wraps a literal: +`DEFAULT 'abc'` and `DEFAULT -1` become `Literal(...)` and render as +`DEFAULT ('abc')` and `DEFAULT (-1)`. + +| # | Rule | Evidence | +|---|---|---| +| **C1** | **The number of parameters is the number of paths.** Never the number of keywords. | | +| **C2** | Several keywords on one path are **one** parameter. | `CREATE TABLE IF NOT EXISTS …` — three keywords, one path, one parameter: `if_not_exists: bool = False`. | +| **C3** | **Mutually exclusive** paths at one position get one parameter each, and the **formatter validates** the exclusion. | `TEMP` / `TEMPORARY` are two paths, so two parameters — even though they are synonyms. Mechanically counting is the rule; collapsing synonyms is not. | + +Measured on SQLite 3.50.4, which is what collapses `DEFAULT`: + +``` +DEFAULT 'abc' -> OK DEFAULT ('abc') -> OK +DEFAULT 1+2 -> REJECT DEFAULT (1+2) -> OK +DEFAULT -1 -> OK DEFAULT (-1) -> OK +``` + +`( expr )` is a **superset** of the other two paths, so there is no divergence +to model (B3). + +C1 applied to what already exists, all of which stay as they are: + +| Construct | Paths | Parameters | +|---|---|---| +| `AS [NOT] MATERIALIZED ( select-stmt )` | 2 | `materialized` / `not_materialized` | +| `[NOT] DEFERRABLE` | 2 | `deferrable` / `not_deferrable` | +| `INITIALLY { DEFERRED \| IMMEDIATE }` | 2 | `initially_deferred` / `initially_immediate` | +| `[ ENFORCED \| NOT ENFORCED ]` | 2 | `enforced` / `not_enforced` | +| `PRIMARY KEY [ASC\|DESC] [conflict-clause] [AUTOINCREMENT]` | 2 + 1 + 1 | `asc` / `desc`, `conflict`, `auto_increment` | + +C3 is not optional. Measured on `_format_constraint_enforcement`, whose last +line was `return "ENFORCED" if enforced else "NOT ENFORCED"`: + +``` +constructor, both flags set -> ValueError (constructor guard holds) +flag injected afterwards -> ' CHECK (0 < "age") ENFORCED' + ^ not_enforced silently dropped +``` + +The constructor guard does not cover deserialisation, a backend subclass, or +attribute injection. Every mutually exclusive pair needs the formatter to check +again as the last line of defence. + +## D. Parameter shape + +| # | Category | Form | Why | +|---|---|---|---| +| **D1** | Mandatory object | Required parameter, no default | Absence is a syntax error. `column-name` cannot be omitted. | +| **D2** | Optional object | **`Optional[X] = None`** | Absence is legal and there is **no natural default object**. | +| **D3** | Switch | Default value, **never `Optional`** | "Off" *is* the default behaviour, so there is something to default to. | +| **D4** | Optional list | **`= ()`**, never `Optional` | The empty collection already means "none". | + +D2 exists because of D3's opposite. Measured on SQLite 3.50.4: + +``` +CREATE TABLE t1(x) -> OK, pragma type = '' <- no type at all +CREATE TABLE t2(x INTEGER) -> OK, pragma type = 'INTEGER' +CREATE TABLE t4() -> syntax error <- column-name cannot go +``` + +A missing type reports as the empty string, not as some sentinel type. A +defaulted `DataType` would therefore state something false. This is the one +place `Optional` is correct in an expression constructor, and it is not the same +thing as a switch. + +## E. Layering and naming + +| # | Rule | +|---|---| +| **E1** | A grammar production that is itself a clause is a **directly formattable node**. It carries whatever the grammar hangs on the production rather than on a branch. | +| **E2** | Each parallel path is that node's **branch**, with its own formatter. | +| **E3** | Prefix by the nature of the **owning parent node**: column → `Column*`, table → `Table*`, domain → `Domain*`. | +| **E4** | E3 extends to enum field types. An enum used at one level takes that prefix; one shared across levels takes none. | + +E1 is why `ColumnConstraint` must not be an abstract base. `column-constraint` +is a clause, so it is formattable. The `Column*Constraint` nodes are its branch +parameters, not its subclasses. + +The grammar decides where the shared data goes. Every backend that spells a +naming prefix puts it on the **production**, not on the branch: + +``` +PostgreSQL column_constraint ::= [ CONSTRAINT constraint_name ] { …branches… } + [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY … ] [ ENFORCED | NOT ENFORCED ] +Oracle inline_constraint ::= [ CONSTRAINT constraint_name ] { …branches… } [ constraint_state ] +Snowflake inlineUniquePK ::= [ CONSTRAINT ] { UNIQUE | PRIMARY KEY } [ ] +``` + +So `name` and the trailing traits live on `ColumnConstraint`, and each branch +carries only what is specific to it. Two levels is not a convenience — it is +where the grammar puts the data. It also removes the `Any` in A2: the shared +traits are rendered in exactly two places, `format_column_constraint` and +`format_table_constraint`, each with one concrete type. + +### Layout + +``` +ColumnDefinition(name, data_type: Optional[…], constraints: List[ColumnConstraint], attributes: List[ColumnClause]) + +ColumnConstraint(dialect, *, branch, # <- the column-constraint clause + name = "", deferrable = False, not_deferrable = False, + initially_deferred = False, initially_immediate = False, + enforced = False, not_enforced = False) + format_method -> "format_column_constraint" + +ColumnConstraintBranch # abstract, never instantiated + ├─ ColumnPrimaryKeyConstraint (asc, desc, conflict, auto_increment) + ├─ ColumnUniqueConstraint (conflict) + ├─ ColumnNotNullConstraint (conflict, no_inherit) + ├─ ColumnNullableConstraint () + ├─ ColumnCheckConstraint (condition) # mandatory + ├─ ColumnDefaultConstraint (value) # mandatory; literal wrapped as Literal + ├─ ColumnForeignKeyConstraint (references) # mandatory + └─ ColumnGeneratedConstraint (expression, stored, virtual) +``` + +Rendering walks the production, then dispatches to the branch: + +``` +format_column_definition + └─ format_column_constraint(c) # once per column-constraint + ├─ [CONSTRAINT name] # production level + ├─ getattr(dialect, c.branch.format_method)(c.branch) + └─ [DEFERRABLE…] [INITIALLY…] [ENFORCED…] # production level, probe-gated +``` + +### A backend-only field stays in the backend + +`PRIMARY KEY [ASC|DESC]` exists in exactly one of the ten backends. The core +`ColumnPrimaryKeyConstraint` carries `asc` / `desc` / `conflict` / +`auto_increment`; SQLite derives `SQLitePrimaryKeyConstraint` and adds what +only it has. "Accommodating the backend" means the core provides an extension +point that a backend can extend — not that the core grows a field nine backends +cannot use. + +## Catalogue: what this replaced + +| Anti-pattern | Why it was wrong | +|---|---| +| One `ColumnConstraint` class with a `ColumnConstraintType` tag and sixteen parameters | A fat tagged union. Required for `CHECK`, meaningless for `PRIMARY KEY`, and the docstring admitted it: "requesting them on one is accepted and simply never rendered". | +| `TableConstraint` shaped the same way | Same, plus `Any`-typed private formatters serving both families. | +| `is_auto_increment` on the core `ColumnConstraint` | A single-backend field on a shared node, silently dropped by backends that could not render it. | +| `format_column_constraint` dispatching on `constraint_type` | A1: one formatter, seven expressions. | +| `_format_constraint_enforcement(constraint: Any)` | A2 and A3. | +| `_format_with_clause(ctes_sql: List[str], …)` | Corresponded to no expression; took rendered strings, so no backend could override it. | +| `Optional[bool]` for switches | D3. | +| `Optional[List[X]] = None` then `list(x or [])` | D4. `None` and `[]` are the same thing here. | diff --git a/.claude/rules/introspection.md b/.claude/rules/introspection.md new file mode 100644 index 000000000..fd24e9527 --- /dev/null +++ b/.claude/rules/introspection.md @@ -0,0 +1,103 @@ +# Introspection + +Four rules decide how introspection is built. They are judgements, not style +preferences: when an implementation disagrees with one of them, the +implementation changes. + +## 1. Nobody spells SQL. This is absolute. + +Production code contains no whole SQL statement and no SQL fragment built with +an f-string or `+`. Table names, column names, values, conditions and joins go +through the expression nodes (`column`, `relation`, `literal`, `compare`, +`join`, `select_sql`, ...). SQL text lives in one place only: the dialect's +`format_*` methods. + +Unit tests may write SQL by hand, to build a fixture or to check what the +database actually returns. That exemption stops at the test boundary: a query +that a test spelled may not become the production path, and it may not be +"factored out" into a shared helper later. + +When the expression layer cannot express an introspection question, the answer +is to add what is missing -- a node, a statement class, a formatter -- not to +fall back to a string inside the formatter. A gap is a missing class, not a +licence to spell SQL. + +## 2. Core keeps the smallest set, and does not arbitrate capability. + +Core carries only the families an ActiveRecord layer generally cares about and +most engines can answer: database information, tables, views, columns, indexes, +foreign keys, triggers. Partitions and schemas/catalogs are optional. + +Core maintains no capability list. `supports_*` is a backend stating a fact +about itself; anything not declared is treated as unsupported. A backend that +cannot answer a question says so (`UnsupportedFeatureError`) and its CLI does +not offer the type -- it never returns a successful-looking answer about the +wrong objects. + +The table-kind vocabulary is a different thing and does live in core: +`TableType` names what a catalogue can report (a base table, a view, a +materialized view, a partitioned table), and each backend maps its own +spelling onto it. Spellings that belong to one engine -- BigQuery's `CLONE` +and `SNAPSHOT`, ClickHouse's engines, Snowflake's `MANAGED` and `EVENT` +tables -- ride in `extra` beside the member they map to instead of widening +the enum. The rule is the same in both cases: the introspector is the only +layer that saw the catalog's distinction, so nothing may be folded away there +and recovered later. + +## 3. Every backend completes its own semantics. + +An introspection class derives from core, and that derivation is the floor, not +the ceiling. The backend is responsible for asking *its* catalogue everything +its own model can answer. + +Objects a given engine has and core does not -- Firebird generators and +domains, Oracle sequences, synonyms and packages, ClickHouse partitions and +storage definitions, BigQuery datasets, SQL Server schemas, extended +properties, procedures and functions -- get the backend's own statement +classes, formatters, manager factories and parsers. They are not squeezed into +core, and they are not hidden inside private helpers. + +"Complete" means the engine can answer every question it is capable of +answering. It does not mean copying another backend, and it does not mean +deleting a fact that only this engine has in order to match core. + +## 4. Every backend CLI can introspect, on one connection. + +Each backend CLI has an `introspect` subcommand covering every question that +makes sense for the connection it was given. + +That qualifier is the real difference: the connection arguments already name a +database (a file, a server, a port, a database), so cross-database and +cross-schema questions usually have no meaning from a CLI. A backend exposing +`list_tables(schema=...)` does not imply a `--schema` flag. Conversely, a type +listed by the CLI must actually be answerable -- never an option that ends in +`NotImplementedError`. + +The type list is therefore owned by each backend (`INTROSPECT_TYPES` beside its +`introspect` command). It is a subset or a reshaping of the backend's +capability for the single-connection case, not a mirror of it, and core does +not arbitrate it. + +## What follows for the code + +- A statement collects all of its parameters at construction. There are no + chained setters (`with_schema`, `for_table`, ...): a chain hides the final + parameters from the IDE and from static analysis, which is exactly what + one-shot construction exists to prevent. +- A question is three existing things, never a fourth: a statement class, a + dialect `format_*_query_statement`, and a public manager method pair + (`_statement` / `parse_`). +- Names carry no underscore. A method that would be private is either a + public contract or a class that has not been written yet. + +## Checking it + +Three mechanical checks belong in the shared test suite, one call per +repository: + +1. no `def _name` inside the introspection packages (`__init__` and other + dunders excepted); +2. no string literal that starts a statement (`SELECT`, `INSERT`, `UPDATE`, + `DELETE`, `WITH`) and no f-string containing one; +3. every family declared by `supports_*_introspection()` has a matching + `format_*_query_statement` that renders non-empty SQL. diff --git a/.claude/rules/operation-contracts.md b/.claude/rules/operation-contracts.md index 9828e76c0..68d26fda4 100644 --- a/.claude/rules/operation-contracts.md +++ b/.claude/rules/operation-contracts.md @@ -113,8 +113,8 @@ never a silent substitution of a different question: time anyone could vote on it, so no hook can reach it. The worked example is `TrimExpression` -- the core factory builds the node, `TrimMixin` renders the standard form, and SQLite overrides `format_trim_expression` because it parses - no `trim(... from ...)` syntax at all. `ILIKEExpression` and `ArrayExpression` - follow the same shape. A factory reaching for `RawSQLExpression` (or a + no `trim(... from ...)` syntax at all. `ILikePredicate` and `ArrayExpression` + follow the same shape. A factory reaching for `RawSQL` (or a `WildcardExpression` standing in for `COUNT(*)`'s star) is a bug in review terms, not a shortcut. - Capability statements: this file, plus the docstring of the method that diff --git a/.claude/rules/python-hazards.md b/.claude/rules/python-hazards.md new file mode 100644 index 000000000..c0e5574a9 --- /dev/null +++ b/.claude/rules/python-hazards.md @@ -0,0 +1,93 @@ +# Python syntax for the organisation-level clauses + +> The clauses themselves are language-neutral and live in the organisation's +> `.claude/rules/expression-dialect.md` §11 -- override order, the reflection +> ban, enum-first. This file records only the Python spelling of each and the +> measured surface in this repository. + +| Clause | Python spelling | +|---|---| +| §11.1 override order | `class D(SQLiteJoinMixin, SQLDialectBase)`: the base list left to right *is* the MRO, so `super()` follows it | +| §11.2 reflection ban | `getattr` / `hasattr` may not probe an expression for a field; assert to `BaseExpression`, never to a method | +| §11.3 enum-first | `class U(str, Enum)`; inheriting `str` keeps the member usable wherever a string is expected | + +--- + +## 1. Override order (clause §11.1) + +Measured surface: **1** violation. + +| Site | What | +|---|---| +| `impl/sqlite/expression/json.py` `SQLiteJsonExtractFunction` | spells `AliasableMixin, ComparisonMixin, TypeCastingMixin, SQLValue` by hand instead of deriving from the core JSON-extraction node, so the two sides drift when the core gains an operation | + +Eighteen SQLite classes derive from a bare `BaseExpression` / `SQLValue` / +`SQLPredicate` and that is **correct**: `ATTACH`, `DETACH`, `REINDEX`, `VACUUM`, +`ANALYZE`, the virtual table statements and the FTS5 / R*Tree / Geopoly families +are concepts no other backend has and no core node describes. §11.1 exempts them +— the test is whether you can name the core node being specialised. + +`impl/sqlite/dialect.py` already keeps the name mixins ahead of `NamespaceMixin` +and documents why; the same discipline is what the other mixins need. + +--- + +## 2. Reflection ban (clause §11.2) + +**`getattr(x, "f", None)` / `hasattr(x, "f")` on an expression is forbidden.** + +| Instead of | Declare | +|---|---| +| `getattr(self, "alias", None)` | `alias: Optional[str] = None` on `BaseExpression` | +| `hasattr(other, "to_sql")` | `isinstance(other, BaseExpression)` | +| `getattr(self.dialect, name, None)` | `format_method` names it; absent ⇒ `UnsupportedFeatureError` | +| probing `_{name}` in `get_params()` | store each `__init__` parameter under its own name | + +**`get_params()` is the exhibit.** It used to probe `_{name}` then `name`, warn, and +**skip the parameter** -- a serialization path deciding what to record by trial and +error, and putting the omission in the artifact with nothing to show for it. It is +now `getattr(self, name)`: the convention is enforced by +`feature/expression/test_artifact_contract.py` and by the `**kwargs` ban in +`feature/expression/test_kind_naming.py`, so a parameter the class did not store +raises `AttributeError` naming the class and the parameter rather than warning. + +Two exemptions, neither a discovery: `getattr` inside `__getattr__` itself, and +`getattr(module, name)` on the kind registry where the name is already part of +the artifact's wire contract. + +Measured surface: **205** `getattr`/`hasattr` sites under `backend/` — +`named_expression/procedure.py` (24), `type_adapter.py` (15), +`migration/cli.py` (13), `expression/mixins.py` (10), `expression/bases.py` (8). +The expression and dialect layers are the ones that must reach zero. + +--- + +## 3. Enum-first (clause §11.3) + +**A parameter with a closed value set takes an Enum, not a string and not a +module-level tuple / frozenset / dict.** `str` as the base keeps the member +usable wherever a string is expected. + +Exempt: compiled regexes and fragments, numeric defaults, and lazily built +caches keyed by classes — the last is the same exemption §11.2 makes for the +registry. + +Measured surface: **34** private constants under `expression/` and `dialect/`, +counting class-level ones and not only module-level. **9** are enumerable +domains; the other 25 are exempt. + +| Site | Domain | Count | +|---|---|---| +| `expression/query_sources.py:105` `_VALID_OPS` | the four compound-SELECT word forms | 4 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `dialect/mixins/set_operation.py:15` `_OP_SYMBOLS` | the same four, mapped to their SQL keyword | 4 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `expression/mixins.py:850` `_OPERATOR_WORDS` | Python operator symbol -> operation word | 3 | +| `expression/query_parts.py:238` `_VALID_DIRECTIONS` | sort direction | 2 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `dialect/mixins/dql.py:160` `_VALID_ORDER_DIRECTIONS` | sort direction, **the same set declared a second time** | 2 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `expression/query_parts.py:239` `_VALID_NULLS` | where nulls sit in an ordering | 2 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `expression/uuid.py:115` `_KINDS` | which UUID constant | 2 | — **done**: `SetOperationKind` / `SortDirection` / `NullsOrder` / `UuidConstantKind` +| `expression/datetime.py:61` `DATETIME_FIELD_ALIASES` | field aliases collapsing `"yyyy"`/`"yy"`/`"years"` | 24 | — keyed by `DateTimeField` | +| `expression/datetime.py:76` `INTERVAL_UNIT_ALIASES` | unit aliases, same shape | 22 | — keyed by `IntervalUnit` | + +The sort-direction set was declared in the expression layer *and* again in the +dialect layer with nothing keeping them in step. One Enum removes the duplication +and makes a fifth direction impossible to add to only one of them. \ No newline at end of file diff --git a/.claude/skills/dev-backend-development/SKILL.md b/.claude/skills/dev-backend-development/SKILL.md index 113a439bf..212b19077 100644 --- a/.claude/skills/dev-backend-development/SKILL.md +++ b/.claude/skills/dev-backend-development/SKILL.md @@ -283,13 +283,18 @@ is a second, unreliable parse of data the formatter already had as typed values. of SQL is also an injection surface: a mismatch between scanner assumptions and real dialect syntax silently corrupts the statement. -**The single exception — inline-literal resolution.** When a statement -(`requires_inline_literals() == True`, trusted developer-declared DDL) renders literal values -inline, the dialect's `format_literal` produces **escaped, quoted atoms** concatenated directly -during formatting — no re-scanning of the assembled SQL. Because this exception splices raw -values into SQL text, it is a **SQL-injection hazard by nature**: developers must be highly -alert that inline content is trusted-only and never reachable by end users, and the inline -switch must default to `False` (bind parameters). +**The single exception — inline literals.** A value can be spliced into the SQL text +instead of bound, and the call site asks for that per value: +`Literal(dialect, value, inline=True)`. The dialect then produces **escaped, quoted atoms** +(`inline_sql_literal`) concatenated directly during formatting — no re-scanning of the +assembled SQL. + +Whether to inline is **the caller's decision, not the framework's**: some engines reject a +statement carrying a bind parameter in a position with no room for one, and the developer +knows which engine the statement is for. The default is bind parameters (`inline=False`), +because inlining is a **SQL-injection hazard by nature** — inline content must be trusted-only +and never reachable by end users. A formatter must not inline on its own initiative, and must +not refuse a statement merely because it binds where an engine would prefer text. ## Type Adaptation diff --git a/.claude/skills/dev-expression-dialect/SKILL.md b/.claude/skills/dev-expression-dialect/SKILL.md index 3e6bb7412..a1c91ebe0 100644 --- a/.claude/skills/dev-expression-dialect/SKILL.md +++ b/.claude/skills/dev-expression-dialect/SKILL.md @@ -1,6 +1,6 @@ --- name: dev-expression-dialect -description: Architecture guide for the Expression-Dialect separation system in rhosocial-activerecord - expression modules, dialect protocols/mixins, SQL generation rules, and how to add new protocols +description: Architecture guide for the Expression-Dialect separation system in rhosocial-activerecord - expression modules, dialect protocols/mixins, SQL generation rules, when a backend may derive an expression class, and how to add new protocols license: MIT compatibility: opencode metadata: @@ -11,12 +11,9 @@ metadata: # Expression-Dialect System -Explains the architecture that separates SQL **query construction** (expressions) from SQL -**generation** (dialects), enabling database-agnostic building with per-database formatting. - -## Core Principle - -**Expression defines structure. Dialect generates SQL.** +The architecture that separates SQL **query construction** (expressions) from SQL +**generation** (dialects), so the same query builds portably and each database +renders its own words. ``` Query (ActiveQuery) @@ -28,282 +25,461 @@ Dialect (SQLiteDialect) ← generates SQL Backend (StorageBackend) ``` -## Golden Rule +Information flows **one way**, from a typed object to SQL text: -**NEVER** concatenate SQL strings in Expression classes: -```python -# WRONG -return f'"{table}"."{column}"' - -# CORRECT -return self.dialect.format_column_reference(table, column) ``` +caller builds an expression → factory composes expressions → dialect renders + Column / Literal / ... FunctionCall format_* +``` + +Two rules decide most of the rest: + +1. **Information flows one way.** A formatter needs an expression to have + anything to format, so **no formatting function may manufacture the + expression it is about to render** — not by guessing, coercing, or parsing. +2. **A backend is entirely static.** An IDE and a type checker give a definite + answer. Nothing is worked out at run time, and nothing is left for the caller + to assert in a form a checker cannot read. + +--- + +## 1. The only two semantic entry points -## Backend Boundary Rule - -**ActiveRecord is the user of the backend; the backend never knows ActiveRecord exists.** The -dialect/expression layer describes **generic SQL and the concrete capabilities of the target -database** — it is never a description of, or servant to, the ActiveRecord layer above it. -Consequently ActiveRecord must adapt to the backend: it consumes whatever protocol/interface the -backend provides. If ActiveRecord needs a feature that requires backend cooperation, the feature -must be **negotiated as a protocol/interface contract** on the backend's own generic terms — never -by bending the backend's design to fit ActiveRecord. - -Forbidden (all absolutely wrong): -- Adding ActiveRecord-serving helpers to the dialect, e.g. `ddl_inline`, `inline_params`, or any - parameter-inlining / SQL-rewriting machinery whose only purpose is to support an ActiveRecord - DDL feature. -- Making the dialect infer intent from **string characteristics** (regex matches, prefix/suffix - checks, statement-type sniffing from leading keywords, etc.). The dialect never interprets SQL - text to guess what a caller meant. - -## The Only Expression Semantics Are `to_sql()` and `dialect.format_*()` - -The expression layer exposes exactly two kinds of semantic entry points and nothing else: - -1. **`to_sql()`** — the single method on every expression/predicate/statement object. -2. **`dialect.format_*()`** — the single family of formatting functions on the dialect. - -**IRON RULE — `to_sql()` accepts NO parameters. Absolutely never.** `to_sql()` is a *terminal* -method: every value it needs was already collected when the expression was **instantiated**. The -signature is fixed by `ToSQLProtocol` as `to_sql(self) -> SQLQueryAndParams` and **never takes -arguments of any kind** — no `to_sql(inline_literals=...)`, no keyword arguments, no rendering -options. Violating this is a protocol violation and is unacceptable. - -**IRON RULE — `format_*()` formatting functions accept expression-class instances only, never -additional formatting parameters.** A formatting function receives the expression/predicate it -must render (and the dialect), and nothing else. There is no `format_*(..., inline_literals=...)`, -no rendering flags, no per-call formatting options on the formatting function either. - -**Consequence — every formatting choice is a construction-time (factory) choice.** If an output -format must differ (e.g. a `Literal` inside a DDL clause that accepts no bind parameters must be -rendered as inline SQL text rather than a placeholder), that choice is **captured when the -expression is instantiated** — the expression class collects it as a constructor parameter and -carries it, so both `to_sql()` and `format_*()` are pure reads of already-collected state. - -**No other semantic methods are permitted.** In particular, **`render*()` methods, -`to_sql_inline()` / `to_sql_inline_literals()` and any other "variant rendering" entry points are -absolutely forbidden.** There is exactly one `to_sql()` and exactly one `format_*` per concern. - -If a render-time choice appears necessary, the expression was constructed wrong: the choice -belongs at **construction** time, never in `to_sql()` and never as a formatting-function parameter. - -**No private functions in backend protocol/implementation — everything is transparent.** The -backend needs no private helper functions (no `_render_*`, no internal `_format_*` / recursion -helpers). All behaviour is reachable through the public surface: `to_sql()` on expressions, -`format_*()` / `supports_*()` on the dialect, the expression/statement classes, the type adapters. -**If you feel the urge to add a private function, you are doing something wrong — stop.** A private -function either re-implements / pre-empts public protocol behaviour (a protocol leak that bypasses -the negotiated, backend-overridable interface) or hides a design gap behind an unexposed shortcut. -The fix belongs in the public protocol (or in how expressions are **constructed**), never in a -private helper. - -**Why this rule exists:** the backend's power comes from a *known, negotiated* public surface — -backends override public `format_*()`/`supports_*()`, expressions and callers consume public -`to_sql()`. A private helper is invisible to that contract: it can't be overridden by a backend -that needs different behaviour, isn't covered by protocol tests, and hides where behaviour lives. -**What happens if you break it (harm):** a private render helper silently forks behaviour — core -renders one way, a backend unaware of the helper renders another, no protocol test catches the -divergence. The helper becomes a hidden second protocol; later work duplicates it (drift) or -"fixes" it per-backend (fragmentation). The urge to add a private function is a reliable signal -the public protocol is missing a deliberate capability — hiding it guarantees the gap festers. - -## Backend Protocol Changes Require Extreme Restraint, Deep Deliberation, Broad Research - -The backend protocol is a **shared contract implicated across every backend** (core + all -extension backends). Changing any part of it is a large, cross-cutting project. **Before touching -backend protocol: (1) broadly research every backend's implementation/tests and validate against -real servers; (2) deliberate — ensure the change is minimal, generic, and composes with every -existing `format_*()`/`supports_*()`/expression contract; (3) negotiate, then implement across -the generic layer and every backend together, keeping behaviour identical everywhere.** - -**Why:** the protocol is the system's nervous system — every backend dials into the same seams -(`to_sql()`, `format_*()`, `supports_*()`, expression/statement classes). A hasty protocol change -(e.g. inventing a new render helper, parameterizing `to_sql()`/`format_*()`) commits every backend -to behaviour that was never surveyed; backends drift as each "fixes" it differently; the shared -contract fractures and forces costly retrofits. **This is exactly what repeatedly poisoned the -abandoned DDL-derivation work** (`ddl_inline`, `inline_params`, string-sniffing, invented render -helpers) — each was a protocol shortcut with catastrophic cross-backend cost. - -## Formatting Is Pure Concatenation — Never Touch or Scan the SQL String - -Backend expressions and formatting functions build SQL by **simple concatenation** of fragments: -identifiers (via `format_identifier`), literal atoms (via `format_literal`), placeholders, and -pre-rendered sub-fragment SQL. They **must never inspect, parse, scan, or rewrite the SQL string -itself** — no `startswith`/`endswith`/`in` checks on generated SQL, no regex matching, no -character-by-character scanning, no quote-state machines, no post-hoc placeholder substitution. - -**Why:** generated SQL is the *output* of formatting, not its input. Logic that reads the -generated string is guessing at structure the formatter already knew as typed data at -construction time — a second, unreliable parse. String scanning of SQL is also an injection -surface: any mismatch between the scanner's assumptions and the real dialect syntax silently -corrupts the statement. - -**The single exception — inline-literal resolution.** When a statement -(`requires_inline_literals() == True`, trusted developer-declared DDL) renders literal values -inline, the dialect's `format_literal` produces **escaped, quoted atoms** concatenated directly -during formatting — no re-scanning of the assembled SQL. Because this exception splices raw -values into SQL text, it is a **SQL-injection hazard by nature**: developers must be highly -alert that inline content is trusted-only and never reachable by end users, and the inline -switch must default to `False` (bind parameters). - -**What happens if you break it (harm):** a placeholder scanner mis-fires on `?` appearing inside -string literals, breaks when a dialect's quoting/placeholder syntax differs, and duplicates -knowledge the formatter already had — solving a problem pure concatenation never creates. - -## Advantages Over Other Approaches - -- No complex state management — expressions are stateless and pure -- Only 2 steps from expression to SQL; no multi-layer compilation -- Dummy backend is a complete SQL-standard reference; other dialects only override differences -- Test-friendly — SQL generation testable without a database connection -- Fragment generation — any expression can generate SQL independently -- Explicit control — full visibility into when database operations occur - -## Relationship Model +The expression layer exposes exactly two kinds of semantic entry point, and +nothing else. +**`to_sql()`** — the single method on every expression / predicate / statement. + +> **IRON RULE — `to_sql()` accepts NO parameters. Absolutely never.** It is a +> terminal method: every value it needs was already collected when the +> expression was **instantiated**. The signature is fixed by `ToSQLProtocol` as +> `to_sql(self) -> SQLQueryAndParams`. No `to_sql(inline_literals=...)`, no +> keyword arguments, no rendering options. + +**`dialect.format_*()`** — the single family of formatting functions. + +> **IRON RULE — a `format_*()` accepts expression-class instances only, never +> additional formatting parameters.** It receives the expression it must render +> (and the dialect), and nothing else. No rendering flags, no per-call options. + +**Consequence — every formatting choice is a construction-time choice.** If an +output must differ (a `Literal` in a DDL clause that takes no bind parameters has +to render inline), that is captured **when the expression is instantiated**: the +class collects it as a constructor parameter and carries it, so `to_sql()` and +`format_*()` are pure reads of already-collected state. A render-time choice that +seems necessary means the expression was constructed wrong. + +### An expression collects; a formatter validates + +An expression **collects its parameters and nothing more.** Its `__init__` +accepts them, stores them, and stops. It does **not** check them, does **not** +normalize them, and does **not** raise on a value a particular dialect might +reject. + +**Validation belongs to the formatter**, because only the formatter knows the +dialect that has to render the expression: + +```python +# WRONG — the expression decides, before any dialect is consulted +def __init__(self, dialect=None, *, unsigned: bool = False): + if not isinstance(unsigned, bool): + raise TypeError("unsigned must be a bool") + self.unsigned = unsigned + +# RIGHT — the expression collects; the dialect decides what it can honour +def __init__(self, dialect=None, *, unsigned: bool = False): + self.unsigned = unsigned + +def format_data_type_integer(self, data_type) -> Tuple[str, tuple]: + self._check_spelling(data_type, IntegerType) # dialect-level gate + self._refuse_unsigned(data_type, "INTEGER") # honour or refuse, by name + ... ``` -Expression.to_sql() -> Dialect.format_*() -> SQL string and parameters + +**Why.** An expression is dialect-independent and knows nothing about which +server will render it. A check placed in `__init__` can only answer a +dialect-blind question ("is this a bool?"), while the question that actually +matters — "can **this** backend honour this value?" — can only be answered by +the formatter that knows the backend. Putting the check where the knowledge +lives keeps one answer per question, and it is the answer a reader needs. + +A type annotation already states the shape; a checker verifies it statically. +A second, run-time statement of the same fact is the kind of thing +`anti-patterns.md` lists as *disabling checking rather than satisfying it*, and +it is the reason so many expressions carry a raise that the annotation had +already made impossible. + +**The corollary — collect completely at instantiation.** Because nothing is +checked and normalized later, every value the rendering will need must be a +constructor parameter. A formatter that reaches back for a value the +constructor did not take has found a design error: either add the parameter, or +the choice was never the expression's to make. + +### No probing: an attribute is either declared or it is an error + +**An expression class *is* the protocol.** Its signature declares the fields it +carries, and a formatter is written against that declaration. So whether a field +exists is **statically settled**: either the core class declares it (every +subclass has it, because a subclass may not rewrite the core signature — see +"Protocol B" below), or no class in the family has it. + +That is why a formatter **reads the attribute directly**: + +```python +# WRONG — probes for something that is settled at class-definition time +if getattr(data_type, "unsigned", False): + ... + +# RIGHT — the field is declared; its presence is not in question +if data_type.unsigned: + ... ``` -## Key Components - -1. **Expression formatting**: `BaseExpression` subclasses build query structure and delegate - formatting to dialect methods. -2. **Type adaptation**: plain Python values passed as parameters (e.g. `datetime` in WHERE) are - converted by the **backend** via `SQLTypeAdapter` (see `dev-backend-development`). - -## Expression System Modules - -Located under `backend/expression/`: -- `bases.py` — abstract base classes and protocol definitions -- `core.py` — core components (columns, literals, function calls, subqueries) -- `literals.py` — literal value expressions -- `executable.py` — executable statement expressions -- `mixins.py` — operator-overloading capabilities (incl. `AliasableMixin.as_`) -- `operators.py` — binary/unary/arithmetic expressions -- `predicates.py` — WHERE-clause predicates -- `query_parts.py` — query clauses (WHERE, GROUP BY, HAVING, ORDER BY, ...) -- `statements/` — DML/DQL/DDL statements (directory package: `ddl_*`, `dml`, `dql`, `explain`) -- `functions/` — standalone factory functions (directory package) -- `aggregates.py` — aggregation expressions/functions -- `advanced_functions.py` — CASE, CAST, EXISTS, window functions -- `query_sources.py` — VALUES, table functions, CTEs -- `graph.py` — Graph Query (MATCH) -- Additional: `collation.py`, `datetime.py`, `serialization.py`, `transaction.py`, - `introspection.py`, `xml.py`, `types/` - -**Limitation**: the system builds SQL per user intent but does **not** validate standard -compliance or executability — that is the database engine's responsibility. - -## Dialect Protocol-Mixin Architecture - -- **Protocols** (`protocols.py`): interface contract — what a dialect must implement. -- **Mixins** (`mixins.py`): default SQL-standard implementations dialects can override. -- Every dialect inherits: `SQLDialectBase` + relevant Mixins + relevant Protocols. - -### Current Protocols and Mixins (main package) - -| Protocol | Mixin | Feature | -|----------|-------|---------| -| `WindowFunctionSupport` | `WindowFunctionMixin` | Window functions (OVER, PARTITION BY) | -| `CTESupport` | `CTEMixin` | CTEs (WITH clause) | -| `AdvancedGroupingSupport` | `AdvancedGroupingMixin` | ROLLUP, CUBE, GROUPING SETS | -| `ReturningSupport` | `ReturningMixin` | RETURNING clause | -| `UpsertSupport` | `UpsertMixin` | UPSERT (ON CONFLICT) | -| `LateralJoinSupport` | `LateralJoinMixin` | LATERAL joins | -| `ArraySupport` | `ArrayMixin` | Array types/operations | -| `JSONSupport` | `JSONMixin` | JSON types/operations | -| `ExplainSupport` | `ExplainMixin` | EXPLAIN | -| `FilterClauseSupport` | `FilterClauseMixin` | FILTER clause | -| `OrderedSetAggregationSupport` | `OrderedSetAggregationMixin` | WITHIN GROUP (ORDER BY) | -| `MergeSupport` | `MergeMixin` | MERGE | -| `TemporalTableSupport` | `TemporalTableMixin` | FOR SYSTEM_TIME | -| `QualifyClauseSupport` | `QualifyClauseMixin` | QUALIFY | -| `LockingSupport` | `LockingMixin` | FOR UPDATE, SKIP LOCKED | -| `GraphSupport` | `GraphMixin` | Graph queries (MATCH) | -| `JoinSupport` | `JoinMixin` | JOIN operations | -| `SetOperationSupport` | `SetOperationMixin` | UNION, INTERSECT, EXCEPT | -| `ILIKESupport` | `ILIKEMixin` | Case-insensitive LIKE | -| `TableSupport` | `TableMixin` | CREATE/DROP/ALTER TABLE | -| `ViewSupport` | `ViewMixin` | CREATE/DROP VIEW | -| `TruncateSupport` | `TruncateMixin` | TRUNCATE TABLE | -| `SchemaSupport` | `SchemaMixin` | CREATE/DROP SCHEMA | -| `IndexSupport` | `IndexMixin` | CREATE/DROP INDEX | -| `SequenceSupport` | `SequenceMixin` | CREATE/DROP/ALTER SEQUENCE | -| `TriggerSupport` | `TriggerMixin` | CREATE/DROP TRIGGER (SQL:1999) | -| `FunctionSupport` | `FunctionMixin` | CREATE/DROP FUNCTION (SQL/PSM) | - -### Where to Add Features — Generic vs Concrete (Decision Rule) - -**The goal is for the generic (core) layer to implement as much as possible, to minimise backend -work.** Decide placement by how *broad* a feature's base is: - -1. **Has broad base across databases → lift to the generic layer.** The feature exists (with - standard semantics) in many databases. Add: a `XxxSupport` protocol, a `XxxMixin` with the - generic implementation, and `supports_xxx()` capability detection. Backends simply compose the - pair and inherit the generic behaviour. -2. **No broad base (e.g. table partitioning, vendor-proprietary features) → each backend - implements its own**, and **prefixes its classes with the backend name** - (`MySQLRangePartition`, `PostgresRangePartition`, `OracleListPartition`, …). The core provides - at most an empty marker base (`PartitionSpec`); each backend claims its own via `build_spec` - / `isinstance`. There is no core "generic" implementation for a non-generic feature. -3. **If a generic-layer expression is supported but its generic implementation does not fit a - concrete backend's needs, that backend simply overrides the affected method(s)** — this is - the "Override Discipline (Generic-First)" rule below. Overriding is the escape hatch for - correctness; it is not a reason to avoid lifting broadly-shared features to the core. - -Placement summary (same as the table above, restated as a rule): - -- **Main package** when: defined in SQL standard (SQL:1999–2016); widely supported across DBs; a - DDL or DML construct. -- **Dialect extension** when: dialect-specific; differs across databases; vendor-proprietary. - -Examples: `CREATE TRIGGER`/`CREATE FUNCTION` → main package; `COMMENT ON`, `CREATE TYPE ... -AS ENUM`, `BIGSERIAL` → PostgreSQL; `AUTO_INCREMENT` → MySQL; RANGE/LIST/HASH partitioning → -backend-specific (each backend names and implements its own partition classes). - -### Generic vs Backend: The Two-Way Responsibility (Core Balances, Backends Never Share) - -The generic (core) layer and the concrete backend layers divide responsibility in **two -directions**, and both directions are mandatory: - -1. **The generic layer must actively balance across backends.** When defining a generic - expression's parameters, its `supports_*()` switches, and its `format_*()` method, the core - must deliberately account for *how the different backends differ* — model the common shape - broadly enough to cover the shared semantics (not a narrow subset, not an over-broad grab-bag), - and give a sensible generic default implementation. The generic layer's job is to **shoulder as - much of the backends' work as possible**, so each backend can inherit behaviour untouched. - -2. **A difference the generic layer cannot balance is handled by each backend on its own.** For - such a difference a backend derives its **own** expression class (from the generic base), - extends/overrides its **own** `supports_*()` switches, and provides its **own** `format_*()` - method. This is the `MySQLRangePartition` / `PostgresRangePartition` / `OracleListPartition` - pattern. - -**IRON RULE — a backend must never reuse another backend's expression classes, switches, or -formatters — not even when the definitions are byte-for-byte identical.** Cross-backend code -sharing is forbidden. MariaDB does **not** import MySQL's partition expressions even though the -syntax is identical; it declares its own `MariaDB*` classes derived from the same generic base. -Backends are **independent**: each depends only on the core generic layer, never on a sibling -backend. - -**Why:** (a) the generic layer owning the balance keeps backends thin and the shared contract -coherent; (b) a backend is free to diverge the moment its real capability differs — but that -freedom is only real if the class/switch/formatter actually lives in its own package. Sharing a -sibling's class welds their fates: a change made for one backend silently changes the other, a -version-gated capability cannot be tightened independently, and the override seam belongs to the -wrong package. "It is identical today" is not a reason to share, because identity is not -guaranteed tomorrow. - -**What happens if you break it (harm):** a cross-backend import creates a hidden dependency edge -(backend→backend) that the packaging, versioning, and CI of independent repositories do not model; -upgrading or splitting one backend breaks the other; and the shared class becomes an undeclared -third contract with no owner. The correct move is always: if it is truly common, **lift it to the -generic layer (rule 1)**; otherwise **derive a private copy in each backend (rule 2)** — never -import across backends. - -### Adding a New Protocol +**Why probing is forbidden.** The `getattr` default is a claim that the field +*might* be absent, and it is always false — either the field is there, or the +wrong class was passed. The two cases then look identical: + +- a caller passes a class without the field, and the formatter renders as if the + flag were `False`, silently dropping a declaration the caller made; +- the same formatter later "supports" a family it never renders, because it + never distinguishes the two. + +Using the wrong expression becomes an `AttributeError` **at the point of the +mistake**, which is the one place a reader can act on it. A default converts +that crash into a quiet wrong answer. `anti-patterns.md` lists `__getattr__` +forwarding on an expression class for the same reason — a checker stops there +and every attribute looks valid; `getattr` with a default is the same blindness, +written at the call site instead of on the class. + +**What to do instead.** If the field really is optional for some member of the +family, that is a statement about the *protocol*, so fix the protocol: give each +class the field, or split the formatter so each one reads what its own annotation +declares. If the formatter is handed the wrong class, let it say so. + +**Forbidden:** any `render*()` method, `to_sql_inline()`, or other "variant +rendering" entry point. One `to_sql()`, one `format_*()` per concern. + +### An expression and its formatter are one-to-one + +The pairing is **strictly one-to-one, in both directions**: + +- Every concrete expression class **names exactly one** `format_*()` method, and + declares that name **once, for itself** — via the read-only `format_method` + property. An expression that has not overridden it is **not renderable**; + reading the getter raises `NotImplementedError`, and a formatter the bound + dialect does not provide raises `UnsupportedFeatureError`. +- **No formatter is shared by two expressions.** If two classes name the same + `format_*()` method, the pairing is no longer one-to-one and one of them does + not need to exist. A word-count difference between two expressions that render + the same SQL is a `spelling`, a parameter, or a storage difference — not a + reason for a second class pointing at one formatter. + +The declaration is read-only by construction — a plain `@property` with no +setter — so it cannot be mutated at run time, and `to_sql()` resolves that one +declared name against the bound dialect and calls it with **this** expression. + +**Why it is one-to-one.** The pairing is the seam that makes a backend +overridable and the system checkable: one name per expression means a renderer +is findable by reading the class, and a class is findable by its formatter. +Sharing a formatter across classes collapses that — two expressions become +indistinguishable to dispatch, and the schema differ starts inventing changes +between them. Measured: `PostgresByteaType` and core `BlobType` both render +`BYTEA`, and eleven Snowflake classes render what their core types already +render; one-to-one is what those defects break. + +**No private helpers in the protocol or implementation.** All behaviour is +reachable through the public surface — `to_sql()`, `format_*()`, `supports_*()`. +If you feel the urge to add `_render_*` / `_format_*`, the public protocol is +missing a deliberate capability; the fix belongs in the protocol or in how the +expression is constructed, never in a hidden helper. A private helper becomes a +second, invisible protocol: a backend unaware of it renders differently and no +conformance test sees the divergence. + +### Why the reverse direction is not a style preference + +The type position of a cast cannot take a bind parameter, so whatever lands +there is SQL code. That is why `cast()` takes a `DataType` and refuses a string, +and why `CustomType` validates its name: the reverse direction is where +injection lives. The same shape appears wherever a loose value is turned into a +node — a helper that accepts `str` and decides whether it is a column name or a +string value infers SQL semantics at run time, invisibly at the call site. + +**Forbidden:** a coercion helper that manufactures the expression; a factory +parameter typed `Any` or `Union[str, BaseExpression]`; parsing rendered SQL +back into a domain object. + +### Formatting is pure concatenation — never scan the SQL string + +Formatting concatenates fragments (identifiers via `format_identifier`, literal +atoms via `format_literal`, placeholders, pre-rendered sub-fragments). It must +never inspect, parse, or rewrite the assembled SQL — no `startswith`/`endswith` +checks, no regex, no quote-state machine, no post-hoc placeholder substitution. + +Generated SQL is the **output** of formatting, not its input; reading it guesses +at structure the formatter already held as typed data, and it is an injection +surface. + +**The single exception — inline literals.** A value can be spliced into the SQL +text instead of bound, and the call site asks for that per value: +`Literal(dialect, value, inline=True)`. The dialect then produces escaped, +quoted atoms (`inline_sql_literal`) concatenated during formatting — no +re-scanning. + +Whether to inline is **the caller's decision, not the framework's**: some +engines reject a statement carrying a bind parameter in a position with no room +for one, and the developer knows which engine the statement is for. The default +is bind parameters (`inline=False`), because inlining is an injection hazard by +nature — inline content is trusted-only, never reachable by end users. A +formatter must not inline on its own initiative, and must not refuse a statement +merely because it binds where an engine would prefer text. + +--- + +## 2. Layering: where a feature belongs + +Decide placement by how *broad* a feature's base is. + +1. **Broad base → generic layer.** Exists with standard semantics across many + databases. Lift it to core as a `XxxSupport` protocol + `XxxMixin` + implementation + `supports_xxx()`. Backends compose the pair and inherit it + untouched. The goal: the generic layer implements **as much as possible**, + minimising backend work. +2. **No broad base → per-backend, backend-named.** Vendor-specific syntax (table + partitioning, proprietary clauses) is implemented by each backend and + **prefixed with the backend name** (`MySQLRangePartition`, + `PostgresRangePartition`, …). Core provides at most an empty marker base; + each backend claims its own classes. +3. **Generic implementation doesn't fit a backend → that backend overrides.** See + "Override Discipline (Generic-First)" below. + +Placement summary: **main package** when defined in the SQL standard or widely +supported; **dialect extension** when vendor-specific. `CREATE TRIGGER` / +`CREATE FUNCTION` → main; `COMMENT ON`, `BIGSERIAL` → PostgreSQL; `AUTO_INCREMENT` +→ MySQL; `RANGE`/`LIST`/`HASH` partitioning → backend-specific. + +--- + +## 3. Generic vs backend: the two-way responsibility + +The generic and concrete layers divide responsibility in **two** directions, and +both are mandatory. + +**1. The generic layer must actively balance across backends.** When defining a +generic expression's parameters, `supports_*()` switches, and `format_*()`, the +core deliberately accounts for **how the backends differ** — modelled broadly +enough to cover the shared semantics, with a sensible generic default — so each +backend can inherit it untouched. + +**2. A difference the generic layer cannot balance is handled by the backend +alone.** The backend derives **its own** expression class from the generic base, +its own `supports_*()` switches, and its own `format_*()`. This is the +`MySQLRangePartition` / `PostgresRangePartition` pattern. + +> **IRON RULE — a backend must never reuse another backend's expression classes, +> switches, or formatters — not even when byte-for-byte identical.** MariaDB does +> not import MySQL's partition expressions; it declares its own `MariaDB*` classes +> from the same generic base. Backends are **independent**: each depends only on +> core, never on a sibling. Sharing welds their fates — a change for one silently +> changes the other, and the override seam lands in the wrong package. "Identical +> today" is not a reason to share; identity is not guaranteed tomorrow. + +### Override Discipline (Generic-First) + +Generic mixins implement the standard behaviour **once**. A concrete dialect +composes a Support/Mixin pair and inherits it; it overrides a method **only when +its real capability or behaviour differs from the generic implementation**. Never +re-declare a method just to return the value the mixin already provides. + +Rationale: backends stay minimal, the overrides read as an exact diff against the +generic implementation, and dialects become comparable at a glance. Enforced by +`tests/.../dummy/test_dummy_protocol_member_completeness.py` plus per-feature +tests asserting `"method" not in ConcreteDialect.__dict__` for dialects that +match the generic behaviour. + +The identity / auto-increment probes fail closed: `IdentityColumnMixin` and +`AutoIncrementMixin` default every probe to `False` (a probe answering `True` by +default would emit SQL the server rejects), so each dialect declares only what it +really has and `DummyDialect` overrides them to `True`. + +### Backend protocol changes require extreme restraint + +The protocol is a **shared contract implicated across every backend**. Before +touching it: (1) research every backend's implementation and validate against +real servers; (2) make the change minimal, generic, composable with every +existing contract; (3) implement across the generic layer and every backend +together, keeping behaviour identical everywhere. + +A hasty protocol change commits every backend to behaviour never surveyed; +backends drift as each "fixes" it differently. This is what repeatedly poisoned +the abandoned DDL-derivation work (`ddl_inline`, `inline_params`, string +sniffing, invented render helpers). + +--- + +## 4. Deriving an expression class (every family) + +This section governs when a backend may define an expression class of its own, +and what that class owes the core. It applies to `DataType`, +`SQLValueExpression`, `SQLPredicate`, DDL statements, sequence options alike. + +### The line + +> **Core first, respect the backend.** If core already expresses the concept +> completely, a backend does not define it again. Where core cannot serve a +> backend's need — a new parameter, a different storage, a word no core formatter +> can write — the backend derives from core and overrides the formatter. + +One question decides whether a backend class belongs at all: **does it carry +something the core class does not?** + +- Yes — a capability, a storage difference, a word only this server spells. +- No — it is a second name for something that already has one. + +Decide on **capability or storage**, never on how two names sound. In PostgreSQL +`bytea` (variable-length, no format check) and `uuid` (fixed 128 bits, +validated, own operators) are separate types; size and "both look binary" say +nothing. Read the storage off the server, not the name. + +### Derive, or own it outright + +Once the answer is "yes, it carries something", there are two different places +that something can live, and they are not interchangeable. + +**Derive from core** when the concept has a core counterpart and this backend +needs more of it — a new parameter, a different storage for the same idea, a word +this server writes. The backend subclasses the core class, adds only what core +lacks, and overrides the formatter. `MySQLIntType` is a core `IntegerType` plus +`zerofill`; `ClickHouseDateTimeType` is a core `TimestampType` rendered this +server's way. See the four shapes below. + +**Own it outright, under the backend's own name** when the concept exists in +**neither the SQL standard nor any sibling backend** — it is this server's alone. +PostgreSQL has no peers for `citext`, `ltree`, `hstore`, `int4range`, `inet`, +`geometry` or `pg_lsn`, so those are defined in `impl/postgres` and nowhere else, +each carrying the `postgres_` prefix. There is no core base to derive from, +because there is no core concept to be a specialisation of; the core may supply +at most an empty marker base if one is needed for dispatch. + +The test that separates the two: **ask whether a core class already names the +concept.** If it does, derive — even a large departure stays a derivation +(`FirebirdInt128Type` is an integer, so it derives from a core integer class +rather than standing alone). If nothing in core names it, own it. What never +justifies owning it is a difference in *spelling* or *size*; those belong to the +core class as a `spelling` or a parameter, not to a second class under a new +name. + +### Protocol A — a core formatter does not reject a derived class + +A core formatter's annotation takes the **core base class** — +`format_data_type_integer(self, data_type: IntegerType)` — and reads attributes. +A derived instance passes straight through; there is no exact-class check. + +Two consequences, both load-bearing: + +1. **A derived class that adds a field must bring its own formatter.** The core + one cannot read what it does not know: a derived `zerofill` handed to the core + formatter renders happily and the new parameter vanishes. Silent loss is the + failure mode, not an error. +2. **The dispatch entry point keeps families apart.** `format_data_type()` routes + on `data_type.name`, and `__init_subclass__` forces every derived class to + carry a backend-prefixed `name`. A derived class therefore needs a + `format_data_type_`; a core formatter is never asked to render a + foreign subclass by accident. + +### Protocol B — a derived class does not rewrite the core signature + +The parameter list is a contract shared with every formatter that accepts the +base class. Adding is fine; changing it is not, because a core formatter may +still be handed the instance. + +| Move | Verdict | Example | +|---|---|---| +| Add a parameter, keep every core parameter | Correct | `MySQLIntType` adds `zerofill`; `MariaDBEnumType` adds `charset`, `collation` | +| Narrow the signature **and** narrow the identity together | Correct | `SQLServerTinyIntType` pins `unsigned=True`, still in `PARAMETERS`, so `==` still separates signed from unsigned; `MariaDBIntType` drops `spelling` because this backend writes one word for that width | +| Narrow the signature but leave the field in `PARAMETERS` | **Broken** | equality reads a field the constructor cannot supply | + +The judgement is not "did the signature change" but **"do the signature and the +identity agree"**. Both may narrow; only one narrowing alone is a defect. + +### The four shapes, on `DataType` + +`spelling` is one parameter that appears only in the `DataType` family; it is the +answer to the fourth shape, not part of the two protocols above. + +1. **A pure second name — do not derive.** No new capability, a prefixed `name`, + and a formatter producing the same SQL as core's. `parse_type` then returns + the core class, and since `__eq__` is class-exact the declaration and the + catalog row read back from it are unequal — the schema differ invents a + change. Measured: `PostgresByteaType`, `PostgresUUIDType`, `PostgresXMLType`, + eleven Snowflake classes, four Firebird classes. + The control case shows the judgement: `SQLServerUniqueIdentifierType` is + **correct** because that backend does not implement core's + `format_data_type_uuid`, so the substitute is its only entry point. The test + is "can a core formatter write this word", never "does a same-named class + exist". +2. **A new parameter — the healthy derivation.** Add what core lacks, override + the formatter. Core-first working as intended. +3. **A real storage difference — derive, and say so.** `RAW(n)` bounded and in + the row versus `BLOB` unbounded behind a locator; a SERIAL family that is a + DDL declaration in its own right; an integer wider than anything core has. + State the storage difference in the docstring — the reason must survive being + read by the next person, because a docstring that justifies the class with the + wrong argument invites its deletion. `OracleNVarChar2Type` is kept because no + core formatter writes `NVARCHAR2(n)`, not because "the charset is a field". +4. **A synonym — one class plus a spelling.** Same storage, different word. + `SPELLINGS` lists them, `spelling` selects, the formatter honours or refuses + by name and never silently drops it. The list is the **union across + dialects**, not one dialect's vocabulary. Two ways to get this wrong: splitting + a synonym into several classes (shape 1), and accepting a `spelling` the + formatter then ignores — a silent drop is more dangerous, being invisible + where a second class is at least visible. + +### One decision, across backends + +Two backends must not give opposite answers to the same situation. Resolve it +with the line above: the question is whether the class carries capability core +lacks. `zerofill`, citext's case-insensitive operators, a wider integer — keep. A +second spelling of the same key with a backend prefix — delete. + +### Two guards, written once + +Both belong in the shared testsuite, not in nine backends: + +1. **A formatter that receives a core parameter either honours it or refuses it + by name.** +2. **A parameter absent from a derived `__init__` is absent from its identity.** + +### Checklist, for adding a backend class + +- [ ] Can core express this completely? If yes, do not derive; if only the word + differs, use the family's synonym mechanism. +- [ ] If it needs something core lacks: **does a core class already name the + concept?** If yes, derive from it and add only what is missing. If the + concept is this server's alone, own it outright under the backend name. +- [ ] If derived, what does it carry that core lacks? Write it in the docstring. +- [ ] The expression **collects only** — no `raise` in its `__init__`. Whatever a + dialect must accept or refuse (a spelling, an `unsigned`, a length) is + checked in the `format_*()`, which is the only place that knows the backend. +- [ ] Core parameters: added only, never rewritten; if narrowed, identity narrows + with them. +- [ ] It has its own formatter and handles inherited parameters honestly — honour + or refuse, never drop. +- [ ] `name` carries the backend prefix, and parsing returns **this** subclass. +- [ ] The class names its **own** `format_*()` — one-to-one with the + expression. If another expression already renders this, that difference + belongs to that class (a `spelling`, a parameter, a storage fact). + +--- + +## 5. Protocol / mixin architecture + +- **Protocols** (`protocols.py`): the interface contract — what a dialect must + implement. Never executed. +- **Mixins** (`mixins.py`): default SQL-standard implementations dialects can + override. +- Every dialect inherits `SQLDialectBase` + relevant Mixins + relevant Protocols. + +### Adding a new protocol ```python # 1. main package protocols.py @@ -313,97 +489,87 @@ class NewFeatureSupport(Protocol): def format_new_feature_statement(self, expr): ... # 2. main package mixins.py — generic implementation; the supports_* default -# must represent the COMMON behaviour across databases (usually False for -# non-universal features, True for near-universal ones like auto-increment) +# must represent the COMMON behaviour across databases class NewFeatureMixin: def supports_new_feature(self): return False def format_new_feature_statement(self, expr): ... -# 3. concrete dialects: compose the pair; override ONLY methods whose real -# capability/behaviour differs from the generic implementation +# 3. concrete dialects: compose the pair; override ONLY what genuinely differs -# 4. dummy dialect: compose NewFeatureMixin/NewFeatureSupport and override -# supports_* to True only where the generic default is not already True +# 4. dummy dialect: compose both and override supports_* to True only where the +# generic default is not already True # 5. tests: tests/.../dummy2/test_new_feature.py (expression), # tests/.../dummy/test_dummy_protocol_support.py (protocol support) ``` -### Override Discipline (Generic-First) +### Main-package protocol/mixin pairs + +`WindowFunction`, `CTE`, `AdvancedGrouping`, `Returning`, `Upsert`, `LateralJoin`, +`Array`, `JSON`, `Explain`, `FilterClause`, `OrderedSetAggregation`, `Merge`, +`TemporalTable`, `QualifyClause`, `Locking`, `Graph`, `Join`, `SetOperation`, +`ILike`, `Table`, `View`, `Truncate`, `Schema`, `Index`, `Sequence`, `Trigger`, +`Function` — each as `XxxSupport` (protocol) + `XxxMixin` (implementation). + +--- + +## 6. The Dummy dialect: generic reference and test vehicle + +`backend/impl/dummy/dialect.py` (`DummyDialect`) is the SQL-standard reference +dialect and the vehicle for testing everything that does not need a live +connection. + +- **Scope — compose (almost) everything:** every generic `XxxSupport`/`XxxMixin` + pair, so each protocol's path is reachable without a database. Only genuinely + server-bound capabilities are excluded (introspection composes but its + `supports_*()` return `False`; backend-specific types are not modelled). +- **Behaviour — switches fully on:** every feature-detection method returns + `True`, inherited or overridden. Dummy is a reference switchboard, not a + simulation. +- **Enforcement:** `tests/.../dummy/test_dummy_protocol_member_completeness.py` + discovers every Protocol in `protocols.py` and fails unless `DummyDialect` + composes it and implements all members. + +**Test organization.** `tests/.../feature/backend/dummy/` covers the protocol +surface (every `supports_*()` `True`, member completeness); `tests/.../feature/ +backend/dummy2/` covers expression `to_sql()` generation through Dummy's +standard formatting. Extending both is part of the definition of done. + +--- + +## 7. Backend boundary + +**ActiveRecord is the user of the backend; the backend never knows ActiveRecord +exists.** The dialect/expression layer describes generic SQL and the concrete +capabilities of the target database — never a servant to the ActiveRecord layer +above it. ActiveRecord adapts to the backend; if it needs a feature requiring +backend cooperation, the feature is negotiated as a protocol on the backend's own +generic terms, never by bending the backend's design. + +**Forbidden:** ActiveRecord-serving helpers on the dialect (`ddl_inline`, +`inline_params`, parameter-inlining machinery whose only purpose is an +ActiveRecord DDL feature); making the dialect infer intent from string +characteristics (regex, prefix/suffix checks, statement sniffing). + +## 8. Verification + +- Rendering is not validation. `(end - start) * 1` renders happily on a dialect + that cannot execute it. Test against a real database. +- Semantic expectations come from the server, not intuition. +- A byte-identical SQL diff before and after is the evidence a refactor changed + nothing. Capture it first, then compare. +- Run the whole check the change needs. Full suites are not run locally; + targeted files, `--collect-only`, and lint are. +- A refusal is not a failure. `UnsupportedFeatureError: requires PostgreSQL 14+` + is the code working: the dialect declined to render a type the server lacks. The + test should ask the dialect what the server supports and skip. +- A check that was not run cannot have passed. Run the directory, not just the + files you edited. + +## Why this matters + +Backend-agnostic expressions, injection-safe rendering centralised in the +dialect, a new backend reduced to a new dialect, and structure testable without +a database. + -Generic mixins implement the standard/common behaviour **once**. A concrete -dialect composes a Support/Mixin pair and inherits that behaviour untouched; -it overrides a method **only when its real capability differs from the -generic implementation**. Never re-declare a method just to return the same -value the mixin already provides. - -Rationale: -- Concrete backends stay minimal — no boilerplate restating standard behaviour. -- The overrides in `{backend}/dialect.py` read as an exact diff against the - generic implementation, so backends can be compared at a glance. -- Enforcement lives in `tests/.../dummy/test_dummy_protocol_member_completeness.py` - (every protocol method must exist on `DummyDialect`) plus per-feature tests - asserting `"method_name" not in ConcreteDialect.__dict__` for dialects that - match the generic behaviour. - -Example: the identity / auto-increment probes fail closed. -`IdentityColumnMixin` and `AutoIncrementMixin` default every probe to `False` -(a probe answering `True` by default would let the formatter emit SQL the -server rejects), so each dialect declares the mechanisms it really has and the -switchboard `DummyDialect` overrides all of them to `True`. The probes are -split by mechanism and by option: `IdentityColumnSupport` answers for the -parameterised `GENERATED ... AS IDENTITY` clause, `AutoIncrementColumnSupport` -for the parameterless `AUTO_INCREMENT` marker. - -## The Dummy Dialect: Generic Reference and Test Vehicle - -`backend/impl/dummy/dialect.py` (`DummyDialect`) is the SQL-standard -reference dialect and the vehicle for testing everything that does not -require a real database connection. - -**Scope — compose (almost) everything:** - -- Compose every generic `XxxSupport`/`XxxMixin` pair, so each protocol's - code path is reachable in tests without a database. -- The only exclusions are capabilities that inherently need a real server: - introspection is composed but every `supports_introspection*()` returns - `False`; backend-specific data types are not modelled (core types get - standard formatters via `DDLTypeMixin.handles(...)` for expression - `to_sql()` testing). - -**Behaviour — switches fully on:** - -- Every feature-detection method returns `True`, either inherited from the - generic mixin or overridden where the generic default differs. Dummy is a - *reference switchboard*, not a simulation of any real product. - -**Enforcement:** - -- `tests/.../dummy/test_dummy_protocol_member_completeness.py` dynamically - discovers every Protocol in `protocols.py` (excluding only - `IntrospectionSupport`) and fails unless `DummyDialect` composes it and - implements all of its members. - -**Test organization — indirect coverage of the generic layer:** - -- `tests/.../feature/backend/dummy/` — protocol support surface: every - `supports_*()` verified `True`, plus member-completeness enforcement. -- `tests/.../feature/backend/dummy2/` — expression `to_sql()` generation: - exercises the generic Expression classes through the dummy dialect's - standard formatting, i.e. tests generic expressions, protocols, and - mixin implementations indirectly, with no database involved. - -When you add a protocol/mixin pair (see "Adding a New Protocol"), extending -both directories is part of the definition of done. - -## Why This Matters - -1. Backend agnostic — same expressions work with any database -2. SQL injection safe — centralized escaping -3. Extensible — new backend = new dialect only -4. Testable — structure and SQL tested separately - -## Testing - -- Expressions: verify structure, not SQL -- Dialects: verify correct SQL generation \ No newline at end of file diff --git a/.claude/skills/dev-expression-lineage/SKILL.md b/.claude/skills/dev-expression-lineage/SKILL.md index 9dc869241..825854300 100644 --- a/.claude/skills/dev-expression-lineage/SKILL.md +++ b/.claude/skills/dev-expression-lineage/SKILL.md @@ -213,7 +213,7 @@ from expression_lineage import build_graph, discover_projects graph = build_graph() graph.stats() # counts for the report -graph.consumers("core::CreateDomainExpression") # {'postgres', 'firebird'} +graph.consumers("core::CreateDomainStatement") # {'postgres', 'firebird'} graph.violations() # iron-rule breaches [node.name for node in graph.misplaced()] graph.node("postgres::PostgresCreateDomainExpression").bases diff --git a/.claude/skills/dev-expression-lineage/scripts/fix_dangling_typecheck.py b/.claude/skills/dev-expression-lineage/scripts/fix_dangling_typecheck.py index 240f933f0..0c145367d 100644 --- a/.claude/skills/dev-expression-lineage/scripts/fix_dangling_typecheck.py +++ b/.claude/skills/dev-expression-lineage/scripts/fix_dangling_typecheck.py @@ -82,11 +82,11 @@ "from ..backend.async_backend import", None, ), - ("impl.firebird.mixins.transaction", "BeginTransactionExpression"): ( + ("impl.firebird.mixins.transaction", "BeginTransactionStatement"): ( "from rhosocial.activerecord.backend.expression.transaction import", None, ), - ("impl.firebird.mixins.transaction", "SetTransactionExpression"): ( + ("impl.firebird.mixins.transaction", "SetTransactionStatement"): ( "from rhosocial.activerecord.backend.expression.transaction import", None, ), diff --git a/.claude/testing.md b/.claude/testing.md index 8f1f4cad0..3277b9ede 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -102,6 +102,10 @@ python -m pytest tests/ -n auto --dist=loadgroup -p rhosocial.activerecord.tests Running `pytest -n` without the plugin still risks collisions on shared `:memory:`/named SQLite connections and temp files — in that case run serially. +On Windows, run DB-backed scenarios in parallel with pooling disabled (`--db-pool-size 0`): the pool +then stays off and every test takes its own unique database name, so no cross-process slot lock is +needed. Pooled slot locking uses `fcntl`, which is POSIX-only. + ## 6. Free-Threaded Python (3.13t / 3.14t) Free-threaded builds are fully supported and all tests pass. Setup: @@ -125,7 +129,58 @@ find /tmp -name "test_activerecord_*.sqlite" -delete # macOS/Linux Get-ChildItem -Path $env:TEMP -Name "test_activerecord_*.sqlite" | Remove-Item # PowerShell ``` -## 8. Authoring Tests +## 8. Windows / PowerShell 7 + +`scripts/preflight.sh` and `scripts/install-git-hooks.sh` are POSIX. On Windows use their +counterparts, which share helpers via `scripts/_devlib.ps1`: + +| Task | Command | +|------|---------| +| Environment report | `./scripts/dev.ps1 doctor` | +| Serial test run | `./scripts/dev.ps1 test` | +| Test run with pytest arguments | `./scripts/dev.ps1 test -k cte` | +| Parallel test run (opt-in, §5) | `./scripts/dev.ps1 test -Parallel` | +| Import check, no tests run | `./scripts/dev.ps1 collect` | +| Coverage | `./scripts/dev.ps1 cov -Parallel` | +| Lint / format / types | `./scripts/dev.ps1 lint` / `fmt` / `type` | +| Pre-flight (lint + import + collect) | `./scripts/dev.ps1 preflight` | +| Pre-push bundle | `./scripts/dev.ps1 verify` | +| Install / remove the pre-commit hook | `./scripts/dev.ps1 hooks [-Remove]` | +| Create a changelog fragment | `./scripts/dev.ps1 fragment ""` | +| Changelog rules locally (§ mirrors CI) | `./scripts/dev.ps1 changelog-check` | +| Redis service for `-m redis` | `./scripts/dev.ps1 redis up` / `down` / `status` | +| Remove stray temp databases and caches | `./scripts/dev.ps1 clean [-Dry]` | + +Notes: + +- **Serial by default.** `test` runs serially; `-Parallel` is the opt-in and adds + `-n auto --dist=loadgroup -p rhosocial.activerecord.testsuite.conftest`, matching §5 and CI. + Parallel execution is a supported and encouraged mode — iron rule 1 requires the plugin and the + `serial` marker, it does not mandate a serial run. +- **`PYTHONPATH` is set for you**, as absolute `/src;/tests` (Windows path + separator). An existing `PYTHONPATH` is preserved after ours. +- **Overrides**: `AR_VENV=` selects the virtualenv (default `.venv3.14`), `AR_RUFF=` + pins an executable. `doctor` reports both. +- **The git hook is a POSIX sh script** even when installed from PowerShell, because git for + Windows runs hooks through its bundled shell. It prefers `preflight.ps1` and falls back to + `preflight.sh`. +- **`doctor` checks the things that silently break a Windows run**: tool availability, the + resolved testsuite path (it is an editable install and may point into a worktree), Redis + reachability, hook installation, and whether `fcntl` is importable (see below). + +### fcntl and pooled databases + +`rhosocial-activerecord-testsuite`'s `core/pool.py` locks per-slot files with `fcntl.flock` while +the database pool is active — that is, parallel runs with a positive `--db-pool-size`. `fcntl` is +POSIX-only, so on Windows run DB scenarios in parallel with `--db-pool-size 0`: pooling stays off, +each test takes its own unique database name, and no cross-process slot lock is needed. + +A serial Windows run used to fail after *every* test with `ModuleNotFoundError: No module named +'fcntl'` — the teardown hook releases slots unconditionally and imported `fcntl` at function entry, +although a serial run never holds a slot. Fixed in testsuite `458af92`: the release path returns +before the import. + +## 9. Authoring Tests **Writing/adding tests is covered by the `dev-testing-contributor` skill** — load it for: testsuite vs backend division of responsibilities, provider pattern & composite fixtures (tuples), protocol diff --git a/.experiments/auto_increment_test.py b/.experiments/auto_increment_test.py index 4b1a3a658..7a5e4211b 100644 --- a/.experiments/auto_increment_test.py +++ b/.experiments/auto_increment_test.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -31,8 +31,8 @@ without_ai = ColumnDefinition("id", "INTEGER", constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=False)]) -sql_ai, _ = CreateTableExpression(dialect, table="test_ai", columns=[with_ai]).to_sql() -sql_no_ai, _ = CreateTableExpression(dialect, table="test_no_ai", columns=[without_ai]).to_sql() +sql_ai, _ = CreateTableStatement(dialect, table="test_ai", columns=[with_ai]).to_sql() +sql_no_ai, _ = CreateTableStatement(dialect, table="test_no_ai", columns=[without_ai]).to_sql() print(f" 有 AUTOINCREMENT: {sql_ai}") print(f" 无 AUTOINCREMENT: {sql_no_ai}") diff --git a/.experiments/schema_roundtrip.py b/.experiments/schema_roundtrip.py index e14d2d1d8..095d95c30 100644 --- a/.experiments/schema_roundtrip.py +++ b/.experiments/schema_roundtrip.py @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ForeignKeyConstraint, IndexDefinition ) @@ -54,7 +54,7 @@ def experiment_column_types(backend): ] dialect = backend.dialect - expr = CreateTableExpression(dialect, table="roundtrip_types", columns=columns) + expr = CreateTableStatement(dialect, table="roundtrip_types", columns=columns) sql, params = expr.to_sql() print(f"\n生成的 DDL:\n{INDENT}{sql}") @@ -217,10 +217,10 @@ def experiment_ddl_expression_flow(backend): print(f"{INDENT}→ data_type 直接被拼接到 SQL 中, 没有任何转换") # 步骤3:构建完整 DDL - print("\n步骤3: CreateTableExpression.to_sql()") + print("\n步骤3: CreateTableStatement.to_sql()") columns = [col, ColumnDefinition("id", "INTEGER", constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression(dialect, table="type_flow_test", columns=columns) + expr = CreateTableStatement(dialect, table="type_flow_test", columns=columns) full_sql, full_params = expr.to_sql() print(f"{INDENT}完整 CREATE TABLE: {full_sql}") print(f"{INDENT}→ data_type 仅是字符串拼接: '{col.data_type}' → 出现在 SQL 中的位置") diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5aa866f7..59aa65e55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: pip install -r requirements-dev.txt pip install codecov build - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Run tests with coverage env: @@ -112,7 +112,7 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Run benchmark suite env: @@ -279,7 +279,7 @@ jobs: pip install -r requirements-dev.txt fi - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Run tests env: @@ -340,7 +340,7 @@ jobs: pip install -r requirements-dev.txt fi - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Run tests env: @@ -397,7 +397,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Run tests env: diff --git a/.gitignore b/.gitignore index 7ff1a8cc7..7ccd0a7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,9 @@ gha-creds-*.json # Local-only experiment scripts .experiments/ddl_derivation_probe.py .experiments/field_type_adaptation.py + +# 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/ diff --git a/CLAUDE.md b/CLAUDE.md index d2d88c983..3e474fdd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,40 @@ abstraction layer. Source lives in `src/rhosocial/activerecord/`. - **Related repos**: `python-activerecord-mysql`, `-postgres`, `-mariadb`, `-oracle`, `-sqlserver` (backends), `python-activerecord-testsuite` (shared test contracts) +## The named series is Python-only + +The **named series** — `named-expression`, `named-procedure`, +`named-procedure-graph`, `named-migration`, `named-connection` — is a +**Python-only** capability, and the organization rules say so +(`../.claude/rules/cross-language-backend.md` §8). The Go, Rust and TypeScript +ports do not provide it, and their absence is a scope boundary rather than a +backlog item. + +The reason is the mechanism, not the idea. The series addresses user code by +a **string** at **run time**: it imports a module by name +(`importlib.import_module`), reads an attribute off it, introspects the +callable's signature and docstring (`inspect.signature` / `inspect.getdoc`), +asks at run time whether the value is one of ours (`issubclass`), and +enumerates a module's contents to list what it holds (`dir()`). It also +prints each parameter's **type annotation**, which exists only because +Python keeps annotations at run time. + +Every Python backend's CLI wires the five subcommands, because the machinery +lives in the core (`backend/named_expression/`, `backend/migration/`, +`backend/named_connection/`) and each backend's CLI only supplies the +backend-specific parts. + +When a port needs this, the honest answer is to use Python — not to build a +runtime module system for a language that has none. The self-describing +half of the idea (an explicit signature/parameter/doc contract on a trait or +interface) is portable in principle, but the addressing half is not, so the +series as a whole stays here. + ## Iron rules (do not skip) -1. **Run tests with `PYTHONPATH=src`** (tests are not on the module path). Never run tests in - parallel. +1. **Run tests with `PYTHONPATH=src`** (tests are not on the module path). Parallel execution is + supported and encouraged — use the invocations in `.claude/testing.md` §5, which load the + testsuite plugin and keep shared-state tests on the `serial` marker. 2. **No line exceeds 120 chars**; ruff config in `pyproject.toml` (`[tool.ruff.lint]`, ignore=B024). 3. **Expressions never concatenate SQL** — always delegate to `self.dialect.format_*()`. 4. **Sync/async must be functionally equivalent** — same features, same method names. diff --git a/README.md b/README.md index 40700dbdf..6da4d32de 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ pip install rhosocial-activerecord from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnConstraintType, ColumnDefinition, ) @@ -109,7 +109,7 @@ User.configure(config, SQLiteBackend) # Create table using DDL expressions. Every node takes the dialect as its # first argument, and column types are DataType instances rather than strings. dialect = User.__backend__.dialect -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table="users", columns=[ @@ -149,7 +149,7 @@ sql, params = User.query().where(User.c.age >= 18).to_sql() from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, ColumnConstraintType, ColumnDefinition, ForeignKeyConstraint, ReferentialAction, @@ -185,7 +185,7 @@ Post.__backend__ = Author.__backend__ # Create tables using DDL expressions dialect = Author.__backend__.dialect -Author.__backend__.execute(*CreateTableExpression( +Author.__backend__.execute(*CreateTableStatement( dialect=dialect, table="authors", columns=[ @@ -196,7 +196,7 @@ Author.__backend__.execute(*CreateTableExpression( ] ).to_sql()) -Author.__backend__.execute(*CreateTableExpression( +Author.__backend__.execute(*CreateTableStatement( dialect=dialect, table="posts", columns=[ @@ -237,11 +237,11 @@ All features support both **sync** and **async** APIs with identical method name Type-safe schema definition without raw SQL: -* **[CreateTableExpression](src/rhosocial/activerecord/backend/expression/statements/ddl_table.py)** — Create tables with columns and constraints -* **[DropTableExpression](src/rhosocial/activerecord/backend/expression/statements/ddl_table.py)** — Drop tables with IF EXISTS support -* **[AlterTableExpression](src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py)** — Add/drop columns, rename tables -* **[CreateIndexExpression](src/rhosocial/activerecord/backend/expression/statements/ddl_index.py)** — Create indexes with IF NOT EXISTS -* **[CreateViewExpression](src/rhosocial/activerecord/backend/expression/statements/ddl_view.py)** — Create views from query expressions +* **[CreateTableStatement](src/rhosocial/activerecord/backend/expression/statements/ddl_table.py)** — Create tables with columns and constraints +* **[DropTableStatement](src/rhosocial/activerecord/backend/expression/statements/ddl_table.py)** — Drop tables with IF EXISTS support +* **[AlterTableStatement](src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py)** — Add/drop columns, rename tables +* **[CreateIndexStatement](src/rhosocial/activerecord/backend/expression/statements/ddl_index.py)** — Create indexes with IF NOT EXISTS +* **[CreateViewStatement](src/rhosocial/activerecord/backend/expression/statements/ddl_view.py)** — Create views from query expressions ### Query Builders diff --git a/changelog.d/120.removed.md b/changelog.d/120.removed.md index 40506595d..f061bdead 100644 --- a/changelog.d/120.removed.md +++ b/changelog.d/120.removed.md @@ -1 +1 @@ -Remove the duplicate ALTER TABLE actions `AddConstraint`/`DropConstraint`/`RenameColumn`, the `as_query`/`like_table` paths of `CreateTableExpression`, `supports_returning_clause()`, and dialect propagation. +Remove the duplicate ALTER TABLE actions `AddConstraint`/`DropConstraint`/`RenameColumn`, the `as_query`/`like_table` paths of `CreateTableStatement`, `supports_returning_clause()`, and dialect propagation. diff --git a/changelog.d/121.removed.md b/changelog.d/121.removed.md index aaf443a96..c47413da4 100644 --- a/changelog.d/121.removed.md +++ b/changelog.d/121.removed.md @@ -1 +1 @@ -Remove the `dialect_options` bag from DataType value objects, non-DDL expressions, database/view/function DDL and DML, table constraints and ALTER actions, index expressions, column expressions and options, and the partition clause and definitions; narrow `CreateTableOptions` to generic fields; drop the legacy `identity`/`collation` fields and the Firebird-only table clauses from the generic `CreateTableExpression`; and rename the table-inheritance capability to `supports_table_inheritance`. +Remove the `dialect_options` bag from DataType value objects, non-DDL expressions, database/view/function DDL and DML, table constraints and ALTER actions, index expressions, column expressions and options, and the partition clause and definitions; narrow `CreateTableOptions` to generic fields; drop the legacy `identity`/`collation` fields and the Firebird-only table clauses from the generic `CreateTableStatement`; and rename the table-inheritance capability to `supports_table_inheritance`. diff --git a/changelog.d/126.changed.md b/changelog.d/126.changed.md index 9c10c450b..32d7c3bd6 100644 --- a/changelog.d/126.changed.md +++ b/changelog.d/126.changed.md @@ -1 +1 @@ -Make the bare Column a reference that renders an identifier and keeps only the shared surface (aliasing, null tests, casting): operations are reached through a typed column or built explicitly as ComparisonPredicate, InPredicate, BetweenPredicate or BinaryArithmeticExpression, which is what the query and AR layers now do. Redefine DDLSource.column_type() as a declaration accessor that presents the field's UseColumnType marker as-is and answers None when there is none, leaving the SQL type to column_data_type(). +Make the bare Column a reference that renders an identifier and keeps only the shared surface (aliasing, null tests, casting): operations are reached through a typed column or built explicitly as ComparisonPredicate, InPredicate, BetweenPredicate or ArithmeticOperation, which is what the query and AR layers now do. Redefine DDLSource.column_type() as a declaration accessor that presents the field's UseColumnType marker as-is and answers None when there is none, leaving the SQL type to column_data_type(). diff --git a/changelog.d/127.added.md b/changelog.d/127.added.md new file mode 100644 index 000000000..954ce6e9e --- /dev/null +++ b/changelog.d/127.added.md @@ -0,0 +1 @@ +Add a version vocabulary that answers before a server is asked -- `SQLDialectBase.version_or_none`, `version_at_least()` and `_declared_version()`, `StorageBackendBase.get_cached_server_version()`, and `IntrospectorMixin.server_version` -- plus `TableType.MATERIALIZED_VIEW`, `FOREIGN_TABLE` and `PARTITIONED_TABLE` for kinds a catalogue reports and core previously folded away. Register the SQLite backend's command-line interface as the `rhosocial-activerecord-sqlite` console script, the command the documentation has been promising all along. On that command line, `introspect` gained the two things a catalogue question needs in order to be askable at all: an answer for a single view (`views NAME`), and a `_TYPE_CAPABILITIES` table the advertised type list is derived from, so a family cannot be offered without a dialect switch behind it. diff --git a/changelog.d/127.changed.md b/changelog.d/127.changed.md new file mode 100644 index 000000000..82e68bf94 --- /dev/null +++ b/changelog.d/127.changed.md @@ -0,0 +1,3 @@ +Capability predicates answer rather than raise when the version is unknown (an unadapted dialect now reports "not supported" instead of throwing `DialectNotAdaptedException`), every catalogue question became a statement class plus a dialect `format_*_query_statement` plus a public manager pair, and the `table_type` an `IntrospectionTableInfo` carries keeps the distinction `PRAGMA table_list` makes: on SQLite the storage tables behind an FTS or R-Tree virtual table are `SYSTEM_TABLE`, and the virtual table itself is `EXTERNAL`. + +A `NAME` the type cannot use is now refused -- exit 2, the code that already means "there is no such question here" -- instead of being ignored. `introspect tables users` printed the whole catalogue while reading as though it had been narrowed, and `introspect views NAME` dropped the name altogether; the second now answers for that view and exits 1 when there is none. A *missing* required name keeps exit 1, the same code a missing connection argument gets, because the run never became a question. diff --git a/changelog.d/127.docs.md b/changelog.d/127.docs.md new file mode 100644 index 000000000..9d1880df6 --- /dev/null +++ b/changelog.d/127.docs.md @@ -0,0 +1,3 @@ +The user documentation's examples were brought back in line with the expression API this cycle introduced: `TableExpression` became `NamedRelationRef` over a relation object, `Column(...) == ...` became a typed-column comparison or an explicit `ComparisonPredicate`, `inline_literals=True` became `inline=True`, DDL statements take `Table` / `View` / `Index` / `Schema` / `Sequence` objects instead of name keywords, and the introspection pages describe the statement layer and the injectable `IntrospectionCache` rather than the retired hidden attribute. Every migrated page's code blocks were executed against the current API, and `scripts/verify_ddl_documentation.py` was removed because it kept a second copy of the same examples -- the copy is what rotted, not the check. + +The command-line guides were brought under the same rule. The introspection type table now states what a `NAME` means for each type, in place of a single "table name required" column that was wrong for half of them; the backend-template page no longer keeps its own list of backend-specific types, which had two of the eight that exist, and points at each backend's own CLI page instead; and the examples that taught `--format json` now use the `-o json` the command actually accepts. The SQLite page also records the boundary the CLI draws around `backend.introspector.pragma`: `integrity_check`, `wal_checkpoint` and `journal_mode` are actions and state changes, not questions about what the database holds. diff --git a/changelog.d/127.fixed.md b/changelog.d/127.fixed.md new file mode 100644 index 000000000..5014728c4 --- /dev/null +++ b/changelog.d/127.fixed.md @@ -0,0 +1,9 @@ +A disjunction nested inside a conjunction keeps its brackets when a logical predicate renders, a SET DEFAULT value reaches the ALTER statement, a type modifier is judged where it is rendered rather than where it is declared, `UseColumnType` resolves `ColumnBase` at runtime, and escaping goes through the dialect instead of the base class. + +`introspect columns` omitted a table's generated columns altogether. The question was `PRAGMA table_info`, which returns neither a generated column nor the `hidden` field that marks one, while the parser behind it had been written for `table_xinfo` all along -- so `is_generated` could never be true and the columns were missing from the list rather than mislabelled. SQLite's column question now asks `table_xinfo`; a virtual table's own hidden columns are still skipped, because they are the module's storage and not the table a caller reads, and a generated column's expression stays `None` because the pragma reports *that* a column is generated rather than what it is made of. + +`pool.transaction()` no longer reports a failed rollback instead of the error that caused it: when the body raised and the rollback then failed (a transaction already ended by the failing statement, a lost connection), the `TransactionError` from the rollback replaced the original exception, so callers saw "No active transaction to rollback" for what was, in the observed case, a duplicate-key `IntegrityError`. The rollback failure is now logged and the original exception propagates, in both the sync and async pools. + +A cancelled `acquire()` no longer keeps its semaphore permit. The connection-preparation code caught `Exception`, and `CancelledError` is a `BaseException`, so a caller that gave up while a backend was still connecting -- a readiness probe with a two-second budget, during a database restart -- leaked one permit per cancellation. The pool then reported `available=0, in_use=0` while every request waited out the acquire timeout, and never recovered on its own. The permit is now returned (and the half-prepared backend destroyed) on every exit that does not hand a connection to the caller. + + diff --git a/changelog.d/127.internal.md b/changelog.d/127.internal.md new file mode 100644 index 000000000..f7b5b65a8 --- /dev/null +++ b/changelog.d/127.internal.md @@ -0,0 +1 @@ +The S3 expression-layer refactor: the introspection expressions were renamed to `*Statement`, constraint nodes were split into clause and branch levels, the value / function / predicate families took the names in the naming spec, the wildcard node was split by grammar position per spec 4.10, and `IntrospectionCache` became an injectable component the manager owns rather than a hidden attribute. Rather than let the pin rot, the test toolchain runs pytest 9.2 and later: 9.2 moved a node's identifier into a structured `NodeId`, which broke pytest-benchmark's read of the private `node._nodeid` and silently unhooked the `--dist=loadgroup` grouping pytest-xdist writes to the same slot. Both are shimmed in the test suite's `pytest_compat` plugin, loaded by entry point wherever the suite is installed, so the matrix keeps proving the library on the pytest everyone is being offered instead of on the one it was last green with. diff --git a/changelog.d/127.removed.md b/changelog.d/127.removed.md new file mode 100644 index 000000000..7cec8f53d --- /dev/null +++ b/changelog.d/127.removed.md @@ -0,0 +1 @@ +Remove the `introspect table` aggregate from the CLI (one type per catalogue item instead), drop SQLite's geopoly content table that SQLite has no such option for, and delete the pre-refactor introspection surfaces: the underscore-prefixed `parse_*` contract, the `*Expression` class names, and the module helpers that were only public by accident. diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index f8ba2e8c3..52392d381 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -67,7 +67,7 @@ The model base class. Inherits from Pydantic `BaseModel`. ### FieldProxy (base/field_proxy.py) Enables type-safe, IDE-friendly query expressions. -- **Usage**: `User.c.age >= 18` returns a `ComparisonExpression`, not a boolean. +- **Usage**: `User.c.age >= 18` returns a `ComparisonPredicate`, not a boolean. - **Pattern**: Declare as `c: ClassVar[FieldProxy] = FieldProxy()` on the model class. ### Query Types (query/) @@ -90,12 +90,19 @@ Three core query builders with full sync/async parity: ### Expression hierarchy (expression/) All expressions implement `ToSQLProtocol` (i.e., they have `.to_sql(dialect) -> (str, list)`). -- `SQLColumn("users", "age")` — a column reference. -- `SQLLiteral(42)` — a bound parameter. -- `ComparisonExpression(left, op, right)` — e.g., `age >= 18`. -- `LogicalExpression(op, children)` — AND/OR/NOT combinator. -- `AggregateExpression(func, column)` — COUNT, SUM, etc. -- `WindowExpression`, `CTEExpression` — advanced SQL constructs. +- `Column(dialect, "age", "users")` — a column reference. Comparison, arithmetic and `LIKE` are **not** on this + base class: a typed column (`StringColumn`, `IntegerColumn`, `NumericColumn`, `TimestampColumn`, + `BooleanColumn`, ...) mixes in the operations its value type can carry. +- `Literal(dialect, 42)` — a bound parameter; `Literal(dialect, 42, inline=True)` renders as SQL text for a + DDL position, which accepts no bind parameters. +- `ComparisonPredicate(dialect, ComparisonOperator.GTE, left, right)` — e.g. `age >= 18` + (operators: `EQ`/`NE`/`GT`/`GTE`/`LT`/`LTE`). `StringColumn(...) == "x"` builds this node. +- `LogicalPredicate` — AND/OR/NOT combinator. +- Function factories (`count`, `sum_`, `avg`, `case`, ...) — return function/case nodes; there is no + `AggregateExpression` class. +- Table sources: `NamedRelationRef(dialect, Table(dialect, "users"), alias="u")`; relations themselves + (`Table`, `View`, `MaterializedView`, `ForeignTable`) live in `expression/objects/`. +- `WindowSpecification`, `CTEClause` — advanced SQL constructs. ### Dialect (dialect/) Translates expressions into backend-specific SQL strings. diff --git a/docs/en_US/SUMMARY.md b/docs/en_US/SUMMARY.md index 1787694a2..2ddd3d68e 100644 --- a/docs/en_US/SUMMARY.md +++ b/docs/en_US/SUMMARY.md @@ -137,6 +137,7 @@ ### SQLite Backend * [Overview](backend/sqlite/README.md) +* [Command-Line Interface](backend/sqlite/cli/README.md) * [Pragma](backend/sqlite/backend_specific_features/pragma.md) * [FTS5](backend/sqlite/backend_specific_features/fts5.md) * [Extension](backend/sqlite/backend_specific_features/extension.md) diff --git a/docs/en_US/backend/architecture.md b/docs/en_US/backend/architecture.md index 1bcaef1ee..de9838b9d 100644 --- a/docs/en_US/backend/architecture.md +++ b/docs/en_US/backend/architecture.md @@ -188,6 +188,22 @@ behavior on the *actually connected* server version. Backends re-adapt their dia `introspect_and_adapt()` after connecting, so capability is "negotiated with the server" rather than statically declared. +A capability question asked **before** adaptation answered differently: it used to raise +`DialectNotAdaptedException`, so every caller that merely wanted to know whether a feature +existed had to catch it. The version vocabulary now answers first: + +| Accessor | Answers | +|----------|---------| +| `dialect._declared_version()` | the version the dialect can name without asking a server (SQLite's is the linked engine's), or `None` | +| `dialect.version_or_none` | the adapted version, else the declared one, else `None` -- never raises | +| `dialect.version_at_least(v)` | whether the version in force reaches `v`; `False` when unknown | +| `backend.get_cached_server_version()` | the version the backend already resolved, without a round trip -- `None` until something resolves it | +| `introspector.server_version` | the version the introspector read from the server | + +So `supports_*()` reports "not supported" when the version is unknown, while the strict +`dialect.version` still raises: a renderer deciding *which* SQL to emit must know the +version it gates against, and guessing there would produce SQL the server rejects. + ## See Also - **[Custom Backend](custom_backend.md)** — how to assemble your own backend from these diff --git a/docs/en_US/backend/custom_backend.md b/docs/en_US/backend/custom_backend.md index 21f8844fd..bcd1e4f23 100644 --- a/docs/en_US/backend/custom_backend.md +++ b/docs/en_US/backend/custom_backend.md @@ -61,6 +61,18 @@ Connection configuration follows the same "generic / backend-specific" layering 3. **Backend-specific config class**: the backend inherits `ConnectionConfig`, mixes in the Mixins it needs, and adds database-private parameters on top. ```python +from typing import Optional + +from rhosocial.activerecord.backend.config import ( + CharsetMixin, + ConnectionConfig, + ConnectionPoolMixin, + LoggingMixin, + SSLMixin, + TimezoneMixin, + VersionMixin, +) + class MySQLConnectionConfig( ConnectionConfig, ConnectionPoolMixin, SSLMixin, CharsetMixin, @@ -120,7 +132,7 @@ from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionCo from rhosocial.activerecord.model import ActiveRecord # Build a config and bind it to the model -config = SQLiteConnectionConfig(database="app.db") +config = SQLiteConnectionConfig(database=":memory:") ActiveRecord.configure(config, SQLiteBackend) # Backend-specific subclass @@ -150,16 +162,27 @@ The `DummyDialect` (`src/rhosocial/activerecord/backend/impl/dummy/dialect.py`) Notice how it simply mixes in standard implementations: ```python +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.mixins import CTEMixin, WindowFunctionMixin +from rhosocial.activerecord.backend.dialect.protocols import ( + AdvancedGroupingSupport, + CTESupport, + WindowFunctionSupport, +) + class DummyDialect( SQLDialectBase, # Standard implementations via Mixins - WindowFunctionMixin, CTEMixin, AdvancedGroupingMixin, ... + WindowFunctionMixin, CTEMixin, # Protocol definitions for type checking - WindowFunctionSupport, CTESupport, AdvancedGroupingSupport, ... + WindowFunctionSupport, CTESupport, AdvancedGroupingSupport, ): # Feature flags - def supports_window_functions(self) -> bool: return True - # ... + def supports_window_functions(self) -> bool: + return True + + def supports_basic_cte(self) -> bool: + return True ``` ### Implementation Strategy @@ -180,6 +203,8 @@ When implementing protocols for your custom backend, follow these principles: Generic protocols (e.g., `WindowFunctionSupport`, `TableSupport`, `IndexSupport`) are defined in `rhosocial.activerecord.backend.dialect.protocols` and **should not include any backend-specific prefix**. ```python +from typing import Protocol + # ✅ Correct: Generic protocol class WindowFunctionSupport(Protocol): def supports_window_functions(self) -> bool: ... @@ -193,6 +218,8 @@ class MySQLWindowFunctionSupport(Protocol): ... Backend-specific protocols must include the backend name as a prefix (e.g., `PostgresPartitionSupport`, `MySQLFullTextSearchSupport`). ```python +from typing import Protocol + # ✅ Correct: Backend-specific protocol has prefix class PostgresPartitionSupport(Protocol): ... @@ -206,21 +233,26 @@ class PartitionSupport(Protocol): ... ```python # Example: MySQL Full-text Search -# The generic IndexSupport protocol already defines supports_fulltext_index +# The generic FulltextIndexSupport protocol already defines supports_fulltext_index +from typing import Protocol + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.protocols import FulltextIndexSupport + # ✅ Use generic protocol and just set the version check class MySQLDialect( - IndexSupport, # Already has supports_fulltext_index - ... + SQLDialectBase, + FulltextIndexSupport, # Already has supports_fulltext_index ): def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) # Only create MySQLFullTextSearchSupport for MySQL-specific interfaces: # - format_match_against() - MySQL MATCH...AGAINST syntax # - format_fulltext_index_options() - WITH PARSER, etc. class MySQLFullTextSearchSupport(Protocol): - def format_match_against(...): ... # MySQL-specific - def format_fulltext_index_options(...): ... # MySQL-specific + def format_match_against(self, expr) -> tuple: ... # MySQL-specific + def format_fulltext_index_options(self, expr) -> tuple: ... # MySQL-specific ``` This principle ensures: @@ -233,6 +265,8 @@ This principle ensures: When a generic protocol's formatting interface has backend-specific parameters, use the `dialect_options` parameter: ```python +from typing import Any, Dict, Optional, Protocol, Tuple + # Generic protocol defines the interface with dialect_options class JSONSupport(Protocol): def supports_json_table( @@ -264,13 +298,19 @@ class JSONSupport(Protocol): **Usage in expression:** ```python -# Pass dialect_options when creating expressions +# Backend-specific options belong to the capability probe, not to the +# expression: JSONTableExpression takes the generic arguments only. +from rhosocial.activerecord.backend.expression.query_sources import ( + JSONTableColumn, + JSONTableExpression, +) + JSONTableExpression( dialect, - json_column=User.json_data, + json_column="json_data", path='$.addresses[*]', - columns=[...], - dialect_options={'on_error': 'IGNORE'} # MySQL-specific + columns=[JSONTableColumn("id", "INTEGER", "$.id")], + alias="addresses", ) ``` @@ -445,6 +485,9 @@ Dialect format implementation **Step 1: Define the Protocol** (`mysql/protocols.py`) ```python +from typing import Protocol, Tuple, runtime_checkable + + @runtime_checkable class FullTextSearchSupport(Protocol): def supports_fulltext_index(self) -> bool: @@ -462,10 +505,17 @@ class FullTextSearchSupport(Protocol): **Step 2: Define the Expression** (`mysql/expression/match_against.py`) ```python -class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +from typing import List, Optional + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.expression import AliasableMixin, SQLValue +from rhosocial.activerecord.backend.expression.mixins import ComparisonMixin + + +class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValue): def __init__( self, - dialect: MySQLDialect, + dialect: SQLDialectBase, columns: List[str], search_string: str, mode: Optional[str] = None, @@ -480,21 +530,29 @@ class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValueExpression # Never override to_sql(): BaseExpression.to_sql() resolves this # name on the bound dialect and calls dialect.format_match_against(expr). return "format_match_against" - - def as_(self, alias: str) -> AliasColumn: - return AliasColumn(self, alias) ``` **Step 3: Implement the Protocol in Dialect** (`mysql/dialect.py`) ```python +from typing import Protocol, Tuple, runtime_checkable + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + +@runtime_checkable +class FullTextSearchSupport(Protocol): + def supports_fulltext_index(self) -> bool: ... + + def format_match_against(self, expr: "MatchAgainstExpression") -> Tuple[str, tuple]: ... + + class MySQLDialect( - MySQLBaseMixin, + SQLDialectBase, FullTextSearchSupport, # Add protocol - ... ): def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def format_match_against( self, @@ -502,7 +560,7 @@ class MySQLDialect( ) -> Tuple[str, tuple]: cols_sql = ", ".join(self.format_identifier(c) for c in expr.columns) placeholder = self.get_parameter_placeholder() - + # Mode handling mode_map = { "NATURAL_LANGUAGE": "IN NATURAL LANGUAGE MODE", @@ -510,7 +568,7 @@ class MySQLDialect( "QUERY_EXPANSION": "IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION", } mode_str = mode_map.get(expr.mode, "IN NATURAL LANGUAGE MODE") - + sql = f"MATCH({cols_sql}) AGAINST({placeholder} {mode_str})" return sql, (expr.search_string,) ``` @@ -519,7 +577,8 @@ class MySQLDialect( ```python def test_match_against_expression(self): - dialect = MySQLDialect(version=(8, 0, 0)) + dialect = MySQLDialect() + dialect.version = (8, 0, 0) expr = MatchAgainstExpression( dialect, columns=['title', 'content'], @@ -547,28 +606,34 @@ For better organization, protocol implementations can be separated into individu **`mysql/mixins.py`** (Protocol Implementation) ```python +from typing import Tuple + + class MySQLFullTextMixin: def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def supports_fulltext_parser(self) -> bool: - return self.version >= (5, 1, 0) + return self.version_at_least((5, 1, 0)) def format_match_against( self, expr: MatchAgainstExpression, ) -> Tuple[str, tuple]: # Implementation... + return "", () ``` **`mysql/dialect.py`** (Compose the Mixin) ```python +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + class MySQLDialect( - MySQLBaseMixin, + SQLDialectBase, MySQLFullTextMixin, # Separate mixin for organization FullTextSearchSupport, - ... ): pass ``` @@ -604,16 +669,20 @@ Benefits: **Example**: Adding `MODIFY COLUMN` support for MySQL ```python +from typing import Protocol, Tuple, runtime_checkable + + # Step 1: MySQL-specific Protocol (mysql/protocols.py) @runtime_checkable class MySQLModifyColumnSupport(Protocol): def supports_modify_column(self) -> bool: ... def format_modify_column_action(self, action) -> Tuple[str, tuple]: ... + # Step 2: MySQL-specific Mixin (mysql/mixins.py) class MySQLModifyColumnMixin: def supports_modify_column(self) -> bool: - return self.version >= (5, 0, 0) # All MySQL 5.x+ support this + return self.version_at_least((5, 0, 0)) # All MySQL 5.x+ support this def format_modify_column_action(self, action) -> Tuple[str, tuple]: col_sql, col_params = self.format_column_definition(action.column) @@ -624,11 +693,15 @@ class MySQLModifyColumnMixin: sql += " FIRST" return sql, col_params + # Step 3: MySQL dialect mixes in Protocol + Mixin (mysql/dialect.py) +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + class MySQLDialect( + SQLDialectBase, MySQLModifyColumnMixin, MySQLModifyColumnSupport, - ... ): pass # Mixin already provides implementation, no override needed ``` diff --git a/docs/en_US/backend/explain.md b/docs/en_US/backend/explain.md index 8ed394a7b..83045a9b4 100644 --- a/docs/en_US/backend/explain.md +++ b/docs/en_US/backend/explain.md @@ -45,10 +45,11 @@ The explain system follows the project's sync/async parity principle. Protocols All explain results inherit from `BaseExplainResult`, a plain `pydantic.BaseModel` (not an `ActiveRecord`): ```python -class BaseExplainResult(BaseModel): - raw_rows: List[Dict[str, Any]] # Raw rows from fetch_all() - sql: str # The EXPLAIN SQL that was executed - duration: float # Execution time in seconds +from rhosocial.activerecord.backend.explain.types import BaseExplainResult + +# The model fields every explain result carries +for name, field in BaseExplainResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` Using `pydantic.BaseModel` avoids a reverse dependency from the backend layer back to the ActiveRecord model layer. @@ -60,15 +61,16 @@ Using `pydantic.BaseModel` avoids a reverse dependency from the backend layer ba ```python from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainResult -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect dialect = SQLiteDialect() -backend = SQLiteBackend(database="mydb.sqlite3") +backend = SQLiteBackend(database=":memory:") backend.connect() +backend.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)") # Explain the bytecode for a query -expr = RawSQLExpression(dialect, "SELECT * FROM users WHERE id = 1") +expr = RawSQL(dialect, "SELECT * FROM users WHERE id = 1") result = backend.explain(expr) assert isinstance(result, SQLiteExplainResult) @@ -80,18 +82,18 @@ for row in result.rows: ### Using the Expression Builder -You can pass any `BaseExpression` (except `ExplainExpression` itself): +You can pass any `BaseExpression` (except `ExplainStatement` itself): ```python -from rhosocial.activerecord.backend.expression.core import ( - TableExpression, WildcardExpression, +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, NamedRelationRef, QueryStatement, ) -from rhosocial.activerecord.backend.expression.statements import QueryExpression +from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "users"), + select=[AllColumnsWildcard(dialect)], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) result = backend.explain(query) ``` @@ -117,13 +119,25 @@ for row in result.rows: ### Async API ```python +import asyncio + from rhosocial.activerecord.backend.impl.sqlite.backend import AsyncSQLiteBackend -backend = AsyncSQLiteBackend(database="mydb.sqlite3") -await backend.connect() +async_backend = AsyncSQLiteBackend(database=":memory:") + + +async def main(): + await async_backend.connect() + await async_backend.execute( + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)" + ) + + result = await async_backend.explain(query) # bytecode + result = await async_backend.explain(query, opts) # query plan + await async_backend.disconnect() + -result = await backend.explain(query) # bytecode -result = await backend.explain(query, opts) # query plan +asyncio.run(main()) ``` ## SQLite Result Types @@ -135,20 +149,22 @@ SQLite provides two completely different output schemas depending on the EXPLAIN Returned for plain `EXPLAIN `. Each row represents one VDBE (Virtual DataBase Engine) instruction: ```python -class SQLiteExplainRow(BaseModel): - addr: int # Program counter address - opcode: str # Instruction name (e.g. "OpenRead", "SeekGE", "Column") - p1: int # First operand (often cursor number) - p2: int # Second operand (often B-tree page number or jump target) - p3: int # Third operand - p4: Optional[str] # Optional string or blob operand - p5: int # Flags bitmask - comment: Optional[str] # Human-readable annotation +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainRow + +# addr: program counter address; opcode: instruction name +# p1: first operand (often cursor number) +# p2: second operand (often B-tree page number or jump target) +# p3: third operand; p4: optional string or blob operand +# p5: flags bitmask; comment: human-readable annotation +for name, field in SQLiteExplainRow.model_fields.items(): + print(f"{name}: {field.annotation}") ``` ```python -class SQLiteExplainResult(BaseExplainResult): - rows: List[SQLiteExplainRow] +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainResult + +for name, field in SQLiteExplainResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` #### Understanding Bytecode Opcodes @@ -170,16 +186,23 @@ The most important opcodes for query analysis: Returned for `EXPLAIN QUERY PLAN `. Each row describes one step in the query strategy: ```python -class SQLiteExplainQueryPlanRow(BaseModel): - id: int # Row identifier (used to build the tree structure) - parent: int # Parent row id (0 = top-level) - notused: int # Reserved field (always 0 in current SQLite) - detail: str # Human-readable description of the step +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainQueryPlanRow + +# id: row identifier (used to build the tree structure) +# parent: parent row id (0 = top-level) +# notused: reserved field (always 0 in current SQLite) +# detail: human-readable description of the step +for name, field in SQLiteExplainQueryPlanRow.model_fields.items(): + print(f"{name}: {field.annotation}") ``` ```python -class SQLiteExplainQueryPlanResult(BaseExplainResult): - rows: List[SQLiteExplainQueryPlanRow] +from rhosocial.activerecord.backend.impl.sqlite.explain import ( + SQLiteExplainQueryPlanResult, +) + +for name, field in SQLiteExplainQueryPlanResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` The `detail` text uses keywords such as `SCAN`, `SEARCH`, and `USING (COVERING) INDEX` that directly indicate the query strategy. @@ -210,11 +233,24 @@ Returns one of four string labels: ### Example: Diagnosing Index Usage ```python -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL +from rhosocial.activerecord.backend.expression.statements import ( + ExplainOptions, ExplainType, +) -dialect = SQLiteDialect() -backend = SQLiteBackend(database="mydb.sqlite3") -backend.connect() +backend.execute( + "CREATE TABLE IF NOT EXISTS orders " + "(id INTEGER PRIMARY KEY, status TEXT, total REAL)" +) +backend.execute("CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status)") +backend.execute( + "CREATE TABLE IF NOT EXISTS order_items " + "(id INTEGER PRIMARY KEY, order_id INTEGER, sku TEXT)" +) +backend.execute( + "CREATE INDEX IF NOT EXISTS idx_order_items_order_sku " + "ON order_items (order_id, sku)" +) queries = { "full scan": "SELECT * FROM orders", @@ -224,7 +260,7 @@ queries = { for label, sql in queries.items(): # Bytecode analysis - result = backend.explain(RawSQLExpression(dialect, sql)) + result = backend.explain(RawSQL(dialect, sql)) print(f"{label}: {result.analyze_index_usage()}") print(f" is_full_scan={result.is_full_scan}, " f"is_index_used={result.is_index_used}, " @@ -232,7 +268,7 @@ for label, sql in queries.items(): # Query plan analysis (same properties available) plan = backend.explain( - RawSQLExpression(dialect, sql), + RawSQL(dialect, sql), ExplainOptions(type=ExplainType.QUERY_PLAN), ) for row in plan.rows: @@ -292,18 +328,23 @@ assert isinstance(async_backend, AsyncExplainBackendProtocol) 3. **Override `_parse_explain_result()`** to return your backend's typed result class. ```python -from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin -from rhosocial.activerecord.backend.base import StorageBackend from pydantic import BaseModel -from typing import List, Dict, Any +from typing import Any, Dict, List + +from rhosocial.activerecord.backend.base import StorageBackend +from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin +from rhosocial.activerecord.backend.explain.types import BaseExplainResult + class MyExplainRow(BaseModel): # Define fields matching your database's EXPLAIN output - ... + detail: str + class MyExplainResult(BaseExplainResult): rows: List[MyExplainRow] + class MyBackend(SyncExplainBackendMixin, StorageBackend): def _parse_explain_result( self, @@ -323,7 +364,7 @@ You also need to override `format_explain_statement()` in your dialect if your d | Parameter | Type | Description | |-----------|------|-------------| -| `expression` | `BaseExpression` | Any expression except `ExplainExpression` | +| `expression` | `BaseExpression` | Any expression except `ExplainStatement` | | `options` | `ExplainOptions \| None` | Optional explain options (type, format, analyze) | **Returns**: A `BaseExplainResult` subclass specific to the backend. diff --git a/docs/en_US/backend/expression/README.md b/docs/en_US/backend/expression/README.md index 78a20817b..715a8d314 100644 --- a/docs/en_US/backend/expression/README.md +++ b/docs/en_US/backend/expression/README.md @@ -73,7 +73,7 @@ Expressions can be serialized into three forms and reconstructed from them into Key points: - **The dialect is not serialized**: it must be supplied at deserialization time (the `dialect` parameter), because SQL generation depends on the dialect -- Nested expressions, tuples, `cast()` chains, dataclass values, and non-JSON-native scalars round-trip fully through reserved keys (`__expr__`, `__tuple__`, `__cast__`, `__vdc__`, `__value__`) +- Everything in an artifact is plain JSON: a node is `{kind, params}`, a parameter is a scalar, an array or an object. **There are no markers** — nothing for a reader to be told to dispatch on, which is what lets one artifact be byte-identical across languages. The only values JSON cannot spell — `bytes`, a `timedelta`, a `Decimal` — are bind values wrapped in a `Literal`, and travel as the text their kind names - Serialization lets expressions travel **across processes, machines, and languages**, laying the groundwork for query templating, caching, and distributed scenarios > For the full serialization spec (spec format, nested/tuple markers, security considerations and the registry mechanism) see [Expression Serialization](serialization.md); for wiring custom expressions into serialization see [Extending](extending.md). @@ -94,15 +94,16 @@ The expression system allows you to compose queries programmatically: ```python from rhosocial.activerecord.backend.expression import ( - QueryExpression, TableExpression, Column, Literal + QueryStatement, NamedRelationRef, Column, IntegerColumn ) +from rhosocial.activerecord.backend.expression.objects import Table # SELECT name, age FROM users WHERE age >= 18 -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "age")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "age") >= Literal(dialect, 18) + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=IntegerColumn(dialect, "age") >= 18 ) sql, params = query.to_sql() # sql: 'SELECT "name", "age" FROM "users" WHERE "age" >= ?' diff --git a/docs/en_US/backend/expression/advanced.md b/docs/en_US/backend/expression/advanced.md index 3985aea1e..16ab246f5 100644 --- a/docs/en_US/backend/expression/advanced.md +++ b/docs/en_US/backend/expression/advanced.md @@ -12,12 +12,14 @@ The preferred way is through the `cast()` method on expressions: ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import CustomType col = Column(dialect, "amount") -expr = col.cast("money") +expr = col.cast(CustomType(dialect, raw="money")) sql, params = expr.to_sql() -# PostgreSQL: ("amount"::money, ()) -# Other backends: (CAST("amount" AS money), ()) +# sql: 'CAST("amount" AS money)' +# params: () +# PostgreSQL spells the same cast with its own operator: '"amount"::money' ``` ### Using the cast() Function @@ -25,76 +27,88 @@ sql, params = expr.to_sql() For more flexibility, you can use the `cast()` function from the functions module: ```python -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import Column, IntegerColumn, Literal from rhosocial.activerecord.backend.expression.functions import cast +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType -# Cast a column +# Cast a column -- the target is a DataType instance, never a string col = Column(dialect, "price") -expr = cast(dialect, col, "integer") +expr = cast(dialect, col, IntegerType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("price"::integer, ()) -# Other backends: (CAST("price" AS integer), ()) +# sql: 'CAST("price" AS INTEGER)' +# params: () # Cast a literal value -expr = cast(dialect, "123", "INTEGER") +expr = cast(dialect, Literal(dialect, "123"), TextType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ('123'::INTEGER, ()) -# Other backends: (CAST('123' AS INTEGER), ()) +# sql: 'CAST(? AS TEXT)' +# params: ('123',) -# Cast an arithmetic expression -expr = cast(dialect, Column(dialect, "value") + Literal(dialect, 1), "TEXT") +# Cast an arithmetic expression -- arithmetic needs a numeric column class +expr = cast(dialect, IntegerColumn(dialect, "value") + 1, TextType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("value" + 1)::TEXT, ()) -# Other backends: (CAST("value" + 1 AS TEXT), ()) +# sql: 'CAST("value" + ? AS TEXT)' +# params: (1,) ``` ### Chained Type Conversions ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import ( + CustomType, DecimalType, FloatType, +) col = Column(dialect, "amount") -# Multi-level type conversion: money -> numeric -> float8 -expr = col.cast("money").cast("numeric").cast("float8") +# Multi-level type conversion: money -> decimal -> float +expr = col.cast(CustomType(dialect, raw="money")) +expr = expr.cast(DecimalType(dialect)).cast(FloatType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("amount"::money::numeric::float8, ()) +# sql: 'CAST(CAST(CAST("amount" AS money) AS DECIMAL) AS FLOAT)' +# params: () ``` ### With Type Modifiers -Type modifiers should be included directly in the type string: +Type modifiers are constructor arguments of the `DataType`: ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import DecimalType, VarCharType col = Column(dialect, "name") -expr = col.cast("VARCHAR(100)") +expr = col.cast(VarCharType(dialect, length=100)) sql, params = expr.to_sql() -# PostgreSQL: ("name"::VARCHAR(100), ()) +# sql: 'CAST("name" AS VARCHAR(100))' +# params: () col2 = Column(dialect, "price") -expr2 = col2.cast("NUMERIC(10,2)") +expr2 = col2.cast(DecimalType(dialect, precision=10, scale=2)) sql, params = expr2.to_sql() -# PostgreSQL: ("price"::NUMERIC(10,2), ()) +# sql: 'CAST("price" AS DECIMAL(10,2))' +# params: () ``` ### Using in Queries ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import DecimalType # Use in WHERE clause col = Column(dialect, "amount") -predicate = col.cast("numeric") > 100 +predicate = col.cast(DecimalType(dialect)) > 100 sql, params = predicate.to_sql() -# PostgreSQL: ("amount"::numeric > %s, (100,)) +# sql: 'CAST("amount" AS DECIMAL) > ?' +# params: (100,) # Use in arithmetic expressions col1 = Column(dialect, "price1") col2 = Column(dialect, "price2") -expr = col1.cast("numeric") + col2.cast("numeric") +expr = col1.cast(DecimalType(dialect)) + col2.cast(DecimalType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("price1"::numeric + "price2"::numeric, ()) +# sql: 'CAST("price1" AS DECIMAL) + CAST("price2" AS DECIMAL)' +# params: () ``` ### PostgreSQL Type Casting Syntax @@ -119,7 +133,9 @@ A window function call is a `FunctionCall` carrying a `window_spec`: attach it fluently with `.over(...)`, or pass `window_spec=` at construction. ```python -from rhosocial.activerecord.backend.expression import FunctionCall, WindowSpecification, Column, Literal +from rhosocial.activerecord.backend.expression import ( + Column, FunctionCall, Literal, WindowSpecification, +) from rhosocial.activerecord.backend.expression.query_parts import OrderByClause # Define Window Specification @@ -160,97 +176,106 @@ sql, params = frame_spec.to_sql() CTEs provide a way to write auxiliary statements for use in a larger query. -### WithQueryExpression +### WithStatement -Represents a `WITH` clause containing one or more CTEs. +Represents a complete `WITH` statement: the CTE definitions plus the query they precede. A single CTE definition is a `CTEClause`. ```python -from rhosocial.activerecord.backend.expression import WithQueryExpression, CTEExpression, Subquery, QueryExpression, TableExpression, Column +from rhosocial.activerecord.backend.expression import ( + CTEClause, Column, NamedRelationRef, QueryStatement, Subquery, WithStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table # Define a CTE cte_query = Subquery(dialect, "SELECT id, name FROM users WHERE status = ?", ("active",)) -cte = CTEExpression(dialect, name="active_users", query=cte_query) +cte = CTEClause(dialect, name="active_users", query=cte_query) # Main Query using the CTE -main_query = QueryExpression( +main_query = QueryStatement( dialect, select=[Column(dialect, "name")], - from_=TableExpression(dialect, "active_users") + from_=NamedRelationRef(dialect, Table(dialect, "active_users")) ) # Combine into WITH query -with_query = WithQueryExpression(dialect, ctes=[cte], main_query=main_query) +with_query = WithStatement(dialect, ctes=[cte], query=main_query) sql, params = with_query.to_sql() -# sql: 'WITH "active_users" AS (SELECT id, name FROM users WHERE status = ?) SELECT "name" FROM "active_users"' -# params: ("active",) +# sql: 'WITH "active_users" AS ((SELECT id, name FROM users WHERE status = ?)) ' +# 'SELECT "name" FROM "active_users"' +# params: ('active',) ``` ## Set Operations Combine the results of two or more queries into a single result set. -### SetOperationExpression +### SetOperation -Supports `UNION`, `UNION ALL`, `INTERSECT`, and `EXCEPT`. +Supports `UNION`, `UNION ALL`, `INTERSECT`, and `EXCEPT`. The operation is a +`SetOperationKind` member; `UNION ALL` is the `UNION_ALL` member. ```python -from rhosocial.activerecord.backend.expression import SetOperationExpression, Subquery +from rhosocial.activerecord.backend.expression import Subquery +from rhosocial.activerecord.backend.expression.query_sources import ( + SetOperation, SetOperationKind, +) query1 = Subquery(dialect, "SELECT id FROM table1 WHERE col = ?", ("a",)) query2 = Subquery(dialect, "SELECT id FROM table2 WHERE col = ?", ("b",)) # UNION -union_op = SetOperationExpression( +union_op = SetOperation( dialect, + op=SetOperationKind.UNION, left=query1, - right=query2, - operation="UNION" + right=query2 ) sql, params = union_op.to_sql() # sql: '(SELECT id FROM table1 WHERE col = ?) UNION (SELECT id FROM table2 WHERE col = ?)' -# params: ("a", "b") +# params: ('a', 'b') # INTERSECT -intersect_op = SetOperationExpression( +intersect_op = SetOperation( dialect, + op=SetOperationKind.INTERSECT, left=query1, - right=query2, - operation="INTERSECT" + right=query2 ) sql, params = intersect_op.to_sql() # sql: '(SELECT id FROM table1 WHERE col = ?) INTERSECT (SELECT id FROM table2 WHERE col = ?)' -# params: ("a", "b") +# params: ('a', 'b') ``` ## JSON Functions Expressions for working with JSON data. -### JSONDocumentExpression / JSONTextExpression +### JSONDocumentValue / JSONTextValue Represents JSON path extraction. The operator picks the class: ``->`` -yields a document and is a ``JSONDocumentExpression``, ``->>`` yields text and -is a ``JSONTextExpression``. Two classes rather than one class with a spelling +yields a document and is a ``JSONDocumentValue``, ``->>`` yields text and +is a ``JSONTextValue``. Two classes rather than one class with a spelling parameter, because the two results are different kinds of value -- a document can be indexed into again, text cannot. ```python from rhosocial.activerecord.backend.expression import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) col = Column(dialect, "data") # Extract value from JSON path as text -json_extract = JSONTextExpression(dialect, col, "$.user.name", operation="->>") +json_extract = JSONTextValue(dialect, col, "$.user.name", operation="->>") # A nested path is still a document, so it can be walked further -nested = JSONDocumentExpression(dialect, col, "$.user", operation="->") +nested = JSONDocumentValue(dialect, col, "$.user", operation="->") sql, params = json_extract.to_sql() -# sql: '"data"->>?', or dialect-specific equivalent like 'JSON_EXTRACT("data", ?)' -# params: ("$.user.name",) +# sql: 'JSON_UNQUOTE(JSON_EXTRACT("data", '$.user.name'))' +# params: () +# Dialects that spell the operator directly render '"data"->>?' form instead. ``` ## Table Functions diff --git a/docs/en_US/backend/expression/clauses.md b/docs/en_US/backend/expression/clauses.md index f579e0106..2f51a65d0 100644 --- a/docs/en_US/backend/expression/clauses.md +++ b/docs/en_US/backend/expression/clauses.md @@ -4,16 +4,15 @@ This document details the classes in `rhosocial.activerecord.backend.expression. ## JoinType -Enumeration of SQL JOIN types. +Enumeration of SQL JOIN types. The stored value is the bare word (`inner`, +`left`, ...); the dialect spells the JOIN keyword. ```python -class JoinType(Enum): - INNER = "INNER JOIN" - LEFT = "LEFT JOIN" - RIGHT = "RIGHT JOIN" - FULL = "FULL JOIN" - CROSS = "CROSS JOIN" - # ... +from rhosocial.activerecord.backend.expression.query_parts import JoinType + +# The member value is the bare word; the dialect spells the JOIN keyword +for member in JoinType: + print(f"{member.name:<12} {member.value}") ``` ## WhereClause @@ -21,26 +20,48 @@ class JoinType(Enum): Represents a `WHERE` clause in a SQL query. ```python -class WhereClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", condition: "bases.SQLPredicate"): ... - - def and_(self, predicate: "bases.SQLPredicate") -> 'WhereClause': ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import WhereClause + +# The constructor and and_() as the library declares them +print(inspect.signature(WhereClause.__init__)) +print(inspect.signature(WhereClause.and_)) ``` ### Usage ```python -# WHERE status = 'active' -# where_clause = WhereClause( -# dialect, -# condition=Column(dialect, "status") == Literal(dialect, "active") -# ) -# -> ('WHERE "status" = ?', ('active',)) +from rhosocial.activerecord.backend.expression import ( + Column, ComparisonOperator, ComparisonPredicate, IntegerColumn, Literal, + StringColumn, +) +from rhosocial.activerecord.backend.expression.query_parts import WhereClause + +# WHERE status = 'active' -- a typed column wraps the plain value in a Literal +where_clause = WhereClause( + dialect, + condition=StringColumn(dialect, "status") == "active" +) +sql, params = where_clause.to_sql() +# sql: 'WHERE "status" = ?' +# params: ('active',) + +# The same predicate spelled out as an explicit node +where_clause = WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "status"), + Literal(dialect, "active"), + ) +) -# Chaining AND conditions -# WHERE age > 18 AND status = 'active' -# where_clause.and_(Column(dialect, "age") > Literal(dialect, 18)) -# -> ('WHERE "status" = ? AND "age" > ?', ('active', 18)) +# Chaining AND conditions: WHERE status = 'active' AND age > 18 +where_clause = where_clause.and_(IntegerColumn(dialect, "age") > 18) +sql, params = where_clause.to_sql() +# sql: 'WHERE "status" = ? AND "age" > ?' +# params: ('active', 18) ``` ## GroupByHavingClause @@ -48,23 +69,27 @@ class WhereClause(bases.BaseExpression): Represents combined `GROUP BY` and `HAVING` clauses. Enforces the rule that `HAVING` requires `GROUP BY`. ```python -class GroupByHavingClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - group_by: Optional[List["bases.BaseExpression"]] = None, - having: Optional["bases.SQLPredicate"] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause + +print(inspect.signature(GroupByHavingClause.__init__)) ``` ### Usage ```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause + # GROUP BY category HAVING COUNT(id) > 5 -# group_clause = GroupByHavingClause( -# dialect, -# group_by=[Column(dialect, "category")], -# having=FunctionCall(dialect, "COUNT", Column(dialect, "id")) > Literal(dialect, 5) -# ) -# -> ('GROUP BY "category" HAVING COUNT("id") > ?', (5,)) +group_clause = GroupByHavingClause( + dialect, + group_by=[Column(dialect, "category")], + having=FunctionCall(dialect, "COUNT", Column(dialect, "id")) > 5 +) +sql, params = group_clause.to_sql() +# sql: 'GROUP BY "category" HAVING COUNT("id") > ?' +# params: (5,) ``` ## OrderByClause @@ -72,27 +97,29 @@ class GroupByHavingClause(bases.BaseExpression): Represents an `ORDER BY` clause. ```python -class OrderByClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - expressions: List[Union[ - "bases.BaseExpression", - Tuple["bases.BaseExpression", str] - ]]): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +print(inspect.signature(OrderByClause.__init__)) ``` ### Usage ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + # ORDER BY created_at DESC, name ASC -# order_by = OrderByClause( -# dialect, -# expressions=[ -# (Column(dialect, "created_at"), "DESC"), -# (Column(dialect, "name"), "ASC") -# ] -# ) -# -> ('ORDER BY "created_at" DESC, "name" ASC', ()) +order_by = OrderByClause( + dialect, + expressions=[ + (Column(dialect, "created_at"), "DESC"), + (Column(dialect, "name"), "ASC") + ] +) +sql, params = order_by.to_sql() +# sql: 'ORDER BY "created_at" DESC, "name" ASC' +# params: () ``` ## LimitOffsetClause @@ -100,19 +127,22 @@ class OrderByClause(bases.BaseExpression): Represents `LIMIT` and `OFFSET` clauses. ```python -class LimitOffsetClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - limit: Optional[Union[int, "bases.BaseExpression"]] = None, - offset: Optional[Union[int, "bases.BaseExpression"]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + +print(inspect.signature(LimitOffsetClause.__init__)) ``` ### Usage ```python +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + # LIMIT 10 OFFSET 20 -# limit_clause = LimitOffsetClause(dialect, limit=10, offset=20) -# -> ('LIMIT ? OFFSET ?', (10, 20)) +limit_clause = LimitOffsetClause(dialect, limit=10, offset=20) +sql, params = limit_clause.to_sql() +# sql: 'LIMIT ? OFFSET ?' +# params: (10, 20) ``` ## QualifyClause @@ -120,19 +150,27 @@ class LimitOffsetClause(bases.BaseExpression): Represents a `QUALIFY` clause (used in databases like Snowflake/BigQuery to filter window function results). ```python -class QualifyClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", condition: "bases.SQLPredicate"): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import QualifyClause + +print(inspect.signature(QualifyClause.__init__)) ``` ### Usage ```python -# QUALIFY ROW_NUMBER() OVER (...) <= 3 -# qualify_clause = QualifyClause( -# dialect, -# condition=row_number_func <= Literal(dialect, 3) -# ) -# -> ('QUALIFY ROW_NUMBER() OVER (...) <= ?', (3,)) +from rhosocial.activerecord.backend.expression import FunctionCall +from rhosocial.activerecord.backend.expression.query_parts import QualifyClause + +# QUALIFY ROW_NUMBER() <= 3 +row_number_func = FunctionCall(dialect, "ROW_NUMBER") +qualify_clause = QualifyClause( + dialect, + condition=row_number_func <= 3 +) +sql, params = qualify_clause.to_sql() +# sql: 'QUALIFY ROW_NUMBER() <= ?' +# params: (3,) ``` ## ForUpdateClause @@ -140,89 +178,108 @@ class QualifyClause(bases.BaseExpression): Represents a `FOR UPDATE` locking clause. ```python -class ForUpdateClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", - of_columns: Optional[List[Union[str, "bases.BaseExpression"]]] = None, - nowait: bool = False, - skip_locked: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + +print(inspect.signature(ForUpdateClause.__init__)) ``` ### Usage ```python -# FOR UPDATE OF users NOWAIT +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + +# FOR UPDATE OF "users" NOWAIT lock_clause = ForUpdateClause( dialect, of_columns=[Column(dialect, "users")], nowait=True ) +sql, params = lock_clause.to_sql() +# sql: 'FOR UPDATE OF "users" NOWAIT' +# params: () ``` -## JoinExpression +## JoinClause -Represents a `JOIN` expression. Supports chaining. +Represents a `JOIN` clause. Supports chaining. ```python -class JoinExpression(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - left_table: Union[str, "core.TableExpression", "core.Subquery", "QueryExpression", "JoinExpression"], - right_table: Union[str, "core.TableExpression", "core.Subquery", "QueryExpression", "JoinExpression"], - join_type: str = "JOIN", - condition: Optional["bases.SQLPredicate"] = None, - using: Optional[List[str]] = None, - natural: bool = False, - alias: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import JoinClause - def join(self, right_table, join_type="JOIN", condition=None, using=None, natural=False, alias=None) -> "JoinExpression": ... - def inner_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def left_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def right_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def full_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def cross_join(self, right_table, alias=None) -> "JoinExpression": ... +print(inspect.signature(JoinClause.__init__)) +for name in ("join", "inner_join", "left_join", "right_join", "full_join", "cross_join"): + print(name, inspect.signature(getattr(JoinClause, name))) ``` ### Usage ```python +from rhosocial.activerecord.backend.expression import ( + Column, ComparisonOperator, ComparisonPredicate, NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import JoinClause + # users JOIN orders ON users.id = orders.user_id -# join_expr = JoinExpression( -# dialect, -# left_table=TableExpression(dialect, "users"), -# right_table=TableExpression(dialect, "orders"), -# join_type="INNER JOIN", -# condition=Column(dialect, "id", "users") == Column(dialect, "user_id", "orders") -# ) -# -> ('"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id"', ()) +join_expr = JoinClause( + dialect, + left_table=NamedRelationRef(dialect, Table(dialect, "users")), + right_table=NamedRelationRef(dialect, Table(dialect, "orders")), + join_type="INNER JOIN", + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "users"), + Column(dialect, "user_id", "orders"), + ) +) +sql, params = join_expr.to_sql() +# sql: '"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id"' +# params: () # Chaining: users JOIN orders ... LEFT JOIN products ... -# complex_join = join_expr.left_join( -# right_table=TableExpression(dialect, "products"), -# condition=Column(dialect, "product_id", "orders") == Column(dialect, "id", "products") -# ) -# -> ('"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id" LEFT JOIN "products" ON "orders"."product_id" = "products"."id"', ()) +complex_join = join_expr.left_join( + right_table=NamedRelationRef(dialect, Table(dialect, "products")), + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "product_id", "orders"), + Column(dialect, "id", "products"), + ) +) +sql, params = complex_join.to_sql() +# sql: '("users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id") ' +# 'LEFT JOIN "products" ON "orders"."product_id" = "products"."id"' +# params: () ``` -## GroupingExpression +## GroupingClause Represents grouping operations like `ROLLUP`, `CUBE`. ```python -class GroupingExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", - operation: str, - expressions: List["bases.BaseExpression"]): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import GroupingClause + +print(inspect.signature(GroupingClause.__init__)) ``` ### Usage ```python -# ROLLUP(year, month) -rollup = GroupingExpression( +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import GroupingClause + +# ROLLUP("year", "month") +rollup = GroupingClause( dialect, operation="ROLLUP", expressions=[Column(dialect, "year"), Column(dialect, "month")] ) +sql, params = rollup.to_sql() +# sql: 'ROLLUP("year", "month")' +# params: () ``` diff --git a/docs/en_US/backend/expression/core.md b/docs/en_US/backend/expression/core.md index 6a75bb242..a10ae3e5e 100644 --- a/docs/en_US/backend/expression/core.md +++ b/docs/en_US/backend/expression/core.md @@ -14,19 +14,20 @@ This section details the fundamental building blocks of the Expression System, i - [StringMixin](#stringmixin) - [TypeCastingMixin](#typecastingmixin) - [Core Expressions](#core-expressions) - - [TableExpression](#tableexpression) + - [Table Sources and Relation Objects](#table-sources-and-relation-objects) - [Column](#column) - [Literal](#literal) - - [WildcardExpression](#wildcardexpression) + - [AllColumnsWildcard](#allcolumnswildcard) - [Subquery](#subquery) - [Operators](#operators) - [Identifier](#identifier) - [SQLOperation](#sqloperation) - [BinaryExpression](#binaryexpression) - - [BinaryArithmeticExpression](#binaryarithmeticexpression) + - [ArithmeticOperation](#arithmeticoperation) - [UnaryExpression](#unaryexpression) - - [RawSQLExpression](#rawsqlexpression) + - [RawSQL](#rawsqlexpression) - [RawSQLPredicate](#rawsqlpredicate) +- [COLLATE Expressions](#collate-expressions) ## Base Classes @@ -37,6 +38,8 @@ The expression system is built on a hierarchy of abstract base classes defined i The `ToSQLProtocol` defines the contract for any object that can be converted to SQL. ```python +from typing import Protocol, Tuple + class ToSQLProtocol(Protocol): def to_sql(self) -> Tuple[str, tuple]: """ @@ -53,12 +56,13 @@ class ToSQLProtocol(Protocol): `BaseExpression` is the root base class for all expression components. It satisfies `ToSQLProtocol` structurally, holds a **per-node** dialect reference, and implements `to_sql()` **once, centrally** — subclasses never override it; they declare only their dialect formatting method name via the read-only `format_method` property. ```python +from typing import Optional, Tuple + class BaseExpression: def __init__(self, dialect: Optional["SQLDialectBase"] = None): # The dialect is the conventional first argument but optional — # binding may be deferred (e.g. model declaration time has no # dialect; ActiveRecord's DDL derivation binds one per node). - self._inline_literals = False self.dialect = dialect @property @@ -95,16 +99,18 @@ class BaseExpression: `SQLPredicate` represents an expression that evaluates to a boolean value (e.g., `WHERE` conditions). It mixes in `LogicalMixin` to support `&` (AND), `|` (OR), and `~` (NOT) operators. ```python +from rhosocial.activerecord.backend.expression import bases, mixins + class SQLPredicate(mixins.LogicalMixin, BaseExpression): pass ``` -### SQLValueExpression +### SQLValue -`SQLValueExpression` represents an expression that evaluates to a scalar value (e.g., columns, literals, function results). +`SQLValue` represents an expression that evaluates to a scalar value (e.g., columns, literals, function results). ```python -class SQLValueExpression(BaseExpression): +class SQLValue(BaseExpression): pass ``` @@ -117,90 +123,161 @@ The `rhosocial.activerecord.backend.expression.core` module defines the fundamen Represents a literal value in a SQL query. It handles parameter binding automatically. ```python -class Literal(mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", value: Any, *, inline_literals: bool = False): ... - - # Default: bind-parameter mode - # Literal(dialect, "active") - # -> ('?', ('active',)) +import inspect +from rhosocial.activerecord.backend.expression import Literal - # Inline mode: value embedded directly in SQL (for DDL clauses) - # Literal(dialect, "active", inline_literals=True) - # -> ("'active'", ()) +# The constructor as the library declares it +print(inspect.signature(Literal.__init__)) + +# Default: bind-parameter mode +# Literal(dialect, "active") +# -> ('?', ('active',)) + +# Inline mode: value embedded directly in SQL (for DDL clauses) +# Literal(dialect, "active", inline=True) +# -> ("'active'", ()) ``` #### Inline Literals By default, `Literal` renders as a **bind parameter** (`?` or `%s` depending on the dialect), following DB-API 2.0 (PEP 249) conventions. This is the safe, preferred mode for DML statements (`INSERT`, `SELECT`, `UPDATE`, `DELETE`). -However, **DDL clauses** (`DEFAULT`, `CHECK`, partition boundaries, partial-index `WHERE`) do **not** accept bind parameters — the database engine expects literal SQL text. For these cases, set `inline_literals=True` on the `Literal` node: +However, **DDL clauses** (`DEFAULT`, `CHECK`, partition boundaries, partial-index `WHERE`) do **not** accept bind parameters — the database engine expects literal SQL text. For these cases, set `inline=True` on the `Literal` node: ```python from rhosocial.activerecord.backend.expression import ( - Literal, Column, ComparisonPredicate, - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + Column, + ColumnCheckConstraint, + ColumnConstraint, + ColumnDefaultConstraint, + ColumnDefinition, + ComparisonOperator, + ComparisonPredicate, + Literal, ) from rhosocial.activerecord.backend.expression.types import IntegerType # DDL: DEFAULT value must be inlined -ColumnDefinition(dialect, +ColumnDefinition( + dialect, name="age", data_type=IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0, inline_literals=True), # -> DEFAULT 0 + ColumnConstraint( + dialect, + branch=ColumnDefaultConstraint( + dialect, + value=Literal(dialect, 0, inline=True), # -> DEFAULT 0 + ), ), ], ) # DDL: CHECK condition — the comparison value must be inlined -ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", - Column(dialect, "score"), - Literal(dialect, 0, inline_literals=True), # -> CHECK ("score" >= 0) +ColumnConstraint( + dialect, + branch=ColumnCheckConstraint( + dialect, + condition=ComparisonPredicate( + dialect, + ComparisonOperator.GTE, + Column(dialect, "score"), + Literal(dialect, 0, inline=True), # -> CHECK ("score" >= 0) + ), ), ) ``` -The `inline_literals` switch is **per-node** — each `Literal` decides independently. A single predicate can mix inline and bind-parameter values: +The `inline` switch is **per-node** — each `Literal` decides independently. A single predicate can mix inline and bind-parameter values: ```python +from rhosocial.activerecord.backend.expression import ComparisonPredicate, Literal + pred = ComparisonPredicate( dialect, "=", - Literal(dialect, "a", inline_literals=True), # inline - Literal(dialect, "b"), # bind param + Literal(dialect, "a", inline=True), # inline + Literal(dialect, "b"), # bind param ) pred.to_sql() # -> ("'a' = ?", ("b",)) ``` -> **Security note**: Inline literals are a SQL-injection hazard when the value can be influenced by end users. Only use `inline_literals=True` for developer-declared constants (DDL defaults, schema-derived values). +> **Security note**: Inline literals are a SQL-injection hazard when the value can be influenced by end users. Only use `inline=True` for developer-declared constants (DDL defaults, schema-derived values). ### Column Represents a column reference, optionally with a table qualifier and an alias. ```python -class Column(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", name: str, table: Optional[str] = None, alias: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import Column + +print(inspect.signature(Column.__init__)) + +# Example: users.name +# Column(dialect, "name", table="users") +``` + +The bare `Column` is a *reference* only. Comparison, arithmetic and string operators were deliberately removed from it: `Column(...) == Literal(...)` is a plain Python `bool`, not a predicate. Use a typed column instead — `StringColumn`, `IntegerColumn`, `NumericColumn`, `TimestampColumn` or `BooleanColumn` — which wraps a plain Python value in a `Literal` and therefore builds a real predicate. `ComparisonPredicate(dialect, ComparisonOperator.EQ, left, right)` is the explicit node when the operands are already expressions. + +### Table Sources and Relation Objects + +Everything a `FROM` clause can name is a **table source**, but not every source is a persisted object. The two questions are answered by two separate trees: + +- **Persisted relations** — `Table`, `View`, `MaterializedView`, `ForeignTable`, and the graph tables `NodeTable` / `EdgeTable` — live in `rhosocial.activerecord.backend.expression.objects`. They carry catalog identity (a name plus optional catalog and schema) and nothing else. +- **Query-only sources** — `NamedRelationRef`, `DerivedTableSource`, `ValuesTableSource`, `TableFunctionSource`, `JsonTableSource`, `XmlTableSource` and `GraphTableSource` — live in `rhosocial.activerecord.backend.expression.sources`. Their rows exist for the duration of one statement; `FROM json_table(...)` has no catalog identity at all. + +The trees are connected by **composition**, not inheritance: `NamedRelationRef` holds a relation object. Aliases and temporal options belong to the source, because `FROM users AS u` renames a row for the duration of a query and renames nothing in the catalogue. + +```python +import inspect +from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable, + ForeignTable, + MaterializedView, + NodeTable, + Table, + View, +) - # Example: users.name - # Column(dialect, "name", table="users") +for relation in (Table, View, MaterializedView, ForeignTable, NodeTable, EdgeTable): + print(relation.__name__, inspect.signature(relation.__init__)) ``` -### TableExpression +```python +import inspect +from rhosocial.activerecord.backend.expression.sources import ( + DerivedTableSource, + GraphTableSource, + JsonTableSource, + NamedRelationRef, + TableFunctionSource, + ValuesTableSource, + XmlTableSource, +) -Represents a table or view, optionally with an alias and temporal options (e.g., FOR SYSTEM_TIME). +for source in ( + NamedRelationRef, + DerivedTableSource, + ValuesTableSource, + TableFunctionSource, + JsonTableSource, + XmlTableSource, + GraphTableSource, +): + print(source.__name__, inspect.signature(source.__init__)) +``` ```python -class TableExpression(mixins.AliasableMixin, bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", name: str, alias: Optional[str] = None, temporal_options: Optional[Dict[str, Any]] = None): ... +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - # Example: FROM users AS u - # TableExpression(dialect, "users", alias="u") - # -> ('"users" AS "u"', ()) +# FROM "users" +NamedRelationRef(dialect, Table(dialect, "users")) +# -> ('"users"', ()) + +# FROM "users" AS "u" -- the alias lives on the source +NamedRelationRef(dialect, Table(dialect, "users"), alias="u") +# -> ('"users" AS "u"', ()) ``` ### FunctionCall @@ -210,8 +287,10 @@ Represents a generic scalar function call. When `niladic=True` and no arguments are provided, the function call generates SQL without parentheses (e.g., `CURRENT_TIMESTAMP` instead of `CURRENT_TIMESTAMP()`). Per SQL:2003, certain value functions are niladic and must not use parentheses when invoked without arguments. When a niladic function has arguments (e.g., `CURRENT_TIMESTAMP(6)`), parentheses are included as normal. ```python -class FunctionCall(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", func_name: str, *args: "bases.BaseExpression", is_distinct: bool = False, alias: Optional[str] = None, niladic: bool = False): ... +import inspect +from rhosocial.activerecord.backend.expression import FunctionCall + +print(inspect.signature(FunctionCall.__init__)) ``` **Niladic usage:** @@ -230,26 +309,36 @@ FunctionCall(dialect, 'NOW') # -> ('NOW()', ()) ``` -### WildcardExpression +### AllColumnsWildcard -Represents a wildcard (`*`) or table wildcard (`table.*`). PREFER this over `Literal("*")`. +Represents the select-list wildcard (`*`), optionally qualified through a +relation reference (`rel.*`). PREFER this over `Literal("*")`. ```python -class WildcardExpression(bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", table: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import AllColumnsWildcard + +print(inspect.signature(AllColumnsWildcard.__init__)) ``` +A bare `*` lets the surrounding clause decide what the expansion spans (the +select list's FROM sources, or a RETURNING clause's DML target). The row +count is a different contract: `COUNT(*)`'s parameter is `RowCountWildcard`, +which carries no relation and renders only the bare asterisk. + ### Subquery Represents a subquery expression. It can wrap a SQL string, a `(sql, params)` tuple, or another expression. ```python -class Subquery(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", query_input: Union[str, tuple, "BaseExpression"], query_params: Optional[tuple] = None, alias: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import Subquery + +print(inspect.signature(Subquery.__init__)) - # Example: (SELECT id FROM users WHERE age > ?) - # Subquery(dialect, "SELECT id FROM users WHERE age > ?", (25,)) - # -> ('(SELECT id FROM users WHERE age > ?)', (25,)) +# Example: (SELECT id FROM users WHERE age > ?) +# Subquery(dialect, "SELECT id FROM users WHERE age > ?", (25,)) +# -> ('(SELECT id FROM users WHERE age > ?)', (25,)) ``` ## Mixins @@ -267,37 +356,43 @@ Column(dialect, "name").as_("user_name") ### ComparisonMixin -Enables standard Python comparison operators (`==`, `!=`, `>`, `<`, `>=`, `<=`) to generate SQL predicates. +Enables standard Python comparison operators (`==`, `!=`, `>`, `<`, `>=`, `<=`) to generate SQL predicates. The mixin is carried by typed columns and expression wrappers; a bare `Column` deliberately does not carry it. ```python -# age >= 18 -Column(dialect, "age") >= 18 +from rhosocial.activerecord.backend.expression import ( + Column, IntegerColumn, StringColumn, +) + +# age >= 18 -- a typed column wraps 18 in a Literal +IntegerColumn(dialect, "age") >= 18 # status == 'active' -Column(dialect, "status") == "active" +StringColumn(dialect, "status") == "active" # IS NULL / IS NOT NULL Column(dialect, "email").is_null() Column(dialect, "email").is_not_null() # IN / NOT IN -Column(dialect, "status").in_(["active", "pending"]) -Column(dialect, "status").not_in(["banned", "deleted"]) +StringColumn(dialect, "status").in_(["active", "pending"]) +StringColumn(dialect, "status").not_in(["banned", "deleted"]) # BETWEEN -Column(dialect, "age").between(18, 65) +IntegerColumn(dialect, "age").between(18, 65) ``` ### ArithmeticMixin -Enables standard Python arithmetic operators (`+`, `-`, `*`, `/`, `%`) to generate SQL arithmetic expressions. +Enables standard Python arithmetic operators (`+`, `-`, `*`, `/`, `%`) to generate SQL arithmetic expressions. Arithmetic needs a numeric typed column, which also supplies the literal for the other operand. ```python +from rhosocial.activerecord.backend.expression import IntegerColumn, NumericColumn + # price * 0.9 -Column(dialect, "price") * 0.9 +NumericColumn(dialect, "price") * 0.9 # (count + 1) -Column(dialect, "count") + 1 +IntegerColumn(dialect, "count") + 1 ``` ### LogicalMixin @@ -305,11 +400,15 @@ Column(dialect, "count") + 1 Enables standard Python bitwise operators (`&`, `|`, `~`) to generate SQL logical predicates (`AND`, `OR`, `NOT`). ```python +from rhosocial.activerecord.backend.expression import ( + BooleanColumn, IntegerColumn, StringColumn, +) + # (age >= 18) AND (active = true) -(Column(dialect, "age") >= 18) & (Column(dialect, "active") == True) +(IntegerColumn(dialect, "age") >= 18) & (BooleanColumn(dialect, "active") == True) # NOT (status = 'banned') -~(Column(dialect, "status") == "banned") +~(StringColumn(dialect, "status") == "banned") ``` ### StringMixin @@ -317,11 +416,13 @@ Enables standard Python bitwise operators (`&`, `|`, `~`) to generate SQL logica Provides string-specific methods like `like` and `ilike`. ```python +from rhosocial.activerecord.backend.expression import StringColumn + # name LIKE 'John%' -Column(dialect, "name").like("John%") +StringColumn(dialect, "name").like("John%") # email ILIKE '%@gmail.com' -Column(dialect, "email").ilike("%@gmail.com") +StringColumn(dialect, "email").ilike("%@gmail.com") ``` ### TypeCastingMixin @@ -329,42 +430,52 @@ Column(dialect, "email").ilike("%@gmail.com") Provides type casting capability, allowing expressions to be converted to different SQL types. It follows the Expression-Dialect separation architecture where the actual SQL syntax is generated by each backend's `format_cast_expression` method. ```python -# Basic type casting -Column(dialect, "price").cast("integer") -# PostgreSQL generates: "price"::integer -# Other backends generate: CAST("price" AS integer) +from rhosocial.activerecord.backend.expression import NumericColumn, StringColumn +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType + +# Basic type casting -- the target is a DataType object, never a type-name string +NumericColumn(dialect, "price").cast(IntegerType(dialect)) +# PostgreSQL generates: "price"::INTEGER +# Other backends generate: CAST("price" AS INTEGER) -# Type casting with modifiers -Column(dialect, "name").cast("VARCHAR(100)") +# Type modifiers belong to the DataType object +StringColumn(dialect, "name").cast(VarCharType(dialect, length=100)) # PostgreSQL generates: "name"::VARCHAR(100) # Chained type conversions -Column(dialect, "amount").cast("money").cast("numeric").cast("float8") -# PostgreSQL generates: "amount"::money::numeric::float8 +StringColumn(dialect, "amount").cast(VarCharType(dialect, length=20)).cast(IntegerType(dialect)) +# -> CAST(CAST("amount" AS VARCHAR(20)) AS INTEGER) # Type casting supports method chaining -expr = Column(dialect, "value").cast("numeric") -result = expr * 1.1 # Arithmetic operation -predicate = expr > 100 # Comparison operation +expr = NumericColumn(dialect, "value").cast(IntegerType(dialect)) +result = expr * 1.1 # Arithmetic operation +predicate = expr > 100 # Comparison operation ``` **Important Notes**: - `TypeCastingMixin` does not directly concatenate SQL strings -- Type modifiers (e.g., `VARCHAR(100)`) should be included directly in the type string +- The target type is always a `DataType` instance (`IntegerType(dialect)`, `VarCharType(dialect, length=100)`, ...); a type-name string is refused at construction +- Type modifiers (e.g., `VARCHAR(100)`) are expressed as `DataType` arguments such as `VarCharType(dialect, length=100)` - The actual SQL syntax is determined by the dialect's `format_cast_expression` method - PostgreSQL uses `::` syntax, other databases use `CAST()` function ## Core Expressions -### TableExpression +### Table Sources in FROM -Represents a table or view in a SQL query. +A relation object carries only catalog identity; the source wraps it and carries the alias (and any temporal options). ```python -from rhosocial.activerecord.backend.expression import TableExpression +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -# FROM users AS u -table = TableExpression(dialect, "users", alias="u") +# The persisted relation: catalog identity only, no alias +relation = Table(dialect, "users") +relation.to_sql() # -> ('"users"', ()) + +# The query source: wraps the relation and carries the alias +source = NamedRelationRef(dialect, relation, alias="u") +source.to_sql() # -> ('"users" AS "u"', ()) ``` ### Column @@ -389,18 +500,22 @@ from rhosocial.activerecord.backend.expression import Literal lit = Literal(dialect, 100) ``` -### WildcardExpression +### AllColumnsWildcard Represents `*` or `table.*`. ```python -from rhosocial.activerecord.backend.expression import WildcardExpression +from rhosocial.activerecord.backend.expression import AllColumnsWildcard +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef # SELECT * -wildcard = WildcardExpression(dialect) +wildcard = AllColumnsWildcard(dialect) # SELECT users.* -wildcard = WildcardExpression(dialect, table="users") +wildcard = AllColumnsWildcard( + dialect, relation=NamedRelationRef(dialect, Table(dialect, "users")) +) ``` ### Subquery @@ -408,10 +523,15 @@ wildcard = WildcardExpression(dialect, table="users") Represents a subquery expression. ```python -from rhosocial.activerecord.backend.expression import Subquery, QueryExpression +from rhosocial.activerecord.backend.expression import Column, QueryStatement, Subquery +from rhosocial.activerecord.backend.expression.objects import Table + +query = QueryStatement( + dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users") +) # (SELECT ...) AS sub -sub = Subquery(dialect, query_expr, alias="sub") +sub = Subquery(dialect, query, alias="sub") ``` ## Operators and Expressions @@ -423,8 +543,10 @@ The `rhosocial.activerecord.backend.expression.operators` module provides classe Represents a generic SQL identifier (e.g., table name, column name). ```python -class Identifier(mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", name: str): ... +import inspect +from rhosocial.activerecord.backend.expression import Identifier + +print(inspect.signature(Identifier.__init__)) ``` ### SQLOperation @@ -432,10 +554,10 @@ class Identifier(mixins.ComparisonMixin, bases.SQLValueExpression): Represents a generic function-call style operation. ```python -from rhosocial.activerecord.backend.expression import SQLOperation +from rhosocial.activerecord.backend.expression import Literal, SQLOperation -# Example: custom_op(arg1, arg2) -op = SQLOperation(dialect, "custom_op", arg1, arg2) +# Example: custom_op(?, ?) +op = SQLOperation(dialect, "custom_op", Literal(dialect, 1), Literal(dialect, 2)) ``` ### BinaryExpression @@ -443,8 +565,10 @@ op = SQLOperation(dialect, "custom_op", arg1, arg2) Represents a standard binary operator (e.g., `=`, `!=`, `LIKE`). ```python -class BinaryExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "bases.BaseExpression", right: "bases.BaseExpression"): ... +import inspect +from rhosocial.activerecord.backend.expression import BinaryExpression + +print(inspect.signature(BinaryExpression.__init__)) ``` ### UnaryExpression @@ -452,30 +576,42 @@ class BinaryExpression(bases.BaseExpression): Represents a unary operator (e.g., `NOT`, `-`). ```python -class UnaryExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, operand: "bases.BaseExpression", pos: str = 'before'): ... +import inspect +from rhosocial.activerecord.backend.expression import UnaryExpression + +print(inspect.signature(UnaryExpression.__init__)) ``` -### BinaryArithmeticExpression +### ArithmeticOperation Represents binary arithmetic operations with operator precedence handling. +`op` is an `ArithmeticOperator` member whose stored value is the **operation as +a word** — `add` / `sub` / `mul` / `div` / `mod` — and the dialect formatter maps +each word to the character its SQL writes (see `OPERATOR_SYMBOLS`). The artifact +therefore carries a language-neutral word, not one language's spelling of the +operator. The word set is closed: an unknown word is refused at construction. + ```python -class BinaryArithmeticExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "bases.SQLValueExpression", right: "bases.SQLValueExpression"): ... +import inspect +from rhosocial.activerecord.backend.expression import ArithmeticOperation + +print(inspect.signature(ArithmeticOperation.__init__)) ``` ### Raw Expressions **Warning**: These classes should be used with caution as they allow raw SQL injection. Ensure all inputs are trusted or properly sanitized. -#### RawSQLExpression +#### RawSQL Represents a raw SQL string as a value expression. ```python -class RawSQLExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", expression: str, params: tuple = ()): ... +import inspect +from rhosocial.activerecord.backend.expression import RawSQL + +print(inspect.signature(RawSQL.__init__)) ``` #### RawSQLPredicate @@ -488,3 +624,30 @@ from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate # Example: Raw SQL condition pred = RawSQLPredicate(dialect, "EXISTS (SELECT 1 FROM log WHERE log.user_id = users.id)") ``` + +## COLLATE Expressions + +`CollateFunction` (defined in `rhosocial.activerecord.backend.expression.collation`) applies an explicit collation to a SQL value expression. + +```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.collation import collate + +# Basic usage: give a column a collation +expr = collate(Column(dialect, "name"), "Chinese_PRC_CI_AS") +sql, params = expr.to_sql() +# sql: '"name" COLLATE Chinese_PRC_CI_AS' +# params: () + +# Chaining: a collated expression still supports every mixin operation +expr = collate(Column(dialect, "name"), "NOCASE") == "alice" +sql, params = expr.to_sql() +# sql: '"name" COLLATE NOCASE = ?' +# params: ("alice",) +``` + +`CollateFunction` mixes in `AliasableMixin`, `ArithmeticMixin`, `ComparisonMixin`, `StringPatternPredicateMixin` and `TypeCastingMixin`, so it takes part in arithmetic, comparison, pattern matching and aliasing like any other SQL value expression. + +### Dialect Integration + +Each backend dialect must implement `supports_collation()` and `format_collation()` to support the COLLATE clause. The SQLite backend provides built-in COLLATE formatting support. diff --git a/docs/en_US/backend/expression/extending.md b/docs/en_US/backend/expression/extending.md index 964221c68..2288932ba 100644 --- a/docs/en_US/backend/expression/extending.md +++ b/docs/en_US/backend/expression/extending.md @@ -27,15 +27,15 @@ class MyExpression(BaseExpression): # No need to manually implement get_params(), default implementation auto-extracts ``` -`CollateExpression` is the `**kwargs` case in core: it collects `**collation_options` into `self.collation_options: dict`, so `get_params()` returns `binary`, `pad` and the rest as top-level parameters and reconstruction passes them back as keywords. +`CollateFunction` is the `**kwargs` case in core: it collects `**collation_options` into `self.collation_options: dict`, so `get_params()` returns `binary`, `pad` and the rest as top-level parameters and reconstruction passes them back as keywords. A parameter the convention cannot resolve is skipped with a warning. That warning is the diagnostic — it means `__init__` stores state under a name `get_params()` cannot see — and the repair is always in `__init__`. ### Why There Is No Custom get_params() -`get_params()` is not an extension point: an override is a defect, not a customization. Core enforces this with no exceptions in `tests/.../dummy2/test_expression_contract.py::test_no_get_params_override` — every registered expression class must leave the generic implementation alone. (The single exemption the test allows, a `VAR_POSITIONAL` class doing genuine raw-vs-normalized round-trip rewriting, has no instance in the core registry.) +`get_params()` is not an extension point: an override is a defect, not a customization. Core enforces this with no exceptions in `tests/.../dummy2/test_expression_artifact_contract.py::test_no_get_params_override` — every registered expression class must leave the generic implementation alone. (The single exemption the test allows, a `VAR_POSITIONAL` class doing genuine raw-vs-normalized round-trip rewriting, has no instance in the core registry.) -The reason is structural rather than stylistic. A round-trip is rebuilt by calling the constructor with whatever `get_params()` returned, so those two halves have to agree. An override is exactly where they drift: the class emits a key the constructor renames, merges or refuses, `_reconstruct()` wraps the resulting `TypeError` as `ExpressionDeserializationError`, and the state is silently gone. The backend packages accumulated their own overrides, every one of them written because `__init__` renamed or merged what the caller passed — and every one of them became unnecessary once the state was stored under the name it was given. +The reason is structural rather than stylistic. A round-trip is rebuilt by calling the constructor with whatever `get_params()` returned, so those two halves have to agree. An override is exactly where they drift: the class emits a key the constructor renames, merges or refuses, and the state is silently gone — the decoder hands values over by name and does not second-guess the result. The backend packages accumulated their own overrides, every one of them written because `__init__` renamed or merged what the caller passed — and every one of them became unnecessary once the state was stored under the name it was given. When the generic result is not what you wanted, the repair is always in `__init__`, and it is one of three shapes: @@ -49,18 +49,42 @@ When the generic result is not what you wanted, the repair is always in `__init_ ### Auto-Registration -Built-in expressions are auto-registered to `ExpressionRegistry` via `_auto_register_builtins()`. +Built-in expressions need nothing from you. The framework assembles its default +registry once, by walking the expression packages, and every node it finds is +registered by its kind. There is no function for a caller to invoke and no +list to maintain. -### Manual Registration +### Registering Your Own -Custom expressions need manual registration: +`ExpressionRegistry` starts empty — that is what makes it safe to hand a caller +a table that admits only what you chose. `register_node` is an instance method +and the kind is derived from the class: ```python -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry -ExpressionRegistry.register(MyExpression) +allowed = ExpressionRegistry() +allowed.register_node(MyExpression) # kind "my_expression" ``` +An enum needs no registration at all. A `(str, Enum)` member is a JSON string, +so there is nothing to resolve — the closed set is declared in the class, where +every language declares its own. + +Two kinds ship without being in the default table: `RawSQL` and `RawSQLPredicate`, +because `to_sql()` emits caller-vouched text and an artifact should not be able +to choose it. They set `REGISTER_BY_DEFAULT = False`; registering either one is +the same call as any other, for a caller that trusts its artifacts. + +Then pass it to the read: + +```python +deserialize(artifact, dialect, registry=allowed) +``` + +A registry only governs what a *reader* may produce. Writing consults none, so +nothing here affects `serialize`. + ## Four Special Cases The four cases below are the usual reasons a developer reaches for a custom `get_params()`. None of them needs one: each is solved in `__init__`. @@ -129,62 +153,101 @@ class TreeNodeExpression(BaseExpression): self._parent = None ``` -When the parent genuinely has to travel with the node, make it an ordinary nested-expression parameter: that is what `{"__expr__": ...}` exists for. The tree is then simply finite. +When the parent genuinely has to travel with the node, make it an ordinary nested-expression parameter — a plain `parent` holding another expression, which serializes as a nested `{kind, params}` node. The tree is then simply finite. ## IntrospectionExpression Convention -`IntrospectionExpression` subclasses (like `TableListExpression`) combine a constructor with fluent setters that share the parameter names. Both halves of the generic path already account for this: +An introspection node combines a constructor with fluent setters, and the two used +to want the same names. They no longer do, because a parameter has to be +readable by its own name for an artifact to be rebuildable at all: ```python -class TableListExpression(IntrospectionExpression): +class TableListQueryStatement(IntrospectionExpression): def __init__(self, dialect, schema=None, include_views=True, include_system=False, table_type=None): super().__init__(dialect, schema) - self._include_views = include_views - self._include_system = include_system - self._table_type = table_type + self.include_views = include_views # the parameter's own name + self.include_system = include_system + self.table_type = table_type - def include_views(self, value: bool = True): - self._include_views = value # the same attribute the parameter is read from + def with_include_views(self, value: bool = True): # never plain `include_views` + self.include_views = value return self ``` -- **When both spellings exist, the callable one is not the state.** If a parameter has both `self.foo` and `self._foo` and the public attribute is a fluent method, the private attribute is taken as the value. -- **An optional parameter left unset is emitted as `null`, not omitted.** That is safe: the deserializer passes it back and the constructor's default applies. What is *not* safe is dropping a parameter whose value differs from its default — that is state the round-trip loses. +- **One spelling, and it is the parameter's own name.** `get_params()` reads + `getattr(self, name)`. There is no `_{name}` probe, and no "the callable one is + not the state" rule to apply. A parameter the class did not store raises + `AttributeError` naming the class and the parameter — which is the defect + worth hearing about, rather than a warning nobody reads. +- **A fluent setter is `with_*`.** A setter called `include_views` would occupy + the name the value itself needs, and the class would have to choose between + `self.include_views = value` (which shadows the method) and hiding the value + where `get_params()` has to go looking for it. +- **An optional parameter left unset is emitted as `null`, not omitted.** That is + safe: the deserializer passes it back and the constructor's default applies. + What is *not* safe is dropping a parameter whose value differs from its + default — that is state the round-trip loses. ## Error Contract -All paths through `_reconstruct()`, `TypeError` is wrapped as `ExpressionDeserializationError`. This ensures callers only need to catch one exception type. - -## Serialization Format Convention (Reserved Key Names) - -The **values** `get_params()` returns must not contain the following reserved keys at any depth, otherwise deserialization behavior is undefined — the deserializer reads them as framework markers: +Everything a read can reject surfaces as `ExpressionDeserializationError`, so a +caller catches that one type and nothing else: -- `__expr__`: Used to mark nested expressions -- `__tuple__`: Used to mark tuples -- `__value__` / `__vdc__`: Used to mark codec-encoded scalars and dataclass values +``` +ExpressionDeserializationError +├── ArtifactError — the artifact itself is malformed +└── a registry refusal, restated — the kind is not admitted here +``` -Your own top-level keys come from your `__init__` parameter names (plus whatever `**kwargs` extras were merged in), so never name a parameter after a reserved key. For an opaque payload whose keys you do not control, the hazard is inside the payload: those keys are copied into the spec verbatim, and renaming the parameter does nothing about them. Validate at construction time, or store the payload as text. +`ArtifactError` is the *subclass*, so `except ExpressionDeserializationError` +covers both. `RegistryError` never reaches a caller: the decode layer restates it +in its own vocabulary, because the registry is a lower layer and must not know +what a reader catches. + +A `TypeError` from your own constructor is **not** caught for you. An artifact +carrying `{"name": 123}` for a `name` parameter rebuilds without complaint when +the constructor does not check. Validate there if it matters — the deserializer's +job is to hand values over by name, not to decide whether they are acceptable. + +## Parameter Convention (JSON scalars) + +**There are no reserved keys any more.** An artifact is plain JSON, so a +parameter's value must already be something JSON can say: null, a boolean, a +number, a string, an array, or an object. + +That gives a custom expression two obligations and one option. + +- **A closed value set takes a string enum.** Declare it `(str, Enum)` and set + `__str__ = str.__str__` so the member *is* the word it renders — `(str, Enum)` + alone does not do that, `Enum.__str__` wins. The wire form is then the member's + value, which is a JSON string, and the closed set is declared in code where + every language can declare it too. An enum that does not subclass `str` cannot + be written at all. +- **A value sent to the database goes in a `Literal`.** That is the one place a + type JSON cannot spell may appear; the `LiteralKind` member names the text. +- **An opaque payload is the hazard.** A `dict` of keys you do not control is + copied verbatim into the artifact, and renaming your parameter does nothing + about its *contents*. A key that happens to read as a node (`kind` plus + `params`) will be read back as one. Validate at construction time, or store the + payload as text. ```python -_RESERVED = ("__expr__", "__tuple__", "__value__", "__vdc__") - -# Incorrect - a payload containing "__expr__" is read back as a nested expression +# Incorrect - a dict parameter whose contents you do not control class RawJsonStorageExpr(BaseExpression): def __init__(self, dialect, data: dict): super().__init__(dialect) - self._data = data + self.data = data # {"kind": ..., "params": ...} in it is read back as a node -# Correct - the payload is rejected before it can reach a spec +# Correct - reject the shape before it can reach an artifact class SafeJsonStorageExpr(BaseExpression): def __init__(self, dialect, json_data: dict): super().__init__(dialect) - for key in json_data: - if key in _RESERVED: - raise ValueError(f"{key!r} is a reserved serialization key") - self._json_data = json_data + if set(json_data) & {"kind", "params"}: + raise ValueError("payload must not carry node-shaped keys") + self.json_data = json_data ``` ## Related Documents - [Core Documentation](./serialization.md): Serialization mechanism -- [Format Reference](./format-reference.md): Complete ExpressionSpec specification \ No newline at end of file +- [Format Reference](./format-reference.md): Complete artifact specification \ No newline at end of file diff --git a/docs/en_US/backend/expression/format-reference.md b/docs/en_US/backend/expression/format-reference.md index 78afe6725..5c11936e8 100644 --- a/docs/en_US/backend/expression/format-reference.md +++ b/docs/en_US/backend/expression/format-reference.md @@ -1,110 +1,165 @@ -# ExpressionSpec Format Reference +# Artifact Format Reference -This document defines the complete specification for the serialization format. +The complete specification of what `serialize()` writes and `deserialize()` +reads. Everything here is enforced by the code, not merely described by it. -## ExpressionSpec Structure +> **Runnable**: `docs/examples/expression_artifact.py` walks the whole of it. -The target format for expression serialization: +## Node Structure + +An artifact is a **bare node. No envelope** (§4.11.3): ```python { - "type": str, # Required: class name - "module": str, # Required: full module path + "kind": str, # Required: the node's kind "params": dict # Required: constructor parameters } ``` -### Field Constraints - | Field | Type | Constraint | |-------|------|------------| -| `type` | str | Non-empty, valid Python identifier | -| `module` | str | Importable module path, class must be importable from that module | -| `params` | dict | Keys must match target class `__init__` parameter names | +| `kind` | str | Non-empty; must be in the reader's registry | +| `params` | dict | Keys must match the target class's `__init__` parameter names | -## Marker Types +There is no `type`, no `module`, no version, no producer, no captured dialect, +no connection fingerprint. A node carrying anything beyond `kind` and `params` +is rejected by name, so a leftover envelope field cannot ride along unnoticed. -### __expr__ Marker (Reserved Key) +## Where the kind comes from -Nested expressions use `__expr__` wrapping: +`kind` is **computed from the class name**, never stored: a snake_case form, with +a run of capitals counted as one word (`JSONValue` → `json_value`). A class +carries no kind field, so the two cannot drift apart. -```python -{ - "type": "WhereClause", - "params": { - "condition": { - "__expr__": { - "type": "ComparisonPredicate", - "module": "...", - "params": {...} - } - } - } -} -``` +A **literal** is the one node whose kind is not its class name. One node carries +every value and the wire still names them apart — `null_value`, `integer_value`, +`date_value`, … — because a date's spelling and a string's are the same bytes and +only the kind tells them apart. The value kind is the node's identity on the wire; +`Literal` is merely what all of them resolve to. -**Semantics**: During deserialization, the value of `__expr__` is recursively deserialized as an expression object. +## Parameters -**Important**: `__expr__` is a framework reserved key. Custom expressions' `get_params()` return values must NOT use this key. +`params` holds every constructor parameter except **`dialect`**, which is +context and never travels: the same artifact re-renders under any dialect object +the reader hands it. -### __tuple__ Marker (Reserved Key) +Each parameter is one of exactly two forms: -Tuples use `__tuple__` marker: +- a **language-independent scalar** (JSON-native, or one of the markers below) +- an **expression**, written as a nested `{kind, params}` node -```python -{ - "type": "OrderByClause", - "params": { - "order_by_items": [ - {"__tuple__": [Column(...), "DESC"]} - ] - } -} -``` +There is no third form, and no marker around a nested node — the shape *is* the +signal. A `set`, a driver handle, a closure or a pickled blob has no +language-independent encoding and is therefore not serializable; model it as an +expression instead. -**Semantics**: During deserialization, the `__tuple__` array is restored as a Python tuple. +## Marker Types -**Important**: `__tuple__` is a framework reserved key. Custom expressions' `get_params()` return values must NOT use this key. +**There are none.** + +An artifact contains only JSON: null, booleans, numbers, strings, arrays, and +objects that are either a node or a plain mapping. A nested expression is a bare +`{kind, params}` node; a value JSON cannot spell travels as the text its kind +names. + +This is not a simplification. A marker is a *dispatch* — the reader has to +recognise the key and know what it means — and a dispatch is something every +language reimplements. The artifact is required to be byte-identical across +implementations, so anything a reader must dispatch on has to be something all +of them agree on by construction. A JSON scalar needs no agreement: every +language already parses one. ## Scalar Type Rules | Python Type | JSON Representation | Notes | |-------------|---------------------|-------| -| `str` | string | | -| `int` | number | | -| `float` | number | | +| `str` | string | A `str`-based enum is one of these — it *is* its value | +| `int` | number | Arbitrary precision; a reader that cannot hold it must fail loudly | +| `float` | number | IEEE 754 bit pattern is what must survive, not the text | | `bool` | boolean | | | `None` | null | | -| `list` | array | Recursively serialize elements | -| `dict` | object | Recursively serialize values | -| `tuple` | object | Use `__tuple__` marker | -| `BaseExpression` | object | Use `__expr__` marker | -| `set` | - | Not supported, convert to list | -| Other custom objects | object | Via a registered `__value__` codec, or converted in `__init__` | +| `list` | array | Elements processed recursively | +| `tuple` | array | The parameter's declared type says it was a pair; the constructor restores it | +| `dict` | object | Values processed recursively | +| `BaseExpression` | object | A bare nested node | +| `set` | — | Not supported; convert to a list | +| Driver handles, closures, arbitrary objects | — | No language-independent encoding; model as an expression | + +### Bind values are the one exception + +A parameter that is **sent to the database** may hold a type JSON has no spelling +for. It is wrapped in a `Literal`, and its kind names the text: + +| Kind | Carries | Wire form | +|------|---------|-----------| +| `binary_value` | `bytes` | base64 string | +| `interval_value` | `timedelta` | ISO 8601 string — days/hours/minutes/seconds, since years and months have no fixed length | +| `decimal_value` | `Decimal` | exact decimal string, never a float | +| `timestamp_value` / `date_value` / `time_value` | date and time | ISO text | +| `uuid_value` | `UUID` | canonical text | + +The kind is the whole reason a kind exists here: a date's spelling and a +string's are the same bytes, and only `date_value` tells them apart. The +constructor takes the scalar back and converts it. ## Serialization Flow -1. Call `expression.get_params()` to get parameter dictionary -2. Recursively process values in `params`: - - `BaseExpression` → `{"__expr__": serialize(expr)}` - - `tuple` → `{"__tuple__": [...]}` - - `list` → recursively process elements - - `dict` → recursively process values - - others → pass through directly -3. Add `type` (class name) and `module` (module path) +1. Read `get_params()` — one attribute per `__init__` parameter, under its own + name, `dialect` excepted. +2. Process each value: a `BaseExpression` becomes a nested node; a list, tuple or + dict is walked; everything else must already be a JSON scalar. +3. Emit `{"kind": ..., "params": ...}`. + +No registry is consulted. A node knows its own kind, so a caller that only +serializes never builds a lookup table. ## Deserialization Flow -1. Validate `type` and `module` exist -2. Find class via `ExpressionRegistry.lookup()` -3. Call `_reconstruct(cls, dialect, params)` to rebuild instance (deserialization path only — the rendering path, `to_sql()`, never reconstructs) -4. Recursively process nested structures in `params`: - - `__expr__` → `deserialize(value, dialect)` - - `__tuple__` → tuple(recursively process elements) - - others → recursively process +The artifact is **untrusted input** (§4.11.4). + +1. Reject a node with any field beyond `kind` and `params`. +2. Resolve `kind` through the registry's whitelist. There is no import path and + no reflection: a name is either registered or refused by name. +3. Require `params` to be an object, and check every key against the resolved + class's `__init__` — an unrecognised parameter and a missing required one each + fail by name. +4. Reject a class declaring `**kwargs`: a parameter whose identity depends on how + many extras arrived cannot be matched by name on read. +5. Decode each value recursively and hand the result to the constructor **by + name**. The decoder does no type checking of its own — validation belongs in + `__init__`, where it can name the class and the parameter. +6. Bound nesting depth (64 by default) and total length. + +### Not every kind is in the default table + +`BaseExpression.REGISTER_BY_DEFAULT` decides. A node whose rendered SQL is +caller-vouched text sets it `False` — `RawSQL` and `RawSQLPredicate` do — so a +payload cannot name a kind whose `to_sql()` emits whatever the payload said. +That is a *default*, not a prohibition: `register_node` works exactly as it does +for any other kind, and a caller that trusts its artifacts can opt in. + +### Failures + +Every rejection names what it rejected, because an artifact you did not write +cannot be debugged by guessing: + +| Situation | Message | +|-----------|---------| +| Kind not admitted | `kind 'x' is not in the kind whitelist (N kinds registered)` | +| Leftover envelope field | `artifact node has unexpected field(s) ['type']` | +| Missing kind | `artifact node has no usable 'kind' (got None)` | +| Unrecognised parameter | `kind 'x' does not accept parameter(s) [...]` | +| Missing required parameter | `kind 'x' is missing required parameter(s) [...]` | +| `**kwargs` declared | `kind 'x' accepts **kwargs, so its parameters cannot be matched by name` | +| Too deep | `artifact nesting depth exceeds 64` | + +All of them are `ArtifactError`, which subclasses +`ExpressionDeserializationError` — so a caller catches that one type. +`RegistryError` never escapes: the decode layer restates it in its own +vocabulary, because the registry is a lower layer and must not know what a +reader catches. ## Related Documents - [Core Documentation](./serialization.md) -- [Extending Guide](./extending.md) -- [Migration Guide](./migration.md) \ No newline at end of file +- [Extending Guide](./extending.md) \ No newline at end of file diff --git a/docs/en_US/backend/expression/functions.md b/docs/en_US/backend/expression/functions.md index ae72c5968..5eedbdb7f 100644 --- a/docs/en_US/backend/expression/functions.md +++ b/docs/en_US/backend/expression/functions.md @@ -15,27 +15,24 @@ construction-time `is_aggregate` fact. It supports `FILTER (WHERE ...)` clauses and `OVER (...)` window clauses. ```python -class FunctionCall(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringPatternPredicateMixin, mixins.TypeCastingMixin, mixins.ResultTypeMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", func_name: str, *args, - is_distinct: bool = False, alias: Optional[str] = None, - niladic: bool = False, filter_predicate: "SQLPredicate" = None, - window_spec: Union["WindowSpecification", str] = None, - is_aggregate: bool = False): ... +import inspect +from rhosocial.activerecord.backend.expression import FunctionCall - def filter(self, predicate: "SQLPredicate") -> 'FunctionCall': - """Applies a FILTER (WHERE ...) clause to the aggregate expression.""" - ... - - def over(self, window_spec: Union["WindowSpecification", str]) -> 'FunctionCall': - """Attaches an OVER (...) clause to the call.""" - ... +print(inspect.signature(FunctionCall.__init__)) +print(inspect.signature(FunctionCall.filter)) +print(inspect.signature(FunctionCall.over)) ``` **Example:** ```python -# COUNT with FILTER +from rhosocial.activerecord.backend.expression import ( + BooleanColumn, Column, StringColumn, +) +from rhosocial.activerecord.backend.expression.functions import count, sum_ + +# COUNT with FILTER -- typed columns build real predicates active_count = count(dialect, "*", alias="active_count").filter( - Column(dialect, "status") == Literal(dialect, "active") + StringColumn(dialect, "status") == "active" ) sql, params = active_count.to_sql() # sql: 'COUNT(*) FILTER (WHERE "status" = ?) AS "active_count"' @@ -43,9 +40,9 @@ sql, params = active_count.to_sql() # SUM with multiple chained filters high_value_sum = sum_(dialect, Column(dialect, "amount"), alias="high_value_sum").filter( - Column(dialect, "category") == Literal(dialect, "sales") + StringColumn(dialect, "category") == "sales" ).filter( - Column(dialect, "priority") == Literal(dialect, True) + BooleanColumn(dialect, "priority") == True ) sql, params = high_value_sum.to_sql() # sql: 'SUM("amount") FILTER (WHERE "category" = ? AND "priority" = ?) AS "high_value_sum"' @@ -57,16 +54,23 @@ sql, params = high_value_sum.to_sql() Creates a `COUNT` aggregate function call. ```python -def count(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"] = "*", is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import count + +# The signature as the library declares it +print(inspect.signature(count)) ``` **Usage Rules:** -- `count(dialect, "*")` -> `COUNT(*)` -- `count(dialect, Column(dialect, "id"))` -> `COUNT(id)` -- `count(dialect, "val")` -> `COUNT('val')` (Literal) +- `count(dialect, "*")` -> `COUNT(*)` (a `RowCountWildcard`) +- `count(dialect, Column(dialect, "id"))` -> `COUNT("id")` +- `count(dialect, "val")` -> `COUNT("val")` (a column reference) **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import count + # Basic COUNT(*) c1 = count(dialect, "*") sql, params = c1.to_sql() @@ -85,11 +89,18 @@ sql, params = c2.to_sql() Creates a `SUM` aggregate function call. Note the trailing underscore to avoid conflict with Python's built-in `sum`. ```python -def sum_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sum_ + +# The signature as the library declares it +print(inspect.signature(sum_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import sum_ + s = sum_(dialect, Column(dialect, "amount")) sql, params = s.to_sql() # sql: 'SUM("amount")' @@ -101,11 +112,18 @@ sql, params = s.to_sql() Creates an `AVG` aggregate function call. ```python -def avg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import avg + +# The signature as the library declares it +print(inspect.signature(avg)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import avg + a = avg(dialect, Column(dialect, "score"), is_distinct=True, alias="avg_score") sql, params = a.to_sql() # sql: 'AVG(DISTINCT "score") AS "avg_score"' @@ -117,11 +135,18 @@ sql, params = a.to_sql() Creates a `MIN` aggregate function call. Note the trailing underscore. ```python -def min_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import min_ + +# The signature as the library declares it +print(inspect.signature(min_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import min_ + m = min_(dialect, Column(dialect, "price")) sql, params = m.to_sql() # sql: 'MIN("price")' @@ -133,11 +158,18 @@ sql, params = m.to_sql() Creates a `MAX` aggregate function call. Note the trailing underscore. ```python -def max_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import max_ + +# The signature as the library declares it +print(inspect.signature(max_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import max_ + m = max_(dialect, Column(dialect, "price")) sql, params = m.to_sql() # sql: 'MAX("price")' @@ -151,11 +183,18 @@ sql, params = m.to_sql() Creates a `CONCAT` scalar function call. ```python -def concat(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import concat + +# The signature as the library declares it +print(inspect.signature(concat)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import concat + c = concat(dialect, Column(dialect, "first_name"), " ", Column(dialect, "last_name")) sql, params = c.to_sql() # sql: 'CONCAT("first_name", ?, "last_name")' @@ -167,11 +206,18 @@ sql, params = c.to_sql() Creates a `COALESCE` scalar function call. ```python -def coalesce(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import coalesce + +# The signature as the library declares it +print(inspect.signature(coalesce)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import coalesce + c = coalesce(dialect, Column(dialect, "nickname"), Column(dialect, "real_name"), "Anonymous") sql, params = c.to_sql() # sql: 'COALESCE("nickname", "real_name", ?)' @@ -183,11 +229,18 @@ sql, params = c.to_sql() Creates a `LENGTH` scalar function call. ```python -def length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import length + +# The signature as the library declares it +print(inspect.signature(length)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import length + l = length(dialect, Column(dialect, "description")) sql, params = l.to_sql() # sql: 'LENGTH("description")' @@ -199,11 +252,18 @@ sql, params = l.to_sql() Creates a `SUBSTRING` scalar function call. ```python -def substring(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], start: Union[int, "bases.BaseExpression"], length: Optional[Union[int, "bases.BaseExpression"]] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import substring + +# The signature as the library declares it +print(inspect.signature(substring)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import substring + # With length s1 = substring(dialect, Column(dialect, "text"), 1, 5) sql, params = s1.to_sql() @@ -222,11 +282,18 @@ sql, params = s2.to_sql() Creates a `TRIM` scalar function call. ```python -def trim(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], chars: Optional[Union[str, "bases.BaseExpression"]] = None, direction: str = "BOTH") -> "operators.RawSQLExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import trim + +# The signature as the library declares it +print(inspect.signature(trim)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import trim + # Default trim (spaces from both sides) t1 = trim(dialect, Column(dialect, "name")) sql, params = t1.to_sql() @@ -245,11 +312,18 @@ sql, params = t2.to_sql() Creates a `REPLACE` scalar function call. ```python -def replace(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], pattern: Union[str, "bases.BaseExpression"], replacement: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import replace + +# The signature as the library declares it +print(inspect.signature(replace)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import replace + r = replace(dialect, Column(dialect, "content"), "http", "https") sql, params = r.to_sql() # sql: 'REPLACE("content", ?, ?)' @@ -261,11 +335,18 @@ sql, params = r.to_sql() Creates an `UPPER` scalar function call. ```python -def upper(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import upper + +# The signature as the library declares it +print(inspect.signature(upper)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import upper + u = upper(dialect, Column(dialect, "email")) sql, params = u.to_sql() # sql: 'UPPER("email")' @@ -277,11 +358,18 @@ sql, params = u.to_sql() Creates a `LOWER` scalar function call. ```python -def lower(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lower + +# The signature as the library declares it +print(inspect.signature(lower)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import lower + l = lower(dialect, Column(dialect, "email")) sql, params = l.to_sql() # sql: 'LOWER("email")' @@ -293,11 +381,18 @@ sql, params = l.to_sql() Creates an `INITCAP` scalar function call (title case). ```python -def initcap(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import initcap + +# The signature as the library declares it +print(inspect.signature(initcap)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import initcap + i = initcap(dialect, Column(dialect, "title")) sql, params = i.to_sql() # sql: 'INITCAP("title")' @@ -309,11 +404,18 @@ sql, params = i.to_sql() Creates a `LEFT` scalar function call. ```python -def left(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import left + +# The signature as the library declares it +print(inspect.signature(left)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import left + l = left(dialect, Column(dialect, "code"), 3) sql, params = l.to_sql() # sql: 'LEFT("code", ?)' @@ -325,11 +427,18 @@ sql, params = l.to_sql() Creates a `RIGHT` scalar function call. ```python -def right(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import right + +# The signature as the library declares it +print(inspect.signature(right)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import right + r = right(dialect, Column(dialect, "code"), 4) sql, params = r.to_sql() # sql: 'RIGHT("code", ?)' @@ -341,11 +450,18 @@ sql, params = r.to_sql() Creates an `LPAD` scalar function call. ```python -def lpad(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], length: int, pad: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lpad + +# The signature as the library declares it +print(inspect.signature(lpad)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import lpad + p = lpad(dialect, Column(dialect, "id"), 5, "0") sql, params = p.to_sql() # sql: 'LPAD("id", ?, ?)' @@ -357,11 +473,18 @@ sql, params = p.to_sql() Creates an `RPAD` scalar function call. ```python -def rpad(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], length: int, pad: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rpad + +# The signature as the library declares it +print(inspect.signature(rpad)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import rpad + p = rpad(dialect, Column(dialect, "id"), 5, " ") sql, params = p.to_sql() # sql: 'RPAD("id", ?, ?)' @@ -373,11 +496,18 @@ sql, params = p.to_sql() Creates a `REVERSE` scalar function call. ```python -def reverse(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import reverse + +# The signature as the library declares it +print(inspect.signature(reverse)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import reverse + r = reverse(dialect, Column(dialect, "text")) sql, params = r.to_sql() # sql: 'REVERSE("text")' @@ -389,11 +519,18 @@ sql, params = r.to_sql() Creates a `STRPOS` scalar function call (find substring position). ```python -def strpos(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], substring: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import strpos + +# The signature as the library declares it +print(inspect.signature(strpos)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import strpos + p = strpos(dialect, Column(dialect, "text"), "world") sql, params = p.to_sql() # sql: 'STRPOS("text", ?)' @@ -407,11 +544,17 @@ sql, params = p.to_sql() Creates an `ABS` scalar function call. Note the trailing underscore to avoid conflict with Python's built-in `abs`. ```python -def abs_(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import abs_ + +# The signature as the library declares it +print(inspect.signature(abs_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import abs_ + a = abs_(dialect, -5) sql, params = a.to_sql() # sql: 'ABS(?)' @@ -423,15 +566,21 @@ sql, params = a.to_sql() Creates a `ROUND` scalar function call. Note the trailing underscore. ```python -def round_(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], decimals: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import round_ + +# The signature as the library declares it +print(inspect.signature(round_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import round_ + r = round_(dialect, 3.14159, 2) sql, params = r.to_sql() # sql: 'ROUND(?, ?)' -# params: (3.14159, 2) +# params: (Decimal('3.14159'), 2) -- numeric literals bind as Decimal ``` ### ceil @@ -439,15 +588,21 @@ sql, params = r.to_sql() Creates a `CEIL` scalar function call. ```python -def ceil(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import ceil + +# The signature as the library declares it +print(inspect.signature(ceil)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import ceil + c = ceil(dialect, 3.14) sql, params = c.to_sql() # sql: 'CEIL(?)' -# params: (3.14,) +# params: (Decimal('3.14'),) ``` ### floor @@ -455,15 +610,21 @@ sql, params = c.to_sql() Creates a `FLOOR` scalar function call. ```python -def floor(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import floor + +# The signature as the library declares it +print(inspect.signature(floor)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import floor + f = floor(dialect, 3.99) sql, params = f.to_sql() # sql: 'FLOOR(?)' -# params: (3.99,) +# params: (Decimal('3.99'),) ``` ### sqrt @@ -471,11 +632,17 @@ sql, params = f.to_sql() Creates a `SQRT` scalar function call. ```python -def sqrt(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sqrt + +# The signature as the library declares it +print(inspect.signature(sqrt)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import sqrt + s = sqrt(dialect, 16) sql, params = s.to_sql() # sql: 'SQRT(?)' @@ -487,11 +654,17 @@ sql, params = s.to_sql() Creates a `POWER` scalar function call. ```python -def power(dialect: "SQLDialectBase", base: Union[int, float, "bases.BaseExpression"], exponent: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import power + +# The signature as the library declares it +print(inspect.signature(power)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import power + p = power(dialect, 2, 3) sql, params = p.to_sql() # sql: 'POWER(?, ?)' @@ -503,11 +676,17 @@ sql, params = p.to_sql() Creates an `EXP` scalar function call. ```python -def exp(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import exp + +# The signature as the library declares it +print(inspect.signature(exp)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import exp + e = exp(dialect, 1) sql, params = e.to_sql() # sql: 'EXP(?)' @@ -519,11 +698,17 @@ sql, params = e.to_sql() Creates a `LOG` scalar function call. ```python -def log(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], base: Optional[Union[int, float, "bases.BaseExpression"]] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import log + +# The signature as the library declares it +print(inspect.signature(log)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import log + l = log(dialect, 100, 10) sql, params = l.to_sql() # sql: 'LOG(?, ?)' @@ -535,13 +720,19 @@ sql, params = l.to_sql() Creates trigonometric function calls. ```python -def sin(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def cos(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def tan(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sin, cos, tan + +# The signature as the library declares it +print(inspect.signature(sin)) +print(inspect.signature(cos)) +print(inspect.signature(tan)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import cos, sin, tan + s = sin(dialect, 0) c = cos(dialect, 0) t = tan(dialect, 0) @@ -554,11 +745,17 @@ t = tan(dialect, 0) Creates a `NOW` scalar function call. ```python -def now(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import now + +# The signature as the library declares it +print(inspect.signature(now)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import now + n = now(dialect) sql, params = n.to_sql() # sql: 'NOW()' @@ -570,12 +767,18 @@ sql, params = n.to_sql() Creates a `CURRENT_DATE` niladic value function. Per SQL:2003, generates `CURRENT_DATE` without parentheses. ```python -def current_date(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_date + +# The signature as the library declares it +print(inspect.signature(current_date)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import current_date + d = current_date(dialect) sql, params = d.to_sql() # sql: 'CURRENT_DATE' @@ -587,12 +790,18 @@ sql, params = d.to_sql() Creates a `CURRENT_TIME` niladic value function. Per SQL:2003, generates `CURRENT_TIME` without parentheses. ```python -def current_time(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_time + +# The signature as the library declares it +print(inspect.signature(current_time)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import current_time + t = current_time(dialect) sql, params = t.to_sql() # sql: 'CURRENT_TIME' @@ -604,16 +813,23 @@ sql, params = t.to_sql() Creates date part extraction function calls. ```python -def year(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def month(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def day(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def hour(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def minute(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def second(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import year, month, day, hour, minute, second + +# The signature as the library declares it +print(inspect.signature(year)) +print(inspect.signature(month)) +print(inspect.signature(day)) +print(inspect.signature(hour)) +print(inspect.signature(minute)) +print(inspect.signature(second)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import year + y = year(dialect, Column(dialect, "created_at")) sql, params = y.to_sql() # sql: 'YEAR("created_at")' @@ -625,15 +841,22 @@ sql, params = y.to_sql() Creates a `DATE_PART` scalar function call. ```python -def date_part(dialect: "SQLDialectBase", field: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import date_part + +# The signature as the library declares it +print(inspect.signature(date_part)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import date_part + p = date_part(dialect, "year", Column(dialect, "created_at")) sql, params = p.to_sql() -# sql: 'DATE_PART(?, "created_at")' -# params: ("year",) +# sql: 'EXTRACT(YEAR FROM "created_at")' +# params: () ``` ### date_trunc @@ -641,15 +864,22 @@ sql, params = p.to_sql() Creates a `DATE_TRUNC` scalar function call. ```python -def date_trunc(dialect: "SQLDialectBase", precision: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import date_trunc + +# The signature as the library declares it +print(inspect.signature(date_trunc)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import date_trunc + t = date_trunc(dialect, "month", Column(dialect, "created_at")) sql, params = t.to_sql() -# sql: 'DATE_TRUNC(?, "created_at")' -# params: ("month",) +# sql: 'DATE_TRUNC('month', "created_at")' +# params: () ``` ## Conditional Functions @@ -659,13 +889,31 @@ sql, params = t.to_sql() Creates a `CASE` expression. ```python -def case(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.CaseExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import case + +# The signature as the library declares it +print(inspect.signature(case)) ``` **Example:** ```python -c = case(dialect).when(Column(dialect, "status") == "active", "Active").else_("Inactive") +from rhosocial.activerecord.backend.expression import Literal, StringColumn +from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression +from rhosocial.activerecord.backend.expression.functions import case + +# The factory creates an empty CASE node +c = case(dialect) + +# Branches are supplied as (condition, result) pairs at construction +c = CaseExpression( + dialect, + cases=[(StringColumn(dialect, "status") == "active", Literal(dialect, "Active"))], + else_result=Literal(dialect, "Inactive"), +) sql, params = c.to_sql() +# sql: 'CASE WHEN "status" = ? THEN ? ELSE ? END' +# params: ("active", "Active", "Inactive") ``` ### nullif @@ -673,11 +921,18 @@ sql, params = c.to_sql() Creates a `NULLIF` scalar function call. ```python -def nullif(dialect: "SQLDialectBase", expr1: Union[str, "bases.BaseExpression"], expr2: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import nullif + +# The signature as the library declares it +print(inspect.signature(nullif)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import nullif + n = nullif(dialect, Column(dialect, "value"), "N/A") sql, params = n.to_sql() # sql: 'NULLIF("value", ?)' @@ -689,11 +944,18 @@ sql, params = n.to_sql() Creates a `GREATEST` scalar function call. ```python -def greatest(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import greatest + +# The signature as the library declares it +print(inspect.signature(greatest)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import greatest + g = greatest(dialect, Column(dialect, "a"), Column(dialect, "b"), Column(dialect, "c")) sql, params = g.to_sql() # sql: 'GREATEST("a", "b", "c")' @@ -705,11 +967,18 @@ sql, params = g.to_sql() Creates a `LEAST` scalar function call. ```python -def least(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import least + +# The signature as the library declares it +print(inspect.signature(least)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import least + l = least(dialect, Column(dialect, "a"), Column(dialect, "b"), Column(dialect, "c")) sql, params = l.to_sql() # sql: 'LEAST("a", "b", "c")' @@ -723,11 +992,17 @@ sql, params = l.to_sql() Creates a `ROW_NUMBER` window function call. ```python -def row_number(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import row_number + +# The signature as the library declares it +print(inspect.signature(row_number)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import row_number + r = row_number(dialect) sql, params = r.to_sql() # sql: 'ROW_NUMBER()' @@ -739,8 +1014,12 @@ sql, params = r.to_sql() Creates `RANK` and `DENSE_RANK` window function calls. ```python -def rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... -def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rank, dense_rank + +# The signature as the library declares it +print(inspect.signature(rank)) +print(inspect.signature(dense_rank)) ``` ### lag, lead @@ -748,8 +1027,12 @@ def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core. Creates `LAG` and `LEAD` window function calls. ```python -def lag(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], offset: Optional[int] = 1, default: Optional[Union[str, "bases.BaseExpression"]] = None, alias: Optional[str] = None) -> "core.FunctionCall": ... -def lead(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], offset: Optional[int] = 1, default: Optional[Union[str, "bases.BaseExpression"]] = None, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lag, lead + +# The signature as the library declares it +print(inspect.signature(lag)) +print(inspect.signature(lead)) ``` ### first_value, last_value, nth_value @@ -757,9 +1040,13 @@ def lead(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], of Creates value window function calls. ```python -def first_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... -def last_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... -def nth_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import first_value, last_value, nth_value + +# The signature as the library declares it +print(inspect.signature(first_value)) +print(inspect.signature(last_value)) +print(inspect.signature(nth_value)) ``` ## JSON Functions @@ -769,12 +1056,19 @@ def nth_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" Creates JSON extraction function calls. ```python -def json_extract(dialect: "SQLDialectBase", expr: "bases.BaseExpression", path: str) -> "operators.BinaryExpression": ... -def json_extract_text(dialect: "SQLDialectBase", expr: "bases.BaseExpression", path: str) -> "operators.BinaryExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_extract, json_extract_text + +# The signature as the library declares it +print(inspect.signature(json_extract)) +print(inspect.signature(json_extract_text)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import json_extract + j = json_extract(dialect, Column(dialect, "data"), "$.name") sql, params = j.to_sql() # Uses -> operator to extract JSON value @@ -785,7 +1079,11 @@ sql, params = j.to_sql() Creates a `JSON_BUILD_OBJECT` function call. ```python -def json_build_object(dialect: "SQLDialectBase", *args: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_build_object + +# The signature as the library declares it +print(inspect.signature(json_build_object)) ``` ### json_array_elements @@ -793,7 +1091,11 @@ def json_build_object(dialect: "SQLDialectBase", *args: Union[str, "bases.BaseEx Creates a `JSON_ARRAY_ELEMENTS` function call. ```python -def json_array_elements(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_array_elements + +# The signature as the library declares it +print(inspect.signature(json_array_elements)) ``` ### json_objectagg, json_arrayagg @@ -801,8 +1103,12 @@ def json_array_elements(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseE Creates JSON aggregate function calls. ```python -def json_objectagg(dialect: "SQLDialectBase", key_expr: Union[str, "bases.BaseExpression"], value_expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... -def json_arrayagg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_objectagg, json_arrayagg + +# The signature as the library declares it +print(inspect.signature(json_objectagg)) +print(inspect.signature(json_arrayagg)) ``` ## Array Functions @@ -812,7 +1118,11 @@ def json_arrayagg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpress Creates an `ARRAY_AGG` aggregate function call. ```python -def array_agg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import array_agg + +# The signature as the library declares it +print(inspect.signature(array_agg)) ``` ### unnest @@ -820,7 +1130,11 @@ def array_agg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" Creates an `UNNEST` function call. ```python -def unnest(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import unnest + +# The signature as the library declares it +print(inspect.signature(unnest)) ``` ### array_length @@ -828,7 +1142,11 @@ def unnest(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) Creates an `ARRAY_LENGTH` function call. ```python -def array_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], dimension: int = 1) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import array_length + +# The signature as the library declares it +print(inspect.signature(array_length)) ``` ## Type Conversion Functions @@ -838,12 +1156,21 @@ def array_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpressi Creates a type conversion expression. ```python -def cast(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], type_name: str) -> "core.Column": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import cast + +# The signature as the library declares it +print(inspect.signature(cast)) ``` **Example:** ```python -c = cast(dialect, Column(dialect, "value"), "INTEGER") +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import cast +from rhosocial.activerecord.backend.expression.types import IntegerType + +# The target type is a DataType object, never a type-name string +c = cast(dialect, Column(dialect, "value"), IntegerType(dialect)) sql, params = c.to_sql() # sql: 'CAST("value" AS INTEGER)' # params: () @@ -854,7 +1181,11 @@ sql, params = c.to_sql() Creates a `TO_CHAR` function call. ```python -def to_char(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_char + +# The signature as the library declares it +print(inspect.signature(to_char)) ``` ### to_number @@ -862,7 +1193,11 @@ def to_char(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], Creates a `TO_NUMBER` function call. ```python -def to_number(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_number + +# The signature as the library declares it +print(inspect.signature(to_number)) ``` ### to_date @@ -870,7 +1205,11 @@ def to_number(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" Creates a `TO_DATE` function call. ```python -def to_date(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_date + +# The signature as the library declares it +print(inspect.signature(to_date)) ``` ## Grouping Functions @@ -880,11 +1219,18 @@ def to_date(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], Creates a `ROLLUP` grouping expression. ```python -def rollup(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rollup + +# The signature as the library declares it +print(inspect.signature(rollup)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import rollup + r = rollup(dialect, Column(dialect, "department"), Column(dialect, "region")) sql, params = r.to_sql() # sql: 'ROLLUP("department", "region")' @@ -896,7 +1242,11 @@ sql, params = r.to_sql() Creates a `CUBE` grouping expression. ```python -def cube(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import cube + +# The signature as the library declares it +print(inspect.signature(cube)) ``` ### grouping_sets @@ -904,7 +1254,11 @@ def cube(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) Creates a `GROUPING SETS` grouping expression. ```python -def grouping_sets(dialect: "SQLDialectBase", *sets: List[Union[str, "bases.BaseExpression"]]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import grouping_sets + +# The signature as the library declares it +print(inspect.signature(grouping_sets)) ``` ## String Concatenation Operator @@ -914,11 +1268,18 @@ def grouping_sets(dialect: "SQLDialectBase", *sets: List[Union[str, "bases.BaseE Creates a string concatenation expression using the `||` operator. ```python -def concat_op(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "operators.BinaryExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import concat_op + +# The signature as the library declares it +print(inspect.signature(concat_op)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import concat_op + # Concatenate two columns expr = concat_op(dialect, Column(dialect, "first_name"), Column(dialect, "last_name")) sql, params = expr.to_sql() @@ -943,11 +1304,17 @@ The following functions are defined in SQL:2003 standard but were not previously Creates a `MOD` function call (modulo operation). ```python -def mod(dialect: "SQLDialectBase", dividend: Union[int, float, "bases.BaseExpression"], divisor: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import mod + +# The signature as the library declares it +print(inspect.signature(mod)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import mod + m = mod(dialect, 10, 3) sql, params = m.to_sql() # sql: 'MOD(?, ?)' @@ -959,11 +1326,17 @@ sql, params = m.to_sql() Creates a `SIGN` function call (returns -1, 0, or 1). ```python -def sign(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sign + +# The signature as the library declares it +print(inspect.signature(sign)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import sign + s = sign(dialect, -42) sql, params = s.to_sql() # sql: 'SIGN(?)' @@ -975,15 +1348,21 @@ sql, params = s.to_sql() Creates a `TRUNCATE` function call (truncates a number). ```python -def truncate(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import truncate + +# The signature as the library declares it +print(inspect.signature(truncate)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import truncate + t = truncate(dialect, 3.14159, 2) sql, params = t.to_sql() # sql: 'TRUNCATE(?, ?)' -# params: (3.14159, 2) +# params: (Decimal('3.14159'), 2) ``` ### chr_ @@ -991,11 +1370,17 @@ sql, params = t.to_sql() Creates a `CHR` function call (ASCII code to character). Note the trailing underscore. ```python -def chr_(dialect: "SQLDialectBase", code: Union[int, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import chr_ + +# The signature as the library declares it +print(inspect.signature(chr_)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import chr_ + c = chr_(dialect, 65) # Returns 'A' sql, params = c.to_sql() # sql: 'CHR(?)' @@ -1007,11 +1392,17 @@ sql, params = c.to_sql() Creates an `ASCII` function call (character to ASCII code). ```python -def ascii(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import ascii + +# The signature as the library declares it +print(inspect.signature(ascii)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import ascii + a = ascii(dialect, "A") sql, params = a.to_sql() # sql: 'ASCII(?)' @@ -1023,11 +1414,18 @@ sql, params = a.to_sql() Creates an `OCTET_LENGTH` function call (returns byte length). ```python -def octet_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import octet_length + +# The signature as the library declares it +print(inspect.signature(octet_length)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import octet_length + o = octet_length(dialect, Column(dialect, "text")) sql, params = o.to_sql() # sql: 'OCTET_LENGTH("text")' @@ -1039,11 +1437,18 @@ sql, params = o.to_sql() Creates a `BIT_LENGTH` function call (returns bit length). ```python -def bit_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import bit_length + +# The signature as the library declares it +print(inspect.signature(bit_length)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import bit_length + b = bit_length(dialect, Column(dialect, "text")) sql, params = b.to_sql() # sql: 'BIT_LENGTH("text")' @@ -1055,11 +1460,18 @@ sql, params = b.to_sql() Creates a `POSITION` function call (find substring position, 1-based). ```python -def position(dialect: "SQLDialectBase", substring: Union[str, "bases.BaseExpression"], expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import position + +# The signature as the library declares it +print(inspect.signature(position)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import position + p = position(dialect, "world", Column(dialect, "text")) sql, params = p.to_sql() # sql: 'POSITION(?, "text")' @@ -1071,11 +1483,18 @@ sql, params = p.to_sql() Creates an `OVERLAY` function call (replace substring). ```python -def overlay(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], replacement: Union[str, "bases.BaseExpression"], start: int, length: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import overlay + +# The signature as the library declares it +print(inspect.signature(overlay)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import overlay + o = overlay(dialect, Column(dialect, "text"), "xxx", 1, 3) sql, params = o.to_sql() # sql: 'OVERLAY("text", ?, ?, ?)' @@ -1087,11 +1506,18 @@ sql, params = o.to_sql() Creates a `TRANSLATE` function call (character substitution). ```python -def translate(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], from_chars: str, to_chars: str) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import translate + +# The signature as the library declares it +print(inspect.signature(translate)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import translate + t = translate(dialect, Column(dialect, "text"), "abc", "xyz") sql, params = t.to_sql() # sql: 'TRANSLATE("text", ?, ?)' @@ -1103,11 +1529,17 @@ sql, params = t.to_sql() Creates a `REPEAT` function call (repeat string). ```python -def repeat(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], count: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import repeat + +# The signature as the library declares it +print(inspect.signature(repeat)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import repeat + r = repeat(dialect, "ab", 3) sql, params = r.to_sql() # sql: 'REPEAT(?, ?)' @@ -1119,11 +1551,17 @@ sql, params = r.to_sql() Creates a `SPACE` function call (generate spaces). ```python -def space(dialect: "SQLDialectBase", count: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import space + +# The signature as the library declares it +print(inspect.signature(space)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import space + s = space(dialect, 5) sql, params = s.to_sql() # sql: 'SPACE(?)' @@ -1135,12 +1573,18 @@ sql, params = s.to_sql() Creates a `CURRENT_TIMESTAMP` niladic value function. Per SQL:2003, generates `CURRENT_TIMESTAMP` without parentheses when no precision is specified. When precision is specified, generates `CURRENT_TIMESTAMP(precision)` with parentheses. ```python -def current_timestamp(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_timestamp + +# The signature as the library declares it +print(inspect.signature(current_timestamp)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import current_timestamp + # Without precision — niladic form (no parentheses) c = current_timestamp(dialect) sql, params = c.to_sql() @@ -1159,12 +1603,18 @@ sql, params = c.to_sql() Creates a `LOCALTIMESTAMP` niladic value function. Per SQL:2003, generates `LOCALTIMESTAMP` without parentheses when no precision is specified. When precision is specified, generates `LOCALTIMESTAMP(precision)` with parentheses. ```python -def localtimestamp(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import localtimestamp + +# The signature as the library declares it +print(inspect.signature(localtimestamp)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import localtimestamp + # Without precision — niladic form (no parentheses) l = localtimestamp(dialect) sql, params = l.to_sql() @@ -1177,15 +1627,22 @@ sql, params = l.to_sql() Creates an `EXTRACT` function call (extract datetime part). ```python -def extract(dialect: "SQLDialectBase", field: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import extract + +# The signature as the library declares it +print(inspect.signature(extract)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import extract + e = extract(dialect, "YEAR", Column(dialect, "created_at")) sql, params = e.to_sql() -# sql: 'EXTRACT(?, "created_at")' -# params: ("YEAR",) +# sql: 'EXTRACT(YEAR FROM "created_at")' +# params: () ``` ### current_user, session_user, system_user @@ -1193,13 +1650,19 @@ sql, params = e.to_sql() Creates SQL:2003 niladic value functions. These generate `CURRENT_USER`, `SESSION_USER`, `SYSTEM_USER` without parentheses. ```python -def current_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... -def session_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... -def system_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_user, session_user, system_user + +# The signature as the library declares it +print(inspect.signature(current_user)) +print(inspect.signature(session_user)) +print(inspect.signature(system_user)) ``` **Example:** ```python +from rhosocial.activerecord.backend.expression.functions import current_user + u = current_user(dialect) sql, params = u.to_sql() # sql: 'CURRENT_USER' @@ -1215,16 +1678,18 @@ This section describes SQL/XML standard expression factory functions, defined in Parses a string into an XML type. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlparse # Parse string as XML DOCUMENT -expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="DOCUMENT") +expr = xmlparse(dialect, Column(dialect, "xml_string"), document=True) sql, params = expr.to_sql() # sql: 'XMLPARSE(DOCUMENT "xml_string")' # params: () # Parse string as XML CONTENT -expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="CONTENT") +expr = xmlparse(dialect, Column(dialect, "xml_string"), document=False) ``` ### xmlserialize @@ -1232,11 +1697,15 @@ expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="CONTENT") Serializes an XML type to a string. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlserialize +from rhosocial.activerecord.backend.expression.types import VarCharType -expr = xmlserialize(dialect, Column(dialect, "xml_data"), "VARCHAR(255)") +# The target type is a DataType object, never a type-name string +expr = xmlserialize(dialect, Column(dialect, "xml_data"), VarCharType(dialect, length=255)) sql, params = expr.to_sql() -# sql: 'XMLSERIALIZE(DOCUMENT "xml_data" AS VARCHAR(255))' +# sql: 'XMLSERIALIZE(CONTENT "xml_data" AS VARCHAR(255))' # params: () ``` @@ -1245,18 +1714,24 @@ sql, params = expr.to_sql() Constructs an XML element. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlelement, xmlattributes # Simple element elem = xmlelement(dialect, "name", Column(dialect, "user_name")) sql, params = elem.to_sql() -# sql: 'XMLELEMENT(NAME "name", "user_name")' +# sql: 'XMLELEMENT(NAME name, "user_name")' # params: () -# Element with attributes -elem = xmlelement(dialect, "person", - xmlattributes(dialect, Column(dialect, "id"), "id"), - Column(dialect, "name")) +# Element with attributes -- attributes are passed by name, not as content +elem = xmlelement( + dialect, + "person", + Column(dialect, "name"), + attributes=xmlattributes(dialect, (Column(dialect, "id"), "id")), +) +# sql: 'XMLELEMENT(NAME person, XMLATTRIBUTES("id" AS id), "name")' ``` ### xmlforest @@ -1264,13 +1739,15 @@ elem = xmlelement(dialect, "person", Constructs an XML forest (sequence of elements). ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlforest expr = xmlforest(dialect, (Column(dialect, "first_name"), "first"), (Column(dialect, "last_name"), "last")) sql, params = expr.to_sql() -# sql: 'XMLFOREST("first_name" AS "first", "last_name" AS "last")' +# sql: 'XMLFOREST("first_name" AS first, "last_name" AS last)' # params: () ``` @@ -1279,6 +1756,8 @@ sql, params = expr.to_sql() Concatenates multiple XML values. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlconcat expr = xmlconcat(dialect, Column(dialect, "xml1"), Column(dialect, "xml2")) @@ -1304,7 +1783,7 @@ Creates an XML processing instruction. from rhosocial.activerecord.backend.expression.functions.xml import xmlpi expr = xmlpi(dialect, "xml-stylesheet", 'type="text/xsl" href="style.xsl"') -# sql: 'XMLPI(NAME "xml-stylesheet", ?)' +# sql: 'XMLPI(NAME xml-stylesheet, ?)' # params: ('type="text/xsl" href="style.xsl"',) ``` @@ -1313,9 +1792,19 @@ expr = xmlpi(dialect, "xml-stylesheet", 'type="text/xsl" href="style.xsl"') Modifies the root node attributes of an XML value. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlroot +from rhosocial.activerecord.backend.expression.xml import XMLStandaloneOption -expr = xmlroot(dialect, Column(dialect, "xml_data"), version="1.0", standalone="YES") +expr = xmlroot( + dialect, + Column(dialect, "xml_data"), + version="1.0", + standalone=XMLStandaloneOption.YES, +) +# sql: 'XMLROOT("xml_data", VERSION ?, STANDALONE YES)' +# params: ('1.0',) ``` ### xmlagg @@ -1323,10 +1812,12 @@ expr = xmlroot(dialect, Column(dialect, "xml_data"), version="1.0", standalone=" XML aggregate function. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlagg -expr = xmlagg(dialect, Column(dialect, "xml_column"), alias="combined_xml") -# sql: 'XMLAGG("xml_column") AS "combined_xml"' +expr = xmlagg(dialect, Column(dialect, "xml_column")) +# sql: 'XMLAGG("xml_column")' # params: () ``` @@ -1335,11 +1826,13 @@ expr = xmlagg(dialect, Column(dialect, "xml_column"), alias="combined_xml") XQuery evaluation. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlquery expr = xmlquery(dialect, "/root/element/text()", - passing=Column(dialect, "xml_column")) -# sql: 'XMLQUERY(?) PASSING BY VALUE "xml_column" EMPTY ON EMPTY' + passing=[Column(dialect, "xml_column")]) +# sql: 'XMLQUERY(? PASSING "xml_column" RETURNING CONTENT)' # params: ('/root/element/text()',) ``` @@ -1348,11 +1841,13 @@ expr = xmlquery(dialect, "/root/element/text()", XQuery existence check. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlexists expr = xmlexists(dialect, "/root/element", - passing=Column(dialect, "xml_column")) -# sql: 'XMLEXISTS(?) PASSING BY VALUE "xml_column"' + passing=[Column(dialect, "xml_column")]) +# sql: 'XMLEXISTS(? PASSING "xml_column")' # params: ('/root/element',) ``` @@ -1361,15 +1856,91 @@ expr = xmlexists(dialect, "/root/element", Generates a table-valued result from an XQuery expression. ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmltable +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.xml import XMLTableColumn -expr = xmltable(dialect, - xquery="/root/row", - passing=Column(dialect, "xml_data"), +expr = xmltable( + dialect, + "/root/row", + passing=[Column(dialect, "xml_data")], columns=[ - ("id", "INTEGER", "id"), - ("name", "VARCHAR(100)", "name"), - ]) -# sql: 'XMLTABLE(?) PASSING BY VALUE "xml_data" COLUMNS ("id" INTEGER PATH "id", "name" VARCHAR(100) PATH "name")' + XMLTableColumn("id", IntegerType(dialect), path=Column(dialect, "id")), + XMLTableColumn( + "name", VarCharType(dialect, length=100), path=Column(dialect, "name") + ), + ], +) +# sql: 'XMLTABLE(? PASSING "xml_data" COLUMNS ("id" INTEGER PATH "id", "name" VARCHAR(100) PATH "name"))' # params: ('/root/row',) ``` + +## Date/Time Interval Expressions + +Functions defined in `rhosocial.activerecord.backend.expression.functions.datetime`. + +### extract + +Creates a structured `EXTRACT` expression (stricter field enumeration than the `extract` factory above). + +```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.datetime import ( + DateTimeField, ExtractExpression, +) + +# Extract the year +expr = ExtractExpression(dialect, DateTimeField.YEAR, Column(dialect, "created_at")) +sql, params = expr.to_sql() +# sql: 'EXTRACT(YEAR FROM "created_at")' +# params: () +``` + +### interval + +Creates a structured interval value. + +```python +from rhosocial.activerecord.backend.expression.datetime import ( + IntervalExpression, IntervalUnit, +) + +# A 7-day interval +interval = IntervalExpression(dialect, 7, IntervalUnit.DAY) +sql, params = interval.to_sql() +# sql: "INTERVAL '7' DAY" +# params: () +``` + +### Date/time arithmetic + +```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.datetime import ( + DateTimeField, + DatetimeAddExpression, + DatetimeDiffExpression, + DatetimeSubtractExpression, + IntervalExpression, + IntervalUnit, +) + +# date + interval +expr = DatetimeAddExpression(dialect, + Column(dialect, "created_at"), + IntervalExpression(dialect, 30, IntervalUnit.DAY)) +# sql: '"created_at" + INTERVAL '30' DAY' +# params: () + +# date - interval +expr = DatetimeSubtractExpression(dialect, + Column(dialect, "created_at"), + IntervalExpression(dialect, 7, IntervalUnit.DAY)) + +# date difference -- rendering is dialect-specific (SQL Server: DATEDIFF(DAY, ...)) +expr = DatetimeDiffExpression(dialect, DateTimeField.DAY, + Column(dialect, "start_date"), + Column(dialect, "end_date")) +``` diff --git a/docs/en_US/backend/expression/limitations.md b/docs/en_US/backend/expression/limitations.md index b62904b5f..2658d5f28 100644 --- a/docs/en_US/backend/expression/limitations.md +++ b/docs/en_US/backend/expression/limitations.md @@ -15,34 +15,40 @@ According to SQL standards, when performing set operations like UNION, INTERSECT ```python # The following code is valid in the expression system but may fail during database execution from rhosocial.activerecord.backend.expression import ( - Column, Literal, QueryExpression, TableExpression + Column, NamedRelationRef, QueryStatement +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_sources import ( + SetOperation, SetOperationKind ) -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() # First query returns integer ID and text name -query1 = QueryExpression( +query1 = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users") + from_=NamedRelationRef(dialect, Table(dialect, "users")) ) # Second query returns text email and integer age -query2 = QueryExpression( +query2 = QueryStatement( dialect, select=[Column(dialect, "email"), Column(dialect, "age")], - from_=TableExpression(dialect, "customers") + from_=NamedRelationRef(dialect, Table(dialect, "customers")) ) # Expression system will successfully build this UNION, but database execution will fail -union_query = SetOperationExpression( +union_query = SetOperation( dialect, + op=SetOperationKind.UNION, left=query1, - right=query2, - operation="UNION" + right=query2 ) +sql, params = union_query.to_sql() +# sql: 'SELECT "id", "name" FROM "users" UNION SELECT "email", "age" FROM "customers"' +# params: () ``` Although the above code is valid at the expression system level, it will fail during database execution due to column type mismatches. diff --git a/docs/en_US/backend/expression/predicates.md b/docs/en_US/backend/expression/predicates.md index 34181499c..27d9b72c9 100644 --- a/docs/en_US/backend/expression/predicates.md +++ b/docs/en_US/backend/expression/predicates.md @@ -13,21 +13,32 @@ Predicates are expressions that evaluate to a boolean result (TRUE, FALSE, or UN Represents standard comparison operations between two expressions. ```python -class ComparisonPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "SQLValueExpression", right: "SQLValueExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import ComparisonPredicate + +print(inspect.signature(ComparisonPredicate)) +# (dialect: 'SQLDialectBase', op: Any, left: 'SQLValue', right: 'SQLValue') ``` **Parameters:** - `dialect`: The SQL dialect instance. -- `op`: The comparison operator (e.g., `=`, `>`, `<`, `>=`, `<=`, `<>`). +- `op`: The comparison operation — a `ComparisonOperator` member (`EQ`, `NE`, `GT`, `GTE`, `LT`, `LTE`) or its SQL spelling (`=`, `!=`, `>`, `>=`, `<`, `<=`). - `left`: The left-side expression. - `right`: The right-side expression. **Example:** ```python +from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, IntegerColumn, Literal, +) + # users.age >= 18 - # pred = ComparisonPredicate(dialect, ">=", Column(dialect, "age"), Literal(dialect, 18)) - # -> ('"age" >= ?', (18,)) +pred = ComparisonPredicate( + dialect, ">=", IntegerColumn(dialect, "age"), Literal(dialect, 18) +) +pred.to_sql() +# -> ('"age" >= ?', (18,)) ``` ### LogicalPredicate @@ -35,22 +46,32 @@ class ComparisonPredicate(bases.SQLPredicate): Represents logical operations combining other predicates. ```python -class LogicalPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, *predicates: "bases.SQLPredicate"): ... +import inspect + +from rhosocial.activerecord.backend.expression import LogicalPredicate + +print(inspect.signature(LogicalPredicate)) +# (dialect: 'SQLDialectBase', op: str, *operands: 'SQLPredicate') ``` **Parameters:** - `dialect`: The SQL dialect instance. - `op`: The logical operator (e.g., `AND`, `OR`, `NOT`). -- `*predicates`: One or more predicates to combine. +- `*operands`: One or more predicates to combine. **Example:** ```python +from rhosocial.activerecord.backend.expression import ( + IntegerColumn, LogicalPredicate, StringColumn, +) + # (age >= 18) AND (status = 'active') - # pred1 = ComparisonPredicate(dialect, ">=", Column(dialect, "age"), Literal(dialect, 18)) - # pred2 = ComparisonPredicate(dialect, "=", Column(dialect, "status"), Literal(dialect, "active")) - # combined = LogicalPredicate(dialect, "AND", pred1, pred2) - # -> ('"age" >= ? AND "status" = ?', (18, 'active')) +# Typed columns build comparison predicates with Python operators. +pred1 = IntegerColumn(dialect, "age") >= 18 +pred2 = StringColumn(dialect, "status") == "active" +combined = LogicalPredicate(dialect, "AND", pred1, pred2) +combined.to_sql() +# -> ('"age" >= ? AND "status" = ?', (18, 'active')) ``` ### LikePredicate @@ -58,21 +79,32 @@ class LogicalPredicate(bases.SQLPredicate): Represents pattern matching operations (`LIKE`, `ILIKE`). ```python -class LikePredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, expr: "SQLValueExpression", pattern: "SQLValueExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import LikePredicate + +print(inspect.signature(LikePredicate)) +# (dialect: 'SQLDialectBase', op: 'LikeOperator', left: 'SQLValue', pattern: 'SQLValue') ``` **Parameters:** - `dialect`: The SQL dialect instance. -- `op`: The operator (`LIKE`, `ILIKE`, `NOT LIKE`, etc.). -- `expr`: The expression to test. +- `op`: The operation — a `LikeOperator` member (`like`, `not_like`, `ilike`) or its SQL keyword (`LIKE`, `NOT LIKE`, `ILIKE`). +- `left`: The expression to test. - `pattern`: The pattern to match against. **Example:** ```python +from rhosocial.activerecord.backend.expression import ( + LikePredicate, Literal, StringColumn, +) + # name LIKE 'User%' - # pred = LikePredicate(dialect, "LIKE", Column(dialect, "name"), Literal(dialect, "User%")) - # -> ('"name" LIKE ?', ('User%',)) +pred = LikePredicate( + dialect, "like", StringColumn(dialect, "name"), Literal(dialect, "User%") +) +pred.to_sql() +# -> ('"name" LIKE ?', ('User%',)) ``` ### InPredicate @@ -80,19 +112,33 @@ class LikePredicate(bases.SQLPredicate): Represents set membership tests (`IN`). ```python -class InPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "SQLValueExpression", values: "bases.BaseExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import InPredicate + +print(inspect.signature(InPredicate)) +# (dialect: 'SQLDialectBase', operand: 'SQLValue', values: 'BaseExpression') ``` **Parameters:** - `dialect`: The SQL dialect instance. -- `expr`: The expression to test. +- `operand`: The expression to test. - `values`: A `Literal` containing a collection (list/tuple) or a subquery expression. **Example:** ```python +from rhosocial.activerecord.backend.expression import ( + InPredicate, Literal, StringColumn, +) + # status IN ('active', 'pending') -pred = InPredicate(dialect, Column(dialect, "status"), Literal(dialect, ["active", "pending"])) +pred = InPredicate( + dialect, + StringColumn(dialect, "status"), + Literal(dialect, ["active", "pending"]), +) +pred.to_sql() +# -> ('"status" IN (?, ?)', ('active', 'pending')) ``` ### BetweenPredicate @@ -100,20 +146,33 @@ pred = InPredicate(dialect, Column(dialect, "status"), Literal(dialect, ["active Represents range tests (`BETWEEN`). ```python -class BetweenPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", low: "bases.BaseExpression", high: "bases.BaseExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import BetweenPredicate + +print(inspect.signature(BetweenPredicate)) +# (dialect: 'SQLDialectBase', operand: 'BaseExpression', +# low: 'BaseExpression', high: 'BaseExpression') ``` **Parameters:** - `dialect`: The SQL dialect instance. -- `expr`: The expression to test. +- `operand`: The expression to test. - `low`: The lower bound. - `high`: The upper bound. **Example:** ```python +from rhosocial.activerecord.backend.expression import ( + BetweenPredicate, IntegerColumn, Literal, +) + # age BETWEEN 18 AND 65 -pred = BetweenPredicate(dialect, Column(dialect, "age"), Literal(dialect, 18), Literal(dialect, 65)) +pred = BetweenPredicate( + dialect, IntegerColumn(dialect, "age"), Literal(dialect, 18), Literal(dialect, 65) +) +pred.to_sql() +# -> ('"age" BETWEEN ? AND ?', (18, 65)) ``` ### IsNullPredicate @@ -121,20 +180,27 @@ pred = BetweenPredicate(dialect, Column(dialect, "age"), Literal(dialect, 18), L Represents NULL tests (`IS NULL`, `IS NOT NULL`). ```python -class IsNullPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", is_not: bool = False): ... +import inspect + +from rhosocial.activerecord.backend.expression import IsNullPredicate + +print(inspect.signature(IsNullPredicate)) +# (dialect: 'SQLDialectBase', operand: 'BaseExpression', is_not: bool = False) ``` **Parameters:** - `dialect`: The SQL dialect instance. -- `expr`: The expression to test. +- `operand`: The expression to test. - `is_not`: If `True`, creates `IS NOT NULL`. Default is `False` (`IS NULL`). **Example:** ```python +from rhosocial.activerecord.backend.expression import IsNullPredicate, StringColumn + # email IS NOT NULL - # pred = IsNullPredicate(dialect, Column(dialect, "email"), is_not=True) - # -> ('"email" IS NOT NULL', ()) +pred = IsNullPredicate(dialect, StringColumn(dialect, "email"), is_not=True) +pred.to_sql() +# -> ('"email" IS NOT NULL', ()) ``` ### IsBooleanPredicate @@ -142,8 +208,12 @@ class IsNullPredicate(bases.SQLPredicate): Represents boolean value tests (`IS TRUE`, `IS NOT TRUE`, `IS FALSE`, `IS NOT FALSE`). ```python -class IsBooleanPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", value: bool, is_not: bool = False): ... +import inspect + +from rhosocial.activerecord.backend.expression import IsBooleanPredicate + +print(inspect.signature(IsBooleanPredicate)) +# (dialect: 'SQLDialectBase', expr: 'BaseExpression', value: bool, is_not: bool = False) ``` **Parameters:** @@ -164,22 +234,32 @@ This is different from direct equality comparisons (`= TRUE` or `= FALSE`) which **Example:** ```python +from rhosocial.activerecord.backend.expression import BooleanColumn, IsBooleanPredicate + # is_active IS TRUE -pred = IsBooleanPredicate(dialect, Column(dialect, "is_active"), value=True, is_not=False) +pred = IsBooleanPredicate( + dialect, BooleanColumn(dialect, "is_active"), value=True, is_not=False +) +pred.to_sql() # -> ('"is_active" IS TRUE', ()) # is_active IS NOT FALSE -pred = IsBooleanPredicate(dialect, Column(dialect, "is_active"), value=False, is_not=True) +pred = IsBooleanPredicate( + dialect, BooleanColumn(dialect, "is_active"), value=False, is_not=True +) +pred.to_sql() # -> ('"is_active" IS NOT FALSE', ()) ``` **Convenience Methods:** -The `Column` class provides convenience methods for creating these predicates: +The `BooleanColumn` class provides convenience methods for creating these predicates: ```python -col = Column(dialect, "is_active") -col.is_true() # IS TRUE -col.is_not_true() # IS NOT TRUE -col.is_false() # IS FALSE -col.is_not_false() # IS NOT FALSE +from rhosocial.activerecord.backend.expression import BooleanColumn + +col = BooleanColumn(dialect, "is_active") +col.is_true().to_sql() # -> ('"is_active" IS TRUE', ()) +col.is_not_true().to_sql() # -> ('"is_active" IS NOT TRUE', ()) +col.is_false().to_sql() # -> ('"is_active" IS FALSE', ()) +col.is_not_false().to_sql() # -> ('"is_active" IS NOT FALSE', ()) ``` diff --git a/docs/en_US/backend/expression/serialization.md b/docs/en_US/backend/expression/serialization.md index 22ff24be8..78bf55807 100644 --- a/docs/en_US/backend/expression/serialization.md +++ b/docs/en_US/backend/expression/serialization.md @@ -1,192 +1,215 @@ # Expression Serialization -This document describes the serialization and deserialization mechanism for SQL expression objects, which is essential for implementing expression caching, distributed queries, and task scheduling. +> **Runnable example**: `docs/examples/expression_artifact.py`. Everything below is +> exercised there end to end. ## Design Motivation -Why not serialize `dialect` directly? Here's why: +An expression is a **value object**: the same query built twice must be equal, +and it must survive being written down and read back somewhere else — a cache, +a queue, another process. -1. **Runtime Injection**: Dialect is tightly coupled with specific database backends; serialization should decouple expressions from dialects -2. **Multi-Dialect Support**: The same expression may generate different SQL under different dialects; serialization should not lock in a dialect -3. **Security Isolation**: Some dialect implementations depend on database connection state, which is not suitable for cross-process transmission +The artifact is the whole of that. It is deliberately small, and everything +about its shape follows from one decision: **the artifact carries no import +path**. A dotted path in a payload is what would force a reader to import +whatever module the string pointed at, and an artifact is untrusted input. So a +node is named by a **kind** — a snake_case form of its class name — and that +kind is the node's identity, whether in memory or on the wire. -Therefore, the serialized `ExpressionSpec` does not contain dialect information. At deserialization time, the caller provides the target dialect. +Two consequences worth stating up front: -> **Serialization vs. rendering are separate paths**: `_reconstruct()` is used **only** by the deserialization round-trip (spec → object). Rendering never reconstructs anything — `to_sql()` is stateless and allocation-free, reading each node's own bound dialect and rendering the existing tree in place. +- **Writing needs no registry.** A node reports its own kind, derived from its + class name. A caller that only serializes never builds a lookup table. +- **Reading takes one.** Turning a kind back into a class is a question only a + table can answer, and the caller decides which table. ## Core API -### serialize() - -Serializes an expression object to a dictionary: +### serialize(expr) ```python from rhosocial.activerecord.backend.expression.serialization import serialize -spec = serialize(expression) -# Returns {"type": "Column", "module": "...", "params": {...}} +artifact = serialize(query) ``` -### deserialize() +The artifact is JSON-native, so it goes through a plain `json.dumps` with no +custom encoder: -Restores an expression object from a dictionary: +```python +text = json.dumps(artifact) +``` + +### deserialize(artifact, dialect, registry=None) ```python from rhosocial.activerecord.backend.expression.serialization import deserialize -expr = deserialize(spec, dialect) +expr = deserialize(json.loads(text), dialect) ``` -### ExpressionFactory +`json.loads` needs **no `object_hook`** — and there is nothing for a hook to do. +The artifact is plain JSON throughout: a node is `{kind, params}`, a parameter +is a scalar, an array or an object. There are no markers, so nothing has to be +recognised in order to be decoded. The one thing JSON cannot spell — a value +sent to the database, such as `bytes` or a `timedelta` — is carried as the text +its kind names. -Provides factory methods to create expressions: +`registry=None` means the framework's default instance — every node the +distribution ships. Pass your own to control exactly which kinds a read may +produce. -```python -from rhosocial.activerecord.backend.expression.serialization import ExpressionFactory +### deserialize_json(text, dialect, registry=None) / deserialize_xml(bytes, ...) -factory = ExpressionFactory(dialect) -expr = factory.create("Column", name="id", table="users") -``` +The same artifact in three renderings: a dict, a JSON text, an XML document. +All three round-trip to the same instance; the parameters and the rendered SQL +must both come back unchanged. -## ExpressionSpec Format +## Artifact Format -The serialized dictionary contains three required fields: +A **bare node. No envelope.** | Field | Type | Description | |-------|------|-------------| -| `type` | str | Expression class name (e.g., `Column`, `ComparisonPredicate`) | -| `module` | str | Full module path to the expression class | -| `params` | dict | Constructor parameters, keys must match `__init__` parameter names | +| `kind` | str | The node's kind (e.g. `column`, `comparison_predicate`) | +| `params` | dict | Constructor parameters, keyed by the `__init__` parameter names | -### Example +That is the whole thing — no version, no producer, no captured dialect, no +connection fingerprint. ```python # Column("users", "id") { - "type": "Column", - "module": "rhosocial.activerecord.backend.expression.core", - "params": {"name": "id", "table": "users", "alias": null, "schema_name": null} -} -``` - -## Nested Expressions - -Expression parameters may contain other expression objects (e.g., predicates in WHERE clauses). Use the `__expr__` marker during serialization: - -```python -# Serialization of WHERE status = 'active' -{ - "type": "WhereClause", - "module": "rhosocial.activerecord.backend.expression.query_parts", + "kind": "column", "params": { - "condition": { - "__expr__": { - "type": "ComparisonPredicate", - "module": "rhosocial.activerecord.backend.expression.predicates", - "params": {...} - } - } + "name": "id", + "table": {"kind": "table", "params": {"name": "users"}}, + "alias": None, } } ``` -During deserialization, sub-dictionaries marked with `__expr__` are automatically restored as expression objects. +A parameter is either a **language-independent scalar** or an **expression**. +The second form — a nested `{kind, params}` node — is the same thing at a +different layer: a live instance in memory, an encoded node on the wire. There +is no third form, which is what makes the artifact readable at all. -## Tuple Marker +Note there is **no wrapper** around a nested expression. The shape is the whole +signal, so there is nothing to unwrap and no second, weaker way of saying the +same thing. An `__expr__` envelope would have given one fact two spellings and +left the reader guessing which a payload used. -Python `tuple` cannot be directly JSON serialized; use the `__tuple__` marker: +Note what is *not* there. The artifact carries no dialect, so the same payload +re-renders under any dialect object you hand it: ```python -# ORDER BY id DESC, name ASC -{ - "type": "OrderByClause", - "params": { - "order_by_items": [ - {"__tuple__": [Column(...), "DESC"]}, - {"__tuple__": [Column(...), "ASC"]} - ] - } -} +deserialize(artifact, SQLiteDialect(version=(3, 53, 0))).to_sql() ``` -## Error Handling +## Bind Parameters -During deserialization, the following exceptions may be raised: +`to_sql()` returns a pair — the statement and the values to bind: -- `ExpressionDeserializationError`: Type not registered, missing parameters, construction failed -- `TypeError`: Parameter type mismatch (wrapped as `ExpressionDeserializationError`) +```python +sql, params = expr.to_sql() +# ("SELECT ... WHERE age > ?", (18,)) +``` -Dialect compatibility errors (e.g., PostgreSQL-specific syntax not supported by MySQL) only surface at `to_sql()` time. +That `18` was never stored anywhere. It was read off the `literal` node inside +the payload, so nothing else carries it and nothing else can disagree with it. +Expressions never inline a value into the SQL text; the database never sees a +quoted string built by concatenation. -## Use Cases +## Selective Registration -### Cache Query Plans +A registry is a **read-side boundary**: it filters what an artifact *contains*, +not what a class *declares*. A parameter left at its default produces no node, +and so needs no entry. ```python -# Serialize -query = query.select(...).where(...) -spec = serialize(query) +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry + +read_only = ExpressionRegistry() +for kind in ("query_statement", "where_clause", "comparison_predicate", + "column", "literal", "table"): + read_only.register_node(default_registry().resolve_node(kind)) -# Deserialize -restored = deserialize(spec, new_dialect) -sql = restored.to_sql() +deserialize(artifact, dialect, registry=read_only) ``` -### Distributed Task Scheduling +A query without a grouping reads fine under that table; one with a `GROUP BY` +is refused, by name. That is the permission shape: **whatever this channel does +not accept, it cannot be made to accept by sending it something.** -Pass expressions as task parameters; at deserialization, inject the target environment's dialect. +Be clear about what a registry does *not* do. `serialize` consults no registry, +so a narrow table does not stop a tree being written, and it does not decide +what may be executed. It governs what a reader accepts from an artifact it did +not author. A boundary that had to hold on the write side would need a +different mechanism, because the writer is the same process that would enforce +it. -## Security Considerations +## Error Handling -### Why Must We Verify BaseExpression Type? +Every rejection names what it rejected, because an artifact you did not write +cannot be debugged by guessing. -During deserialization, the framework verifies that the class pointed to by `spec["type"]` must be a subclass of `BaseExpression`. This security check prevents: +| Situation | Error | +|-----------|-------| +| A kind nobody registered | `kind 'x' is not in the kind whitelist` | +| A leftover envelope field | `a bare node carries exactly 'kind' and 'params'` | +| An unrecognised parameter | `kind 'x' does not accept parameter 'y'` | +| A missing kind | `the kind is the node's identity and is mandatory` | +| Nesting past the depth limit | `artifact nesting depth exceeds N` | -1. **Arbitrary Class Instantiation**: Attackers could craft malicious specs to deserialize dangerous classes like `subprocess.Popen` -2. **Code Execution**: Without type checking, `_reconstruct()` might call dangerous class constructors +## Use Cases -By enforcing this check, only expression classes inheriting from `BaseExpression` can be deserialized, ensuring security boundaries. +### Cache Query Plans -### Why Is Dynamic Module Import Not Allowed? +```python +stored = json.dumps(serialize(query)) +# ... later, in another process +query = deserialize(json.loads(stored), dialect) +``` -`ExpressionRegistry.lookup()` does not support dynamic module import via the `module` parameter. This is a security design decision: +The cache holds JSON text, so it goes into any cache that stores strings, and +the dialect is chosen at read time — one cache serves every backend. -1. **Arbitrary Module Loading**: Attackers could load any installed Python module via the `module` field in spec -2. **Global Registry Pollution**: Dynamically imported classes were written to the global registry, affecting subsequent requests +### Distributed Task Scheduling -Therefore, all expression classes must be pre-registered via `ExpressionRegistry.register()` or `_auto_register_builtins()` before use. +A worker receives text and rebuilds the tree under its own dialect. Nothing +about the payload is tied to the machine that produced it. -### Secure Usage Recommendations +## Security Considerations -**Recommended: Complete Round-Trip** +### No name in an artifact reaches an import -```python -# Correct: Use serialize/deserialize round-trip, do not manually modify spec -expr = query.where(...) -spec = serialize(expr) +Resolution is by whitelist, never by import path. A payload naming +`os.system` gets "not in the kind whitelist" — it is looked up, found absent, +and the read fails. There is no code path that could have imported it. -# Store, transmit, deserialize -restored = deserialize(spec, dialect) -``` +The registry module does not import expression packages at all, and the +mechanism that assembles the default instance lives with the framework, not in +the registry. A registry cannot discover anything. + +### The artifact is untrusted input -**Not Recommended: Manually Construct Spec** +- Unknown kinds, leftover fields and unrecognised parameters each fail by name. +- Nesting depth and length are bounded. +- No dialect-level validation happens here — that is left to `to_sql()`, where + support questions belong. + +### Do not hand-edit artifacts ```python -# Incorrect: Manually constructing spec may cause security issues -spec = { - "type": "Column", - "module": "some.module", # Could be abused - "params": {...} -} -``` +# Correct: build the tree, let the codec write it +text = json.dumps(serialize(query)) -Unless you fully understand the following risks, avoid manually modifying specs: -- Incorrect `type` or `module` will cause deserialization failure -- Nested expressions must use `__expr__` marker -- Using reserved key names (`__expr__`, `__tuple__`) will cause data corruption +# Wrong: assembling a payload by hand couples you to a format the library +# does not promise to keep, and an artifact you did not write is exactly the +# input the reader is built to distrust. +``` ## Related Documents -- [Extending Guide](./extending.md): How to implement serialization for custom expressions -- [Format Reference](./format-reference.md): Complete ExpressionSpec specification -- [Migration Guide](./migration.md): Version compatibility notes \ No newline at end of file +- `docs/examples/expression_artifact.py` — a runnable end-to-end example +- `.claude/plan/2026-10-12/表达式-方言架构规则.md` §7, §10 — registry and artifact rules \ No newline at end of file diff --git a/docs/en_US/backend/expression/statements.md b/docs/en_US/backend/expression/statements.md index b2974d9ec..2acd4eda3 100644 --- a/docs/en_US/backend/expression/statements.md +++ b/docs/en_US/backend/expression/statements.md @@ -4,30 +4,47 @@ The `statements` module defines the core DML (Data Manipulation Language) and DD ## DML Statements -### QueryExpression (SELECT) +### QueryStatement (SELECT) -`QueryExpression` represents a `SELECT` statement. It composes various clauses (SELECT, FROM, WHERE, GROUP BY, etc.) into a complete query. +`QueryStatement` represents a `SELECT` statement. It composes various clauses (SELECT, FROM, WHERE, GROUP BY, etc.) into a complete query. ```python -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + Literal, + NamedRelationRef, + QueryStatement, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table -# Basic SELECT -query = QueryExpression( +# Basic SELECT -- a typed column builds the predicate +query = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "email")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "status") == Literal(dialect, "active") + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=StringColumn(dialect, "status") == "active" ) sql, params = query.to_sql() # sql: 'SELECT "name", "email" FROM "users" WHERE "status" = ?' # params: ("active",) +# The same predicate spelled out as a node +predicate = ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "status"), + Literal(dialect, "active"), +) + # SELECT with DISTINCT from rhosocial.activerecord.backend.expression import SelectModifier -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "category")], - from_=TableExpression(dialect, "products"), + from_=NamedRelationRef(dialect, Table(dialect, "products")), select_modifier=SelectModifier.DISTINCT ) sql, params = query.to_sql() @@ -35,22 +52,23 @@ sql, params = query.to_sql() # params: () ``` -### InsertExpression (INSERT) +### InsertStatement (INSERT) -`InsertExpression` represents an `INSERT` statement. It supports single-row, multi-row, `INSERT ... SELECT`, and `ON CONFLICT` clauses. +`InsertStatement` represents an `INSERT` statement. It supports single-row, multi-row, `INSERT ... SELECT`, and `ON CONFLICT` clauses. ```python -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, Literal +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource, Literal +from rhosocial.activerecord.backend.expression.objects import Table # Single row insert source = ValuesSource(dialect, values_list=[ [Literal(dialect, "John Doe"), Literal(dialect, "john@example.com")] ]) -insert = InsertExpression( +insert = InsertStatement( dialect, - into="users", - columns=["name", "email"], - source=source + Table(dialect, "users"), + source=source, + columns=["name", "email"] ) sql, params = insert.to_sql() # sql: 'INSERT INTO "users" ("name", "email") VALUES (?, ?)' @@ -63,11 +81,11 @@ on_conflict = OnConflictClause( conflict_target=["id"], do_nothing=True ) -insert = InsertExpression( +insert = InsertStatement( dialect, - into="products", - columns=["id", "name"], + Table(dialect, "products"), source=source, + columns=["id", "name"], on_conflict=on_conflict ) sql, params = insert.to_sql() @@ -75,64 +93,83 @@ sql, params = insert.to_sql() # params: (...) ``` -### UpdateExpression (UPDATE) +### UpdateStatement (UPDATE) -`UpdateExpression` represents an `UPDATE` statement. It supports `SET` assignments, `WHERE` clauses, and optional `FROM` clauses (for joins). +`UpdateStatement` represents an `UPDATE` statement. It supports `SET` assignments, `WHERE` clauses, and optional `FROM` clauses (for joins). ```python -from rhosocial.activerecord.backend.expression import UpdateExpression, Literal, Column +from rhosocial.activerecord.backend.expression import ( + IntegerColumn, + Literal, + ReturningClause, + StringColumn, + UpdateStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table # Basic UPDATE -update = UpdateExpression( +update = UpdateStatement( dialect, - table="users", + Table(dialect, "users"), assignments={ "status": Literal(dialect, "inactive"), "updated_at": Literal(dialect, "2023-01-01") }, - where=Column(dialect, "last_login") < Literal(dialect, "2022-01-01") + where=StringColumn(dialect, "last_login") < "2022-01-01" ) sql, params = update.to_sql() # sql: 'UPDATE "users" SET "status" = ?, "updated_at" = ? WHERE "last_login" < ?' # params: ("inactive", "2023-01-01", "2022-01-01") -# UPDATE with RETURNING -from rhosocial.activerecord.backend.expression import ReturningClause -update = UpdateExpression( +# UPDATE with RETURNING -- arithmetic needs a numeric column class +update = UpdateStatement( dialect, - table="items", - assignments={"quantity": Column(dialect, "quantity") - Literal(dialect, 1)}, - where=Column(dialect, "id") == Literal(dialect, 123), - returning=ReturningClause(dialect, [Column(dialect, "quantity")]) + Table(dialect, "items"), + assignments={"quantity": IntegerColumn(dialect, "quantity") - 1}, + where=IntegerColumn(dialect, "id") == 123, + returning=ReturningClause(dialect, [IntegerColumn(dialect, "quantity")]) ) sql, params = update.to_sql() # sql: 'UPDATE "items" SET "quantity" = "quantity" - ? WHERE "id" = ? RETURNING "quantity"' # params: (1, 123) ``` -### DeleteExpression (DELETE) +### DeleteStatement (DELETE) -`DeleteExpression` represents a `DELETE` statement. It supports `WHERE` clauses and optional `USING` clauses (for joins). +`DeleteStatement` represents a `DELETE` statement. It supports `WHERE` clauses and optional `USING` clauses (for joins). ```python -from rhosocial.activerecord.backend.expression import DeleteExpression, Literal, Column +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + DeleteStatement, + NamedRelationRef, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table # Basic DELETE -delete = DeleteExpression( +delete = DeleteStatement( dialect, - table="sessions", - where=Column(dialect, "expires_at") < Literal(dialect, "NOW()") + Table(dialect, "sessions"), + where=StringColumn(dialect, "expires_at") < "NOW()" ) sql, params = delete.to_sql() # sql: 'DELETE FROM "sessions" WHERE "expires_at" < ?' # params: ("NOW()",) # DELETE with USING (Join delete) -delete = DeleteExpression( +delete = DeleteStatement( dialect, - table="users", - using="old_users", - where=Column(dialect, "id", "users") == Column(dialect, "old_id", "old_users") + Table(dialect, "users"), + using=NamedRelationRef(dialect, Table(dialect, "old_users")), + where=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "users"), + Column(dialect, "old_id", "old_users") + ) ) sql, params = delete.to_sql() # sql: 'DELETE FROM "users" USING "old_users" WHERE "users"."id" = "old_users"."old_id"' @@ -143,41 +180,45 @@ sql, params = delete.to_sql() > **Inline Literals in DDL** > -> DDL clauses (`DEFAULT`, `CHECK`, partial-index `WHERE`, partition boundaries, etc.) do **not** accept bind parameters — the database engine requires these positions to contain directly parseable SQL text. Therefore, when using `Literal` expressions in DDL, you **must** set `inline_literals=True`: +> DDL clauses (`DEFAULT`, `CHECK`, partial-index `WHERE`, partition boundaries, etc.) do **not** accept bind parameters — the database engine requires these positions to contain directly parseable SQL text. A literal therefore needs `inline=True` wherever the clause does not normalise the value itself: > > ```python > # ✗ Wrong: DEFAULT ? in DDL causes execution failure -> default_value=Literal(dialect, 0) +> Literal(dialect, 0) > > # ✓ Correct: inlined produces DEFAULT 0 -> default_value=Literal(dialect, 0, inline_literals=True) +> Literal(dialect, 0, inline=True) > ``` > > DML statements (`INSERT`/`SELECT`/`UPDATE`/`DELETE`) are not affected — the default bind-parameter mode works normally. -### CreateTableExpression +### CreateTableStatement -`CreateTableExpression` represents a `CREATE TABLE` statement. +`CreateTableStatement` represents a `CREATE TABLE` statement. Data types should use `DataType` expressions (e.g. `IntegerType`, `TextType`, `VarCharType`, etc.) rather than raw strings: ```python from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, Literal, Column, + ColumnConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType, RealType +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType # Create table — using DataType expressions -create = CreateTableExpression( +create = CreateTableStatement( dialect, - table="new_users", + Table(dialect, "new_users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ColumnDefinition(dialect, "bio", TextType(dialect)), ], @@ -185,79 +226,98 @@ create = CreateTableExpression( ) sql, params = create.to_sql() # sql: 'CREATE TABLE IF NOT EXISTS "new_users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, -# "name" VARCHAR(255) NOT NULL, "bio" TEXT)' +# "name" TEXT NOT NULL, "bio" TEXT)' # params: () ``` #### Inline Literals in DEFAULT and CHECK Constraints -DDL `DEFAULT` and `CHECK` clauses require inline literals. Use `Literal(..., inline_literals=True)`: +A DDL syntax position accepts no bind parameters. `ColumnDefaultConstraint` +normalises whatever it is given to an inline literal, so a plain value is +enough; a predicate inside `CHECK` keeps the literals it was built with, so mark +them `inline=True`: ```python +from rhosocial.activerecord.backend.expression import ( + ComparisonOperator, + ComparisonPredicate, + IntegerColumn, + Literal, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, +) +from rhosocial.activerecord.backend.expression.types import RealType + # Column with DEFAULT value ColumnDefinition(dialect, "status", TextType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, "active", inline_literals=True), # -> DEFAULT 'active' - ), -]), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")), +]) +# -> "status" TEXT DEFAULT 'active' -# Column with CHECK constraint +# Column with CHECK constraint -- inline=True keeps the value out of params ColumnDefinition(dialect, "age", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", - Column(dialect, "age"), - Literal(dialect, 0, inline_literals=True), # -> CHECK ("age" >= 0) + ColumnConstraint(dialect, branch=ColumnCheckConstraint( + dialect, + ComparisonPredicate( + dialect, + ComparisonOperator.GTE, + IntegerColumn(dialect, "age"), + Literal(dialect, 0, inline=True), # -> CHECK ("age" >= 0) ), - ), -]), + )), +]) # DEFAULT numeric value ColumnDefinition(dialect, "score", RealType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0.0, inline_literals=True), # -> DEFAULT 0.0 - ), -]), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0)), +]) +# -> "score" REAL DEFAULT 0.0 ``` #### Generated Columns (SQLite 3.31.0+, PostgreSQL 12+, MySQL 5.7+) -Generated columns (computed columns) use the `GeneratedColumnExpression` expression, rendered through `dialect.format_generated_column_expression`: +Generated columns (computed columns) use the `ColumnGeneratedConstraint` expression, rendered through `dialect.format_column_generated_constraint`: ```python from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, Column, + ColumnConstraint, + ColumnDefinition, + ColumnPrimaryKeyConstraint, + IntegerColumn, + NumericColumn, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import IntegerType, RealType from rhosocial.activerecord.backend.expression.statements import ( - GeneratedColumnExpression, GeneratedColumnType, + ColumnGeneratedConstraint, + CreateTableStatement, + GeneratedColumnType, ) # Create table with generated columns columns = [ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "price", RealType(dialect)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), # STORED generated column - computed and stored on disk ColumnDefinition(dialect, - name="total", - data_type=RealType(dialect), - generated_expression=GeneratedColumnExpression( + "total", + RealType(dialect), + generated_expression=ColumnGeneratedConstraint( dialect, - expression=Column(dialect, "price") * Column(dialect, "quantity"), + expression=NumericColumn(dialect, "price") * IntegerColumn(dialect, "quantity"), storage_type=GeneratedColumnType.STORED, ), ), ] -create = CreateTableExpression( - dialect=dialect, - table="products", +create = CreateTableStatement( + dialect, + Table(dialect, "products"), columns=columns, ) sql, params = create.to_sql() @@ -271,16 +331,17 @@ sql, params = create.to_sql() # dialect.supports_virtual_generated_columns() - VIRTUAL support ``` -### DropTableExpression +### DropTableStatement -`DropTableExpression` represents a `DROP TABLE` statement. +`DropTableStatement` represents a `DROP TABLE` statement. ```python -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import DropTableStatement -drop = DropTableExpression( +drop = DropTableStatement( dialect, - table_name="old_users", + Table(dialect, "old_users"), if_exists=True, cascade=True ) @@ -289,50 +350,60 @@ sql, params = drop.to_sql() # params: () ``` -### CreateViewExpression / DropViewExpression +### CreateViewStatement / DropViewStatement Expressions for managing database views. ```python -from rhosocial.activerecord.backend.expression import CreateViewExpression +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import ( + CreateViewStatement, + DropViewStatement, +) # Create View -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "name")], - from_=TableExpression(dialect, "users") + from_=NamedRelationRef(dialect, Table(dialect, "users")) ) -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_names", + View(dialect, "user_names"), query=query, - or_replace=True + replace=True ) sql, params = create_view.to_sql() # sql: 'CREATE OR REPLACE VIEW "user_names" AS SELECT "name" FROM "users"' # params: () + +# Drop View +drop_view = DropViewStatement(dialect, View(dialect, "user_names"), if_exists=True) +sql, params = drop_view.to_sql() +# sql: 'DROP VIEW IF EXISTS "user_names"' +# params: () ``` -### TruncateExpression +### TruncateStatement -`TruncateExpression` represents a `TRUNCATE TABLE` statement for fast row deletion. +`TruncateStatement` represents a `TRUNCATE TABLE` statement for fast row deletion. ```python -from rhosocial.activerecord.backend.expression import TruncateExpression +from rhosocial.activerecord.backend.expression import TruncateStatement # Basic TRUNCATE -truncate = TruncateExpression( +truncate = TruncateStatement( dialect, - table_name="logs" + Table(dialect, "logs") ) sql, params = truncate.to_sql() # sql: 'TRUNCATE TABLE "logs"' # params: () # TRUNCATE with RESTART IDENTITY and CASCADE -truncate = TruncateExpression( +truncate = TruncateStatement( dialect, - table_name="orders", + Table(dialect, "orders"), restart_identity=True, cascade=True ) @@ -343,26 +414,27 @@ sql, params = truncate.to_sql() ### Schema DDL -#### CreateSchemaExpression +#### CreateSchemaStatement -`CreateSchemaExpression` represents a `CREATE SCHEMA` statement for creating database schemas (namespaces). +`CreateSchemaStatement` represents a `CREATE SCHEMA` statement for creating database schemas (namespaces). ```python -from rhosocial.activerecord.backend.expression.statement import CreateSchemaExpression +from rhosocial.activerecord.backend.expression.objects import Schema +from rhosocial.activerecord.backend.expression.statements import CreateSchemaStatement # Basic CREATE SCHEMA -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="my_schema" + Schema(dialect, "my_schema") ) sql, params = create_schema.to_sql() # sql: 'CREATE SCHEMA "my_schema"' # params: () # CREATE SCHEMA IF NOT EXISTS with AUTHORIZATION -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="app_schema", + Schema(dialect, "app_schema"), if_not_exists=True, authorization="app_user" ) @@ -371,17 +443,17 @@ sql, params = create_schema.to_sql() # params: () ``` -#### DropSchemaExpression +#### DropSchemaStatement -`DropSchemaExpression` represents a `DROP SCHEMA` statement. +`DropSchemaStatement` represents a `DROP SCHEMA` statement. ```python -from rhosocial.activerecord.backend.expression.statement import DropSchemaExpression +from rhosocial.activerecord.backend.expression.statements import DropSchemaStatement # DROP SCHEMA with options -drop_schema = DropSchemaExpression( +drop_schema = DropSchemaStatement( dialect, - schema_name="old_schema", + Schema(dialect, "old_schema"), if_exists=True, cascade=True ) @@ -392,18 +464,20 @@ sql, params = drop_schema.to_sql() ### Index DDL -#### CreateIndexExpression +#### CreateIndexStatement -`CreateIndexExpression` represents a `CREATE INDEX` statement with support for various index types and options. +`CreateIndexStatement` represents a `CREATE INDEX` statement with support for various index types and options. ```python -from rhosocial.activerecord.backend.expression.statement import CreateIndexExpression +from rhosocial.activerecord.backend.expression import StringColumn +from rhosocial.activerecord.backend.expression.objects import Index, Table +from rhosocial.activerecord.backend.expression.statements import CreateIndexStatement # Basic CREATE INDEX -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"] ) sql, params = create_index.to_sql() @@ -411,23 +485,23 @@ sql, params = create_index.to_sql() # params: () # UNIQUE INDEX with WHERE clause (partial index) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_active_users", - table_name="users", + Index(dialect, "idx_active_users"), + Table(dialect, "users"), columns=["email"], unique=True, - where=Column(dialect, "status") == Literal(dialect, "active") + where=StringColumn(dialect, "status") == "active" ) sql, params = create_index.to_sql() # sql: 'CREATE UNIQUE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' # params: ("active",) # Composite index with index type -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_orders_user_date", - table_name="orders", + Index(dialect, "idx_orders_user_date"), + Table(dialect, "orders"), columns=["user_id", "created_at"], index_type="BTREE" ) @@ -436,10 +510,10 @@ sql, params = create_index.to_sql() # params: () # Index with INCLUDE clause (covering index) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"], include=["id", "name"] ) @@ -448,19 +522,19 @@ sql, params = create_index.to_sql() # params: () ``` -#### DropIndexExpression +#### DropIndexStatement -`DropIndexExpression` represents a `DROP INDEX` statement. +`DropIndexStatement` represents a `DROP INDEX` statement. ```python -from rhosocial.activerecord.backend.expression.statement import DropIndexExpression +from rhosocial.activerecord.backend.expression.statements import DropIndexStatement # DROP INDEX -drop_index = DropIndexExpression( +drop_index = DropIndexStatement( dialect, - index_name="idx_old_index", + Index(dialect, "idx_old_index"), if_exists=True, - table_name="users" # Optional: provides table context + table=Table(dialect, "users") # Optional: provides table context ) sql, params = drop_index.to_sql() # sql: 'DROP INDEX IF EXISTS "idx_old_index" ON "users"' @@ -469,26 +543,27 @@ sql, params = drop_index.to_sql() ### Sequence DDL -#### CreateSequenceExpression +#### CreateSequenceStatement -`CreateSequenceExpression` represents a `CREATE SEQUENCE` statement for generating unique numeric identifiers. +`CreateSequenceStatement` represents a `CREATE SEQUENCE` statement for generating unique numeric identifiers. ```python -from rhosocial.activerecord.backend.expression.statement import CreateSequenceExpression +from rhosocial.activerecord.backend.expression.objects import Sequence +from rhosocial.activerecord.backend.expression.statements import CreateSequenceStatement # Basic CREATE SEQUENCE -create_seq = CreateSequenceExpression( +create_seq = CreateSequenceStatement( dialect, - sequence_name="user_id_seq" + Sequence(dialect, "user_id_seq") ) sql, params = create_seq.to_sql() # sql: 'CREATE SEQUENCE "user_id_seq" NO CYCLE' # params: () # CREATE SEQUENCE with all options -create_seq = CreateSequenceExpression( +create_seq = CreateSequenceStatement( dialect, - sequence_name="order_seq", + Sequence(dialect, "order_seq"), if_not_exists=True, start=1000, increment=1, @@ -503,16 +578,16 @@ sql, params = create_seq.to_sql() # params: () ``` -#### DropSequenceExpression +#### DropSequenceStatement -`DropSequenceExpression` represents a `DROP SEQUENCE` statement. +`DropSequenceStatement` represents a `DROP SEQUENCE` statement. ```python -from rhosocial.activerecord.backend.expression.statement import DropSequenceExpression +from rhosocial.activerecord.backend.expression.statements import DropSequenceStatement -drop_seq = DropSequenceExpression( +drop_seq = DropSequenceStatement( dialect, - sequence_name="old_seq", + Sequence(dialect, "old_seq"), if_exists=True ) sql, params = drop_seq.to_sql() @@ -520,17 +595,17 @@ sql, params = drop_seq.to_sql() # params: () ``` -#### AlterSequenceExpression +#### AlterSequenceStatement -`AlterSequenceExpression` represents an `ALTER SEQUENCE` statement for modifying existing sequences. +`AlterSequenceStatement` represents an `ALTER SEQUENCE` statement for modifying existing sequences. ```python -from rhosocial.activerecord.backend.expression.statement import AlterSequenceExpression +from rhosocial.activerecord.backend.expression.statements import AlterSequenceStatement # Restart sequence -alter_seq = AlterSequenceExpression( +alter_seq = AlterSequenceStatement( dialect, - sequence_name="user_id_seq", + Sequence(dialect, "user_id_seq"), restart=1000 ) sql, params = alter_seq.to_sql() @@ -538,9 +613,9 @@ sql, params = alter_seq.to_sql() # params: () # Multiple changes -alter_seq = AlterSequenceExpression( +alter_seq = AlterSequenceStatement( dialect, - sequence_name="order_seq", + Sequence(dialect, "order_seq"), increment=2, maxvalue=1000000, cycle=True @@ -550,19 +625,30 @@ sql, params = alter_seq.to_sql() # params: () ``` -### MergeExpression (MERGE) +### MergeStatement (MERGE) -`MergeExpression` represents a `MERGE` statement (Upsert). +`MergeStatement` represents a `MERGE` statement (Upsert). ```python -from rhosocial.activerecord.backend.expression import MergeExpression, MergeAction, MergeActionType +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + MergeAction, + MergeActionType, + MergeStatement, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table # Define Actions when_matched = MergeAction( + dialect, action_type=MergeActionType.UPDATE, assignments={"name": Column(dialect, "name", "source")} ) when_not_matched = MergeAction( + dialect, action_type=MergeActionType.INSERT, assignments={ "id": Column(dialect, "id", "source"), @@ -571,17 +657,22 @@ when_not_matched = MergeAction( ) # Merge Statement -merge = MergeExpression( - dialect, - target_table=TableExpression(dialect, "target", "t"), - source=TableExpression(dialect, "source", "s"), - on_condition=Column(dialect, "id", "t") == Column(dialect, "id", "s"), +merge = MergeStatement( + dialect, + target_table=Table(dialect, "target"), + source=NamedRelationRef(dialect, Table(dialect, "source"), alias="s"), + on_condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "target"), + Column(dialect, "id", "s") + ), when_matched=[when_matched], when_not_matched=[when_not_matched] ) sql, params = merge.to_sql() -# sql: 'MERGE INTO "target" AS "t" USING "source" AS "s" ON "t"."id" = "s"."id" ...' +# sql: 'MERGE INTO "target" USING "source" AS "s" ON "target"."id" = "s"."id" ...' # params: () ``` @@ -595,23 +686,29 @@ Materialized views are database objects that pre-compute and store query results > - MySQL: Not supported (use tables as alternative) > - SQLite: Not supported -#### CreateMaterializedViewExpression +#### CreateMaterializedViewStatement -`CreateMaterializedViewExpression` represents a `CREATE MATERIALIZED VIEW` statement. +`CreateMaterializedViewStatement` represents a `CREATE MATERIALIZED VIEW` statement. ```python -from rhosocial.activerecord.backend.expression.statement import CreateMaterializedViewExpression +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + GroupByHavingClause, +) +from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table +from rhosocial.activerecord.backend.expression.statements import CreateMaterializedViewStatement # Basic CREATE MATERIALIZED VIEW -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "user_id"), FunctionCall(dialect, "COUNT", Column(dialect, "id"))], - from_=TableExpression(dialect, "orders"), + from_=NamedRelationRef(dialect, Table(dialect, "orders")), group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "user_id")]) ) -create_mv = CreateMaterializedViewExpression( +create_mv = CreateMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), query=query ) sql, params = create_mv.to_sql() @@ -619,29 +716,29 @@ sql, params = create_mv.to_sql() # params: () # Materialized view with TABLESPACE (PostgreSQL) -create_mv = CreateMaterializedViewExpression( +create_mv = CreateMaterializedViewStatement( dialect, - view_name="user_summary", + MaterializedView(dialect, "user_summary"), query=query, tablespace="fast_storage", - with_data=False + no_data=True ) sql, params = create_mv.to_sql() # sql: 'CREATE MATERIALIZED VIEW "user_summary" TABLESPACE "fast_storage" AS SELECT ... WITH NO DATA' # params: () ``` -#### DropMaterializedViewExpression +#### DropMaterializedViewStatement -`DropMaterializedViewExpression` represents a `DROP MATERIALIZED VIEW` statement. +`DropMaterializedViewStatement` represents a `DROP MATERIALIZED VIEW` statement. ```python -from rhosocial.activerecord.backend.expression.statement import DropMaterializedViewExpression +from rhosocial.activerecord.backend.expression.statements import DropMaterializedViewStatement # DROP MATERIALIZED VIEW -drop_mv = DropMaterializedViewExpression( +drop_mv = DropMaterializedViewStatement( dialect, - view_name="old_summary", + MaterializedView(dialect, "old_summary"), if_exists=True, cascade=True ) @@ -650,26 +747,26 @@ sql, params = drop_mv.to_sql() # params: () ``` -#### RefreshMaterializedViewExpression +#### RefreshMaterializedViewStatement -`RefreshMaterializedViewExpression` represents a `REFRESH MATERIALIZED VIEW` statement for refreshing materialized view data. +`RefreshMaterializedViewStatement` represents a `REFRESH MATERIALIZED VIEW` statement for refreshing materialized view data. ```python -from rhosocial.activerecord.backend.expression.statement import RefreshMaterializedViewExpression +from rhosocial.activerecord.backend.expression.statements import RefreshMaterializedViewStatement # Basic refresh -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts" + MaterializedView(dialect, "order_counts") ) sql, params = refresh_mv.to_sql() # sql: 'REFRESH MATERIALIZED VIEW "order_counts"' # params: () # CONCURRENTLY refresh (PostgreSQL 9.4+, requires unique index) -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), concurrent=True ) sql, params = refresh_mv.to_sql() @@ -677,9 +774,9 @@ sql, params = refresh_mv.to_sql() # params: () # WITH DATA/WITH NO DATA option -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), with_data=True ) sql, params = refresh_mv.to_sql() @@ -702,16 +799,16 @@ if dialect.supports_refresh_materialized_view(): # Can refresh materialized view pass -# Check CONCURRENTLY refresh support (PostgreSQL 9.4+) -if dialect.supports_materialized_view_concurrent_refresh(): - # Can use CONCURRENTLY option - pass - # Check TABLESPACE support if dialect.supports_materialized_view_tablespace(): # Can specify tablespace for materialized view pass +# Check storage options support (WITH DATA / WITH NO DATA) +if dialect.supports_with_data_clause(): + # Can spell the WITH [NO] DATA clause + pass + # Check storage options support if dialect.supports_materialized_view_storage_options(): # Can specify storage parameters diff --git a/docs/en_US/backend/introspection.md b/docs/en_US/backend/introspection.md index 70b70b46a..1b7db0569 100644 --- a/docs/en_US/backend/introspection.md +++ b/docs/en_US/backend/introspection.md @@ -7,7 +7,7 @@ Database introspection is the ability to query database structure metadata at ru The introspection system is accessible via the `backend.introspector` attribute and provides: - **Database Information**: Name, version, encoding, size, etc. -- **Table Information**: Table list, table details, table types (base table, view, system table) +- **Table Information**: Table list, table details, table types (`BASE_TABLE`, `VIEW`, `MATERIALIZED_VIEW`, `FOREIGN_TABLE`, `PARTITIONED_TABLE`, `SYSTEM_TABLE`, `TEMPORARY`, `EXTERNAL`) - **Column Information**: Column name, data type, nullability, default value, primary key info - **Index Information**: Index name, columns, uniqueness, index type - **Foreign Key Information**: Referenced table, column mapping, update/delete actions @@ -21,30 +21,45 @@ The introspection system is accessible via the `backend.introspector` attribute ``` backend/introspection/ ├── __init__.py # Module exports -├── base.py # Abstract base classes -├── types.py # Data structure definitions +├── base.py # Abstract introspector bases (sync and async) +├── types.py # Data structure definitions (TableType, ...) ├── errors.py # Introspection-specific exceptions ├── executor.py # Executor abstraction -└── backend_mixin.py # Backend mixin class +├── cache.py # IntrospectionCache -- the injectable result cache +├── backend_mixin.py # Backend mixin class +└── status/ # Server status overview (config, performance, storage) ``` +### A Catalogue Question Is a Statement + +Introspection does not build SQL text inline. Each question is a statement class +(named `*Statement`, e.g. `TableListQueryStatement`), rendered by a dialect +`format_*_query_statement` method; the introspector holds the public method pairs +(`list_tables()` / `parse_tables()`, and so on) that use them. Two consequences +matter to a backend author: + +- the expression layer keeps no state -- a statement holds what it needs to be + rendered anywhere; +- a backend answers a catalogue question by declaring a statement plus a + formatter, not by overriding a method that assembles a string. + ### Sync and Async Separation The introspection system follows the project's sync/async parity principle: - `SyncAbstractIntrospector` — Synchronous introspector - `AsyncAbstractIntrospector` — Asynchronous introspector -- `IntrospectorMixin` — Shared non-I/O logic (caching, SQL generation, parsing) +- `IntrospectorMixin` — Shared non-I/O logic (caching, SQL generation, parsing), including `server_version`: the version the introspector read from the server, so a capability question can be answered without asking again ## Data Structures -### DatabaseInfo +### IntrospectionDatabaseInfo Basic database information: ```python @dataclass -class DatabaseInfo: +class IntrospectionDatabaseInfo: name: str # Database name version: str # Version string version_tuple: Tuple[int, ...] # Version tuple @@ -57,29 +72,36 @@ class DatabaseInfo: extra: Dict[str, Any] # Additional info ``` -### TableInfo +### IntrospectionTableInfo Table information: ```python @dataclass -class TableInfo: +class IntrospectionTableInfo: name: str # Table name schema: str # Schema name - table_type: TableType # Table type + table_type: TableType # Table type (BASE_TABLE, VIEW, MATERIALIZED_VIEW, + # FOREIGN_TABLE, PARTITIONED_TABLE, SYSTEM_TABLE, + # TEMPORARY, EXTERNAL) comment: Optional[str] # Table comment row_count: Optional[int] # Row count estimate size_bytes: Optional[int] # Table size extra: Dict[str, Any] # Additional info ``` -### ColumnInfo +`table_type` keeps the distinction the catalogue actually makes rather than +folding every relation into "table". On SQLite, for instance, the storage tables +behind an FTS or R-Tree virtual table are `SYSTEM_TABLE` and the virtual table +itself is `EXTERNAL`. + +### IntrospectionColumnInfo Column information: ```python @dataclass -class ColumnInfo: +class IntrospectionColumnInfo: name: str # Column name table_name: str # Owner table name data_type: str # Data type @@ -110,13 +132,13 @@ class IndexInfo: extra: Dict[str, Any] # Additional info ``` -### ForeignKeyInfo +### IntrospectionForeignKeyInfo Foreign key information: ```python @dataclass -class ForeignKeyInfo: +class IntrospectionForeignKeyInfo: name: str # Foreign key name table_name: str # Owner table name columns: List[str] # Foreign key columns @@ -128,13 +150,13 @@ class ForeignKeyInfo: extra: Dict[str, Any] # Additional info ``` -### ViewInfo +### IntrospectionViewInfo View information: ```python @dataclass -class ViewInfo: +class IntrospectionViewInfo: name: str # View name schema: str # Schema name definition: str # View definition SQL @@ -143,13 +165,13 @@ class ViewInfo: extra: Dict[str, Any] # Additional info ``` -### TriggerInfo +### IntrospectionTriggerInfo Trigger information: ```python @dataclass -class TriggerInfo: +class IntrospectionTriggerInfo: name: str # Trigger name table_name: str # Associated table name event: TriggerEvent # Trigger event @@ -317,13 +339,52 @@ triggers = await backend.introspector.list_triggers() ## Caching Mechanism -Introspection results are cached by default for performance. +Introspection results are cached by default for performance. The cache is a +component the manager **owns** and a caller may replace, rather than a hidden +attribute: `backend.introspector.cache`. + +### The Cache Contract -### Cache Configuration +`IntrospectionCache` (`rhosocial.activerecord.backend.introspection.cache`) is a +protocol, so a backend can put its own implementation behind the manager instead +of inheriting this package's expiry policy: + +| Method | Purpose | +|--------|---------| +| `key(scope, name=None, schema=None, extra=None)` | Build the key two callers asking the same question share | +| `get(key)` | Return a cached result, or `None` | +| `set(key, value)` | Store a result | +| `invalidate(scope=None, name=None)` | Drop results from everything down to one object | +| `clear()` | Drop every cached result | + +`InMemoryIntrospectionCache` is the default: in-process, expiring after a TTL +(300 seconds by default), dropped lazily the moment somebody looks at an expired +entry rather than by a background sweeper. ```python -# Default TTL is 300 seconds (5 minutes) -# Automatically set when introspector is initialized +from rhosocial.activerecord.backend.introspection.cache import InMemoryIntrospectionCache + +# Default: in-process, 300 s TTL +backend.introspector.cache = InMemoryIntrospectionCache(ttl=60) + +# Or a cache of your own: anything that satisfies the protocol +class NoCache: + def key(self, scope, name=None, schema=None, extra=None): + return (scope, name, schema, extra) + + def get(self, key): + return None + + def set(self, key, value): + pass + + def invalidate(self, scope=None, name=None): + pass + + def clear(self): + pass + +backend.introspector.cache = NoCache() ``` ### Cache Management @@ -338,38 +399,33 @@ from rhosocial.activerecord.backend.introspection.types import IntrospectionScop # Invalidate all table-related cache backend.introspector.invalidate_cache(scope=IntrospectionScope.TABLE) -# Invalidate cache for a specific table +# Invalidate cache for a specific table, index, column set, ... backend.introspector.invalidate_cache( scope=IntrospectionScope.TABLE, name="users" ) - -# Invalidate column-related cache -backend.introspector.invalidate_cache( - scope=IntrospectionScope.COLUMN, - table_name="users" -) - -# Invalidate index-related cache backend.introspector.invalidate_cache( scope=IntrospectionScope.INDEX, - table_name="users", name="idx_users_email" ) ``` ### Cache Scopes -| Scope | Description | Invalidation Parameters | -|-------|-------------|------------------------| -| `DATABASE` | Database information | None | -| `SCHEMA` | Schema information | `name` | -| `TABLE` | Table information | `name` | -| `COLUMN` | Column information | `table_name`, `name` | -| `INDEX` | Index information | `table_name`, `name` | -| `FOREIGN_KEY` | Foreign key information | `table_name`, `name` | -| `VIEW` | View information | `name` | -| `TRIGGER` | Trigger information | `table_name`, `name` | +A scope says which cached answers an invalidation reaches, so the table a +question is about travels in the `name` (or, for `key()`, in `schema`/`extra`): + +| Scope | Description | +|-------|-------------| +| `DATABASE` | Database information | +| `SCHEMA` | Schema information | +| `TABLE` | Table information | +| `COLUMN` | Column information | +| `INDEX` | Index information | +| `FOREIGN_KEY` | Foreign key information | +| `VIEW` | View information | +| `TRIGGER` | Trigger information | + ## Backend-Specific Implementations diff --git a/docs/en_US/backend/named_expression.md b/docs/en_US/backend/named_expression.md index 7e4526b8e..801c5b26b 100644 --- a/docs/en_US/backend/named_expression.md +++ b/docs/en_US/backend/named_expression.md @@ -79,19 +79,29 @@ Before Named Expression existed, the most common pattern for running database qu Named Expression encapsulates query logic in a **pure Python function**. The key innovation is that the `dialect` parameter is automatically injected by the framework at execution time, allowing the same query code to work across different database backends. ```python +from rhosocial.activerecord.backend.expression import ( + Column, NamedRelationRef, QueryStatement, StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + # Define once, use anywhere with any dialect def active_users(dialect, limit: int = 100, status: str = "active"): """Get active users from the database.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "status") == status, + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=StringColumn(dialect, "status") == status, limit_offset=LimitOffsetClause(dialect, limit=limit), ) + # When executed with SQLite dialect, generates SQLite SQL # When executed with MySQL dialect, generates MySQL SQL +sql, params = active_users(dialect).to_sql() +print(sql, params) ``` ### Invoking Named Queries @@ -151,41 +161,54 @@ Executes `EXPLAIN` to show the query execution plan, useful for performance anal #### 2. Programmatic API (Python Code) -The programmatic API provides more control and integrates well with applications: +The programmatic API provides more control and integrates well with applications. +The blocks below point at the shipped SQLite example module, so they run as +written; substitute your own module path in practice. **One-shot method (quick):** ```python from rhosocial.activerecord.backend.named_expression import resolve_named_expression from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -backend = SQLiteBackend(database="mydb.sqlite") +# A dotted path into an importable module -- here, one of the shipped examples +QUERY = ( + "rhosocial.activerecord.backend.impl.sqlite.examples" + ".named_expressions.order_clauses.where_example" +) + +backend = SQLiteBackend(database=":memory:") dialect = backend.dialect -# Resolve, generate SQL, and execute in one call -expr, sql, params = resolve_named_expression( - "myapp.queries.users.active_users", - dialect, - {"limit": 50, "status": "active"}, -) +# Resolve the named expression: it returns the expression, not SQL +expr = resolve_named_expression(QUERY, dialect, {"status": "active"}) +sql, params = expr.to_sql() print("Generated SQL:", sql) +print("Params:", params) # Use with pandas, direct execute, etc. ``` **Step-by-step method (flexible):** ```python -from rhosocial.activerecord.backend.named_expression import NamedQueryResolver +from rhosocial.activerecord.backend.named_expression import NamedExpressionResolver + +# Same dotted path as above +QUERY = ( + "rhosocial.activerecord.backend.impl.sqlite.examples" + ".named_expressions.order_clauses.where_example" +) # Load the named query resolver -resolver = NamedQueryResolver("myapp.queries.users.active_users").load() +resolver = NamedExpressionResolver(QUERY).load() # Inspect without execution info = resolver.describe() -print(f"Parameters: {info['parameters']}") -print(f"Returns: {info['return_type']}") +print(f"Parameters: {list(info['parameters'])}") +print(f"Signature: {info['signature']}") # Generate the expression and SQL when needed -expr = resolver.execute(dialect, {"limit": 50}) +expr = resolver.execute(dialect, {"status": "active"}) sql, params = expr.to_sql() +print(sql, params) ``` #### 3. CI/CD Integration @@ -298,7 +321,9 @@ The `ctx` object provides methods to interact with the execution environment: For independent sub-tasks that can run concurrently, use `ctx.parallel()`: ```python -from rhosocial.activerecord.backend.named_expression import Procedure, ProcedureContext, ParallelStep +from rhosocial.activerecord.backend.named_expression import ( + Procedure, ProcedureContext, ParallelStep, +) class OrderProcessingProcedure(Procedure): order_id: int @@ -390,33 +415,67 @@ python -m rhosocial.activerecord.backend.impl.sqlite named-procedure \ #### Programmatic API ```python +import sys +import types + +from rhosocial.activerecord.backend.expression import ( + Column, NamedRelationRef, QueryStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.named_expression import ( - ProcedureRunner, TransactionMode, ProcedureResult, + Procedure, ProcedureContext, ProcedureRunner, TransactionMode, ) -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -backend = SQLiteBackend(database="mydb.sqlite") -dialect = backend.dialect +# The runner resolves a dotted name, so the query and the procedure must live +# in importable modules. Registering them in sys.modules keeps this snippet +# self-contained; in a project they are ordinary modules under "myapp". +backend.execute("CREATE TABLE IF NOT EXISTS demo_orders (id INTEGER PRIMARY KEY)") +backend.execute("INSERT INTO demo_orders (id) VALUES (1)") + +queries = types.ModuleType("doc_demo.queries") + + +def count_orders(dialect): + """Count the demo orders.""" + return QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "demo_orders")), + ) + + +queries.count_orders = count_orders +sys.modules["doc_demo.queries"] = queries + + +class ArchiveProcedure(Procedure): + month: str = "2026-03" + + def run(self, ctx: ProcedureContext) -> None: + ctx.execute("doc_demo.queries.count_orders", bind="orders") + count = len(list(ctx.rows("orders"))) + ctx.log(f"{count} order(s) for {self.month}", level="INFO") + + +procedures = types.ModuleType("doc_demo.procedures") +procedures.ArchiveProcedure = ArchiveProcedure +sys.modules["doc_demo.procedures"] = procedures # Load the procedure class -runner = ProcedureRunner( - "myapp.procedures.monthly_cleanup.MonthlyCleanupProcedure" -).load() +runner = ProcedureRunner("doc_demo.procedures.ArchiveProcedure").load() # Execute with parameters -result: ProcedureResult = runner.run( - dialect, +result = runner.run( + backend, user_params={"month": "2026-03"}, transaction_mode=TransactionMode.AUTO, - backend=backend, - execute_query=backend.execute, ) # Check result if result.aborted: - print(f"⚠️ Aborted: {result.abort_reason}") + print(f"Aborted: {result.abort_reason}") else: - print(f"✅ Done. Output steps: {len(result.outputs)}") + print(f"Done. Output steps: {len(result.outputs)}") # Access logs for entry in result.logs: @@ -459,7 +518,7 @@ from rhosocial.activerecord.backend.named_expression import AsyncProcedureRunner from rhosocial.activerecord.backend.impl.sqlite.backend import AsyncSQLiteBackend app = FastAPI() -async_backend = AsyncSQLiteBackend(database="mydb.sqlite") +async_backend = AsyncSQLiteBackend(database=":memory:") @app.post("/admin/cleanup/{month}") async def run_cleanup(month: str): @@ -467,22 +526,20 @@ async def run_cleanup(month: str): "myapp.procedures.monthly_cleanup_async.MonthlyCleanupAsyncProcedure" ).load() result = await runner.run( - async_backend.dialect, + async_backend, user_params={"month": month}, transaction_mode=TransactionMode.AUTO, - backend=async_backend, - execute_query=async_backend.execute, ) return { "aborted": result.aborted, "abort_reason": result.abort_reason, - "outputs": result.outputs, + "outputs": len(result.outputs), } ``` > **Important**: > - `AsyncProcedure` subclasses must be run by `AsyncProcedureRunner`; `Procedure` subclasses must be run by `ProcedureRunner`. Do not mix them. -> - `ProcedureRunner` requires a **sync** backend (e.g., `SQLiteBackend`, `MySQLBackend`); `AsyncProcedureRunner` requires an **async** backend (e.g., `AsyncSQLiteBackend`, `AsyncMySQLBackend`). Passing the wrong backend type will raise `NamedQueryError` at runtime. +> - `ProcedureRunner` requires a **sync** backend (e.g., `SQLiteBackend`, `MySQLBackend`); `AsyncProcedureRunner` requires an **async** backend (e.g., `AsyncSQLiteBackend`, `AsyncMySQLBackend`). Passing the wrong backend type will raise `NamedExpressionError` at runtime. --- @@ -514,6 +571,16 @@ Generate a flowchart without executing the procedure — useful for documentatio ```python from rhosocial.activerecord.backend.named_expression import Procedure +class MyProcedure(Procedure): + """A procedure whose diagram can be drawn without a database.""" + month: str = "2026-03" + + def run(self, ctx): + ctx.execute( + "myapp.queries.orders.count_monthly_orders", + params={"month": self.month}, + ) + # Flowchart format print(MyProcedure.static_diagram("flowchart")) @@ -526,16 +593,11 @@ print(MyProcedure.static_diagram("sequence")) After executing a procedure, generate a flowchart showing actual execution status and timing: ```python -from rhosocial.activerecord.backend.named_expression import ProcedureRunner - -runner = ProcedureRunner("myapp.procedures.OrderProcessing").load() -result = runner.run(backend) - -# Flowchart format with execution status -print(result.diagram("flowchart", procedure_name="OrderProcessing")) +# `runner` and `result` come from the Programmatic API example above +print(result.diagram("flowchart", procedure_name="ArchiveProcedure")) # Sequence diagram format -print(result.diagram("sequence", procedure_name="OrderProcessing")) +print(result.diagram("sequence", procedure_name="ArchiveProcedure")) ``` ### Feature Comparison @@ -555,20 +617,21 @@ print(result.diagram("sequence", procedure_name="OrderProcessing")) ### Exceptions -- `NamedQueryError` - Base exception for all named query/procedure errors -- `NamedQueryNotFoundError` - Query function not found -- `NamedQueryModuleNotFoundError` - Module containing query not found -- `NamedQueryInvalidReturnTypeError` - Query doesn't return a valid expression -- `NamedQueryInvalidParameterError` - Invalid parameter provided -- `NamedQueryMissingParameterError` - Required parameter not provided -- `NamedQueryNotCallableError` - Named object is not callable -- `NamedQueryExplainNotAllowedError` - EXPLAIN not allowed for this query type +- `NamedExpressionError` - Base exception for all named query/procedure errors +- `NamedExpressionNotFoundError` - Query function not found +- `NamedExpressionModuleNotFoundError` - Module containing query not found +- `NamedExpressionModuleNotAllowedError` - Module outside the resolver's allowlist +- `NamedExpressionInvalidReturnTypeError` - Query doesn't return a valid expression +- `NamedExpressionInvalidParameterError` - Invalid parameter provided +- `NamedExpressionMissingParameterError` - Required parameter not provided +- `NamedExpressionNotCallableError` - Named object is not callable +- `NamedExpressionExplainNotAllowedError` - EXPLAIN not allowed for this query type ### Named Expression API -- `NamedQueryResolver` - Main resolver class for loading and executing named queries -- `resolve_named_expression()` - Convenience function for one-shot resolve and execute -- `list_named_queries_in_module()` - Discover all queries in a module +- `NamedExpressionResolver` - Main resolver class for loading and executing named queries +- `resolve_named_expression()` - One-shot resolve and build; returns the expression, SQL generation stays an explicit step +- `list_named_expressions_in_module()` - Discover all queries in a module ### Named Procedure API @@ -591,7 +654,7 @@ All examples use the **SQLite backend** as the demonstration database: | Example | Description | |---------|-------------| -| `named_queries/order_queries.py` | Order-related named query definitions with full Setup/Business Logic/Execution/Teardown structure | +| `named_expressions/order_expressions.py` | Order-related named query definitions with full Setup/Business Logic/Execution/Teardown structure | | `named_procedures/order_workflow.py` | Order processing workflow demonstrating parallel execution | | `named_procedures/diagram_demo.py` | Flowchart visualization demonstration | @@ -599,7 +662,7 @@ All examples use the **SQLite backend** as the demonstration database: | Example | Description | |---------|-------------| -| `cli/named_expression_demo.py` | Demonstrates all named query CLI operations (list, describe, dry-run, execute) | +| `cli/named_query_demo.py` | Demonstrates all named query CLI operations (list, describe, dry-run, execute) | | `cli/named_procedure_demo.py` | Demonstrates all named procedure CLI operations (list, describe, dry-run, execute, transaction modes) | | `cli/named_connection_demo.py` | Demonstrates named connection CLI operations | @@ -608,7 +671,7 @@ All examples use the **SQLite backend** as the demonstration database: ```bash # Python example: Run named query cd src/rhosocial/activerecord/backend/impl/sqlite/examples -PYTHONPATH=../../../../..:. python3 named_queries/order_queries.py +PYTHONPATH=../../../../..:. python3 named_expressions/order_expressions.py # Python example: Generate flowchart cd src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures @@ -616,7 +679,7 @@ PYTHONPATH=../../../../..:. python3 diagram_demo.py # CLI example: Named Expression CLI cd src/rhosocial/activerecord/backend/impl/sqlite/examples -PYTHONPATH=../../../../..:. python3 cli/named_expression_demo.py +PYTHONPATH=../../../../..:. python3 cli/named_query_demo.py # CLI example: Named Procedure CLI PYTHONPATH=../../../../..:. python3 cli/named_procedure_demo.py diff --git a/docs/en_US/backend/sqlite/backend_specific_features/fts5.md b/docs/en_US/backend/sqlite/backend_specific_features/fts5.md index 62c062e90..04a9733d0 100644 --- a/docs/en_US/backend/sqlite/backend_specific_features/fts5.md +++ b/docs/en_US/backend/sqlite/backend_specific_features/fts5.md @@ -557,7 +557,7 @@ class FTS5Extension(SQLiteExtensionBase): def get_supported_tokenizers(self, version: Tuple[int, int, int]) -> List[str]: """Get tokenizers supported for specified version""" - def format_create_virtual_table(...) -> Tuple[str, tuple]: + def format_create_virtual_table_statement(...) -> Tuple[str, tuple]: """Generate CREATE statement""" def format_match_expression(...) -> Tuple[str, tuple]: @@ -572,7 +572,7 @@ class FTS5Extension(SQLiteExtensionBase): def format_snippet_expression(...) -> Tuple[str, tuple]: """Generate snippet expression""" - def format_drop_virtual_table(...) -> Tuple[str, tuple]: + def format_drop_virtual_table_statement(...) -> Tuple[str, tuple]: """Generate DROP statement""" ``` diff --git a/docs/en_US/backend/sqlite/cli/README.md b/docs/en_US/backend/sqlite/cli/README.md index cab35a3ff..92970506b 100644 --- a/docs/en_US/backend/sqlite/cli/README.md +++ b/docs/en_US/backend/sqlite/cli/README.md @@ -6,8 +6,8 @@ The SQLite backend includes a command-line interface for database operations. Th The CLI commands fall into two categories: -1. **SQLite-specific commands** — SQLite-unique operations (query, introspect, info) -2. **Core inherited commands** — shared across all backends (named-expression, named-procedure, named-migration, named-connection) +1. **SQLite-specific commands** — SQLite-unique operations (query, introspect, status, info) +2. **Core inherited commands** — shared across all backends (named-expression, named-procedure, named-procedure-graph, named-migration, named-connection) ## Invocation @@ -136,8 +136,9 @@ Inspect database metadata: # List all tables rhosocial-activerecord-sqlite introspect tables --db-file my.db -# List all views +# List all views, or describe one of them rhosocial-activerecord-sqlite introspect views --db-file my.db +rhosocial-activerecord-sqlite introspect views active_users --db-file my.db # Get database information rhosocial-activerecord-sqlite introspect database --db-file my.db @@ -145,27 +146,71 @@ rhosocial-activerecord-sqlite introspect database --db-file my.db # Include system tables rhosocial-activerecord-sqlite introspect tables --db-file my.db --include-system -# Get complete table info (columns, indexes, foreign keys) -rhosocial-activerecord-sqlite introspect table users --db-file my.db - # Query specific details rhosocial-activerecord-sqlite introspect columns users --db-file my.db rhosocial-activerecord-sqlite introspect indexes users --db-file my.db rhosocial-activerecord-sqlite introspect foreign-keys posts --db-file my.db + +# The triggers of one table +rhosocial-activerecord-sqlite introspect triggers posts --db-file my.db ``` #### 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, generated columns included | required (table) | +| `indexes` | Index information | required (table) | +| `foreign-keys` | Foreign key information | required (table) | +| `triggers` | Trigger information | optional (table) | + +Each introspection type reports exactly one kind of catalogue item; ask for `columns`, `indexes` and `foreign-keys` separately instead of expecting a combined per-table report. + +A name the type cannot use is refused (exit 2) rather than ignored: `introspect tables users` used to print the whole catalogue while reading as though it had been narrowed. + +The raw `PRAGMA` sub-introspector (`backend.introspector.pragma`) is reachable from the library only. Its fourteen methods are not catalogue questions: `integrity_check`, `foreign_key_check` and `wal_checkpoint` are actions, `journal_mode` and `set` change state, and `table_info`/`index_list` are the raw reads the seven types above already wrap. `introspect` answers "what does this database hold"; a second door onto the same facts, or a door onto actions, would make that sentence untrue. + +### status + +Display a status overview of the server: + +```bash +# Complete overview +rhosocial-activerecord-sqlite status --db-file my.db + +# Configuration parameters only +rhosocial-activerecord-sqlite status config --db-file my.db + +# Performance counters only +rhosocial-activerecord-sqlite status performance --db-file my.db + +# Storage usage only +rhosocial-activerecord-sqlite status storage --db-file my.db + +# Attached databases only +rhosocial-activerecord-sqlite status databases --db-file my.db + +# Add descriptive columns +rhosocial-activerecord-sqlite status config -v --db-file my.db + +# JSON output +rhosocial-activerecord-sqlite status -o json --db-file my.db +``` + +Status types: + +| Type | Description | +|------|-------------| +| `all` | Complete overview (default) | +| `config` | Configuration parameters | +| `performance` | Performance counters | +| `storage` | Storage usage | +| `databases` | Attached databases | + +The `all` type renders a nested structure, so `csv` and `tsv` requests fall back to `json` for it. ## Core Inherited Commands @@ -256,6 +301,24 @@ rhosocial-activerecord-sqlite named-procedure \ --db-file mydb.sqlite ``` +### named-procedure-graph + +Execute procedure graphs (DAG workflows) described in a Python module: + +```bash +# Run a graph +rhosocial-activerecord-sqlite named-procedure-graph run \ + myapp.npg.monthly_report \ + --db-file mydb.sqlite \ + --params '{"month": "2026-04"}' + +# List the graphs a module defines +rhosocial-activerecord-sqlite named-procedure-graph list myapp.npg + +# Validate a graph without running it +rhosocial-activerecord-sqlite named-procedure-graph validate myapp.npg.monthly_report +``` + ### named-migration Execute named migrations: @@ -315,10 +378,12 @@ backend/impl/sqlite/ ├── info.py # 'info' command handler ├── query.py # 'query' command handler ├── introspect.py # 'introspect' command handler + ├── status.py # 'status' command handler │ │ # Core inherited commands (thin adapters) ├── named_expression.py # Delegates to core named_expression.cli ├── named_procedure.py # Delegates to core named_expression.procedure.cli + ├── named_procedure_graph.py # Delegates to core named_expression.procedure.cli ├── named_migration.py # Delegates to core migration.cli └── named_connection.py # Delegates to core named_connection.cli ``` diff --git a/docs/en_US/backend/template/backend_specific_features/README.md b/docs/en_US/backend/template/backend_specific_features/README.md index d0ad7749d..330aeda47 100644 --- a/docs/en_US/backend/template/backend_specific_features/README.md +++ b/docs/en_US/backend/template/backend_specific_features/README.md @@ -19,7 +19,7 @@ When you encounter a feature in this section, check whether it is a backend-spec |---------|-------------|---------------------------| | Expressions | Core expression classes (Column, Literal, FunctionCall, etc.) | Dialect overrides and {database}-specific expression classes | | Type System | Core DataType hierarchy (IntegerType, VarCharType, etc.) | {database}-specific DataType subclasses and type adapters | -| EXPLAIN | ExplainExpression interface | {database}-specific EXPLAIN syntax and result parsing | +| EXPLAIN | ExplainStatement interface | {database}-specific EXPLAIN syntax and result parsing | | Introspection | Introspector interface | {database}-specific metadata queries | ## Related Topics diff --git a/docs/en_US/backend/template/backend_specific_features/dialect.md b/docs/en_US/backend/template/backend_specific_features/dialect.md index d231f6e81..73723f6eb 100644 --- a/docs/en_US/backend/template/backend_specific_features/dialect.md +++ b/docs/en_US/backend/template/backend_specific_features/dialect.md @@ -6,7 +6,7 @@ Understanding these principles is essential for working with the expression syst ### 1. Expressions Are Declarative -An expression instance **collects all parameters** that affect SQL generation. It is a data container, not an SQL generator. When you construct `Column('age') >= Literal(18)`, you are building a data structure that captures the column name, the operator, and the value — not generating SQL. +An expression instance **collects all parameters** that affect SQL generation. It is a data container, not an SQL generator. When you construct `ComparisonPredicate(dialect, ComparisonOperator.GTE, Column(dialect, "age"), Literal(dialect, 18))`, you are building a data structure that captures the column name, the operator, and the value — not generating SQL. ### 2. Delegation to Dialect @@ -39,8 +39,17 @@ rhosocial-activerecord's expression system has two layers: This means the **same expression class** produces different SQL depending on which backend is configured: ```python +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + Literal, +) + # Core expression — works on any backend -expr = Column('age') >= Literal(18) +expr = ComparisonPredicate( + dialect, ComparisonOperator.GTE, Column(dialect, "age"), Literal(dialect, 18) +) # On MySQL dialect: `age` >= %s # On PostgreSQL dialect: "age" >= $1 @@ -71,7 +80,7 @@ Because protocols are `@runtime_checkable`, you can check whether a dialect impl ```python from rhosocial.activerecord.backend.dialect.protocols import WindowFunctionSupport -dialect = get_current_dialect() +# dialect is your configured backend's dialect (backend.dialect) if isinstance(dialect, WindowFunctionSupport): # Window functions are available — check version-gated specifics @@ -87,8 +96,8 @@ Each protocol exposes `supports_*()` methods that return `True` or `False`. Thes ```python # Check if the backend supports a specific feature if dialect.supports_returning_insert(): - # Use RETURNING clause - query = User.query().insert(...).returning('id') + # RETURNING can be attached to an INSERT statement + ... if dialect.supports_json_type(): # Use JSON column type @@ -145,8 +154,9 @@ The following expression classes are shared across all backends. They live in `r | `Literal` | Parameter placeholder (e.g., `?`, `%s`, `$1`) | | `FunctionCall` | Function invocation (`FUNC(args)`), aggregate `FILTER (WHERE ...)` and window `OVER (...)` | | `Subquery` | Subquery as expression | -| `TableExpression` | Table reference with temporal options | -| `WildcardExpression` | `*` wildcard | +| `Table` | Table object naming a relation | +| `NamedRelationRef` | A relation in a FROM list, optionally aliased | +| `AllColumnsWildcard` | `*` wildcard | | `ComparisonPredicate` | `=`, `!=`, `>`, `>=`, `<`, `<=` | | `LikePredicate` | `LIKE`, `ILIKE` | | `InPredicate` | `IN (...)` | @@ -154,11 +164,11 @@ The following expression classes are shared across all backends. They live in `r | `IsNullPredicate` | `IS NULL`, `IS NOT NULL` | | `LogicalPredicate` | `AND`, `OR`, `NOT` | | `BinaryExpression` | Binary operators | -| `BinaryArithmeticExpression` | `+`, `-`, `*`, `/`, `%` | +| `ArithmeticOperation` | `add`, `sub`, `mul`, `div`, `mod` (written as `+`, `-`, `*`, `/`, `%`) | | `CaseExpression` | `CASE WHEN ... THEN ... END` | -| `JSONDocumentExpression` | JSON path, yielding a document (`->`) | -| `JSONTextExpression` | JSON path, yielding text (`->>`) | -| `ArrayExpression` | Array operators | +| `JSONDocumentValue` | JSON path, yielding a document (`->`) | +| `JSONTextValue` | JSON path, yielding text (`->>`) | +| `ArrayValue` | Array operators | ### Mixin Classes (Operator Overloading) @@ -265,18 +275,49 @@ In addition to overriding core methods, the {backend} backend provides its own e ```python # Using core expression classes directly -from rhosocial.activerecord.backend.expression import Column, Literal, FunctionCall +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + NamedRelationRef, + QueryStatement, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table # Column references -query = User.query().where(Column('age') >= Literal(18)) +query = QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=(StringColumn(dialect, "name") == "Alice"), +) # Function calls -query = User.query().select(FunctionCall('COUNT', Column('id'))) +query = QueryStatement( + dialect, + select=[FunctionCall(dialect, "COUNT", Column(dialect, "id"))], + from_=NamedRelationRef(dialect, Table(dialect, "users")), +) ``` ### Through Model Proxies ```python +from typing import ClassVar + +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.model import ActiveRecord + +class User(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: int + age: int + +User.configure(SQLiteConnectionConfig(database=":memory:"), SQLiteBackend) + # More idiomatic: use model.c proxy query = User.query().where(User.c.age >= 18) diff --git a/docs/en_US/backend/template/backend_specific_features/introspection.md b/docs/en_US/backend/template/backend_specific_features/introspection.md index ae56ac2c2..ff28eb0c2 100644 --- a/docs/en_US/backend/template/backend_specific_features/introspection.md +++ b/docs/en_US/backend/template/backend_specific_features/introspection.md @@ -203,8 +203,8 @@ python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect tables python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect columns users # Output formats -python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect tables --format json -python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect tables --format csv +python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect tables -o json +python -m rhosocial.activerecord.backend.impl.{backend}.cli introspect tables -o csv ``` ## See Also diff --git a/docs/en_US/backend/template/backend_specific_features/partition.md b/docs/en_US/backend/template/backend_specific_features/partition.md index 2c4977ab1..3785b4ace 100644 --- a/docs/en_US/backend/template/backend_specific_features/partition.md +++ b/docs/en_US/backend/template/backend_specific_features/partition.md @@ -63,7 +63,7 @@ MySQL supports subpartitioning (partitioning a partition), which PostgreSQL does > **Declaring partitions.** Partitions are declared by constructing > backend-specific expression classes and passing them to -> `CreateTableExpression(partition=...)`. The example below shows the +> `CreateTableStatement(partition=...)`. The example below shows the > expression-level path. ### PostgreSQL Example diff --git a/docs/en_US/backend/template/cli/README.md b/docs/en_US/backend/template/cli/README.md index 90f9fca53..4437b25d8 100644 --- a/docs/en_US/backend/template/cli/README.md +++ b/docs/en_US/backend/template/cli/README.md @@ -7,7 +7,7 @@ Every {backend} backend includes a command-line interface (CLI) for database ope The CLI commands fall into two categories: 1. **Backend-specific commands** — `{database}`-unique operations (query, introspect, info, status) -2. **Core inherited commands** — shared across all backends (named-expression, named-procedure, named-migration, named-connection) +2. **Core inherited commands** — shared across all backends (named-expression, named-procedure, named-procedure-graph, named-migration, named-connection) ## Invocation @@ -130,10 +130,6 @@ Inspect database metadata: rhosocial-activerecord-{backend} introspect tables \ --host localhost --port {port} --database mydb -# Describe a specific table -rhosocial-activerecord-{backend} introspect table users \ - --host localhost --port {port} --database mydb - # List columns rhosocial-activerecord-{backend} introspect columns users \ --host localhost --port {port} --database mydb @@ -143,23 +139,21 @@ rhosocial-activerecord-{backend} introspect columns users \ Each backend supports different introspection types: -| Type | Description | -|------|-------------| -| `tables` | List all tables | -| `views` | List all views | -| `table` | Describe a specific table | -| `columns` | List columns for a table | -| `indexes` | List indexes for a table | -| `foreign-keys` | List foreign keys for a table | -| `triggers` | List triggers | -| `database` | Database information | +| Type | Description | Name | +|------|-------------|------| +| `tables` | List all tables | not accepted | +| `views` | List all views, or describe one | optional (view) | +| `columns` | List columns for a table | required (table) | +| `indexes` | List indexes for a table | required (table) | +| `foreign-keys` | List foreign keys for a table | required (table) | +| `triggers` | List triggers, optionally for one table | optional (table) | +| `database` | Database information | not accepted | + +Each type reports exactly one kind of catalogue item; there is no combined per-table report, so describe a table by asking for its `columns`, `indexes` and `foreign-keys` separately. -Backend-specific types: +A name a type cannot use is an error (exit 2), never a silently ignored argument. -| Backend | Extra Types | -|---------|------------| -| PostgreSQL | `extensions` | -| Oracle | `sequences` | +Backend-specific types live in each backend's own CLI documentation — `src/rhosocial/activerecord/backend/impl/{backend}/cli/README.md` — rather than in a list here, because a second hand-maintained list is a second thing to fall behind. ### status @@ -274,6 +268,16 @@ rhosocial-activerecord-{backend} named-procedure \ --host localhost --port {port} --database mydb ``` +### named-procedure-graph + +Execute procedure graphs (DAG workflows): + +```bash +rhosocial-activerecord-{backend} named-procedure-graph run \ + --host localhost --port {port} --database mydb \ + --params '{"month": "2026-04"}' +``` + ### named-migration Execute named migrations: diff --git a/docs/en_US/backend/template/customization/custom_adapters.md b/docs/en_US/backend/template/customization/custom_adapters.md index 4066c80b1..4729ba12f 100644 --- a/docs/en_US/backend/template/customization/custom_adapters.md +++ b/docs/en_US/backend/template/customization/custom_adapters.md @@ -52,7 +52,7 @@ class ColorAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() # Register: {python_type: {db_types}} - self._register_type(Color, str) + self.register_type(Color, str) def _do_to_database(self, value, target_type, options): """Convert Color -> hex string for database storage.""" diff --git a/docs/en_US/backend/template/customization/custom_expressions.md b/docs/en_US/backend/template/customization/custom_expressions.md index 57e392da0..57192142c 100644 --- a/docs/en_US/backend/template/customization/custom_expressions.md +++ b/docs/en_US/backend/template/customization/custom_expressions.md @@ -29,7 +29,7 @@ When `to_sql()` is called, the expression delegates to its bound dialect's `form You never write this delegation yourself: `to_sql()` is implemented **once in `BaseExpression`** and resolves the `format_method` name your class declares: ```python -class MyExpression(SQLValueExpression): +class MyExpression(SQLValue): @property def format_method(self) -> str: # to_sql() resolves this name on the bound dialect and calls @@ -60,20 +60,20 @@ All expressions inherit from one of these base classes in `rhosocial.activerecor | Base Class | Purpose | Use When | |-----------|---------|----------| -| `SQLValueExpression` | Returns a non-boolean value (integer, string, date, etc.) | Creating a function call or computed value | +| `SQLValue` | Returns a non-boolean value (integer, string, date, etc.) | Creating a function call or computed value | | `SQLPredicate` | Returns a boolean value | Creating a condition or filter | Both are rendered through the unified `to_sql()` — your subclass declares only its `format_method` name; the dialect's matching `format_*()` method returns `(sql_string, params_tuple)`. ## Creating a Custom Value Expression -Subclass `SQLValueExpression` for expressions that produce a value: +Subclass `SQLValue` for expressions that produce a value: ```python -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import SQLValue, SQLQueryAndParams -class RegexMatchExpression(SQLValueExpression): +class RegexMatchExpression(SQLValue): """Custom expression: column REGEXP pattern.""" def __init__(self, dialect, column, pattern): @@ -153,11 +153,11 @@ Expression classes gain operators by composing mixins from `rhosocial.activereco Example with operator support: ```python -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ArithmeticMixin, ComparisonMixin -class DistanceExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): +class DistanceExpression(ArithmeticMixin, ComparisonMixin, SQLValue): """Custom expression: ST_Distance(col1, col2).""" def __init__(self, dialect, col1, col2): @@ -196,7 +196,7 @@ query = User.query().where(distance < 10000) Delegation is the **only** rendering mechanism: the expression class declares the formatting method name, and the dialect implements it. This allows different backends to produce different SQL: ```python -class MyCustomExpression(SQLValueExpression): +class MyCustomExpression(SQLValue): @property def format_method(self) -> str: # to_sql() resolves this on the bound dialect and calls diff --git a/docs/en_US/introduction/coming_from_frameworks.md b/docs/en_US/introduction/coming_from_frameworks.md index 512bcdaaa..653600f35 100644 --- a/docs/en_US/introduction/coming_from_frameworks.md +++ b/docs/en_US/introduction/coming_from_frameworks.md @@ -35,7 +35,7 @@ If you're familiar with other ORMs or frameworks, this guide will help you map y | `Column(Integer)` | `int` with type hints | Native Python types | | `session.add()` | `.save()` | Persist object | | `session.commit()` | Auto-commit or explicit | Transaction handling | -| `select()` | `QueryExpression` | SQL expression building | +| `select()` | `QueryStatement` | SQL expression building | | `text()` raw SQL | Use with caution | We prefer expressions | **Key differences:** diff --git a/docs/en_US/introduction/competitor_analysis/distinguishing_features.md b/docs/en_US/introduction/competitor_analysis/distinguishing_features.md index 0fcac3011..5a1de4698 100644 --- a/docs/en_US/introduction/competitor_analysis/distinguishing_features.md +++ b/docs/en_US/introduction/competitor_analysis/distinguishing_features.md @@ -41,7 +41,7 @@ The guarantee is structural, not procedural: # the name of the dialect method that renders it. @property def format_method(self) -> str: - return "format_cte_expression" # e.g. CTEQuery declares this + return "format_cte_clause" # e.g. CTEClause declares this ``` `BaseExpression.to_sql()` is implemented **once, at the root** and is the @@ -59,7 +59,7 @@ SQLQueryAndParams = Tuple[str, tuple] Three properties follow directly from this design: -1. **Values never touch SQL text.** By default `inline_literals=False`, so +1. **Values never touch SQL text.** By default `inline=False`, so literals render as bind-parameter placeholders (`?`, or `%s`/`$1` etc. per-dialect via `get_parameter_placeholder()`), and values are carried in the separate tuple. Inline rendering is an explicit, opt-in, diff --git a/docs/en_US/modeling/batch_processing.md b/docs/en_US/modeling/batch_processing.md index 6611e229e..fcfc249eb 100644 --- a/docs/en_US/modeling/batch_processing.md +++ b/docs/en_US/modeling/batch_processing.md @@ -7,6 +7,46 @@ efficiently -- whether you're running ETL pipelines, data migrations, or analyti > 💡 **AI Prompt:** "I need to process all rows in a 10-million-row table without running > out of memory. What is the recommended approach with rhosocial-activerecord?" +Every example on this page shares the setup below: a small model over an in-memory +SQLite database, filled with a few rows, plus the helpers the snippets call. + +```python +from typing import ClassVar, Optional + +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.model import ActiveRecord + + +class User(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: str + + +def process(row): + """Stand-in for the work the job does with one row.""" + return row["id"] if isinstance(row, dict) else row.id + + +def chunked(items, size): + for start in range(0, len(items), size): + yield items[start:start + size] + + +User.configure(SQLiteConnectionConfig(database=":memory:"), SQLiteBackend) +User.backend().execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" +) +for i in range(1, 12): + User(name=f"user{i}", email=f"user{i}@example.com").save() + +source_data = [{"name": f"new{i}", "email": f"new{i}@example.com"} for i in range(1, 6)] +``` + --- ## 1. The Core Problem: Memory @@ -88,17 +128,21 @@ For large reads, the framework provides `execute_batch_dql` as a method on the b instance. It handles pagination internally and yields one `BatchDQLResult` page at a time: ```python -from rhosocial.activerecord.backend.expression.statements import QueryExpression -from rhosocial.activerecord.backend.expression import WildcardExpression, TableExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + Column, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import OrderByClause -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.statements import QueryStatement # Build a SELECT * FROM users ORDER BY id expression dialect = User.backend().dialect -query_expr = QueryExpression( +query_expr = QueryStatement( dialect, - select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "users"), + select=[AllColumnsWildcard(dialect)], + from_=NamedRelationRef(dialect, Table(dialect, "users")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), ) @@ -132,15 +176,15 @@ trip, that is 10 seconds -- for a dataset that a bulk insert could handle in und ```python # ✅ Bulk insert via the backend's execute_batch_dml method from rhosocial.activerecord.backend.expression import ( - InsertExpression, ValuesSource, Literal, + InsertStatement, ValuesSource, Literal, ) -from rhosocial.activerecord.backend.result import BatchCommitMode +from rhosocial.activerecord.backend.expression.objects import Table dialect = User.backend().dialect exprs = [ - InsertExpression( + InsertStatement( dialect, - into="users", + into=Table(dialect, "users"), columns=["name", "email"], source=ValuesSource( dialect, @@ -163,16 +207,16 @@ for batch_result in User.backend().execute_batch_dml(exprs, batch_size=500): ```python from rhosocial.activerecord.backend.expression import ( - InsertExpression, ValuesSource, Literal, + InsertStatement, ValuesSource, Literal, ) -from rhosocial.activerecord.backend.result import BatchCommitMode +from rhosocial.activerecord.backend.expression.objects import Table dialect = User.backend().dialect with User.transaction(): for chunk in chunked(source_data, size=500): exprs = [ - InsertExpression( - dialect, into="users", columns=["name", "email"], + InsertStatement( + dialect, into=Table(dialect, "users"), columns=["name", "email"], source=ValuesSource(dialect, values_list=[ [Literal(dialect, row["name"]), Literal(dialect, row["email"])] ]), @@ -218,7 +262,7 @@ users = User.query().all() emails = [u.email for u in users] # ✅ Fetches only id and email -rows = User.query().select("id", "email").all() +rows = User.backend().fetch_all("SELECT id, email FROM users") emails = [r["email"] for r in rows] ``` @@ -230,7 +274,7 @@ emails = [r["email"] for r in rows] - [ ] Use cursor-based chunking for live tables; offset pagination for static snapshots - [ ] Use `execute_batch_dml` (or `insert_many`) instead of per-row `save()` for bulk writes - [ ] Choose `WHOLE` transaction for integrity-critical jobs; `PER_BATCH` for resumable jobs -- [ ] Select only the columns you actually need (`query().select(...)`) +- [ ] Select only the columns you actually need (`backend.fetch_all(...)`) - [ ] Monitor memory usage during development with representative data volumes --- diff --git a/docs/en_US/modeling/ddl.md b/docs/en_US/modeling/ddl.md index 95bb1188f..8e16a3137 100644 --- a/docs/en_US/modeling/ddl.md +++ b/docs/en_US/modeling/ddl.md @@ -15,118 +15,239 @@ ### ColumnDefinition -Defines a column with its data type and optional constraints. +Defines a column with its data type and optional constraints. The data type is a +`DataType` object, not a string: the dialect is what knows how a type is spelled. ```python from rhosocial.activerecord.backend.expression import ColumnDefinition -from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnUniqueConstraint, +) # Basic column -ColumnDefinition("name", "VARCHAR(100)") +ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)) +# sql: '"name" TEXT' (SQLite adapts VARCHAR to TEXT) # Column with constraints ColumnDefinition( + dialect, "email", - "VARCHAR(255)", + VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE) + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ] ) +# sql: '"email" TEXT NOT NULL UNIQUE' -# Column with default value +# A DEFAULT is a constraint branch, not a keyword argument ColumnDefinition( + dialect, "status", - "VARCHAR(20)", - default="active" + VarCharType(dialect, length=20), + constraints=[ + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")) + ] ) +# sql: '"status" TEXT DEFAULT \'active\'' ``` ### ColumnConstraint -| Type | Description | -|------|-------------| -| `NOT_NULL` | Column cannot be NULL | -| `NULL` | Column allows NULL (default) | -| `PRIMARY_KEY` | Primary key column | -| `UNIQUE` | Column values must be unique | -| `AUTO_INCREMENT` | Auto-incrementing (database-dependent) | +A column constraint is two levels, mirroring the grammar: `ColumnConstraint` is +the *clause* (the `CONSTRAINT ` prefix and the trailing traits) and each +alternative is a *branch* it refers to. A branch cannot be written on its own, +and a trait a branch's kind cannot carry is refused at construction time rather +than dropped at render time. + +| Branch | Renders | +|--------|---------| +| `ColumnPrimaryKeyConstraint` | `PRIMARY KEY` (SQLite adds `AUTOINCREMENT` when `is_auto_increment=True`) | +| `ColumnUniqueConstraint` | `UNIQUE` | +| `ColumnNotNullConstraint` | `NOT NULL` | +| `ColumnNullableConstraint` | `NULL` | +| `ColumnDefaultConstraint` | `DEFAULT ` | +| `ColumnCheckConstraint` | `CHECK ()` | +| `ColumnForeignKeyConstraint` | `REFERENCES ()` | +| `ColumnGeneratedConstraint` | `GENERATED ALWAYS AS () [STORED\|VIRTUAL]` | + +The clause also carries the traits the standard allows on a constraint: + +```python +from rhosocial.activerecord.backend.expression import ( + ColumnCheckConstraint, + ColumnConstraint, + ColumnUniqueConstraint, + StringColumn, +) + +# CONSTRAINT ... +ColumnConstraint( + dialect, + branch=ColumnUniqueConstraint(dialect), + name="uq_users_email" +) +# sql: ' CONSTRAINT "uq_users_email" UNIQUE' (leading space: the clause is appended to a column) + +# ... DEFERRABLE INITIALLY DEFERRED +ColumnConstraint( + dialect, + branch=ColumnUniqueConstraint(dialect), + deferrable=True, + initially_deferred=True +) +# sql: ' UNIQUE DEFERRABLE INITIALLY DEFERRED' + +# CHECK () +ColumnConstraint( + dialect, + branch=ColumnCheckConstraint(dialect, StringColumn(dialect, "status") != "deleted") +) +# sql: ' CHECK ("status" <> ?)' params: ('deleted',) +``` + +Each trait is a pair of mutually exclusive flags, and each is gated twice: + +| Trait | Flags | Authorised by | Branches that carry it | +|-------|-------|---------------|------------------------| +| Name | `name=` | `supports_constraint_naming` | every branch | +| Deferral | `deferrable` / `not_deferrable` | `supports_deferrable_constraint` | PRIMARY KEY, UNIQUE, FOREIGN KEY | +| Timing | `initially_deferred` / `initially_immediate` | `supports_deferrable_constraint` | PRIMARY KEY, UNIQUE, FOREIGN KEY | +| Enforcement | `enforced` / `not_enforced` | `supports_constraint_enforcement` | PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK | + +Both flags of a pair at once, or a trait on a branch whose kind has no meaning +for it, is an error at construction: + +```python +# ValueError: deferrable and not_deferrable are mutually exclusive options +try: + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), + deferrable=True, not_deferrable=True) +except ValueError as exc: + print(exc) + +# ValueError: DEFERRABLE / INITIALLY cannot ride a ColumnNotNullConstraint; +# the SQL:2016 kinds are PRIMARY KEY, UNIQUE and FOREIGN KEY +try: + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect), deferrable=True) +except ValueError as exc: + print(exc) +``` + +A dialect that cannot spell an accepted trait refuses at render time, so a +statement never silently loses a constraint the caller asked for: + +```python +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +try: + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), enforced=True).to_sql() +except UnsupportedFeatureError as exc: + print(exc) # 'SQLite' dialect does not support ENFORCED/NOT ENFORCED constraint. +``` ### TableConstraint -Defines table-level constraints like primary keys, unique constraints, and foreign keys. +Defines table-level constraints like primary keys, unique constraints, and foreign keys. It follows the same clause/branch split as `ColumnConstraint`. ```python +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( + ReferentialAction, TableConstraint, - TableConstraintType, - ForeignKeyConstraint, - ReferentialAction + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, ) # Composite primary key TableConstraint( - TableConstraintType.PRIMARY_KEY, - columns=["user_id", "role_id"] + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["user_id", "role_id"]), ) +# sql: 'PRIMARY KEY ("user_id", "role_id")' # Foreign key with referential actions -ForeignKeyConstraint( - columns=["author_id"], - reference_table="authors", - reference_columns=["id"], - on_delete=ReferentialAction.CASCADE, - on_update=ReferentialAction.RESTRICT +TableConstraint( + dialect, + branch=TableForeignKeyConstraint( + dialect, + ["author_id"], + (Table(dialect, "authors"), ["id"]), + on_delete=ReferentialAction.CASCADE, + on_update=ReferentialAction.RESTRICT, + ), + name="fk_posts_author", ) +# sql: 'CONSTRAINT "fk_posts_author" FOREIGN KEY ("author_id") +# REFERENCES "authors"("id") ON DELETE CASCADE ON UPDATE RESTRICT' ``` + ## Basic Operations ### Create Table +A table is named by a `Table` object; the statement takes it positionally. + ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import ( + ColumnConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement +from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType, VarCharType columns = [ ColumnDefinition( + dialect, "id", - "INTEGER", - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)] + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] ), ColumnDefinition( + dialect, "username", - "VARCHAR(50)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + VarCharType(dialect, length=50), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] ), ColumnDefinition( + dialect, "email", - "VARCHAR(100)", + VarCharType(dialect, length=100), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE) + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)) ] ), - ColumnDefinition("created_at", "TIMESTAMP") + ColumnDefinition(dialect, "created_at", TimestampType(dialect)) ] -create = CreateTableExpression( - dialect=dialect, - table_name="users", +create = CreateTableStatement( + dialect, + Table(dialect, "users"), columns=columns ) sql, params = create.to_sql() -# sql: 'CREATE TABLE "users" ("id" INTEGER PRIMARY KEY, "username" VARCHAR(50) NOT NULL, -# "email" VARCHAR(100) NOT NULL UNIQUE, "created_at" TIMESTAMP)' +# sql: 'CREATE TABLE "users" ("id" INTEGER PRIMARY KEY, "username" TEXT NOT NULL, +# "email" TEXT NOT NULL UNIQUE, "created_at" NUMERIC)' # params: () ``` ### Create Table with IF NOT EXISTS ```python -create = CreateTableExpression( - dialect=dialect, - table_name="users", +create = CreateTableStatement( + dialect, + Table(dialect, "users"), columns=columns, if_not_exists=True ) @@ -137,9 +258,9 @@ sql, params = create.to_sql() ### Create Temporary Table ```python -create = CreateTableExpression( - dialect=dialect, - table_name="temp_sessions", +create = CreateTableStatement( + dialect, + Table(dialect, "temp_sessions"), columns=columns, temporary=True ) @@ -150,104 +271,126 @@ sql, params = create.to_sql() ### Drop Table ```python -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop = DropTableExpression( +drop = DropTableStatement( dialect, - table_name="old_users", - if_exists=True, - cascade=True + Table(dialect, "old_users"), + if_exists=True ) sql, params = drop.to_sql() -# sql: 'DROP TABLE IF EXISTS "old_users" CASCADE' +# sql: 'DROP TABLE IF EXISTS "old_users"' +``` + +`cascade=True` is authorised by `supports_drop_table_cascade`, so SQLite — which +has neither `CASCADE` nor `RESTRICT` on `DROP TABLE` — refuses rather than +dropping the clause: + +```python +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +try: + DropTableStatement(dialect, Table(dialect, "old_users"), cascade=True).to_sql() +except UnsupportedFeatureError as exc: + print(exc) # 'SQLite' dialect does not support DROP TABLE ... CASCADE. ``` ### Alter Table +Each action is its own node; the statement holds the list. + ```python from rhosocial.activerecord.backend.expression import ( - AlterTableExpression, - AddColumn, - DropColumn, - AlterColumn + AlterTableStatement, + AddColumnAction, + DropColumnAction ) # Add a new column -alter = AlterTableExpression( +alter = AlterTableStatement( dialect, - table_name="users", + Table(dialect, "users"), actions=[ - AddColumn( - ColumnDefinition( - "phone", - "VARCHAR(20)" - ) + AddColumnAction( + dialect, + ColumnDefinition(dialect, "phone", VarCharType(dialect, length=20)) ) ] ) sql, params = alter.to_sql() -# sql: 'ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20)' +# sql: 'ALTER TABLE "users" ADD COLUMN "phone" TEXT' # Drop a column -alter = AlterTableExpression( +alter = AlterTableStatement( dialect, - table_name="users", + Table(dialect, "users"), actions=[ - DropColumn("old_field") + DropColumnAction(dialect, "old_field") ] ) sql, params = alter.to_sql() # sql: 'ALTER TABLE "users" DROP COLUMN "old_field"' ``` + ## Index Operations ### Create Index +The index and the table are both named objects, and the columns are a plain list +of names. + ```python -from rhosocial.activerecord.backend.expression.statement import CreateIndexExpression +from rhosocial.activerecord.backend.expression import StringColumn +from rhosocial.activerecord.backend.expression.objects import Index, Table +from rhosocial.activerecord.backend.expression.statements import CreateIndexStatement # Basic index -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"] ) sql, params = create_idx.to_sql() # sql: 'CREATE INDEX "idx_users_email" ON "users" ("email")' # Unique index -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect, - index_name="idx_users_username", - table_name="users", + Index(dialect, "idx_users_username"), + Table(dialect, "users"), columns=["username"], unique=True ) -# Partial index (with WHERE clause) -from rhosocial.activerecord.backend.expression import Column, Literal - -create_idx = CreateIndexExpression( +# Partial index (with WHERE clause) -- the predicate is built by a typed column +create_idx = CreateIndexStatement( dialect, - index_name="idx_active_users", - table_name="users", + Index(dialect, "idx_active_users"), + Table(dialect, "users"), columns=["email"], - where=Column(dialect, "status") == Literal(dialect, "active") + unique=True, + where=StringColumn(dialect, "status") == "active" ) sql, params = create_idx.to_sql() -# sql: 'CREATE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' +# sql: 'CREATE UNIQUE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' +# params: ('active',) ``` +> Predicates come from a column class that can be compared, not from `==` on a +> bare `Column`: `Column` carries only aliasing, null tests and casting, so +> `Column(...) == ...` is a Python identity test. Use `StringColumn`, +> `NumericColumn` and their siblings for anything a predicate compares. + ### Drop Index ```python -from rhosocial.activerecord.backend.expression.statement import DropIndexExpression +from rhosocial.activerecord.backend.expression.statements import DropIndexStatement -drop_idx = DropIndexExpression( +drop_idx = DropIndexStatement( dialect, - index_name="idx_users_email", + Index(dialect, "idx_users_email"), if_exists=True ) sql, params = drop_idx.to_sql() @@ -258,51 +401,65 @@ sql, params = drop_idx.to_sql() ### Create Schema +A schema is a `Schema` object. SQLite has no schemas, so its dialect refuses the +statement rather than emitting something the engine rejects: + ```python -from rhosocial.activerecord.backend.expression.statement import CreateSchemaExpression +from rhosocial.activerecord.backend.expression.objects import Schema +from rhosocial.activerecord.backend.expression.statements import CreateSchemaStatement -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="app_schema", + Schema(dialect, "app_schema"), if_not_exists=True ) -sql, params = create_schema.to_sql() +# On a dialect that supports schemas: # sql: 'CREATE SCHEMA IF NOT EXISTS "app_schema"' +# On SQLite: +# UnsupportedFeatureError: 'SQLite' dialect does not support CREATE SCHEMA. ``` ### Drop Schema ```python -from rhosocial.activerecord.backend.expression.statement import DropSchemaExpression +from rhosocial.activerecord.backend.expression.statements import DropSchemaStatement -drop_schema = DropSchemaExpression( +drop_schema = DropSchemaStatement( dialect, - schema_name="old_schema", + Schema(dialect, "old_schema"), cascade=True ) -sql, params = drop_schema.to_sql() +# On a dialect that supports schemas: # sql: 'DROP SCHEMA "old_schema" CASCADE' ``` + ## Executing DDL Statements -DDL expressions can be executed directly on the backend: +A statement renders to `(sql, params)`; the backend executes SQL text, so the +two steps are separate: ```python -from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement # Create table -create = CreateTableExpression(dialect, "users", columns) -User.__backend__.execute(create) +create = CreateTableStatement( + dialect, + Table(dialect, "users"), + columns=columns +) +sql, params = create.to_sql() +backend.execute(sql, params) # Or build SQL first and inspect -sql, params = create.to_sql() print(f"SQL: {sql}") print(f"Params: {params}") ``` > **Note**: DDL statements in `rhosocial-activerecord` don't require `ExecutionOptions(stmt_type=StatementType.DDL)` — the expression objects carry their own statement type information. + ## Introspection for Schema Verification After creating, modifying, or deleting tables, you can use the backend's **introspection API** to verify schema changes: @@ -322,6 +479,10 @@ if table_info: print(f"Column: {col.name}, Type: {col.data_type}, PK: {col.is_primary_key}") ``` +`TableType` distinguishes the kinds a catalogue reports — `BASE_TABLE`, +`VIEW`, `MATERIALIZED_VIEW`, `FOREIGN_TABLE`, `PARTITIONED_TABLE`, +`SYSTEM_TABLE`, `EXTERNAL` — so a relation is not silently folded into "table". + ### Backend-Specific Differences > ⚠️ **Important**: This documentation uses SQLite as the example backend. Different database backends have significant differences: @@ -329,10 +490,11 @@ if table_info: | Feature | SQLite | MySQL | PostgreSQL | |---------|--------|-------|------------| | **Introspection API** | `list_tables()`, `get_table_info()`, `pragma.*` | Different method names | Different method names | -| **DDL Support** | Limited ALTER TABLE (ADD/DROP column only) | Full ALTER TABLE | Full ALTER TABLE | +| **DDL Support** | Limited ALTER TABLE (ADD/DROP column only); no `CREATE SCHEMA` | Full ALTER TABLE | Full ALTER TABLE | | **Index Types** | No USING clause | BTREE, HASH, etc. | BTREE, HASH, GIN, etc. | | **Partial Indexes** | Supported (WHERE clause) | Not supported | Supported | | **Generated Columns** | 3.31.0+ | 5.7.31+ | Supported | +| **Constraint traits** | name and deferral render; `ENFORCED` refused | name renders | all render | Always refer to your specific backend's documentation for accurate API usage. diff --git a/docs/en_US/modeling/ddl_source.md b/docs/en_US/modeling/ddl_source.md index f4472dd60..bca834c96 100644 --- a/docs/en_US/modeling/ddl_source.md +++ b/docs/en_US/modeling/ddl_source.md @@ -112,7 +112,7 @@ The table below describes the default `DDLSourceMixin` implementation. A custom | `column_attributes(field)` | `Sequence[ColumnAttribute]` | Expands markers in order without dialect filtering. | | `column_indexes(field)` | `Sequence[IndexDefinition]` | Builds one definition per `UseIndex` and substitutes the physical column name. | | `column_comment(field)` | `Optional[str]` | Returns the first `UseComment` text. | -| `generated_column(field)` | `Optional[GeneratedColumnExpression or factory]` | Returns the first `UseGeneratedColumn` value without calling a factory. | +| `generated_column(field)` | `Optional[ColumnGeneratedConstraint or factory]` | Returns the first `UseGeneratedColumn` value without calling a factory. | | `column_options(field)` | `None`, `ColumnOptions`, or a sequence | Returns values written by a handler to `DDLFieldMetadata.column_options`; one value is returned directly, multiple values as a list. | | `table_options()` | `None`, expression, or sequence | `None` by default; an override is returned unchanged. | | `table_storage_options()` | `None`, expression, or sequence | `None` by default; an override is returned unchanged. | @@ -207,7 +207,7 @@ This code belongs to the backend expression consumer, not to `DDLSourceMixin`. | Snowflake | `Snowflake*Type`, `SnowflakeCreateTableOptions`, Snowflake partition/clone expressions | VARIANT, OBJECT, ARRAY, and other types remain candidates; the source does not perform Snowflake-specific selection. | | ClickHouse | `ClickHouse*Type`, `ClickHouseColumnOptions`, `ClickHouseIndexDefinition`, ClickHouse partition expressions | Unsupported UNIQUE or foreign-key capabilities fail in the ClickHouse dialect; declarations can still be presented. | | BigQuery | Currently mainly generic `DataType`, constraints, and `CreateTableOptions` | The package currently has no automatically registered BigQuery-specific `ColumnOptions`; generic declarations can still be collected. | -| Firebird | `Firebird*Type`, `FirebirdColumnOptions`, `FirebirdCreateTableExpression`, Firebird partition/computed-column expressions | Computed columns, tablespaces, and specialized create-table classes are handled by the Firebird consumer. | +| Firebird | `Firebird*Type`, `FirebirdColumnOptions`, `FirebirdCreateTableStatement`, Firebird partition/computed-column expressions | Computed columns, tablespaces, and specialized create-table classes are handled by the Firebird consumer. | Backend tests should verify at least three things: the objects and order returned by the source, that backend-specific types/options are not rewritten early, and that collected values can be passed to an existing backend expression construction path. Collection tests do not need a live database; render assertions should use the backend dialect and minimal expressions separately. @@ -225,7 +225,7 @@ from rhosocial.activerecord.base import ( UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -245,7 +245,7 @@ class Account(ActiveRecord): ] status: Annotated[ str, - UseConstraint(ColumnConstraintType.NOT_NULL), + UseConstraint(ColumnNotNullConstraint), ] = "active" note: Optional[str] = None diff --git a/docs/en_US/modeling/ddl_source_examples.md b/docs/en_US/modeling/ddl_source_examples.md index b23b0359d..8f6326219 100644 --- a/docs/en_US/modeling/ddl_source_examples.md +++ b/docs/en_US/modeling/ddl_source_examples.md @@ -10,7 +10,7 @@ These snippets are documentation examples, not modules to add under `src/` or `t model declaration → DDLSourceMixin collection → external consumer selects/binds candidates - → ColumnDefinition / CreateTableExpression + → ColumnDefinition / CreateTableStatement → dialect.to_sql() ``` @@ -90,10 +90,10 @@ PlainAccount.table_constraints() # [] PlainAccount.column_constraints("id") -# ColumnConstraint(PRIMARY_KEY), ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnPrimaryKeyConstraint), ColumnConstraint(branch=ColumnNotNullConstraint) PlainAccount.column_constraints("name") -# ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnNotNullConstraint) PlainAccount.column_constraints("note") # [] @@ -141,7 +141,7 @@ from rhosocial.activerecord.base import ( UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -159,7 +159,7 @@ class CoreDeclared(ActiveRecord): ] status: Annotated[ str, - UseConstraint(ColumnConstraintType.NOT_NULL), + UseConstraint(ColumnNotNullConstraint), ] = "active" ``` @@ -182,7 +182,7 @@ CoreDeclared.column_attributes("name")[0].name # 'und:ci' CoreDeclared.column_constraints("status") -# ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnNotNullConstraint) ``` The same core type renders differently after the consumer binds each dialect: @@ -544,12 +544,12 @@ This demonstrates three rules: | MySQL | `MySQLCreateTableOptions(engine="InnoDB", charset="utf8mb4")` | Produces MySQL `ENGINE` and `DEFAULT CHARSET` table options. | | MariaDB | `MariaDBCreateTableOptions` | Produces options matched to the MariaDB dialect. | | PostgreSQL | `PostgresCreateTableOptions(unlogged=True)` | Produces `CREATE UNLOGGED TABLE`; version capability is checked by PostgreSQL. | -| SQL Server | `SQLServerCreateTableOptions(memory_optimized=True)` and `SQLServerCreateTableExpression` | Produces `WITH (MEMORY_OPTIMIZED = ON)`; requires SQL Server 2014+. | +| SQL Server | `SQLServerCreateTableOptions(memory_optimized=True)` and `SQLServerCreateTableStatement` | Produces `WITH (MEMORY_OPTIMIZED = ON)`; requires SQL Server 2014+. | | Oracle | Core `CreateTableOptions` and `tablespace="TS_DATA"` | Oracle's table formatter handles tablespaces; generic `StorageOptionsExpression` fails fast in the Oracle create-table path. | | Snowflake | `SnowflakeCreateTableOptions(transient=True)` | Produces `CREATE OR REPLACE TRANSIENT TABLE`. | | ClickHouse | `ClickHouseColumnOptions` and `StorageOptionsExpression` | Produces `CODEC(...)` and `ENGINE = ...` clauses. | | BigQuery | Core `CreateTableOptions` plus `TableCommentClause` | Produces table-level `OPTIONS(description=...)`; there is no BigQuery-specific `ColumnOptions` today. | -| Firebird | `FirebirdCreateTableExpression` and `FirebirdColumnOptions` | Produces `CREATE GLOBAL TEMPORARY TABLE` and `CHARACTER SET` clauses. | +| Firebird | `FirebirdCreateTableStatement` and `FirebirdColumnOptions` | Produces `CREATE GLOBAL TEMPORARY TABLE` and `CHARACTER SET` clauses. | Applying one backend's `ColumnOptions` to another backend's core `ColumnDefinition` normally raises `TypeError`. If it is applied to a different backend's own column definition class, unknown backend fields may be silently ignored by that formatter. Keep the option class, column definition class, and dialect from the same backend. diff --git a/docs/en_US/modeling/ddl_views.md b/docs/en_US/modeling/ddl_views.md index 415889298..0d58fb0ab 100644 --- a/docs/en_US/modeling/ddl_views.md +++ b/docs/en_US/modeling/ddl_views.md @@ -6,25 +6,27 @@ Views are virtual tables based on a SELECT query. They don't store data but prov ```python from rhosocial.activerecord.backend.expression import ( - CreateViewExpression, - QueryExpression, - TableExpression, - Column + Column, + NamedRelationRef, + QueryStatement, ) +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import CreateViewStatement # Basic view -create_view = CreateViewExpression( +query = QueryStatement( dialect, - view_name="user_summary", - query=QueryExpression( - dialect, - select=[ - Column(dialect, "id"), - Column(dialect, "name"), - Column(dialect, "email") - ], - from_=TableExpression(dialect, "users") - ) + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "email") + ], + from_=NamedRelationRef(dialect, Table(dialect, "users")) +) +create_view = CreateViewStatement( + dialect, + View(dialect, "user_summary"), + query=query ) sql, params = create_view.to_sql() # sql: 'CREATE VIEW "user_summary" AS SELECT "id", "name", "email" FROM "users"' @@ -33,19 +35,11 @@ sql, params = create_view.to_sql() ## View with Column Aliases ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_details", - query=QueryExpression( - dialect, - select=[ - Column(dialect, "id"), - Column(dialect, "name"), - Column(dialect, "email") - ], - from_=TableExpression(dialect, "users") - ), - columns=["user_id", "full_name", "contact_email"] + View(dialect, "user_details"), + query=query, + column_aliases=["user_id", "full_name", "contact_email"] ) sql, params = create_view.to_sql() # sql: 'CREATE VIEW "user_details" ("user_id", "full_name", "contact_email") AS SELECT ...' @@ -54,11 +48,11 @@ sql, params = create_view.to_sql() ## View with OR REPLACE ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_summary", + View(dialect, "user_summary"), query=query, - or_replace=True + replace=True ) sql, params = create_view.to_sql() # sql: 'CREATE OR REPLACE VIEW "user_summary" AS ...' @@ -67,9 +61,9 @@ sql, params = create_view.to_sql() ## View with TEMPORARY (SQLite) ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="temp_stats", + View(dialect, "temp_stats"), query=query, temporary=True ) @@ -80,20 +74,21 @@ sql, params = create_view.to_sql() ## Drop View ```python -from rhosocial.activerecord.backend.expression import DropViewExpression +from rhosocial.activerecord.backend.expression.objects import View +from rhosocial.activerecord.backend.expression.statements import DropViewStatement # Basic drop -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect, - view_name="old_view" + View(dialect, "old_view") ) sql, params = drop_view.to_sql() # sql: 'DROP VIEW "old_view"' # Drop if exists -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect, - view_name="old_view", + View(dialect, "old_view"), if_exists=True ) sql, params = drop_view.to_sql() @@ -103,13 +98,51 @@ sql, params = drop_view.to_sql() ## Executing View DDL ```python +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnDefinition, + NamedRelationRef, + QueryStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + CreateViewStatement, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType + # Create a table first -create_table = CreateTableExpression(dialect, "users", user_columns) -backend.execute(create_table.to_sql()) +user_columns = [ + ColumnDefinition( + dialect, "id", IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] + ), + ColumnDefinition( + dialect, "name", VarCharType(dialect, length=100), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] + ), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255)), +] +create_table = CreateTableStatement(dialect, Table(dialect, "users"), columns=user_columns) +sql, params = create_table.to_sql() +backend.execute(sql, params) # Create a view -create_view = CreateViewExpression(dialect, "user_summary", view_query) -backend.execute(create_view.to_sql()) +view_query = QueryStatement( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "email") + ], + from_=NamedRelationRef(dialect, Table(dialect, "users")) +) +create_view = CreateViewStatement(dialect, View(dialect, "user_summary"), query=view_query) +sql, params = create_view.to_sql() +backend.execute(sql, params) # Introspection: List views views = backend.introspector.list_views() @@ -121,4 +154,4 @@ for v in views: ## Example Code -Full example: [docs/examples/chapter_03_modeling/ddl_views.py](../../../examples/chapter_03_modeling/ddl_views.py) \ No newline at end of file +Full example: [docs/examples/chapter_03_modeling/ddl_views.py](../../../examples/chapter_03_modeling/ddl_views.py) diff --git a/docs/en_US/modeling/mixins.md b/docs/en_US/modeling/mixins.md index 8938e80c7..339550162 100644 --- a/docs/en_US/modeling/mixins.md +++ b/docs/en_US/modeling/mixins.md @@ -235,14 +235,14 @@ from rhosocial.activerecord.field import OptimisticLockMixin from typing import Annotated from pydantic import Field from rhosocial.activerecord.base.fields import UseColumn, UseConstraint -from rhosocial.activerecord.backend.expression.statements.ddl_table import ColumnConstraintType + class Article(OptimisticLockMixin, ActiveRecord): __version_field__ = "row_version" __version_increment_by__ = 2 # optional, default 1 row_version: Annotated[ - int, UseColumn("row_ver"), UseConstraint(ColumnConstraintType.NOT_NULL) + int, UseColumn("row_ver"), UseConstraint(ColumnNotNullConstraint) ] = Field(default=1, ge=1) title: str ``` diff --git a/docs/en_US/performance/batch_operations.md b/docs/en_US/performance/batch_operations.md index 15304f782..901adc084 100644 --- a/docs/en_US/performance/batch_operations.md +++ b/docs/en_US/performance/batch_operations.md @@ -17,10 +17,14 @@ Both methods support synchronous and asynchronous variants with identical APIs. ```python from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, Literal +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource, Literal +from rhosocial.activerecord.backend.expression.objects import Table backend = SQLiteBackend(database=":memory:") backend.connect() +backend.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" +) # Create a list of INSERT expressions expressions = [] @@ -29,9 +33,9 @@ for i in range(100): [Literal(backend.dialect, f"user{i}"), Literal(backend.dialect, f"user{i}@example.com")] ]) - expr = InsertExpression( + expr = InsertStatement( backend.dialect, - into="users", + into=Table(backend.dialect, "users"), columns=["name", "email"], source=source ) @@ -40,8 +44,6 @@ for i in range(100): # Execute in batches of 20 for batch_result in backend.execute_batch_dml(expressions, batch_size=20): print(f"Batch {batch_result.batch_index}: {batch_result.total_affected_rows} rows affected") - -backend.disconnect() ``` ### Commit Modes @@ -55,6 +57,11 @@ The entire batch operation is wrapped in a single transaction. If any error occu ```python from rhosocial.activerecord.backend.result import BatchCommitMode + +def process_batch(batch): + print(f"Batch {batch.batch_index}: {batch.total_affected_rows} rows affected") + + # All-or-nothing: either all batches succeed or none do for batch in backend.execute_batch_dml( expressions, @@ -114,6 +121,36 @@ Batch DML requires **homogeneous expressions**: ```python # Valid: All INSERT expressions targeting the same table +from rhosocial.activerecord.backend.expression import IntegerColumn, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + InsertStatement, + UpdateStatement, + ValuesSource, +) + + +def make_insert(name, email): + return InsertStatement( + backend.dialect, + into=Table(backend.dialect, "users"), + columns=["name", "email"], + source=ValuesSource( + backend.dialect, + values_list=[[Literal(backend.dialect, name), Literal(backend.dialect, email)]], + ), + ) + + +def make_update(user_id, name): + return UpdateStatement( + backend.dialect, + Table(backend.dialect, "users"), + assignments={"name": Literal(backend.dialect, name)}, + where=(IntegerColumn(backend.dialect, "id") == user_id), + ) + + expressions = [ make_insert("user1", "user1@example.com"), make_insert("user2", "user2@example.com"), @@ -121,10 +158,15 @@ expressions = [ ] # Invalid: Mixed expression types -expressions = [ - make_insert("user1", "user1@example.com"), - make_update(1, "new_name"), # TypeError: heterogeneous expressions -] +try: + mixed = [ + make_insert("user1", "user1@example.com"), + make_update(1, "new_name"), + ] + for _ in backend.execute_batch_dml(mixed): + pass +except TypeError as exc: + print(f"TypeError: {exc}") ``` ## Batch DQL Operations @@ -134,12 +176,22 @@ expressions = [ `execute_batch_dql` provides memory-efficient pagination for large result sets: ```python -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, WildcardExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import QueryStatement + + +def process_row(row): + return row["id"] + -query = QueryExpression( +query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "large_table"), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "users")), ) # Process 1000-row pages one at a time @@ -166,29 +218,47 @@ for page in backend.execute_batch_dql(query, page_size=100): `execute_batch_dql` supports all DQL expression types: -- `QueryExpression`: Basic SELECT queries -- `WithQueryExpression`: Common Table Expressions (CTEs) -- `SetOperationExpression`: UNION, INTERSECT, EXCEPT operations +- `QueryStatement`: Basic SELECT queries +- `WithStatement`: Common Table Expressions (CTEs) +- `SetOperationStatement`: UNION, INTERSECT, EXCEPT operations ```python # CTE example -from rhosocial.activerecord.backend.expression.query_sources import WithQueryExpression, CTEExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + NamedRelationRef, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.query_sources import CTEClause, WithStatement +from rhosocial.activerecord.backend.expression.statements import QueryStatement + +backend.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, status TEXT)") -cte_query = QueryExpression( +cte_query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "orders"), - where=WhereClause(backend.dialect, condition=...), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "orders")), + where=WhereClause( + backend.dialect, + condition=(StringColumn(backend.dialect, "status") == "open"), + ), ) -cte = CTEExpression(backend.dialect, name="recent_orders", query=cte_query) +cte = CTEClause(backend.dialect, name="recent_orders", query=cte_query) -main_query = QueryExpression( +main_query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "recent_orders"), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "recent_orders")), ) -with_query = WithQueryExpression(backend.dialect, ctes=[cte], main_query=main_query) +with_query = WithStatement(backend.dialect, ctes=[cte], query=main_query) + + +def process_orders(rows): + return len(rows) + for page in backend.execute_batch_dql(with_query, page_size=50): process_orders(page.data) @@ -200,12 +270,61 @@ Both batch methods have async equivalents with identical semantics: ```python # Async batch DML -async for batch in async_backend.execute_batch_dml(expressions, batch_size=20): - await process_batch(batch) +import asyncio + +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + InsertStatement, + Literal, + NamedRelationRef, + ValuesSource, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import QueryStatement +from rhosocial.activerecord.backend.impl.sqlite.backend.async_backend import AsyncSQLiteBackend + + +async def run_async_batches(): + async_backend = AsyncSQLiteBackend(database=":memory:") + await async_backend.connect() + await async_backend.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" + ) -# Async batch DQL -async for page in async_backend.execute_batch_dql(query, page_size=1000): - await process_page(page) + async_dialect = async_backend.dialect + async_expressions = [ + InsertStatement( + async_dialect, + into=Table(async_dialect, "users"), + columns=["name", "email"], + source=ValuesSource( + async_dialect, + values_list=[[ + Literal(async_dialect, f"async{i}"), + Literal(async_dialect, f"async{i}@example.com"), + ]], + ), + ) + for i in range(10) + ] + + # Async batch DML + async for batch in async_backend.execute_batch_dml(async_expressions, batch_size=4): + print(f"Batch {batch.batch_index}: {batch.total_affected_rows} rows affected") + + # Async batch DQL + async_query = QueryStatement( + async_dialect, + select=[AllColumnsWildcard(async_dialect)], + from_=NamedRelationRef(async_dialect, Table(async_dialect, "users")), + ) + async for page in async_backend.execute_batch_dql(async_query, page_size=4): + print(f"Page {page.page_index}: {page.page_size} rows") + + await async_backend.disconnect() + + +asyncio.run(run_async_batches()) ``` ## Transaction Interaction @@ -225,6 +344,8 @@ with backend.transaction(): ### Error Handling ```python +from rhosocial.activerecord.backend.errors import IntegrityError + try: for batch in backend.execute_batch_dml( expressions, @@ -232,10 +353,10 @@ try: commit_mode=BatchCommitMode.WHOLE ): process_batch(batch) -except IntegrityError: +except IntegrityError as exc: # In WHOLE mode: all changes rolled back # In PER_BATCH mode: previous batches may be committed - handle_error() + print(f"Integrity error, batch rolled back: {exc}") ``` ## Performance Considerations diff --git a/docs/en_US/querying/active_query.md b/docs/en_US/querying/active_query.md index f6839e3b6..f9a1ae2be 100644 --- a/docs/en_US/querying/active_query.md +++ b/docs/en_US/querying/active_query.md @@ -696,7 +696,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note over Query, Expr: 1. SQL Generation (Delegated to Expression System) - Query->>Expr: Construct QueryExpression + Query->>Expr: Construct QueryStatement Note right of Query: Assemble Select, From, Where...
(one() uses temporary Limit=1) Expr->>Expr: to_sql() Expr-->>Query: Return (sql, params) @@ -754,7 +754,7 @@ sequenceDiagram alt explain() enabled Note over Query, Expr: EXPLAIN Mode - Query->>Expr: Construct ExplainExpression + Query->>Expr: Construct ExplainStatement Expr->>Expr: to_sql() Expr-->>Query: Return (sql, params) Query->>Backend: fetch_all(sql, params) @@ -762,7 +762,7 @@ sequenceDiagram Query-->>User: Return Plan Data else Standard Mode Note over Query, Expr: Standard Aggregation - Query->>Expr: Construct QueryExpression + Query->>Expr: Construct QueryStatement Expr->>Expr: to_sql() Expr-->>Query: Return (sql, params) Query->>Backend: fetch_all(sql, params) diff --git a/docs/en_US/querying/cheatsheet.md b/docs/en_US/querying/cheatsheet.md index 0b5f44229..5b974fa55 100644 --- a/docs/en_US/querying/cheatsheet.md +++ b/docs/en_US/querying/cheatsheet.md @@ -418,7 +418,7 @@ result = User.__backend__.execute( users = [User(**row) for row in result.rows] ``` -> ⚠️ **Strong warning:** Do **not** use raw SQL (including `RawSQLExpression`, `RawSQLPredicate`, or directly executing SQL strings) unless absolutely necessary. It easily opens **security holes such as SQL injection** and reduces portability across database backends. Prefer FieldProxy expressions (`User.c.field`) which build type-safe, automatically parameterized queries. Only consider raw SQL in extreme cases the standard expression system cannot express (e.g., database-private features) — and always use parameter placeholders, never concatenate user input. +> ⚠️ **Strong warning:** Do **not** use raw SQL (including `RawSQL`, `RawSQLPredicate`, or directly executing SQL strings) unless absolutely necessary. It easily opens **security holes such as SQL injection** and reduces portability across database backends. Prefer FieldProxy expressions (`User.c.field`) which build type-safe, automatically parameterized queries. Only consider raw SQL in extreme cases the standard expression system cannot express (e.g., database-private features) — and always use parameter placeholders, never concatenate user input. --- diff --git a/docs/en_US/querying/cte_query.md b/docs/en_US/querying/cte_query.md index bc283d4fb..98c2a3512 100644 --- a/docs/en_US/querying/cte_query.md +++ b/docs/en_US/querying/cte_query.md @@ -93,7 +93,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note over Query, Expr: 1. SQL Generation (Delegated to Expression System) - Query->>Expr: Build WithQueryExpression + Query->>Expr: Build WithStatement Note right of Query: Assemble all CTE definitions + Main Query Expr->>Expr: to_sql() Expr-->>Query: Return (sql, params) diff --git a/docs/en_US/scenarios/parallel_workers.md b/docs/en_US/scenarios/parallel_workers.md index ab9f72833..65b6e5b06 100644 --- a/docs/en_US/scenarios/parallel_workers.md +++ b/docs/en_US/scenarios/parallel_workers.md @@ -10,7 +10,9 @@ Every call to `MyModel.configure(config, Backend)` writes the backend instance t In a multi-threaded environment, two threads calling `MyModel.find()` simultaneously will interleave their operations on the same connection. Most database drivers do not support this: cursors get corrupted, queries fail with cryptic errors, and connections leak. The `mysql-connector-python` documentation states this explicitly, and `psycopg` does as well. -The same problem exists with async code, but in a subtler way. An event loop is single-threaded, so two coroutines cannot truly run concurrently — but they can interleave at every `await` point. If one coroutine is in the middle of a query when another starts, the connection state is corrupted. +Transactions make this concrete rather than probable: a transaction belongs to the thread that opened it, and only that thread may nest it (nesting becomes a savepoint). A second thread starting one on the same connection is refused with a `TransactionError` that names the cause, instead of being allowed to interleave `BEGIN`/`COMMIT` until the bookkeeping reports "No active transaction to commit" somewhere far away. Ownership is released when the outermost transaction ends, so the connection is usable by another thread afterwards. + +The same problem exists with async code, but in a subtler way. An event loop is single-threaded, so two coroutines cannot truly run concurrently — but they can interleave at every `await` point. If one coroutine is in the middle of a query when another starts, the connection state is corrupted. Tasks are told apart by identity here rather than by thread. The solution in both cases is the same: **do not share a connection**. Each independent unit of work needs its own backend instance. @@ -57,10 +59,10 @@ The critical detail is that `configure()` runs inside the child process, not bef ## Async Behavior: Sequential Within a Process -Within a single process, async code using a single-connection backend executes sequentially. You cannot use `asyncio.gather()` to run multiple queries concurrently on the same connection — the second query will fail because the first one is still in progress. +Within a single process, async code using a single-connection backend executes sequentially. You cannot use `asyncio.gather()` to run multiple transactions concurrently on the same connection: the first task to begin owns the connection's transaction, and the second is refused with a `TransactionError` rather than quietly corrupting the level counter. ```python -# This will raise RuntimeError +# This raises TransactionError for every task after the first async def wrong(post_ids: list[int]): await asyncio.gather(*[update_one(pid) for pid in post_ids]) @@ -68,8 +70,15 @@ async def wrong(post_ids: list[int]): async def correct(post_ids: list[int]): for pid in post_ids: await update_one(pid) + +# And this gives each task its own connection +async def parallel(post_ids: list[int]): + async with pool.transaction() as tx: + ... ``` +A pool is how you get concurrency: `AsyncBackendPool.connection()` and `AsyncBackendPool.transaction()` hand each task a connection of its own, so the ownership check never fires. `pool.transaction()` goes through the same `begin()` gate rather than bypassing it. + The advantage of async is not concurrency within a process, but efficiency at the I/O boundary. When a coroutine sends a query and awaits the response, the event loop can handle other work — reading from a queue, processing an HTTP request, writing logs. The database query itself still executes one at a time. True concurrency comes from running multiple processes, each with its own connection and event loop. diff --git a/docs/examples/chapter_03_modeling/ddl_basic.py b/docs/examples/chapter_03_modeling/ddl_basic.py index bd7efce53..7ec228a99 100644 --- a/docs/examples/chapter_03_modeling/ddl_basic.py +++ b/docs/examples/chapter_03_modeling/ddl_basic.py @@ -1,6 +1,6 @@ -""" -Chapter 3: Modeling Data - DDL Examples -Demonstrates DDL operations using expression-based API: +"""Chapter 3: Modeling Data - DDL Examples + +Demonstrates DDL operations using the expression-based API: 1. Create table with various column types and constraints 2. Create table with IF NOT EXISTS 3. Create temporary table @@ -10,39 +10,47 @@ """ from typing import ClassVar -from pydantic import Field -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.base import FieldProxy -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig + from rhosocial.activerecord.backend.expression import ( + AddColumnAction, + AlterTableStatement, + ColumnConstraint, ColumnDefinition, - CreateTableExpression, - DropTableExpression, - AlterTableExpression, - AddColumn, - DropColumn, + CreateTableStatement, + DropColumnAction, + DropTableStatement, ) -from rhosocial.activerecord.backend.expression.types import BooleanType, FloatType, IntegerType, TextType, TimestampType, VarCharType +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ) +from rhosocial.activerecord.backend.expression.types import ( + BooleanType, + FloatType, + IntegerType, + TextType, + TimestampType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): """User Model for DDL demo""" - username: str = Field(..., max_length=50) + __table_name__ = "users" + + username: str email: str is_active: bool = True c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "users" - def print_table_list(introspector, label: str): """Helper to print table list with label.""" @@ -59,7 +67,10 @@ def print_table_info(introspector, table_name: str, label: str): table_info = introspector.get_table_info(table_name) if table_info and table_info.columns: for col in table_info.columns: - print(f" Column: {col.name}, Type: {col.data_type}, Nullable: {col.nullable}, PK: {col.is_primary_key}") + print( + f" Column: {col.name}, Type: {col.data_type}, " + f"Nullable: {col.nullable}, PK: {col.is_primary_key}" + ) else: print(f" Table '{table_name}' not found or has no columns") print() @@ -81,21 +92,32 @@ def main(): # Example 1: Create basic table # ============================================================ user_columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], + ), + ColumnDefinition( + dialect, + "username", + VarCharType(dialect, length=50), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ), + ColumnDefinition( + dialect, "email", - VarCharType(100), + VarCharType(dialect, length=100), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), - ColumnDefinition("is_active", BooleanType()), - ColumnDefinition("created_at", TimestampType()), + ColumnDefinition(dialect, "is_active", BooleanType(dialect)), + ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ] - create_users = CreateTableExpression(dialect=dialect, table_name="users", columns=user_columns) + create_users = CreateTableStatement(dialect, Table(dialect, "users"), columns=user_columns) sql, params = create_users.to_sql() print("=== Create Users Table ===") @@ -103,9 +125,8 @@ def main(): print(f"Params: {params}") print() - # Execute DDL - convert to SQL string first - sql_str, params_str = create_users.to_sql() - backend.execute(sql_str, params_str) + # Execute DDL + backend.execute(sql, params) # Introspection: Verify table was created print_table_list(introspector, "After creating users table") @@ -114,13 +135,23 @@ def main(): # ============================================================ # Example 2: Create table with IF NOT EXISTS # ============================================================ - create_products = CreateTableExpression( - dialect=dialect, - table_name="products", + create_products = CreateTableStatement( + dialect, + Table(dialect, "products"), columns=[ - ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), - ColumnDefinition("price", FloatType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], + ), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), + ColumnDefinition( + dialect, + "price", + FloatType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ), ], if_not_exists=True, ) @@ -131,8 +162,7 @@ def main(): print() # Execute DDL - sql_str, params_str = create_products.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Verify products table was created print_table_list(introspector, "After creating products table") @@ -141,13 +171,13 @@ def main(): # ============================================================ # Example 3: Create temporary table # ============================================================ - create_temp = CreateTableExpression( - dialect=dialect, - table_name="temp_sessions", + create_temp = CreateTableStatement( + dialect, + Table(dialect, "temp_sessions"), columns=[ - ColumnDefinition("session_id", VarCharType(50)), - ColumnDefinition("data", TextType()), - ColumnDefinition("expires_at", TimestampType()), + ColumnDefinition(dialect, "session_id", VarCharType(dialect, length=50)), + ColumnDefinition(dialect, "data", TextType(dialect)), + ColumnDefinition(dialect, "expires_at", TimestampType(dialect)), ], temporary=True, ) @@ -158,8 +188,7 @@ def main(): print() # Execute DDL - sql_str, params_str = create_temp.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Temporary tables are also visible in table_list print_table_list(introspector, "After creating temp table") @@ -168,17 +197,19 @@ def main(): # Example 4: Drop table # ============================================================ # First create a table to drop - create_to_drop = CreateTableExpression( - dialect=dialect, table_name="old_table", columns=[ColumnDefinition("id", IntegerType())] + create_to_drop = CreateTableStatement( + dialect, + Table(dialect, "old_table"), + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], ) - sql_str, params_str = create_to_drop.to_sql() - backend.execute(sql_str, params_str) + sql, params = create_to_drop.to_sql() + backend.execute(sql, params) print("--- Before drop ---") tables = introspector.list_tables() print(f"Tables: {[t.name for t in tables]}") - drop_old = DropTableExpression(dialect, table_name="old_table", if_exists=True) + drop_old = DropTableStatement(dialect, Table(dialect, "old_table"), if_exists=True) sql, params = drop_old.to_sql() print("\n=== Drop Table ===") @@ -186,8 +217,7 @@ def main(): print() # Execute DDL - sql_str, params_str = drop_old.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Verify table was dropped print("--- After drop ---") @@ -198,8 +228,15 @@ def main(): # ============================================================ # Example 5: Alter table - Add column # ============================================================ - alter_add = AlterTableExpression( - dialect, table_name="users", actions=[AddColumn(dialect, column=ColumnDefinition("phone", VarCharType(20)))] + alter_add = AlterTableStatement( + dialect, + Table(dialect, "users"), + actions=[ + AddColumnAction( + dialect, + ColumnDefinition(dialect, "phone", VarCharType(dialect, length=20)), + ) + ], ) sql, params = alter_add.to_sql() @@ -208,8 +245,7 @@ def main(): print() # Execute DDL - sql_str, params_str = alter_add.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Verify column was added print_table_info(introspector, "users", "users table after adding phone column") @@ -218,17 +254,26 @@ def main(): # Example 6: Alter table - Drop column # ============================================================ # First add a column to drop - alter_add_field = AlterTableExpression( - dialect, table_name="users", actions=[AddColumn(dialect, column=ColumnDefinition("temp_field", TextType()))] + alter_add_field = AlterTableStatement( + dialect, + Table(dialect, "users"), + actions=[ + AddColumnAction( + dialect, + ColumnDefinition(dialect, "temp_field", TextType(dialect)), + ) + ], ) - sql_str, params_str = alter_add_field.to_sql() - backend.execute(sql_str, params_str) + sql, params = alter_add_field.to_sql() + backend.execute(sql, params) print("--- Before dropping temp_field ---") print_table_info(introspector, "users", "users table before dropping temp_field") - alter_drop = AlterTableExpression( - dialect, table_name="users", actions=[DropColumn(dialect, column_name="temp_field")] + alter_drop = AlterTableStatement( + dialect, + Table(dialect, "users"), + actions=[DropColumnAction(dialect, "temp_field")], ) sql, params = alter_drop.to_sql() @@ -237,8 +282,7 @@ def main(): print() # Execute DDL - sql_str, params_str = alter_drop.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Verify column was dropped print_table_info(introspector, "users", "users table after dropping temp_field") diff --git a/docs/examples/chapter_03_modeling/ddl_indexes.py b/docs/examples/chapter_03_modeling/ddl_indexes.py index 99423a02f..276a9cea6 100644 --- a/docs/examples/chapter_03_modeling/ddl_indexes.py +++ b/docs/examples/chapter_03_modeling/ddl_indexes.py @@ -1,5 +1,5 @@ -""" -Chapter 3: Modeling Data - DDL with Indexes +"""Chapter 3: Modeling Data - DDL with Indexes + Demonstrates index creation and management: 1. Create basic index 2. Create unique index @@ -9,28 +9,35 @@ """ from typing import ClassVar -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.base import FieldProxy -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, - Column, Literal, - CreateIndexExpression, - DropIndexExpression, + StringColumn, ) -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + CreateIndexStatement, + CreateTableStatement, + DropIndexStatement, ) +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.model import ActiveRecord class Product(ActiveRecord): """Product Model for DDL index demo""" + __table_name__ = "products" + + id: int = None name: str category: str price: float @@ -38,10 +45,6 @@ class Product(ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "products" - def main(): # Configure Database @@ -54,39 +57,49 @@ def main(): # First create the products table product_columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("category", VarCharType(50)), - ColumnDefinition("price", FloatType()), - ColumnDefinition("status", VarCharType(20)), + ColumnDefinition( + dialect, "id", IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] + ), + ColumnDefinition( + dialect, "name", VarCharType(dialect, length=100), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] + ), + ColumnDefinition(dialect, "category", VarCharType(dialect, length=50)), + ColumnDefinition(dialect, "price", FloatType(dialect)), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=20)), ] - create_products = CreateTableExpression(dialect=dialect, table_name="products", columns=product_columns) + create_products = CreateTableStatement( + dialect, Table(dialect, "products"), columns=product_columns + ) - sql_str, params_str = create_products.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_products.to_sql() + backend.execute(sql_str, params) print("Products table created.") # ============================================================ # Example 1: Create basic index # ============================================================ - create_idx = CreateIndexExpression(dialect, index_name="idx_products_name", table_name="products", columns=["name"]) + create_idx = CreateIndexStatement( + dialect, Index(dialect, "idx_products_name"), Table(dialect, "products"), columns=["name"] + ) sql, params = create_idx.to_sql() print("=== Create Basic Index ===") print(f"SQL: {sql}") print() - sql_str, params_str = create_idx.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_idx.to_sql() + backend.execute(sql_str, params) # ============================================================ - # Example 2: Create unique index + # Example 2: Create unique composite index # ============================================================ - create_unique_idx = CreateIndexExpression( + create_unique_idx = CreateIndexStatement( dialect, - index_name="idx_products_category_name", - table_name="products", + Index(dialect, "idx_products_category_name"), + Table(dialect, "products"), columns=["category", "name"], unique=True, ) @@ -96,18 +109,20 @@ def main(): print(f"SQL: {sql}") print() - sql_str, params_str = create_unique_idx.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_unique_idx.to_sql() + backend.execute(sql_str, params) # ============================================================ # Example 3: Create partial index (with WHERE clause) # ============================================================ - create_partial_idx = CreateIndexExpression( + # The predicate is built by a typed column; the literal is marked + # inline because SQLite partial indexes cannot carry bind parameters. + create_partial_idx = CreateIndexStatement( dialect, - index_name="idx_products_active_price", - table_name="products", + Index(dialect, "idx_products_active_price"), + Table(dialect, "products"), columns=["price"], - where=Column(dialect, "status") == Literal(dialect, "active"), + where=StringColumn(dialect, "status") == Literal(dialect, "active", inline=True), ) sql, params = create_partial_idx.to_sql() @@ -116,38 +131,42 @@ def main(): print(f"Params: {params}") print() - # Note: SQLite partial indexes require literals in WHERE clause, not parameters. - # This will fail when executed via parameterized query. - # For demonstration, we skip execution but show the generated SQL. - # sql_str, params_str = create_partial_idx.to_sql() - # backend.execute(sql_str, params_str) - print("Note: SQLite partial indexes don't support parameters in WHERE clause.") - print("The generated SQL shows how it would look with a literal value.") - print() + sql_str, params = create_partial_idx.to_sql() + backend.execute(sql_str, params) - # Note: SQLite doesn't support USING BTREE syntax, so this would fail. - # Showing it for demonstration purposes only. + # ============================================================ + # Example 4: Index type (not supported in SQLite) + # ============================================================ + # SQLite doesn't support USING BTREE syntax, so the dialect refuses. print("=== Index Type (not supported in SQLite) ===") - create_btree_idx = CreateIndexExpression( - dialect, index_name="idx_products_category", table_name="products", columns=["category"], index_type="BTREE" - ) - sql, params = create_btree_idx.to_sql() - print(f"SQL: {sql}") - print("Note: SQLite doesn't support index_type (USING BTREE).") + try: + create_btree_idx = CreateIndexStatement( + dialect, + Index(dialect, "idx_products_category"), + Table(dialect, "products"), + columns=["category"], + index_type="BTREE", + ) + sql, params = create_btree_idx.to_sql() + print(f"SQL: {sql}") + except UnsupportedFeatureError as exc: + print(f"Refused: {exc}") print() # ============================================================ # Example 5: Drop index # ============================================================ - drop_idx = DropIndexExpression(dialect, index_name="idx_products_category", if_exists=True) + drop_idx = DropIndexStatement( + dialect, Index(dialect, "idx_products_active_price"), if_exists=True + ) sql, params = drop_idx.to_sql() print("=== Drop Index ===") print(f"SQL: {sql}") print() - sql_str, params_str = drop_idx.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = drop_idx.to_sql() + backend.execute(sql_str, params) # Introspection: Verify index was dropped print("--- Indexes on products table after drop ---") diff --git a/docs/examples/chapter_03_modeling/ddl_relationships.py b/docs/examples/chapter_03_modeling/ddl_relationships.py index 62d0d5564..fc3e85364 100644 --- a/docs/examples/chapter_03_modeling/ddl_relationships.py +++ b/docs/examples/chapter_03_modeling/ddl_relationships.py @@ -1,5 +1,5 @@ -""" -Chapter 3: Modeling Data - DDL with Relationships +"""Chapter 3: Modeling Data - DDL with Relationships + Demonstrates DDL operations for tables with foreign key relationships: 1. Create parent table (Author) 2. Create child table (Post) with foreign key constraint @@ -7,74 +7,72 @@ """ from typing import ClassVar -from pydantic import Field -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.base import FieldProxy -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ColumnDefinition, CreateTableExpression -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType -from rhosocial.activerecord.backend.expression.statements import ( + +from rhosocial.activerecord.backend.expression import ( ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, + ColumnDefinition, + CreateTableStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, ReferentialAction, + TableConstraint, + TablePrimaryKeyConstraint, ) +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.model import ActiveRecord class Author(ActiveRecord): """Author Model for DDL relationship demo""" + __table_name__ = "authors" + name: str email: str c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "authors" - class Post(ActiveRecord): """Post Model for DDL relationship demo""" + __table_name__ = "posts" + title: str content: str author_id: int c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "posts" - class Tag(ActiveRecord): """Tag Model for DDL relationship demo""" - name: str = Field(..., max_length=50) + __table_name__ = "tags" - c: ClassVar[FieldProxy] = FieldProxy() + name: str - @classmethod - def table_name(cls) -> str: - return "tags" + c: ClassVar[FieldProxy] = FieldProxy() class PostTag(ActiveRecord): """Junction table for many-to-many relationship""" + __table_name__ = "post_tags" + post_id: int tag_id: int c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "post_tags" - def main(): # Configure Database @@ -95,21 +93,38 @@ def main(): # ============================================================ author_columns = [ ColumnDefinition( - "id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ], + ), + ColumnDefinition( + dialect, + "name", + VarCharType(dialect, length=100), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), - ColumnDefinition("name", VarCharType(100), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ColumnDefinition( + dialect, "email", - VarCharType(255), + VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), ] - create_authors = CreateTableExpression( - dialect=dialect, table_name="authors", columns=author_columns, if_not_exists=True + create_authors = CreateTableStatement( + dialect, + Table(dialect, "authors"), + columns=author_columns, + if_not_exists=True, ) sql, params = create_authors.to_sql() @@ -117,36 +132,54 @@ def main(): print(f"SQL: {sql}") print() - sql_str, params_str = create_authors.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # ============================================================ # Example 2: Create child table (Post) with foreign key # ============================================================ post_columns = [ ColumnDefinition( - "id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ], + ), + ColumnDefinition( + dialect, + "title", + VarCharType(dialect, length=200), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ), + ColumnDefinition(dialect, "content", TextType(dialect)), + ColumnDefinition( + dialect, + "author_id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), - ColumnDefinition("title", VarCharType(200), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("author_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ] - # Use table_constraints for foreign key at table level + # Use table_constraints for foreign key at table level. + # The referenced table is a Table object, not a bare string. post_constraints = [ ForeignKeyConstraint( - constraint_type=TableConstraintType.FOREIGN_KEY, + dialect, columns=["author_id"], - foreign_key_table="authors", + foreign_key_table=Table(dialect, "authors"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, on_update=ReferentialAction.CASCADE, ) ] - create_posts = CreateTableExpression( - dialect=dialect, - table_name="posts", + create_posts = CreateTableStatement( + dialect, + Table(dialect, "posts"), columns=post_columns, table_constraints=post_constraints, if_not_exists=True, @@ -157,69 +190,94 @@ def main(): print(f"SQL: {sql}") print() - sql_str, params_str = create_posts.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # ============================================================ # Example 3: Create Tag table # ============================================================ tag_columns = [ ColumnDefinition( - "id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ], ), ColumnDefinition( + dialect, "name", - VarCharType(50), + VarCharType(dialect, length=50), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), ] - create_tags = CreateTableExpression(dialect=dialect, table_name="tags", columns=tag_columns, if_not_exists=True) + create_tags = CreateTableStatement( + dialect, + Table(dialect, "tags"), + columns=tag_columns, + if_not_exists=True, + ) sql, params = create_tags.to_sql() print("=== Create Tags Table ===") print(f"SQL: {sql}") print() - sql_str, params_str = create_tags.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # ============================================================ # Example 4: Create junction table (PostTag) for many-to-many # ============================================================ post_tag_columns = [ - ColumnDefinition("post_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tag_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition( + dialect, + "post_id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ), + ColumnDefinition( + dialect, + "tag_id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ), ] # Table-level constraints for composite PK and foreign keys post_tag_constraints = [ # Composite primary key - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, columns=["post_id", "tag_id"]), + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["post_id", "tag_id"]), + ), # Foreign key to posts table ForeignKeyConstraint( - constraint_type=TableConstraintType.FOREIGN_KEY, + dialect, columns=["post_id"], - foreign_key_table="posts", + foreign_key_table=Table(dialect, "posts"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, ), # Foreign key to tags table ForeignKeyConstraint( - constraint_type=TableConstraintType.FOREIGN_KEY, + dialect, columns=["tag_id"], - foreign_key_table="tags", + foreign_key_table=Table(dialect, "tags"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, ), ] - create_post_tags = CreateTableExpression( - dialect=dialect, - table_name="post_tags", + create_post_tags = CreateTableStatement( + dialect, + Table(dialect, "post_tags"), columns=post_tag_columns, table_constraints=post_tag_constraints, if_not_exists=True, @@ -230,8 +288,7 @@ def main(): print(f"SQL: {sql}") print() - sql_str, params_str = create_post_tags.to_sql() - backend.execute(sql_str, params_str) + backend.execute(sql, params) # Introspection: Verify tables and foreign keys print("=== Introspection Results ===") diff --git a/docs/examples/chapter_03_modeling/ddl_views.py b/docs/examples/chapter_03_modeling/ddl_views.py index 3b167cd9d..3bbc17e78 100644 --- a/docs/examples/chapter_03_modeling/ddl_views.py +++ b/docs/examples/chapter_03_modeling/ddl_views.py @@ -1,5 +1,5 @@ -""" -Chapter 3: Modeling Data - DDL Views Examples +"""Chapter 3: Modeling Data - DDL Views Examples + Demonstrates DDL operations for views: 1. Create basic view 2. Create view with column aliases @@ -9,39 +9,41 @@ """ from typing import ClassVar -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.base import FieldProxy -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig + from rhosocial.activerecord.backend.expression import ( - ColumnDefinition, - CreateTableExpression, - CreateViewExpression, - DropViewExpression, - QueryExpression, - TableExpression, Column, + ColumnDefinition, + NamedRelationRef, + QueryStatement, ) -from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + CreateViewStatement, + DropViewStatement, ) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): """User Model for DDL view demo""" + __table_name__ = "users" + + id: int = None name: str email: str status: str = "active" c: ClassVar[FieldProxy] = FieldProxy() - @classmethod - def table_name(cls) -> str: - return "users" - def main(): # Configure Database @@ -55,15 +57,24 @@ def main(): # Create base table first user_columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", VarCharType(20)), + ColumnDefinition( + dialect, "id", IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] + ), + ColumnDefinition( + dialect, "name", VarCharType(dialect, length=100), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] + ), + ColumnDefinition( + dialect, "email", VarCharType(dialect, length=255), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] + ), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=20)), ] - create_users = CreateTableExpression(dialect=dialect, table_name="users", columns=user_columns) - sql_str, params_str = create_users.to_sql() - backend.execute(sql_str, params_str) + create_users = CreateTableStatement(dialect, Table(dialect, "users"), columns=user_columns) + sql_str, params = create_users.to_sql() + backend.execute(sql_str, params) print("Users table created.") # Insert some sample data @@ -80,21 +91,23 @@ def main(): # ============================================================ # Note: SQLite doesn't allow parameters in VIEW definitions. # We use a simple SELECT without WHERE parameter. - all_users_query = QueryExpression( + all_users_query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], - from_=TableExpression(dialect, "users"), + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) - create_active_view = CreateViewExpression(dialect, view_name="all_users", query=all_users_query) + create_all_users_view = CreateViewStatement( + dialect, View(dialect, "all_users"), query=all_users_query + ) - sql, params = create_active_view.to_sql() + sql, params = create_all_users_view.to_sql() print("=== Create Basic View ===") print(f"SQL: {sql}") print() - sql_str, params_str = create_active_view.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_all_users_view.to_sql() + backend.execute(sql_str, params) # Introspection: Verify view was created print("--- Views after creation ---") @@ -105,24 +118,27 @@ def main(): # Query the view print() print("--- Querying the view ---") - result = backend.execute("SELECT * FROM all_users") - if result and result.data: - for row in result.data: - print(f" {row}") + for row in backend.fetch_all("SELECT * FROM all_users"): + print(f" {row}") print() # ============================================================ # Example 2: Create view with column aliases # ============================================================ - user_details_query = QueryExpression( + user_details_query = QueryStatement( dialect, - select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email"), Column(dialect, "status")], - from_=TableExpression(dialect, "users"), + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "email"), + Column(dialect, "status"), + ], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) - create_aliased_view = CreateViewExpression( + create_aliased_view = CreateViewStatement( dialect, - view_name="user_details", + View(dialect, "user_details"), query=user_details_query, column_aliases=["user_id", "full_name", "contact_email", "user_status"], ) @@ -132,57 +148,59 @@ def main(): print(f"SQL: {sql}") print() - sql_str, params_str = create_aliased_view.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_aliased_view.to_sql() + backend.execute(sql_str, params) # Query the view print("--- Querying aliased view ---") - result = backend.execute("SELECT * FROM user_details") - if result and result.data: - for row in result.data: - print(f" {row}") + for row in backend.fetch_all("SELECT * FROM user_details"): + print(f" {row}") print() # ============================================================ # Example 3: Create view with OR REPLACE # ============================================================ - all_users_query = QueryExpression( - dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=TableExpression(dialect, "users") + active_users_query = QueryStatement( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) - create_replace_view = CreateViewExpression(dialect, view_name="active_users", query=all_users_query, replace=True) + create_replace_view = CreateViewStatement( + dialect, View(dialect, "active_users"), query=active_users_query, replace=True + ) sql, params = create_replace_view.to_sql() print("=== Create View with OR REPLACE ===") print(f"SQL: {sql}") + print("Note: SQLite has no CREATE OR REPLACE VIEW; its dialect renders IF NOT EXISTS instead.") print() - sql_str, params_str = create_replace_view.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = create_replace_view.to_sql() + backend.execute(sql_str, params) # Query the replaced view print("--- Querying replaced view ---") - result = backend.execute("SELECT * FROM active_users") - if result and result.data: - for row in result.data: - print(f" {row}") + for row in backend.fetch_all("SELECT * FROM active_users"): + print(f" {row}") print() # ============================================================ # Example 4: Drop view # ============================================================ - drop_view = DropViewExpression(dialect, view_name="user_details", if_exists=True) + drop_view = DropViewStatement(dialect, View(dialect, "user_details"), if_exists=True) sql, params = drop_view.to_sql() print("=== Drop View ===") print(f"SQL: {sql}") print() - sql_str, params_str = drop_view.to_sql() - backend.execute(sql_str, params_str) + sql_str, params = drop_view.to_sql() + backend.execute(sql_str, params) # Introspection: Verify view was dropped print("--- Views after drop ---") + introspector.clear_cache() views = introspector.list_views() for v in views: print(f" View: {v.name}") diff --git a/docs/examples/chapter_03_modeling/verify_batch_api.py b/docs/examples/chapter_03_modeling/verify_batch_api.py index b6b5d6cdb..0a85be1a6 100644 --- a/docs/examples/chapter_03_modeling/verify_batch_api.py +++ b/docs/examples/chapter_03_modeling/verify_batch_api.py @@ -10,10 +10,11 @@ - BatchCommitMode ← from rhosocial.activerecord.backend.result - BatchDMLResult ← from rhosocial.activerecord.backend.result - BatchDQLResult ← from rhosocial.activerecord.backend.result - - InsertExpression ← from rhosocial.activerecord.backend.expression - - QueryExpression ← from rhosocial.activerecord.backend.expression.statements - - WildcardExpression← from rhosocial.activerecord.backend.expression - - TableExpression ← from rhosocial.activerecord.backend.expression + - InsertStatement ← from rhosocial.activerecord.backend.expression + - QueryStatement ← from rhosocial.activerecord.backend.expression.statements + - AllColumnsWildcard← from rhosocial.activerecord.backend.expression + - Table ← from rhosocial.activerecord.backend.expression.objects + - NamedRelationRef ← from rhosocial.activerecord.backend.expression - ValuesSource ← from rhosocial.activerecord.backend.expression - Column, Literal ← from rhosocial.activerecord.backend.expression - OrderByClause ← from rhosocial.activerecord.backend.expression.query_parts @@ -125,18 +126,19 @@ def try_import(module: str, name: str) -> None: try_import("rhosocial.activerecord.backend.result", "BatchDQLResult") # Expression types (DML) - try_import("rhosocial.activerecord.backend.expression", "InsertExpression") - try_import("rhosocial.activerecord.backend.expression", "UpdateExpression") - try_import("rhosocial.activerecord.backend.expression", "DeleteExpression") - try_import("rhosocial.activerecord.backend.expression", "TableExpression") + try_import("rhosocial.activerecord.backend.expression", "InsertStatement") + try_import("rhosocial.activerecord.backend.expression", "UpdateStatement") + try_import("rhosocial.activerecord.backend.expression", "DeleteStatement") + try_import("rhosocial.activerecord.backend.expression.objects", "Table") + try_import("rhosocial.activerecord.backend.expression", "NamedRelationRef") try_import("rhosocial.activerecord.backend.expression", "ValuesSource") try_import("rhosocial.activerecord.backend.expression", "Column") try_import("rhosocial.activerecord.backend.expression", "Literal") # Expression types (DQL) - try_import("rhosocial.activerecord.backend.expression.statements", "QueryExpression") - try_import("rhosocial.activerecord.backend.expression.query_sources", "WithQueryExpression") - try_import("rhosocial.activerecord.backend.expression", "WildcardExpression") + try_import("rhosocial.activerecord.backend.expression.statements", "QueryStatement") + try_import("rhosocial.activerecord.backend.expression.query_sources", "WithStatement") + try_import("rhosocial.activerecord.backend.expression", "AllColumnsWildcard") try_import("rhosocial.activerecord.backend.expression.query_parts", "OrderByClause") try_import("rhosocial.activerecord.backend.expression.query_parts", "LimitOffsetClause") @@ -174,10 +176,11 @@ def demonstrate_batch_dml() -> None: from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, ValuesSource, Literal, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.result import BatchCommitMode from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -194,7 +197,7 @@ def demonstrate_batch_dml() -> None: dialect = backend.dialect - # Build InsertExpression list + # Build InsertStatement list rows = [ ("Alice", "alice@example.com"), ("Bob", "bob@example.com"), @@ -203,9 +206,9 @@ def demonstrate_batch_dml() -> None: ("Eve", "eve@example.com"), ] exprs = [ - InsertExpression( + InsertStatement( dialect, - into="users", + into=Table(dialect, "users"), columns=["name", "email"], source=ValuesSource( dialect, @@ -257,11 +260,12 @@ def demonstrate_batch_dql() -> None: from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.expression import ( - WildcardExpression, - TableExpression, + AllColumnsWildcard, + NamedRelationRef, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import OrderByClause from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.options import ExecutionOptions @@ -288,10 +292,10 @@ def demonstrate_batch_dql() -> None: dialect = backend.dialect # Build SELECT * FROM items ORDER BY id - query_expr = QueryExpression( + query_expr = QueryStatement( dialect, - select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "items"), + select=[AllColumnsWildcard(dialect)], + from_=NamedRelationRef(dialect, Table(dialect, "items")), order_by=OrderByClause( dialect, expressions=[(Column(dialect, "id"), "ASC")], @@ -334,8 +338,8 @@ def print_usage_summary() -> None: ✅ CORRECT imports for related types: from rhosocial.activerecord.backend.result import BatchCommitMode, BatchDMLResult, BatchDQLResult - from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, Column, Literal - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource, Column, Literal + from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.expression.query_parts import OrderByClause, LimitOffsetClause from rhosocial.activerecord.backend.base import BatchExecutionMixin # for type hints only """) diff --git a/docs/examples/expression_artifact.py b/docs/examples/expression_artifact.py new file mode 100644 index 000000000..b1c9356c3 --- /dev/null +++ b/docs/examples/expression_artifact.py @@ -0,0 +1,171 @@ +"""Serializing an expression to JSON text and reading it back. + +Run it: .venv3.14\\Scripts\\python.exe docs\\examples\\expression_artifact.py + +The point of the whole design is in the middle four lines: a plain +``json.dumps`` / ``json.loads`` pair, with no custom encoder, no ``object_hook`` +and no class import on the reading side. +""" + +import json + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + default_registry, + serialize, +) +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + + +def build_query(dialect): + """An ordinary query. Nothing here knows about serialization.""" + users = Table(dialect, "users") + return QueryStatement( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=users, + where=WhereClause( + dialect, + ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 18)), + ), + ) + + +def rule(title): + print() + print(title) + print("-" * len(title)) + + +def show(label, result): + """Print what ``to_sql()`` gives: the statement and its bind parameters. + + Expressions never inline a value into the SQL. A literal becomes a + ``?`` and its value comes back alongside, so the driver can bind it and + the database never sees a quoted string built by string concatenation. + """ + sql, params = result + print("%-22s %s" % (label, sql)) + print("%-22s params=%r (%s)" % ("", list(params), ", ".join(type(p).__name__ for p in params))) + return sql, params + + +def main(): + # Whatever dialect you happen to be holding. The artifact will not carry it. + dialect = DummyDialect() + query = build_query(dialect) + built_sql, built_params = show("built", query.to_sql()) + + # ---------------------------------------------------------------- 1. write + rule("1. expression instance -> artifact") + artifact = serialize(query) + print(json.dumps(artifact, indent=2)[:300], "...") + + # ---------------------------------------------------------------- 2. text + rule("2. artifact -> JSON text (plain json.dumps)") + text = json.dumps(artifact) + print(text[:300], "...") + print("bytes :", len(text)) + + # ---------------------------------------------------------------- 3. back + rule("3. JSON text -> artifact (plain json.loads, no object_hook)") + loaded = json.loads(text) + print("keys :", sorted(loaded)) + print("kind :", loaded["kind"]) + # Nothing needs decoding before the codec sees it. The artifact is plain JSON + # end to end: a node is {kind, params}, a parameter is a scalar, an array + # or an object. There are no markers, so there is nothing to recognise -- + # and nothing a reader in another language would have to be told about. + + # ---------------------------------------------------------------- 4. read + rule("4. artifact -> expression instance") + restored = deserialize(loaded, dialect) + print("type :", type(restored).__name__) + rebuilt_sql, rebuilt_params = show("rebuilt", restored.to_sql()) + print("same sql :", rebuilt_sql == built_sql) + print("same params:", rebuilt_params == built_params) + + # The bind parameters were never stored anywhere. They are a *consequence* + # of the payload: the literal travelled as a node, so the reader knows it + # is a literal and offers it up for binding. An artifact that had to carry + # "SQL text plus a parameter list" would have two things to keep in step, + # and no way to check they agreed. + rule("4b. where the bind value actually lives") + # A nested expression is a bare `{kind, params}` node, with no wrapper + # announcing it -- the shape is the whole signal. So there is nothing to + # unwrap here, which is a large part of why an artifact stays readable. + condition = loaded["params"]["where"]["params"]["condition"] + literal = condition["params"]["right"] + print("the predicate node :", condition["kind"]) + print("its right operand :", literal["kind"]) + print("that node's params :", json.dumps(literal["params"])) + print() + print(" The 18 in `params=(18,)` was read off that node. Nothing else") + print(" carried it, so nothing else can disagree with it.") + print() + print(" A literal's kind is its *value* kind, not the node's class name.") + print(" One node carries every value and the wire still names them apart,") + print(" because a date's spelling and a string's are the same bytes.") + + # ------------------------------------------------- 5. the actual payoff + rule("5. the artifact carries no dialect") + flat = json.dumps(loaded) + print("mentions a dialect:", any(w in flat for w in ("Dialect", "dummy", "sqlite"))) + sqlite = SQLiteDialect(version=(3, 53, 0)) + on_sqlite = deserialize(loaded, sqlite) + show("on SQLite", on_sqlite.to_sql()) + print() + print(" Same artifact, second dialect object, no re-serialization.") + print(" The SQL happens to match because DummyDialect and SQLiteDialect") + print(" in this distribution share one set of formatters; a dialect that") + print(" models a feature differently will differ, and it will differ here") + print(" -- the payload was never tied to the first one.") + + # ------------------------------------------------------- 6. what a read may accept + rule("6. the caller's registry decides which kinds a read may produce") + narrow = ExpressionRegistry() + narrow.register_node(Column) + try: + deserialize(loaded, dialect, registry=narrow) + except Exception as exc: + print("table with 1 kind -> refused:", type(exc).__name__) + print(" ", str(exc)[:104]) + + enough = ExpressionRegistry() + for name in ( + "query_statement", "where_clause", "column", + "comparison_predicate", "table", + ): + enough.register_node(default_registry().resolve_node(name)) + # A value kind is admitted by its *wire name*, never by class. One node + # carries every value, so there is no class named `integer_value` to + # register and `register_node` could not produce the key even if you + # handed it the class -- it would file it under `literal`, the class name. + enough.register_value_kinds("integer_value") + ok = deserialize(loaded, dialect, registry=enough) + print("table with 6 kinds -> rebuilt:", type(ok).__name__) + print(" The reader only ever produces what the caller allowed.") + + # --------------------------------------------------------- 7. untrusted input + rule("7. an artifact is untrusted input") + for label, bad in ( + ("unknown kind", {"kind": "no_such_kind", "params": {}}), + ("leftover envelope", dict(loaded, type="...SelectStatement")), + ("missing kind", {"params": {}}), + ): + try: + deserialize(bad, dialect) + except Exception as exc: + print("%-20s -> %s" % (label, type(exc).__name__)) + print("%-20s %s" % ("", str(exc)[:104])) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docs/zh_CN/SUMMARY.md b/docs/zh_CN/SUMMARY.md index 00f660a30..ab3a0cd02 100644 --- a/docs/zh_CN/SUMMARY.md +++ b/docs/zh_CN/SUMMARY.md @@ -137,6 +137,7 @@ ### SQLite 后端 * [概述](backend/sqlite/README.md) +* [命令行界面](backend/sqlite/cli/README.md) * [Pragma](backend/sqlite/backend_specific_features/pragma.md) * [FTS5](backend/sqlite/backend_specific_features/fts5.md) * [扩展](backend/sqlite/backend_specific_features/extension.md) diff --git a/docs/zh_CN/backend/architecture.md b/docs/zh_CN/backend/architecture.md index 969d5c22d..2cbfedb12 100644 --- a/docs/zh_CN/backend/architecture.md +++ b/docs/zh_CN/backend/architecture.md @@ -174,6 +174,20 @@ sequenceDiagram 门控行为。后端在连接后会通过 `introspect_and_adapt()` 重新适配方言,因此能力是「与服务器 协商的」,而非静态声明。 +在适配**之前**询问能力,过去的行为不同:它会抛 `DialectNotAdaptedException`,于是每个只想 +知道「有没有这个特性」的调用者都得去捕获它。现在的版本词汇表会先给出答案: + +| 访问方式 | 回答的问题 | +|---------|-----------| +| `dialect._declared_version()` | dialect 无需询问服务器就能给出的版本(SQLite 用的是所链接引擎的版本),或 `None` | +| `dialect.version_or_none` | 已适配的版本,否则是声明的版本,否则 `None` —— 从不抛异常 | +| `dialect.version_at_least(v)` | 当前生效版本是否不低于 `v`;未知时返回 `False` | +| `backend.get_cached_server_version()` | 后端已解析出的版本,无需再往返一次 —— 尚未解析时为 `None` | +| `introspector.server_version` | 内省器从服务器读到的版本 | + +因此版本未知时 `supports_*()` 报告「不支持」,而严格的 `dialect.version` 仍然抛异常: +渲染器要决定**发出哪种** SQL,就必须知道它所门控的版本;在这里猜测只会生成服务器会拒绝的 SQL。 + ## 参见 - **[自定义后端](custom_backend.md)** —— 如何用同样这些 Mixin 与协议组装你自己的后端 diff --git a/docs/zh_CN/backend/custom_backend.md b/docs/zh_CN/backend/custom_backend.md index 85a7216b3..915d4688d 100644 --- a/docs/zh_CN/backend/custom_backend.md +++ b/docs/zh_CN/backend/custom_backend.md @@ -61,6 +61,18 @@ except OperationalError as e: 3. **后端特定配置类**:后端继承 `ConnectionConfig` 并混入所需 Mixin,再叠加数据库私有参数。 ```python +from typing import Optional + +from rhosocial.activerecord.backend.config import ( + CharsetMixin, + ConnectionConfig, + ConnectionPoolMixin, + LoggingMixin, + SSLMixin, + TimezoneMixin, + VersionMixin, +) + class MySQLConnectionConfig( ConnectionConfig, ConnectionPoolMixin, SSLMixin, CharsetMixin, @@ -120,7 +132,7 @@ from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionCo from rhosocial.activerecord.model import ActiveRecord # 构建配置并绑定到模型 -config = SQLiteConnectionConfig(database="app.db") +config = SQLiteConnectionConfig(database=":memory:") ActiveRecord.configure(config, SQLiteBackend) # 后端特定子类 @@ -150,16 +162,27 @@ ActiveRecord.configure(SQLiteInMemoryConfig(), SQLiteBackend) 注意它是如何简单地混入标准实现的: ```python +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.mixins import CTEMixin, WindowFunctionMixin +from rhosocial.activerecord.backend.dialect.protocols import ( + AdvancedGroupingSupport, + CTESupport, + WindowFunctionSupport, +) + class DummyDialect( SQLDialectBase, # 通过 Mixins 引入标准实现 - WindowFunctionMixin, CTEMixin, AdvancedGroupingMixin, ... + WindowFunctionMixin, CTEMixin, # 协议定义用于类型检查 - WindowFunctionSupport, CTESupport, AdvancedGroupingSupport, ... + WindowFunctionSupport, CTESupport, AdvancedGroupingSupport, ): # 特性标志 - def supports_window_functions(self) -> bool: return True - # ... + def supports_window_functions(self) -> bool: + return True + + def supports_basic_cte(self) -> bool: + return True ``` ### 实现策略 @@ -180,6 +203,8 @@ class DummyDialect( 通用协议(如 `WindowFunctionSupport`、`TableSupport`、`IndexSupport`)定义在 `rhosocial.activerecord.backend.dialect.protocols` 中,**不应包含任何后端特定前缀**。 ```python +from typing import Protocol + # ✅ 正确:通用协议 class WindowFunctionSupport(Protocol): def supports_window_functions(self) -> bool: ... @@ -193,6 +218,8 @@ class MySQLWindowFunctionSupport(Protocol): ... 后端特定协议必须包含后端名称作为前缀(例如 `PostgresPartitionSupport`、`MySQLFullTextSearchSupport`)。 ```python +from typing import Protocol + # ✅ 正确:后端特定协议有前缀 class PostgresPartitionSupport(Protocol): ... @@ -206,21 +233,26 @@ class PartitionSupport(Protocol): ... ```python # 示例:MySQL 全文搜索 -# 通用 IndexSupport 协议已经定义了 supports_fulltext_index +# 通用 FulltextIndexSupport 协议已经定义了 supports_fulltext_index +from typing import Protocol + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.protocols import FulltextIndexSupport + # ✅ 使用通用协议并设置版本检查 class MySQLDialect( - IndexSupport, # 已经包含 supports_fulltext_index - ... + SQLDialectBase, + FulltextIndexSupport, # 已经包含 supports_fulltext_index ): def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) # 仅在需要 MySQL 特定接口时创建 MySQLFullTextSearchSupport: # - format_match_against() - MySQL MATCH...AGAINST 语法 # - format_fulltext_index_options() - WITH PARSER 等 class MySQLFullTextSearchSupport(Protocol): - def format_match_against(...): ... # MySQL 特定 - def format_fulltext_index_options(...): ... # MySQL 特定 + def format_match_against(self, expr) -> tuple: ... # MySQL 特定 + def format_fulltext_index_options(self, expr) -> tuple: ... # MySQL 特定 ``` 这一原则确保: @@ -233,6 +265,8 @@ class MySQLFullTextSearchSupport(Protocol): 当通用协议的格式化接口有后端特定参数时,使用 `dialect_options` 参数: ```python +from typing import Any, Dict, Optional, Protocol, Tuple + # 通用协议定义带 dialect_options 的接口 class JSONSupport(Protocol): def supports_json_table( @@ -264,13 +298,19 @@ class JSONSupport(Protocol): **在表达式中的用法:** ```python -# 创建表达式时传入 dialect_options +# 后端特定选项属于能力探测接口,而不属于表达式本身: +# JSONTableExpression 只接受通用参数 +from rhosocial.activerecord.backend.expression.query_sources import ( + JSONTableColumn, + JSONTableExpression, +) + JSONTableExpression( dialect, - json_column=User.json_data, + json_column="json_data", path='$.addresses[*]', - columns=[...], - dialect_options={'on_error': 'IGNORE'} # MySQL 特定 + columns=[JSONTableColumn("id", "INTEGER", "$.id")], + alias="addresses", ) ``` @@ -445,6 +485,9 @@ Dialect 格式化实现 **步骤 1: 定义协议** (`mysql/protocols.py`,Protocol: 协议) ```python +from typing import Protocol, Tuple, runtime_checkable + + @runtime_checkable class FullTextSearchSupport(Protocol): def supports_fulltext_index(self) -> bool: # supports_* 方法 @@ -462,10 +505,17 @@ class FullTextSearchSupport(Protocol): **步骤 2: 定义表达式** (`mysql/expression/match_against.py`,Expression: 表达式) ```python -class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +from typing import List, Optional + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.expression import AliasableMixin, SQLValue +from rhosocial.activerecord.backend.expression.mixins import ComparisonMixin + + +class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValue): def __init__( self, - dialect: MySQLDialect, + dialect: SQLDialectBase, columns: List[str], search_string: str, mode: Optional[str] = None, @@ -480,21 +530,29 @@ class MatchAgainstExpression(AliasableMixin, ComparisonMixin, SQLValueExpression # 绝不复写 to_sql():BaseExpression.to_sql() 会在绑定的方言上 # 解析该名称并调用 dialect.format_match_against(expr)。 return "format_match_against" - - def as_(self, alias: str) -> AliasColumn: - return AliasColumn(self, alias) ``` **步骤 3: 在方言中实现协议** (`mysql/dialect.py`,Dialect: 方言) ```python +from typing import Protocol, Tuple, runtime_checkable + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + +@runtime_checkable +class FullTextSearchSupport(Protocol): + def supports_fulltext_index(self) -> bool: ... + + def format_match_against(self, expr: "MatchAgainstExpression") -> Tuple[str, tuple]: ... + + class MySQLDialect( - MySQLBaseMixin, + SQLDialectBase, FullTextSearchSupport, # 添加协议 - ... ): def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def format_match_against( self, @@ -502,7 +560,7 @@ class MySQLDialect( ) -> Tuple[str, tuple]: cols_sql = ", ".join(self.format_identifier(c) for c in expr.columns) placeholder = self.get_parameter_placeholder() - + # 模式映射 (mode mapping) mode_map = { "NATURAL_LANGUAGE": "IN NATURAL LANGUAGE MODE", @@ -510,7 +568,7 @@ class MySQLDialect( "QUERY_EXPANSION": "IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION", } mode_str = mode_map.get(expr.mode, "IN NATURAL LANGUAGE MODE") - + sql = f"MATCH({cols_sql}) AGAINST({placeholder} {mode_str})" return sql, (expr.search_string,) ``` @@ -519,7 +577,8 @@ class MySQLDialect( ```python def test_match_against_expression(self): - dialect = MySQLDialect(version=(8, 0, 0)) + dialect = MySQLDialect() + dialect.version = (8, 0, 0) expr = MatchAgainstExpression( dialect, columns=['title', 'content'], @@ -547,28 +606,34 @@ def test_match_against_expression(self): **`mysql/mixins.py`** (协议实现) ```python +from typing import Tuple + + class MySQLFullTextMixin: def supports_fulltext_index(self) -> bool: - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def supports_fulltext_parser(self) -> bool: - return self.version >= (5, 1, 0) + return self.version_at_least((5, 1, 0)) def format_match_against( self, expr: MatchAgainstExpression, ) -> Tuple[str, tuple]: # 实现... + return "", () ``` **`mysql/dialect.py`** (组合 Mixin) ```python +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + class MySQLDialect( - MySQLBaseMixin, + SQLDialectBase, MySQLFullTextMixin, # 分离的 mixin 用于组织 FullTextSearchSupport, - ... ): pass ``` @@ -603,16 +668,20 @@ class MySQLDialect( **示例**:为 MySQL 添加 `MODIFY COLUMN` 支持 ```python +from typing import Protocol, Tuple, runtime_checkable + + # 步骤 1: MySQL 专属 Protocol (mysql/protocols.py) @runtime_checkable class MySQLModifyColumnSupport(Protocol): def supports_modify_column(self) -> bool: ... def format_modify_column_action(self, action) -> Tuple[str, tuple]: ... + # 步骤 2: MySQL 专属 Mixin (mysql/mixins.py) class MySQLModifyColumnMixin: def supports_modify_column(self) -> bool: - return self.version >= (5, 0, 0) # 所有 MySQL 5.x+ 支持 + return self.version_at_least((5, 0, 0)) # 所有 MySQL 5.x+ 支持 def format_modify_column_action(self, action) -> Tuple[str, tuple]: col_sql, col_params = self.format_column_definition(action.column) @@ -623,11 +692,15 @@ class MySQLModifyColumnMixin: sql += " FIRST" return sql, col_params + # 步骤 3: MySQL 方言混入 Protocol + Mixin (mysql/dialect.py) +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + class MySQLDialect( + SQLDialectBase, MySQLModifyColumnMixin, MySQLModifyColumnSupport, - ... ): pass # Mixin 中已有实现,无需额外覆写 ``` diff --git a/docs/zh_CN/backend/explain.md b/docs/zh_CN/backend/explain.md index d6ae5b456..452595cf9 100644 --- a/docs/zh_CN/backend/explain.md +++ b/docs/zh_CN/backend/explain.md @@ -45,10 +45,11 @@ backend/impl/sqlite/explain/ 所有解释结果均继承自 `BaseExplainResult`,它是普通的 `pydantic.BaseModel`(而非 `ActiveRecord`): ```python -class BaseExplainResult(BaseModel): - raw_rows: List[Dict[str, Any]] # 来自 fetch_all() 的原始行数据 - sql: str # 实际执行的 EXPLAIN SQL - duration: float # 执行耗时(秒) +from rhosocial.activerecord.backend.explain.types import BaseExplainResult + +# 每个解释结果都携带的模型字段 +for name, field in BaseExplainResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` 使用 `pydantic.BaseModel` 可避免后端层对 ActiveRecord 模型层的反向依赖。 @@ -60,15 +61,16 @@ class BaseExplainResult(BaseModel): ```python from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainResult -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect dialect = SQLiteDialect() -backend = SQLiteBackend(database="mydb.sqlite3") +backend = SQLiteBackend(database=":memory:") backend.connect() +backend.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)") # 获取查询的字节码解释 -expr = RawSQLExpression(dialect, "SELECT * FROM users WHERE id = 1") +expr = RawSQL(dialect, "SELECT * FROM users WHERE id = 1") result = backend.explain(expr) assert isinstance(result, SQLiteExplainResult) @@ -80,18 +82,18 @@ for row in result.rows: ### 使用表达式构建器 -可以传入任何 `BaseExpression`(`ExplainExpression` 本身除外): +可以传入任何 `BaseExpression`(`ExplainStatement` 本身除外): ```python -from rhosocial.activerecord.backend.expression.core import ( - TableExpression, WildcardExpression, +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, NamedRelationRef, QueryStatement, ) -from rhosocial.activerecord.backend.expression.statements import QueryExpression +from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "users"), + select=[AllColumnsWildcard(dialect)], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) result = backend.explain(query) ``` @@ -117,13 +119,25 @@ for row in result.rows: ### 异步 API ```python +import asyncio + from rhosocial.activerecord.backend.impl.sqlite.backend import AsyncSQLiteBackend -backend = AsyncSQLiteBackend(database="mydb.sqlite3") -await backend.connect() +async_backend = AsyncSQLiteBackend(database=":memory:") + + +async def main(): + await async_backend.connect() + await async_backend.execute( + "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)" + ) + + result = await async_backend.explain(query) # 字节码 + result = await async_backend.explain(query, opts) # 查询计划 + await async_backend.disconnect() + -result = await backend.explain(query) # 字节码 -result = await backend.explain(query, opts) # 查询计划 +asyncio.run(main()) ``` ## SQLite 结果类型 @@ -135,20 +149,22 @@ SQLite 根据 EXPLAIN 模式的不同,输出的列格式完全不同。后端 由普通 `EXPLAIN ` 返回。每行代表一条 VDBE(Virtual DataBase Engine,虚拟数据库引擎)指令: ```python -class SQLiteExplainRow(BaseModel): - addr: int # 程序计数器地址 - opcode: str # 指令名称(如 "OpenRead"、"SeekGE"、"Column") - p1: int # 第一操作数(通常是游标编号) - p2: int # 第二操作数(通常是 B 树页号或跳转目标) - p3: int # 第三操作数 - p4: Optional[str] # 可选的字符串或 blob 操作数 - p5: int # 标志位掩码 - comment: Optional[str] # 人类可读的注释 +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainRow + +# addr: 程序计数器地址;opcode: 指令名称 +# p1: 第一操作数(通常是游标编号) +# p2: 第二操作数(通常是 B 树页号或跳转目标) +# p3: 第三操作数;p4: 可选的字符串或 blob 操作数 +# p5: 标志位掩码;comment: 人类可读的注释 +for name, field in SQLiteExplainRow.model_fields.items(): + print(f"{name}: {field.annotation}") ``` ```python -class SQLiteExplainResult(BaseExplainResult): - rows: List[SQLiteExplainRow] +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainResult + +for name, field in SQLiteExplainResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` #### 核心操作码说明 @@ -170,16 +186,23 @@ class SQLiteExplainResult(BaseExplainResult): 由 `EXPLAIN QUERY PLAN ` 返回。每行描述查询策略中的一个步骤: ```python -class SQLiteExplainQueryPlanRow(BaseModel): - id: int # 行标识符(用于构建树形结构) - parent: int # 父行 id(0 表示顶层) - notused: int # 保留字段(当前 SQLite 版本始终为 0) - detail: str # 该步骤的人类可读描述 +from rhosocial.activerecord.backend.impl.sqlite.explain import SQLiteExplainQueryPlanRow + +# id: 行标识符(用于构建树形结构) +# parent: 父行 id(0 表示顶层) +# notused: 保留字段(当前 SQLite 版本始终为 0) +# detail: 该步骤的人类可读描述 +for name, field in SQLiteExplainQueryPlanRow.model_fields.items(): + print(f"{name}: {field.annotation}") ``` ```python -class SQLiteExplainQueryPlanResult(BaseExplainResult): - rows: List[SQLiteExplainQueryPlanRow] +from rhosocial.activerecord.backend.impl.sqlite.explain import ( + SQLiteExplainQueryPlanResult, +) + +for name, field in SQLiteExplainQueryPlanResult.model_fields.items(): + print(f"{name}: {field.annotation}") ``` `detail` 文本中使用 `SCAN`、`SEARCH`、`USING (COVERING) INDEX` 等关键词,直接反映查询策略。 @@ -210,12 +233,24 @@ class SQLiteExplainQueryPlanResult(BaseExplainResult): ### 示例:诊断索引使用情况 ```python -from rhosocial.activerecord.backend.expression import RawSQLExpression -from rhosocial.activerecord.backend.expression.statements import ExplainOptions, ExplainType +from rhosocial.activerecord.backend.expression import RawSQL +from rhosocial.activerecord.backend.expression.statements import ( + ExplainOptions, ExplainType, +) -dialect = SQLiteDialect() -backend = SQLiteBackend(database="mydb.sqlite3") -backend.connect() +backend.execute( + "CREATE TABLE IF NOT EXISTS orders " + "(id INTEGER PRIMARY KEY, status TEXT, total REAL)" +) +backend.execute("CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status)") +backend.execute( + "CREATE TABLE IF NOT EXISTS order_items " + "(id INTEGER PRIMARY KEY, order_id INTEGER, sku TEXT)" +) +backend.execute( + "CREATE INDEX IF NOT EXISTS idx_order_items_order_sku " + "ON order_items (order_id, sku)" +) queries = { "全表扫描": "SELECT * FROM orders", @@ -225,7 +260,7 @@ queries = { for label, sql in queries.items(): # 字节码分析 - result = backend.explain(RawSQLExpression(dialect, sql)) + result = backend.explain(RawSQL(dialect, sql)) print(f"{label}: {result.analyze_index_usage()}") print(f" is_full_scan={result.is_full_scan}, " f"is_index_used={result.is_index_used}, " @@ -233,7 +268,7 @@ for label, sql in queries.items(): # 查询计划分析(具备相同属性) plan = backend.explain( - RawSQLExpression(dialect, sql), + RawSQL(dialect, sql), ExplainOptions(type=ExplainType.QUERY_PLAN), ) for row in plan.rows: @@ -293,18 +328,23 @@ assert isinstance(async_backend, AsyncExplainBackendProtocol) 3. **覆写 `_parse_explain_result()`**,返回您的后端专属结果类。 ```python -from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin -from rhosocial.activerecord.backend.base import StorageBackend from pydantic import BaseModel -from typing import List, Dict, Any +from typing import Any, Dict, List + +from rhosocial.activerecord.backend.base import StorageBackend +from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin +from rhosocial.activerecord.backend.explain.types import BaseExplainResult + class MyExplainRow(BaseModel): # 定义与您的数据库 EXPLAIN 输出匹配的字段 - ... + detail: str + class MyExplainResult(BaseExplainResult): rows: List[MyExplainRow] + class MyBackend(SyncExplainBackendMixin, StorageBackend): def _parse_explain_result( self, @@ -324,7 +364,7 @@ class MyBackend(SyncExplainBackendMixin, StorageBackend): | 参数 | 类型 | 说明 | |------|------|------| -| `expression` | `BaseExpression` | 除 `ExplainExpression` 以外的任意表达式 | +| `expression` | `BaseExpression` | 除 `ExplainStatement` 以外的任意表达式 | | `options` | `ExplainOptions \| None` | 可选的解释选项(类型、格式、analyze) | **返回值**:后端专属的 `BaseExplainResult` 子类。 diff --git a/docs/zh_CN/backend/expression/README.md b/docs/zh_CN/backend/expression/README.md index 4e68ec659..4c9ca54a6 100644 --- a/docs/zh_CN/backend/expression/README.md +++ b/docs/zh_CN/backend/expression/README.md @@ -73,7 +73,7 @@ 要点: - **方言不参与序列化**:方言必须在反序列化时提供(`dialect` 参数),因为 SQL 生成取决于方言 -- 嵌套表达式、元组、`cast()` 链、dataclass 值、非 JSON 原生标量均通过预留的保留键(`__expr__`、`__tuple__`、`__cast__`、`__vdc__`、`__value__`)完整往返 +- 工件里的一切都是普通 JSON:节点是 `{kind, params}`,参数是标量、数组或对象。**没有任何标记**——读方不需要被告知该对什么派发,而这正是同一份工件能在各语言间逐字节一致的原因。JSON 唯一拼不出的值——`bytes`、`timedelta`、`Decimal`——是由 `Literal` 包裹的绑定值,以其 kind 所命名的文本形式出行 - 序列化使表达式可以**跨进程、跨机器、跨语言**传递,为查询模板化、缓存与分布式场景提供基础 > 序列化的完整规范(spec 格式、嵌套/元组标记、安全考虑与注册表机制)见 [表达式序列化](serialization.md),自定义表达式接入序列化见[扩展指南](extending.md)。 @@ -94,15 +94,16 @@ ```python from rhosocial.activerecord.backend.expression import ( - QueryExpression, TableExpression, Column, Literal + QueryStatement, NamedRelationRef, Column, IntegerColumn ) +from rhosocial.activerecord.backend.expression.objects import Table # SELECT name, age FROM users WHERE age >= 18 -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "age")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "age") >= Literal(dialect, 18) + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=IntegerColumn(dialect, "age") >= 18 ) sql, params = query.to_sql() # sql: 'SELECT "name", "age" FROM "users" WHERE "age" >= ?' diff --git a/docs/zh_CN/backend/expression/advanced.md b/docs/zh_CN/backend/expression/advanced.md index 5c4eccf13..b62886eed 100644 --- a/docs/zh_CN/backend/expression/advanced.md +++ b/docs/zh_CN/backend/expression/advanced.md @@ -12,12 +12,14 @@ rhosocial-activerecord 中的类型转换使用 `TypeCastingMixin`,它提供 ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import CustomType col = Column(dialect, "amount") -expr = col.cast("money") +expr = col.cast(CustomType(dialect, raw="money")) sql, params = expr.to_sql() -# PostgreSQL: ("amount"::money, ()) -# 其他后端: (CAST("amount" AS money), ()) +# sql: 'CAST("amount" AS money)' +# params: () +# PostgreSQL 用自有运算符表示同一个类型转换:'"amount"::money' ``` ### 使用 cast() 函数 @@ -25,76 +27,88 @@ sql, params = expr.to_sql() 如需更灵活的控制,可以使用 functions 模块中的 `cast()` 函数: ```python -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import Column, IntegerColumn, Literal from rhosocial.activerecord.backend.expression.functions import cast +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType -# 转换列 +# 转换列 —— 目标类型必须是 DataType 实例,不能是字符串 col = Column(dialect, "price") -expr = cast(dialect, col, "integer") +expr = cast(dialect, col, IntegerType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("price"::integer, ()) -# 其他后端: (CAST("price" AS integer), ()) +# sql: 'CAST("price" AS INTEGER)' +# params: () # 转换字面值 -expr = cast(dialect, "123", "INTEGER") +expr = cast(dialect, Literal(dialect, "123"), TextType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ('123'::INTEGER, ()) -# 其他后端: (CAST('123' AS INTEGER), ()) +# sql: 'CAST(? AS TEXT)' +# params: ('123',) -# 转换算术表达式 -expr = cast(dialect, Column(dialect, "value") + Literal(dialect, 1), "TEXT") +# 转换算术表达式 —— 算术运算需要数值类型的列类 +expr = cast(dialect, IntegerColumn(dialect, "value") + 1, TextType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("value" + 1)::TEXT, ()) -# 其他后端: (CAST("value" + 1 AS TEXT), ()) +# sql: 'CAST("value" + ? AS TEXT)' +# params: (1,) ``` ### 链式类型转换 ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import ( + CustomType, DecimalType, FloatType, +) col = Column(dialect, "amount") -# 多级类型转换:money -> numeric -> float8 -expr = col.cast("money").cast("numeric").cast("float8") +# 多级类型转换:money -> decimal -> float +expr = col.cast(CustomType(dialect, raw="money")) +expr = expr.cast(DecimalType(dialect)).cast(FloatType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("amount"::money::numeric::float8, ()) +# sql: 'CAST(CAST(CAST("amount" AS money) AS DECIMAL) AS FLOAT)' +# params: () ``` ### 带类型修饰符 -类型修饰符应直接包含在类型字符串中: +类型修饰符是 `DataType` 的构造参数: ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import DecimalType, VarCharType col = Column(dialect, "name") -expr = col.cast("VARCHAR(100)") +expr = col.cast(VarCharType(dialect, length=100)) sql, params = expr.to_sql() -# PostgreSQL: ("name"::VARCHAR(100), ()) +# sql: 'CAST("name" AS VARCHAR(100))' +# params: () col2 = Column(dialect, "price") -expr2 = col2.cast("NUMERIC(10,2)") +expr2 = col2.cast(DecimalType(dialect, precision=10, scale=2)) sql, params = expr2.to_sql() -# PostgreSQL: ("price"::NUMERIC(10,2), ()) +# sql: 'CAST("price" AS DECIMAL(10,2))' +# params: () ``` ### 在查询中使用 ```python from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.types import DecimalType # 在 WHERE 子句中使用 col = Column(dialect, "amount") -predicate = col.cast("numeric") > 100 +predicate = col.cast(DecimalType(dialect)) > 100 sql, params = predicate.to_sql() -# PostgreSQL: ("amount"::numeric > %s, (100,)) +# sql: 'CAST("amount" AS DECIMAL) > ?' +# params: (100,) # 在算术表达式中使用 col1 = Column(dialect, "price1") col2 = Column(dialect, "price2") -expr = col1.cast("numeric") + col2.cast("numeric") +expr = col1.cast(DecimalType(dialect)) + col2.cast(DecimalType(dialect)) sql, params = expr.to_sql() -# PostgreSQL: ("price1"::numeric + "price2"::numeric, ()) +# sql: 'CAST("price1" AS DECIMAL) + CAST("price2" AS DECIMAL)' +# params: () ``` ### PostgreSQL 类型转换语法 @@ -119,7 +133,9 @@ PostgreSQL dialect 会自动使用 `::` 语法生成更简洁的 SQL。 流式附加窗口规范,或在构造时传入 `window_spec=`。 ```python -from rhosocial.activerecord.backend.expression import FunctionCall, WindowSpecification, Column, Literal +from rhosocial.activerecord.backend.expression import ( + Column, FunctionCall, Literal, WindowSpecification, +) from rhosocial.activerecord.backend.expression.query_parts import OrderByClause # 定义窗口规范 @@ -160,96 +176,105 @@ sql, params = frame_spec.to_sql() CTE 提供了一种编写辅助语句以在较大查询中使用的方法。 -### WithQueryExpression +### WithStatement -表示包含一个或多个 CTE 的 `WITH` 子句。 +表示完整的 `WITH` 语句:CTE 定义加上它们所引导的查询。单个 CTE 定义是 `CTEClause`。 ```python -from rhosocial.activerecord.backend.expression import WithQueryExpression, CTEExpression, Subquery, QueryExpression, TableExpression, Column +from rhosocial.activerecord.backend.expression import ( + CTEClause, Column, NamedRelationRef, QueryStatement, Subquery, WithStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table # 定义 CTE cte_query = Subquery(dialect, "SELECT id, name FROM users WHERE status = ?", ("active",)) -cte = CTEExpression(dialect, name="active_users", query=cte_query) +cte = CTEClause(dialect, name="active_users", query=cte_query) # 使用 CTE 的主查询 -main_query = QueryExpression( +main_query = QueryStatement( dialect, select=[Column(dialect, "name")], - from_=TableExpression(dialect, "active_users") + from_=NamedRelationRef(dialect, Table(dialect, "active_users")) ) # 组合成 WITH 查询 -with_query = WithQueryExpression(dialect, ctes=[cte], main_query=main_query) +with_query = WithStatement(dialect, ctes=[cte], query=main_query) sql, params = with_query.to_sql() -# sql: 'WITH "active_users" AS (SELECT id, name FROM users WHERE status = ?) SELECT "name" FROM "active_users"' -# params: ("active",) +# sql: 'WITH "active_users" AS ((SELECT id, name FROM users WHERE status = ?)) ' +# 'SELECT "name" FROM "active_users"' +# params: ('active',) ``` ## 集合操作 (Set Operations) 将两个或多个查询的结果组合成单个结果集。 -### SetOperationExpression +### SetOperation -支持 `UNION`、`UNION ALL`、`INTERSECT` 和 `EXCEPT`。 +支持 `UNION`、`UNION ALL`、`INTERSECT` 和 `EXCEPT`。运算类型是 +`SetOperationKind` 的成员;`UNION ALL` 对应 `UNION_ALL` 成员。 ```python -from rhosocial.activerecord.backend.expression import SetOperationExpression, Subquery +from rhosocial.activerecord.backend.expression import Subquery +from rhosocial.activerecord.backend.expression.query_sources import ( + SetOperation, SetOperationKind, +) query1 = Subquery(dialect, "SELECT id FROM table1 WHERE col = ?", ("a",)) query2 = Subquery(dialect, "SELECT id FROM table2 WHERE col = ?", ("b",)) # UNION -union_op = SetOperationExpression( +union_op = SetOperation( dialect, + op=SetOperationKind.UNION, left=query1, - right=query2, - operation="UNION" + right=query2 ) sql, params = union_op.to_sql() # sql: '(SELECT id FROM table1 WHERE col = ?) UNION (SELECT id FROM table2 WHERE col = ?)' -# params: ("a", "b") +# params: ('a', 'b') # INTERSECT -intersect_op = SetOperationExpression( +intersect_op = SetOperation( dialect, + op=SetOperationKind.INTERSECT, left=query1, - right=query2, - operation="INTERSECT" + right=query2 ) sql, params = intersect_op.to_sql() # sql: '(SELECT id FROM table1 WHERE col = ?) INTERSECT (SELECT id FROM table2 WHERE col = ?)' -# params: ("a", "b") +# params: ('a', 'b') ``` ## JSON 函数 用于处理 JSON 数据的表达式。 -### JSONDocumentExpression / JSONTextExpression +### JSONDocumentValue / JSONTextValue 表示 JSON 路径提取。运算符决定用哪个类:`->` 得到文档,是 -`JSONDocumentExpression`;`->>` 得到文本,是 `JSONTextExpression`。 +`JSONDocumentValue`;`->>` 得到文本,是 `JSONTextValue`。 之所以是两个类而不是一个类加一个拼写参数,是因为两者得到的值是不同的 种类 —— 文档可以继续向下索引,文本不行。 ```python from rhosocial.activerecord.backend.expression import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) col = Column(dialect, "data") # 从 JSON 路径提取值(文本) -json_extract = JSONTextExpression(dialect, col, "$.user.name", operation="->>") +json_extract = JSONTextValue(dialect, col, "$.user.name", operation="->>") # 嵌套路径仍然是文档,可以继续往下走 -nested = JSONDocumentExpression(dialect, col, "$.user", operation="->") +nested = JSONDocumentValue(dialect, col, "$.user", operation="->") sql, params = json_extract.to_sql() -# sql: '"data"->>?', 或特定方言的等价形式,如 'JSON_EXTRACT("data", ?)' -# params: ("$.user.name",) +# sql: 'JSON_UNQUOTE(JSON_EXTRACT("data", '$.user.name'))' +# params: () +# 直接拼运算符的方言会渲染成 '"data"->>?' 的形式。 ``` ## 表函数 (Table Functions) diff --git a/docs/zh_CN/backend/expression/clauses.md b/docs/zh_CN/backend/expression/clauses.md index 77c6b853a..1056638e9 100644 --- a/docs/zh_CN/backend/expression/clauses.md +++ b/docs/zh_CN/backend/expression/clauses.md @@ -4,16 +4,14 @@ ## JoinType (连接类型) -SQL 连接类型的枚举。 +SQL 连接类型的枚举。存储值是裸词(`inner`、`left` 等),JOIN 关键字由方言拼出。 ```python -class JoinType(Enum): - INNER = "INNER JOIN" - LEFT = "LEFT JOIN" - RIGHT = "RIGHT JOIN" - FULL = "FULL JOIN" - CROSS = "CROSS JOIN" - # ... +from rhosocial.activerecord.backend.expression.query_parts import JoinType + +# 成员值是裸词,JOIN 关键字由方言拼出 +for member in JoinType: + print(f"{member.name:<12} {member.value}") ``` ## WhereClause (WHERE 子句) @@ -21,26 +19,48 @@ class JoinType(Enum): 表示 SQL 查询中的 `WHERE` 子句。 ```python -class WhereClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", condition: "bases.SQLPredicate"): ... - - def and_(self, predicate: "bases.SQLPredicate") -> 'WhereClause': ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import WhereClause + +# 库实际声明的构造函数与 and_() +print(inspect.signature(WhereClause.__init__)) +print(inspect.signature(WhereClause.and_)) ``` ### 用法 ```python -# WHERE status = 'active' -# where_clause = WhereClause( -# dialect, -# condition=Column(dialect, "status") == Literal(dialect, "active") -# ) -# -> ('WHERE "status" = ?', ('active',)) +from rhosocial.activerecord.backend.expression import ( + Column, ComparisonOperator, ComparisonPredicate, IntegerColumn, Literal, + StringColumn, +) +from rhosocial.activerecord.backend.expression.query_parts import WhereClause + +# WHERE status = 'active' —— 带类型的列会把普通值自动包成 Literal +where_clause = WhereClause( + dialect, + condition=StringColumn(dialect, "status") == "active" +) +sql, params = where_clause.to_sql() +# sql: 'WHERE "status" = ?' +# params: ('active',) + +# 同一个谓词也可以用显式节点拼出来 +where_clause = WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "status"), + Literal(dialect, "active"), + ) +) -# Chaining AND conditions -# WHERE age > 18 AND status = 'active' -# where_clause.and_(Column(dialect, "age") > Literal(dialect, 18)) -# -> ('WHERE "status" = ? AND "age" > ?', ('active', 18)) +# 链式 AND 条件:WHERE status = 'active' AND age > 18 +where_clause = where_clause.and_(IntegerColumn(dialect, "age") > 18) +sql, params = where_clause.to_sql() +# sql: 'WHERE "status" = ? AND "age" > ?' +# params: ('active', 18) ``` ## GroupByHavingClause (GROUP BY 和 HAVING 子句) @@ -48,23 +68,27 @@ class WhereClause(bases.BaseExpression): 表示组合的 `GROUP BY` 和 `HAVING` 子句。强制执行 `HAVING` 需要 `GROUP BY` 的规则。 ```python -class GroupByHavingClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - group_by: Optional[List["bases.BaseExpression"]] = None, - having: Optional["bases.SQLPredicate"] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause + +print(inspect.signature(GroupByHavingClause.__init__)) ``` ### 用法 ```python +from rhosocial.activerecord.backend.expression import Column, FunctionCall +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause + # GROUP BY category HAVING COUNT(id) > 5 -# group_clause = GroupByHavingClause( -# dialect, -# group_by=[Column(dialect, "category")], -# having=FunctionCall(dialect, "COUNT", Column(dialect, "id")) > Literal(dialect, 5) -# ) -# -> ('GROUP BY "category" HAVING COUNT("id") > ?', (5,)) +group_clause = GroupByHavingClause( + dialect, + group_by=[Column(dialect, "category")], + having=FunctionCall(dialect, "COUNT", Column(dialect, "id")) > 5 +) +sql, params = group_clause.to_sql() +# sql: 'GROUP BY "category" HAVING COUNT("id") > ?' +# params: (5,) ``` ## OrderByClause (ORDER BY 子句) @@ -72,26 +96,29 @@ class GroupByHavingClause(bases.BaseExpression): 表示 `ORDER BY` 子句。 ```python -class OrderByClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - expressions: List[Union[ - "bases.BaseExpression", - Tuple["bases.BaseExpression", str] - ]]): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +print(inspect.signature(OrderByClause.__init__)) ``` ### 用法 -```python# ORDER BY created_at DESC, name ASC -# order_by = OrderByClause( -# dialect, -# expressions=[ -# (Column(dialect, "created_at"), "DESC"), -# (Column(dialect, "name"), "ASC") -# ] -# ) -# -> ('ORDER BY "created_at" DESC, "name" ASC', ()) +```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +# ORDER BY created_at DESC, name ASC +order_by = OrderByClause( + dialect, + expressions=[ + (Column(dialect, "created_at"), "DESC"), + (Column(dialect, "name"), "ASC") + ] +) +sql, params = order_by.to_sql() +# sql: 'ORDER BY "created_at" DESC, "name" ASC' +# params: () ``` ## LimitOffsetClause (LIMIT 和 OFFSET 子句) @@ -99,19 +126,22 @@ class OrderByClause(bases.BaseExpression): 表示 `LIMIT` 和 `OFFSET` 子句。 ```python -class LimitOffsetClause(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - limit: Optional[Union[int, "bases.BaseExpression"]] = None, - offset: Optional[Union[int, "bases.BaseExpression"]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + +print(inspect.signature(LimitOffsetClause.__init__)) ``` ### 用法 ```python +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + # LIMIT 10 OFFSET 20 -# limit_clause = LimitOffsetClause(dialect, limit=10, offset=20) -# -> ('LIMIT ? OFFSET ?', (10, 20)) +limit_clause = LimitOffsetClause(dialect, limit=10, offset=20) +sql, params = limit_clause.to_sql() +# sql: 'LIMIT ? OFFSET ?' +# params: (10, 20) ``` ## QualifyClause (QUALIFY 子句) @@ -119,19 +149,27 @@ class LimitOffsetClause(bases.BaseExpression): 表示 `QUALIFY` 子句(在 Snowflake/BigQuery 等数据库中用于过滤窗口函数结果)。 ```python -class QualifyClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", condition: "bases.SQLPredicate"): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import QualifyClause + +print(inspect.signature(QualifyClause.__init__)) ``` ### 用法 ```python -# QUALIFY ROW_NUMBER() OVER (...) <= 3 -# qualify_clause = QualifyClause( -# dialect, -# condition=row_number_func <= Literal(dialect, 3) -# ) -# -> ('QUALIFY ROW_NUMBER() OVER (...) <= ?', (3,)) +from rhosocial.activerecord.backend.expression import FunctionCall +from rhosocial.activerecord.backend.expression.query_parts import QualifyClause + +# QUALIFY ROW_NUMBER() <= 3 +row_number_func = FunctionCall(dialect, "ROW_NUMBER") +qualify_clause = QualifyClause( + dialect, + condition=row_number_func <= 3 +) +sql, params = qualify_clause.to_sql() +# sql: 'QUALIFY ROW_NUMBER() <= ?' +# params: (3,) ``` ## ForUpdateClause (FOR UPDATE 子句) @@ -139,89 +177,108 @@ class QualifyClause(bases.BaseExpression): 表示 `FOR UPDATE` 锁定子句。 ```python -class ForUpdateClause(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", - of_columns: Optional[List[Union[str, "bases.BaseExpression"]]] = None, - nowait: bool = False, - skip_locked: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + +print(inspect.signature(ForUpdateClause.__init__)) ``` ### 用法 ```python -# FOR UPDATE OF users NOWAIT +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + +# FOR UPDATE OF "users" NOWAIT lock_clause = ForUpdateClause( dialect, of_columns=[Column(dialect, "users")], nowait=True ) +sql, params = lock_clause.to_sql() +# sql: 'FOR UPDATE OF "users" NOWAIT' +# params: () ``` -## JoinExpression (JOIN 表达式) +## JoinClause (JOIN 子句) -表示 `JOIN` 表达式。支持链式调用。 +表示 `JOIN` 子句。支持链式调用。 ```python -class JoinExpression(bases.BaseExpression): - def __init__(self, - dialect: "SQLDialectBase", - left_table: Union[str, "core.TableExpression", "core.Subquery", "QueryExpression", "JoinExpression"], - right_table: Union[str, "core.TableExpression", "core.Subquery", "QueryExpression", "JoinExpression"], - join_type: str = "JOIN", - condition: Optional["bases.SQLPredicate"] = None, - using: Optional[List[str]] = None, - natural: bool = False, - alias: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import JoinClause - def join(self, right_table, join_type="JOIN", condition=None, using=None, natural=False, alias=None) -> "JoinExpression": ... - def inner_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def left_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def right_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def full_join(self, right_table, condition=None, using=None, alias=None) -> "JoinExpression": ... - def cross_join(self, right_table, alias=None) -> "JoinExpression": ... +print(inspect.signature(JoinClause.__init__)) +for name in ("join", "inner_join", "left_join", "right_join", "full_join", "cross_join"): + print(name, inspect.signature(getattr(JoinClause, name))) ``` ### 用法 ```python -# users JOIN orders ON users.id = orders.user_id -# join_expr = JoinExpression( -# dialect, -# left_table=TableExpression(dialect, "users"), -# right_table=TableExpression(dialect, "orders"), -# join_type="INNER JOIN", -# condition=Column(dialect, "id", "users") == Column(dialect, "user_id", "orders") -# ) -# -> ('"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id"', ()) +from rhosocial.activerecord.backend.expression import ( + Column, ComparisonOperator, ComparisonPredicate, NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import JoinClause -# Chaining: users JOIN orders ... LEFT JOIN products ... -# complex_join = join_expr.left_join( -# right_table=TableExpression(dialect, "products"), -# condition=Column(dialect, "product_id", "orders") == Column(dialect, "id", "products") -# ) -# -> ('"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id" LEFT JOIN "products" ON "orders"."product_id" = "products"."id"', ()) +# users JOIN orders ON users.id = orders.user_id +join_expr = JoinClause( + dialect, + left_table=NamedRelationRef(dialect, Table(dialect, "users")), + right_table=NamedRelationRef(dialect, Table(dialect, "orders")), + join_type="INNER JOIN", + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "users"), + Column(dialect, "user_id", "orders"), + ) +) +sql, params = join_expr.to_sql() +# sql: '"users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id"' +# params: () + +# 链式调用:users JOIN orders ... LEFT JOIN products ... +complex_join = join_expr.left_join( + right_table=NamedRelationRef(dialect, Table(dialect, "products")), + condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "product_id", "orders"), + Column(dialect, "id", "products"), + ) +) +sql, params = complex_join.to_sql() +# sql: '("users" INNER JOIN "orders" ON "users"."id" = "orders"."user_id") ' +# 'LEFT JOIN "products" ON "orders"."product_id" = "products"."id"' +# params: () ``` -## GroupingExpression (分组表达式) +## GroupingClause (分组子句) 表示 `ROLLUP`、`CUBE` 等分组操作。 ```python -class GroupingExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", - operation: str, - expressions: List["bases.BaseExpression"]): ... +import inspect +from rhosocial.activerecord.backend.expression.query_parts import GroupingClause + +print(inspect.signature(GroupingClause.__init__)) ``` ### 用法 ```python -# ROLLUP(year, month) -rollup = GroupingExpression( +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.query_parts import GroupingClause + +# ROLLUP("year", "month") +rollup = GroupingClause( dialect, operation="ROLLUP", expressions=[Column(dialect, "year"), Column(dialect, "month")] ) +sql, params = rollup.to_sql() +# sql: 'ROLLUP("year", "month")' +# params: () ``` diff --git a/docs/zh_CN/backend/expression/core.md b/docs/zh_CN/backend/expression/core.md index 8e4684f07..3802b3401 100644 --- a/docs/zh_CN/backend/expression/core.md +++ b/docs/zh_CN/backend/expression/core.md @@ -14,19 +14,20 @@ - [StringMixin](#stringmixin) - [TypeCastingMixin](#typecastingmixin) - [核心表达式](#核心表达式) - - [TableExpression](#tableexpression) + - [表源与关系对象 (Table Sources and Relation Objects)](#表源与关系对象-table-sources-and-relation-objects) - [Column](#column) - [Literal](#literal) - - [WildcardExpression](#wildcardexpression) + - [AllColumnsWildcard](#allcolumnswildcard) - [Subquery](#subquery) - [运算符](#运算符) - [Identifier](#identifier) - [SQLOperation](#sqloperation) - [BinaryExpression](#binaryexpression) - - [BinaryArithmeticExpression](#binaryarithmeticexpression) + - [ArithmeticOperation](#arithmeticoperation) - [UnaryExpression](#unaryexpression) - - [RawSQLExpression](#rawsqlexpression) + - [RawSQL](#rawsqlexpression) - [RawSQLPredicate](#rawsqlpredicate) +- [COLLATE 表达式](#collate-表达式) ## 基类 (Base Classes) @@ -37,6 +38,8 @@ `ToSQLProtocol` 定义了任何可以转换为 SQL 的对象的契约。 ```python +from typing import Protocol, Tuple + class ToSQLProtocol(Protocol): def to_sql(self) -> Tuple[str, tuple]: """ @@ -53,12 +56,13 @@ class ToSQLProtocol(Protocol): `BaseExpression` 是所有表达式组件的根基类。它以结构方式满足 `ToSQLProtocol`,持有**逐节点**的方言引用,并且**只在基类集中实现一次** `to_sql()`——子类绝不复写它,只通过只读的 `format_method` 属性声明渲染自己的方言格式化方法名。 ```python +from typing import Optional, Tuple + class BaseExpression: def __init__(self, dialect: Optional["SQLDialectBase"] = None): # 方言是约定俗成的第一个参数,但可选—— # 允许推迟绑定(例如模型声明期没有方言; # ActiveRecord 的 DDL 推导会逐节点绑定)。 - self._inline_literals = False self.dialect = dialect @property @@ -92,16 +96,18 @@ class BaseExpression: `SQLPredicate` 表示计算结果为布尔值的表达式(例如 `WHERE` 条件)。它混入了 `LogicalMixin` 以支持 `&` (AND)、`|` (OR) 和 `~` (NOT) 运算符。 ```python +from rhosocial.activerecord.backend.expression import bases, mixins + class SQLPredicate(mixins.LogicalMixin, BaseExpression): pass ``` -### SQLValueExpression +### SQLValue -`SQLValueExpression` 表示计算结果为标量值的表达式(例如列、字面量、函数结果)。 +`SQLValue` 表示计算结果为标量值的表达式(例如列、字面量、函数结果)。 ```python -class SQLValueExpression(BaseExpression): +class SQLValue(BaseExpression): pass ``` @@ -114,91 +120,161 @@ class SQLValueExpression(BaseExpression): 表示 SQL 查询中的字面量值。它会自动处理参数绑定。 ```python -class Literal(mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", value: Any, *, inline_literals: bool = False): ... - - # 默认:绑定参数模式 - # Literal(dialect, "active") - # -> ('?', ('active',)) +import inspect +from rhosocial.activerecord.backend.expression import Literal + +# 库实际声明的构造函数 +print(inspect.signature(Literal.__init__)) + +# 默认:绑定参数模式 +# Literal(dialect, "active") +# -> ('?', ('active',)) - # 内联模式:值直接嵌入 SQL(用于 DDL 子句) - # Literal(dialect, "active", inline_literals=True) - # -> ("'active'", ()) +# 内联模式:值直接嵌入 SQL(用于 DDL 子句) +# Literal(dialect, "active", inline=True) +# -> ("'active'", ()) ``` #### 内联字面量 默认情况下,`Literal` 渲染为**绑定参数**(`?` 或 `%s`,取决于方言),遵循 DB-API 2.0 (PEP 249) 规范。这是 DML 语句(`INSERT`、`SELECT`、`UPDATE`、`DELETE`)的安全首选模式。 -然而,**DDL 子句**(`DEFAULT`、`CHECK`、分区边界、部分索引 `WHERE`)**不接受绑定参数**——数据库引擎要求直接使用 SQL 文本字面量。对于这些情况,请在 `Literal` 节点上设置 `inline_literals=True`: +然而,**DDL 子句**(`DEFAULT`、`CHECK`、分区边界、部分索引 `WHERE`)**不接受绑定参数**——数据库引擎要求直接使用 SQL 文本字面量。对于这些情况,请在 `Literal` 节点上设置 `inline=True`: ```python from rhosocial.activerecord.backend.expression import ( - Literal, Column, ComparisonPredicate, - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + Column, + ColumnCheckConstraint, + ColumnConstraint, + ColumnDefaultConstraint, + ColumnDefinition, + ComparisonOperator, + ComparisonPredicate, + Literal, ) from rhosocial.activerecord.backend.expression.types import IntegerType # DDL:DEFAULT 值必须内联 -ColumnDefinition(dialect, +ColumnDefinition( + dialect, name="age", data_type=IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0, inline_literals=True), # -> DEFAULT 0 + ColumnConstraint( + dialect, + branch=ColumnDefaultConstraint( + dialect, + value=Literal(dialect, 0, inline=True), # -> DEFAULT 0 + ), ), ], ) # DDL:CHECK 条件——比较值必须内联 -ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", - Column(dialect, "score"), - Literal(dialect, 0, inline_literals=True), # -> CHECK ("score" >= 0) +ColumnConstraint( + dialect, + branch=ColumnCheckConstraint( + dialect, + condition=ComparisonPredicate( + dialect, + ComparisonOperator.GTE, + Column(dialect, "score"), + Literal(dialect, 0, inline=True), # -> CHECK ("score" >= 0) + ), ), ) ``` -`inline_literals` 开关是**逐节点控制**的——每个 `Literal` 独立决定。单个谓词可以混合内联和绑定参数值: +`inline` 开关是**逐节点控制**的——每个 `Literal` 独立决定。单个谓词可以混合内联和绑定参数值: ```python +from rhosocial.activerecord.backend.expression import ComparisonPredicate, Literal + pred = ComparisonPredicate( dialect, "=", - Literal(dialect, "a", inline_literals=True), # 内联 - Literal(dialect, "b"), # 绑定参数 + Literal(dialect, "a", inline=True), # 内联 + Literal(dialect, "b"), # 绑定参数 ) pred.to_sql() # -> ("'a' = ?", ("b",)) ``` -> **安全提示**:当值可能受终端用户影响时,内联字面量存在 SQL 注入风险。仅对开发者声明的常量(DDL 默认值、派生自 Schema 的值)使用 `inline_literals=True`。 +> **安全提示**:当值可能受终端用户影响时,内联字面量存在 SQL 注入风险。仅对开发者声明的常量(DDL 默认值、派生自 Schema 的值)使用 `inline=True`。 ### Column 表示列引用,可选地包含表限定符和别名。 ```python -class Column(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", name: str, table: Optional[str] = None, alias: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import Column + +print(inspect.signature(Column.__init__)) + +# 示例: users.name +# Column(dialect, "name", table="users") +``` + +裸 `Column` 只是**引用**。比较、算术与字符串运算符已从它上面移除:`Column(...) == Literal(...)` 现在只是普通的 Python `bool`,而不是谓词。请改用带类型的列——`StringColumn`、`IntegerColumn`、`NumericColumn`、`TimestampColumn` 或 `BooleanColumn`——它们会把普通 Python 值包装成 `Literal`,因此能构造真正的谓词。当两侧都已经是表达式时,可以使用显式节点 `ComparisonPredicate(dialect, ComparisonOperator.EQ, left, right)`。 + +### 表源与关系对象 (Table Sources and Relation Objects) + +`FROM` 子句能命名的对象都是**表源 (table source)**,但并非每个源都是持久化对象。这两个问题由两棵独立的树回答: + +- **持久化关系**——`Table`、`View`、`MaterializedView`、`ForeignTable`,以及图表的 `NodeTable` / `EdgeTable`——位于 `rhosocial.activerecord.backend.expression.objects`。它们只承载目录身份(名称,以及可选的 catalog 与 schema)。 +- **仅查询期存在的源**——`NamedRelationRef`、`DerivedTableSource`、`ValuesTableSource`、`TableFunctionSource`、`JsonTableSource`、`XmlTableSource` 与 `GraphTableSource`——位于 `rhosocial.activerecord.backend.expression.sources`。它们的行只在一条语句的生命周期内存在;`FROM json_table(...)` 根本没有目录身份。 + +两棵树通过**组合**而非继承连接:`NamedRelationRef` 持有一个关系对象。别名与时态选项属于源,因为 `FROM users AS u` 只在一条查询内重命名一行,并不改动目录中的任何东西。 + +```python +import inspect +from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable, + ForeignTable, + MaterializedView, + NodeTable, + Table, + View, +) - # 示例: users.name - # Column(dialect, "name", table="users") - # -> ('"users"."name"', ()) +for relation in (Table, View, MaterializedView, ForeignTable, NodeTable, EdgeTable): + print(relation.__name__, inspect.signature(relation.__init__)) ``` -### TableExpression +```python +import inspect +from rhosocial.activerecord.backend.expression.sources import ( + DerivedTableSource, + GraphTableSource, + JsonTableSource, + NamedRelationRef, + TableFunctionSource, + ValuesTableSource, + XmlTableSource, +) -表示表或视图,可选地包含别名和时态选项(例如 FOR SYSTEM_TIME)。 +for source in ( + NamedRelationRef, + DerivedTableSource, + ValuesTableSource, + TableFunctionSource, + JsonTableSource, + XmlTableSource, + GraphTableSource, +): + print(source.__name__, inspect.signature(source.__init__)) +``` ```python -class TableExpression(mixins.AliasableMixin, bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", name: str, alias: Optional[str] = None, temporal_options: Optional[Dict[str, Any]] = None): ... +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + +# FROM "users" +NamedRelationRef(dialect, Table(dialect, "users")) +# -> ('"users"', ()) - # 示例: FROM users AS u - # TableExpression(dialect, "users", alias="u") - # -> ('"users" AS "u"', ()) +# FROM "users" AS "u" —— 别名位于源上 +NamedRelationRef(dialect, Table(dialect, "users"), alias="u") +# -> ('"users" AS "u"', ()) ``` ### FunctionCall @@ -208,8 +284,10 @@ class TableExpression(mixins.AliasableMixin, bases.BaseExpression): 当 `niladic=True` 且没有参数时,函数调用生成不含括号的 SQL(如 `CURRENT_TIMESTAMP` 而非 `CURRENT_TIMESTAMP()`)。根据 SQL:2003 标准,某些值函数是零元的(niladic),在无参数调用时不得使用括号。当零元函数有参数时(如 `CURRENT_TIMESTAMP(6)`),括号照常生成。 ```python -class FunctionCall(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", func_name: str, *args: "bases.BaseExpression", is_distinct: bool = False, alias: Optional[str] = None, niladic: bool = False): ... +import inspect +from rhosocial.activerecord.backend.expression import FunctionCall + +print(inspect.signature(FunctionCall.__init__)) ``` **零元函数用法:** @@ -228,26 +306,33 @@ FunctionCall(dialect, 'NOW') # -> ('NOW()', ()) ``` -### WildcardExpression +### AllColumnsWildcard -表示通配符 (`*`) 或表通配符 (`table.*`)。请优先使用此类而不是 `Literal("*")`。 +表示 select list 的通配符 (`*`),可经行源引用限定为 `rel.*`。请优先使用此类而不是 `Literal("*")`。 ```python -class WildcardExpression(bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", table: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import AllColumnsWildcard + +print(inspect.signature(AllColumnsWildcard.__init__)) ``` +裸 `*` 的展开范围由所在子句决定(select list 为 FROM 全体源,RETURNING 为 DML 目标表)。 +行计数是另一个契约:`COUNT(*)` 的参数是 `RowCountWildcard`,它不带 relation,只渲染裸 `*`。 + ### Subquery 表示子查询表达式。它可以包装 SQL 字符串、`(sql, params)` 元组或其他表达式。 ```python -class Subquery(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", query_input: Union[str, tuple, "BaseExpression"], query_params: Optional[tuple] = None, alias: Optional[str] = None): ... +import inspect +from rhosocial.activerecord.backend.expression import Subquery - # 示例: (SELECT id FROM users WHERE age > ?) - # Subquery(dialect, "SELECT id FROM users WHERE age > ?", (25,)) - # -> ('(SELECT id FROM users WHERE age > ?)', (25,)) +print(inspect.signature(Subquery.__init__)) + +# 示例: (SELECT id FROM users WHERE age > ?) +# Subquery(dialect, "SELECT id FROM users WHERE age > ?", (25,)) +# -> ('(SELECT id FROM users WHERE age > ?)', (25,)) ``` ## Mixins @@ -265,37 +350,43 @@ Column(dialect, "name").as_("user_name") ### ComparisonMixin -启用标准 Python 比较运算符 (`==`, `!=`, `>`, `<`, `>=`, `<=`) 生成 SQL 谓词。 +启用标准 Python 比较运算符 (`==`, `!=`, `>`, `<`, `>=`, `<=`) 生成 SQL 谓词。该 Mixin 由带类型的列和表达式包装类承载;裸 `Column` 故意不携带它。 ```python -# age >= 18 -Column(dialect, "age") >= 18 +from rhosocial.activerecord.backend.expression import ( + Column, IntegerColumn, StringColumn, +) + +# age >= 18 —— 带类型的列会把 18 包装成 Literal +IntegerColumn(dialect, "age") >= 18 # status == 'active' -Column(dialect, "status") == "active" +StringColumn(dialect, "status") == "active" # IS NULL / IS NOT NULL Column(dialect, "email").is_null() Column(dialect, "email").is_not_null() # IN / NOT IN -Column(dialect, "status").in_(["active", "pending"]) -Column(dialect, "status").not_in(["banned", "deleted"]) +StringColumn(dialect, "status").in_(["active", "pending"]) +StringColumn(dialect, "status").not_in(["banned", "deleted"]) # BETWEEN -Column(dialect, "age").between(18, 65) +IntegerColumn(dialect, "age").between(18, 65) ``` ### ArithmeticMixin -启用标准 Python 算术运算符 (`+`, `-`, `*`, `/`, `%`) 生成 SQL 算术表达式。 +启用标准 Python 算术运算符 (`+`, `-`, `*`, `/`, `%`) 生成 SQL 算术表达式。算术运算需要数值类型的列,它同时为另一操作数提供字面量。 ```python +from rhosocial.activerecord.backend.expression import IntegerColumn, NumericColumn + # price * 0.9 -Column(dialect, "price") * 0.9 +NumericColumn(dialect, "price") * 0.9 # (count + 1) -Column(dialect, "count") + 1 +IntegerColumn(dialect, "count") + 1 ``` ### LogicalMixin @@ -303,11 +394,15 @@ Column(dialect, "count") + 1 启用标准 Python 位运算符 (`&`, `|`, `~`) 生成 SQL 逻辑谓词 (`AND`, `OR`, `NOT`)。 ```python +from rhosocial.activerecord.backend.expression import ( + BooleanColumn, IntegerColumn, StringColumn, +) + # (age >= 18) AND (active = true) -(Column(dialect, "age") >= 18) & (Column(dialect, "active") == True) +(IntegerColumn(dialect, "age") >= 18) & (BooleanColumn(dialect, "active") == True) # NOT (status = 'banned') -~(Column(dialect, "status") == "banned") +~(StringColumn(dialect, "status") == "banned") ``` ### StringMixin @@ -315,11 +410,13 @@ Column(dialect, "count") + 1 提供字符串特定的方法,如 `like` 和 `ilike`。 ```python +from rhosocial.activerecord.backend.expression import StringColumn + # name LIKE 'John%' -Column(dialect, "name").like("John%") +StringColumn(dialect, "name").like("John%") # email ILIKE '%@gmail.com' -Column(dialect, "email").ilike("%@gmail.com") +StringColumn(dialect, "email").ilike("%@gmail.com") ``` ### TypeCastingMixin @@ -327,42 +424,52 @@ Column(dialect, "email").ilike("%@gmail.com") 提供类型转换能力,允许表达式转换为不同的 SQL 类型。遵循 Expression-Dialect 分离架构,实际的 SQL 语法由各后端的 `format_cast_expression` 方法生成。 ```python -# 基本类型转换 -Column(dialect, "price").cast("integer") -# PostgreSQL 生成: "price"::integer -# 其他后端生成: CAST("price" AS integer) +from rhosocial.activerecord.backend.expression import NumericColumn, StringColumn +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType -# 带类型修饰符的转换 -Column(dialect, "name").cast("VARCHAR(100)") +# 基本类型转换 —— 目标类型是 DataType 对象,绝不是类型名字符串 +NumericColumn(dialect, "price").cast(IntegerType(dialect)) +# PostgreSQL 生成: "price"::INTEGER +# 其他后端生成: CAST("price" AS INTEGER) + +# 类型修饰符属于 DataType 对象 +StringColumn(dialect, "name").cast(VarCharType(dialect, length=100)) # PostgreSQL 生成: "name"::VARCHAR(100) # 链式类型转换 -Column(dialect, "amount").cast("money").cast("numeric").cast("float8") -# PostgreSQL 生成: "amount"::money::numeric::float8 +StringColumn(dialect, "amount").cast(VarCharType(dialect, length=20)).cast(IntegerType(dialect)) +# -> CAST(CAST("amount" AS VARCHAR(20)) AS INTEGER) # 类型转换支持链式调用 -expr = Column(dialect, "value").cast("numeric") -result = expr * 1.1 # 算术运算 -predicate = expr > 100 # 比较运算 +expr = NumericColumn(dialect, "value").cast(IntegerType(dialect)) +result = expr * 1.1 # 算术运算 +predicate = expr > 100 # 比较运算 ``` **重要说明**: - `TypeCastingMixin` 不直接拼接 SQL 字符串 -- 类型修饰符(如 `VARCHAR(100)`)应直接包含在类型字符串中 +- 目标类型始终是 `DataType` 实例(`IntegerType(dialect)`、`VarCharType(dialect, length=100)` 等);类型名字符串会在构造时被拒绝 +- 类型修饰符(如 `VARCHAR(100)`)通过 `DataType` 参数表达,例如 `VarCharType(dialect, length=100)` - 实际的 SQL 语法由 dialect 的 `format_cast_expression` 方法决定 - PostgreSQL 使用 `::` 语法,其他数据库使用 `CAST()` 函数 ## 核心表达式 -### TableExpression +### FROM 中的表源 (Table Sources in FROM) -表示 SQL 查询中的表或视图。 +关系对象只承载目录身份;源包装它,并携带别名(以及任何时态选项)。 ```python -from rhosocial.activerecord.backend.expression import TableExpression +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + +# 持久化关系:只有目录身份,没有别名 +relation = Table(dialect, "users") +relation.to_sql() # -> ('"users"', ()) -# FROM users AS u -table = TableExpression(dialect, "users", alias="u") +# 查询源:包装关系并携带别名 +source = NamedRelationRef(dialect, relation, alias="u") +source.to_sql() # -> ('"users" AS "u"', ()) ``` ### Column @@ -387,18 +494,22 @@ from rhosocial.activerecord.backend.expression import Literal lit = Literal(dialect, 100) ``` -### WildcardExpression +### AllColumnsWildcard 表示 `*` 或 `table.*`。 ```python -from rhosocial.activerecord.backend.expression import WildcardExpression +from rhosocial.activerecord.backend.expression import AllColumnsWildcard +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef # SELECT * -wildcard = WildcardExpression(dialect) +wildcard = AllColumnsWildcard(dialect) # SELECT users.* -wildcard = WildcardExpression(dialect, table="users") +wildcard = AllColumnsWildcard( + dialect, relation=NamedRelationRef(dialect, Table(dialect, "users")) +) ``` ### Subquery @@ -406,10 +517,15 @@ wildcard = WildcardExpression(dialect, table="users") 表示子查询表达式。 ```python -from rhosocial.activerecord.backend.expression import Subquery, QueryExpression +from rhosocial.activerecord.backend.expression import Column, QueryStatement, Subquery +from rhosocial.activerecord.backend.expression.objects import Table + +query = QueryStatement( + dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users") +) # (SELECT ...) AS sub -sub = Subquery(dialect, query_expr, alias="sub") +sub = Subquery(dialect, query, alias="sub") ``` ## 运算符和表达式 (Operators and Expressions) @@ -421,8 +537,10 @@ sub = Subquery(dialect, query_expr, alias="sub") 表示通用的 SQL 标识符(例如表名、列名)。 ```python -class Identifier(mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", name: str): ... +import inspect +from rhosocial.activerecord.backend.expression import Identifier + +print(inspect.signature(Identifier.__init__)) ``` ### SQLOperation @@ -430,10 +548,10 @@ class Identifier(mixins.ComparisonMixin, bases.SQLValueExpression): 表示通用的函数调用风格的操作。 ```python -from rhosocial.activerecord.backend.expression import SQLOperation +from rhosocial.activerecord.backend.expression import Literal, SQLOperation -# 示例: custom_op(arg1, arg2) -op = SQLOperation(dialect, "custom_op", arg1, arg2) +# 示例: custom_op(?, ?) +op = SQLOperation(dialect, "custom_op", Literal(dialect, 1), Literal(dialect, 2)) ``` ### BinaryExpression @@ -441,8 +559,10 @@ op = SQLOperation(dialect, "custom_op", arg1, arg2) 表示标准的二元运算符(例如 `=`, `!=`, `LIKE`)。 ```python -class BinaryExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "bases.BaseExpression", right: "bases.BaseExpression"): ... +import inspect +from rhosocial.activerecord.backend.expression import BinaryExpression + +print(inspect.signature(BinaryExpression.__init__)) ``` ### UnaryExpression @@ -450,30 +570,38 @@ class BinaryExpression(bases.BaseExpression): 表示一元运算符(例如 `NOT`, `-`)。 ```python -class UnaryExpression(bases.BaseExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, operand: "bases.BaseExpression", pos: str = 'before'): ... +import inspect +from rhosocial.activerecord.backend.expression import UnaryExpression + +print(inspect.signature(UnaryExpression.__init__)) ``` -### BinaryArithmeticExpression +### ArithmeticOperation 表示具有运算符优先级处理的二元算术运算。 +`op` 是 `ArithmeticOperator` 的成员,其存储值是**运算词**:`add` / `sub` / `mul` / `div` / `mod`。方言格式化函数再把词映射成该后端书写的字符(见 `OPERATOR_SYMBOLS`),因此工件里承载的是语言中立的词,而不是某个词法器写下的符号。词集封闭,未知词在构造处即被拒绝。 + ```python -class BinaryArithmeticExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "bases.SQLValueExpression", right: "bases.SQLValueExpression"): ... +import inspect +from rhosocial.activerecord.backend.expression import ArithmeticOperation + +print(inspect.signature(ArithmeticOperation.__init__)) ``` ### 原始表达式 (Raw Expressions) **警告**:这些类应谨慎使用,因为它们允许原始 SQL 注入。确保所有输入都是受信任的或已正确清理。 -#### RawSQLExpression +#### RawSQL 表示作为值表达式的原始 SQL 字符串。 ```python -class RawSQLExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", expression: str, params: tuple = ()): ... +import inspect +from rhosocial.activerecord.backend.expression import RawSQL + +print(inspect.signature(RawSQL.__init__)) ``` #### RawSQLPredicate @@ -489,25 +617,26 @@ pred = RawSQLPredicate(dialect, "EXISTS (SELECT 1 FROM log WHERE log.user_id = u ## COLLATE 表达式 -`CollateExpression`(定义在 `rhosocial.activerecord.backend.expression.collation`)为 SQL 值表达式应用显式排序规则。 +`CollateFunction`(定义在 `rhosocial.activerecord.backend.expression.collation`)为 SQL 值表达式应用显式排序规则。 ```python +from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.collation import collate # 基本用法:为列指定排序规则 expr = collate(Column(dialect, "name"), "Chinese_PRC_CI_AS") sql, params = expr.to_sql() -# sql: '"name" COLLATE "Chinese_PRC_CI_AS"' +# sql: '"name" COLLATE Chinese_PRC_CI_AS' # params: () # 链式操作:排序规则后的表达式仍支持所有 Mixin 操作 expr = collate(Column(dialect, "name"), "NOCASE") == "alice" sql, params = expr.to_sql() -# sql: '"name" COLLATE "NOCASE" = ?' +# sql: '"name" COLLATE NOCASE = ?' # params: ("alice",) ``` -`CollateExpression` 混入了 `AliasableMixin`、`ArithmeticMixin`、`ComparisonMixin`、`StringMixin` 和 `TypeCastingMixin`,可以像普通 SQL 值表达式一样参与运算、比较和别名。 +`CollateFunction` 混入了 `AliasableMixin`、`ArithmeticMixin`、`ComparisonMixin`、`StringPatternPredicateMixin` 和 `TypeCastingMixin`,可以像普通 SQL 值表达式一样参与运算、比较、模式匹配和别名。 ### 方言集成 diff --git a/docs/zh_CN/backend/expression/extending.md b/docs/zh_CN/backend/expression/extending.md index ac6b385ca..31278273f 100644 --- a/docs/zh_CN/backend/expression/extending.md +++ b/docs/zh_CN/backend/expression/extending.md @@ -27,15 +27,15 @@ class MyExpression(BaseExpression): # 无需手动实现 get_params(),默认实现会自动提取 ``` -核心中的 `CollateExpression` 就是 `**kwargs` 的例子:它把 `**collation_options` 收集进 `self.collation_options: dict`,于是 `get_params()` 会把 `binary`、`pad` 等作为顶层参数返回,重建时再把它们当关键字参数传回。 +核心中的 `CollateFunction` 就是 `**kwargs` 的例子:它把 `**collation_options` 收集进 `self.collation_options: dict`,于是 `get_params()` 会把 `binary`、`pad` 等作为顶层参数返回,重建时再把它们当关键字参数传回。 若某个参数按约定无法解析,会跳过并发出警告。这条警告就是诊断信号——它意味着 `__init__` 把状态存在了 `get_params()` 看不到的名字上,而修复永远在 `__init__` 里。 ### 为什么没有"自定义 get_params()" -`get_params()` 不是扩展点:重写它是缺陷,不是定制。核心在 `tests/.../dummy2/test_expression_contract.py::test_no_get_params_override` 里无例外地强制这一点——每个已注册的表达式类都必须沿用通用实现。(该测试允许的唯一例外,是做真正的"原始值 ↔ 归一化值"往返改写的 `VAR_POSITIONAL` 类,而核心注册表里没有任何一个属于这种情况。) +`get_params()` 不是扩展点:重写它是缺陷,不是定制。核心在 `tests/.../dummy2/test_expression_artifact_contract.py::test_no_get_params_override` 里无例外地强制这一点——每个已注册的表达式类都必须沿用通用实现。(该测试允许的唯一例外,是做真正的"原始值 ↔ 归一化值"往返改写的 `VAR_POSITIONAL` 类,而核心注册表里没有任何一个属于这种情况。) -原因是结构性的,不是风格问题。往返是靠"用 `get_params()` 返回的内容去调用构造函数"重建的,所以这两半必须彼此吻合。而重写恰恰是它们开始走偏的地方:这个类发出的键会被构造函数改名、合并或拒绝,`_reconstruct()` 再把由此产生的 `TypeError` 包装成 `ExpressionDeserializationError`,状态就悄悄丢了。各后端包曾各自积累过重写实现,而它们存在的理由全都是同一个:`__init__` 把调用方传进来的东西改了名或合并了——而一旦按收到的名字原样保存,这些重写就全都不再必要。 +原因是结构性的,不是风格问题。往返是靠"用 `get_params()` 返回的内容去调用构造函数"重建的,所以这两半必须彼此吻合。而重写恰恰是它们开始走偏的地方:这个类发出的键会被构造函数改名、合并或拒绝,状态就悄悄丢了——解码器只按名字把值交出去,并不替结果把关。各后端包曾各自积累过重写实现,而它们存在的理由全都是同一个:`__init__` 把调用方传进来的东西改了名或合并了——而一旦按收到的名字原样保存,这些重写就全都不再必要。 当通用实现的结果不是你想要的时,修复始终在 `__init__` 里,且只有三种形态: @@ -49,18 +49,31 @@ class MyExpression(BaseExpression): ### 自动注册 -内置表达式通过 `_auto_register_builtins()` 自动注册到 `ExpressionRegistry`。 +内置表达式不需要你做任何事。框架**一次性**装配默认注册表——遍历表达式包,遇到的每个节点都按 kind 登记。**没有**任何函数需要调用,也**没有**任何清单需要维护。 -### 手动注册 +### 注册你自己的 -自定义表达式需要手动注册: +`ExpressionRegistry` 构造出来是空的——这正是它能被交给调用方、只放行你选定内容的前提。`register_node` 是**实例方法**,kind 由类派生: ```python -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry -ExpressionRegistry.register(MyExpression) +allowed = ExpressionRegistry() +allowed.register_node(MyExpression) # kind 为 "my_expression" ``` +枚举**根本不需要登记**。`(str, Enum)` 的成员就是一个 JSON 字符串,没有东西需要解析——封闭集合声明在类里,各语言也各自声明自己的。 + +有两个 kind 不在默认表里:`RawSQL` 与 `RawSQLPredicate`,因为 `to_sql()` 原样输出调用方担保过的文本,而工件不该能选择它。它们设了 `REGISTER_BY_DEFAULT = False`;登记其中任何一个与登记其它 kind 是同一个调用,交给信任自己工件的调用方。 + +然后把它传给读侧: + +```python +deserialize(artifact, dialect, registry=allowed) +``` + +注册表只管**读方**能产出什么。写侧不查表,所以这里的一切都不影响 `serialize`。 + ## 四类特殊情况 下面这四种情况,就是开发者会去写自定义 `get_params()` 的常见理由。它们没有一个需要重写:每一种都在 `__init__` 里解决。 @@ -123,68 +136,83 @@ class ColumnSetExpression(BaseExpression): class TreeNodeExpression(BaseExpression): def __init__(self, dialect, name, parent_id=None): super().__init__(dialect) - self._name = name - self._parent_id = parent_id # 标识,而非对象 + self.name = name + self.parent_id = parent_id # 标识,而非对象 # 由树遍历器解析;没有同名参数,因此永不进入序列化 self._parent = None ``` -如果父节点确实必须随节点一起传递,就把它做成普通的嵌套表达式参数——`{"__expr__": ...}` 正是为此存在的。这样一来,树自然就是有限的。 +如果父节点确实必须随节点一起传递,就把它做成普通的嵌套表达式参数——一个普通的 `parent`,持有另一个表达式,序列化时就是一个嵌套的 `{kind, params}` 节点。这样一来,树自然就是有限的。 ## IntrospectionExpression 约定 -`IntrospectionExpression` 子类(如 `TableListExpression`)把构造函数与同名 fluent setter 结合在一起。通用路径的两处机制已经考虑到了这一点: +内省节点把构造函数与 fluent setter 结合在一起,而这两者过去想要同一个名字。现在不想要了——因为参数必须能按自己的名字被读到,工件才谈得上可重建: ```python -class TableListExpression(IntrospectionExpression): +class TableListQueryStatement(IntrospectionExpression): def __init__(self, dialect, schema=None, include_views=True, include_system=False, table_type=None): super().__init__(dialect, schema) - self._include_views = include_views - self._include_system = include_system - self._table_type = table_type + self.include_views = include_views # 参数自己的名字 + self.include_system = include_system + self.table_type = table_type - def include_views(self, value: bool = True): - self._include_views = value # 与读取参数同一个属性 + def with_include_views(self, value: bool = True): # 绝不叫 include_views + self.include_views = value return self ``` -- **两种写法都存在时,可调用的那个不是状态。** 若某参数同时存在 `self.foo` 与 `self._foo`,且公开属性是个 fluent 方法,则取私有属性的值。 +- **只有一种写法,就是参数自己的名字。** `get_params()` 读的是 `getattr(self, name)`:没有 `_{name}` 探测,也没有「可调用的那个不是状态」这条规则要套用。类没存下来的参数会抛 `AttributeError`,并指明是哪个类、哪个参数——这才是值得听见的缺陷,而不是一条没人读的警告。 +- **fluent setter 叫 `with_*`。** 叫 `include_views` 的 setter 会占掉值本身需要的名字,于是类只能在「写 `self.include_views`(把方法遮住)」与「把值藏到 `get_params()` 得四处去找的地方」之间二选一。 - **未赋值的可选参数会输出为 `null`,而不是被省略。** 这是安全的:反序列化会把它传回去,构造函数的默认值随之生效。真正不安全的是丢掉一个取值与其默认值不同的参数——那才是往返丢掉的状态。 ## 错误契约 -所有经过 `_reconstruct()` 的路径,`TypeError` 都会被包装为 `ExpressionDeserializationError`。这确保调用方只需捕获一种异常类型。 +读侧能拒绝的一切都以 `ExpressionDeserializationError` 浮现,所以调用方只捕获这一种类型: -## 序列化格式约定(键名保留) +``` +ExpressionDeserializationError +├── ArtifactError —— 工件本身不合法 +└── 注册表拒绝,被改述 —— 该 kind 在此不被接纳 +``` -`get_params()` 返回的**值**在任何深度上都不得包含以下保留键名,否则反序列化行为未定义——反序列化会把它们当作框架标记来读: +`ArtifactError` 是**子类**,所以 `except ExpressionDeserializationError` 两者皆覆盖。`RegistryError` 不会到达调用方:解码层把它改述成自己的词汇,因为注册表是更底层的东西,不该知道读方捕获什么。 -- `__expr__`:用于标记嵌套表达式 -- `__tuple__`:用于标记元组 -- `__value__` / `__vdc__`:用于标记 codec 编码的标量值与 dataclass 值 +你自己构造函数抛出的 `TypeError` **不会**被替你捕获。携带 `{"name": 123}` 的工件,在构造函数不做检查时会毫无怨言地重建。要紧的话就在 `__init__` 里校验——反序列化的职责是按名字把值交出去,而不是判断它们可不可接受。 -你自己的顶层键来自 `__init__` 的参数名(以及合并进来的 `**kwargs` 额外参数),所以绝不要把参数命名为保留键。对于键不由你控制的不透明载荷,危险在载荷内部:那些键会被原样复制进 spec,给参数换个名字对此毫无帮助。要么在构造时校验,要么把载荷存成文本。 +## 参数约定(JSON 标量) -```python -_RESERVED = ("__expr__", "__tuple__", "__value__", "__vdc__") +**保留键已经一个都没有了。** 工件是普通 JSON,所以参数的取值必须本来就是 JSON +说得出来的东西:null、布尔、数字、字符串、数组,或对象。 -# 错误示例 - 含有 "__expr__" 的载荷会被当成嵌套表达式读回来 +这给自定义表达式两项义务、一个选项。 + +- **封闭取值集合取字符串枚举。** 声明成 `(str, Enum)`,并设 `__str__ = str.__str__`, + 让成员**就是**它渲染出来的那个词——单靠 `(str, Enum)` 做不到,`Enum.__str__` 会赢。 + 这样线上形态就是成员的值,即一个 JSON 字符串,而封闭集合声明在代码里,各语言也都能 + 各自声明。不继承 `str` 的枚举根本写不出去。 +- **送进数据库的值放进 `Literal`。** 这是唯一允许出现 JSON 拼不出的类型的位置; + 由 `LiteralKind` 的成员指明那种文本。 +- **不透明载荷是危险所在。** 键不由你控制的 `dict` 会被原样复制进工件,而给参数换个名字 + 对它的**内容**毫无帮助。一个恰好读起来像节点(`kind` 加 `params`)的键会被当成节点读回来。 + 要么在构造时校验,要么把载荷存成文本。 + +```python +# 错误示例 - 键不由你控制的 dict 参数 class RawJsonStorageExpr(BaseExpression): def __init__(self, dialect, data: dict): super().__init__(dialect) - self._data = data + self.data = data # 里面若有 {"kind": ..., "params": ...} 会被当成节点读回来 -# 正确示例 - 载荷在进入 spec 之前就被拒绝 +# 正确示例 - 在进入工件之前就拒绝这种形状 class SafeJsonStorageExpr(BaseExpression): def __init__(self, dialect, json_data: dict): super().__init__(dialect) - for key in json_data: - if key in _RESERVED: - raise ValueError(f"{key!r} is a reserved serialization key") - self._json_data = json_data + if set(json_data) & {"kind", "params"}: + raise ValueError("载荷不得携带形似节点的键") + self.json_data = json_data ``` ## 相关文档 - [核心文档](./serialization.md):序列化机制说明 -- [格式参考](./format-reference.md):ExpressionSpec 完整规范 \ No newline at end of file +- [格式参考](./format-reference.md):工件完整规范 \ No newline at end of file diff --git a/docs/zh_CN/backend/expression/format-reference.md b/docs/zh_CN/backend/expression/format-reference.md index b54aed316..55ca0e90b 100644 --- a/docs/zh_CN/backend/expression/format-reference.md +++ b/docs/zh_CN/backend/expression/format-reference.md @@ -1,110 +1,118 @@ -# ExpressionSpec 格式参考 +# 工件格式参考 -本文档定义序列化格式的完整规范。 +`serialize()` 写什么、`deserialize()` 读什么的完整规格。这里的每一条都由代码强制,而不只是被描述。 -## ExpressionSpec 结构 +> **可运行**:`docs/examples/expression_artifact.py` 把整份走了一遍。 -表达式序列化的目标格式: +## 节点结构 + +工件是**裸节点,无信封**(§4.11.3): ```python { - "type": str, # 必填:类名 - "module": str, # 必填:完整模块路径 - "params": dict # 必填:构造参数 + "kind": str, # 必需:节点的 kind + "params": dict # 必需:构造参数 } ``` -### 字段约束 - | 字段 | 类型 | 约束 | |------|------|------| -| `type` | str | 非空,有效 Python 标识符 | -| `module` | str | 可导入的模块路径,类必须可从该模块导入 | -| `params` | dict | 键名与目标类 `__init__` 参数名一致 | +| `kind` | str | 非空;必须在读方的注册表里 | +| `params` | dict | 键名须匹配目标类的 `__init__` 参数名 | -## 标记类型 +没有 `type`、没有 `module`、没有版本、生产者、捕获的方言、连接指纹。节点携带 `kind` 与 `params` 之外的任何东西都会**按名拒绝**,所以残留的信封字段不可能蒙混过关。 -### __expr__ 标记(保留键) +## kind 从哪来 -嵌套表达式使用 `__expr__` 包装: +`kind` **由类名算出**,从不存储:类名的 snake_case 形式,连续大写算一个词(`JSONValue` → `json_value`)。类身上没有 kind 字段,所以两者不可能走偏。 -```python -{ - "type": "WhereClause", - "params": { - "condition": { - "__expr__": { - "type": "ComparisonPredicate", - "module": "...", - "params": {...} - } - } - } -} -``` +**字面量**是唯一一个 kind 不等于类名的节点。一个节点承载全部值,而线上仍把它们分开命名——`null_value`、`integer_value`、`date_value`……因为日期与字符串的字节形态相同,只有 kind 能区分。值 kind 才是节点在线上的身份;`Literal` 只是它们全部解析到的那个类。 -**语义**:反序列化时,`__expr__` 的值会被递归反序列化为表达式对象。 +## 参数 -**重要**:`__expr__` 是框架保留键,用户自定义表达式的 `get_params()` 返回值中**不得使用此键**。 +`params` 承载除 **`dialect`** 外的全部构造参数——方言是上下文,永不随行:同一份工件可以交给读方手上的任意方言对象重渲染。 -### __tuple__ 标记(保留键) +每个参数恰好是两种形态之一: -元组使用 `__tuple__` 标记: +- **语言无关标量**(JSON 原生,或下列标记之一) +- **表达式**,写成就地嵌套的 `{kind, params}` 节点 -```python -{ - "type": "OrderByClause", - "params": { - "order_by_items": [ - {"__tuple__": [Column(...), "DESC"]} - ] - } -} -``` +**没有第三种形态**,嵌套节点外面也**没有标记**——形状本身就是信号。`set`、驱动句柄、闭包、pickle 出来的 blob 都没有语言无关编码,因此不可序列化;要把它们建模成表达式。 + +## 标记类型 + +**一个也没有。** -**语义**:反序列化时,`__tuple__` 数组被还原为 Python tuple。 +工件里只有 JSON:null、布尔、数字、字符串、数组,以及要么是节点、要么是普通映射的对象。嵌套表达式是裸的 `{kind, params}` 节点;JSON 说不出的值则以它的 kind 所命名的文本形式出行。 -**重要**:`__tuple__` 是框架保留键,用户自定义表达式的 `get_params()` 返回值中**不得使用此键**。 +这不是简化。标记是一种**派发**——读方必须认出这个键并知道它意味着什么——而派发是每种语言都要重新实现一遍的东西。工件要求跨实现逐字节一致,所以凡是读方需要派发的东西,都必须是各语言天然一致的东西。JSON 标量不需要一致:每种语言本来就能解析它。 ## 标量类型规则 -| Python 类型 | JSON 表示 | 备注 | +| Python 类型 | JSON 形态 | 说明 | |-------------|-----------|------| -| `str` | string | | -| `int` | number | | -| `float` | number | | +| `str` | string | `str` 基枚举就是其中之一——它**就是**它的值 | +| `int` | number | 任意精度;读不下的一方必须显式失败 | +| `float` | number | 要活下来的是 IEEE 754 位型,不是文本 | | `bool` | boolean | | | `None` | null | | -| `list` | array | 递归序列化元素 | -| `dict` | object | 递归序列化值 | -| `tuple` | object | 使用 `__tuple__` 标记 | -| `BaseExpression` | object | 使用 `__expr__` 标记 | -| `set` | - | 不支持,需转为 list | -| 其他自定义对象 | object | 通过已注册的 `__value__` codec 编码,或在 `__init__` 中转换 | +| `list` | array | 元素递归处理 | +| `tuple` | array | 是「对」还是「列表」由参数声明的类型说明,构造函数负责还原 | +| `dict` | object | 值递归处理 | +| `BaseExpression` | object | 裸嵌套节点 | +| `set` | — | 不支持;转成 list | +| 驱动句柄、闭包、任意对象 | — | 无语言无关编码;建模成表达式 | + +### 绑定值是唯一的例外 + +**送进数据库**的参数可以持有 JSON 无从拼写的类型。它由 `Literal` 包裹,其 kind 指明那种文本: + +| kind | 承载 | 线上形态 | +|------|------|---------| +| `binary_value` | `bytes` | base64 字符串 | +| `interval_value` | `timedelta` | ISO 8601 字符串——天/时/分/秒,因为年月长度不固定 | +| `decimal_value` | `Decimal` | 精确十进制文本,绝不降级成浮点 | +| `timestamp_value` / `date_value` / `time_value` | 日期与时间 | ISO 文本 | +| `uuid_value` | `UUID` | 规范文本 | + +kind 在这里存在的全部理由就在于此:日期与字符串的字节形态相同,只有 `date_value` 能区分。构造函数收下标量并转换。 ## 序列化流程 -1. 调用 `expression.get_params()` 获取参数字典 -2. 递归处理 `params` 中的值: - - `BaseExpression` → `{"__expr__": serialize(expr)}` - - `tuple` → `{"__tuple__": [...]}` - - `list` → 递归处理元素 - - `dict` → 递归处理值 - - 其他 → 直接传递 -3. 添加 `type`(类名)和 `module`(模块路径) +1. 读 `get_params()`——每个 `__init__` 参数读一个同名属性,`dialect` 除外。 +2. 逐个处理取值:`BaseExpression` 变嵌套节点;list、tuple、dict 递归;其余必须已经是 JSON 标量。 +3. 产出 `{"kind": ..., "params": ...}`。 + +**不查注册表。** 节点自带 kind,所以只做序列化的调用方永远不必构造查找表。 ## 反序列化流程 -1. 验证 `type` 和 `module` 存在 -2. 通过 `ExpressionRegistry.lookup()` 查找类 -3. 调用 `_reconstruct(cls, dialect, params)` 重建实例(仅限反序列化路径——渲染路径 `to_sql()` 从不重建) -4. 递归处理 `params` 中的嵌套结构: - - `__expr__` → `deserialize(value, dialect)` - - `__tuple__` → tuple(递归处理元素) - - 其他 → 递归处理 +工件是**不可信输入**(§4.11.4)。 + +1. 拒绝携带 `kind`、`params` 之外任何字段的节点。 +2. 通过注册表白名单解析 `kind`。没有 import 路径,也没有反射:一个名字要么已登记,要么按名拒绝。 +3. 要求 `params` 是对象,并把每个键与解析出的类的 `__init__` 对照——无法识别的参数与缺失的必填参数各自按名失败。 +4. 拒绝声明了 `**kwargs` 的类:一个身份取决于「来了多少个额外参数」的参数,在读侧无法按名匹配。 +5. 递归解码各取值,**按名字**交给构造函数。解码器自己不做类型判断——校验属于 `__init__`,那里能指名是哪个类、哪个参数。 +6. 限制嵌套深度(默认 64)与总长度。 + +### 失败 + +每一次拒绝都指名它拒绝了什么,因为一份你并非亲手写出的产物,没法靠猜来调试: + +| 情况 | 消息 | +|------|------| +| kind 未被接纳 | `kind 'x' is not in the kind whitelist (N kinds registered)` | +| 残留信封字段 | `artifact node has unexpected field(s) ['type']` | +| 缺失 kind | `artifact node has no usable 'kind' (got None)` | +| 无法识别的参数 | `kind 'x' does not accept parameter(s) [...]` | +| 缺失必填参数 | `kind 'x' is missing required parameter(s) [...]` | +| 声明了 `**kwargs` | `kind 'x' accepts **kwargs, so its parameters cannot be matched by name` | +| 嵌套过深 | `artifact nesting depth exceeds 64` | + +全部是 `ArtifactError`,而它继承自 `ExpressionDeserializationError`——所以调用方捕获这一种即可。`RegistryError` 不会外泄:解码层把它改述成自己的词汇,因为注册表是更底层的东西,不该知道读方捕获什么。 ## 相关文档 - [核心文档](./serialization.md) -- [扩展指南](./extending.md) -- [迁移说明](./migration.md) \ No newline at end of file +- [扩展指南](./extending.md) \ No newline at end of file diff --git a/docs/zh_CN/backend/expression/functions.md b/docs/zh_CN/backend/expression/functions.md index e453e4daf..4f68261db 100644 --- a/docs/zh_CN/backend/expression/functions.md +++ b/docs/zh_CN/backend/expression/functions.md @@ -13,27 +13,24 @@ 聚合调用就是 `FunctionCall`;聚合工厂会在构造时设置其 `is_aggregate` 事实。它支持 `FILTER` (WHERE) 子句和 `OVER (...)` 窗口子句。 ```python -class FunctionCall(mixins.AliasableMixin, mixins.ArithmeticMixin, mixins.ComparisonMixin, mixins.StringPatternPredicateMixin, mixins.TypeCastingMixin, mixins.ResultTypeMixin, bases.SQLValueExpression): - def __init__(self, dialect: "SQLDialectBase", func_name: str, *args, - is_distinct: bool = False, alias: Optional[str] = None, - niladic: bool = False, filter_predicate: "SQLPredicate" = None, - window_spec: Union["WindowSpecification", str] = None, - is_aggregate: bool = False): ... +import inspect +from rhosocial.activerecord.backend.expression import FunctionCall - def filter(self, predicate: "SQLPredicate") -> 'FunctionCall': - """应用 FILTER (WHERE ...) 子句到聚合表达式。""" - ... - - def over(self, window_spec: Union["WindowSpecification", str]) -> 'FunctionCall': - """为调用附加 OVER (...) 子句。""" - ... +print(inspect.signature(FunctionCall.__init__)) +print(inspect.signature(FunctionCall.filter)) +print(inspect.signature(FunctionCall.over)) ``` **示例:** ```python -# 带 FILTER 的 COUNT +from rhosocial.activerecord.backend.expression import ( + BooleanColumn, Column, StringColumn, +) +from rhosocial.activerecord.backend.expression.functions import count, sum_ + +# 带 FILTER 的 COUNT —— 带类型的列会构造真正的谓词 active_count = count(dialect, "*", alias="active_count").filter( - Column(dialect, "status") == Literal(dialect, "active") + StringColumn(dialect, "status") == "active" ) sql, params = active_count.to_sql() # sql: 'COUNT(*) FILTER (WHERE "status" = ?) AS "active_count"' @@ -41,9 +38,9 @@ sql, params = active_count.to_sql() # 带多个链式过滤器的 SUM high_value_sum = sum_(dialect, Column(dialect, "amount"), alias="high_value_sum").filter( - Column(dialect, "category") == Literal(dialect, "sales") + StringColumn(dialect, "category") == "sales" ).filter( - Column(dialect, "priority") == Literal(dialect, True) + BooleanColumn(dialect, "priority") == True ) sql, params = high_value_sum.to_sql() # sql: 'SUM("amount") FILTER (WHERE "category" = ? AND "priority" = ?) AS "high_value_sum"' @@ -55,16 +52,23 @@ sql, params = high_value_sum.to_sql() 创建 `COUNT` 聚合函数调用。 ```python -def count(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"] = "*", is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import count + +# 库实际声明的函数签名 +print(inspect.signature(count)) ``` **使用规则:** -- `count(dialect, "*")` -> `COUNT(*)` -- `count(dialect, Column(dialect, "id"))` -> `COUNT(id)` -- `count(dialect, "val")` -> `COUNT('val')` (字面量) +- `count(dialect, "*")` -> `COUNT(*)`(`RowCountWildcard`) +- `count(dialect, Column(dialect, "id"))` -> `COUNT("id")` +- `count(dialect, "val")` -> `COUNT("val")`(列引用) **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import count + # 基本 COUNT(*) c1 = count(dialect, "*") sql, params = c1.to_sql() @@ -83,11 +87,18 @@ sql, params = c2.to_sql() 创建 `SUM` 聚合函数调用。注意尾随下划线以避免与 Python 内置的 `sum` 冲突。 ```python -def sum_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sum_ + +# 库实际声明的函数签名 +print(inspect.signature(sum_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import sum_ + s = sum_(dialect, Column(dialect, "amount")) sql, params = s.to_sql() # sql: 'SUM("amount")' @@ -99,11 +110,18 @@ sql, params = s.to_sql() 创建 `AVG` 聚合函数调用。 ```python -def avg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import avg + +# 库实际声明的函数签名 +print(inspect.signature(avg)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import avg + a = avg(dialect, Column(dialect, "score"), is_distinct=True, alias="avg_score") sql, params = a.to_sql() # sql: 'AVG(DISTINCT "score") AS "avg_score"' @@ -115,11 +133,18 @@ sql, params = a.to_sql() 创建 `MIN` 聚合函数调用。注意尾随下划线。 ```python -def min_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import min_ + +# 库实际声明的函数签名 +print(inspect.signature(min_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import min_ + m = min_(dialect, Column(dialect, "price")) sql, params = m.to_sql() # sql: 'MIN("price")' @@ -131,11 +156,18 @@ sql, params = m.to_sql() 创建 `MAX` 聚合函数调用。注意尾随下划线。 ```python -def max_(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import max_ + +# 库实际声明的函数签名 +print(inspect.signature(max_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import max_ + m = max_(dialect, Column(dialect, "price")) sql, params = m.to_sql() # sql: 'MAX("price")' @@ -149,11 +181,18 @@ sql, params = m.to_sql() 创建 `CONCAT` 标量函数调用。 ```python -def concat(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import concat + +# 库实际声明的函数签名 +print(inspect.signature(concat)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import concat + c = concat(dialect, Column(dialect, "first_name"), " ", Column(dialect, "last_name")) sql, params = c.to_sql() # sql: 'CONCAT("first_name", ?, "last_name")' @@ -165,11 +204,18 @@ sql, params = c.to_sql() 创建 `COALESCE` 标量函数调用。 ```python -def coalesce(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import coalesce + +# 库实际声明的函数签名 +print(inspect.signature(coalesce)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import coalesce + c = coalesce(dialect, Column(dialect, "nickname"), Column(dialect, "real_name"), "Anonymous") sql, params = c.to_sql() # sql: 'COALESCE("nickname", "real_name", ?)' @@ -181,11 +227,18 @@ sql, params = c.to_sql() 创建 `LENGTH` 标量函数调用。 ```python -def length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import length + +# 库实际声明的函数签名 +print(inspect.signature(length)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import length + l = length(dialect, Column(dialect, "description")) sql, params = l.to_sql() # sql: 'LENGTH("description")' @@ -197,11 +250,18 @@ sql, params = l.to_sql() 创建 `SUBSTRING` 标量函数调用。 ```python -def substring(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], start: Union[int, "bases.BaseExpression"], length: Optional[Union[int, "bases.BaseExpression"]] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import substring + +# 库实际声明的函数签名 +print(inspect.signature(substring)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import substring + # 指定长度 s1 = substring(dialect, Column(dialect, "text"), 1, 5) sql, params = s1.to_sql() @@ -220,11 +280,18 @@ sql, params = s2.to_sql() 创建 `TRIM` 标量函数调用。 ```python -def trim(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], chars: Optional[Union[str, "bases.BaseExpression"]] = None, direction: str = "BOTH") -> "operators.RawSQLExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import trim + +# 库实际声明的函数签名 +print(inspect.signature(trim)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import trim + # 默认 TRIM (去除两侧空格) t1 = trim(dialect, Column(dialect, "name")) sql, params = t1.to_sql() @@ -243,11 +310,18 @@ sql, params = t2.to_sql() 创建 `REPLACE` 标量函数调用。 ```python -def replace(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], pattern: Union[str, "bases.BaseExpression"], replacement: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import replace + +# 库实际声明的函数签名 +print(inspect.signature(replace)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import replace + r = replace(dialect, Column(dialect, "content"), "http", "https") sql, params = r.to_sql() # sql: 'REPLACE("content", ?, ?)' @@ -259,11 +333,18 @@ sql, params = r.to_sql() 创建 `UPPER` 标量函数调用。 ```python -def upper(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import upper + +# 库实际声明的函数签名 +print(inspect.signature(upper)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import upper + u = upper(dialect, Column(dialect, "email")) sql, params = u.to_sql() # sql: 'UPPER("email")' @@ -275,11 +356,18 @@ sql, params = u.to_sql() 创建 `LOWER` 标量函数调用。 ```python -def lower(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lower + +# 库实际声明的函数签名 +print(inspect.signature(lower)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import lower + l = lower(dialect, Column(dialect, "email")) sql, params = l.to_sql() # sql: 'LOWER("email")' @@ -291,11 +379,18 @@ sql, params = l.to_sql() 创建 `INITCAP` 标量函数调用 (首字母大写)。 ```python -def initcap(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import initcap + +# 库实际声明的函数签名 +print(inspect.signature(initcap)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import initcap + i = initcap(dialect, Column(dialect, "title")) sql, params = i.to_sql() # sql: 'INITCAP("title")' @@ -307,11 +402,18 @@ sql, params = i.to_sql() 创建 `LEFT` 标量函数调用。 ```python -def left(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import left + +# 库实际声明的函数签名 +print(inspect.signature(left)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import left + l = left(dialect, Column(dialect, "code"), 3) sql, params = l.to_sql() # sql: 'LEFT("code", ?)' @@ -323,11 +425,18 @@ sql, params = l.to_sql() 创建 `RIGHT` 标量函数调用。 ```python -def right(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import right + +# 库实际声明的函数签名 +print(inspect.signature(right)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import right + r = right(dialect, Column(dialect, "code"), 4) sql, params = r.to_sql() # sql: 'RIGHT("code", ?)' @@ -339,11 +448,18 @@ sql, params = r.to_sql() 创建 `LPAD` 标量函数调用。 ```python -def lpad(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], length: int, pad: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lpad + +# 库实际声明的函数签名 +print(inspect.signature(lpad)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import lpad + p = lpad(dialect, Column(dialect, "id"), 5, "0") sql, params = p.to_sql() # sql: 'LPAD("id", ?, ?)' @@ -355,11 +471,18 @@ sql, params = p.to_sql() 创建 `RPAD` 标量函数调用。 ```python -def rpad(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], length: int, pad: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rpad + +# 库实际声明的函数签名 +print(inspect.signature(rpad)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import rpad + p = rpad(dialect, Column(dialect, "id"), 5, " ") sql, params = p.to_sql() # sql: 'RPAD("id", ?, ?)' @@ -371,11 +494,18 @@ sql, params = p.to_sql() 创建 `REVERSE` 标量函数调用。 ```python -def reverse(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import reverse + +# 库实际声明的函数签名 +print(inspect.signature(reverse)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import reverse + r = reverse(dialect, Column(dialect, "text")) sql, params = r.to_sql() # sql: 'REVERSE("text")' @@ -387,11 +517,18 @@ sql, params = r.to_sql() 创建 `STRPOS` 标量函数调用 (查找子字符串位置)。 ```python -def strpos(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], substring: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import strpos + +# 库实际声明的函数签名 +print(inspect.signature(strpos)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import strpos + p = strpos(dialect, Column(dialect, "text"), "world") sql, params = p.to_sql() # sql: 'STRPOS("text", ?)' @@ -405,11 +542,17 @@ sql, params = p.to_sql() 创建 `ABS` 标量函数调用。注意尾随下划线以避免与 Python 内置的 `abs` 冲突。 ```python -def abs_(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import abs_ + +# 库实际声明的函数签名 +print(inspect.signature(abs_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import abs_ + a = abs_(dialect, -5) sql, params = a.to_sql() # sql: 'ABS(?)' @@ -421,15 +564,21 @@ sql, params = a.to_sql() 创建 `ROUND` 标量函数调用。注意尾随下划线。 ```python -def round_(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], decimals: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import round_ + +# 库实际声明的函数签名 +print(inspect.signature(round_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import round_ + r = round_(dialect, 3.14159, 2) sql, params = r.to_sql() # sql: 'ROUND(?, ?)' -# params: (3.14159, 2) +# params: (Decimal('3.14159'), 2) -- 数值字面量以 Decimal 绑定 ``` ### ceil @@ -437,15 +586,21 @@ sql, params = r.to_sql() 创建 `CEIL` 标量函数调用。 ```python -def ceil(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import ceil + +# 库实际声明的函数签名 +print(inspect.signature(ceil)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import ceil + c = ceil(dialect, 3.14) sql, params = c.to_sql() # sql: 'CEIL(?)' -# params: (3.14,) +# params: (Decimal('3.14'),) ``` ### floor @@ -453,15 +608,21 @@ sql, params = c.to_sql() 创建 `FLOOR` 标量函数调用。 ```python -def floor(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import floor + +# 库实际声明的函数签名 +print(inspect.signature(floor)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import floor + f = floor(dialect, 3.99) sql, params = f.to_sql() # sql: 'FLOOR(?)' -# params: (3.99,) +# params: (Decimal('3.99'),) ``` ### sqrt @@ -469,11 +630,17 @@ sql, params = f.to_sql() 创建 `SQRT` 标量函数调用。 ```python -def sqrt(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sqrt + +# 库实际声明的函数签名 +print(inspect.signature(sqrt)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import sqrt + s = sqrt(dialect, 16) sql, params = s.to_sql() # sql: 'SQRT(?)' @@ -485,11 +652,17 @@ sql, params = s.to_sql() 创建 `POWER` 标量函数调用。 ```python -def power(dialect: "SQLDialectBase", base: Union[int, float, "bases.BaseExpression"], exponent: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import power + +# 库实际声明的函数签名 +print(inspect.signature(power)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import power + p = power(dialect, 2, 3) sql, params = p.to_sql() # sql: 'POWER(?, ?)' @@ -501,11 +674,17 @@ sql, params = p.to_sql() 创建 `EXP` 标量函数调用。 ```python -def exp(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import exp + +# 库实际声明的函数签名 +print(inspect.signature(exp)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import exp + e = exp(dialect, 1) sql, params = e.to_sql() # sql: 'EXP(?)' @@ -517,11 +696,17 @@ sql, params = e.to_sql() 创建 `LOG` 标量函数调用。 ```python -def log(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], base: Optional[Union[int, float, "bases.BaseExpression"]] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import log + +# 库实际声明的函数签名 +print(inspect.signature(log)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import log + l = log(dialect, 100, 10) sql, params = l.to_sql() # sql: 'LOG(?, ?)' @@ -533,13 +718,19 @@ sql, params = l.to_sql() 创建三角函数调用。 ```python -def sin(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def cos(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def tan(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sin, cos, tan + +# 库实际声明的函数签名 +print(inspect.signature(sin)) +print(inspect.signature(cos)) +print(inspect.signature(tan)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import cos, sin, tan + s = sin(dialect, 0) c = cos(dialect, 0) t = tan(dialect, 0) @@ -552,11 +743,17 @@ t = tan(dialect, 0) 创建 `NOW` 标量函数调用。 ```python -def now(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import now + +# 库实际声明的函数签名 +print(inspect.signature(now)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import now + n = now(dialect) sql, params = n.to_sql() # sql: 'NOW()' @@ -568,12 +765,18 @@ sql, params = n.to_sql() 创建 `CURRENT_DATE` 零元值函数。根据 SQL:2003 标准,生成不含括号的 `CURRENT_DATE`。 ```python -def current_date(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_date + +# 库实际声明的函数签名 +print(inspect.signature(current_date)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import current_date + d = current_date(dialect) sql, params = d.to_sql() # sql: 'CURRENT_DATE' @@ -585,12 +788,18 @@ sql, params = d.to_sql() 创建 `CURRENT_TIME` 零元值函数。根据 SQL:2003 标准,生成不含括号的 `CURRENT_TIME`。 ```python -def current_time(dialect: "SQLDialectBase") -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_time + +# 库实际声明的函数签名 +print(inspect.signature(current_time)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import current_time + t = current_time(dialect) sql, params = t.to_sql() # sql: 'CURRENT_TIME' @@ -602,16 +811,23 @@ sql, params = t.to_sql() 创建提取日期部分的函数调用。 ```python -def year(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def month(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def day(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def hour(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def minute(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... -def second(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import year, month, day, hour, minute, second + +# 库实际声明的函数签名 +print(inspect.signature(year)) +print(inspect.signature(month)) +print(inspect.signature(day)) +print(inspect.signature(hour)) +print(inspect.signature(minute)) +print(inspect.signature(second)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import year + y = year(dialect, Column(dialect, "created_at")) sql, params = y.to_sql() # sql: 'YEAR("created_at")' @@ -623,15 +839,22 @@ sql, params = y.to_sql() 创建 `DATE_PART` 标量函数调用。 ```python -def date_part(dialect: "SQLDialectBase", field: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import date_part + +# 库实际声明的函数签名 +print(inspect.signature(date_part)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import date_part + p = date_part(dialect, "year", Column(dialect, "created_at")) sql, params = p.to_sql() -# sql: 'DATE_PART(?, "created_at")' -# params: ("year",) +# sql: 'EXTRACT(YEAR FROM "created_at")' +# params: () ``` ### date_trunc @@ -639,15 +862,22 @@ sql, params = p.to_sql() 创建 `DATE_TRUNC` 标量函数调用。 ```python -def date_trunc(dialect: "SQLDialectBase", precision: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import date_trunc + +# 库实际声明的函数签名 +print(inspect.signature(date_trunc)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import date_trunc + t = date_trunc(dialect, "month", Column(dialect, "created_at")) sql, params = t.to_sql() -# sql: 'DATE_TRUNC(?, "created_at")' -# params: ("month",) +# sql: 'DATE_TRUNC('month', "created_at")' +# params: () ``` ## 条件函数 (Conditional Functions) @@ -657,13 +887,31 @@ sql, params = t.to_sql() 创建 `CASE` 表达式。 ```python -def case(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.CaseExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import case + +# 库实际声明的函数签名 +print(inspect.signature(case)) ``` **示例:** ```python -c = case(dialect).when(Column(dialect, "status") == "active", "Active").else_("Inactive") +from rhosocial.activerecord.backend.expression import Literal, StringColumn +from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression +from rhosocial.activerecord.backend.expression.functions import case + +# 工厂创建一个空的 CASE 节点 +c = case(dialect) + +# 分支以 (条件, 结果) 对的形式在构造时传入 +c = CaseExpression( + dialect, + cases=[(StringColumn(dialect, "status") == "active", Literal(dialect, "Active"))], + else_result=Literal(dialect, "Inactive"), +) sql, params = c.to_sql() +# sql: 'CASE WHEN "status" = ? THEN ? ELSE ? END' +# params: ("active", "Active", "Inactive") ``` ### nullif @@ -671,11 +919,18 @@ sql, params = c.to_sql() 创建 `NULLIF` 标量函数调用。 ```python -def nullif(dialect: "SQLDialectBase", expr1: Union[str, "bases.BaseExpression"], expr2: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import nullif + +# 库实际声明的函数签名 +print(inspect.signature(nullif)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import nullif + n = nullif(dialect, Column(dialect, "value"), "N/A") sql, params = n.to_sql() # sql: 'NULLIF("value", ?)' @@ -687,11 +942,18 @@ sql, params = n.to_sql() 创建 `GREATEST` 标量函数调用。 ```python -def greatest(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import greatest + +# 库实际声明的函数签名 +print(inspect.signature(greatest)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import greatest + g = greatest(dialect, Column(dialect, "a"), Column(dialect, "b"), Column(dialect, "c")) sql, params = g.to_sql() # sql: 'GREATEST("a", "b", "c")' @@ -703,11 +965,18 @@ sql, params = g.to_sql() 创建 `LEAST` 标量函数调用。 ```python -def least(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import least + +# 库实际声明的函数签名 +print(inspect.signature(least)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import least + l = least(dialect, Column(dialect, "a"), Column(dialect, "b"), Column(dialect, "c")) sql, params = l.to_sql() # sql: 'LEAST("a", "b", "c")' @@ -721,11 +990,17 @@ sql, params = l.to_sql() 创建 `ROW_NUMBER` 窗口函数调用。 ```python -def row_number(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import row_number + +# 库实际声明的函数签名 +print(inspect.signature(row_number)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import row_number + r = row_number(dialect) sql, params = r.to_sql() # sql: 'ROW_NUMBER()' @@ -737,8 +1012,12 @@ sql, params = r.to_sql() 创建 `RANK` 和 `DENSE_RANK` 窗口函数调用。 ```python -def rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... -def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rank, dense_rank + +# 库实际声明的函数签名 +print(inspect.signature(rank)) +print(inspect.signature(dense_rank)) ``` ### lag, lead @@ -746,8 +1025,12 @@ def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "core. 创建 `LAG` 和 `LEAD` 窗口函数调用。 ```python -def lag(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], offset: Optional[int] = 1, default: Optional[Union[str, "bases.BaseExpression"]] = None, alias: Optional[str] = None) -> "core.FunctionCall": ... -def lead(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], offset: Optional[int] = 1, default: Optional[Union[str, "bases.BaseExpression"]] = None, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import lag, lead + +# 库实际声明的函数签名 +print(inspect.signature(lag)) +print(inspect.signature(lead)) ``` ### first_value, last_value, nth_value @@ -755,9 +1038,13 @@ def lead(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], of 创建值窗口函数调用。 ```python -def first_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... -def last_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], alias: Optional[str] = None) -> "core.FunctionCall": ... -def nth_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], n: int, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import first_value, last_value, nth_value + +# 库实际声明的函数签名 +print(inspect.signature(first_value)) +print(inspect.signature(last_value)) +print(inspect.signature(nth_value)) ``` ## JSON 函数 (JSON Functions) @@ -767,12 +1054,19 @@ def nth_value(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" 创建 JSON 提取函数调用。 ```python -def json_extract(dialect: "SQLDialectBase", expr: "bases.BaseExpression", path: str) -> "operators.BinaryExpression": ... -def json_extract_text(dialect: "SQLDialectBase", expr: "bases.BaseExpression", path: str) -> "operators.BinaryExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_extract, json_extract_text + +# 库实际声明的函数签名 +print(inspect.signature(json_extract)) +print(inspect.signature(json_extract_text)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import json_extract + j = json_extract(dialect, Column(dialect, "data"), "$.name") sql, params = j.to_sql() # 使用 -> 操作符提取 JSON 值 @@ -783,7 +1077,11 @@ sql, params = j.to_sql() 创建 `JSON_BUILD_OBJECT` 函数调用。 ```python -def json_build_object(dialect: "SQLDialectBase", *args: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_build_object + +# 库实际声明的函数签名 +print(inspect.signature(json_build_object)) ``` ### json_array_elements @@ -791,7 +1089,11 @@ def json_build_object(dialect: "SQLDialectBase", *args: Union[str, "bases.BaseEx 创建 `JSON_ARRAY_ELEMENTS` 函数调用。 ```python -def json_array_elements(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_array_elements + +# 库实际声明的函数签名 +print(inspect.signature(json_array_elements)) ``` ### json_objectagg, json_arrayagg @@ -799,8 +1101,12 @@ def json_array_elements(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseE 创建 JSON 聚合函数调用。 ```python -def json_objectagg(dialect: "SQLDialectBase", key_expr: Union[str, "bases.BaseExpression"], value_expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... -def json_arrayagg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import json_objectagg, json_arrayagg + +# 库实际声明的函数签名 +print(inspect.signature(json_objectagg)) +print(inspect.signature(json_arrayagg)) ``` ## 数组函数 (Array Functions) @@ -810,7 +1116,11 @@ def json_arrayagg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpress 创建 `ARRAY_AGG` 聚合函数调用。 ```python -def array_agg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import array_agg + +# 库实际声明的函数签名 +print(inspect.signature(array_agg)) ``` ### unnest @@ -818,7 +1128,11 @@ def array_agg(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" 创建 `UNNEST` 函数调用。 ```python -def unnest(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import unnest + +# 库实际声明的函数签名 +print(inspect.signature(unnest)) ``` ### array_length @@ -826,7 +1140,11 @@ def unnest(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) 创建 `ARRAY_LENGTH` 函数调用。 ```python -def array_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], dimension: int = 1) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import array_length + +# 库实际声明的函数签名 +print(inspect.signature(array_length)) ``` ## 类型转换函数 (Type Conversion Functions) @@ -836,12 +1154,21 @@ def array_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpressi 创建类型转换表达式。 ```python -def cast(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], type_name: str) -> "core.Column": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import cast + +# 库实际声明的函数签名 +print(inspect.signature(cast)) ``` **示例:** ```python -c = cast(dialect, Column(dialect, "value"), "INTEGER") +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import cast +from rhosocial.activerecord.backend.expression.types import IntegerType + +# 目标类型是 DataType 对象,绝不是类型名字符串 +c = cast(dialect, Column(dialect, "value"), IntegerType(dialect)) sql, params = c.to_sql() # sql: 'CAST("value" AS INTEGER)' # params: () @@ -852,7 +1179,11 @@ sql, params = c.to_sql() 创建 `TO_CHAR` 函数调用。 ```python -def to_char(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_char + +# 库实际声明的函数签名 +print(inspect.signature(to_char)) ``` ### to_number @@ -860,7 +1191,11 @@ def to_char(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], 创建 `TO_NUMBER` 函数调用。 ```python -def to_number(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_number + +# 库实际声明的函数签名 +print(inspect.signature(to_number)) ``` ### to_date @@ -868,7 +1203,11 @@ def to_number(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression" 创建 `TO_DATE` 函数调用。 ```python -def to_date(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], format: Optional[str] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import to_date + +# 库实际声明的函数签名 +print(inspect.signature(to_date)) ``` ## 分组函数 (Grouping Functions) @@ -878,11 +1217,18 @@ def to_date(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], 创建 `ROLLUP` 分组表达式。 ```python -def rollup(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import rollup + +# 库实际声明的函数签名 +print(inspect.signature(rollup)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import rollup + r = rollup(dialect, Column(dialect, "department"), Column(dialect, "region")) sql, params = r.to_sql() # sql: 'ROLLUP("department", "region")' @@ -894,7 +1240,11 @@ sql, params = r.to_sql() 创建 `CUBE` 分组表达式。 ```python -def cube(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import cube + +# 库实际声明的函数签名 +print(inspect.signature(cube)) ``` ### grouping_sets @@ -902,7 +1252,11 @@ def cube(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) 创建 `GROUPING SETS` 分组表达式。 ```python -def grouping_sets(dialect: "SQLDialectBase", *sets: List[Union[str, "bases.BaseExpression"]]) -> "query_parts.GroupingExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import grouping_sets + +# 库实际声明的函数签名 +print(inspect.signature(grouping_sets)) ``` ## 字符串连接操作符 (String Concatenation Operator) @@ -912,11 +1266,18 @@ def grouping_sets(dialect: "SQLDialectBase", *sets: List[Union[str, "bases.BaseE 使用 `||` 操作符创建字符串连接表达式。 ```python -def concat_op(dialect: "SQLDialectBase", *exprs: Union[str, "bases.BaseExpression"]) -> "operators.BinaryExpression": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import concat_op + +# 库实际声明的函数签名 +print(inspect.signature(concat_op)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import concat_op + # 连接两列 expr = concat_op(dialect, Column(dialect, "first_name"), Column(dialect, "last_name")) sql, params = expr.to_sql() @@ -941,11 +1302,17 @@ sql, params = expr.to_sql() 创建 `MOD` 函数调用 (取模运算)。 ```python -def mod(dialect: "SQLDialectBase", dividend: Union[int, float, "bases.BaseExpression"], divisor: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import mod + +# 库实际声明的函数签名 +print(inspect.signature(mod)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import mod + m = mod(dialect, 10, 3) sql, params = m.to_sql() # sql: 'MOD(?, ?)' @@ -957,11 +1324,17 @@ sql, params = m.to_sql() 创建 `SIGN` 函数调用 (返回 -1, 0, 或 1)。 ```python -def sign(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import sign + +# 库实际声明的函数签名 +print(inspect.signature(sign)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import sign + s = sign(dialect, -42) sql, params = s.to_sql() # sql: 'SIGN(?)' @@ -973,15 +1346,21 @@ sql, params = s.to_sql() 创建 `TRUNCATE` 函数调用 (截断数字)。 ```python -def truncate(dialect: "SQLDialectBase", expr: Union[int, float, "bases.BaseExpression"], precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import truncate + +# 库实际声明的函数签名 +print(inspect.signature(truncate)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import truncate + t = truncate(dialect, 3.14159, 2) sql, params = t.to_sql() # sql: 'TRUNCATE(?, ?)' -# params: (3.14159, 2) +# params: (Decimal('3.14159'), 2) ``` ### chr_ @@ -989,11 +1368,17 @@ sql, params = t.to_sql() 创建 `CHR` 函数调用 (ASCII 码转字符)。注意尾随下划线。 ```python -def chr_(dialect: "SQLDialectBase", code: Union[int, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import chr_ + +# 库实际声明的函数签名 +print(inspect.signature(chr_)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import chr_ + c = chr_(dialect, 65) # 返回 'A' sql, params = c.to_sql() # sql: 'CHR(?)' @@ -1005,11 +1390,17 @@ sql, params = c.to_sql() 创建 `ASCII` 函数调用 (字符转 ASCII 码)。 ```python -def ascii(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import ascii + +# 库实际声明的函数签名 +print(inspect.signature(ascii)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import ascii + a = ascii(dialect, "A") sql, params = a.to_sql() # sql: 'ASCII(?)' @@ -1021,11 +1412,18 @@ sql, params = a.to_sql() 创建 `OCTET_LENGTH` 函数调用 (返回字节长度)。 ```python -def octet_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import octet_length + +# 库实际声明的函数签名 +print(inspect.signature(octet_length)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import octet_length + o = octet_length(dialect, Column(dialect, "text")) sql, params = o.to_sql() # sql: 'OCTET_LENGTH("text")' @@ -1037,11 +1435,18 @@ sql, params = o.to_sql() 创建 `BIT_LENGTH` 函数调用 (返回位长度)。 ```python -def bit_length(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import bit_length + +# 库实际声明的函数签名 +print(inspect.signature(bit_length)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import bit_length + b = bit_length(dialect, Column(dialect, "text")) sql, params = b.to_sql() # sql: 'BIT_LENGTH("text")' @@ -1053,11 +1458,18 @@ sql, params = b.to_sql() 创建 `POSITION` 函数调用 (查找子字符串位置,1-based)。 ```python -def position(dialect: "SQLDialectBase", substring: Union[str, "bases.BaseExpression"], expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import position + +# 库实际声明的函数签名 +print(inspect.signature(position)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import position + p = position(dialect, "world", Column(dialect, "text")) sql, params = p.to_sql() # sql: 'POSITION(?, "text")' @@ -1069,11 +1481,18 @@ sql, params = p.to_sql() 创建 `OVERLAY` 函数调用 (替换子字符串)。 ```python -def overlay(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], replacement: Union[str, "bases.BaseExpression"], start: int, length: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import overlay + +# 库实际声明的函数签名 +print(inspect.signature(overlay)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import overlay + o = overlay(dialect, Column(dialect, "text"), "xxx", 1, 3) sql, params = o.to_sql() # sql: 'OVERLAY("text", ?, ?, ?)' @@ -1085,11 +1504,18 @@ sql, params = o.to_sql() 创建 `TRANSLATE` 函数调用 (字符替换)。 ```python -def translate(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], from_chars: str, to_chars: str) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import translate + +# 库实际声明的函数签名 +print(inspect.signature(translate)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import translate + t = translate(dialect, Column(dialect, "text"), "abc", "xyz") sql, params = t.to_sql() # sql: 'TRANSLATE("text", ?, ?)' @@ -1101,11 +1527,17 @@ sql, params = t.to_sql() 创建 `REPEAT` 函数调用 (重复字符串)。 ```python -def repeat(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], count: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import repeat + +# 库实际声明的函数签名 +print(inspect.signature(repeat)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import repeat + r = repeat(dialect, "ab", 3) sql, params = r.to_sql() # sql: 'REPEAT(?, ?)' @@ -1117,11 +1549,17 @@ sql, params = r.to_sql() 创建 `SPACE` 函数调用 (生成空格)。 ```python -def space(dialect: "SQLDialectBase", count: int) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import space + +# 库实际声明的函数签名 +print(inspect.signature(space)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import space + s = space(dialect, 5) sql, params = s.to_sql() # sql: 'SPACE(?)' @@ -1133,12 +1571,18 @@ sql, params = s.to_sql() 创建 `CURRENT_TIMESTAMP` 零元值函数。根据 SQL:2003 标准,无精度参数时生成不含括号的 `CURRENT_TIMESTAMP`;指定精度时生成 `CURRENT_TIMESTAMP(precision)`(带括号)。 ```python -def current_timestamp(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import current_timestamp + +# 库实际声明的函数签名 +print(inspect.signature(current_timestamp)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import current_timestamp + # 无精度参数 — 零元形式(无括号) c = current_timestamp(dialect) sql, params = c.to_sql() @@ -1157,12 +1601,18 @@ sql, params = c.to_sql() 创建 `LOCALTIMESTAMP` 零元值函数。根据 SQL:2003 标准,无精度参数时生成不含括号的 `LOCALTIMESTAMP`;指定精度时生成 `LOCALTIMESTAMP(precision)`(带括号)。 ```python -def localtimestamp(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import localtimestamp + +# 库实际声明的函数签名 +print(inspect.signature(localtimestamp)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression.functions import localtimestamp + # 无精度参数 — 零元形式(无括号) l = localtimestamp(dialect) sql, params = l.to_sql() @@ -1175,15 +1625,46 @@ sql, params = l.to_sql() 创建 `EXTRACT` 函数调用 (提取日期时间部分)。 ```python -def extract(dialect: "SQLDialectBase", field: str, expr: Union[str, "bases.BaseExpression"]) -> "core.FunctionCall": ... +import inspect +from rhosocial.activerecord.backend.expression.functions import extract + +# 库实际声明的函数签名 +print(inspect.signature(extract)) ``` **示例:** ```python +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.functions import extract + e = extract(dialect, "YEAR", Column(dialect, "created_at")) sql, params = e.to_sql() -# sql: 'EXTRACT(?, "created_at")' -# params: ("YEAR",) +# sql: 'EXTRACT(YEAR FROM "created_at")' +# params: () +``` + +### current_user, session_user, system_user + +创建 SQL:2003 零元值函数。生成不含括号的 `CURRENT_USER`、`SESSION_USER`、`SYSTEM_USER`。 + +```python +import inspect +from rhosocial.activerecord.backend.expression.functions import current_user, session_user, system_user + +# 库实际声明的函数签名 +print(inspect.signature(current_user)) +print(inspect.signature(session_user)) +print(inspect.signature(system_user)) +``` + +**示例:** +```python +from rhosocial.activerecord.backend.expression.functions import current_user + +u = current_user(dialect) +sql, params = u.to_sql() +# sql: 'CURRENT_USER' +# params: () ``` ## SQL/XML 函数 @@ -1195,16 +1676,18 @@ sql, params = e.to_sql() XML 解析:将字符串解析为 XML 类型。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlparse # 将字符串解析为 XML DOCUMENT -expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="DOCUMENT") +expr = xmlparse(dialect, Column(dialect, "xml_string"), document=True) sql, params = expr.to_sql() # sql: 'XMLPARSE(DOCUMENT "xml_string")' # params: () # 将字符串解析为 XML CONTENT -expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="CONTENT") +expr = xmlparse(dialect, Column(dialect, "xml_string"), document=False) ``` ### xmlserialize @@ -1212,11 +1695,15 @@ expr = xmlparse(dialect, Column(dialect, "xml_string"), doc_type="CONTENT") XML 序列化:将 XML 类型序列化为字符串。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlserialize +from rhosocial.activerecord.backend.expression.types import VarCharType -expr = xmlserialize(dialect, Column(dialect, "xml_data"), "VARCHAR(255)") +# 目标类型是 DataType 对象,绝不是类型名字符串 +expr = xmlserialize(dialect, Column(dialect, "xml_data"), VarCharType(dialect, length=255)) sql, params = expr.to_sql() -# sql: 'XMLSERIALIZE(DOCUMENT "xml_data" AS VARCHAR(255))' +# sql: 'XMLSERIALIZE(CONTENT "xml_data" AS VARCHAR(255))' # params: () ``` @@ -1225,18 +1712,24 @@ sql, params = expr.to_sql() 构造 XML 元素。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlelement, xmlattributes # 简单元素 elem = xmlelement(dialect, "name", Column(dialect, "user_name")) sql, params = elem.to_sql() -# sql: 'XMLELEMENT(NAME "name", "user_name")' +# sql: 'XMLELEMENT(NAME name, "user_name")' # params: () -# 带属性的元素 -elem = xmlelement(dialect, "person", - xmlattributes(dialect, Column(dialect, "id"), "id"), - Column(dialect, "name")) +# 带属性的元素 —— 属性按名称传入,而不是作为内容 +elem = xmlelement( + dialect, + "person", + Column(dialect, "name"), + attributes=xmlattributes(dialect, (Column(dialect, "id"), "id")), +) +# sql: 'XMLELEMENT(NAME person, XMLATTRIBUTES("id" AS id), "name")' ``` ### xmlforest @@ -1244,13 +1737,15 @@ elem = xmlelement(dialect, "person", 构造 XML 森林(多个元素的序列)。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlforest expr = xmlforest(dialect, (Column(dialect, "first_name"), "first"), (Column(dialect, "last_name"), "last")) sql, params = expr.to_sql() -# sql: 'XMLFOREST("first_name" AS "first", "last_name" AS "last")' +# sql: 'XMLFOREST("first_name" AS first, "last_name" AS last)' # params: () ``` @@ -1259,6 +1754,8 @@ sql, params = expr.to_sql() 连接多个 XML 值。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlconcat expr = xmlconcat(dialect, Column(dialect, "xml1"), Column(dialect, "xml2")) @@ -1283,8 +1780,8 @@ expr = xmlcomment(dialect, "This is a comment") ```python from rhosocial.activerecord.backend.expression.functions.xml import xmlpi -expr = xmlpi(dialect, "xml-stylesheet", "type=\"text/xsl\" href=\"style.xsl\"") -# sql: 'XMLPI(NAME "xml-stylesheet", ?)' +expr = xmlpi(dialect, "xml-stylesheet", 'type="text/xsl" href="style.xsl"') +# sql: 'XMLPI(NAME xml-stylesheet, ?)' # params: ('type="text/xsl" href="style.xsl"',) ``` @@ -1293,9 +1790,19 @@ expr = xmlpi(dialect, "xml-stylesheet", "type=\"text/xsl\" href=\"style.xsl\"") 修改 XML 值的根节点属性。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlroot +from rhosocial.activerecord.backend.expression.xml import XMLStandaloneOption -expr = xmlroot(dialect, Column(dialect, "xml_data"), version="1.0", standalone="YES") +expr = xmlroot( + dialect, + Column(dialect, "xml_data"), + version="1.0", + standalone=XMLStandaloneOption.YES, +) +# sql: 'XMLROOT("xml_data", VERSION ?, STANDALONE YES)' +# params: ('1.0',) ``` ### xmlagg @@ -1303,10 +1810,12 @@ expr = xmlroot(dialect, Column(dialect, "xml_data"), version="1.0", standalone=" XML 聚合函数。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlagg -expr = xmlagg(dialect, Column(dialect, "xml_column"), alias="combined_xml") -# sql: 'XMLAGG("xml_column") AS "combined_xml"' +expr = xmlagg(dialect, Column(dialect, "xml_column")) +# sql: 'XMLAGG("xml_column")' # params: () ``` @@ -1315,11 +1824,13 @@ expr = xmlagg(dialect, Column(dialect, "xml_column"), alias="combined_xml") XQuery 查询评估。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlquery expr = xmlquery(dialect, "/root/element/text()", - passing=Column(dialect, "xml_column")) -# sql: 'XMLQUERY(?) PASSING BY VALUE "xml_column" EMPTY ON EMPTY' + passing=[Column(dialect, "xml_column")]) +# sql: 'XMLQUERY(? PASSING "xml_column" RETURNING CONTENT)' # params: ('/root/element/text()',) ``` @@ -1328,11 +1839,13 @@ expr = xmlquery(dialect, "/root/element/text()", XQuery 存在性检查。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmlexists expr = xmlexists(dialect, "/root/element", - passing=Column(dialect, "xml_column")) -# sql: 'XMLEXISTS(?) PASSING BY VALUE "xml_column"' + passing=[Column(dialect, "xml_column")]) +# sql: 'XMLEXISTS(? PASSING "xml_column")' # params: ('/root/element',) ``` @@ -1341,16 +1854,24 @@ expr = xmlexists(dialect, "/root/element", 通过 XQuery 表达式生成表值。 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.functions.xml import xmltable +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.xml import XMLTableColumn -expr = xmltable(dialect, - xquery="/root/row", - passing=Column(dialect, "xml_data"), +expr = xmltable( + dialect, + "/root/row", + passing=[Column(dialect, "xml_data")], columns=[ - ("id", "INTEGER", "id"), - ("name", "VARCHAR(100)", "name"), - ]) -# sql: 'XMLTABLE(?) PASSING BY VALUE "xml_data" COLUMNS ("id" INTEGER PATH "id", "name" VARCHAR(100) PATH "name")' + XMLTableColumn("id", IntegerType(dialect), path=Column(dialect, "id")), + XMLTableColumn( + "name", VarCharType(dialect, length=100), path=Column(dialect, "name") + ), + ], +) +# sql: 'XMLTABLE(? PASSING "xml_data" COLUMNS ("id" INTEGER PATH "id", "name" VARCHAR(100) PATH "name"))' # params: ('/root/row',) ``` @@ -1363,7 +1884,11 @@ expr = xmltable(dialect, 创建结构化的 `EXTRACT` 表达式(相比 `core.md` 中的 `extract` 函数,本实现支持更严格的字段枚举)。 ```python -from rhosocial.activerecord.backend.expression.datetime import ExtractExpression, DateTimeField +from rhosocial.activerecord.backend.expression import Column + +from rhosocial.activerecord.backend.expression.datetime import ( + DateTimeField, ExtractExpression, +) # 提取年份 expr = ExtractExpression(dialect, DateTimeField.YEAR, Column(dialect, "created_at")) @@ -1377,56 +1902,42 @@ sql, params = expr.to_sql() 创建结构化时间间隔值。 ```python -from rhosocial.activerecord.backend.expression.datetime import IntervalExpression, IntervalUnit +from rhosocial.activerecord.backend.expression.datetime import ( + IntervalExpression, IntervalUnit, +) # 间隔 7 天 interval = IntervalExpression(dialect, 7, IntervalUnit.DAY) sql, params = interval.to_sql() -# sql: 'INTERVAL ? DAY' -# params: (7,) +# sql: "INTERVAL '7' DAY" +# params: () ``` ### 日期时间运算 ```python +from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.datetime import ( - DateTimeAddExpression, DateTimeSubtractExpression, DateTimeDiffExpression, + DatetimeAddExpression, DatetimeSubtractExpression, DatetimeDiffExpression, IntervalExpression, IntervalUnit, DateTimeField ) # 日期 + 间隔 -expr = DateTimeAddExpression(dialect, +expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 30, IntervalUnit.DAY)) -# sql: '"created_at" + INTERVAL ? DAY' -# params: (30,) +# sql: '"created_at" + INTERVAL '30' DAY' +# params: () # 日期 - 间隔 -expr = DateTimeSubtractExpression(dialect, +expr = DatetimeSubtractExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 7, IntervalUnit.DAY)) # 日期差值 -expr = DateTimeDiffExpression(dialect, DateTimeField.DAY, +expr = DatetimeDiffExpression(dialect, DateTimeField.DAY, Column(dialect, "start_date"), Column(dialect, "end_date")) # sql: 'DATEDIFF(DAY, "start_date", "end_date")' ``` - -### current_user, session_user, system_user - -创建 SQL:2003 零元值函数。生成不含括号的 `CURRENT_USER`、`SESSION_USER`、`SYSTEM_USER`。 - -```python -def current_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... -def session_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... -def system_user(dialect: "SQLDialectBase") -> "core.FunctionCall": ... -``` - -**示例:** -```python -u = current_user(dialect) -sql, params = u.to_sql() -# sql: 'CURRENT_USER' -# params: () -``` diff --git a/docs/zh_CN/backend/expression/limitations.md b/docs/zh_CN/backend/expression/limitations.md index 34ed86691..90954eb6b 100644 --- a/docs/zh_CN/backend/expression/limitations.md +++ b/docs/zh_CN/backend/expression/limitations.md @@ -15,34 +15,40 @@ rhosocial-activerecord 的表达式系统是一个强大而灵活的工具,用 ```python # 以下代码在表达式系统中是有效的,但可能在数据库执行时失败 from rhosocial.activerecord.backend.expression import ( - Column, Literal, QueryExpression, TableExpression + Column, NamedRelationRef, QueryStatement +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_sources import ( + SetOperation, SetOperationKind ) -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() # 第一个查询返回整数 ID 和文本名称 -query1 = QueryExpression( +query1 = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users") + from_=NamedRelationRef(dialect, Table(dialect, "users")) ) # 第二个查询返回文本 email 和整数年龄 -query2 = QueryExpression( +query2 = QueryStatement( dialect, select=[Column(dialect, "email"), Column(dialect, "age")], - from_=TableExpression(dialect, "customers") + from_=NamedRelationRef(dialect, Table(dialect, "customers")) ) # 表达式系统会成功构建此 UNION,但数据库执行时会报错 -union_query = SetOperationExpression( +union_query = SetOperation( dialect, + op=SetOperationKind.UNION, left=query1, - right=query2, - operation="UNION" + right=query2 ) +sql, params = union_query.to_sql() +# sql: 'SELECT "id", "name" FROM "users" UNION SELECT "email", "age" FROM "customers"' +# params: () ``` 尽管上述代码在表达式系统层面是有效的,但在数据库执行时会因为列类型不匹配而失败。 diff --git a/docs/zh_CN/backend/expression/predicates.md b/docs/zh_CN/backend/expression/predicates.md index 0be553e6e..b063689b5 100644 --- a/docs/zh_CN/backend/expression/predicates.md +++ b/docs/zh_CN/backend/expression/predicates.md @@ -13,21 +13,32 @@ 表示两个表达式之间的标准比较操作。 ```python -class ComparisonPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, left: "SQLValueExpression", right: "SQLValueExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import ComparisonPredicate + +print(inspect.signature(ComparisonPredicate)) +# (dialect: 'SQLDialectBase', op: Any, left: 'SQLValue', right: 'SQLValue') ``` **参数:** - `dialect`: SQL 方言实例。 -- `op`: 比较运算符 (例如 `=`, `>`, `<`, `>=`, `<=`, `<>`)。 +- `op`: 比较操作——`ComparisonOperator` 成员(`EQ`、`NE`、`GT`、`GTE`、`LT`、`LTE`)或其 SQL 写法(`=`、`!=`、`>`、`>=`、`<`、`<=`)。 - `left`: 左侧表达式。 - `right`: 右侧表达式。 **示例:** ```python +from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, IntegerColumn, Literal, +) + # users.age >= 18 - pred = ComparisonPredicate(dialect, ">=", Column(dialect, "age"), Literal(dialect, 18)) - # -> ('"age" >= ?', (18,)) +pred = ComparisonPredicate( + dialect, ">=", IntegerColumn(dialect, "age"), Literal(dialect, 18) +) +pred.to_sql() +# -> ('"age" >= ?', (18,)) ``` ### LogicalPredicate @@ -35,22 +46,32 @@ class ComparisonPredicate(bases.SQLPredicate): 表示组合其他谓词的逻辑操作。 ```python -class LogicalPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, *predicates: "bases.SQLPredicate"): ... +import inspect + +from rhosocial.activerecord.backend.expression import LogicalPredicate + +print(inspect.signature(LogicalPredicate)) +# (dialect: 'SQLDialectBase', op: str, *operands: 'SQLPredicate') ``` **参数:** - `dialect`: SQL 方言实例。 - `op`: 逻辑运算符 (例如 `AND`, `OR`, `NOT`)。 -- `*predicates`: 一个或多个要组合的谓词。 +- `*operands`: 一个或多个要组合的谓词。 **示例:** ```python +from rhosocial.activerecord.backend.expression import ( + IntegerColumn, LogicalPredicate, StringColumn, +) + # (age >= 18) AND (status = 'active') - pred1 = ComparisonPredicate(dialect, ">=", Column(dialect, "age"), Literal(dialect, 18)) - pred2 = ComparisonPredicate(dialect, "=", Column(dialect, "status"), Literal(dialect, "active")) - combined = LogicalPredicate(dialect, "AND", pred1, pred2) - # -> ('"age" >= ? AND "status" = ?', (18, 'active')) +# 带类型的列用 Python 运算符构建比较谓词。 +pred1 = IntegerColumn(dialect, "age") >= 18 +pred2 = StringColumn(dialect, "status") == "active" +combined = LogicalPredicate(dialect, "AND", pred1, pred2) +combined.to_sql() +# -> ('"age" >= ? AND "status" = ?', (18, 'active')) ``` ### LikePredicate @@ -58,21 +79,32 @@ class LogicalPredicate(bases.SQLPredicate): 表示模式匹配操作 (`LIKE`, `ILIKE`)。 ```python -class LikePredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", op: str, expr: "SQLValueExpression", pattern: "SQLValueExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import LikePredicate + +print(inspect.signature(LikePredicate)) +# (dialect: 'SQLDialectBase', op: 'LikeOperator', left: 'SQLValue', pattern: 'SQLValue') ``` **参数:** - `dialect`: SQL 方言实例。 -- `op`: 运算符 (`LIKE`, `ILIKE`, `NOT LIKE` 等)。 -- `expr`: 要测试的表达式。 +- `op`: 操作——`LikeOperator` 成员(`like`、`not_like`、`ilike`)或其 SQL 关键字(`LIKE`、`NOT LIKE`、`ILIKE`)。 +- `left`: 要测试的表达式。 - `pattern`: 要匹配的模式。 **示例:** ```python +from rhosocial.activerecord.backend.expression import ( + LikePredicate, Literal, StringColumn, +) + # name LIKE 'User%' - pred = LikePredicate(dialect, "LIKE", Column(dialect, "name"), Literal(dialect, "User%")) - # -> ('"name" LIKE ?', ('User%',)) +pred = LikePredicate( + dialect, "like", StringColumn(dialect, "name"), Literal(dialect, "User%") +) +pred.to_sql() +# -> ('"name" LIKE ?', ('User%',)) ``` ### InPredicate @@ -80,19 +112,33 @@ class LikePredicate(bases.SQLPredicate): 表示集合成员测试 (`IN`)。 ```python -class InPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "SQLValueExpression", values: "bases.BaseExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import InPredicate + +print(inspect.signature(InPredicate)) +# (dialect: 'SQLDialectBase', operand: 'SQLValue', values: 'BaseExpression') ``` **参数:** - `dialect`: SQL 方言实例。 -- `expr`: 要测试的表达式。 +- `operand`: 要测试的表达式。 - `values`: 包含集合(列表/元组)的 `Literal` 或子查询表达式。 **示例:** ```python +from rhosocial.activerecord.backend.expression import ( + InPredicate, Literal, StringColumn, +) + # status IN ('active', 'pending') -pred = InPredicate(dialect, Column(dialect, "status"), Literal(dialect, ["active", "pending"])) +pred = InPredicate( + dialect, + StringColumn(dialect, "status"), + Literal(dialect, ["active", "pending"]), +) +pred.to_sql() +# -> ('"status" IN (?, ?)', ('active', 'pending')) ``` ### BetweenPredicate @@ -100,20 +146,33 @@ pred = InPredicate(dialect, Column(dialect, "status"), Literal(dialect, ["active 表示范围测试 (`BETWEEN`)。 ```python -class BetweenPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", low: "bases.BaseExpression", high: "bases.BaseExpression"): ... +import inspect + +from rhosocial.activerecord.backend.expression import BetweenPredicate + +print(inspect.signature(BetweenPredicate)) +# (dialect: 'SQLDialectBase', operand: 'BaseExpression', +# low: 'BaseExpression', high: 'BaseExpression') ``` **参数:** - `dialect`: SQL 方言实例。 -- `expr`: 要测试的表达式。 +- `operand`: 要测试的表达式。 - `low`: 下界。 - `high`: 上界。 **示例:** ```python +from rhosocial.activerecord.backend.expression import ( + BetweenPredicate, IntegerColumn, Literal, +) + # age BETWEEN 18 AND 65 -pred = BetweenPredicate(dialect, Column(dialect, "age"), Literal(dialect, 18), Literal(dialect, 65)) +pred = BetweenPredicate( + dialect, IntegerColumn(dialect, "age"), Literal(dialect, 18), Literal(dialect, 65) +) +pred.to_sql() +# -> ('"age" BETWEEN ? AND ?', (18, 65)) ``` ### IsNullPredicate @@ -121,20 +180,27 @@ pred = BetweenPredicate(dialect, Column(dialect, "age"), Literal(dialect, 18), L 表示 NULL 测试 (`IS NULL`, `IS NOT NULL`)。 ```python -class IsNullPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", is_not: bool = False): ... +import inspect + +from rhosocial.activerecord.backend.expression import IsNullPredicate + +print(inspect.signature(IsNullPredicate)) +# (dialect: 'SQLDialectBase', operand: 'BaseExpression', is_not: bool = False) ``` **参数:** - `dialect`: SQL 方言实例。 -- `expr`: 要测试的表达式。 +- `operand`: 要测试的表达式。 - `is_not`: 如果为 `True`,创建 `IS NOT NULL`。默认为 `False` (`IS NULL`)。 **示例:** ```python +from rhosocial.activerecord.backend.expression import IsNullPredicate, StringColumn + # email IS NOT NULL - pred = IsNullPredicate(dialect, Column(dialect, "email"), is_not=True) - # -> ('"email" IS NOT NULL', ()) +pred = IsNullPredicate(dialect, StringColumn(dialect, "email"), is_not=True) +pred.to_sql() +# -> ('"email" IS NOT NULL', ()) ``` ### IsBooleanPredicate @@ -142,8 +208,12 @@ class IsNullPredicate(bases.SQLPredicate): 表示布尔值测试 (`IS TRUE`, `IS NOT TRUE`, `IS FALSE`, `IS NOT FALSE`)。 ```python -class IsBooleanPredicate(bases.SQLPredicate): - def __init__(self, dialect: "SQLDialectBase", expr: "bases.BaseExpression", value: bool, is_not: bool = False): ... +import inspect + +from rhosocial.activerecord.backend.expression import IsBooleanPredicate + +print(inspect.signature(IsBooleanPredicate)) +# (dialect: 'SQLDialectBase', expr: 'BaseExpression', value: bool, is_not: bool = False) ``` **参数:** @@ -164,22 +234,32 @@ class IsBooleanPredicate(bases.SQLPredicate): **示例:** ```python +from rhosocial.activerecord.backend.expression import BooleanColumn, IsBooleanPredicate + # is_active IS TRUE -pred = IsBooleanPredicate(dialect, Column(dialect, "is_active"), value=True, is_not=False) +pred = IsBooleanPredicate( + dialect, BooleanColumn(dialect, "is_active"), value=True, is_not=False +) +pred.to_sql() # -> ('"is_active" IS TRUE', ()) # is_active IS NOT FALSE -pred = IsBooleanPredicate(dialect, Column(dialect, "is_active"), value=False, is_not=True) +pred = IsBooleanPredicate( + dialect, BooleanColumn(dialect, "is_active"), value=False, is_not=True +) +pred.to_sql() # -> ('"is_active" IS NOT FALSE', ()) ``` **便捷方法:** -`Column` 类提供了创建这些谓词的便捷方法: +`BooleanColumn` 类提供了创建这些谓词的便捷方法: ```python -col = Column(dialect, "is_active") -col.is_true() # IS TRUE -col.is_not_true() # IS NOT TRUE -col.is_false() # IS FALSE -col.is_not_false() # IS NOT FALSE +from rhosocial.activerecord.backend.expression import BooleanColumn + +col = BooleanColumn(dialect, "is_active") +col.is_true().to_sql() # -> ('"is_active" IS TRUE', ()) +col.is_not_true().to_sql() # -> ('"is_active" IS NOT TRUE', ()) +col.is_false().to_sql() # -> ('"is_active" IS FALSE', ()) +col.is_not_false().to_sql() # -> ('"is_active" IS NOT FALSE', ()) ``` diff --git a/docs/zh_CN/backend/expression/serialization.md b/docs/zh_CN/backend/expression/serialization.md index d63f61fb5..82da2fc8b 100644 --- a/docs/zh_CN/backend/expression/serialization.md +++ b/docs/zh_CN/backend/expression/serialization.md @@ -1,192 +1,167 @@ # 表达式序列化 -本文档介绍 SQL 表达式对象的序列化与反序列化机制,这是实现表达式缓存、分布式查询和任务调度的核心能力。 +> **可运行示例**:`docs/examples/expression_artifact.py`。下文每一条都在那里端到端跑过。 ## 设计动机 -为什么不直接序列化 `dialect`?原因如下: +表达式是**值对象**:同一个查询构造两次必须相等;它还必须能被写下来、在别处(缓存、队列、另一个进程)读回来。 -1. **运行时注入**:dialect 与具体数据库后端紧耦合,序列化时应与表达式解耦 -2. **多方言支持**:同一表达式可能在不同方言下生成不同 SQL,序列化时不应锁定方言 -3. **安全隔离**:某些 dialect 实现依赖数据库连接状态,不适合跨进程传输 +工件就是为此存在的。它刻意做得很小,而形状只由一个决定推导出来:**工件里不带 import 路径**。载荷里出现点分路径,正是会迫使读方去 import 那个字符串所指模块的东西,而工件是不可信输入。所以节点用 **kind** 指认——类名的 snake_case 形式——无论在内存还是在链路上,这个 kind 就是节点的身份。 -因此,序列化后的 `ExpressionSpec` 不包含 dialect 信息,反序列化时由调用方注入目标 dialect。 +由此有两条值得先说清的推论: -> **序列化与渲染是两条独立的路径**:`_reconstruct()` **仅**用于反序列化往返(spec → 对象)。渲染不做任何重建——`to_sql()` 无状态、零分配,读取每个节点自身绑定的方言,就地渲染现有的树。 +- **写不需要注册表。** 节点自己会报告 kind,由类名派生。只做序列化的调用方**永远不必构造查找表**。 +- **读需要一张。** 把 kind 变回某个类,只有表能回答;而**用哪张表由调用方决定**。 ## 核心 API -### serialize() - -将表达式对象序列化为字典格式: +### serialize(expr) ```python from rhosocial.activerecord.backend.expression.serialization import serialize -spec = serialize(expression) -# 返回 {"type": "Column", "module": "...", "params": {...}} +artifact = serialize(query) ``` -### deserialize() +工件是 JSON 原生的,因此直接走普通 `json.dumps`,不需要自定义 encoder: -从字典恢复表达式对象: +```python +text = json.dumps(artifact) +``` + +### deserialize(artifact, dialect, registry=None) ```python from rhosocial.activerecord.backend.expression.serialization import deserialize -expr = deserialize(spec, dialect) +expr = deserialize(json.loads(text), dialect) ``` -### ExpressionFactory +`json.loads` **不需要 `object_hook`**——而且也没有钩子可做的事。工件通篇是普通 JSON:节点是 `{kind, params}`,参数是标量、数组或对象。**没有任何标记**,所以没有东西需要被"认出"才能解码。JSON 唯一拼不出的东西——送进数据库的值,比如 `bytes` 或 `timedelta`——以它的 kind 所命名的文本形式出行。 -提供工厂方法创建表达式: +`registry=None` 表示框架默认实例——本发行版自带的全部节点。传自己的表,就精确控制这次读允许产出哪些 kind。 -```python -from rhosocial.activerecord.backend.expression.serialization import ExpressionFactory +### deserialize_json(text, dialect, registry=None) / deserialize_xml(bytes, ...) -factory = ExpressionFactory(dialect) -expr = factory.create("Column", name="id", table="users") -``` +同一份工件的三种渲染:字典、JSON 文本、XML 文档。三者往返到同一个实例;**参数与渲染出的 SQL 都必须原样回来**。 -## ExpressionSpec 格式 +## 工件格式 -序列化后的字典包含三个必填字段: +**裸节点,无信封。** | 字段 | 类型 | 说明 | |------|------|------| -| `type` | str | 表达式类名(如 `Column`、`ComparisonPredicate`) | -| `module` | str | 表达式类的完整模块路径 | +| `kind` | str | 节点的 kind(如 `column`、`comparison_predicate`) | | `params` | dict | 构造参数,键名与 `__init__` 参数名一致 | -### 示例 +就这些——**没有**版本、生产者、捕获的方言、连接指纹。 ```python # Column("users", "id") { - "type": "Column", - "module": "rhosocial.activerecord.backend.expression.core", - "params": {"name": "id", "table": "users", "alias": null, "schema_name": null} -} -``` - -## 嵌套表达式 - -表达式参数中可能包含其他表达式对象(如 WHERE 子句中的谓词)。序列化时使用 `__expr__` 标记: - -```python -# WHERE status = 'active' 的序列化结果 -{ - "type": "WhereClause", - "module": "rhosocial.activerecord.backend.expression.query_parts", + "kind": "column", "params": { - "condition": { - "__expr__": { - "type": "ComparisonPredicate", - "module": "rhosocial.activerecord.backend.expression.predicates", - "params": {...} - } - } + "name": "id", + "table": {"kind": "table", "params": {"name": "users"}}, + "alias": None, } } ``` -反序列化时,`__expr__` 标记的子字典会被自动还原为表达式对象。 +参数要么是**语言无关标量**,要么是**表达式**。第二种形态(嵌套的 `{kind, params}` 节点)是同一件事的不同层面:内存里是活实例,链路上是编码节点。**没有第三种形态**——这正是工件可读的原因。 -## 元组标记 +注意嵌套表达式**外面没有任何包裹**。形状本身就是全部信号,因此没有东西需要拆开,也不存在「这是节点」的第二个、更弱的说法。 +`__expr__` 信封会让同一件事有两种写法,读方就得猜载荷用的是哪一种。 -Python `tuple` 无法直接 JSON 序列化,使用 `__tuple__` 标记: +注意**没有什么**。工件不带方言,所以同一份载荷可以交给任意方言对象重渲染: ```python -# ORDER BY id DESC, name ASC -{ - "type": "OrderByClause", - "params": { - "order_by_items": [ - {"__tuple__": [Column(...), "DESC"]}, - {"__tuple__": [Column(...), "ASC"]} - ] - } -} +deserialize(artifact, SQLiteDialect(version=(3, 53, 0))).to_sql() ``` -## 错误处理 +## 绑定参数 -反序列化时可能抛出以下异常: +`to_sql()` 返回一对——语句与待绑定的值: -- `ExpressionDeserializationError`:类型未注册、参数缺失、构造失败 -- `TypeError`:参数类型不匹配(被包装为 `ExpressionDeserializationError`) +```python +sql, params = expr.to_sql() +# ("SELECT ... WHERE age > ?", (18,)) +``` -方言兼容性错误(如 PostgreSQL 特有语法在 MySQL 下不支持)会在 `to_sql()` 时才暴露。 +那个 `18` **哪儿都没被单独存过**。它是从载荷里的 `literal` 节点读出来的,所以没有第二处携带它,也就没有第二处可能与它不一致。表达式从不把值拼进 SQL 文本;数据库永远看不到字符串拼接出来的引号。 -## 使用场景 +## 选择性注册 -### 缓存查询计划 +注册表是**读侧边界**:它过滤的是工件**实际携带**的 kind,不是类**声明过**的参数类型。参数保持默认值就不产生节点,因而也不需要登记。 ```python -# 序列化 -query = query.select(...).where(...) -spec = serialize(query) +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry + +read_only = ExpressionRegistry() +for kind in ("query_statement", "where_clause", "comparison_predicate", + "column", "literal", "table"): + read_only.register_node(default_registry().resolve_node(kind)) -# 反序列化 -restored = deserialize(spec, new_dialect) -sql = restored.to_sql() +deserialize(artifact, dialect, registry=read_only) ``` -### 分布式任务调度 +不带分组的查询在这张表下读得通;带 `GROUP BY` 的会被按名拒绝。这就是权限形态:**这个通道不接受的东西,发给它也变不出来。** -将表达式作为任务参数传递,反序列化时注入目标环境的 dialect。 +要讲清注册表**不**做什么。`serialize` 不查注册表,所以窄表**阻止不了写出**,也**决定不了执行**。它管的是:读者从一份自己没写过的产物里能还原出什么。**一个必须在写侧成立的边界需要另一套机制**——因为写方与执行方同进程。 -## 安全考虑 +## 错误处理 -### 为什么必须校验 BaseExpression 类型? +每一次拒绝都指名它拒绝了什么,因为一份你并非亲手写出的产物,没法靠猜来调试。 -反序列化时,框架会校验 `spec["type"]` 所指向的类必须是 `BaseExpression` 的子类。这一安全检查防止了以下攻击: +| 情况 | 错误 | +|------|------| +| 无人登记的 kind | `kind 'x' is not in the kind whitelist` | +| 残留的信封字段 | `a bare node carries exactly 'kind' and 'params'` | +| 无法识别的参数 | `kind 'x' does not accept parameter 'y'` | +| 缺失 kind | `the kind is the node's identity and is mandatory` | +| 嵌套超过深度上限 | `artifact nesting depth exceeds N` | -1. **任意类实例化**:攻击者可能构造恶意 spec,尝试反序列化如 `subprocess.Popen` 等危险类 -2. **代码执行**:若不校验类型,可能在 `_reconstruct()` 时调用危险类的构造函数 +## 使用场景 -通过强制校验,只有继承自 `BaseExpression` 的表达式类才能被反序列化,确保安全边界。 +### 缓存查询计划 -### 为什么不允许动态模块导入? +```python +stored = json.dumps(serialize(query)) +# ……之后,在另一个进程 +query = deserialize(json.loads(stored), dialect) +``` -`ExpressionRegistry.lookup()` 不支持通过 `module` 参数动态导入模块。这是安全设计决策: +缓存里存的是 JSON 文本,因此任何存字符串的缓存都能放;而方言在**读时**选定——一份缓存服务所有后端。 -1. **任意模块加载**:通过 spec 中的 `module` 字段可以加载任意已安装的 Python 模块 -2. **全局注册表污染**:动态导入的类会被写入全局注册表,影响后续请求 +### 分布式任务调度 -因此,所有表达式类必须在使用前通过 `ExpressionRegistry.register()` 或 `_auto_register_builtins()` 预先注册。 +worker 收到文本,用它自己的方言重建树。**载荷里没有任何东西**绑在产出它的那台机器上。 -### 安全使用建议 +## 安全考虑 -**推荐方式:完整往返** +### 工件里的名字永远到不了 import -```python -# 正确:使用 serialize/deserialize 完整往返,不手动修改 spec -expr = query.where(...) -spec = serialize(expr) +解析只走白名单,永不走 import 路径。载荷里写 `os.system` 得到的答复是「不在 kind 白名单」——它被查了、查不到、读取失败。**不存在**任何可能 import 它的代码路径。 -# 存储、传输、反序列化 -restored = deserialize(spec, dialect) -``` +注册表模块**根本不 import** 表达式包;装配默认实例的机制属于框架,不属于注册表。**注册表无法发现任何东西。** + +### 工件是不可信输入 -**不推荐:手动构造 spec** +- 未知 kind、残留字段、无法识别的参数,一律按名失败。 +- 嵌套深度与长度有上限。 +- **这里不做方言层校验**——那属于 `to_sql()`,支持性问题归它管。 + +### 不要手工编辑工件 ```python -# 错误:手动构造 spec 可能导致安全问题 -spec = { - "type": "Column", - "module": "some.module", # 可能被滥用 - "params": {...} -} -``` +# 正确:构造树,交给编解码器写 +text = json.dumps(serialize(query)) -除非您完全理解以下风险,否则应避免手动修改 spec: -- 错误的 `type` 或 `module` 会导致反序列化失败 -- 嵌套表达式必须使用 `__expr__` 标记 -- 使用保留键名(`__expr__`、`__tuple__`)会导致数据损坏 +# 错误:手工拼装载荷等于把自己绑在一个库不承诺保持的格式上, +# 而一份你并非亲手写出的产物,恰恰是读方设计上就不信任的输入。 +``` ## 相关文档 -- [扩展指南](./extending.md):如何为自定义表达式实现序列化 -- [格式参考](./format-reference.md):ExpressionSpec 完整规范 -- [迁移说明](./migration.md):版本兼容性说明 \ No newline at end of file +- `docs/examples/expression_artifact.py` —— 可运行的端到端示例 +- `.claude/plan/2026-10-12/表达式-方言架构规则.md` §7、§10 —— 注册表与工件规则 \ No newline at end of file diff --git a/docs/zh_CN/backend/expression/statements.md b/docs/zh_CN/backend/expression/statements.md index cb59da2fa..a717b393b 100644 --- a/docs/zh_CN/backend/expression/statements.md +++ b/docs/zh_CN/backend/expression/statements.md @@ -4,30 +4,47 @@ ## DML 语句 -### QueryExpression (SELECT) +### QueryStatement (SELECT) -`QueryExpression` 表示 `SELECT` 语句。它将各种子句(SELECT、FROM、WHERE、GROUP BY 等)组合成一个完整的查询。 +`QueryStatement` 表示 `SELECT` 语句。它将各种子句(SELECT、FROM、WHERE、GROUP BY 等)组合成一个完整的查询。 ```python -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + Literal, + NamedRelationRef, + QueryStatement, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table -# 基础 SELECT -query = QueryExpression( +# 基础 SELECT —— 谓词由带类型的列构建 +query = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "email")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "status") == Literal(dialect, "active") + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=StringColumn(dialect, "status") == "active" ) sql, params = query.to_sql() # sql: 'SELECT "name", "email" FROM "users" WHERE "status" = ?' # params: ("active",) +# 同一个谓词写成显式节点 +predicate = ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "status"), + Literal(dialect, "active"), +) + # 带 DISTINCT 的 SELECT from rhosocial.activerecord.backend.expression import SelectModifier -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "category")], - from_=TableExpression(dialect, "products"), + from_=NamedRelationRef(dialect, Table(dialect, "products")), select_modifier=SelectModifier.DISTINCT ) sql, params = query.to_sql() @@ -35,22 +52,23 @@ sql, params = query.to_sql() # params: () ``` -### InsertExpression (INSERT) +### InsertStatement (INSERT) -`InsertExpression` 表示 `INSERT` 语句。它支持单行、多行、`INSERT ... SELECT` 以及 `ON CONFLICT` 子句。 +`InsertStatement` 表示 `INSERT` 语句。它支持单行、多行、`INSERT ... SELECT` 以及 `ON CONFLICT` 子句。 ```python -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, Literal +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource, Literal +from rhosocial.activerecord.backend.expression.objects import Table # 单行插入 source = ValuesSource(dialect, values_list=[ [Literal(dialect, "John Doe"), Literal(dialect, "john@example.com")] ]) -insert = InsertExpression( +insert = InsertStatement( dialect, - into="users", - columns=["name", "email"], - source=source + Table(dialect, "users"), + source=source, + columns=["name", "email"] ) sql, params = insert.to_sql() # sql: 'INSERT INTO "users" ("name", "email") VALUES (?, ?)' @@ -63,11 +81,11 @@ on_conflict = OnConflictClause( conflict_target=["id"], do_nothing=True ) -insert = InsertExpression( +insert = InsertStatement( dialect, - into="products", - columns=["id", "name"], + Table(dialect, "products"), source=source, + columns=["id", "name"], on_conflict=on_conflict ) sql, params = insert.to_sql() @@ -75,64 +93,83 @@ sql, params = insert.to_sql() # params: (...) ``` -### UpdateExpression (UPDATE) +### UpdateStatement (UPDATE) -`UpdateExpression` 表示 `UPDATE` 语句。它支持 `SET` 赋值、`WHERE` 子句以及可选的 `FROM` 子句(用于连接更新)。 +`UpdateStatement` 表示 `UPDATE` 语句。它支持 `SET` 赋值、`WHERE` 子句以及可选的 `FROM` 子句(用于连接更新)。 ```python -from rhosocial.activerecord.backend.expression import UpdateExpression, Literal, Column +from rhosocial.activerecord.backend.expression import ( + IntegerColumn, + Literal, + ReturningClause, + StringColumn, + UpdateStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table # 基础 UPDATE -update = UpdateExpression( +update = UpdateStatement( dialect, - table="users", + Table(dialect, "users"), assignments={ "status": Literal(dialect, "inactive"), "updated_at": Literal(dialect, "2023-01-01") }, - where=Column(dialect, "last_login") < Literal(dialect, "2022-01-01") + where=StringColumn(dialect, "last_login") < "2022-01-01" ) sql, params = update.to_sql() # sql: 'UPDATE "users" SET "status" = ?, "updated_at" = ? WHERE "last_login" < ?' # params: ("inactive", "2023-01-01", "2022-01-01") -# 带 RETURNING 的 UPDATE -from rhosocial.activerecord.backend.expression import ReturningClause -update = UpdateExpression( +# 带 RETURNING 的 UPDATE —— 算术需要数值列类 +update = UpdateStatement( dialect, - table="items", - assignments={"quantity": Column(dialect, "quantity") - Literal(dialect, 1)}, - where=Column(dialect, "id") == Literal(dialect, 123), - returning=ReturningClause(dialect, [Column(dialect, "quantity")]) + Table(dialect, "items"), + assignments={"quantity": IntegerColumn(dialect, "quantity") - 1}, + where=IntegerColumn(dialect, "id") == 123, + returning=ReturningClause(dialect, [IntegerColumn(dialect, "quantity")]) ) sql, params = update.to_sql() # sql: 'UPDATE "items" SET "quantity" = "quantity" - ? WHERE "id" = ? RETURNING "quantity"' # params: (1, 123) ``` -### DeleteExpression (DELETE) +### DeleteStatement (DELETE) -`DeleteExpression` 表示 `DELETE` 语句。它支持 `WHERE` 子句和可选的 `USING` 子句(用于连接删除)。 +`DeleteStatement` 表示 `DELETE` 语句。它支持 `WHERE` 子句和可选的 `USING` 子句(用于连接删除)。 ```python -from rhosocial.activerecord.backend.expression import DeleteExpression, Literal, Column +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + DeleteStatement, + NamedRelationRef, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table # 基础 DELETE -delete = DeleteExpression( +delete = DeleteStatement( dialect, - table="sessions", - where=Column(dialect, "expires_at") < Literal(dialect, "NOW()") + Table(dialect, "sessions"), + where=StringColumn(dialect, "expires_at") < "NOW()" ) sql, params = delete.to_sql() # sql: 'DELETE FROM "sessions" WHERE "expires_at" < ?' # params: ("NOW()",) # 带 USING 的 DELETE (连接删除) -delete = DeleteExpression( +delete = DeleteStatement( dialect, - table="users", - using="old_users", - where=Column(dialect, "id", "users") == Column(dialect, "old_id", "old_users") + Table(dialect, "users"), + using=NamedRelationRef(dialect, Table(dialect, "old_users")), + where=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "users"), + Column(dialect, "old_id", "old_users") + ) ) sql, params = delete.to_sql() # sql: 'DELETE FROM "users" USING "old_users" WHERE "users"."id" = "old_users"."old_id"' @@ -143,41 +180,45 @@ sql, params = delete.to_sql() > **关于 DDL 中的参数内联** > -> DDL 子句(`DEFAULT`、`CHECK`、部分索引 `WHERE`、分区边界等)**不接受绑定参数**——数据库引擎要求这些位置必须是直接可解析的 SQL 文本。因此,当在 DDL 中使用 `Literal` 表达式时,**必须**设置 `inline_literals=True`: +> DDL 子句(`DEFAULT`、`CHECK`、部分索引 `WHERE`、分区边界等)**不接受绑定参数**——数据库引擎要求这些位置必须是直接可解析的 SQL 文本。因此,凡是子句本身不做规范化的位置,字面量都要写成 `inline=True`: > > ```python > # ✗ 错误:DDL 中 DEFAULT ? 会导致执行失败 -> default_value=Literal(dialect, 0) +> Literal(dialect, 0) > > # ✓ 正确:内联后生成 DEFAULT 0 -> default_value=Literal(dialect, 0, inline_literals=True) +> Literal(dialect, 0, inline=True) > ``` > > DML 语句(`INSERT`/`SELECT`/`UPDATE`/`DELETE`)不受此限制,默认绑定参数模式即可正常工作。 -### CreateTableExpression +### CreateTableStatement -`CreateTableExpression` 表示 `CREATE TABLE` 语句。 +`CreateTableStatement` 表示 `CREATE TABLE` 语句。 数据类型应使用 `DataType` 表达式(如 `IntegerType`、`TextType`、`VarCharType` 等),而非原始字符串: ```python from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, Literal, Column, + ColumnConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType, RealType +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, VarCharType # 创建表 — 使用 DataType 表达式 -create = CreateTableExpression( +create = CreateTableStatement( dialect, - table="new_users", + Table(dialect, "new_users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ColumnDefinition(dialect, "bio", TextType(dialect)), ], @@ -185,79 +226,95 @@ create = CreateTableExpression( ) sql, params = create.to_sql() # sql: 'CREATE TABLE IF NOT EXISTS "new_users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, -# "name" VARCHAR(255) NOT NULL, "bio" TEXT)' +# "name" TEXT NOT NULL, "bio" TEXT)' # params: () ``` #### DEFAULT 和 CHECK 约束中的内联字面量 -DDL 的 `DEFAULT` 和 `CHECK` 子句需要内联字面量。使用 `Literal(..., inline_literals=True)`: +DDL 语法位置不接受绑定参数。`ColumnDefaultConstraint` 会把收到的值规范化为内联字面量,所以直接传值即可;`CHECK` 中的谓词保留构造时的字面量,因此需要显式标记 `inline=True`: ```python +from rhosocial.activerecord.backend.expression import ( + ComparisonOperator, + ComparisonPredicate, + IntegerColumn, + Literal, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, +) +from rhosocial.activerecord.backend.expression.types import RealType + # 带 DEFAULT 值的列 ColumnDefinition(dialect, "status", TextType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, "active", inline_literals=True), # -> DEFAULT 'active' - ), -]), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")), +]) +# -> "status" TEXT DEFAULT 'active' -# 带 CHECK 约束的列 +# 带 CHECK 约束的列 —— inline=True 让值不进入 params ColumnDefinition(dialect, "age", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", - Column(dialect, "age"), - Literal(dialect, 0, inline_literals=True), # -> CHECK ("age" >= 0) + ColumnConstraint(dialect, branch=ColumnCheckConstraint( + dialect, + ComparisonPredicate( + dialect, + ComparisonOperator.GTE, + IntegerColumn(dialect, "age"), + Literal(dialect, 0, inline=True), # -> CHECK ("age" >= 0) ), - ), -]), + )), +]) # DEFAULT 数值 ColumnDefinition(dialect, "score", RealType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0.0, inline_literals=True), # -> DEFAULT 0.0 - ), -]), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0)), +]) +# -> "score" REAL DEFAULT 0.0 ``` #### 生成列(SQLite 3.31.0+、PostgreSQL 12+、MySQL 5.7+) -生成列(计算列)使用 `GeneratedColumnExpression` 表达式,通过 `dialect.format_generated_column_expression` 渲染: +生成列(计算列)使用 `ColumnGeneratedConstraint` 表达式,通过 `dialect.format_column_generated_constraint` 渲染: ```python from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, Column, + ColumnConstraint, + ColumnDefinition, + ColumnPrimaryKeyConstraint, + IntegerColumn, + NumericColumn, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import IntegerType, RealType from rhosocial.activerecord.backend.expression.statements import ( - GeneratedColumnExpression, GeneratedColumnType, + ColumnGeneratedConstraint, + CreateTableStatement, + GeneratedColumnType, ) # 创建带生成列的表 columns = [ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "price", RealType(dialect)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), # STORED 生成列 - 计算并存储在磁盘上 ColumnDefinition(dialect, - name="total", - data_type=RealType(dialect), - generated_expression=GeneratedColumnExpression( + "total", + RealType(dialect), + generated_expression=ColumnGeneratedConstraint( dialect, - expression=Column(dialect, "price") * Column(dialect, "quantity"), + expression=NumericColumn(dialect, "price") * IntegerColumn(dialect, "quantity"), storage_type=GeneratedColumnType.STORED, ), ), ] -create = CreateTableExpression( - dialect=dialect, - table="products", +create = CreateTableStatement( + dialect, + Table(dialect, "products"), columns=columns, ) sql, params = create.to_sql() @@ -271,16 +328,17 @@ sql, params = create.to_sql() # dialect.supports_virtual_generated_columns() — VIRTUAL 支持 ``` -### DropTableExpression +### DropTableStatement -`DropTableExpression` 表示 `DROP TABLE` 语句。 +`DropTableStatement` 表示 `DROP TABLE` 语句。 ```python -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import DropTableStatement -drop = DropTableExpression( +drop = DropTableStatement( dialect, - table_name="old_users", + Table(dialect, "old_users"), if_exists=True, cascade=True ) @@ -289,50 +347,60 @@ sql, params = drop.to_sql() # params: () ``` -### CreateViewExpression / DropViewExpression +### CreateViewStatement / DropViewStatement 用于管理数据库视图的表达式。 ```python -from rhosocial.activerecord.backend.expression import CreateViewExpression +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import ( + CreateViewStatement, + DropViewStatement, +) # 创建视图 -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "name")], - from_=TableExpression(dialect, "users") + from_=NamedRelationRef(dialect, Table(dialect, "users")) ) -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_names", + View(dialect, "user_names"), query=query, - or_replace=True + replace=True ) sql, params = create_view.to_sql() # sql: 'CREATE OR REPLACE VIEW "user_names" AS SELECT "name" FROM "users"' # params: () + +# 删除视图 +drop_view = DropViewStatement(dialect, View(dialect, "user_names"), if_exists=True) +sql, params = drop_view.to_sql() +# sql: 'DROP VIEW IF EXISTS "user_names"' +# params: () ``` -### TruncateExpression +### TruncateStatement -`TruncateExpression` 表示 `TRUNCATE TABLE` 语句,用于快速删除表中所有行。 +`TruncateStatement` 表示 `TRUNCATE TABLE` 语句,用于快速删除表中所有行。 ```python -from rhosocial.activerecord.backend.expression import TruncateExpression +from rhosocial.activerecord.backend.expression import TruncateStatement # 基本 TRUNCATE -truncate = TruncateExpression( +truncate = TruncateStatement( dialect, - table_name="logs" + Table(dialect, "logs") ) sql, params = truncate.to_sql() # sql: 'TRUNCATE TABLE "logs"' # params: () # 带 RESTART IDENTITY 和 CASCADE 的 TRUNCATE -truncate = TruncateExpression( +truncate = TruncateStatement( dialect, - table_name="orders", + Table(dialect, "orders"), restart_identity=True, cascade=True ) @@ -343,26 +411,27 @@ sql, params = truncate.to_sql() ### Schema DDL(模式定义语言) -#### CreateSchemaExpression +#### CreateSchemaStatement -`CreateSchemaExpression` 表示 `CREATE SCHEMA` 语句,用于创建数据库模式(命名空间)。 +`CreateSchemaStatement` 表示 `CREATE SCHEMA` 语句,用于创建数据库模式(命名空间)。 ```python -from rhosocial.activerecord.backend.expression.statement import CreateSchemaExpression +from rhosocial.activerecord.backend.expression.objects import Schema +from rhosocial.activerecord.backend.expression.statements import CreateSchemaStatement # 基本 CREATE SCHEMA -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="my_schema" + Schema(dialect, "my_schema") ) sql, params = create_schema.to_sql() # sql: 'CREATE SCHEMA "my_schema"' # params: () # 带 IF NOT EXISTS 和 AUTHORIZATION 的 CREATE SCHEMA -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="app_schema", + Schema(dialect, "app_schema"), if_not_exists=True, authorization="app_user" ) @@ -371,17 +440,17 @@ sql, params = create_schema.to_sql() # params: () ``` -#### DropSchemaExpression +#### DropSchemaStatement -`DropSchemaExpression` 表示 `DROP SCHEMA` 语句。 +`DropSchemaStatement` 表示 `DROP SCHEMA` 语句。 ```python -from rhosocial.activerecord.backend.expression.statement import DropSchemaExpression +from rhosocial.activerecord.backend.expression.statements import DropSchemaStatement # 带 CASCADE 的 DROP SCHEMA -drop_schema = DropSchemaExpression( +drop_schema = DropSchemaStatement( dialect, - schema_name="old_schema", + Schema(dialect, "old_schema"), if_exists=True, cascade=True ) @@ -392,18 +461,20 @@ sql, params = drop_schema.to_sql() ### Index DDL(索引定义语言) -#### CreateIndexExpression +#### CreateIndexStatement -`CreateIndexExpression` 表示 `CREATE INDEX` 语句,支持多种索引类型和选项。 +`CreateIndexStatement` 表示 `CREATE INDEX` 语句,支持多种索引类型和选项。 ```python -from rhosocial.activerecord.backend.expression.statement import CreateIndexExpression +from rhosocial.activerecord.backend.expression import StringColumn +from rhosocial.activerecord.backend.expression.objects import Index, Table +from rhosocial.activerecord.backend.expression.statements import CreateIndexStatement # 基本 CREATE INDEX -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"] ) sql, params = create_index.to_sql() @@ -411,28 +482,23 @@ sql, params = create_index.to_sql() # params: () # 带 WHERE 子句的 UNIQUE INDEX(部分索引) -# 注意:部分索引的 WHERE 条件属于 DDL,Literal 必须使用 inline_literals -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_active_users", - table_name="users", + Index(dialect, "idx_active_users"), + Table(dialect, "users"), columns=["email"], unique=True, - where=ComparisonPredicate( - dialect, "=", - Column(dialect, "status"), - Literal(dialect, "active", inline_literals=True), # DDL:必须内联 - ), + where=StringColumn(dialect, "status") == "active" ) sql, params = create_index.to_sql() -# sql: 'CREATE UNIQUE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ''active''' -# params: () +# sql: 'CREATE UNIQUE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' +# params: ("active",) # 带索引类型的复合索引 -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_orders_user_date", - table_name="orders", + Index(dialect, "idx_orders_user_date"), + Table(dialect, "orders"), columns=["user_id", "created_at"], index_type="BTREE" ) @@ -441,10 +507,10 @@ sql, params = create_index.to_sql() # params: () # 带 INCLUDE 子句的索引(覆盖索引) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"], include=["id", "name"] ) @@ -453,19 +519,19 @@ sql, params = create_index.to_sql() # params: () ``` -#### DropIndexExpression +#### DropIndexStatement -`DropIndexExpression` 表示 `DROP INDEX` 语句。 +`DropIndexStatement` 表示 `DROP INDEX` 语句。 ```python -from rhosocial.activerecord.backend.expression.statement import DropIndexExpression +from rhosocial.activerecord.backend.expression.statements import DropIndexStatement # DROP INDEX -drop_index = DropIndexExpression( +drop_index = DropIndexStatement( dialect, - index_name="idx_old_index", + Index(dialect, "idx_old_index"), if_exists=True, - table_name="users" # 可选:提供表上下文 + table=Table(dialect, "users") # 可选:提供表上下文 ) sql, params = drop_index.to_sql() # sql: 'DROP INDEX IF EXISTS "idx_old_index" ON "users"' @@ -474,26 +540,27 @@ sql, params = drop_index.to_sql() ### Sequence DDL(序列定义语言) -#### CreateSequenceExpression +#### CreateSequenceStatement -`CreateSequenceExpression` 表示 `CREATE SEQUENCE` 语句,用于生成唯一数字标识符。 +`CreateSequenceStatement` 表示 `CREATE SEQUENCE` 语句,用于生成唯一数字标识符。 ```python -from rhosocial.activerecord.backend.expression.statement import CreateSequenceExpression +from rhosocial.activerecord.backend.expression.objects import Sequence +from rhosocial.activerecord.backend.expression.statements import CreateSequenceStatement # 基本 CREATE SEQUENCE -create_seq = CreateSequenceExpression( +create_seq = CreateSequenceStatement( dialect, - sequence_name="user_id_seq" + Sequence(dialect, "user_id_seq") ) sql, params = create_seq.to_sql() # sql: 'CREATE SEQUENCE "user_id_seq" NO CYCLE' # params: () # 带所有选项的 CREATE SEQUENCE -create_seq = CreateSequenceExpression( +create_seq = CreateSequenceStatement( dialect, - sequence_name="order_seq", + Sequence(dialect, "order_seq"), if_not_exists=True, start=1000, increment=1, @@ -508,34 +575,35 @@ sql, params = create_seq.to_sql() # params: () ``` -#### DropSequenceExpression +#### DropSequenceStatement -`DropSequenceExpression` 表示 `DROP SEQUENCE` 语句。 +`DropSequenceStatement` 表示 `DROP SEQUENCE` 语句。 ```python -from rhosocial.activerecord.backend.expression.statement import DropSequenceExpression +from rhosocial.activerecord.backend.expression.statements import DropSequenceStatement -drop_seq = DropSequenceExpression( +drop_seq = DropSequenceStatement( dialect, - sequence_name="old_seq", + Sequence(dialect, "old_seq"), if_exists=True ) sql, params = drop_seq.to_sql() # sql: 'DROP SEQUENCE IF EXISTS "old_seq"' # params: () + ``` -#### AlterSequenceExpression +#### AlterSequenceStatement -`AlterSequenceExpression` 表示 `ALTER SEQUENCE` 语句,用于修改现有序列。 +`AlterSequenceStatement` 表示 `ALTER SEQUENCE` 语句,用于修改现有序列。 ```python -from rhosocial.activerecord.backend.expression.statement import AlterSequenceExpression +from rhosocial.activerecord.backend.expression.statements import AlterSequenceStatement # 重启序列 -alter_seq = AlterSequenceExpression( +alter_seq = AlterSequenceStatement( dialect, - sequence_name="user_id_seq", + Sequence(dialect, "user_id_seq"), restart=1000 ) sql, params = alter_seq.to_sql() @@ -543,9 +611,9 @@ sql, params = alter_seq.to_sql() # params: () # 多项修改 -alter_seq = AlterSequenceExpression( +alter_seq = AlterSequenceStatement( dialect, - sequence_name="order_seq", + Sequence(dialect, "order_seq"), increment=2, maxvalue=1000000, cycle=True @@ -555,19 +623,30 @@ sql, params = alter_seq.to_sql() # params: () ``` -### MergeExpression (MERGE) +### MergeStatement (MERGE) -`MergeExpression` 表示 `MERGE` 语句 (Upsert)。 +`MergeStatement` 表示 `MERGE` 语句 (Upsert)。 ```python -from rhosocial.activerecord.backend.expression import MergeExpression, MergeAction, MergeActionType +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonOperator, + ComparisonPredicate, + MergeAction, + MergeActionType, + MergeStatement, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table # 定义动作 when_matched = MergeAction( + dialect, action_type=MergeActionType.UPDATE, assignments={"name": Column(dialect, "name", "source")} ) when_not_matched = MergeAction( + dialect, action_type=MergeActionType.INSERT, assignments={ "id": Column(dialect, "id", "source"), @@ -576,17 +655,22 @@ when_not_matched = MergeAction( ) # Merge 语句 -merge = MergeExpression( - dialect, - target_table=TableExpression(dialect, "target", "t"), - source=TableExpression(dialect, "source", "s"), - on_condition=Column(dialect, "id", "t") == Column(dialect, "id", "s"), +merge = MergeStatement( + dialect, + target_table=Table(dialect, "target"), + source=NamedRelationRef(dialect, Table(dialect, "source"), alias="s"), + on_condition=ComparisonPredicate( + dialect, + ComparisonOperator.EQ, + Column(dialect, "id", "target"), + Column(dialect, "id", "s") + ), when_matched=[when_matched], when_not_matched=[when_not_matched] ) sql, params = merge.to_sql() -# sql: 'MERGE INTO "target" AS "t" USING "source" AS "s" ON "t"."id" = "s"."id" ...' +# sql: 'MERGE INTO "target" USING "source" AS "s" ON "target"."id" = "s"."id" ...' # params: () ``` @@ -600,23 +684,29 @@ sql, params = merge.to_sql() > - MySQL: 不支持(可使用表替代) > - SQLite: 不支持 -#### CreateMaterializedViewExpression +#### CreateMaterializedViewStatement -`CreateMaterializedViewExpression` 表示 `CREATE MATERIALIZED VIEW` 语句。 +`CreateMaterializedViewStatement` 表示 `CREATE MATERIALIZED VIEW` 语句。 ```python -from rhosocial.activerecord.backend.expression.statement import CreateMaterializedViewExpression +from rhosocial.activerecord.backend.expression import ( + Column, + FunctionCall, + GroupByHavingClause, +) +from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table +from rhosocial.activerecord.backend.expression.statements import CreateMaterializedViewStatement # 基本 CREATE MATERIALIZED VIEW -query = QueryExpression( +query = QueryStatement( dialect, select=[Column(dialect, "user_id"), FunctionCall(dialect, "COUNT", Column(dialect, "id"))], - from_=TableExpression(dialect, "orders"), + from_=NamedRelationRef(dialect, Table(dialect, "orders")), group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "user_id")]) ) -create_mv = CreateMaterializedViewExpression( +create_mv = CreateMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), query=query ) sql, params = create_mv.to_sql() @@ -624,29 +714,29 @@ sql, params = create_mv.to_sql() # params: () # 带 TABLESPACE 的物化视图(PostgreSQL) -create_mv = CreateMaterializedViewExpression( +create_mv = CreateMaterializedViewStatement( dialect, - view_name="user_summary", + MaterializedView(dialect, "user_summary"), query=query, tablespace="fast_storage", - with_data=False + no_data=True ) sql, params = create_mv.to_sql() # sql: 'CREATE MATERIALIZED VIEW "user_summary" TABLESPACE "fast_storage" AS SELECT ... WITH NO DATA' # params: () ``` -#### DropMaterializedViewExpression +#### DropMaterializedViewStatement -`DropMaterializedViewExpression` 表示 `DROP MATERIALIZED VIEW` 语句。 +`DropMaterializedViewStatement` 表示 `DROP MATERIALIZED VIEW` 语句。 ```python -from rhosocial.activerecord.backend.expression.statement import DropMaterializedViewExpression +from rhosocial.activerecord.backend.expression.statements import DropMaterializedViewStatement # DROP MATERIALIZED VIEW -drop_mv = DropMaterializedViewExpression( +drop_mv = DropMaterializedViewStatement( dialect, - view_name="old_summary", + MaterializedView(dialect, "old_summary"), if_exists=True, cascade=True ) @@ -655,26 +745,26 @@ sql, params = drop_mv.to_sql() # params: () ``` -#### RefreshMaterializedViewExpression +#### RefreshMaterializedViewStatement -`RefreshMaterializedViewExpression` 表示 `REFRESH MATERIALIZED VIEW` 语句,用于刷新物化视图的数据。 +`RefreshMaterializedViewStatement` 表示 `REFRESH MATERIALIZED VIEW` 语句,用于刷新物化视图的数据。 ```python -from rhosocial.activerecord.backend.expression.statement import RefreshMaterializedViewExpression +from rhosocial.activerecord.backend.expression.statements import RefreshMaterializedViewStatement # 基本刷新 -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts" + MaterializedView(dialect, "order_counts") ) sql, params = refresh_mv.to_sql() # sql: 'REFRESH MATERIALIZED VIEW "order_counts"' # params: () # CONCURRENTLY 刷新(PostgreSQL 9.4+,需要唯一索引) -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), concurrent=True ) sql, params = refresh_mv.to_sql() @@ -682,9 +772,9 @@ sql, params = refresh_mv.to_sql() # params: () # 带 WITH DATA/WITH NO DATA 选项 -refresh_mv = RefreshMaterializedViewExpression( +refresh_mv = RefreshMaterializedViewStatement( dialect, - view_name="order_counts", + MaterializedView(dialect, "order_counts"), with_data=True ) sql, params = refresh_mv.to_sql() @@ -705,25 +795,34 @@ sql, params = refresh_mv.to_sql() | `HASH` | 哈希分区 | `PARTITION BY HASH (user_id)` | ```python -from rhosocial.activerecord.backend.expression.statements.ddl_partition import PartitionClause, PartitionStrategy +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnConstraint, + ColumnDefinition, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.expression.types import DecimalType, IntegerType, TimestampType -# RANGE 分区 +# RANGE 分区 —— keys 是表达式,不是字符串 partition = PartitionClause( dialect, method=PartitionStrategy.RANGE, - keys=["created_at"] + keys=[Column(dialect, "created_at")] ) # 在 CREATE TABLE 中使用 -from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType -from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType, DecimalType - -create = CreateTableExpression( +create = CreateTableStatement( dialect, - table="orders", + Table(dialect, "orders"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ColumnDefinition(dialect, "amount", DecimalType(dialect, precision=10, scale=2)), @@ -734,6 +833,8 @@ create = CreateTableExpression( # "amount" DECIMAL(10,2)) PARTITION BY RANGE ("created_at")' # params: () ``` +# params: () +``` ### 方言检查 @@ -743,6 +844,7 @@ create = CreateTableExpression( # 检查表分区支持 if dialect.supports_table_partitioning(): # 可以使用 PARTITION BY + pass # 检查具体分区类型 if dialect.supports_range_table_partitioning(): @@ -771,7 +873,7 @@ if dialect.supports_refresh_materialized_view(): pass # 检查 CONCURRENTLY 刷新支持(PostgreSQL 9.4+) -if dialect.supports_materialized_view_concurrent_refresh(): +if dialect.supports_refresh_materialized_view(): # 可以使用 CONCURRENTLY 选项 pass @@ -780,8 +882,8 @@ if dialect.supports_materialized_view_tablespace(): # 可以为物化视图指定表空间 pass -# 检查存储参数支持 -if dialect.supports_materialized_view_storage_options(): - # 可以指定存储参数 +# 检查 WITH [NO] DATA 子句支持 +if dialect.supports_with_data_clause(): + # 可以拼写 WITH [NO] DATA 子句 pass ``` diff --git a/docs/zh_CN/backend/introspection.md b/docs/zh_CN/backend/introspection.md index 843221c6f..fc0d890ab 100644 --- a/docs/zh_CN/backend/introspection.md +++ b/docs/zh_CN/backend/introspection.md @@ -7,7 +7,7 @@ 内省系统位于 `backend.introspector` 属性中,提供: - **数据库信息**:名称、版本、编码、大小等 -- **表信息**:表列表、表详情、表类型(基表、视图、系统表) +- **表信息**:表列表、表详情、表类型(`BASE_TABLE`、`VIEW`、`MATERIALIZED_VIEW`、`FOREIGN_TABLE`、`PARTITIONED_TABLE`、`SYSTEM_TABLE`、`TEMPORARY`、`EXTERNAL`) - **列信息**:列名、数据类型、是否可空、默认值、主键信息 - **索引信息**:索引名、列、唯一性、索引类型 - **外键信息**:引用表、列映射、更新/删除行为 @@ -21,30 +21,39 @@ ``` backend/introspection/ ├── __init__.py # 模块导出 -├── base.py # 抽象基类 -├── types.py # 数据结构定义 +├── base.py # 抽象内省器基类(同步 / 异步) +├── types.py # 数据结构定义(TableType 等) ├── errors.py # 内省专用异常 ├── executor.py # 执行器抽象 -└── backend_mixin.py # Backend 混入类 +├── cache.py # IntrospectionCache —— 可注入的结果缓存 +├── backend_mixin.py # Backend 混入类 +└── status/ # 服务器状态概览(配置、性能、存储) ``` +### 一个目录问题就是一个语句 + +内省不会就地拼接 SQL 文本。每个问题都是一个语句类(名为 `*Statement`,例如 `TableListQueryStatement`),由 dialect 的 `format_*_query_statement` 格式化;内省器持有使用它们的公开方法对(`list_tables()` / `parse_tables()` 等)。这带来两点对后端作者重要的后果: + +- 表达式层不持有状态 —— 语句只包含渲染它所需要的全部信息; +- 后端回答一个目录问题的方式,是声明一个语句加一个格式化方法,而不是覆盖某个拼字符串的方法。 + ### 同步与异步分离 内省系统遵循项目的同步/异步对等原则: - `SyncAbstractIntrospector` — 同步内省器 - `AsyncAbstractIntrospector` — 异步内省器 -- `IntrospectorMixin` — 共享的非 I/O 逻辑(缓存、SQL 生成、解析) +- `IntrospectorMixin` — 共享的非 I/O 逻辑(缓存、SQL 生成、解析),包括 `server_version`:内省器从服务器读到的版本,使能力判断不必再次询问服务器 ## 数据结构 -### DatabaseInfo +### IntrospectionDatabaseInfo 数据库基本信息: ```python @dataclass -class DatabaseInfo: +class IntrospectionDatabaseInfo: name: str # 数据库名称 version: str # 版本字符串 version_tuple: Tuple[int, ...] # 版本元组 @@ -57,29 +66,33 @@ class DatabaseInfo: extra: Dict[str, Any] # 额外信息 ``` -### TableInfo +### IntrospectionTableInfo 表信息: ```python @dataclass -class TableInfo: +class IntrospectionTableInfo: name: str # 表名 schema: str # Schema 名称 - table_type: TableType # 表类型 + table_type: TableType # 表类型(BASE_TABLE、VIEW、MATERIALIZED_VIEW、 + # FOREIGN_TABLE、PARTITIONED_TABLE、SYSTEM_TABLE、 + # TEMPORARY、EXTERNAL) comment: Optional[str] # 表注释 row_count: Optional[int] # 行数估计 size_bytes: Optional[int] # 表大小 extra: Dict[str, Any] # 额外信息 ``` -### ColumnInfo +`table_type` 保留目录实际做出的区分,而不是把所有关系都折叠成"表"。例如在 SQLite 上,FTS 或 R-Tree 虚拟表背后的存储表是 `SYSTEM_TABLE`,而虚拟表本身是 `EXTERNAL`。 + +### IntrospectionColumnInfo 列信息: ```python @dataclass -class ColumnInfo: +class IntrospectionColumnInfo: name: str # 列名 table_name: str # 所属表名 data_type: str # 数据类型 @@ -110,13 +123,13 @@ class IndexInfo: extra: Dict[str, Any] # 额外信息 ``` -### ForeignKeyInfo +### IntrospectionForeignKeyInfo 外键信息: ```python @dataclass -class ForeignKeyInfo: +class IntrospectionForeignKeyInfo: name: str # 外键名 table_name: str # 所属表名 columns: List[str] # 外键列 @@ -128,13 +141,13 @@ class ForeignKeyInfo: extra: Dict[str, Any] # 额外信息 ``` -### ViewInfo +### IntrospectionViewInfo 视图信息: ```python @dataclass -class ViewInfo: +class IntrospectionViewInfo: name: str # 视图名 schema: str # Schema 名称 definition: str # 视图定义 SQL @@ -143,13 +156,13 @@ class ViewInfo: extra: Dict[str, Any] # 额外信息 ``` -### TriggerInfo +### IntrospectionTriggerInfo 触发器信息: ```python @dataclass -class TriggerInfo: +class IntrospectionTriggerInfo: name: str # 触发器名 table_name: str # 关联表名 event: TriggerEvent # 触发事件 @@ -317,13 +330,46 @@ triggers = await backend.introspector.list_triggers() ## 缓存机制 -内省结果默认会被缓存以提高性能。 +内省结果默认会被缓存以提高性能。缓存是管理器的**组件**——管理器拥有它,调用者可以替换它——而不是隐藏属性:`backend.introspector.cache`。 + +### 缓存契约 -### 缓存配置 +`IntrospectionCache`(`rhosocial.activerecord.backend.introspection.cache`)是一个协议,因此后端可以把自有实现放进管理器后面,而不必继承本包的过期策略: + +| 方法 | 用途 | +|------|------| +| `key(scope, name=None, schema=None, extra=None)` | 生成"问同一个问题"的两次调用共享的键 | +| `get(key)` | 返回缓存结果,或 `None` | +| `set(key, value)` | 存入结果 | +| `invalidate(scope=None, name=None)` | 从全体失效到单个对象失效 | +| `clear()` | 丢弃全部缓存结果 | + +`InMemoryIntrospectionCache` 是默认实现:进程内、按 TTL 过期(默认 300 秒),过期项在有人查看时才被丢弃,而不是由后台清扫器处理。 ```python -# 默认 TTL 为 300 秒(5 分钟) -# 内省器初始化时自动设置 +from rhosocial.activerecord.backend.introspection.cache import InMemoryIntrospectionCache + +# 默认:进程内、300 秒 TTL +backend.introspector.cache = InMemoryIntrospectionCache(ttl=60) + +# 或使用自己的缓存:只要满足该协议 +class NoCache: + def key(self, scope, name=None, schema=None, extra=None): + return (scope, name, schema, extra) + + def get(self, key): + return None + + def set(self, key, value): + pass + + def invalidate(self, scope=None, name=None): + pass + + def clear(self): + pass + +backend.introspector.cache = NoCache() ``` ### 缓存管理 @@ -338,38 +384,31 @@ from rhosocial.activerecord.backend.introspection.types import IntrospectionScop # 使所有表相关缓存失效 backend.introspector.invalidate_cache(scope=IntrospectionScope.TABLE) -# 使特定表的缓存失效 +# 使特定表、索引、列集合……的缓存失效 backend.introspector.invalidate_cache( scope=IntrospectionScope.TABLE, name="users" ) - -# 使列相关缓存失效 -backend.introspector.invalidate_cache( - scope=IntrospectionScope.COLUMN, - table_name="users" -) - -# 使索引相关缓存失效 backend.introspector.invalidate_cache( scope=IntrospectionScope.INDEX, - table_name="users", name="idx_users_email" ) ``` ### 缓存作用域 -| 作用域 | 说明 | 失效参数 | -|--------|------|----------| -| `DATABASE` | 数据库信息 | 无 | -| `SCHEMA` | Schema 信息 | `name` | -| `TABLE` | 表信息 | `name` | -| `COLUMN` | 列信息 | `table_name`, `name` | -| `INDEX` | 索引信息 | `table_name`, `name` | -| `FOREIGN_KEY` | 外键信息 | `table_name`, `name` | -| `VIEW` | 视图信息 | `name` | -| `TRIGGER` | 触发器信息 | `table_name`, `name` | +作用域说明一次失效波及哪些已缓存的答案,所以问题涉及的表由 `name` 携带(在 `key()` 中则由 `schema` / `extra` 携带): + +| 作用域 | 说明 | +|--------|------| +| `DATABASE` | 数据库信息 | +| `SCHEMA` | Schema 信息 | +| `TABLE` | 表信息 | +| `COLUMN` | 列信息 | +| `INDEX` | 索引信息 | +| `FOREIGN_KEY` | 外键信息 | +| `VIEW` | 视图信息 | +| `TRIGGER` | 触发器信息 | ## 后端特定实现 diff --git a/docs/zh_CN/backend/named_expression.md b/docs/zh_CN/backend/named_expression.md index acb084d0f..81c6b8d48 100644 --- a/docs/zh_CN/backend/named_expression.md +++ b/docs/zh_CN/backend/named_expression.md @@ -77,19 +77,29 @@ flowchart TB 命名查询将查询逻辑封装为**纯 Python 函数**。关键创新在于 `dialect` 参数由框架在执行时自动注入,允许同一查询代码在不同数据库后端上工作。 ```python +from rhosocial.activerecord.backend.expression import ( + Column, NamedRelationRef, QueryStatement, StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + # 定义一次,可在任何方言下使用 def active_users(dialect, limit: int = 100, status: str = "active"): """从数据库获取活跃用户。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users"), - where=Column(dialect, "status") == status, + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=StringColumn(dialect, "status") == status, limit_offset=LimitOffsetClause(dialect, limit=limit), ) + # 使用 SQLite 方言执行时,生成 SQLite SQL # 使用 MySQL 方言执行时,生成 MySQL SQL +sql, params = active_users(dialect).to_sql() +print(sql, params) ``` ### 命名查询的调用方式 @@ -149,41 +159,53 @@ python -m rhosocial.activerecord.backend.impl.sqlite named-expression \ #### 2. 程序化 API(Python 代码) -程序化 API 提供更多控制,与应用程序集成良好: +程序化 API 提供更多控制,与应用程序集成良好。下面的代码块指向库自带的 +SQLite 示例模块,因此可以直接运行;实际使用时请替换成你自己的模块路径。 **一步式方法(快速):** ```python from rhosocial.activerecord.backend.named_expression import resolve_named_expression from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -backend = SQLiteBackend(database="mydb.sqlite") +# 指向可导入模块的点分路径 —— 这里用库自带的示例模块 +QUERY = ( + "rhosocial.activerecord.backend.impl.sqlite.examples" + ".named_expressions.order_clauses.where_example" +) + +backend = SQLiteBackend(database=":memory:") dialect = backend.dialect -# 一步完成解析、生成 SQL 和执行 -expr, sql, params = resolve_named_expression( - "myapp.queries.users.active_users", - dialect, - {"limit": 50, "status": "active"}, -) +# 解析命名查询:返回的是表达式,不是 SQL +expr = resolve_named_expression(QUERY, dialect, {"status": "active"}) +sql, params = expr.to_sql() print("生成的 SQL:", sql) +print("参数:", params) # 可与 pandas、直接执行等配合使用 ``` **分步方法(灵活):** ```python -from rhosocial.activerecord.backend.named_expression import NamedQueryResolver +from rhosocial.activerecord.backend.named_expression import NamedExpressionResolver + +# 与上例相同的点分路径 +QUERY = ( + "rhosocial.activerecord.backend.impl.sqlite.examples" + ".named_expressions.order_clauses.where_example" +) # 加载命名查询解析器 -resolver = NamedQueryResolver("myapp.queries.users.active_users").load() +resolver = NamedExpressionResolver(QUERY).load() # 检查而不执行 info = resolver.describe() -print(f"参数: {info['parameters']}") -print(f"返回类型: {info['return_type']}") +print(f"参数: {list(info['parameters'])}") +print(f"签名: {info['signature']}") # 在需要时生成表达式和 SQL -expr = resolver.execute(dialect, {"limit": 50}) +expr = resolver.execute(dialect, {"status": "active"}) sql, params = expr.to_sql() +print(sql, params) ``` #### 3. CI/CD 集成 @@ -296,7 +318,9 @@ class MonthlyCleanupProcedure(Procedure): 对于可以并发运行的独立子任务,使用 `ctx.parallel()`: ```python -from rhosocial.activerecord.backend.named_expression import Procedure, ProcedureContext, ParallelStep +from rhosocial.activerecord.backend.named_expression import ( + Procedure, ProcedureContext, ParallelStep, +) class OrderProcessingProcedure(Procedure): order_id: int @@ -388,33 +412,67 @@ python -m rhosocial.activerecord.backend.impl.sqlite named-procedure \ #### 程序化 API ```python +import sys +import types + +from rhosocial.activerecord.backend.expression import ( + Column, NamedRelationRef, QueryStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.named_expression import ( - ProcedureRunner, TransactionMode, ProcedureResult, + Procedure, ProcedureContext, ProcedureRunner, TransactionMode, ) -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -backend = SQLiteBackend(database="mydb.sqlite") -dialect = backend.dialect +# 运行器按点分名解析,因此查询和过程必须位于可导入的模块中。 +# 这里把它们注册到 sys.modules 里,使示例自包含;真实项目中 +# 它们是 "myapp" 下的普通模块。 +backend.execute("CREATE TABLE IF NOT EXISTS demo_orders (id INTEGER PRIMARY KEY)") +backend.execute("INSERT INTO demo_orders (id) VALUES (1)") + +queries = types.ModuleType("doc_demo.queries") + + +def count_orders(dialect): + """统计示例订单数。""" + return QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "demo_orders")), + ) + + +queries.count_orders = count_orders +sys.modules["doc_demo.queries"] = queries + + +class ArchiveProcedure(Procedure): + month: str = "2026-03" + + def run(self, ctx: ProcedureContext) -> None: + ctx.execute("doc_demo.queries.count_orders", bind="orders") + count = len(list(ctx.rows("orders"))) + ctx.log(f"{count} order(s) for {self.month}", level="INFO") + + +procedures = types.ModuleType("doc_demo.procedures") +procedures.ArchiveProcedure = ArchiveProcedure +sys.modules["doc_demo.procedures"] = procedures # 加载过程类 -runner = ProcedureRunner( - "myapp.procedures.monthly_cleanup.MonthlyCleanupProcedure" -).load() +runner = ProcedureRunner("doc_demo.procedures.ArchiveProcedure").load() # 使用参数执行 -result: ProcedureResult = runner.run( - dialect, +result = runner.run( + backend, user_params={"month": "2026-03"}, transaction_mode=TransactionMode.AUTO, - backend=backend, - execute_query=backend.execute, ) # 检查结果 if result.aborted: - print(f"⚠️ 已终止: {result.abort_reason}") + print(f"已终止: {result.abort_reason}") else: - print(f"✅ 完成,输出步骤数: {len(result.outputs)}") + print(f"完成,输出步骤数: {len(result.outputs)}") # 访问日志 for entry in result.logs: @@ -457,7 +515,7 @@ from rhosocial.activerecord.backend.named_expression import AsyncProcedureRunner from rhosocial.activerecord.backend.impl.sqlite.backend import AsyncSQLiteBackend app = FastAPI() -async_backend = AsyncSQLiteBackend(database="mydb.sqlite") +async_backend = AsyncSQLiteBackend(database=":memory:") @app.post("/admin/cleanup/{month}") async def run_cleanup(month: str): @@ -465,22 +523,20 @@ async def run_cleanup(month: str): "myapp.procedures.monthly_cleanup_async.MonthlyCleanupAsyncProcedure" ).load() result = await runner.run( - async_backend.dialect, + async_backend, user_params={"month": month}, transaction_mode=TransactionMode.AUTO, - backend=async_backend, - execute_query=async_backend.execute, ) return { "aborted": result.aborted, "abort_reason": result.abort_reason, - "outputs": result.outputs, + "outputs": len(result.outputs), } ``` > **注意**: > - `AsyncProcedure` 子类只能交给 `AsyncProcedureRunner`;`Procedure` 子类只能交给 `ProcedureRunner`。两者不可混用。 -> - `ProcedureRunner` 需要**同步**后端(如 `SQLiteBackend`、`MySQLBackend`);`AsyncProcedureRunner` 需要**异步**后端(如 `AsyncSQLiteBackend`、`AsyncMySQLBackend`)。传入错误类型的后端将在运行时抛出 `NamedQueryError`。 +> - `ProcedureRunner` 需要**同步**后端(如 `SQLiteBackend`、`MySQLBackend`);`AsyncProcedureRunner` 需要**异步**后端(如 `AsyncSQLiteBackend`、`AsyncMySQLBackend`)。传入错误类型的后端将在运行时抛出 `NamedExpressionError`。 --- @@ -512,6 +568,16 @@ async def run_cleanup(month: str): ```python from rhosocial.activerecord.backend.named_expression import Procedure +class MyProcedure(Procedure): + """一个无需数据库即可绘制流程图的过程。""" + month: str = "2026-03" + + def run(self, ctx): + ctx.execute( + "myapp.queries.orders.count_monthly_orders", + params={"month": self.month}, + ) + # 流程图格式 print(MyProcedure.static_diagram("flowchart")) @@ -524,16 +590,11 @@ print(MyProcedure.static_diagram("sequence")) 执行过程后,生成显示实际执行状态和时间的流程图: ```python -from rhosocial.activerecord.backend.named_expression import ProcedureRunner - -runner = ProcedureRunner("myapp.procedures.OrderProcessing").load() -result = runner.run(backend) - -# 带执行状态的流程图格式 -print(result.diagram("flowchart", procedure_name="OrderProcessing")) +# runner 与 result 来自上面的程序化 API 示例 +print(result.diagram("flowchart", procedure_name="ArchiveProcedure")) # 时序图格式 -print(result.diagram("sequence", procedure_name="OrderProcessing")) +print(result.diagram("sequence", procedure_name="ArchiveProcedure")) ``` ### 特性对比 @@ -553,20 +614,21 @@ print(result.diagram("sequence", procedure_name="OrderProcessing")) ### 异常 -- `NamedQueryError` - 所有命名查询/过程错误的基础异常 -- `NamedQueryNotFoundError` - 找不到查询函数 -- `NamedQueryModuleNotFoundError` - 找不到包含查询的模块 -- `NamedQueryInvalidReturnTypeError` - 查询未返回有效表达式 -- `NamedQueryInvalidParameterError` - 提供了无效参数 -- `NamedQueryMissingParameterError` - 未提供必需参数 -- `NamedQueryNotCallableError` - 命名对象不可调用 -- `NamedQueryExplainNotAllowedError` - 不允许对此查询类型执行 EXPLAIN +- `NamedExpressionError` - 所有命名查询/过程错误的基础异常 +- `NamedExpressionNotFoundError` - 找不到查询函数 +- `NamedExpressionModuleNotFoundError` - 找不到包含查询的模块 +- `NamedExpressionModuleNotAllowedError` - 模块不在解析器的允许列表中 +- `NamedExpressionInvalidReturnTypeError` - 查询未返回有效表达式 +- `NamedExpressionInvalidParameterError` - 提供了无效参数 +- `NamedExpressionMissingParameterError` - 未提供必需参数 +- `NamedExpressionNotCallableError` - 命名对象不可调用 +- `NamedExpressionExplainNotAllowedError` - 不允许对此查询类型执行 EXPLAIN ### 命名查询 API -- `NamedQueryResolver` - 用于加载和执行命名查询的主解析器类 -- `resolve_named_expression()` - 一步式解析和执行的便捷函数 -- `list_named_queries_in_module()` - 发现模块中的所有查询 +- `NamedExpressionResolver` - 用于加载和执行命名查询的主解析器类 +- `resolve_named_expression()` - 一步式解析并构建表达式;返回表达式,SQL 生成仍是显式步骤 +- `list_named_expressions_in_module()` - 发现模块中的所有查询 ### 命名过程 API @@ -589,7 +651,7 @@ print(result.diagram("sequence", procedure_name="OrderProcessing")) | 示例 | 说明 | |------|------| -| `named_queries/order_queries.py` | 订单相关的命名查询定义,包含完整的 Setup/Business Logic/Execution/Teardown 结构 | +| `named_expressions/order_expressions.py` | 订单相关的命名查询定义,包含完整的 Setup/Business Logic/Execution/Teardown 结构 | | `named_procedures/order_workflow.py` | 订单处理工作流,演示并行执行 | | `named_procedures/diagram_demo.py` | 流程图可视化演示 | @@ -597,7 +659,7 @@ print(result.diagram("sequence", procedure_name="OrderProcessing")) | 示例 | 说明 | |------|------| -| `cli/named_expression_demo.py` | 演示所有命名查询 CLI 操作(列出、描述、dry-run、执行) | +| `cli/named_query_demo.py` | 演示所有命名查询 CLI 操作(列出、描述、dry-run、执行) | | `cli/named_procedure_demo.py` | 演示所有命名过程 CLI 操作(列出、描述、dry-run、执行、事务模式) | | `cli/named_connection_demo.py` | 演示命名连接 CLI 操作 | @@ -606,7 +668,7 @@ print(result.diagram("sequence", procedure_name="OrderProcessing")) ```bash # Python 示例:运行命名查询 cd src/rhosocial/activerecord/backend/impl/sqlite/examples -PYTHONPATH=../../../../..:. python3 named_queries/order_queries.py +PYTHONPATH=../../../../..:. python3 named_expressions/order_expressions.py # Python 示例:生成流程图 cd src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures @@ -614,7 +676,7 @@ PYTHONPATH=../../../../..:. python3 diagram_demo.py # CLI 示例:命名查询 CLI cd src/rhosocial/activerecord/backend/impl/sqlite/examples -PYTHONPATH=../../../../..:. python3 cli/named_expression_demo.py +PYTHONPATH=../../../../..:. python3 cli/named_query_demo.py # CLI 示例:命名过程 CLI PYTHONPATH=../../../../..:. python3 cli/named_procedure_demo.py diff --git a/docs/zh_CN/backend/sqlite/backend_specific_features/fts5.md b/docs/zh_CN/backend/sqlite/backend_specific_features/fts5.md index 39f956424..e17e7059c 100644 --- a/docs/zh_CN/backend/sqlite/backend_specific_features/fts5.md +++ b/docs/zh_CN/backend/sqlite/backend_specific_features/fts5.md @@ -557,7 +557,7 @@ class FTS5Extension(SQLiteExtensionBase): def get_supported_tokenizers(self, version: Tuple[int, int, int]) -> List[str]: """获取指定版本支持的 tokenizer""" - def format_create_virtual_table(...) -> Tuple[str, tuple]: + def format_create_virtual_table_statement(...) -> Tuple[str, tuple]: """生成创建语句""" def format_match_expression(...) -> Tuple[str, tuple]: @@ -572,7 +572,7 @@ class FTS5Extension(SQLiteExtensionBase): def format_snippet_expression(...) -> Tuple[str, tuple]: """生成摘要表达式""" - def format_drop_virtual_table(...) -> Tuple[str, tuple]: + def format_drop_virtual_table_statement(...) -> Tuple[str, tuple]: """生成删除语句""" ``` diff --git a/docs/zh_CN/backend/sqlite/cli/README.md b/docs/zh_CN/backend/sqlite/cli/README.md index 931c10b1e..dfa1ad953 100644 --- a/docs/zh_CN/backend/sqlite/cli/README.md +++ b/docs/zh_CN/backend/sqlite/cli/README.md @@ -6,8 +6,8 @@ SQLite 后端包含用于数据库操作的命令行界面。CLI 提供了查询 CLI 命令分为两类: -1. **SQLite 特定命令** —— SQLite 独有的操作(query、introspect、info) -2. **核心继承命令** —— 所有后端共享(named-expression、named-procedure、named-migration、named-connection) +1. **SQLite 特定命令** —— SQLite 独有的操作(query、introspect、status、info) +2. **核心继承命令** —— 所有后端共享(named-expression、named-procedure、named-procedure-graph、named-migration、named-connection) ## 调用 @@ -136,8 +136,9 @@ rhosocial-activerecord-sqlite query -o json "SELECT * FROM users;" # 列出所有表 rhosocial-activerecord-sqlite introspect tables --db-file my.db -# 列出所有视图 +# 列出所有视图,或描述单个视图 rhosocial-activerecord-sqlite introspect views --db-file my.db +rhosocial-activerecord-sqlite introspect views active_users --db-file my.db # 获取数据库信息 rhosocial-activerecord-sqlite introspect database --db-file my.db @@ -145,27 +146,71 @@ rhosocial-activerecord-sqlite introspect database --db-file my.db # 包括系统表 rhosocial-activerecord-sqlite introspect tables --db-file my.db --include-system -# 获取完整表信息(列、索引、外键) -rhosocial-activerecord-sqlite introspect table users --db-file my.db - # 查询特定详细信息 rhosocial-activerecord-sqlite introspect columns users --db-file my.db rhosocial-activerecord-sqlite introspect indexes users --db-file my.db rhosocial-activerecord-sqlite introspect foreign-keys posts --db-file my.db + +# 某张表的触发器 +rhosocial-activerecord-sqlite introspect triggers posts --db-file my.db ``` #### 自省类型 -| 类型 | 描述 | 需要表名 | +| 类型 | 描述 | 名称参数 | |------|------|---------| -| `tables` | 列出所有表 | 否 | -| `views` | 列出所有视图 | 否 | -| `database` | 数据库信息 | 否 | -| `table` | 完整表详情(列、索引、外键) | 是 | -| `columns` | 列信息 | 是 | -| `indexes` | 索引信息 | 是 | -| `foreign-keys` | 外键信息 | 是 | -| `triggers` | 触发器信息 | 可选 | +| `tables` | 列出所有表 | 不接受 | +| `views` | 列出所有视图,或描述单个视图 | 可选(视图名) | +| `database` | 数据库信息 | 不接受 | +| `columns` | 列信息,含生成列 | 必填(表名) | +| `indexes` | 索引信息 | 必填(表名) | +| `foreign-keys` | 外键信息 | 必填(表名) | +| `triggers` | 触发器信息 | 可选(表名) | + +每种自省类型只报告一类目录项;如需按表查看完整信息,请分别查询 `columns`、`indexes` 和 `foreign-keys`,不要期望一次得到合并报表。 + +类型不接受名称参数时会报错(退出码 2),而不是静默忽略:`introspect tables users` 曾经会把整张表列出来,却读起来像是已经按名过滤。 + +原始 `PRAGMA` 子内省器(`backend.introspector.pragma`)只在库内可达。它的十四个方法不是目录问题:`integrity_check`、`foreign_key_check`、`wal_checkpoint` 是动作,`journal_mode` 与 `set` 改的是状态,`table_info`/`index_list` 则是上面七个类型已经封装过的底层读数。`introspect` 回答的是"这个库有什么";再开一扇通往同一批事实的门,或者通往动作的门,这句话就不成立了。 + +### status + +显示服务器状态概览: + +```bash +# 完整概览 +rhosocial-activerecord-sqlite status --db-file my.db + +# 仅配置参数 +rhosocial-activerecord-sqlite status config --db-file my.db + +# 仅性能计数器 +rhosocial-activerecord-sqlite status performance --db-file my.db + +# 仅存储占用 +rhosocial-activerecord-sqlite status storage --db-file my.db + +# 仅已附加数据库 +rhosocial-activerecord-sqlite status databases --db-file my.db + +# 增加描述列 +rhosocial-activerecord-sqlite status config -v --db-file my.db + +# JSON 输出 +rhosocial-activerecord-sqlite status -o json --db-file my.db +``` + +状态类型: + +| 类型 | 描述 | +|------|------| +| `all` | 完整概览(默认) | +| `config` | 配置参数 | +| `performance` | 性能计数器 | +| `storage` | 存储占用 | +| `databases` | 已附加数据库 | + +`all` 类型输出的是嵌套结构,因此对它的 `csv`、`tsv` 请求会回退为 `json`。 ## 核心继承命令 @@ -256,6 +301,24 @@ rhosocial-activerecord-sqlite named-procedure \ --db-file mydb.sqlite ``` +### named-procedure-graph + +执行 Python 模块中描述的过程图(DAG 工作流): + +```bash +# 运行过程图 +rhosocial-activerecord-sqlite named-procedure-graph run \ + myapp.npg.monthly_report \ + --db-file mydb.sqlite \ + --params '{"month": "2026-04"}' + +# 列出模块中定义的过程图 +rhosocial-activerecord-sqlite named-procedure-graph list myapp.npg + +# 仅校验而不运行 +rhosocial-activerecord-sqlite named-procedure-graph validate myapp.npg.monthly_report +``` + ### named-migration 执行命名迁移: @@ -315,10 +378,12 @@ backend/impl/sqlite/ ├── info.py # 'info' 命令处理程序 ├── query.py # 'query' 命令处理程序 ├── introspect.py # 'introspect' 命令处理程序 + ├── status.py # 'status' 命令处理程序 │ │ # 核心继承命令(薄适配器) ├── named_expression.py # 委托到核心 named_expression.cli ├── named_procedure.py # 委托到核心 named_expression.procedure.cli + ├── named_procedure_graph.py # 委托到核心 named_expression.procedure.cli ├── named_migration.py # 委托到核心 migration.cli └── named_connection.py # 委托到核心 named_connection.cli ``` diff --git a/docs/zh_CN/introduction/coming_from_frameworks.md b/docs/zh_CN/introduction/coming_from_frameworks.md index cc2d572d3..25b9c71a4 100644 --- a/docs/zh_CN/introduction/coming_from_frameworks.md +++ b/docs/zh_CN/introduction/coming_from_frameworks.md @@ -35,7 +35,7 @@ | `Column(Integer)` | `int` 配合类型提示 | 原生 Python 类型 | | `session.add()` | `.save()` | 持久化对象 | | `session.commit()` | 自动提交或显式 | 事务处理 | -| `select()` | `QueryExpression` | SQL 表达式构建 | +| `select()` | `QueryStatement` | SQL 表达式构建 | | `text()` 原始 SQL | 谨慎使用 | 我们更倾向于表达式 | **关键差异:** diff --git a/docs/zh_CN/introduction/competitor_analysis/distinguishing_features.md b/docs/zh_CN/introduction/competitor_analysis/distinguishing_features.md index 2f20ba1de..88f30fb9c 100644 --- a/docs/zh_CN/introduction/competitor_analysis/distinguishing_features.md +++ b/docs/zh_CN/introduction/competitor_analysis/distinguishing_features.md @@ -30,7 +30,7 @@ rhosocial-activerecord 会拒绝继续——它抛出显式错误(`Unsupported # 每个表达式只声明一件事:渲染它的方言方法名。 @property def format_method(self) -> str: - return "format_cte_expression" # 例如 CTEQuery 声明这个 + return "format_cte_clause" # 例如 CTEClause 声明这个 ``` `BaseExpression.to_sql()` **只在根部实现一次**,是唯一的渲染入口。具体表达式类 @@ -46,7 +46,7 @@ SQLQueryAndParams = Tuple[str, tuple] 这一设计直接带来三条性质: -1. **参数值绝不进入 SQL 文本。** 默认 `inline_literals=False`,字面量渲染成绑定参数 +1. **参数值绝不进入 SQL 文本。** 默认 `inline=False`,字面量渲染成绑定参数 占位符(`?`,或按方言 `%s`/`$1` 等由 `get_parameter_placeholder()` 决定),参数值 放在独立的元组里。内联渲染是显式开启的、涉及安全的选择,仅留给完全由开发者声明、 不暴露给终端用户的内容(例如派生出的 DDL)。 diff --git a/docs/zh_CN/modeling/batch_processing.md b/docs/zh_CN/modeling/batch_processing.md index cc57530de..b22f632e1 100644 --- a/docs/zh_CN/modeling/batch_processing.md +++ b/docs/zh_CN/modeling/batch_processing.md @@ -6,6 +6,46 @@ > 💡 **AI 提示词:** "我需要处理一张有一千万行数据的表,但不能撑爆内存。 > rhosocial-activerecord 推荐什么方案?" +本页所有示例共用下面的初始化代码:一个基于内存 SQLite 的小模型,写入若干行数据, +以及示例中调用的辅助函数。 + +```python +from typing import ClassVar, Optional + +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.model import ActiveRecord + + +class User(ActiveRecord): + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: str + + +def process(row): + """代替实际任务对单行所做的处理。""" + return row["id"] if isinstance(row, dict) else row.id + + +def chunked(items, size): + for start in range(0, len(items), size): + yield items[start:start + size] + + +User.configure(SQLiteConnectionConfig(database=":memory:"), SQLiteBackend) +User.backend().execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" +) +for i in range(1, 12): + User(name=f"user{i}", email=f"user{i}@example.com").save() + +source_data = [{"name": f"new{i}", "email": f"new{i}@example.com"} for i in range(1, 6)] +``` + --- ## 1. 核心问题:内存 @@ -84,17 +124,21 @@ for user in iter_users_by_cursor(): 框架提供了 `execute_batch_dql` 作为后端实例的方法,在内部自动处理分页,以同步生成器的形式逐页产出 `BatchDQLResult`: ```python -from rhosocial.activerecord.backend.expression.statements import QueryExpression -from rhosocial.activerecord.backend.expression import WildcardExpression, TableExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + Column, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import OrderByClause -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.statements import QueryStatement # 构建 SELECT * FROM users ORDER BY id 表达式 dialect = User.backend().dialect -query_expr = QueryExpression( +query_expr = QueryStatement( dialect, - select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "users"), + select=[AllColumnsWildcard(dialect)], + from_=NamedRelationRef(dialect, Table(dialect, "users")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), ) @@ -128,15 +172,15 @@ for row in source_data: ```python # ✅ 通过后端实例的 execute_batch_dml 方法批量插入 from rhosocial.activerecord.backend.expression import ( - InsertExpression, ValuesSource, Literal, + InsertStatement, ValuesSource, Literal, ) -from rhosocial.activerecord.backend.result import BatchCommitMode +from rhosocial.activerecord.backend.expression.objects import Table dialect = User.backend().dialect exprs = [ - InsertExpression( + InsertStatement( dialect, - into="users", + into=Table(dialect, "users"), columns=["name", "email"], source=ValuesSource( dialect, @@ -159,16 +203,16 @@ for batch_result in User.backend().execute_batch_dml(exprs, batch_size=500): ```python from rhosocial.activerecord.backend.expression import ( - InsertExpression, ValuesSource, Literal, + InsertStatement, ValuesSource, Literal, ) -from rhosocial.activerecord.backend.result import BatchCommitMode +from rhosocial.activerecord.backend.expression.objects import Table dialect = User.backend().dialect with User.transaction(): for chunk in chunked(source_data, size=500): exprs = [ - InsertExpression( - dialect, into="users", columns=["name", "email"], + InsertStatement( + dialect, into=Table(dialect, "users"), columns=["name", "email"], source=ValuesSource(dialect, values_list=[ [Literal(dialect, row["name"]), Literal(dialect, row["email"])] ]), @@ -212,7 +256,7 @@ users = User.query().all() emails = [u.email for u in users] # ✅ 只获取 id 和 email -rows = User.query().select("id", "email").all() +rows = User.backend().fetch_all("SELECT id, email FROM users") emails = [r["email"] for r in rows] ``` @@ -224,7 +268,7 @@ emails = [r["email"] for r in rows] - [ ] 活跃表使用游标分块;静态快照可使用偏移量分页 - [ ] 批量写入使用 `execute_batch_dml`(或 `insert_many`),而非逐行 `save()` - [ ] 完整性要求严格的任务使用全局事务;可续传的任务使用逐批提交 -- [ ] 通过 `query().select(...)` 只查询实际需要的列 +- [ ] 通过 `backend.fetch_all(...)` 只查询实际需要的列 - [ ] 在开发阶段使用接近生产量级的数据验证内存占用 --- diff --git a/docs/zh_CN/modeling/ddl.md b/docs/zh_CN/modeling/ddl.md index 057784905..a02732c6a 100644 --- a/docs/zh_CN/modeling/ddl.md +++ b/docs/zh_CN/modeling/ddl.md @@ -15,118 +15,232 @@ ### ColumnDefinition -定义列及其数据类型和可选约束。 +定义列及其数据类型和可选约束。数据类型是 `DataType` 对象而非字符串——类型的拼写由 dialect 决定。 ```python from rhosocial.activerecord.backend.expression import ColumnDefinition -from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnUniqueConstraint, +) # 基本列 -ColumnDefinition("name", "VARCHAR(100)") +ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)) +# sql: '"name" TEXT' (SQLite 把 VARCHAR 适配为 TEXT) # 带约束的列 ColumnDefinition( + dialect, "email", - "VARCHAR(255)", + VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE) + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ] ) +# sql: '"email" TEXT NOT NULL UNIQUE' -# 带默认值的列 +# DEFAULT 也是约束分支,不是关键字参数 ColumnDefinition( + dialect, "status", - "VARCHAR(20)", - default="active" + VarCharType(dialect, length=20), + constraints=[ + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")) + ] ) +# sql: '"status" TEXT DEFAULT \'active\'' ``` ### ColumnConstraint -| 类型 | 说明 | -|------|------| -| `NOT_NULL` | 列不能为 NULL | -| `NULL` | 列允许 NULL(默认) | -| `PRIMARY_KEY` | 主键列 | -| `UNIQUE` | 列值必须唯一 | -| `AUTO_INCREMENT` | 自增(取决于数据库) | +列约束分两层,与语法结构对应:`ColumnConstraint` 是**子句**(`CONSTRAINT ` 前缀与尾部性状),每个备选形式是它引用的**分支**。分支不能单独写出;分支种类无法承载的性状在构造时就被拒绝,而不是在渲染时被丢弃。 + +| 分支 | 渲染结果 | +|------|---------| +| `ColumnPrimaryKeyConstraint` | `PRIMARY KEY`(SQLite 在 `is_auto_increment=True` 时追加 `AUTOINCREMENT`) | +| `ColumnUniqueConstraint` | `UNIQUE` | +| `ColumnNotNullConstraint` | `NOT NULL` | +| `ColumnNullableConstraint` | `NULL` | +| `ColumnDefaultConstraint` | `DEFAULT ` | +| `ColumnCheckConstraint` | `CHECK ()` | +| `ColumnForeignKeyConstraint` | `REFERENCES
()` | +| `ColumnGeneratedConstraint` | `GENERATED ALWAYS AS () [STORED\|VIRTUAL]` | + +子句还承载标准允许出现在约束上的性状: + +```python +from rhosocial.activerecord.backend.expression import ( + ColumnCheckConstraint, + ColumnConstraint, + ColumnUniqueConstraint, + StringColumn, +) + +# CONSTRAINT ... +ColumnConstraint( + dialect, + branch=ColumnUniqueConstraint(dialect), + name="uq_users_email" +) +# sql: ' CONSTRAINT "uq_users_email" UNIQUE' (前导空格:该子句是追加到列定义上的) + +# ... DEFERRABLE INITIALLY DEFERRED +ColumnConstraint( + dialect, + branch=ColumnUniqueConstraint(dialect), + deferrable=True, + initially_deferred=True +) +# sql: ' UNIQUE DEFERRABLE INITIALLY DEFERRED' + +# CHECK () +ColumnConstraint( + dialect, + branch=ColumnCheckConstraint(dialect, StringColumn(dialect, "status") != "deleted") +) +# sql: ' CHECK ("status" <> ?)' params: ('deleted',) +``` + +每个性状都是一对互斥标志,并且经过两道关卡: + +| 性状 | 标志 | 由谁授权 | 可承载的分支 | +|------|------|---------|-------------| +| 命名 | `name=` | `supports_constraint_naming` | 全部分支 | +| 延迟 | `deferrable` / `not_deferrable` | `supports_deferrable_constraint` | PRIMARY KEY、UNIQUE、FOREIGN KEY | +| 时机 | `initially_deferred` / `initially_immediate` | `supports_deferrable_constraint` | PRIMARY KEY、UNIQUE、FOREIGN KEY | +| 强制 | `enforced` / `not_enforced` | `supports_constraint_enforcement` | PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK | + +同一对标志同时置位,或把性状用在种类上无此含义的分支,都是构造期错误: + +```python +# ValueError: deferrable and not_deferrable are mutually exclusive options +try: + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), + deferrable=True, not_deferrable=True) +except ValueError as exc: + print(exc) + +# ValueError: DEFERRABLE / INITIALLY cannot ride a ColumnNotNullConstraint; +# the SQL:2016 kinds are PRIMARY KEY, UNIQUE and FOREIGN KEY +try: + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect), deferrable=True) +except ValueError as exc: + print(exc) +``` + +dialect 无法拼写某个已被接受的性状时,会在渲染阶段拒绝,因此语句不会悄悄丢掉调用者要求的约束: + +```python +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +try: + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), enforced=True).to_sql() +except UnsupportedFeatureError as exc: + print(exc) # 'SQLite' dialect does not support ENFORCED/NOT ENFORCED constraint. +``` ### TableConstraint -定义表级约束,如主键、唯一约束和外键。 +定义表级约束,如主键、唯一约束和外键。它与 `ColumnConstraint` 一样采用子句/分支的划分。 ```python +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( + ReferentialAction, TableConstraint, - TableConstraintType, - ForeignKeyConstraint, - ReferentialAction + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, ) # 复合主键 TableConstraint( - TableConstraintType.PRIMARY_KEY, - columns=["user_id", "role_id"] + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["user_id", "role_id"]), ) +# sql: 'PRIMARY KEY ("user_id", "role_id")' # 带引用操作的外键 -ForeignKeyConstraint( - columns=["author_id"], - reference_table="authors", - reference_columns=["id"], - on_delete=ReferentialAction.CASCADE, - on_update=ReferentialAction.RESTRICT +TableConstraint( + dialect, + branch=TableForeignKeyConstraint( + dialect, + ["author_id"], + (Table(dialect, "authors"), ["id"]), + on_delete=ReferentialAction.CASCADE, + on_update=ReferentialAction.RESTRICT, + ), + name="fk_posts_author", ) +# sql: 'CONSTRAINT "fk_posts_author" FOREIGN KEY ("author_id") +# REFERENCES "authors"("id") ON DELETE CASCADE ON UPDATE RESTRICT' ``` + ## 基本操作 ### 创建表 +表由 `Table` 对象命名,语句把它作为位置参数接收。 + ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import ( + ColumnConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement +from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType, VarCharType columns = [ ColumnDefinition( + dialect, "id", - "INTEGER", - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)] + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] ), ColumnDefinition( + dialect, "username", - "VARCHAR(50)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + VarCharType(dialect, length=50), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] ), ColumnDefinition( + dialect, "email", - "VARCHAR(100)", + VarCharType(dialect, length=100), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE) + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)) ] ), - ColumnDefinition("created_at", "TIMESTAMP") + ColumnDefinition(dialect, "created_at", TimestampType(dialect)) ] -create = CreateTableExpression( - dialect=dialect, - table_name="users", +create = CreateTableStatement( + dialect, + Table(dialect, "users"), columns=columns ) sql, params = create.to_sql() -# sql: 'CREATE TABLE "users" ("id" INTEGER PRIMARY KEY, "username" VARCHAR(50) NOT NULL, -# "email" VARCHAR(100) NOT NULL UNIQUE, "created_at" TIMESTAMP)' +# sql: 'CREATE TABLE "users" ("id" INTEGER PRIMARY KEY, "username" TEXT NOT NULL, +# "email" TEXT NOT NULL UNIQUE, "created_at" NUMERIC)' # params: () ``` ### 创建表(带 IF NOT EXISTS) ```python -create = CreateTableExpression( - dialect=dialect, - table_name="users", +create = CreateTableStatement( + dialect, + Table(dialect, "users"), columns=columns, if_not_exists=True ) @@ -137,9 +251,9 @@ sql, params = create.to_sql() ### 创建临时表 ```python -create = CreateTableExpression( - dialect=dialect, - table_name="temp_sessions", +create = CreateTableStatement( + dialect, + Table(dialect, "temp_sessions"), columns=columns, temporary=True ) @@ -150,50 +264,59 @@ sql, params = create.to_sql() ### 删除表 ```python -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop = DropTableExpression( +drop = DropTableStatement( dialect, - table_name="old_users", - if_exists=True, - cascade=True + Table(dialect, "old_users"), + if_exists=True ) sql, params = drop.to_sql() -# sql: 'DROP TABLE IF EXISTS "old_users" CASCADE' +# sql: 'DROP TABLE IF EXISTS "old_users"' +``` + +`cascade=True` 由 `supports_drop_table_cascade` 授权,因此 SQLite(`DROP TABLE` 既没有 `CASCADE` 也没有 `RESTRICT`)会直接拒绝,而不是悄悄丢掉子句: + +```python +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +try: + DropTableStatement(dialect, Table(dialect, "old_users"), cascade=True).to_sql() +except UnsupportedFeatureError as exc: + print(exc) # 'SQLite' dialect does not support DROP TABLE ... CASCADE. ``` ### 修改表 +每个动作都是独立节点,由语句持有列表。 + ```python from rhosocial.activerecord.backend.expression import ( - AlterTableExpression, - AddColumn, - DropColumn, - AlterColumn + AlterTableStatement, + AddColumnAction, + DropColumnAction ) # 添加新列 -alter = AlterTableExpression( +alter = AlterTableStatement( dialect, - table_name="users", + Table(dialect, "users"), actions=[ - AddColumn( - ColumnDefinition( - "phone", - "VARCHAR(20)" - ) + AddColumnAction( + dialect, + ColumnDefinition(dialect, "phone", VarCharType(dialect, length=20)) ) ] ) sql, params = alter.to_sql() -# sql: 'ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20)' +# sql: 'ALTER TABLE "users" ADD COLUMN "phone" TEXT' # 删除列 -alter = AlterTableExpression( +alter = AlterTableStatement( dialect, - table_name="users", + Table(dialect, "users"), actions=[ - DropColumn("old_field") + DropColumnAction(dialect, "old_field") ] ) sql, params = alter.to_sql() @@ -204,105 +327,123 @@ sql, params = alter.to_sql() ### 创建索引 +索引与表都是具名对象,列名是普通列表。 + ```python -from rhosocial.activerecord.backend.expression.statement import CreateIndexExpression +from rhosocial.activerecord.backend.expression import StringColumn +from rhosocial.activerecord.backend.expression.objects import Index, Table +from rhosocial.activerecord.backend.expression.statements import CreateIndexStatement # 基本索引 -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect, - index_name="idx_users_email", - table_name="users", + Index(dialect, "idx_users_email"), + Table(dialect, "users"), columns=["email"] ) sql, params = create_idx.to_sql() # sql: 'CREATE INDEX "idx_users_email" ON "users" ("email")' # 唯一索引 -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect, - index_name="idx_users_username", - table_name="users", + Index(dialect, "idx_users_username"), + Table(dialect, "users"), columns=["username"], unique=True ) -# 局部索引(带 WHERE 子句) -from rhosocial.activerecord.backend.expression import Column, Literal - -create_idx = CreateIndexExpression( +# 局部索引(带 WHERE 子句)—— 谓词由可比较的带类型列构建 +create_idx = CreateIndexStatement( dialect, - index_name="idx_active_users", - table_name="users", + Index(dialect, "idx_active_users"), + Table(dialect, "users"), columns=["email"], - where=Column(dialect, "status") == Literal(dialect, "active") + unique=True, + where=StringColumn(dialect, "status") == "active" ) sql, params = create_idx.to_sql() -# sql: 'CREATE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' +# sql: 'CREATE UNIQUE INDEX "idx_active_users" ON "users" ("email") WHERE "status" = ?' +# params: ('active',) ``` +> 谓词来自能够比较的列类,而不是裸 `Column` 上的 `==`:`Column` 只提供别名、空值判断和类型转换,因此 `Column(...) == ...` 只是一次 Python 同一性比较。任何需要比较的场合都请使用 `StringColumn`、`NumericColumn` 及其兄弟类。 + ### 删除索引 ```python -from rhosocial.activerecord.backend.expression.statement import DropIndexExpression +from rhosocial.activerecord.backend.expression.statements import DropIndexStatement -drop_idx = DropIndexExpression( +drop_idx = DropIndexStatement( dialect, - index_name="idx_users_email", + Index(dialect, "idx_users_email"), if_exists=True ) sql, params = drop_idx.to_sql() # sql: 'DROP INDEX IF EXISTS "idx_users_email"' ``` + ## 模式操作 ### 创建模式 +模式是 `Schema` 对象。SQLite 没有模式,因此它的 dialect 会拒绝该语句,而不是生成引擎会拒绝的 SQL: + ```python -from rhosocial.activerecord.backend.expression.statement import CreateSchemaExpression +from rhosocial.activerecord.backend.expression.objects import Schema +from rhosocial.activerecord.backend.expression.statements import CreateSchemaStatement -create_schema = CreateSchemaExpression( +create_schema = CreateSchemaStatement( dialect, - schema_name="app_schema", + Schema(dialect, "app_schema"), if_not_exists=True ) -sql, params = create_schema.to_sql() +# 在支持模式的后端上: # sql: 'CREATE SCHEMA IF NOT EXISTS "app_schema"' +# 在 SQLite 上: +# UnsupportedFeatureError: 'SQLite' dialect does not support CREATE SCHEMA. ``` ### 删除模式 ```python -from rhosocial.activerecord.backend.expression.statement import DropSchemaExpression +from rhosocial.activerecord.backend.expression.statements import DropSchemaStatement -drop_schema = DropSchemaExpression( +drop_schema = DropSchemaStatement( dialect, - schema_name="old_schema", + Schema(dialect, "old_schema"), cascade=True ) -sql, params = drop_schema.to_sql() +# 在支持模式的后端上: # sql: 'DROP SCHEMA "old_schema" CASCADE' ``` ## 执行 DDL 语句 -DDL 表达式可以直接在后端上执行: +语句渲染为 `(sql, params)`;后端执行的是 SQL 文本,所以两步是分开的: ```python -from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import CreateTableStatement # 创建表 -create = CreateTableExpression(dialect, "users", columns) -User.__backend__.execute(create) +create = CreateTableStatement( + dialect, + Table(dialect, "users"), + columns=columns +) +sql, params = create.to_sql() +backend.execute(sql, params) # 或者先构建 SQL 并检查 -sql, params = create.to_sql() print(f"SQL: {sql}") print(f"Params: {params}") ``` > **注意**:`rhosocial-activerecord` 中的 DDL 语句不需要 `ExecutionOptions(stmt_type=StatementType.DDL)` — 表达式对象自带语句类型信息。 + ## 内省用于架构验证 在创建、修改或删除表之后,你可以使用后端的**内省 API** 来验证架构变化: @@ -322,6 +463,8 @@ if table_info: print(f"Column: {col.name}, Type: {col.data_type}, PK: {col.is_primary_key}") ``` +`TableType` 区分目录实际报告的种类 —— `BASE_TABLE`、`VIEW`、`MATERIALIZED_VIEW`、`FOREIGN_TABLE`、`PARTITIONED_TABLE`、`SYSTEM_TABLE`、`EXTERNAL` —— 关系不会被悄悄折叠成"表"。 + ### 后端差异 > ⚠️ **重要提示**:本文档以 SQLite 作为示例后端。不同的数据库后端存在显著差异: @@ -329,10 +472,11 @@ if table_info: | 特性 | SQLite | MySQL | PostgreSQL | |------|--------|-------|------------| | **内省 API** | `list_tables()`、`get_table_info()`、`pragma.*` | 不同的方法名 | 不同的方法名 | -| **DDL 支持** | 有限的 ALTER TABLE(仅支持 ADD/DROP column) | 完整的 ALTER TABLE | 完整的 ALTER TABLE | +| **DDL 支持** | 有限的 ALTER TABLE(仅支持 ADD/DROP column);没有 `CREATE SCHEMA` | 完整的 ALTER TABLE | 完整的 ALTER TABLE | | **索引类型** | 不支持 USING 子句 | BTREE、HASH 等 | BTREE、HASH、GIN 等 | | **局部索引** | 支持(WHERE 子句) | 不支持 | 支持 | | **生成列** | 3.31.0+ | 5.7.31+ | 支持 | +| **约束性状** | 命名与延迟可渲染;`ENFORCED` 被拒绝 | 命名可渲染 | 全部可渲染 | 请始终参考你所用后端的文档以获取准确的 API 用法。 diff --git a/docs/zh_CN/modeling/ddl_source.md b/docs/zh_CN/modeling/ddl_source.md index 62e5d0105..f980e424c 100644 --- a/docs/zh_CN/modeling/ddl_source.md +++ b/docs/zh_CN/modeling/ddl_source.md @@ -112,7 +112,7 @@ class Report(ActiveRecord): | `column_attributes(field)` | `Sequence[ColumnAttribute]` | 按标记顺序展开属性;不做方言筛选。 | | `column_indexes(field)` | `Sequence[IndexDefinition]` | 为每个 `UseIndex` 建立定义,并使用物理列名。 | | `column_comment(field)` | `Optional[str]` | 返回第一个 `UseComment` 的文本。 | -| `generated_column(field)` | `Optional[GeneratedColumnExpression or factory]` | 返回第一个 `UseGeneratedColumn` 的值;不调用工厂。 | +| `generated_column(field)` | `Optional[ColumnGeneratedConstraint or factory]` | 返回第一个 `UseGeneratedColumn` 的值;不调用工厂。 | | `column_options(field)` | `None`、`ColumnOptions` 或其序列 | 返回处理器写入 `DDLFieldMetadata.column_options` 的值;一个选项返回对象,多个选项返回 list。 | | `table_options()` | `None`、表达式或其序列 | 默认 `None`;覆盖方法的结果原样返回。 | | `table_storage_options()` | `None`、表达式或其序列 | 默认 `None`;覆盖方法的结果原样返回。 | @@ -207,7 +207,7 @@ option.apply_to(column) | Snowflake | `Snowflake*Type`、`SnowflakeCreateTableOptions`、Snowflake 分区/克隆相关表达式 | VARIANT、OBJECT、ARRAY 等类型只是候选声明;source 不做 Snowflake 专属选择。 | | ClickHouse | `ClickHouse*Type`、`ClickHouseColumnOptions`、`ClickHouseIndexDefinition`、ClickHouse 分区表达式 | UNIQUE、外键等不受支持的能力由 ClickHouse dialect fail-fast;source 仍可呈现用户声明。 | | BigQuery | 当前主要使用通用 `DataType`、约束和 `CreateTableOptions` | 当前包没有与 `DDLSource` 自动绑定的 BigQuery 专属 `ColumnOptions`;通用声明仍可被收集。 | -| Firebird | `Firebird*Type`、`FirebirdColumnOptions`、`FirebirdCreateTableExpression`、Firebird 分区/计算列表达式 | 计算列、表空间和专属建表类由 Firebird 消费者处理。 | +| Firebird | `Firebird*Type`、`FirebirdColumnOptions`、`FirebirdCreateTableStatement`、Firebird 分区/计算列表达式 | 计算列、表空间和专属建表类由 Firebird 消费者处理。 | 后端测试应至少验证三件事:DDLSource 返回的对象和顺序、后端专属类型/选项没有被提前改写、收集结果可以交给该后端已有的表达式构造路径。收集测试不需要连接真实数据库;SQL 渲染应使用方言和最小表达式单独验证。 @@ -225,7 +225,7 @@ from rhosocial.activerecord.base import ( UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -245,7 +245,7 @@ class Account(ActiveRecord): ] status: Annotated[ str, - UseConstraint(ColumnConstraintType.NOT_NULL), + UseConstraint(ColumnNotNullConstraint), ] = "active" note: Optional[str] = None diff --git a/docs/zh_CN/modeling/ddl_source_examples.md b/docs/zh_CN/modeling/ddl_source_examples.md index e0dfe04ce..15d06c50b 100644 --- a/docs/zh_CN/modeling/ddl_source_examples.md +++ b/docs/zh_CN/modeling/ddl_source_examples.md @@ -10,7 +10,7 @@ 模型声明 → DDLSourceMixin 收集 → 外部消费者读取并选择/绑定候选 - → ColumnDefinition / CreateTableExpression + → ColumnDefinition / CreateTableStatement → dialect.to_sql() ``` @@ -92,10 +92,10 @@ PlainAccount.table_constraints() # [] PlainAccount.column_constraints("id") -# ColumnConstraint(PRIMARY_KEY), ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnPrimaryKeyConstraint), ColumnConstraint(branch=ColumnNotNullConstraint) PlainAccount.column_constraints("name") -# ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnNotNullConstraint) PlainAccount.column_constraints("note") # [] @@ -145,7 +145,7 @@ from rhosocial.activerecord.base import ( UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -163,7 +163,7 @@ class CoreDeclared(ActiveRecord): ] status: Annotated[ str, - UseConstraint(ColumnConstraintType.NOT_NULL), + UseConstraint(ColumnNotNullConstraint), ] = "active" ``` @@ -186,7 +186,7 @@ CoreDeclared.column_attributes("name")[0].name # 'und:ci' CoreDeclared.column_constraints("status") -# ColumnConstraint(NOT_NULL) +# ColumnConstraint(branch=ColumnNotNullConstraint) ``` 同一个核心表达式被各后端绑定后,实际 SQL 片段如下: @@ -548,12 +548,12 @@ SQLServerDialect does not support the generic type 'oracle_varchar2' | MySQL | `MySQLCreateTableOptions(engine="InnoDB", charset="utf8mb4")` | 产生 `ENGINE`、`DEFAULT CHARSET` 等 MySQL 表选项。 | | MariaDB | `MariaDBCreateTableOptions` | 产生与 MariaDB 方言匹配的表选项。 | | PostgreSQL | `PostgresCreateTableOptions(unlogged=True)` | 产生 `CREATE UNLOGGED TABLE`;版本能力由 PostgreSQL 方言检查。 | -| SQL Server | `SQLServerCreateTableOptions(memory_optimized=True)`、`SQLServerCreateTableExpression` | 产生 `WITH (MEMORY_OPTIMIZED = ON)`;需要 SQL Server 2014+。 | +| SQL Server | `SQLServerCreateTableOptions(memory_optimized=True)`、`SQLServerCreateTableStatement` | 产生 `WITH (MEMORY_OPTIMIZED = ON)`;需要 SQL Server 2014+。 | | Oracle | 核心 `CreateTableOptions`、`tablespace="TS_DATA"` | 表空间由 Oracle 建表 formatter 处理;通用 `StorageOptionsExpression` 在 Oracle 建表路径会 fail-fast。 | | Snowflake | `SnowflakeCreateTableOptions(transient=True)` | 产生 `CREATE OR REPLACE TRANSIENT TABLE`。 | | ClickHouse | `ClickHouseColumnOptions`、`StorageOptionsExpression` | 产生 `CODEC(...)`、`ENGINE = ...` 等 ClickHouse 子句。 | | BigQuery | 核心 `CreateTableOptions` + `TableCommentClause` | 产生表级 `OPTIONS(description=...)`;当前没有 BigQuery 专属 `ColumnOptions`。 | -| Firebird | `FirebirdCreateTableExpression`、`FirebirdColumnOptions` | 产生 `CREATE GLOBAL TEMPORARY TABLE`、`CHARACTER SET` 等 Firebird 子句。 | +| Firebird | `FirebirdCreateTableStatement`、`FirebirdColumnOptions` | 产生 `CREATE GLOBAL TEMPORARY TABLE`、`CHARACTER SET` 等 Firebird 子句。 | 把某个后端的 `ColumnOptions` 应用到另一个后端的核心 `ColumnDefinition`,通常会得到 `TypeError`;但如果应用到了另一个后端自己的列定义类,某些不认识的后端字段可能被 formatter 静默忽略。因此示例中应始终让选项类和列定义类来自同一个后端,并让方言也来自同一个后端。 diff --git a/docs/zh_CN/modeling/ddl_views.md b/docs/zh_CN/modeling/ddl_views.md index 9350329ea..d93a7266b 100644 --- a/docs/zh_CN/modeling/ddl_views.md +++ b/docs/zh_CN/modeling/ddl_views.md @@ -6,25 +6,27 @@ ```python from rhosocial.activerecord.backend.expression import ( - CreateViewExpression, - QueryExpression, - TableExpression, - Column + Column, + NamedRelationRef, + QueryStatement, ) +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import CreateViewStatement # 基础视图 -create_view = CreateViewExpression( +query = QueryStatement( dialect, - view_name="user_summary", - query=QueryExpression( - dialect, - select=[ - Column(dialect, "id"), - Column(dialect, "name"), - Column(dialect, "email") - ], - from_=TableExpression(dialect, "users") - ) + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "email") + ], + from_=NamedRelationRef(dialect, Table(dialect, "users")) +) +create_view = CreateViewStatement( + dialect, + View(dialect, "user_summary"), + query=query ) sql, params = create_view.to_sql() # sql: 'CREATE VIEW "user_summary" AS SELECT "id", "name", "email" FROM "users"' @@ -33,19 +35,11 @@ sql, params = create_view.to_sql() ## 带列别名的视图 ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_details", - query=QueryExpression( - dialect, - select=[ - Column(dialect, "id"), - Column(dialect, "name"), - Column(dialect, "email") - ], - from_=TableExpression(dialect, "users") - ), - columns=["user_id", "full_name", "contact_email"] + View(dialect, "user_details"), + query=query, + column_aliases=["user_id", "full_name", "contact_email"] ) sql, params = create_view.to_sql() # sql: 'CREATE VIEW "user_details" ("user_id", "full_name", "contact_email") AS SELECT ...' @@ -54,11 +48,11 @@ sql, params = create_view.to_sql() ## OR REPLACE 视图 ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="user_summary", + View(dialect, "user_summary"), query=query, - or_replace=True + replace=True ) sql, params = create_view.to_sql() # sql: 'CREATE OR REPLACE VIEW "user_summary" AS ...' @@ -67,9 +61,9 @@ sql, params = create_view.to_sql() ## TEMPORARY 视图 (SQLite) ```python -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, - view_name="temp_stats", + View(dialect, "temp_stats"), query=query, temporary=True ) @@ -80,20 +74,21 @@ sql, params = create_view.to_sql() ## 删除视图 ```python -from rhosocial.activerecord.backend.expression import DropViewExpression +from rhosocial.activerecord.backend.expression.objects import View +from rhosocial.activerecord.backend.expression.statements import DropViewStatement # 基础删除 -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect, - view_name="old_view" + View(dialect, "old_view") ) sql, params = drop_view.to_sql() # sql: 'DROP VIEW "old_view"' # 条件删除 -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect, - view_name="old_view", + View(dialect, "old_view"), if_exists=True ) sql, params = drop_view.to_sql() @@ -103,13 +98,51 @@ sql, params = drop_view.to_sql() ## 执行视图 DDL ```python +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnDefinition, + NamedRelationRef, + QueryStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table, View +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + CreateViewStatement, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType + # 先创建表 -create_table = CreateTableExpression(dialect, "users", user_columns) -backend.execute(create_table.to_sql()) +user_columns = [ + ColumnDefinition( + dialect, "id", IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))] + ), + ColumnDefinition( + dialect, "name", VarCharType(dialect, length=100), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] + ), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255)), +] +create_table = CreateTableStatement(dialect, Table(dialect, "users"), columns=user_columns) +sql, params = create_table.to_sql() +backend.execute(sql, params) # 创建视图 -create_view = CreateViewExpression(dialect, "user_summary", view_query) -backend.execute(create_view.to_sql()) +view_query = QueryStatement( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "email") + ], + from_=NamedRelationRef(dialect, Table(dialect, "users")) +) +create_view = CreateViewStatement(dialect, View(dialect, "user_summary"), query=view_query) +sql, params = create_view.to_sql() +backend.execute(sql, params) # 内省:列出视图 views = backend.introspector.list_views() @@ -121,4 +154,4 @@ for v in views: ## 示例代码 -完整示例:[docs/examples/chapter_03_modeling/ddl_views.py](../../../examples/chapter_03_modeling/ddl_views.py) \ No newline at end of file +完整示例:[docs/examples/chapter_03_modeling/ddl_views.py](../../../examples/chapter_03_modeling/ddl_views.py) diff --git a/docs/zh_CN/modeling/mixins.md b/docs/zh_CN/modeling/mixins.md index 57d8f8ba4..606ee71e7 100644 --- a/docs/zh_CN/modeling/mixins.md +++ b/docs/zh_CN/modeling/mixins.md @@ -235,14 +235,14 @@ from rhosocial.activerecord.field import OptimisticLockMixin from typing import Annotated from pydantic import Field from rhosocial.activerecord.base.fields import UseColumn, UseConstraint -from rhosocial.activerecord.backend.expression.statements.ddl_table import ColumnConstraintType + class Article(OptimisticLockMixin, ActiveRecord): __version_field__ = "row_version" __version_increment_by__ = 2 # 可选,默认 1 row_version: Annotated[ - int, UseColumn("row_ver"), UseConstraint(ColumnConstraintType.NOT_NULL) + int, UseColumn("row_ver"), UseConstraint(ColumnNotNullConstraint) ] = Field(default=1, ge=1) title: str ``` diff --git a/docs/zh_CN/performance/batch_operations.md b/docs/zh_CN/performance/batch_operations.md index b47a2bee5..e37c892cb 100644 --- a/docs/zh_CN/performance/batch_operations.md +++ b/docs/zh_CN/performance/batch_operations.md @@ -17,10 +17,14 @@ ```python from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource, Literal +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource, Literal +from rhosocial.activerecord.backend.expression.objects import Table backend = SQLiteBackend(database=":memory:") backend.connect() +backend.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" +) # 创建 INSERT 表达式列表 expressions = [] @@ -29,9 +33,9 @@ for i in range(100): [Literal(backend.dialect, f"user{i}"), Literal(backend.dialect, f"user{i}@example.com")] ]) - expr = InsertExpression( + expr = InsertStatement( backend.dialect, - into="users", + into=Table(backend.dialect, "users"), columns=["name", "email"], source=source ) @@ -40,8 +44,6 @@ for i in range(100): # 以每批 20 条执行 for batch_result in backend.execute_batch_dml(expressions, batch_size=20): print(f"批次 {batch_result.batch_index}: 影响 {batch_result.total_affected_rows} 行") - -backend.disconnect() ``` ### 提交模式 @@ -55,6 +57,11 @@ backend.disconnect() ```python from rhosocial.activerecord.backend.result import BatchCommitMode + +def process_batch(batch): + print(f"批次 {batch.batch_index}: 影响 {batch.total_affected_rows} 行") + + # 全有或全无:要么所有批次成功,要么全部回滚 for batch in backend.execute_batch_dml( expressions, @@ -114,6 +121,36 @@ for batch in backend.execute_batch_dml( ```python # 有效:所有 INSERT 表达式针对同一张表 +from rhosocial.activerecord.backend.expression import IntegerColumn, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + InsertStatement, + UpdateStatement, + ValuesSource, +) + + +def make_insert(name, email): + return InsertStatement( + backend.dialect, + into=Table(backend.dialect, "users"), + columns=["name", "email"], + source=ValuesSource( + backend.dialect, + values_list=[[Literal(backend.dialect, name), Literal(backend.dialect, email)]], + ), + ) + + +def make_update(user_id, name): + return UpdateStatement( + backend.dialect, + Table(backend.dialect, "users"), + assignments={"name": Literal(backend.dialect, name)}, + where=(IntegerColumn(backend.dialect, "id") == user_id), + ) + + expressions = [ make_insert("user1", "user1@example.com"), make_insert("user2", "user2@example.com"), @@ -121,10 +158,15 @@ expressions = [ ] # 无效:混合表达式类型 -expressions = [ - make_insert("user1", "user1@example.com"), - make_update(1, "new_name"), # TypeError: 异质表达式 -] +try: + mixed = [ + make_insert("user1", "user1@example.com"), + make_update(1, "new_name"), + ] + for _ in backend.execute_batch_dml(mixed): + pass +except TypeError as exc: + print(f"TypeError: {exc}") ``` ## 批量 DQL 操作 @@ -134,12 +176,22 @@ expressions = [ `execute_batch_dql` 为大型结果集提供内存高效的分页: ```python -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, WildcardExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + NamedRelationRef, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import QueryStatement + + +def process_row(row): + return row["id"] + -query = QueryExpression( +query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "large_table"), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "users")), ) # 逐页处理 1000 行 @@ -166,29 +218,47 @@ for page in backend.execute_batch_dql(query, page_size=100): `execute_batch_dql` 支持所有 DQL 表达式类型: -- `QueryExpression`:基本 SELECT 查询 -- `WithQueryExpression`:公用表表达式(CTE) -- `SetOperationExpression`:UNION、INTERSECT、EXCEPT 操作 +- `QueryStatement`:基本 SELECT 查询 +- `WithStatement`:公用表表达式(CTE) +- `SetOperationStatement`:UNION、INTERSECT、EXCEPT 操作 ```python # CTE 示例 -from rhosocial.activerecord.backend.expression.query_sources import WithQueryExpression, CTEExpression +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + NamedRelationRef, + StringColumn, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.query_sources import CTEClause, WithStatement +from rhosocial.activerecord.backend.expression.statements import QueryStatement + +backend.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, status TEXT)") -cte_query = QueryExpression( +cte_query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "orders"), - where=WhereClause(backend.dialect, condition=...), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "orders")), + where=WhereClause( + backend.dialect, + condition=(StringColumn(backend.dialect, "status") == "open"), + ), ) -cte = CTEExpression(backend.dialect, name="recent_orders", query=cte_query) +cte = CTEClause(backend.dialect, name="recent_orders", query=cte_query) -main_query = QueryExpression( +main_query = QueryStatement( backend.dialect, - select=[WildcardExpression(backend.dialect)], - from_=TableExpression(backend.dialect, "recent_orders"), + select=[AllColumnsWildcard(backend.dialect)], + from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "recent_orders")), ) -with_query = WithQueryExpression(backend.dialect, ctes=[cte], main_query=main_query) +with_query = WithStatement(backend.dialect, ctes=[cte], query=main_query) + + +def process_orders(rows): + return len(rows) + for page in backend.execute_batch_dql(with_query, page_size=50): process_orders(page.data) @@ -200,12 +270,61 @@ for page in backend.execute_batch_dql(with_query, page_size=50): ```python # 异步批量 DML -async for batch in async_backend.execute_batch_dml(expressions, batch_size=20): - await process_batch(batch) +import asyncio + +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + InsertStatement, + Literal, + NamedRelationRef, + ValuesSource, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import QueryStatement +from rhosocial.activerecord.backend.impl.sqlite.backend.async_backend import AsyncSQLiteBackend + + +async def run_async_batches(): + async_backend = AsyncSQLiteBackend(database=":memory:") + await async_backend.connect() + await async_backend.execute( + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" + ) -# 异步批量 DQL -async for page in async_backend.execute_batch_dql(query, page_size=1000): - await process_page(page) + async_dialect = async_backend.dialect + async_expressions = [ + InsertStatement( + async_dialect, + into=Table(async_dialect, "users"), + columns=["name", "email"], + source=ValuesSource( + async_dialect, + values_list=[[ + Literal(async_dialect, f"async{i}"), + Literal(async_dialect, f"async{i}@example.com"), + ]], + ), + ) + for i in range(10) + ] + + # 异步批量 DML + async for batch in async_backend.execute_batch_dml(async_expressions, batch_size=4): + print(f"批次 {batch.batch_index}: 影响 {batch.total_affected_rows} 行") + + # 异步批量 DQL + async_query = QueryStatement( + async_dialect, + select=[AllColumnsWildcard(async_dialect)], + from_=NamedRelationRef(async_dialect, Table(async_dialect, "users")), + ) + async for page in async_backend.execute_batch_dql(async_query, page_size=4): + print(f"第 {page.page_index} 页: {page.page_size} 行") + + await async_backend.disconnect() + + +asyncio.run(run_async_batches()) ``` ## 事务交互 @@ -225,6 +344,8 @@ with backend.transaction(): ### 错误处理 ```python +from rhosocial.activerecord.backend.errors import IntegrityError + try: for batch in backend.execute_batch_dml( expressions, @@ -232,10 +353,10 @@ try: commit_mode=BatchCommitMode.WHOLE ): process_batch(batch) -except IntegrityError: +except IntegrityError as exc: # WHOLE 模式:所有更改已回滚 # PER_BATCH 模式:之前的批次可能已提交 - handle_error() + print(f"完整性错误,批次已回滚: {exc}") ``` ## 性能考量 diff --git a/docs/zh_CN/querying/active_query.md b/docs/zh_CN/querying/active_query.md index 6050a04c7..93ca12590 100644 --- a/docs/zh_CN/querying/active_query.md +++ b/docs/zh_CN/querying/active_query.md @@ -695,7 +695,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note over Query, Expr: 1. SQL 生成 (委托给表达式系统) - Query->>Expr: 构建 QueryExpression + Query->>Expr: 构建 QueryStatement Note right of Query: 组装 Select, From, Where 等
(one() 会使用临时的 LIMIT 1) Expr->>Expr: to_sql() Expr-->>Query: 返回 (sql, params) @@ -753,7 +753,7 @@ sequenceDiagram alt 启用了 explain() Note over Query, Expr: EXPLAIN 模式 - Query->>Expr: 构建 ExplainExpression + Query->>Expr: 构建 ExplainStatement Expr->>Expr: to_sql() Expr-->>Query: 返回 (sql, params) Query->>Backend: fetch_all(sql, params) @@ -761,7 +761,7 @@ sequenceDiagram Query-->>User: 返回执行计划 else 普通模式 Note over Query, Expr: 标准聚合模式 - Query->>Expr: 构建 QueryExpression + Query->>Expr: 构建 QueryStatement Expr->>Expr: to_sql() Expr-->>Query: 返回 (sql, params) Query->>Backend: fetch_all(sql, params) @@ -786,7 +786,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note over Query, Expr: 1. SQL 生成 (委托给表达式系统) - Query->>Expr: 构建 QueryExpression + Query->>Expr: 构建 QueryStatement Note right of Query: 组装 Select, From, Where...
(one() 会使用临时的 LIMIT 1) Expr->>Expr: to_sql() Expr-->>Query: 返回 (sql, params) diff --git a/docs/zh_CN/querying/cheatsheet.md b/docs/zh_CN/querying/cheatsheet.md index 330960c34..a4085743f 100644 --- a/docs/zh_CN/querying/cheatsheet.md +++ b/docs/zh_CN/querying/cheatsheet.md @@ -421,7 +421,7 @@ result = User.__backend__.execute( users = [User(**row) for row in result.rows] ``` -> ⚠️ **强烈警告:** 不到万不得已,**不要**使用原始 SQL(包括 `RawSQLExpression`、`RawSQLPredicate` 及直接执行 SQL 字符串)。这极易造成 **SQL 注入等安全破口**,并降低跨数据库后端的可移植性。请优先使用 FieldProxy 表达式(`User.c.field`)构建类型安全、自动参数化的查询。只有在标准表达式体系确实无法表达的极端场景(如数据库私有特性)才考虑原始 SQL,且务必使用参数占位符、绝不可拼接用户输入。 +> ⚠️ **强烈警告:** 不到万不得已,**不要**使用原始 SQL(包括 `RawSQL`、`RawSQLPredicate` 及直接执行 SQL 字符串)。这极易造成 **SQL 注入等安全破口**,并降低跨数据库后端的可移植性。请优先使用 FieldProxy 表达式(`User.c.field`)构建类型安全、自动参数化的查询。只有在标准表达式体系确实无法表达的极端场景(如数据库私有特性)才考虑原始 SQL,且务必使用参数占位符、绝不可拼接用户输入。 --- diff --git a/docs/zh_CN/querying/cte_query.md b/docs/zh_CN/querying/cte_query.md index 150451c03..ca4f98faf 100644 --- a/docs/zh_CN/querying/cte_query.md +++ b/docs/zh_CN/querying/cte_query.md @@ -99,7 +99,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note over Query, Expr: 1. SQL 生成 (委托给表达式系统) - Query->>Expr: 构建 WithQueryExpression + Query->>Expr: 构建 WithStatement Note right of Query: 组装所有 CTE 定义 + 主查询 Expr->>Expr: to_sql() Expr-->>Query: 返回 (sql, params) diff --git a/pyproject.toml b/pyproject.toml index 3cfade9fa..6fde07c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,14 @@ dependencies = [ "backports.zoneinfo>=0.2.1; python_version < '3.9'", ] +# Console scripts +# The SQLite backend ships a command-line interface for executing SQL and +# introspecting databases. It is registered here so that it can be invoked as +# `rhosocial-activerecord-sqlite` once the package is installed, instead of +# requiring `python -m rhosocial.activerecord.backend.impl.sqlite`. +[project.scripts] +rhosocial-activerecord-sqlite = "rhosocial.activerecord.backend.impl.sqlite.__main__:main" + [project.optional-dependencies] # Async support for SQLite backend # These are required only when using AsyncSQLiteBackend diff --git a/requirements-dev.txt b/requirements-dev.txt index a2cbe8b7e..5e51353cc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,20 @@ # requirements-dev.txt # Testing framework for unit and integration tests # Core framework for running test suites and fixtures -pytest +# +# pytest 9.x does not install on 3.8/3.9, so the floor there is 8.4. From 3.10 +# the floor is 9.2, and it floats upward: 9.2 broke two plugins by moving a +# node's identifier into a structured NodeId (changelog #14758) -- pytest- +# benchmark read the private ``node._nodeid`` pytest had stopped setting, and +# pytest-xdist wrote the ``--dist=loadgroup`` suffix into the same slot nobody +# read any more, so grouped tests spread across workers instead of staying put. +# Both are shimmed in ``rhosocial.activerecord.testsuite.pytest_compat``, which +# loads as a pytest11 plugin wherever the test suite is installed, so this +# requirement no longer has to hold the toolchain still. A version that arrives +# new every morning is now evidence rather than a coin toss: if a later release +# breaks something, the matrix says so with a name. +pytest~=8.4.2; python_version < "3.10" +pytest>=9.2; python_version >= "3.10" pytest-asyncio # Pytest plugin for performance benchmarking diff --git a/scripts/changelog-check.ps1 b/scripts/changelog-check.ps1 new file mode 100644 index 000000000..c4ec16c07 --- /dev/null +++ b/scripts/changelog-check.ps1 @@ -0,0 +1,284 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Local mirror of .github/workflows/changelog-check.yml. + +.DESCRIPTION + Runs the same fragment rules CI enforces, so a misnumbered or missing + changelog.d fragment is caught before pushing instead of after a CI + round-trip. + + feature/* fix/* bugfix/* test/* docs/* chore/* -> release/* | maint/* + - must not target main directly + - the change must add at least one changelog.d/..md + - every added fragment's must equal the PR number + + release/* -> main + - CHANGELOG.md must have changed + - changelog.d/ must contain no fragments at the merge commit + + Escape hatches (presence requirement only -- the number-must-match rule is + always enforced): [trivial] in the PR title, the skip-changelog label, or a + "docs: update CHANGELOG" title. + + PR number, title and base ref are auto-detected through `gh` when the branch + already has an open PR; pass them explicitly to check a branch that has none + yet. + +.EXAMPLE + ./scripts/changelog-check.ps1 + ./scripts/changelog-check.ps1 -PrNumber 124 -BaseRef release/v1.0.0.dev30 + ./scripts/changelog-check.ps1 -PrNumber 124 -PrTitle 'feat(query): add lateral join [trivial]' + +.NOTES + Exit codes: 0 ok, 1 a rule was violated, 2 the base ref could not be resolved. +#> +[CmdletBinding()] +param( + # PR number the fragment numbers must match. 0 disables the check. + [int]$PrNumber = 0, + # Base branch of the PR, with or without the origin/ prefix. + [string]$BaseRef = '', + # Head branch; defaults to the current branch. + [string]$HeadRef = '', + # PR title, used only for the escape hatches. + [string]$PrTitle = '', + # Stand in for the skip-changelog label. + [switch]$SkipChangelog, + # Skip `git fetch origin ` before diffing. + [switch]$NoFetch, + # Skip the `gh pr view` lookup entirely. + [switch]$NoRemoteLookup +) + +. (Join-Path $PSScriptRoot '_devlib.ps1') + +Set-Location (Get-RepoRoot) + +$failed = $false +$knownTypes = Get-TowncrierTypes +$branchKinds = @('feature/*', 'fix/*', 'bugfix/*', 'test/*', 'docs/*', 'chore/*') + +function Get-GitLines { + param([string[]]$Arguments) + $out = & git @Arguments + if ($LASTEXITCODE -ne 0) { return @() } + return @($out | Where-Object { $_ }) +} + +# --------------------------------------------------------------------------- +# Resolve head / base / PR metadata +# --------------------------------------------------------------------------- +if (-not $HeadRef) { + $HeadRef = (& git rev-parse --abbrev-ref HEAD) + if ($LASTEXITCODE -ne 0 -or -not $HeadRef -or $HeadRef -eq 'HEAD') { + Write-Fail 'cannot determine the current branch (detached HEAD?). Pass -HeadRef.' + exit 2 + } +} + +if (-not $NoRemoteLookup) { + $gh = Resolve-RepoTool -Name 'gh' + if ($gh) { + try { + $json = & $gh pr view --json number,title,baseRefName 2>$null + if ($LASTEXITCODE -eq 0 -and $json) { + $pr = $json | ConvertFrom-Json + if (-not $PrNumber -and $pr.number) { $PrNumber = [int]$pr.number } + if (-not $PrTitle) { $PrTitle = [string]$pr.title } + if (-not $BaseRef) { $BaseRef = [string]$pr.baseRefName } + } + } + catch { + Write-Note 'gh pr view failed; continuing with the values passed in' + } + } + else { + Write-Note 'gh not installed -- pass -PrNumber/-BaseRef explicitly for a full check' + } +} + +if (-not $BaseRef) { + # No PR yet: a release branch is destined for main, everything else for the + # active release branch (see .claude/version_control.md). + if ($HeadRef -like 'release/*') { $BaseRef = 'main' } + else { + Write-Fail 'cannot infer the base branch. Pass -BaseRef release/vX.Y.Z (or maint/X).' + exit 2 + } +} + +$remoteRef = if ($BaseRef -like 'origin/*') { $BaseRef } else { "origin/$BaseRef" } + +Write-Step "changelog-check: $HeadRef -> $remoteRef" +if ($PrNumber) { + $suffix = if ($PrTitle) { " ($PrTitle)" } else { '' } + Write-Note "PR #$PrNumber$suffix" +} +else { Write-Note 'PR number unknown -- fragment numbering not verified' } + +# --------------------------------------------------------------------------- +# Resolve the comparison base +# --------------------------------------------------------------------------- +if (-not $NoFetch) { + $fetchTarget = $remoteRef -replace '^origin/', '' + & git fetch origin $fetchTarget --quiet 2>&1 | Out-Null +} + +& git rev-parse --verify --quiet $remoteRef | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Fail "base ref '$remoteRef' not found locally -- run: git fetch origin" + exit 2 +} + +$diffArgs = @('diff', '--name-only', "$remoteRef...HEAD") + +function Test-BaseIsMain { + return ($remoteRef -eq 'origin/main' -or $remoteRef -eq 'refs/remotes/origin/main') +} + +# --------------------------------------------------------------------------- +# Branch policy: feature-ish branches must not target main +# --------------------------------------------------------------------------- +$isBranchKind = $false +foreach ($kind in $branchKinds) { + if ($HeadRef -like $kind) { $isBranchKind = $true; break } +} + +if ($isBranchKind -and (Test-BaseIsMain)) { + Write-Fail "'$HeadRef' must not target main directly." + Write-Note 'Per the rhosocial merge rules:' + Write-Item 'feature/* fix/* bugfix/* test/* docs/* chore/* -> release/* | maint/*' + Write-Item 'release/* -> main' + Write-Note 'Active release branches:' + foreach ($line in (Get-GitLines @('branch', '-r', '--list', 'origin/release/*'))) { + Write-Item ($line -replace '^\s*origin/', '') + } + exit 1 +} + +# --------------------------------------------------------------------------- +# release/* -> main : CHANGELOG.md must change, changelog.d/ must be clear +# --------------------------------------------------------------------------- +if ($HeadRef -like 'release/*' -and (Test-BaseIsMain)) { + Write-Step 'release merge into main' + + $changed = Get-GitLines @('diff', '--name-only', "$remoteRef...HEAD", '--', 'CHANGELOG.md') + if ($changed) { + Write-Pass 'CHANGELOG.md updated' + } + else { + Write-Fail 'CHANGELOG.md was not updated by this release merge.' + Write-Note 'Regenerate it with: towncrier build --yes --keep' + $failed = $true + } + + # Checked against the merge result, not the diff: leftover fragments from + # earlier in the release cycle must be gone by this point. + $leftovers = Get-GitLines @('ls-tree', '--name-only', 'HEAD', 'changelog.d/') | + Where-Object { $_ -match '^[0-9]+\.[A-Za-z]+\.md$' } + if ($leftovers) { + Write-Fail 'changelog.d/ still contains fragments after the release merge:' + foreach ($item in $leftovers) { Write-Item $item } + Write-Note 'Delete them; their content is already folded into CHANGELOG.md.' + $failed = $true + } + else { + Write-Pass 'changelog.d/ is clear of fragments' + } + + if ($failed) { exit 1 } + exit 0 +} + +# --------------------------------------------------------------------------- +# Fragments added by this branch +# --------------------------------------------------------------------------- +Write-Step 'fragment numbering' + +# Three sources, because this runs before the commit exists: `git diff` alone +# only sees what is already committed, so a freshly created (untracked or +# staged) fragment -- exactly the case worth catching -- would be invisible. +# Fragments already present in changelog.d/ (carried in by previously merged +# branches) are excluded by --diff-filter=A, so only this branch's additions are +# judged, matching what CI will see once the work is pushed. +$added = @() +$added += Get-GitLines @('diff', '--name-only', '--diff-filter=A', "$remoteRef...HEAD", '--', 'changelog.d') +$added += Get-GitLines @('diff', '--name-only', '--diff-filter=A', '--cached', '--', 'changelog.d') +$added += Get-GitLines @('ls-files', '--others', '--exclude-standard', '--', 'changelog.d') +$added = @($added | + Sort-Object -Unique | + Where-Object { ($_ -split '/')[-1] -notin @('README.md', 'template.md', '.gitkeep') }) + +if ($added) { + foreach ($path in $added) { + $base = ($path -split '/')[-1] + if ($base -notmatch '^(\d+)\.([A-Za-z]+)\.md$') { + Write-Fail "$path : fragment must be named ..md, got '$base'" + Write-Note 'Multiple issues in one file (123+456.fixed.md, see changelog.d/README.md)' + Write-Note 'are rejected by the CI check -- use one fragment per issue.' + $failed = $true + continue + } + + $num = $Matches[1] + $type = $Matches[2] + + if ($PrNumber -and $num -ne "$PrNumber") { + Write-Fail "$path : fragment number '$num' does not match PR #$PrNumber" + Write-Note "Rename it to changelog.d/$PrNumber.$type.md, or open a PR for #$num." + $failed = $true + continue + } + + if ($type -notin $knownTypes) { + Write-Note "$path : '$type' is not a configured towncrier type (warning only)" + Write-Note "types: $($knownTypes -join ', ')" + } + } + + if ($failed) { + Write-Host '' + Write-Fail "Fragment numbering does not match PR #$PrNumber" + exit 1 + } + + $summary = ($added | ForEach-Object { ($_ -split '/')[-1] }) -join ', ' + $subject = if ($PrNumber) { "PR #$PrNumber" } else { 'this branch' } + Write-Pass "fragment(s) added for $subject -> $summary" + exit 0 +} + +# --------------------------------------------------------------------------- +# No fragment added: apply the escape hatches, then the internal-only waiver +# --------------------------------------------------------------------------- +if ($PrTitle -like '*[[]trivial[]]*' -or $SkipChangelog -or $PrTitle -like 'docs: update CHANGELOG*') { + Write-Pass 'no fragment required (exempt)' + exit 0 +} + +# Working-tree state counts too, for the same reason as the fragment list above. +$changedFiles = @() +$changedFiles += Get-GitLines $diffArgs +$changedFiles += Get-GitLines @('diff', '--name-only', '--cached') +$changedFiles += Get-GitLines @('diff', '--name-only') +$changedFiles = @($changedFiles | Sort-Object -Unique) +$internalOnly = ($changedFiles | Where-Object { $_ -match '^(tests/|docs/|\.github/)' }) -and + -not ($changedFiles | Where-Object { $_ -match '^src/' }) +if ($internalOnly) { + Write-Pass 'internal-only change, no fragment required' + exit 0 +} + +Write-Fail 'No changelog fragment added by this branch.' +if ($PrNumber) { + Write-Note "Add at least one changelog.d/$PrNumber..md" +} +else { + Write-Note 'Add at least one changelog.d/..md' +} +Write-Note "Types: $($knownTypes -join ' | ')" +Write-Note 'Or waive it: add [trivial] to the PR title, apply the skip-changelog' +Write-Note "label, or title the PR 'docs: update CHANGELOG'." +Write-Note 'Create one with: ./scripts/dev.ps1 fragment ""' +exit 1 diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 000000000..8709b526a --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,571 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Single entry point for local development on Windows / PowerShell 7. + +.DESCRIPTION + Wraps the commands the project actually needs -- the ones currently spread + across .claude/commands/*.md, .claude/testing.md and .github/workflows/*.yml + -- so they can be run from PowerShell without re-typing PYTHONPATH or + remembering flags. + + Every subcommand resolves the repository virtualenv, puts /src and + /tests on PYTHONPATH, and streams native output verbatim (test output + is never filtered or truncated -- see AGENTS.md, "Test Execution"). + + Tests run SERIALLY by default; -Parallel opts into the CI configuration + instead: -n auto --dist=loadgroup with the testsuite plugin loaded, which is + the only parallel mode the suite supports (.claude/testing.md section 5). + Parallel is a supported and encouraged mode -- iron rule 1 asks for the + plugin and the serial marker, it does not require a serial run. + +.EXAMPLE + ./scripts/dev.ps1 doctor + ./scripts/dev.ps1 test + ./scripts/dev.ps1 test tests/rhosocial/activerecord_test/feature/query -k cte + ./scripts/dev.ps1 test -Parallel + ./scripts/dev.ps1 cov -Parallel + ./scripts/dev.ps1 lint src tests + ./scripts/dev.ps1 verify + ./scripts/dev.ps1 fragment 124 added 'Added lateral join support.' + ./scripts/dev.ps1 changelog-check + ./scripts/dev.ps1 redis up + +.NOTES + Exit code is the subcommand's exit code: 0 ok, 1 failure, 2 setup problem. +#> +[CmdletBinding()] +param( + # Subcommand; defaults to help. + [Parameter(Position = 0)][string]$Command = 'help', + # Everything after the subcommand is forwarded verbatim. + [Parameter(ValueFromRemainingArguments = $true)][object[]]$Rest = @() +) + +. (Join-Path $PSScriptRoot '_devlib.ps1') + +$Rest = [string[]]$Rest + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +# Splits the forwarded arguments into pytest passthrough arguments and the two +# control switches this script claims for itself. `-Parallel` is not a pytest +# flag, so there is no collision risk. +function Split-Rest { + param([string[]]$Arguments) + $parallel = $false + $passthrough = [System.Collections.Generic.List[string]]::new() + foreach ($arg in $Arguments) { + if ($arg -eq '-Parallel') { $parallel = $true; continue } + if ($arg -eq '-Serial') { $parallel = $false; continue } + $passthrough.Add($arg) + } + return [pscustomobject]@{ Parallel = $parallel; Args = $passthrough.ToArray() } +} + +function Get-Context { + $ctx = Get-PythonContext + if (-not $ctx.Python) { + Write-Fail 'no Python interpreter found. Create .venv3.14 in the repository root or set AR_VENV.' + return $null + } + return $ctx +} + +function Invoke-Pytest { + <# + .SYNOPSIS + Run pytest with the repository PYTHONPATH and the project default target. + #> + param( + [string[]]$PytestArgs = @(), + [switch]$Parallel, + [string]$Target = 'tests', + [switch]$NoDefaultTarget + ) + + $ctx = Get-Context + if (-not $ctx) { return 2 } + Set-Location $ctx.Root + Initialize-RepoEnv -Root $ctx.Root | Out-Null + + # The default target is only prepended when the caller supplied no path of + # their own. Prepending unconditionally would turn `dev.ps1 test + # tests/providers` into `pytest tests tests/providers`, silently running the + # whole suite instead of the requested subset. + $hasExplicitPath = @($PytestArgs | Where-Object { + -not $_.StartsWith('-') -and (Test-Path -LiteralPath $_) + }).Count -gt 0 + + $arguments = @('-m', 'pytest') + if (-not $NoDefaultTarget -and -not $hasExplicitPath -and $Target) { $arguments += $Target } + if ($Parallel) { + # The testsuite plugin is mandatory here: it owns the per-database + # scenario pools and the `serial` marker grouping that keep parallel + # workers from colliding on shared SQLite connections. + $arguments += @('-n', 'auto', '--dist=loadgroup', '-p', 'rhosocial.activerecord.testsuite.conftest') + } + $arguments += $PytestArgs + + Write-Note "pytest $($arguments -join ' ')" + return Invoke-Native -FilePath $ctx.Python -Arguments $arguments +} + +function Test-TcpPort { + param([string]$HostName = '127.0.0.1', [int]$Port = 6379, [int]$TimeoutMs = 400) + $client = [System.Net.Sockets.TcpClient]::new() + try { + $async = $client.BeginConnect($HostName, $Port, $null, $null) + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { return $false } + $client.EndConnect($async) + return $true + } + catch { return $false } + finally { $client.Dispose() } +} + +function Get-ReportTool { + param([string]$Name, [string]$Install) + $path = Resolve-RepoTool -Name $Name + if ($path) { Write-Pass "$Name -> $path" } + else { + Write-Fail "$Name not installed" + Write-Note "install: $Install" + } + return $path +} + +# --------------------------------------------------------------------------- +# subcommands +# --------------------------------------------------------------------------- + +function Invoke-Doctor { + Write-Step 'environment' + Write-Item "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))" + Write-Item "OS $($PSVersionTable.OS)" + Write-Item "repo $(Get-RepoRoot)" + + $gitVersion = (& git --version) + Write-Item "git $gitVersion" + $branch = (& git rev-parse --abbrev-ref HEAD) + $dirty = @(& git status --porcelain) + Write-Item "branch $branch ($($dirty.Count) uncommitted path(s))" + + Write-Host '' + Write-Step 'interpreter' + $ctx = Get-Context + if ($ctx) { + Write-Item "python $($ctx.Python)" + Write-Item "virtualenv $(if ($ctx.IsVenv) { $ctx.Venv } else { '' })" + $version = (& $ctx.Python -c 'import sys; print(sys.version.split()[0])') + Write-Item "version $version" + Set-Location $ctx.Root + $pythonPath = Initialize-RepoEnv -Root $ctx.Root + Write-Item "PYTHONPATH $pythonPath" + + Write-Host '' + Write-Step 'test suite' + # Captured deliberately: this is a diagnostic path, not test output. + $suitePath = (& $ctx.Python -c 'import pathlib, rhosocial.activerecord.testsuite as t; print(pathlib.Path(t.__file__).parent)' 2>&1) + if ($LASTEXITCODE -eq 0 -and $suitePath) { + Write-Pass "testsuite $suitePath" + if ($suitePath -like '*\.worktree\*') { + Write-Note 'this resolves inside a git worktree. CI installs the testsuite' + Write-Note 'from a pinned release branch instead, so local results may differ.' + } + } + else { + Write-Fail 'rhosocial-activerecord-testsuite is not importable' + Write-Note 'install: pip install rhosocial-activerecord-testsuite' + Write-Note 'or : pip install -e ../python-activerecord-testsuite' + } + } + + Write-Host '' + Write-Step 'tools' + Get-ReportTool -Name 'rg' -Install 'winget install BurntSushi.ripgrep.MSVC (AGENTS.md requires rg for searches)' | Out-Null + Get-ReportTool -Name 'ruff' -Install 'uv tool install ruff' | Out-Null + Get-ReportTool -Name 'mypy' -Install 'uv tool install mypy' | Out-Null + Get-ReportTool -Name 'black' -Install 'uv tool install black' | Out-Null + Get-ReportTool -Name 'isort' -Install 'uv tool install isort' | Out-Null + Get-ReportTool -Name 'towncrier' -Install 'uv tool install towncrier' | Out-Null + Get-ReportTool -Name 'gh' -Install 'winget install GitHub.cli' | Out-Null + Get-ReportTool -Name 'docker' -Install 'winget install Docker.DockerDesktop' | Out-Null + + Write-Host '' + Write-Step 'services' + if (Test-TcpPort -Port 6379) { + Write-Pass 'redis reachable on 127.0.0.1:6379' + } + else { + Write-Fail 'redis not reachable on 127.0.0.1:6379' + Write-Note 'the `redis` marked cache tests need it; start with: ./scripts/dev.ps1 redis up' + } + + Write-Host '' + Write-Step 'portability' + $hasFcntl = (& $ctx.Python -c 'import importlib.util; print("yes" if importlib.util.find_spec("fcntl") else "no")') + if ($hasFcntl -eq 'yes') { + Write-Pass 'fcntl available (POSIX slot locking for pooled databases)' + } + else { + Write-Fail 'fcntl unavailable -- pooled (file-based) SQLite scenarios will error' + Write-Note 'testsuite/core/pool.py imports fcntl unconditionally for slot locking.' + Write-Note 'On Windows every fixture reaching pooled_database_path() raises' + Write-Note 'ModuleNotFoundError at setup. In-memory scenarios are unaffected.' + Write-Note 'This is an upstream testsuite issue, not a repository defect.' + } + + Write-Host '' + Write-Step 'git hooks' + $hooksPath = (& git config --get core.hooksPath) + if ($LASTEXITCODE -ne 0 -or -not $hooksPath) { $hooksPath = '.git/hooks' } + if (-not [System.IO.Path]::IsPathRooted($hooksPath)) { $hooksPath = Join-Path (Get-RepoRoot) $hooksPath } + $hook = Join-Path $hooksPath 'pre-commit' + if (Test-Path -LiteralPath $hook) { Write-Pass "pre-commit installed at $hook" } + else { + Write-Fail 'pre-commit hook not installed' + Write-Note 'install: ./scripts/dev.ps1 hooks' + } + return 0 +} + +function Invoke-Lint { + $split = Split-Rest $Rest + $target = if ($split.Args.Count) { $split.Args[0] } else { 'src' } + $extra = if ($split.Args.Count -gt 1) { $split.Args[1..($split.Args.Count - 1)] } else { @() } + $ruff = Resolve-RepoTool -Name 'ruff' + if (-not $ruff) { + Write-Fail 'ruff not installed (uv tool install ruff)' + return 2 + } + Write-Step "ruff check $target" + return Invoke-Native -FilePath $ruff -Arguments (@('check', $target) + $extra) +} + +function Invoke-Fmt { + # --with-black / --with-isort are claimed by this script, not by ruff, so + # they are stripped before the target is read. + $flags = @($Rest | Where-Object { $_ -in @('--with-black', '--with-isort') }) + $args = @($Rest | Where-Object { $_ -notin @('--with-black', '--with-isort', '-Parallel', '-Serial') }) + $target = if ($args.Count) { $args[0] } else { 'src' } + $code = 0 + + $ruff = Resolve-RepoTool -Name 'ruff' + if ($ruff) { + Write-Step "ruff check --fix $target" + $code = Invoke-Native -FilePath $ruff -Arguments (@('check', '--fix', $target)) + } + else { + Write-Fail 'ruff not installed (uv tool install ruff)' + $code = 2 + } + + # black/isort are configured in pyproject.toml (line-length 100) but are not + # installed by default, and ruff's own line-length is 120 -- so they are + # opt-in to keep `ruff format` from silently reformatting against black. + if ($flags -contains '--with-isort') { + $isort = Resolve-RepoTool -Name 'isort' + if ($isort) { + Write-Step "isort $target" + $result = Invoke-Native -FilePath $isort -Arguments @($target) + if ($result -ne 0) { $code = $result } + } + else { Write-Fail 'isort not installed (uv tool install isort)'; $code = 2 } + } + + if ($flags -contains '--with-black') { + $black = Resolve-RepoTool -Name 'black' + if ($black) { + Write-Step "black $target (line-length 100, per [tool.black])" + $result = Invoke-Native -FilePath $black -Arguments @($target) + if ($result -ne 0) { $code = $result } + } + else { Write-Fail 'black not installed (uv tool install black)'; $code = 2 } + } + else { + Write-Note 'add --with-black / --with-isort to also run the configured formatters' + } + + return $code +} + +function Invoke-TypeCheck { + $mypy = Resolve-RepoTool -Name 'mypy' + if (-not $mypy) { + Write-Fail 'mypy not installed (uv tool install mypy)' + return 2 + } + $target = if ($Rest.Count) { $Rest[0] } else { 'src' } + Write-Note '[tool.mypy] pins python_version = 3.8; expect 3.8-compat diagnostics' + Write-Step "mypy $target" + return Invoke-Native -FilePath $mypy -Arguments @($target) +} + +function Invoke-PreflightOnly { + $path = Join-Path $PSScriptRoot 'preflight.ps1' + $args = @($Rest) + & $path @args + return $LASTEXITCODE +} + +function Invoke-Hooks { + $path = Join-Path $PSScriptRoot 'install-git-hooks.ps1' + & $path @Rest + return $LASTEXITCODE +} + +function Invoke-ChangelogCheck { + $path = Join-Path $PSScriptRoot 'changelog-check.ps1' + & $path @Rest + return $LASTEXITCODE +} + +function Invoke-Fragment { + if ($Rest.Count -lt 2) { + Write-Fail 'usage: dev.ps1 fragment [""]' + Write-Note "types: $((Get-TowncrierTypes) -join ', ')" + return 2 + } + + $number = $Rest[0] + $type = $Rest[1] + $text = if ($Rest.Count -ge 3) { $Rest[2] } else { '' } + + if ($number -notmatch '^\d+$') { + Write-Fail "'$number' is not a PR number" + return 2 + } + + $knownTypes = Get-TowncrierTypes + if ($type -notin $knownTypes) { + Write-Fail "'$type' is not a configured towncrier type" + Write-Note "types: $($knownTypes -join ', ')" + return 2 + } + + $root = Get-RepoRoot + $path = Join-Path $root "changelog.d/$number.$type.md" + if (Test-Path -LiteralPath $path) { + Write-Fail "changelog.d/$number.$type.md already exists" + Write-Note 'a PR may add several fragments, but only one per type' + return 1 + } + + if (-not $text) { + $text = 'TODO: describe the user-visible change in past tense (see changelog.d/README.md)' + } + + # LF, per .gitattributes. + [System.IO.File]::WriteAllText($path, "$text`n", [System.Text.UTF8Encoding]::new($false)) + Write-Pass "created changelog.d/$number.$type.md" + Write-Note "git add changelog.d/$number.$type.md" + return 0 +} + +function Invoke-Redis { + $container = 'ar-test-redis' + # 8.8 matches the redis service version used by the main CI test job. + $image = 'redis:8.8' + $action = if ($Rest.Count) { $Rest[0] } else { 'status' } + + switch ($action) { + 'up' { + $existing = (& docker ps -a --filter "name=^/$container$" --format '{{.Names}}') + if ($existing) { + Write-Step "redis: starting existing container $container" + return Invoke-Native -FilePath 'docker' -Arguments @('start', $container) + } + Write-Step "redis: starting $image on 127.0.0.1:6379" + return Invoke-Native -FilePath 'docker' -Arguments @( + 'run', '-d', '--name', $container, '-p', '6379:6379', $image + ) + } + 'down' { + Write-Step "redis: stopping $container" + return Invoke-Native -FilePath 'docker' -Arguments @('stop', $container) + } + 'logs' { + return Invoke-Native -FilePath 'docker' -Arguments @('logs', '--tail', '50', $container) + } + 'status' { + if (Test-TcpPort -Port 6379) { Write-Pass 'redis reachable on 127.0.0.1:6379' } + else { Write-Fail 'redis not reachable on 127.0.0.1:6379' } + return 0 + } + default { + Write-Fail "unknown redis action '$action' (up | down | status | logs)" + return 2 + } + } +} + +function Invoke-Clean { + $dry = $Rest -contains '-Dry' + $removed = 0 + + Write-Step 'stray test databases in TEMP' + $strays = @(Get-ChildItem -Path $env:TEMP -Filter 'test_activerecord_*.sqlite' -File -ErrorAction SilentlyContinue) + Write-Item "$($strays.Count) file(s) matching test_activerecord_*.sqlite" + if ($strays -and -not $dry) { + $strays | Remove-Item -Force + $removed += $strays.Count + } + + Write-Host '' + Write-Step '__pycache__ under src/ and tests/' + $cacheRoots = @((Join-Path (Get-RepoRoot) 'src'), (Join-Path (Get-RepoRoot) 'tests')) + $caches = @(Get-ChildItem -Path $cacheRoots -Filter '__pycache__' -Directory -Recurse -ErrorAction SilentlyContinue) + Write-Item "$($caches.Count) director(ies)" + if ($caches -and -not $dry) { + # Deepest first: removing a parent invalidates the children's paths. + $caches | Sort-Object { $_.FullName.Length } -Descending | Remove-Item -Recurse -Force + $removed += $caches.Count + } + + Write-Host '' + Write-Step '.pytest_cache' + $pytestCache = Join-Path (Get-RepoRoot) '.pytest_cache' + if (Test-Path -LiteralPath $pytestCache) { + Write-Item $pytestCache + if (-not $dry) { Remove-Item -LiteralPath $pytestCache -Recurse -Force; $removed++ } + } + else { Write-Item 'absent' } + + Write-Host '' + if ($dry) { Write-Note '-Dry: nothing was deleted' } + else { Write-Pass "removed $removed item(s)" } + return 0 +} + +function Invoke-Version { + $root = Get-RepoRoot + Write-Step 'version' + $pyproject = Get-Content -LiteralPath (Join-Path $root 'pyproject.toml') + $declared = ($pyproject | Where-Object { $_ -match '^\s*version\s*=' } | Select-Object -First 1) + if ($declared -match '"([^"]+)"') { + Write-Item "pyproject $($Matches[1])" + $changelog = Join-Path $root 'CHANGELOG.md' + if (Test-Path -LiteralPath $changelog) { + $top = Get-Content -LiteralPath $changelog | Where-Object { $_ -match '^## \[' } | Select-Object -First 1 + if ($top) { Write-Item "CHANGELOG $top" } + } + } + + Write-Item "branch $(& git rev-parse --abbrev-ref HEAD)" + $describe = (& git describe --tags --always 2>$null) + if ($describe) { Write-Item "describe $describe" } + + $fragments = @(Get-ChildItem -Path (Join-Path $root 'changelog.d') -Filter '*.md' -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+\.[A-Za-z]+\.md$' }) + Write-Item "fragments $($fragments.Count) pending: $(($fragments.Name) -join ', ')" + return 0 +} + +function Invoke-Verify { + # Everything CI checks cheaply, in the order that fails fastest. + Write-Step 'verify: lint' + $lintCode = Invoke-Lint + Write-Host '' + Write-Step 'verify: collection' + $collectCode = Invoke-Pytest -PytestArgs @('--collect-only', '-q', '-p', 'no:cacheprovider') + Write-Host '' + Write-Step 'verify: changelog fragments' + $changelogCode = Invoke-ChangelogCheck + + $code = 0 + if ($lintCode -ne 0) { $code = 1 } + if ($collectCode -ne 0) { $code = 1 } + if ($changelogCode -ne 0) { $code = 1 } + + Write-Host '' + if ($code -eq 0) { Write-Pass 'verify OK' } else { Write-Fail 'verify FAILED' } + return $code +} + +function Write-Help { + Write-Step 'dev.ps1 -- local development entry point' + Write-Host '' + Write-Host ' USAGE' + Write-Host ' ./scripts/dev.ps1 [arguments...]' + Write-Host '' + Write-Host ' ENVIRONMENT' + Write-Host ' doctor report interpreter, tools, services, git hooks' + Write-Host ' version pyproject version, CHANGELOG head, pending fragments' + Write-Host ' clean [-Dry] remove stray test_activerecord_*.sqlite, __pycache__, .pytest_cache' + Write-Host ' redis up|down|status|logs manage the CI redis service via docker' + Write-Host '' + Write-Host ' TESTS (serial unless -Parallel is passed)' + Write-Host ' test [pytest args] run pytest, default target: tests' + Write-Host ' cov [pytest args] same, with --cov=rhosocial --cov-branch' + Write-Host ' collect pytest --collect-only (import check, no tests run)' + Write-Host ' bench [pytest args] run tests/benchmark with -m benchmark' + Write-Host '' + Write-Host ' QUALITY' + Write-Host ' lint [target] ruff check (default: src)' + Write-Host ' fmt [target] ruff check --fix; add --with-black / --with-isort' + Write-Host ' type [target] mypy (default: src)' + Write-Host ' preflight [args] lint + import smoke + collection (scripts/preflight.ps1)' + Write-Host ' verify lint + collection + changelog fragment rules' + Write-Host '' + Write-Host ' WORKFLOW' + Write-Host ' hooks [-Remove] install/remove the pre-commit pre-flight hook' + Write-Host ' fragment ["text"]' + Write-Host ' create changelog.d/..md' + Write-Host ' changelog-check [args]' + Write-Host ' local mirror of the CI fragment rules' + Write-Host '' + Write-Host ' EXAMPLES' + Write-Host " ./scripts/dev.ps1 test -k cte" + Write-Host " ./scripts/dev.ps1 test -Parallel" + Write-Host " ./scripts/dev.ps1 test tests/rhosocial/activerecord_test/feature/query --tb=short" + Write-Host " ./scripts/dev.ps1 fragment 124 fixed 'Fixed alias contamination in as_().'" + Write-Host '' + Write-Host ' ENVIRONMENT OVERRIDES' + Write-Host ' AR_VENV= use this virtualenv instead of .venv3.14' + Write-Host ' AR_RUFF= use this ruff executable' + return 0 +} + +# --------------------------------------------------------------------------- +# dispatch +# --------------------------------------------------------------------------- + +Set-Location (Get-RepoRoot) + +$split = Split-Rest $Rest +$code = switch ($Command) { + 'doctor' { Invoke-Doctor } + 'version' { Invoke-Version } + 'clean' { Invoke-Clean } + 'redis' { Invoke-Redis } + 'test' { Invoke-Pytest -PytestArgs $split.Args -Parallel:$split.Parallel } + 'cov' { + Invoke-Pytest -Parallel:$split.Parallel -PytestArgs (@( + '--cov=rhosocial', '--cov-branch', + '--cov-report=term', '--cov-report=xml' + ) + $split.Args) + } + 'collect' { Invoke-Pytest -NoDefaultTarget -PytestArgs (@('--collect-only', '-q', '-p', 'no:cacheprovider') + $split.Args) } + 'bench' { Invoke-Pytest -Target 'tests/benchmark' -PytestArgs (@('-m', 'benchmark') + $split.Args) } + 'lint' { Invoke-Lint } + 'fmt' { Invoke-Fmt } + 'type' { Invoke-TypeCheck } + 'preflight' { Invoke-PreflightOnly } + 'hooks' { Invoke-Hooks } + 'changelog-check' { Invoke-ChangelogCheck } + 'fragment' { Invoke-Fragment } + 'verify' { Invoke-Verify } + { $_ -in @('help', '-h', '--help', '-?', '/?') } { Write-Help } + default { + Write-Fail "unknown command '$Command'" + Write-Host '' + Write-Help | Out-Null + 2 + } +} + +if ($null -eq $code) { $code = 0 } +exit $code diff --git a/scripts/expression_lineage.py b/scripts/expression_lineage.py new file mode 100644 index 000000000..5ed3f9ab6 --- /dev/null +++ b/scripts/expression_lineage.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# scripts/expression_lineage.py +""" +List the expression kinds: what core ships, and what a backend adds. + +Read off the default registry, which is the authority on what exists -- the +same source the artifact whitelist is built from, so this cannot enumerate a +different set. + +A backend kind is an **override** when the kind already exists in core, and +**custom** when core has no kind of that name. Classification is by kind, not by +name similarity: two classes with similar names but different kinds are two +kinds, and the added one is custom. + +Kinds are emitted in whatever order the registry yields them; the caller's +concern is the set, not the sequence. + +Usage:: + + python scripts/expression_lineage.py + python scripts/expression_lineage.py --backend sqlite + python scripts/expression_lineage.py --json + python scripts/expression_lineage.py --kind table_list_expression + +To survey another backend, add its expression package and dialect class to +``BACKENDS``. +""" +from __future__ import annotations + +import argparse +import importlib +import json +import pkgutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +CORE_PACKAGES = ( + "rhosocial.activerecord.backend.expression", + "rhosocial.activerecord.backend.transaction", +) + +BACKENDS = { + "sqlite": { + "expression": ("rhosocial.activerecord.backend.impl.sqlite.expression",), + "dialect": "rhosocial.activerecord.backend.impl.sqlite.dialect", + "dialect_class": "SQLiteDialect", + }, +} + + +def _declares_a_formatter(cls: type) -> bool: + """Whether *cls* resolves to a concrete ``format_method``. + + A base supplying one for its subclasses is a real declaration. Only the + sentinel on the root base means "none", and that class is not a node, so it + is not a kind. + """ + from rhosocial.activerecord.backend.expression.core import BaseExpression + + for klass in cls.__mro__: + if "format_method" in vars(klass): + return klass is not BaseExpression + return False + + +def _walk(pkgname: str): + """Yield every concrete expression class a package defines. + + A module that fails to import is skipped rather than fatal: a backend that + is not installed is not an error. + """ + from rhosocial.activerecord.backend.expression.core import BaseExpression + + try: + pkg = importlib.import_module(pkgname) + except Exception: + return + if hasattr(pkg, "__path__"): + modnames = [ + m + for _, m, _ in pkgutil.walk_packages( + path=pkg.__path__, prefix=pkgname + ".", onerror=lambda x: None + ) + ] + else: + modnames = [pkgname] + 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 not issubclass(obj, BaseExpression) or obj is BaseExpression: + continue + if getattr(obj, "__abstractmethods__", None): + continue + if not _declares_a_formatter(obj): + continue + yield modname, name, obj + + +def core_kinds() -> dict[str, str]: + """Map kind -> class name for everything core ships.""" + from rhosocial.activerecord.backend.expression.core import Literal + + out: dict[str, str] = {} + for pkgname in CORE_PACKAGES: + for _modname, name, cls in _walk(pkgname): + if issubclass(cls, Literal): + continue # one class carries every value kind + out[cls.kind()] = name + return out + + +def backend_kinds(name: str) -> dict[str, dict]: + """Map kind -> its relation to core, for one backend.""" + from rhosocial.activerecord.backend.expression.core import Literal + + spec = BACKENDS[name] + core = core_kinds() + out: dict[str, dict] = {} + for pkgname in spec["expression"]: + for modname, cls_name, cls in _walk(pkgname): + if issubclass(cls, Literal): + continue + kind = cls.kind() + if kind in out: + continue + out[kind] = { + "class": cls_name, + "module": modname.rsplit(".", 1)[-1], + "relation": "override" if kind in core else "custom", + "overrides": core.get(kind), + } + return out + + +def collect(name: str) -> dict: + core = core_kinds() + return {"backend": name, "core": core, "backend_kinds": backend_kinds(name)} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--backend", default="sqlite", choices=sorted(BACKENDS)) + ap.add_argument("--json", action="store_true", help="machine-readable output") + ap.add_argument("--kind", help="show one kind and exit non-zero if absent") + args = ap.parse_args() + + data = collect(args.backend) + + if args.kind: + row = data["backend_kinds"].get(args.kind) or ( + {"class": data["core"][args.kind], "relation": "core"} + if args.kind in data["core"] + else None + ) + if row is None: + print(f"not found: {args.kind}") + return 1 + print(f"{args.kind}\t{row['relation']}\t{row['class']}") + return 0 + + if args.json: + print(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)) + return 0 + + core = data["core"] + rows = data["backend_kinds"] + overrides = sorted(k for k, v in rows.items() if v["relation"] == "override") + customs = sorted(k for k, v in rows.items() if v["relation"] == "custom") + + print(f"core kinds: {len(core)}") + print(f"{args.backend} override: {len(overrides)}") + print(f"{args.backend} custom: {len(customs)}") + + print("\n=== core ===") + for kind in sorted(core): + print(f" {kind}\t{core[kind]}") + + print(f"\n=== {args.backend} override ===") + for kind in overrides: + print(f" {kind}\t{rows[kind]['class']}\t(core: {rows[kind]['overrides']})") + + print(f"\n=== {args.backend} custom ===") + for kind in customs: + print(f" {kind}\t{rows[kind]['class']}\t{rows[kind]['module']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/install-git-hooks.ps1 b/scripts/install-git-hooks.ps1 new file mode 100644 index 000000000..8e33c08c9 --- /dev/null +++ b/scripts/install-git-hooks.ps1 @@ -0,0 +1,94 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Windows counterpart of scripts/install-git-hooks.sh. + +.DESCRIPTION + Installs an optional pre-commit hook that runs the local pre-flight, so a + broken import or lint is caught before the commit (and before a CI + round-trip). Bypass a single commit with: git commit --no-verify + + The hook itself is a POSIX sh script: git for Windows executes hooks + through its bundled shell regardless of the shell you installed from, which + keeps the hook working from both PowerShell and Git Bash. It prefers + preflight.ps1 when pwsh is on PATH and falls back to preflight.sh, so the + same checkout behaves correctly on Linux and macOS too. + + Re-run this after pulling, and re-run it from the repository root. To + remove the hook use -Remove. + +.EXAMPLE + ./scripts/install-git-hooks.ps1 + ./scripts/install-git-hooks.ps1 -Remove +#> +[CmdletBinding()] +param( + [string]$HookName = 'pre-commit', + # Delete the hook instead of installing it. + [switch]$Remove +) + +. (Join-Path $PSScriptRoot '_devlib.ps1') + +$root = Get-RepoRoot +Set-Location $root + +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +$hooksPath = (& git config --get core.hooksPath) +if ($LASTEXITCODE -ne 0 -or -not $hooksPath) { $hooksPath = '.git/hooks' } + +if (-not [System.IO.Path]::IsPathRooted($hooksPath)) { + $hooksPath = Join-Path $root $hooksPath +} +$hook = Join-Path $hooksPath $HookName + +if ($Remove) { + if (Test-Path -LiteralPath $hook) { + Remove-Item -LiteralPath $hook -Force + Write-Step "install-git-hooks: removed $hook" + } + else { + Write-Note "install-git-hooks: nothing to remove at $hook" + } + exit 0 +} + +if (-not (Test-Path -LiteralPath $hooksPath -PathType Container)) { + Write-Fail "hooks directory not found: $hooksPath (not a git repo?)" + exit 2 +} + +$hookBody = @' +#!/usr/bin/env sh +# Installed by scripts/install-git-hooks.ps1 -- runs the local pre-flight. +# Skip with: git commit --no-verify +# +# The guards below matter on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates the pre-flight +# scripts. Without them the hook fails with a bare "not found" and blocks the +# commit with nothing to act on. +ROOT="$(git rev-parse --show-toplevel)" + +if [ -f "$ROOT/scripts/preflight.ps1" ] && command -v pwsh >/dev/null 2>&1; then + exec pwsh -NoProfile -ExecutionPolicy Bypass -File "$ROOT/scripts/preflight.ps1" +fi + +if [ -x "$ROOT/scripts/preflight.sh" ]; then + exec "$ROOT/scripts/preflight.sh" +fi + +exit 0 +'@ + +# sh needs LF endings regardless of how this file was checked out. +$hookBody = $hookBody -replace "`r`n", "`n" +[System.IO.File]::WriteAllText($hook, $hookBody, [System.Text.UTF8Encoding]::new($false)) + +Write-Step "install-git-hooks: installed $hook" +if ($hooksPath -ne (Join-Path $root '.git/hooks')) { + Write-Note "core.hooksPath is '$hooksPath'" +} +Write-Note 'hook runs scripts/preflight.ps1 (or preflight.sh when pwsh is absent)' +Write-Note 're-run this script after pulling; remove with -Remove' +exit 0 diff --git a/scripts/verify_ddl_documentation.py b/scripts/verify_ddl_documentation.py deleted file mode 100644 index 1e3550837..000000000 --- a/scripts/verify_ddl_documentation.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# scripts/verify_ddl_documentation.py -""" -验证 DDL 文档示例的脚本。 - -此脚本验证 docs/en_US/backend/expression/statements.md 和 -docs/zh_CN/backend/expression/statements.md 中新增的 DDL 表达式示例。 -""" -import sys -sys.path.insert(0, 'src') - -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression import ( - Column, Literal, QueryExpression, TableExpression, TruncateExpression -) -from rhosocial.activerecord.backend.expression.statements import ( - CreateSchemaExpression, DropSchemaExpression, - CreateIndexExpression, DropIndexExpression, - CreateSequenceExpression, DropSequenceExpression, AlterSequenceExpression -) - - -def verify_section(name: str): - """打印分节标题""" - print(f"\n{'='*60}") - print(f" {name}") - print('='*60) - - -def verify_test(name: str, sql: str, params: tuple, expected_sql: str, expected_params: tuple): - """验证测试结果""" - sql_match = expected_sql in sql if expected_sql else True - params_match = params == expected_params - - status = "✓" if sql_match and params_match else "✗" - print(f"\n{status} {name}") - print(f" SQL: {sql}") - print(f" Params: {params}") - - if not sql_match: - print(f" ERROR: Expected '{expected_sql}' in SQL") - if not params_match: - print(f" ERROR: Expected params {expected_params}") - - return sql_match and params_match - - -def main(): - dialect = DummyDialect() - all_passed = True - - print("="*60) - print(" DDL 文档示例验证") - print("="*60) - - # ========================================= - # TruncateExpression - # ========================================= - verify_section("TruncateExpression") - - truncate = TruncateExpression(dialect, table_name="logs") - sql, params = truncate.to_sql() - all_passed &= verify_test("基本 TRUNCATE", sql, params, 'TRUNCATE TABLE "logs"', ()) - - truncate = TruncateExpression( - dialect, - table_name="orders", - restart_identity=True, - cascade=True - ) - sql, params = truncate.to_sql() - all_passed &= verify_test("带 RESTART IDENTITY 和 CASCADE", sql, params, 'TRUNCATE TABLE "orders"', ()) - all_passed &= "RESTART IDENTITY" in sql and "CASCADE" in sql - - # ========================================= - # Schema DDL - # ========================================= - verify_section("Schema DDL") - - create_schema = CreateSchemaExpression(dialect, schema_name="my_schema") - sql, params = create_schema.to_sql() - all_passed &= verify_test("基本 CREATE SCHEMA", sql, params, 'CREATE SCHEMA "my_schema"', ()) - - create_schema = CreateSchemaExpression( - dialect, - schema_name="app_schema", - if_not_exists=True, - authorization="app_user" - ) - sql, params = create_schema.to_sql() - all_passed &= verify_test("CREATE SCHEMA IF NOT EXISTS with AUTHORIZATION", sql, params, 'CREATE SCHEMA', ()) - all_passed &= "IF NOT EXISTS" in sql and "AUTHORIZATION" in sql - - drop_schema = DropSchemaExpression( - dialect, - schema_name="old_schema", - if_exists=True, - cascade=True - ) - sql, params = drop_schema.to_sql() - all_passed &= verify_test("DROP SCHEMA with CASCADE", sql, params, 'DROP SCHEMA', ()) - all_passed &= "IF EXISTS" in sql and "CASCADE" in sql - - # ========================================= - # Index DDL - # ========================================= - verify_section("Index DDL") - - create_index = CreateIndexExpression( - dialect, - index_name="idx_users_email", - table_name="users", - columns=["email"] - ) - sql, params = create_index.to_sql() - all_passed &= verify_test("基本 CREATE INDEX", sql, params, 'CREATE INDEX', ()) - all_passed &= '"idx_users_email"' in sql and '"users"' in sql and '"email"' in sql - - create_index = CreateIndexExpression( - dialect, - index_name="idx_active_users", - table_name="users", - columns=["email"], - unique=True, - where=Column(dialect, "status") == Literal(dialect, "active") - ) - sql, params = create_index.to_sql() - all_passed &= verify_test("UNIQUE INDEX with WHERE", sql, params, 'CREATE UNIQUE INDEX', ("active",)) - all_passed &= "WHERE" in sql - - create_index = CreateIndexExpression( - dialect, - index_name="idx_orders_user_date", - table_name="orders", - columns=["user_id", "created_at"], - index_type="BTREE" - ) - sql, params = create_index.to_sql() - all_passed &= verify_test("复合索引 with index type", sql, params, 'USING BTREE', ()) - - create_index = CreateIndexExpression( - dialect, - index_name="idx_users_email", - table_name="users", - columns=["email"], - include=["id", "name"] - ) - sql, params = create_index.to_sql() - all_passed &= verify_test("INDEX with INCLUDE", sql, params, 'INCLUDE', ()) - all_passed &= '"id"' in sql and '"name"' in sql - - drop_index = DropIndexExpression( - dialect, - index_name="idx_old_index", - if_exists=True, - table_name="users" - ) - sql, params = drop_index.to_sql() - all_passed &= verify_test("DROP INDEX", sql, params, 'DROP INDEX IF EXISTS', ()) - all_passed &= '"idx_old_index"' in sql and 'ON "users"' in sql - - # ========================================= - # Sequence DDL - # ========================================= - verify_section("Sequence DDL") - - create_seq = CreateSequenceExpression(dialect, sequence_name="user_id_seq") - sql, params = create_seq.to_sql() - all_passed &= verify_test("基本 CREATE SEQUENCE", sql, params, 'CREATE SEQUENCE', ()) - all_passed &= '"user_id_seq"' in sql - - create_seq = CreateSequenceExpression( - dialect, - sequence_name="order_seq", - if_not_exists=True, - start=1000, - increment=1, - minvalue=1000, - maxvalue=999999, - cycle=True, - cache=20, - owned_by="orders.id" - ) - sql, params = create_seq.to_sql() - all_passed &= verify_test("CREATE SEQUENCE with all options", sql, params, 'CREATE SEQUENCE', ()) - all_passed &= all(opt in sql for opt in ["IF NOT EXISTS", "START WITH 1000", "INCREMENT BY 1", - "MINVALUE 1000", "MAXVALUE 999999", "CYCLE", - "CACHE 20", "OWNED BY orders.id"]) - - drop_seq = DropSequenceExpression(dialect, sequence_name="old_seq", if_exists=True) - sql, params = drop_seq.to_sql() - all_passed &= verify_test("DROP SEQUENCE", sql, params, 'DROP SEQUENCE IF EXISTS', ()) - - alter_seq = AlterSequenceExpression(dialect, sequence_name="user_id_seq", restart=1000) - sql, params = alter_seq.to_sql() - all_passed &= verify_test("ALTER SEQUENCE restart", sql, params, 'ALTER SEQUENCE', ()) - all_passed &= "RESTART WITH 1000" in sql - - alter_seq = AlterSequenceExpression( - dialect, - sequence_name="order_seq", - increment=2, - maxvalue=1000000, - cycle=True - ) - sql, params = alter_seq.to_sql() - all_passed &= verify_test("ALTER SEQUENCE multiple changes", sql, params, 'ALTER SEQUENCE', ()) - all_passed &= all(opt in sql for opt in ["INCREMENT BY 2", "MAXVALUE 1000000", "CYCLE"]) - - # ========================================= - # 总结 - # ========================================= - print("\n" + "="*60) - if all_passed: - print(" ✓ 所有验证通过!") - else: - print(" ✗ 部分验证失败!") - print("="*60) - - return 0 if all_passed else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/rhosocial/activerecord/backend/README.md b/src/rhosocial/activerecord/backend/README.md index 6cf2f1e5c..32ed822eb 100644 --- a/src/rhosocial/activerecord/backend/README.md +++ b/src/rhosocial/activerecord/backend/README.md @@ -275,8 +275,8 @@ SQL expression building blocks, following the Expression-Dialect separation patt ```python from rhosocial.activerecord.backend.expression import ( Column, Literal, FunctionCall, - QueryExpression, InsertExpression, UpdateExpression, - CTEExpression, + QueryStatement, InsertStatement, UpdateStatement, + CTEClause, # Function factories count, sum_, avg, row_number, rank, json_extract, array_agg, diff --git a/src/rhosocial/activerecord/backend/base/base.py b/src/rhosocial/activerecord/backend/base/base.py index 922d9d376..f2e072541 100644 --- a/src/rhosocial/activerecord/backend/base/base.py +++ b/src/rhosocial/activerecord/backend/base/base.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/base/base.py import logging from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Tuple from ..config import ConnectionConfig from ..dialect import SQLDialectBase @@ -107,3 +107,19 @@ def get_default_schema(self) -> Optional[str]: Optional[str]: Default schema name, or None if not configured """ return None + + def get_cached_server_version(self) -> Optional[Tuple[int, int, int]]: + """The server version this backend already knows, without asking. + + The I/O-free counterpart of :meth:`get_server_version`: a caller that + only wants to *report* the version -- introspection's + ``IntrospectionDatabaseInfo``, for one -- should not have to open a + connection to learn it. Returns ``None`` when nothing has resolved the + version yet, which is the honest answer rather than a guess. + + The default reads :attr:`_server_version_cache`, where the backends + that probe the server on connect keep what they found. A backend that + resolves the version some other way (from its configuration, or from + the driver, as SQLite can) overrides this to say so. + """ + return self._server_version_cache diff --git a/src/rhosocial/activerecord/backend/base/batch_execution.py b/src/rhosocial/activerecord/backend/base/batch_execution.py index 1fe3dac33..e7ec31924 100644 --- a/src/rhosocial/activerecord/backend/base/batch_execution.py +++ b/src/rhosocial/activerecord/backend/base/batch_execution.py @@ -24,13 +24,13 @@ from ..result import QueryResult, BatchDMLResult, BatchDQLResult, BatchCommitMode from ..type_adapter import SQLTypeAdapter -from ..expression import InsertExpression, UpdateExpression, DeleteExpression +from ..expression import InsertStatement, UpdateStatement, DeleteStatement if TYPE_CHECKING: from ..expression import ( - QueryExpression, - WithQueryExpression, - SetOperationExpression, + QueryStatement, + WithStatement, + SetOperation, ) @@ -60,16 +60,16 @@ class _BatchDMLBundle: # Type aliases for DML expressions HomogeneousDMLList = Union[ - List["InsertExpression"], - List["UpdateExpression"], - List["DeleteExpression"], + List["InsertStatement"], + List["UpdateStatement"], + List["DeleteStatement"], ] # Type alias for DQL expressions DQLExpression = Union[ - "QueryExpression", - "WithQueryExpression", - "SetOperationExpression", + "QueryStatement", + "WithStatement", + "SetOperation", ] @@ -106,8 +106,8 @@ def execute_batch_dml( Args: expressions: List of homogeneous DML expressions (all must be the same - type - all InsertExpression, all UpdateExpression, or all - DeleteExpression). + type - all InsertStatement, all UpdateStatement, or all + DeleteStatement). batch_size: Number of expressions to process per batch. Default: 100. commit_mode: Transaction commit strategy: - WHOLE: Execute all batches in a single transaction (default). @@ -145,8 +145,8 @@ def execute_batch_dml( Example: >>> expressions = [ - ... InsertExpression(dialect, into="users", source=ValuesSource(...)), - ... InsertExpression(dialect, into="users", source=ValuesSource(...)), + ... InsertStatement(dialect, into="users", source=ValuesSource(...)), + ... InsertStatement(dialect, into="users", source=ValuesSource(...)), ... ] >>> for result in backend.execute_batch_dml(expressions, batch_size=50): ... print(f"Batch {result.batch_index}: {result.total_affected_rows} rows") @@ -259,8 +259,8 @@ def execute_batch_dql( processing of large result sets. Args: - expression: A DQL expression (QueryExpression, WithQueryExpression, - or SetOperationExpression). + expression: A DQL expression (QueryStatement, WithStatement, + or SetOperation). page_size: Number of rows per page. Default: 1000. column_adapters: Optional type adapters for processing columns. column_mapping: Optional mapping from column names to field names. @@ -279,7 +279,7 @@ def execute_batch_dql( - Breaking out of the iteration early will properly close the cursor. Example: - >>> query = QueryExpression(dialect, select=[...], from_="users") + >>> query = QueryStatement(dialect, select=[...], from_="users") >>> for page in backend.execute_batch_dql(query, page_size=500): ... print(f"Page {page.page_index}: {page.page_size} rows") ... for row in page.data: @@ -414,7 +414,7 @@ def _compile_and_validate_dml( # Stage 2: RETURNING conflict detection for i, expr in enumerate(expressions): - if isinstance(expr, (InsertExpression, UpdateExpression, DeleteExpression)) and expr.returning is not None: + if isinstance(expr, (InsertStatement, UpdateStatement, DeleteStatement)) and expr.returning is not None: raise ValueError( f"Expressions must not carry their own RETURNING clause. " f"Use the returning_columns parameter instead. " @@ -819,7 +819,7 @@ def _compile_and_validate_dml( # Stage 2: RETURNING conflict detection for i, expr in enumerate(expressions): - if isinstance(expr, (InsertExpression, UpdateExpression, DeleteExpression)) and expr.returning is not None: + if isinstance(expr, (InsertStatement, UpdateStatement, DeleteStatement)) and expr.returning is not None: raise ValueError( f"Expressions must not carry their own RETURNING clause. " f"Use the returning_columns parameter instead. " diff --git a/src/rhosocial/activerecord/backend/base/operations.py b/src/rhosocial/activerecord/backend/base/operations.py index 4f7fc0aae..cf394ad07 100644 --- a/src/rhosocial/activerecord/backend/base/operations.py +++ b/src/rhosocial/activerecord/backend/base/operations.py @@ -9,7 +9,7 @@ from typing import List, Optional from ..dialect.base import SQLDialectBase -from ..expression import InsertExpression, UpdateExpression, DeleteExpression, Literal, Column +from ..expression import InsertStatement, UpdateStatement, DeleteStatement, Literal, Column from ..expression.objects import Table from ..expression.bases import ToSQLProtocol from ..expression.statements import ReturningClause, ValuesSource @@ -28,7 +28,7 @@ from ..type_adapter import SQLTypeAdapter -def _is_sql_expression(obj) -> bool: +def is_sql_expression(obj) -> bool: """Check if an object is a valid SQL expression. A valid SQL expression must implement ToSQLProtocol and have a dialect @@ -50,7 +50,7 @@ def insert(self, options: InsertOptions) -> QueryResult: """ Inserts a record into the database using encapsulated InsertOptions. - This method generates an `InsertExpression` and executes it. + This method generates an `InsertStatement` and executes it. It uses the Options pattern to keep the method signature clean and extensible. Args: @@ -62,14 +62,14 @@ def insert(self, options: InsertOptions) -> QueryResult: processed_values = [] for v in options.data.values(): # Check if the value is a SQL expression that should be used directly in SQL - if _is_sql_expression(v): + if is_sql_expression(v): # This is an expression object like FunctionCall, BinaryExpression, etc. processed_values.append(v) else: # This is a regular value that should be wrapped in a Literal processed_values.append(Literal(self.dialect, v)) - # Create a ValuesSource to use as the data source for the InsertExpression + # Create a ValuesSource to use as the data source for the InsertStatement values_source = ValuesSource(self.dialect, [processed_values]) # Create ReturningClause if returning_columns is specified @@ -80,7 +80,7 @@ def insert(self, options: InsertOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=self.dialect, into=Table( self.dialect, @@ -112,7 +112,7 @@ def update(self, options: UpdateOptions) -> QueryResult: """ Updates records in the database using encapsulated UpdateOptions. - This method generates an `UpdateExpression` and executes it. + This method generates an `UpdateStatement` and executes it. It expects a `SQLPredicate` for the `where` clause. Args: @@ -126,7 +126,7 @@ def update(self, options: UpdateOptions) -> QueryResult: # Process each item in the data dictionary for k, v in options.data.items(): # Check if the value is a SQL expression that should be used directly in SQL - if _is_sql_expression(v): + if is_sql_expression(v): # This is an expression object like FunctionCall, BinaryExpression, etc. assignments[k] = v else: @@ -141,7 +141,7 @@ def update(self, options: UpdateOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, table=Table( self.dialect, @@ -173,7 +173,7 @@ def delete(self, options: DeleteOptions) -> QueryResult: """ Deletes records from the database using encapsulated DeleteOptions. - This method generates a `DeleteExpression` and executes it. + This method generates a `DeleteStatement` and executes it. It expects a `SQLPredicate` for the `where` clause. Args: @@ -189,7 +189,7 @@ def delete(self, options: DeleteOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dialect=self.dialect, tables=Table( self.dialect, @@ -281,7 +281,7 @@ def bulk_insert(self, options: BulkInsertOptions) -> QueryResult: for row in options.rows: processed_values = [] for v in row: - if _is_sql_expression(v): + if is_sql_expression(v): processed_values.append(v) else: processed_values.append(Literal(self.dialect, v)) @@ -296,7 +296,7 @@ def bulk_insert(self, options: BulkInsertOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=self.dialect, into=Table( self.dialect, @@ -354,7 +354,7 @@ def bulk_update(self, options: BulkUpdateOptions) -> QueryResult: pk_literals = Literal(self.dialect, options.pk_values) where_predicate = InPredicate(self.dialect, pk_col, pk_literals) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, table=Table( self.dialect, @@ -397,14 +397,14 @@ async def insert(self, options: InsertOptions) -> QueryResult: processed_values = [] for v in options.data.values(): # Check if the value is a SQL expression that should be used directly in SQL - if _is_sql_expression(v): + if is_sql_expression(v): # This is an expression object like FunctionCall, BinaryExpression, etc. processed_values.append(v) else: # This is a regular value that should be wrapped in a Literal processed_values.append(Literal(self.dialect, v)) - # Create a ValuesSource to use as the data source for the InsertExpression + # Create a ValuesSource to use as the data source for the InsertStatement values_source = ValuesSource(self.dialect, [processed_values]) # Create ReturningClause if returning_columns is specified @@ -415,7 +415,7 @@ async def insert(self, options: InsertOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=self.dialect, into=Table( self.dialect, @@ -458,7 +458,7 @@ async def update(self, options: UpdateOptions) -> QueryResult: # Process each item in the data dictionary for k, v in options.data.items(): # Check if the value is a SQL expression that should be used directly in SQL - if _is_sql_expression(v): + if is_sql_expression(v): # This is an expression object like FunctionCall, BinaryExpression, etc. assignments[k] = v else: @@ -473,7 +473,7 @@ async def update(self, options: UpdateOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, table=Table( self.dialect, @@ -518,7 +518,7 @@ async def delete(self, options: DeleteOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dialect=self.dialect, tables=Table( self.dialect, @@ -608,7 +608,7 @@ async def bulk_insert(self, options: BulkInsertOptions) -> QueryResult: for row in options.rows: processed_values = [] for v in row: - if _is_sql_expression(v): + if is_sql_expression(v): processed_values.append(v) else: processed_values.append(Literal(self.dialect, v)) @@ -623,7 +623,7 @@ async def bulk_insert(self, options: BulkInsertOptions) -> QueryResult: returning_expressions = [ExprColumn(self.dialect, col) for col in options.returning_columns] returning_clause = ReturningClause(self.dialect, returning_expressions) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=self.dialect, into=Table( self.dialect, @@ -675,7 +675,7 @@ async def bulk_update(self, options: BulkUpdateOptions) -> QueryResult: pk_literals = Literal(self.dialect, options.pk_values) where_predicate = InPredicate(self.dialect, pk_col, pk_literals) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, table=Table( self.dialect, diff --git a/src/rhosocial/activerecord/backend/base/type_adaption.py b/src/rhosocial/activerecord/backend/base/type_adaption.py index 17493bd3b..4b542a2c1 100644 --- a/src/rhosocial/activerecord/backend/base/type_adaption.py +++ b/src/rhosocial/activerecord/backend/base/type_adaption.py @@ -94,7 +94,7 @@ def prepare_parameters( raise TypeError("Unsupported types for params and param_adapters.") - def _adapt_row_types( + def adapt_row_types( self, row_dict: Dict[str, Any], column_adapters: Dict[str, Tuple[SQLTypeAdapter, Type]] ) -> Dict[str, Any]: """ @@ -129,7 +129,7 @@ def _adapt_row_types( processed_row[col_name] = value return processed_row - def _remap_row_columns(self, row_dict: Dict[str, Any], column_mapping: Dict[str, str]) -> Dict[str, Any]: + def remap_row_columns(self, row_dict: Dict[str, Any], column_mapping: Dict[str, str]) -> Dict[str, Any]: """ Maps database column names to Python field names in a result row. @@ -163,8 +163,8 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma Args: cursor: The database cursor object after a query has been executed is_select: Flag indicating if it was a SELECT query - column_adapters: Dictionary for type conversion, passed to `_adapt_row_types` - column_mapping: Dictionary for key remapping, passed to `_remap_row_columns` + column_adapters: Dictionary for type conversion, passed to `adapt_row_types` + column_mapping: Dictionary for key remapping, passed to `remap_row_columns` Returns: List of fully processed row dictionaries, or None if not a SELECT query. @@ -182,8 +182,8 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma mapping = column_mapping or {} for row in rows: row_dict = dict(zip(column_names, row)) - adapted_row = self._adapt_row_types(row_dict, adapters) - final_row = self._remap_row_columns(adapted_row, mapping) + adapted_row = self.adapt_row_types(row_dict, adapters) + final_row = self.remap_row_columns(adapted_row, mapping) final_results.append(final_row) return final_results except Exception as e: @@ -223,8 +223,8 @@ async def _process_result_set( Args: cursor: The async database cursor object after a query has been executed is_select: Flag indicating if it was a SELECT query - column_adapters: Dictionary for type conversion, passed to `_adapt_row_types` - column_mapping: Dictionary for key remapping, passed to `_remap_row_columns` + column_adapters: Dictionary for type conversion, passed to `adapt_row_types` + column_mapping: Dictionary for key remapping, passed to `remap_row_columns` Returns: List of fully processed row dictionaries, or None if not a SELECT query. @@ -242,8 +242,8 @@ async def _process_result_set( mapping = column_mapping or {} for row in rows: row_dict = dict(zip(column_names, row)) - adapted_row = self._adapt_row_types(row_dict, adapters) - final_row = self._remap_row_columns(adapted_row, mapping) + adapted_row = self.adapt_row_types(row_dict, adapters) + final_row = self.remap_row_columns(adapted_row, mapping) final_results.append(final_row) return final_results except Exception as e: diff --git a/src/rhosocial/activerecord/backend/dialect/base.py b/src/rhosocial/activerecord/backend/dialect/base.py index f1891f6eb..296a4a91b 100644 --- a/src/rhosocial/activerecord/backend/dialect/base.py +++ b/src/rhosocial/activerecord/backend/dialect/base.py @@ -6,12 +6,15 @@ All SQL formatting logic lives in Mixin classes in mixins.py. """ -import re import warnings as _warnings -from typing import Any, ClassVar, Dict, FrozenSet, Optional, Tuple, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar, Dict, FrozenSet, Optional, Tuple, Union -from .exceptions import DialectNotAdaptedException, ProtocolNotImplementedError, UnsupportedFeatureError from ..warnings import IdentifierQuotingWarning +from .exceptions import ( + DialectNotAdaptedException, + ProtocolNotImplementedError, + UnsupportedFeatureError, +) if TYPE_CHECKING: import datetime as _dt @@ -57,8 +60,16 @@ def __init__(self) -> None: @property def version(self) -> Tuple[int, int, int]: + """The adapted server version, or a raise when there is none. + + Strict on purpose: a renderer that gates SQL on a version must know + which version it is gating against, so silence would be worse than a + failure. A caller that only needs to *report* or *probe* uses + :attr:`version_or_none` / :meth:`version_at_least`, which never raise. + """ if self._version is None: from .exceptions import DialectNotAdaptedException + raise DialectNotAdaptedException(self.name) return self._version @@ -66,6 +77,47 @@ def version(self) -> Tuple[int, int, int]: def version(self, value: Tuple[int, int, int]) -> None: self._version = value + def _declared_version(self) -> Optional[Tuple[int, int, int]]: + """The version this dialect can name without asking a server. + + A capability check has to answer before a connection exists, and some + dialects already have the answer: SQLite has no server to ask, because + the engine is the library its driver links against, so + ``sqlite3.sqlite_version_info`` is the version in force. A + client/server backend has no such answer -- the driver's own version + says nothing about the server -- so the default is ``None`` and asking + for the strict :attr:`version` keeps raising until adaptation. + + Override this to name the version that holds before + ``backend.introspect_and_adapt()`` runs. :attr:`version_or_none` and + :meth:`version_at_least` then answer instead of raising. + """ + return None + + @property + def version_or_none(self) -> Optional[Tuple[int, int, int]]: + """The version in force, or ``None`` when it is not known yet. + + :attr:`version` is the strict form and raises when the dialect has not + been adapted. This one is for callers that would rather learn nothing + than fail, and it is what :meth:`version_at_least` reads. + """ + if self._version is not None: + return self._version + return self._declared_version() + + def version_at_least(self, floor: Tuple[int, int, int]) -> bool: + """Whether the version in force reaches *floor*, without raising. + + This is the predicate capability checks must use. A dialect that + cannot name its version answers ``False``: a capability nobody has + confirmed is not claimed. Asking must not throw either, because a + caller deciding *whether* to use a feature cannot be expected to catch + an exception just to learn "no". + """ + version = self.version_or_none + return version is not None and version >= floor + @property def name(self) -> str: return self.__class__.__name__.replace("Dialect", "") @@ -84,16 +136,17 @@ def p(self, position: int = 0) -> str: return self.get_parameter_placeholder(position) def inline_sql_literal(self, value: Any) -> str: - """Render a Python scalar as a safe, inline SQL literal. + """Render a Python value as a safe, inline SQL literal. DDL clauses (CHECK / DEFAULT / partition boundaries / partial-index WHERE) accept no bind parameters, so their literal values render inline with dialect-controlled escaping — this is the single point - of control for that rendering. + of control for that rendering, backed by the three-level literal + construction (built-in scalars → backend overrides → the + literal-construction protocol; no ``str()`` fallback). """ return self.format_literal(value) - def get_isolation_level_name(self, level) -> str: level_name = level.name if hasattr(level, "name") else str(level) return self.ISOLATION_LEVEL_NAMES.get(level_name, level_name.replace("_", " ")) @@ -133,15 +186,21 @@ def create_schema_differ(self) -> "SchemaDiffer": def require_protocol(self, protocol_type: type, _feature_name: str, required_by: str) -> None: if not isinstance(self, protocol_type): raise ProtocolNotImplementedError( - dialect_name=self.name, protocol_name=protocol_type.__name__, required_by=required_by + dialect_name=self.name, + protocol_name=protocol_type.__name__, + required_by=required_by, ) - def check_feature_support(self, check_method: str, feature_name: str, suggestion: Optional[str] = None) -> None: + def check_feature_support( + self, check_method: str, feature_name: str, suggestion: Optional[str] = None + ) -> None: is_supported = False if hasattr(self, check_method): is_supported = getattr(self, check_method)() if not is_supported: - raise UnsupportedFeatureError(dialect_name=self.name, feature_name=feature_name, suggestion=suggestion) + raise UnsupportedFeatureError( + dialect_name=self.name, feature_name=feature_name, suggestion=suggestion + ) # ------------------------------------------------------------------ # Version floors for named features (functions above all) @@ -182,7 +241,9 @@ def function_version_floor(self, func_name: str) -> Optional[Tuple[int, int, int The floor as a ``(major, minor, patch)`` tuple, or ``None`` when this dialect records no floor for *func_name*. """ - floors: Optional[Dict[str, Tuple[int, int, int]]] = getattr(self, "function_version_floors", None) + floors: Optional[Dict[str, Tuple[int, int, int]]] = getattr( + self, "function_version_floors", None + ) if not floors: return None floor = floors.get(func_name) @@ -285,17 +346,59 @@ def format_identifier(self, identifier: str, need_quote: bool = True) -> str: escaped = identifier.replace('"', '""') return f'"{escaped}"' + def escape_sql_string(self, value: str) -> str: + """This dialect's string-escape policy, for inline literal construction. + + This is the helper the literal-construction protocol must use for + every string component it embeds in the literal text it returns: + single quotes are doubled so the component is safe inside a + single-quoted SQL string. Hand-concatenated quoting is a violation + of the protocol. + + The policy belongs to the dialect, because SQL dialects differ in + how a string literal represents a quote: most double the quote, + while MySQL, MariaDB and ClickHouse treat a backslash as an escape + character and BigQuery spells a quote as a backslash-quote. So a + dialect overrides this method rather than the framework deciding. + + It is an instance method rather than a static one because a policy + may depend on the dialect's own state: MySQL's depends on the + session's SQL mode, since under ``NO_BACKSLASH_ESCAPES`` a + backslash is a literal character and must not be doubled. + """ + return value.replace("'", "''") + def format_literal( self, value: "Optional[Union[bool, int, float, Decimal, _dt.datetime, _dt.date]]" ) -> str: - """Render a Python scalar as a safe inline SQL literal. + """Render a value as a safe inline SQL literal — the three-level + construction. Used by DDL clauses (CHECK / DEFAULT / partition boundaries / partial-index WHERE / generated columns), which accept no bind - parameters. Only scalar types with portable inline forms are handled - here; backends override for type-specific forms (e.g. bytes, native - date literals). This is the single point of control for inline literal - rendering. + parameters, and by every inline-strategy literal node. Construction + is attempted at three levels, in order, and an unrenderable value + fails explicitly — there is no ``str()`` fallback: + + 1. **Dialect base class built-ins** — the host scalars whose inline + form is portable or dialect-spelled: ``NULL`` and booleans render + as keywords, numbers as numeric text (with a range check: ``NaN`` + and ``±Inf`` must never reach SQL text — ``float('inf')`` + stringifies as ``'inf'`` and would degrade to an identifier), and + date/time values as ISO-8601 strings through the escape helper. + Note ``bool`` is an ``int`` subclass, so the boolean dispatch runs + before the numeric check. + 2. **Backend overrides** — host types whose literal form is + backend-specific (``bytes`` BLOB literals, ``UUID``, + ``timedelta`` intervals, arrays, JSON). The base class does *not* + guess them; a backend dialect must override this method for the + value types it supports, and leave the failure explicit for the + ones it does not. + 3. **The literal-construction protocol** — a user-defined or + backend-specific value type implements + :class:`SQLLiteral`: the returned text is embedded verbatim as an + atom. The protocol is a rendering duty, distinct from + ``DataType`` column-type declaration. The returned literal is an **escaped atom** (string values are quoted and escaped), so any placeholder character inside it cannot be @@ -318,27 +421,73 @@ def format_literal( raise ValueError("non-finite Decimal cannot be inlined into SQL") return str(value) if isinstance(value, (int, float)): - if isinstance(value, float) and value != value: # NaN - raise ValueError("NaN cannot be inlined into SQL") - return repr(value) if isinstance(value, float) else str(value) + if isinstance(value, float): + if value != value: # NaN + raise ValueError("NaN cannot be inlined into SQL") + if value == float("inf") or value == float("-inf"): + raise ValueError("Inf cannot be inlined into SQL") + return repr(value) + return str(value) if isinstance(value, _dt.datetime): - escaped = self._escape_sql_string(value.isoformat(sep=" ")) + escaped = self.escape_sql_string(value.isoformat(sep=" ")) return f"'{escaped}'" if isinstance(value, _dt.date): - escaped = self._escape_sql_string(value.isoformat()) + escaped = self.escape_sql_string(value.isoformat()) + return f"'{escaped}'" + if isinstance(value, _dt.time): + # A time of day is a portable quoted form like the other two, and + # isoformat keeps the offset an aware time carries. It needs its own + # branch because it shares neither base class with datetime/date. + 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}'" + # Level 3: the value type defines its own literal construction. + from ..expression.bases import SQLLiteral + + if isinstance(value, SQLLiteral): + text = value.__sql_literal__(self) + if not isinstance(text, str) or not text: + raise TypeError( + f"{self.name}: {value!r} implements the literal-construction " + f"protocol but returned {type(text).__name__}, expected a " + f"non-empty inline literal string." + ) + return text raise TypeError( f"{self.name}: value {value!r} of type {type(value).__name__} " - f"has no inline SQL literal form; use a backend-specific literal." + f"has no inline SQL literal form; use a backend-specific literal " + f"or implement the literal-construction protocol." ) - @staticmethod - def _escape_sql_string(value: str) -> str: - return value.replace("'", "''") + def refuse_ddl_bind_parameters(self, feature: str) -> None: + """Refuse a DDL statement whose body carries bind parameters. + + The body of a DDL statement is a query, and a query may carry bind + parameters. Whether the engine accepts them is the dialect's + question -- :meth:`supports_create_table_as_bind_parameters`, + :meth:`supports_create_view_bind_parameters` and + :meth:`supports_materialized_view_bind_parameters` answer it -- and + this is where a dialect that answers "no" is told so: by name, with + the way out named. + + The reason comes from the dialect + (``_ddl_bind_parameter_reason``), so a backend that refuses binds + for a *measured* reason says which one; the default lives on the + core mixins, not here, because this class sits early in a dialect's + MRO and a default here would shadow every backend's own answer. + + The way out is always the same: build the query with an inlined + literal, so the value is in the statement's text and not in a + parameter. + """ + raise UnsupportedFeatureError( + self.name, + feature, + suggestion=( + f"{self._ddl_bind_parameter_reason(feature)} " + "Build the query with an inlined literal instead." + ), + ) - @staticmethod - def _validate_data_type(data_type: str) -> bool: - return bool(re.fullmatch(r"[A-Za-z0-9\s(),]+", data_type)) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/array.py b/src/rhosocial/activerecord/backend/dialect/mixins/array.py index 2b14ea691..257673956 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/array.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/array.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: # pragma: no cover - from ...expression.advanced_functions import ArrayExpression + from ...expression.advanced_functions import ArrayValue class ArrayMixin: @@ -35,14 +35,14 @@ def supports_array_access(self) -> bool: """ return False - def format_array_expression(self, expr: "ArrayExpression") -> Tuple[str, Tuple]: + def format_array_expression(self, expr: "ArrayValue") -> Tuple[str, Tuple]: """Format an array expression. Renders either the ``ARRAY[...]`` constructor or a subscript access ``(base[index])`` form depending on ``expr.operation``. Args: - expr: ArrayExpression object to format. + expr: ArrayValue object to format. Returns: Tuple of (SQL string, parameters tuple) for the formatted expression. diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/collation.py b/src/rhosocial/activerecord/backend/dialect/mixins/collation.py index 8ecdbd404..6d5e46d35 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/collation.py @@ -5,7 +5,7 @@ from ..exceptions import UnsupportedFeatureError if TYPE_CHECKING: # pragma: no cover - from ...expression.collation import CollateExpression + from ...expression.collation import CollateFunction class CollationMixin: @@ -22,11 +22,11 @@ def supports_collate_expression(self) -> bool: """ return False - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate a collation expression and return its SQL representation. Args: - expr: CollateExpression whose collation name should be validated. + expr: CollateFunction whose collation name should be validated. Returns: SQL string for the validated collation name. @@ -37,11 +37,11 @@ def validate_collation_name(self, expr: "CollateExpression") -> str: """ raise UnsupportedFeatureError(self.name, "COLLATE collation validation") - def format_collate_expression(self, expr: "CollateExpression") -> Tuple[str, tuple]: + def format_collate_expression(self, expr: "CollateFunction") -> Tuple[str, tuple]: """Format expression-level COLLATE (plus outer alias). Args: - expr: CollateExpression object to format. + expr: CollateFunction object to format. Returns: Tuple of (SQL string, parameters tuple) for the formatted expression. diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/cte.py b/src/rhosocial/activerecord/backend/dialect/mixins/cte.py index f629a399c..9ce8d14b2 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/cte.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/cte.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, List, Tuple if TYPE_CHECKING: # pragma: no cover - from ...expression import bases + from ...expression.query_sources import CTEClause, WithStatement class CTEMixin: @@ -42,11 +42,11 @@ def supports_unconditional_cte_order_by(self) -> bool: """ return True - def format_cte_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + def format_cte_clause(self, expr: "CTEClause") -> Tuple[str, tuple]: """Format a single CTE definition (name AS (query)). Args: - expr: CTE expression carrying ``name``, optional ``columns``, the + expr: The ``CTEClause`` carrying ``name``, optional ``columns``, the ``materialized`` hint, and the ``query``. Returns: @@ -91,42 +91,48 @@ def format_cte_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tupl sql = f"{name_part}{columns_part} AS {materialized_hint}({query_sql})" return sql, query_params - def format_with_query_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """Format a complete query with a WITH clause. + def format_with_statement(self, stmt: "WithStatement") -> Tuple[str, tuple]: + """Format a query with a ``WITH`` clause: ``WITH [RECURSIVE] cte [, ...] query``. + + The probes this construct consults used to be decorative: a dialect + that declares no basic CTE support refused nothing and the clause + rendered anyway, and a ``RECURSIVE`` request on a dialect without + recursive CTEs was silently spelled. Both are refused by name now, + where the construct is spelled. + + Each CTE definition renders through its own formatter + (``format_cte_clause``), so a backend re-spells a single CTE there + rather than re-implementing the composition around it. Args: - expr: Query expression carrying ``ctes``, ``recursive``, and - ``main_query``. + stmt: The ``WithStatement`` carrying the ``ctes``, the + ``recursive`` modifier, and the trailing ``query``. Returns: - Tuple of (SQL string, parameters tuple) for the full query. + Tuple of (SQL string, parameters tuple) for the full statement. + A statement with no CTE definitions renders the bare query. + + Raises: + UnsupportedFeatureError: If CTEs are present but the dialect + declares no basic CTE support, or ``recursive`` was + requested on a dialect without recursive CTE support. """ + from ..exceptions import UnsupportedFeatureError + + if not stmt.ctes: + query_sql, query_params = stmt.query.to_sql() + return query_sql, tuple(query_params) + if not self.supports_basic_cte(): + raise UnsupportedFeatureError(self.name, "WITH clause (basic CTE)") + if stmt.recursive and not self.supports_recursive_cte(): + raise UnsupportedFeatureError(self.name, "WITH RECURSIVE clause") all_params: List[Any] = [] cte_sql_parts: List[str] = [] - for cte in expr.ctes: + for cte in stmt.ctes: cte_sql, cte_params = cte.to_sql() cte_sql_parts.append(cte_sql) all_params.extend(cte_params) - main_sql, main_params = expr.main_query.to_sql() - all_params.extend(main_params) - has_recursive = bool(expr.recursive) - if not cte_sql_parts: - return main_sql, tuple(all_params) - with_clause = self._format_with_clause(cte_sql_parts, has_recursive) - return f"{with_clause} {main_sql}", tuple(all_params) - - def _format_with_clause(self, ctes_sql: List[str], has_recursive: bool = False) -> str: - """Helper to format complete WITH clause from list of CTE definitions. - - Args: - ctes_sql: Rendered CTE definition strings. - has_recursive: Whether to emit the ``RECURSIVE`` keyword. - - Returns: - The rendered ``WITH`` clause, or an empty string when there are no - CTE definitions. - """ - if not ctes_sql: - return "" - recursive_str = "RECURSIVE " if has_recursive else "" - return f"WITH {recursive_str}{', '.join(ctes_sql)}" + recursive_str = "RECURSIVE " if stmt.recursive else "" + query_sql, query_params = stmt.query.to_sql() + all_params.extend(query_params) + return f"WITH {recursive_str}{', '.join(cte_sql_parts)} {query_sql}", tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/data_type.py b/src/rhosocial/activerecord/backend/dialect/mixins/data_type.py index 18a6ef9ff..fad724e27 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/data_type.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/data_type.py @@ -13,7 +13,9 @@ SQLQueryAndParams = Tuple[str, tuple] -_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") +# Name validation lives in expression.types._base.DataType, which enforces it at +# class-definition time; a second copy here would only be a rule that could drift +# from the one that actually runs. class DataTypeMixin: @@ -62,7 +64,7 @@ class DataTypeMixin: No registry ----------- There is deliberately no table mapping a generic name to a class. The naming - family *is* the registry, and :meth:`_type_class_for` says where the class + family *is* the registry, and :meth:`type_class_for` says where the class comes from — the formatter's own annotation first, then the backend's type module, then the live subclass tree. That last step is why ``tests/.../dummy2/test_type_dispatch_registry.py`` asks in a **fresh @@ -92,12 +94,12 @@ def format_data_type(self, data_type: DataType) -> SQLQueryAndParams: f"{type(self).__name__}.format_data_type() expects a " f"DataType instance, got {type(data_type).__name__}." ) - name = getattr(data_type, "name", None) - if not name or not _NAME_RE.match(name): - raise TypeError( - f"{type(data_type).__name__} does not declare a valid generic " - f"type name (name={name!r}); cannot dispatch." - ) + # `name` is declared on DataType and every concrete subclass has it + # validated at class-definition time (see DataType.__init_subclass__), + # so it is read directly: probing for a field the contract already + # guarantees is the reflection §11.2 forbids, and the None check it + # invites would only ever catch a class that never could have existed. + name = data_type.name formatter = getattr(self, f"format_data_type_{name}", None) if formatter is None: raise TypeError( @@ -167,12 +169,12 @@ def supports_data_types(self) -> Dict[str, type]: probe = getattr(self, f"supports_data_type_{name}", None) if probe is not None and not probe(): continue - data_type_class = self._type_class_for(name) + data_type_class = self.type_class_for(name) if data_type_class is not None: result[name] = data_type_class return result - def _type_class_for(self, name: str) -> Optional[type]: + def type_class_for(self, name: str) -> Optional[type]: """Resolve the concrete ``DataType`` class for a generic name. Three answers are available and any two of them agree wherever more than @@ -232,7 +234,7 @@ def _type_class_for(self, name: str) -> Optional[type]: continue seen.add(klass) stack.extend(klass.__subclasses__()) - if isinstance(klass, type) and getattr(klass, "name", None) == name: + if isinstance(klass, type) and klass.name == name: return klass return None @@ -300,7 +302,7 @@ def type_parameter_defaults(self) -> Dict[str, Dict[str, Any]]: """ return {} - def _check_spelling(self, data_type, accepted) -> None: + def check_spelling(self, data_type, accepted) -> None: """Raise unless *data_type* carries a spelling this dialect renders. A ``DataType`` with several spellings (one class, several ways to write diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/datetime.py b/src/rhosocial/activerecord/backend/dialect/mixins/datetime.py index b1128c203..f7c30fd51 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/datetime.py @@ -4,6 +4,7 @@ from typing import Tuple from ...expression import bases +from ...expression.mixins import AliasableMixin class DateTimeMixin: @@ -20,7 +21,7 @@ def apply_alias(self, sql: str, params: tuple, expr) -> Tuple[str, tuple]: Returns: A ``(sql, params)`` tuple, with the alias appended when present. """ - if getattr(expr, "alias", None): + if isinstance(expr, AliasableMixin) and expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params @@ -28,15 +29,63 @@ def format_extract_expression(self, expr: "bases.BaseExpression") -> Tuple[str, """Format an ``EXTRACT(field FROM source)`` expression. Args: - expr: The expression carrying ``field`` and ``source``. + expr: The expression carrying ``field``, ``source`` and the + ``spelling`` that was asked for. Returns: A ``(sql, params)`` tuple. + + The standard's spelling is ``EXTRACT(FIELD FROM source)``; the others + name the field as a function of the source (``YEAR(source)``), which is + the same answer and a different grammar. Both are this node, so which one + is written is the formatter's business and the SQL stays whatever the + call asked for. """ source_sql, source_params = expr.source.to_sql() - sql = f"EXTRACT({expr.field.value.upper()} FROM {source_sql})" + field = expr.field.value.upper() + if expr.spelling == "EXTRACT": + sql = f"EXTRACT({field} FROM {source_sql})" + else: + sql = f"{expr.spelling}({source_sql})" return self.apply_alias(sql, source_params, expr) + def _format_temporal_now(self, name: str, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render a current-time/timestamp node, with or without a precision. + + No precision means the bare keyword -- but "bare" is not the same as + "without parentheses", and which one it is depends on the node: + :attr:`niladic` says the standard writes this one without them + (``CURRENT_TIMESTAMP``) or with them (``NOW``, where dropping them is a + syntax error on MySQL). A precision is an argument either way, so it + brings the parentheses with it. + """ + if expr.precision is None: + sql = name.upper() if expr.niladic else f"{name.upper()}()" + return self.apply_alias(sql, (), expr) + precision_sql, precision_params = expr.precision.to_sql() + sql = f"{name.upper()}({precision_sql})" + return self.apply_alias(sql, precision_params, expr) + + def format_now_function(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render ``NOW()`` -- a timestamp.""" + return self._format_temporal_now("NOW", expr) + + def format_current_date_function(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render ``CURRENT_DATE`` -- a date, with no precision to give it.""" + return self._format_temporal_now("CURRENT_DATE", expr) + + def format_current_time_function(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render ``CURRENT_TIME`` -- a time of day.""" + return self._format_temporal_now("CURRENT_TIME", expr) + + def format_current_timestamp_function(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render ``CURRENT_TIMESTAMP`` -- a timestamp.""" + return self._format_temporal_now("CURRENT_TIMESTAMP", expr) + + def format_localtimestamp_function(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Render ``LOCALTIMESTAMP`` -- a timestamp in the session's zone.""" + return self._format_temporal_now("LOCALTIMESTAMP", expr) + def format_date_part_expression(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: """Format a ``DATE_PART`` expression as ``EXTRACT``. @@ -57,9 +106,8 @@ def format_date_trunc_expression(self, expr: "bases.BaseExpression") -> Tuple[st Returns: A ``(sql, params)`` tuple. """ - from ...dialect.base import SQLDialectBase source_sql, source_params = expr.source.to_sql() - field = SQLDialectBase._escape_sql_string(expr.field.value) + field = expr.field.value sql = f"DATE_TRUNC('{field}', {source_sql})" return self.apply_alias(sql, source_params, expr) @@ -72,8 +120,7 @@ def format_interval_expression(self, expr: "bases.BaseExpression") -> Tuple[str, Returns: A ``(sql, params)`` tuple. """ - from ...dialect.base import SQLDialectBase - value = SQLDialectBase._escape_sql_string(str(expr.value)) + value = self.escape_sql_string(str(expr.value)) sql = f"INTERVAL '{value}' {expr.unit.value.upper()}" return self.apply_alias(sql, (), expr) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_column.py index 83c8a893e..01fd5b782 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_column.py @@ -1,43 +1,60 @@ +from rhosocial.activerecord.backend.expression.statements import ( + TableCheckConstraint, + TableForeignKeyConstraint, +) +from .ddl_table import normalize_foreign_key_match_type +from ...expression.objects import Index, Table +from ...expression.core import Literal +from ...expression.bases import BaseExpression, ToSQLProtocol +from typing import Any, List, Tuple, TYPE_CHECKING # src/rhosocial/activerecord/backend/dialect/mixins/ddl_column.py """DDL column formatting: column references, definitions, constraints, and ALTER TABLE column/constraint actions.""" -from copy import copy -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING - -from ...expression.bases import BaseExpression, ToSQLProtocol -from ...expression.core import Literal -from ...expression.objects import Index, Table -from .ddl_table import normalize_column_constraint_type, normalize_table_constraint_type - if TYPE_CHECKING: # pragma: no cover - from ....base.ddl import ColumnAttribute + from ...expression.statements.ddl_table import ( + ColumnCheckConstraint, + ColumnConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + ) from ...expression.core import Column from ...expression.statements.ddl_alter import ( - AddColumn, - AddIndex, - AddTableConstraint, - AlterColumn, - AlterConstraint, - ChangeColumn, - DropColumn, - DropIndex, - DropTableConstraint, - ModifyColumn, - RenameObject, - RenameTable, - ValidateConstraint, + AddColumnAction, + AddIndexAction, + AddTableConstraintAction, + AlterColumnAction, + AlterConstraintAction, + ChangeColumnAction, + DropColumnAction, + DropIndexAction, + DropTableConstraintAction, + ModifyColumnAction, + RenameColumnAction, + RenameTableAction, + ValidateConstraintAction, ) from ...expression.statements.ddl_table import ( + CharacterSetClause, + CollationClause, + ColumnClause, ColumnCommentClause, - ColumnConstraint, ColumnDefinition, DefaultValueClause, - GeneratedColumnExpression, IndexDefinition, ReferencesClause, StorageOptionsExpression, - TableConstraint, ) @@ -86,7 +103,7 @@ def format_column_comment_clause(self, clause: "ColumnCommentClause") -> Tuple[s self.name, "COLUMN COMMENT", f"{self.name} does not support COLUMN COMMENT." ) - escaped = self._escape_sql_string(clause.comment) + escaped = self.escape_sql_string(clause.comment) return f" COMMENT '{escaped}'", () def supports_column_collation(self) -> bool: @@ -108,33 +125,92 @@ def supports_column_character_set(self) -> bool: """ return False - def format_column_attribute(self, attr: "ColumnAttribute") -> Tuple[str, Tuple]: - """Render one selected column attribute as a definition fragment. - - Identity reuses :meth:`IdentityColumnMixin.format_identity_clause` - (per-backend syntax, capability-gated); collation renders the - SQL-standard ``COLLATE ``. Backend-only kinds (or unknown kinds) - raise ``UnsupportedFeatureError``. + def format_column_attribute(self, attr: "ColumnClause") -> Tuple[str, Tuple]: + """Render one selected column clause as a definition fragment. + + The subject is an expression-layer node: :class:`CollationClause`, + :class:`CharacterSetClause`, :class:`IdentityClause` or + :class:`AutoIncrementClause`. Each names its own formatter through + ``format_method``, so this method only has to dispatch. + + An AR-layer ``ColumnAttribute`` is accepted at the entrance and + translated before dispatch, because no deriver turns a model's + declarations into clauses yet -- that layer does not exist, so this is + currently the only route from ``UseColumnAttributes`` to SQL. The + translation lives in its own method so it can be deleted whole when a + deriver lands. It has to be here rather than in + :meth:`format_column_attributes` because backend + ``format_column_definition`` overrides call this method directly. + """ + from ..exceptions import UnsupportedFeatureError + from ...expression.statements.ddl_table import ( + CharacterSetClause, + CollationClause, + ) - Known coupling, recorded but not fixed here: ``attr`` is an AR-layer - ``IdentityAttribute`` because ``ColumnDefinition.attributes`` accepts - only AR-layer attribute objects -- passing an expression-layer - ``IdentityClause`` directly raises ``UnsupportedFeatureError``. This - reverse dependency (expression layer -> AR layer) predates the - identity split and removing it would touch the AR layer. + if not isinstance(attr, BaseExpression): + # An AR-layer ColumnAttribute, not an expression. The test is the + # type, not `hasattr(attr, "format_method")`: that probed a field + # every expression declares, and it also answered False for an + # expression whose base property raises -- which is a different + # question than "is this one of ours". §11.2: asserting to a known + # base is the way to ask what an argument is. + original = attr + attr = self.translate_ar_attribute(original) + if attr is None: + raise UnsupportedFeatureError( + self.name, + f"COLUMN CLAUSE {type(original).__name__}", + f"{self.name} cannot render this column clause; declare it " + f"only on backends that support it.", + ) + if isinstance(attr, CollationClause): + if not self.supports_column_collation(): + raise UnsupportedFeatureError(self.name, "column-level COLLATE") + return self.format_collation_clause(attr) + if isinstance(attr, CharacterSetClause): + if not self.supports_column_character_set(): + raise UnsupportedFeatureError(self.name, "column-level CHARACTER SET") + return self.format_character_set_clause(attr) + formatter = getattr(self, attr.format_method, None) + if formatter is None: + raise UnsupportedFeatureError( + self.name, + f"{type(attr).__name__} formatter", + f"{self.name} has no formatter for the column clause " + f"{type(attr).__name__}; declare it only on backends that " + f"support it.", + ) + return formatter(attr) + + def translate_ar_attribute(self, attr: Any) -> Any: + """Turn an AR-layer ``ColumnAttribute`` into an expression-layer clause. + + Transitional, and the reason is a missing layer rather than a + preference: nothing in the library turns a model's ``UseColumnAttributes`` + declaration into column clauses, so the AR-layer objects reach + ``ColumnDefinition.attributes`` directly and the dialect has to accept + them. Once that translation layer exists it belongs there, and this + method goes with it. Public because backend ``format_column_definition`` + overrides call it: a protocol member that outside callers need is + public even when its implementation is transitional. """ from ....base.ddl import ( + CharacterSetAttribute, CollationAttribute, IdentityAttribute, ) - from ...expression.statements.ddl_table import IdentityClause - from ..exceptions import UnsupportedFeatureError + from ...expression.statements.ddl_table import ( + CharacterSetClause, + CollationClause, + IdentityClause, + ) if isinstance(attr, IdentityAttribute): # The AR-layer IdentityAttribute keeps its tri-state until the AR # layer's exposure of the split is decided (out of scope for this # round); translate it to the expression layer's two parameters. - clause = IdentityClause( + return IdentityClause( self, attr.generation, start=attr.start, @@ -144,14 +220,11 @@ def format_column_attribute(self, attr: "ColumnAttribute") -> Tuple[str, Tuple]: cycle=attr.cycle is True, no_cycle=attr.cycle is False, ) - return self.format_identity_clause(clause) if isinstance(attr, CollationAttribute): - return f" COLLATE {attr.name}", () - raise UnsupportedFeatureError( - self.name, f"COLUMN ATTRIBUTE {type(attr).__name__}", - f"{self.name} cannot render the {type(attr).__name__} column " - f"attribute; declare it only on backends that support it.", - ) + return CollationClause(self, attr.name) + if isinstance(attr, CharacterSetAttribute): + return CharacterSetClause(self, attr.name) + return None def format_column(self, expr: "Column") -> Tuple[str, Tuple]: """Format a :class:`~...expression.core.Column`. @@ -211,21 +284,57 @@ def supports_drop_constraint_if_exists(self) -> bool: return False def format_column_attributes(self, col_def: "ColumnDefinition") -> Tuple[str, Tuple]: - """Render all of a column's attributes as one definition fragment. + """Render all of a column's clauses as one definition fragment. Shared by the generic formatter and by backend ``format_column_definition`` - overrides (which otherwise would not render the dialect-free - ``attributes`` channel). Returns the concatenated fragments with a - leading space each, and the accumulated parameters. + overrides (which otherwise would not render the ``attributes`` channel). + Returns the concatenated fragments with a leading space each, and the + accumulated parameters. + + Order is the order the clauses were built in; this method does not sort. """ sql = "" params: List[Any] = [] - for attr in getattr(col_def, "attributes", None) or (): + for attr in col_def.attributes or (): attr_sql, attr_params = self.format_column_attribute(attr) sql += attr_sql params.extend(attr_params) return sql, tuple(params) + def format_collation_clause(self, clause: "CollationClause") -> Tuple[str, Tuple]: + """Render a column's ``COLLATE `` clause. + + Generic reusable implementation: ``COLLATE `` with the name + spelled bare, which is what every backend emitted before the clause + became an expression node. Quoting it as an identifier would be the + more defensive choice, but it changes the SQL of seven backends and is + not this change's business. Backends that want a quoted specification + rather than a bare name (Snowflake, BigQuery) override this. + + Args: + clause: The clause carrying the collation name. + + Returns: + A ``(sql, params)`` tuple (params is always empty for DDL). + """ + return f" COLLATE {clause.name}", () + + def format_character_set_clause( + self, clause: "CharacterSetClause" + ) -> Tuple[str, Tuple]: + """Render a column's ``CHARACTER SET `` clause. + + Generic reusable implementation: ``CHARACTER SET `` with the name + spelled bare, for the same reason as :meth:`format_collation_clause`. + + Args: + clause: The clause carrying the character set name. + + Returns: + A ``(sql, params)`` tuple (params is always empty for DDL). + """ + return f" CHARACTER SET {clause.name}", () + def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, Tuple]: """Format a column definition clause (name, type, constraints, comment). @@ -259,8 +368,10 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, Tu all_params.extend(comment_params) return col_sql, tuple(all_params) - def format_generated_column_expression(self, expr: "GeneratedColumnExpression") -> Tuple[str, Tuple]: - """Format a :class:`~...statements.ddl_table.GeneratedColumnExpression`. + def format_column_generated_constraint( + self, constraint: "ColumnGeneratedConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``GENERATED ALWAYS AS`` branch. Renders ``GENERATED ALWAYS AS () STORED|VIRTUAL`` and delegates to :meth:`supports_generated_columns` / @@ -271,7 +382,8 @@ def format_generated_column_expression(self, expr: "GeneratedColumnExpression") should override this method. Args: - expr: The generated column expression to render. + constraint: The branch carrying the generating expression and the + storage type. Returns: A ``(sql, params)`` tuple with a leading space. @@ -290,9 +402,9 @@ def format_generated_column_expression(self, expr: "GeneratedColumnExpression") f"{self.name} does not support generated (computed) columns.", ) - inner_sql, inner_params = expr.expression.to_sql() + inner_sql, inner_params = constraint.expression.to_sql() - if expr.storage_type == GeneratedColumnType.STORED: + if constraint.storage_type == GeneratedColumnType.STORED: if not self.supports_stored_generated_columns(): raise UnsupportedFeatureError( self.name, @@ -309,242 +421,318 @@ def format_generated_column_expression(self, expr: "GeneratedColumnExpression") ) storage = " VIRTUAL" - return f" GENERATED ALWAYS AS ({inner_sql}){storage}", inner_params + return f" GENERATED ALWAYS AS ({inner_sql}){storage}", () - @staticmethod - def _call_constraint_capability(checker: Any, constraint_type: Any) -> bool: - import inspect - - parameters = inspect.signature(checker).parameters - if "constraint_type" in parameters or any( - parameter.kind == inspect.Parameter.VAR_POSITIONAL - for parameter in parameters.values() - ): - return bool(checker(constraint_type)) - return bool(checker()) - - def _format_constraint_enforcement(self, constraint: Any) -> str: - enforced = getattr(constraint, "enforced", False) - not_enforced = getattr(constraint, "not_enforced", False) + def format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + """Render the ``[NOT] ENFORCED`` trait a constraint asked for. + + The kinds that may carry the trait come from the standard-derived + whitelist (SQL:2016 fixes them to PRIMARY KEY, UNIQUE, FOREIGN KEY, + and CHECK); a dialect narrows that set inside its own constraint + formatter and may not widen it. + + Returns: + ``""`` when the constraint asked for neither spelling, else the + ``ENFORCED`` / ``NOT ENFORCED`` fragment. + + Raises: + TypeError: The flags are not bools. + ValueError: The constraint asked for both spellings at once. + UnsupportedFeatureError: The dialect does not support the trait. + """ + enforced = constraint.enforced + not_enforced = constraint.not_enforced if not enforced and not not_enforced: return "" if not isinstance(enforced, bool) or not isinstance(not_enforced, bool): raise TypeError("constraint enforced/not_enforced must be bools") - from ...expression.statements import TableConstraintType - - constraint_type = normalize_table_constraint_type( - getattr(constraint, "constraint_type", None) - ) - if constraint_type not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: - raise ValueError( - "ENFORCED/NOT ENFORCED is only valid for CHECK and FOREIGN KEY constraints" - ) - checker = getattr(self, "supports_constraint_enforced", None) - if checker is None or not self._call_constraint_capability(checker, constraint_type): + # The clause refuses both spellings at construction, but an attribute + # assigned afterwards bypasses __init__, so the pair is re-checked + # here as the last line of defence. + if enforced and not_enforced: + raise ValueError("enforced and not_enforced are mutually exclusive") + # Which kinds may carry the trait is settled by the node now: + # ``ColumnConstraint``/``TableConstraint`` refuse a branch that cannot, + # at construction. Nothing here has to guess which kind this is. + if not self.supports_constraint_enforced(): from ..exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - getattr(self, "name", type(self).__name__), - "ENFORCED/NOT ENFORCED constraint", - ) + raise UnsupportedFeatureError(self.name, "ENFORCED/NOT ENFORCED constraint") return "ENFORCED" if enforced else "NOT ENFORCED" - def _format_constraint_validation(self, constraint: Any) -> str: - from ...expression.statements import ConstraintValidation, TableConstraintType + def format_constraint_validation(self, constraint: "TableConstraint") -> str: + """Render the ``NOT VALID`` trait a table constraint asked for. - constraint_type = normalize_table_constraint_type( - getattr(constraint, "constraint_type", None) + Serves ``ALTER TABLE ADD CONSTRAINT``; PostgreSQL accepts the trait on + CHECK and FOREIGN KEY only, and that restriction lives here -- asked of + the branch, which is what names the kind. + + Returns: + ``""`` when the constraint asked for no validation state or the + default VALIDATE, else ``" NOT VALID"``. + + Raises: + TypeError: ``validation`` is not a ``ConstraintValidation``. + ValueError: The trait rides a branch that cannot carry it. + UnsupportedFeatureError: The dialect has no ``NOT VALID``. + """ + from ...expression.statements import ( + ConstraintValidation, + TableCheckConstraint, + TableForeignKeyConstraint, ) - validation = getattr(constraint, "validation", None) - value = getattr(validation, "value", validation) - if value is None: + from ..exceptions import UnsupportedFeatureError + + validation = constraint.validation + if validation is None: return "" - if not isinstance(value, str): - raise TypeError("constraint validation must be a ConstraintValidation or string") - normalized = "".join(value.strip().upper().split()) - if normalized == "".join(ConstraintValidation.VALIDATE.value.split()): + if not isinstance(validation, ConstraintValidation): + raise TypeError( + "constraint validation must be a ConstraintValidation, got " + f"{type(validation).__name__}" + ) + if validation is ConstraintValidation.VALIDATE: return "" - if normalized not in { - "".join(ConstraintValidation.NOVALIDATE.value.split()), - "".join(ConstraintValidation.NOVALIDATE.name.split()), - }: - raise ValueError("constraint validation must be VALIDATE or NOT VALID") - if constraint_type not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: + if not isinstance(constraint.branch, (TableCheckConstraint, TableForeignKeyConstraint)): raise ValueError("NOT VALID is only valid for CHECK and FOREIGN KEY constraints") - checker = getattr(self, "supports_constraint_novalidate", None) - if checker is None or not checker(): - from ..exceptions import UnsupportedFeatureError - - raise UnsupportedFeatureError( - getattr(self, "name", type(self).__name__), - "NOT VALID constraint", - ) + if not self.supports_constraint_novalidate(): + raise UnsupportedFeatureError(self.name, "NOT VALID constraint") return " NOT VALID" - def _prepare_ddl_check_predicate( - self, - value: Any, - memo: Optional[Dict[int, Any]] = None, - ) -> Any: - if memo is None: - memo = {} - if isinstance(value, BaseExpression): - identity = id(value) - if identity in memo: - return memo[identity] - bound = copy(value) - memo[identity] = bound - bound.dialect = self - for name, child in value.__dict__.items(): - if name == "_dialect": - continue - setattr(bound, name, self._prepare_ddl_check_predicate(child, memo)) - if isinstance(value, Literal): - bound.inline_literals = True - return bound - if isinstance(value, list): - return [self._prepare_ddl_check_predicate(child, memo) for child in value] - if isinstance(value, tuple): - return tuple(self._prepare_ddl_check_predicate(child, memo) for child in value) - if isinstance(value, set): - return {self._prepare_ddl_check_predicate(child, memo) for child in value} - if isinstance(value, dict): - return { - self._prepare_ddl_check_predicate(key, memo): self._prepare_ddl_check_predicate( - child, - memo, - ) - for key, child in value.items() - } - return value - - def _format_ddl_check_predicate(self, predicate: Any) -> str: - prepared = self._prepare_ddl_check_predicate(predicate) - check_sql, check_params = prepared.to_sql() - if check_params: - raise ValueError("DDL CHECK predicates must not contain bind parameters") - return check_sql - def format_column_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, Tuple]: - """Format a single column constraint clause. + """Format one column-level constraint clause. - Dispatches to the specialised formatter for the constraint type. - Simple constraints (PRIMARY KEY, NOT NULL, NULL, UNIQUE) are rendered - directly. + Renders what the grammar hangs on the **production** -- the + ``CONSTRAINT `` prefix, then the branch's own body, then the + trailing traits -- and dispatches to the branch's formatter for the + body. Which kind this is, is the branch's business, not this + method's: the fat node that used to switch on a ``constraint_type`` + field here is gone, and so is the silent acceptance of a trait the + kind cannot carry (the clause refuses it at construction). Args: - constraint: The column constraint expression to render. + constraint: The column constraint clause to render. Returns: A ``(sql, params)`` tuple. The ``sql`` value is prefixed with a - leading space so it can be appended directly to a column definition. + leading space so it can be appended directly to a column + definition. Raises: - TypeError: ``ColumnConstraint.foreign_key_reference`` is not a Table. - The referenced relation would be named by whichever object kind it - actually was, so the FK would point somewhere else. + UnsupportedFeatureError: The dialect refuses the named prefix, the + trait, or the branch's own construct. """ - reference = constraint.foreign_key_reference - if reference is not None and not isinstance(reference[0], Table): - raise TypeError( - f"ColumnConstraint.foreign_key_reference must pair a Table " - f"with its columns, got " - f"{type(reference[0]).__name__}" + prefix = "" + if constraint.name: + if not self.supports_constraint_naming(): + from ..exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + self.name, "CONSTRAINT name on a column constraint" + ) + prefix = f" CONSTRAINT {self.format_identifier(constraint.name)}" + branch_sql, branch_params = self.format_constraint_branch(constraint.branch) + return f"{prefix}{branch_sql}{self.format_constraint_traits(constraint)}", tuple( + branch_params + ) + + def format_constraint_branch(self, branch: "BaseExpression") -> Tuple[str, tuple]: + """Render a constraint branch through its own formatter. + + The branch declares which formatter spells it (``format_method``), + so a dialect that spells one kind differently overrides that one + formatter and nothing else. + + Args: + branch: The branch node to render. + + Returns: + A ``(sql, params)`` tuple, with a leading space for the + column-level branches. + """ + formatter = getattr(self, branch.format_method, None) + if formatter is None: + raise NotImplementedError( + f"{self.name} has no {branch.format_method}() for " + f"{type(branch).__name__}; declare the constraint on backends " + f"that support it." ) - from ...expression.statements import ColumnConstraintType - ctype = normalize_column_constraint_type(constraint.constraint_type) - simple_constraints = { - ColumnConstraintType.PRIMARY_KEY: " PRIMARY KEY", - ColumnConstraintType.NOT_NULL: " NOT NULL", - ColumnConstraintType.NULL: " NULL", - ColumnConstraintType.UNIQUE: " UNIQUE", - } - if ctype in simple_constraints: - if getattr(constraint, "enforced", False) or getattr( - constraint, "not_enforced", False - ): - self._format_constraint_enforcement(constraint) - return simple_constraints[ctype], () - if ctype == ColumnConstraintType.DEFAULT: - if getattr(constraint, "enforced", False) or getattr( - constraint, "not_enforced", False - ): - self._format_constraint_enforcement(constraint) - return self.format_default_constraint(constraint) - if ctype == ColumnConstraintType.CHECK: - return self.format_column_check_constraint(constraint) - if ctype == ColumnConstraintType.FOREIGN_KEY: - return self.format_column_fk_constraint(constraint) - raise ValueError(f"Unsupported column constraint type: {ctype.value}") - - def format_column_check_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, Tuple]: - """Format a column-level ``CHECK`` constraint. + return formatter(branch) + + def format_constraint_traits(self, constraint: "ColumnConstraint") -> Tuple[str, str]: + """Render the production-level traits of a column constraint. + + Both halves of one trait: the deferral half (``DEFERRABLE`` / + ``NOT DEFERRABLE``), which PostgreSQL's grammar puts on PRIMARY KEY, + UNIQUE and FOREIGN KEY, and the enforcement half (``ENFORCED`` / + ``NOT ENFORCED``). Only the foreign-key branch used to read them, so + a request on a PRIMARY KEY was dropped without a word; rendering them + here is what the production level is for. Args: - constraint: The constraint whose ``check_condition`` is rendered. + constraint: The clause whose traits are rendered. + + Returns: + The fragment to append, including its leading space, or ``""``. + + Raises: + UnsupportedFeatureError: The dialect declares neither the deferral + trait nor the enforcement trait. + """ + parts = [] + wants_deferral = ( + constraint.deferrable + or constraint.not_deferrable + or constraint.initially_deferred + or constraint.initially_immediate + ) + if wants_deferral: + from ..exceptions import UnsupportedFeatureError + + if not self.supports_deferrable_constraint(): + raise UnsupportedFeatureError( + self.name, "DEFERRABLE / INITIALLY constraint trait" + ) + if constraint.deferrable or constraint.not_deferrable: + parts.append("DEFERRABLE" if constraint.deferrable else "NOT DEFERRABLE") + if constraint.initially_deferred or constraint.initially_immediate: + parts.append( + "INITIALLY DEFERRED" + if constraint.initially_deferred + else "INITIALLY IMMEDIATE" + ) + enforcement = self.format_constraint_enforcement(constraint) + if enforcement: + parts.append(enforcement) + return f" {' '.join(parts)}" if parts else "" + + def format_column_primary_key_constraint( + self, constraint: "ColumnPrimaryKeyConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``PRIMARY KEY`` branch. + + Split out because a dialect may add to the clause itself: SQLite + spells ``AUTOINCREMENT`` as part of this grammar and nowhere else. + + Args: + constraint: The branch to render. Returns: A ``(sql, params)`` tuple with a leading space. + + Raises: + UnsupportedFeatureError: The dialect has no primary keys. + """ + from ..exceptions import UnsupportedFeatureError + + if not self.supports_primary_key_constraint(): + raise UnsupportedFeatureError(self.name, "PRIMARY KEY column constraint") + # The flag is not rendered here: AUTOINCREMENT is part of SQLite's + # grammar, so the dialect that spells it says so in an override of + # this formatter. Core renders the constraint the standard defines. + return " PRIMARY KEY", () + + def format_column_unique_constraint( + self, constraint: "ColumnUniqueConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``UNIQUE`` branch.""" + from ..exceptions import UnsupportedFeatureError + + if not self.supports_unique_constraint(): + raise UnsupportedFeatureError(self.name, "UNIQUE column constraint") + return " UNIQUE", () + + def format_column_not_null_constraint( + self, constraint: "ColumnNotNullConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``NOT NULL`` branch.""" + from ..exceptions import UnsupportedFeatureError + + if not self.supports_not_null_constraint(): + raise UnsupportedFeatureError(self.name, "NOT NULL column constraint") + return " NOT NULL", () + + def format_column_nullable_constraint( + self, constraint: "ColumnNullableConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``NULL`` branch. + + An explicitly nullable column is part of every SQL grammar rather + than an optional construct, so it consults no probe. + """ + return " NULL", () + + def format_column_check_constraint( + self, constraint: "ColumnCheckConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``CHECK`` branch. + + Args: + constraint: The branch whose ``condition`` is rendered. + + Returns: + A ``(sql, params)`` tuple with a leading space. + + Raises: + UnsupportedFeatureError: The dialect has no CHECK constraints. """ - if constraint.check_condition is None: - return "", () - check_sql = self._format_ddl_check_predicate(constraint.check_condition) - enforcement = self._format_constraint_enforcement(constraint) - suffix = f" {enforcement}" if enforcement else "" - return f" CHECK ({check_sql}){suffix}", () + from ..exceptions import UnsupportedFeatureError + + if not self.supports_check_constraint(): + raise UnsupportedFeatureError(self.name, "CHECK column constraint") + check_sql, check_params = constraint.condition.to_sql() + return f" CHECK ({check_sql})", tuple(check_params) def format_default_value_clause(self, expr: "DefaultValueClause") -> Tuple[str, Tuple]: """Format the value clause of a ``DEFAULT`` constraint. - Scalars are inlined with dialect-controlled escaping; a - ``BaseExpression`` value renders through its own ``to_sql`` (a - parameterised ``Literal`` is inlined so DDL carries no bind params). + The clause normalised its value at construction: a ``DEFAULT`` position + carries no bind parameters, so whatever arrives is a ``Literal`` with + ``inline`` set. Rendering it is therefore the literal's own rendering, + and anything that still produces a bind value is a value the caller + marked as binding in a position that cannot bind -- a refusal, not a + fallback. Args: expr: The ``DefaultValueClause`` carrying the value. Returns: - A ``(sql, params)`` tuple (params is always empty for DDL). + A ``(sql, params)`` tuple; params is always empty for DDL. + + Raises: + ValueError: The expression value still carries bind parameters + (e.g. a ``RawSQL`` with bound values), which a DEFAULT + position cannot accept. """ - from ...dialect.base import SQLDialectBase - value = expr.value - if isinstance(value, BaseExpression): - value_sql, value_params = value.to_sql() - if value_params and isinstance(value, Literal): - # A parameterized literal inside DDL: re-render inline. - value_sql = self.inline_sql_literal(value.value) - value_params = () - return value_sql, tuple(value_params) - if isinstance(value, str): - escaped = SQLDialectBase._escape_sql_string(value) - return f"'{escaped}'", () - return self.inline_sql_literal(value), () - - def format_default_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, Tuple]: - """Format a ``DEFAULT`` constraint. - - The default value is always rendered inline (DDL carries no bind - parameters); value rendering is delegated to - :meth:`format_default_value_clause`. + value_sql, value_params = expr.value.to_sql() + if value_params: + raise ValueError("DEFAULT value must render without bind parameters") + return value_sql, () + + def format_column_default_constraint( + self, constraint: "ColumnDefaultConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``DEFAULT`` branch. + + The value is always rendered inline (DDL carries no bind parameters; + ``ColumnDefaultConstraint`` declares ``inline_binding = True``); + value rendering is delegated to :meth:`format_default_value_clause`. Args: - constraint: The constraint whose ``default_value`` is rendered. + constraint: The branch whose ``value`` is rendered. Returns: A ``(sql, params)`` tuple with a leading space. - - Raises: - ValueError: If ``default_value`` is ``None``. """ from ...expression.statements.ddl_table import DefaultValueClause - if constraint.default_value is None: - raise ValueError("DEFAULT constraint must have a default value specified.") - if isinstance(constraint.default_value, DefaultValueClause): - value_clause = constraint.default_value + + value = constraint.value + if isinstance(value, DefaultValueClause): + value_clause = value else: - value_clause = DefaultValueClause(self, constraint.default_value) - value_sql, value_params = self.format_default_value_clause(value_clause) - return f" DEFAULT {value_sql}", tuple(value_params) + value_clause = DefaultValueClause(self, value) + value_sql, _ = self.format_default_value_clause(value_clause) + return f" DEFAULT {value_sql}", () def format_references_clause(self, expr: "ReferencesClause") -> Tuple[str, Tuple]: """Format a ``REFERENCES`` clause (shared by column/table foreign keys). @@ -565,32 +753,26 @@ def format_references_clause(self, expr: "ReferencesClause") -> Tuple[str, Tuple relation. ValueError: If the referenced table has no columns. """ + from ..exceptions import UnsupportedFeatureError + from ...expression.statements import ReferentialAction + if not isinstance(expr.referenced_table, Table): raise TypeError( f"ReferencesClause.referenced_table must be a Table, " f"got {type(expr.referenced_table).__name__}" ) - from ...expression.statements import ReferentialAction if not expr.referenced_columns: raise ValueError("REFERENCES clause requires at least one referenced column.") ref_cols_str = ", ".join(self.format_identifier(col) for col in expr.referenced_columns) result = f"REFERENCES {expr.referenced_table.to_sql()[0]}({ref_cols_str})" if expr.match_type is not None: - if not isinstance(expr.match_type, str): - raise ValueError("FOREIGN KEY MATCH type must be a string") - match_type = " ".join(expr.match_type.strip().upper().split()) - if match_type not in {"SIMPLE", "PARTIAL", "FULL"}: - raise ValueError( - f"Invalid MATCH type '{expr.match_type}'. " - "Must be one of: FULL, PARTIAL, SIMPLE" - ) + match_type = normalize_foreign_key_match_type(expr.match_type) if not self.supports_fk_match(): - from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( self.name, "FOREIGN KEY MATCH", f"{self.name} does not support MATCH for foreign keys." ) - result += f" MATCH {match_type}" + result += f" MATCH {match_type.value}" on_delete = ( self._referential_action_value(expr.on_delete) if expr.on_delete is not None @@ -598,7 +780,6 @@ def format_references_clause(self, expr: "ReferencesClause") -> Tuple[str, Tuple ) if on_delete is not None and on_delete != ReferentialAction.NO_ACTION.value: if not self.supports_foreign_key_on_delete(): - from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( self.name, "FOREIGN KEY ON DELETE", f"{self.name} does not support ON DELETE for foreign keys." @@ -611,17 +792,28 @@ def format_references_clause(self, expr: "ReferencesClause") -> Tuple[str, Tuple ) if on_update is not None and on_update != ReferentialAction.NO_ACTION.value: if not self.supports_foreign_key_on_update(): - from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( self.name, "FOREIGN KEY ON UPDATE", f"{self.name} does not support ON UPDATE for foreign keys." ) result += f" ON UPDATE {on_update}" - if expr.deferrable or expr.not_deferrable: - result += " DEFERRABLE" if expr.deferrable else " NOT DEFERRABLE" # INITIALLY ... is an independent constraint attribute in the SQL # grammar (PostgreSQL accepts it alone), so it is rendered whenever it - # was requested rather than dropped when DEFERRABLE is absent. + # was requested rather than dropped when DEFERRABLE is absent. Both + # spellings are authorised by the same capability. + has_deferral = ( + expr.deferrable + or expr.not_deferrable + or expr.initially_deferred + or expr.initially_immediate + ) + if has_deferral and not self.supports_deferrable_constraint(): + + raise UnsupportedFeatureError( + self.name, "DEFERRABLE / INITIALLY clause on a foreign key" + ) + if expr.deferrable or expr.not_deferrable: + result += " DEFERRABLE" if expr.deferrable else " NOT DEFERRABLE" if expr.initially_deferred or expr.initially_immediate: result += ( " INITIALLY DEFERRED" @@ -650,42 +842,50 @@ def _referential_action_value(action: Any) -> str: f"{', '.join(member.value for member in ReferentialAction)}" ) - def format_column_fk_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, Tuple]: - """Format a column-level ``REFERENCES`` (foreign key) constraint. + def format_column_fk_constraint( + self, constraint: "ColumnForeignKeyConstraint" + ) -> Tuple[str, Tuple]: + """Format a column-level ``REFERENCES`` branch. - Delegates the reference body to :meth:`format_references_clause`. + Delegates the reference body to :meth:`format_references_clause`. The + deferral traits are the production's to render (see + :meth:`format_constraint_traits`), so they are not passed down here. Args: - constraint: The constraint whose ``foreign_key_reference`` is - rendered. + constraint: The branch whose ``references`` are rendered. Returns: A ``(sql, params)`` tuple with a leading space. Raises: - ValueError: If ``foreign_key_reference`` is ``None``. + UnsupportedFeatureError: The dialect has no foreign keys. """ - if constraint.foreign_key_reference is None: - raise ValueError("Foreign key constraint must have a foreign_key_reference specified.") - referenced_table, referenced_columns = constraint.foreign_key_reference + from ..exceptions import UnsupportedFeatureError from ...expression.statements.ddl_table import ReferencesClause + + # Known coupling: this is the table-level probe reused for the + # column-level grammar. A dialect whose *column* REFERENCES clause is + # unusable while its table-level FOREIGN KEY works (SQL Server) cannot + # be expressed until the two levels get separate probes. + if not self.supports_foreign_key_constraint(): + raise UnsupportedFeatureError(self.name, "REFERENCES column constraint") + + referenced_table, referenced_columns = constraint.references + references = ReferencesClause( self, referenced_table, referenced_columns, on_delete=constraint.on_delete, on_update=constraint.on_update, - deferrable=constraint.deferrable, - not_deferrable=constraint.not_deferrable, - initially_deferred=constraint.initially_deferred, - initially_immediate=constraint.initially_immediate, + match_type=constraint.match_type, ) ref_sql, ref_params = self.format_references_clause(references) - enforcement = self._format_constraint_enforcement(constraint) - suffix = f" {enforcement}" if enforcement else "" - return f" {ref_sql}{suffix}", tuple(ref_params) + return f" {ref_sql}", tuple(ref_params) - def format_pk_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: + def format_table_primary_key_constraint( + self, constraint: "TablePrimaryKeyConstraint" + ) -> Tuple[str, tuple]: """Format the body of a ``PRIMARY KEY`` table constraint. Args: @@ -697,12 +897,14 @@ def format_pk_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: Raises: ValueError: If no columns are specified. """ - if not t_const.columns: + if not constraint.columns: raise ValueError("PRIMARY KEY constraint must have at least one column specified.") - cols_str = ", ".join(self.format_identifier(col) for col in t_const.columns) + cols_str = ", ".join(self.format_identifier(col) for col in constraint.columns) return f"PRIMARY KEY ({cols_str})", () - def format_unique_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: + def format_table_unique_constraint( + self, constraint: "TableUniqueConstraint" + ) -> Tuple[str, tuple]: """Format the body of a ``UNIQUE`` table constraint. Args: @@ -714,12 +916,14 @@ def format_unique_constraint(self, t_const: "TableConstraint") -> Tuple[str, tup Raises: ValueError: If no columns are specified. """ - if not t_const.columns: + if not constraint.columns: raise ValueError("UNIQUE constraint must have at least one column specified.") - cols_str = ", ".join(self.format_identifier(col) for col in t_const.columns) + cols_str = ", ".join(self.format_identifier(col) for col in constraint.columns) return f"UNIQUE ({cols_str})", () - def format_table_check_constraint(self, t_const: "TableConstraint") -> Tuple[str, Tuple]: + def format_table_check_constraint( + self, constraint: "TableCheckConstraint" + ) -> Tuple[str, Tuple]: """Format a table-level ``CHECK`` constraint body. Args: @@ -731,119 +935,133 @@ def format_table_check_constraint(self, t_const: "TableConstraint") -> Tuple[str Raises: ValueError: If ``check_condition`` is ``None``. """ - if t_const.check_condition is None: + if constraint.condition is None: raise ValueError("CHECK constraint must have a check condition specified.") - check_sql = self._format_ddl_check_predicate(t_const.check_condition) - return f"CHECK ({check_sql})", () + check_sql, check_params = constraint.condition.to_sql() + return f"CHECK ({check_sql})", tuple(check_params) - def format_foreign_key_constraint(self, t_const: "TableConstraint") -> Tuple[str, Tuple]: - """Format a table-level ``FOREIGN KEY`` constraint body. + def format_table_foreign_key_constraint( + self, constraint: "TableForeignKeyConstraint" + ) -> Tuple[str, Tuple]: + """Format a table-level ``FOREIGN KEY`` branch. Args: - t_const: The constraint carrying the local columns, referenced - table, and referenced columns. + constraint: The branch carrying the local columns, the referenced + table and columns, and the referential actions. Returns: - A ``(sql, params)`` tuple. + A ``(sql, params)`` tuple; ``sql`` has no leading space. Raises: - ValueError: If local columns, foreign key columns, or the + ValueError: The local columns, the referenced columns or the referenced table are missing. """ - if not t_const.columns: + if not constraint.columns: raise ValueError("FOREIGN KEY constraint must have at least one local column specified.") - if not t_const.foreign_key_columns: + 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 t_const.foreign_key_table: + if not referenced_table: raise ValueError("FOREIGN KEY constraint must have a foreign key table specified.") - cols_str = ", ".join(self.format_identifier(col) for col in t_const.columns) - from ...expression.statements import ForeignKeyConstraint from ...expression.statements.ddl_table import ReferencesClause - if isinstance(t_const, ForeignKeyConstraint): - on_delete = t_const.on_delete - on_update = t_const.on_update - match_type = t_const.match_type - else: - on_delete = on_update = match_type = None + + cols_str = ", ".join(self.format_identifier(col) for col in constraint.columns) + references = ReferencesClause( self, - t_const.foreign_key_table, - list(t_const.foreign_key_columns), - on_delete=on_delete, - on_update=on_update, - match_type=match_type, - deferrable=t_const.deferrable, - not_deferrable=t_const.not_deferrable, - initially_deferred=t_const.initially_deferred, - initially_immediate=t_const.initially_immediate, + 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) return f"FOREIGN KEY ({cols_str}) {ref_sql}", tuple(ref_params) + def format_table_exclude_constraint( + self, constraint: "TableExcludeConstraint" + ) -> Tuple[str, Tuple]: + """Format a table-level ``EXCLUDE`` branch. + + Core has no EXCLUDE grammar to fall back on, so the default refuses + by name rather than rendering SQL a server would reject. The branch + exists because the constraint is enumerable (规范 4.9's table + constraint kinds), and a dialect whose server can spell it overrides + this formatter. + + Args: + constraint: The branch carrying the columns, the operator and the + optional index method. + + Returns: + A ``(sql, params)`` tuple. + + Raises: + UnsupportedFeatureError: Always, unless a dialect overrides this. + """ + from ..exceptions import UnsupportedFeatureError + + if not self.supports_exclude_constraint(): + raise UnsupportedFeatureError(self.name, "EXCLUDE constraint") + raise UnsupportedFeatureError(self.name, "EXCLUDE constraint formatter") + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, Tuple]: - """Format a :class:`~...expression.statements.TableConstraint` clause. + """Format a table-level constraint clause. - Renders the optional ``CONSTRAINT `` prefix followed by the - type-specific body (PRIMARY KEY, UNIQUE, CHECK, or FOREIGN KEY). + The table counterpart of :meth:`format_column_constraint`: the + ``CONSTRAINT `` prefix, then the branch's body, then the + enforcement trait. The branch says which kind this is, so this method + no longer switches on a tag -- and ``EXCLUDE`` is a branch like the + rest rather than an enum member nothing could render. Args: - expr: The table constraint expression to render. + expr: The table constraint clause to render. Returns: A ``(sql, params)`` tuple; ``sql`` is empty when no clause parts are produced. Raises: - TypeError: ``TableConstraint.foreign_key_table`` is not a Table. Another - object kind would have had its own name rendered as the referenced - relation. + UnsupportedFeatureError: The dialect refuses the named prefix, the + trait, or the branch's construct. """ - if expr.foreign_key_table is not None and not isinstance(expr.foreign_key_table, Table): - raise TypeError( - f"TableConstraint.foreign_key_table must be a Table, " - f"got {type(expr.foreign_key_table).__name__}" - ) - from ...expression.statements import TableConstraintType - body_parts = [] - params: Tuple = () - ctype = normalize_table_constraint_type(expr.constraint_type) - if ctype == TableConstraintType.PRIMARY_KEY: - pk_sql, params = self.format_pk_constraint(expr) - body_parts.append(pk_sql) - elif ctype == TableConstraintType.UNIQUE: - unique_sql, params = self.format_unique_constraint(expr) - body_parts.append(unique_sql) - elif ctype == TableConstraintType.CHECK: - check_sql, params = self.format_table_check_constraint(expr) - body_parts.append(check_sql) - elif ctype == TableConstraintType.FOREIGN_KEY: - fk_sql, params = self.format_foreign_key_constraint(expr) - body_parts.append(fk_sql) - elif ctype == TableConstraintType.EXCLUDE: - checker = getattr(self, "supports_exclude_constraint", None) - from ..exceptions import UnsupportedFeatureError + from ..exceptions import UnsupportedFeatureError - if checker is None or not checker(): + parts = [] + if expr.name: + if not self.supports_constraint_naming(): raise UnsupportedFeatureError( - getattr(self, "name", type(self).__name__), - "EXCLUDE constraint", + self.name, "CONSTRAINT name on a table constraint" ) - raise UnsupportedFeatureError( - getattr(self, "name", type(self).__name__), - "EXCLUDE constraint formatter", - ) - if not body_parts: - raise ValueError(f"Unsupported table constraint type: {ctype.value}") - const_parts = [] - if expr.name: - const_parts.append(f"CONSTRAINT {self.format_identifier(expr.name)}") - const_parts.extend(body_parts) - enforcement = self._format_constraint_enforcement(expr) + parts.append(f"CONSTRAINT {self.format_identifier(expr.name)}") + branch_sql, branch_params = self.format_constraint_branch(expr.branch) + if branch_sql: + parts.append(branch_sql) + wants_deferral = ( + expr.deferrable + or expr.not_deferrable + or expr.initially_deferred + or expr.initially_immediate + ) + if wants_deferral: + if not self.supports_deferrable_constraint(): + raise UnsupportedFeatureError( + self.name, "DEFERRABLE / INITIALLY constraint trait" + ) + if expr.deferrable or expr.not_deferrable: + parts.append("DEFERRABLE" if expr.deferrable else "NOT DEFERRABLE") + if expr.initially_deferred or expr.initially_immediate: + parts.append( + "INITIALLY DEFERRED" + if expr.initially_deferred + else "INITIALLY IMMEDIATE" + ) + enforcement = self.format_constraint_enforcement(expr) if enforcement: - const_parts.append(enforcement) - return " ".join(const_parts), tuple(params) + parts.append(enforcement) + return " ".join(parts), tuple(branch_params) - def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: + def format_storage_options_expression(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: """Format a ``WITH (...)`` storage-options clause. Each option value is rendered as an inline SQL literal via @@ -865,7 +1083,7 @@ def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, return " WITH (" + ", ".join(storage_parts) + ")", () return "", () - def format_add_column_action(self, action: "AddColumn") -> Tuple[str, Tuple]: + def format_add_column_action(self, action: "AddColumnAction") -> Tuple[str, Tuple]: """Format an ``ADD COLUMN`` ALTER TABLE action. Args: @@ -880,7 +1098,7 @@ def format_add_column_action(self, action: "AddColumn") -> Tuple[str, Tuple]: """ from ..exceptions import UnsupportedFeatureError column_sql, column_params = self.format_column_definition(action.column) - if getattr(action, "if_not_exists", None): + if action.if_not_exists: if not self.supports_add_column_if_not_exists(): raise UnsupportedFeatureError( self.name, "ADD COLUMN IF NOT EXISTS", @@ -889,7 +1107,7 @@ def format_add_column_action(self, action: "AddColumn") -> Tuple[str, Tuple]: return f"ADD COLUMN IF NOT EXISTS {column_sql}", column_params return f"ADD COLUMN {column_sql}", column_params - def format_drop_column_action(self, action: "DropColumn") -> Tuple[str, Tuple]: + def format_drop_column_action(self, action: "DropColumnAction") -> Tuple[str, Tuple]: """Format a ``DROP COLUMN`` ALTER TABLE action. Emits ``DROP COLUMN IF EXISTS`` when the action requests it. @@ -911,7 +1129,7 @@ def format_drop_column_action(self, action: "DropColumn") -> Tuple[str, Tuple]: self.name, "ALTER TABLE DROP COLUMN", f"{self.name} does not support DROP COLUMN.", ) - if hasattr(action, "if_exists") and action.if_exists: + if action.if_exists: if not self.supports_drop_column_if_exists(): raise UnsupportedFeatureError( self.name, "DROP COLUMN IF EXISTS", @@ -920,12 +1138,13 @@ def format_drop_column_action(self, action: "DropColumn") -> Tuple[str, Tuple]: return f"DROP COLUMN IF EXISTS {self.format_identifier(action.column_name)}", () return f"DROP COLUMN {self.format_identifier(action.column_name)}", () - def format_alter_column_action(self, action: "AlterColumn") -> Tuple[str, Tuple]: + def format_alter_column_action(self, action: "AlterColumnAction") -> Tuple[str, Tuple]: """Format an ``ALTER COLUMN`` ALTER TABLE action. - Handles ``SET DATA TYPE`` (validated type text), string values - (escaped and inlined), expression values rendered through - ``to_sql``, and plain values inlined via ``inline_sql_literal``. + Handles ``SET DATA TYPE`` (a type, rendered through the dialect), + string values (escaped and inlined), expression values rendered + through ``to_sql``, and plain values inlined via + ``inline_sql_literal``. Args: action: The action carrying the column name, operation, and @@ -938,12 +1157,13 @@ def format_alter_column_action(self, action: "AlterColumn") -> Tuple[str, Tuple] UnsupportedFeatureError: If the dialect does not support the requested alteration (data-type changes and column-property changes are gated by separate capability switches). - ValueError: If a ``SET DATA TYPE`` value is not a valid data type - specification. """ from ..exceptions import UnsupportedFeatureError all_params: List[Any] = [] - operation_str = action.operation.value if hasattr(action.operation, "value") else str(action.operation) + + from ...expression.statements.ddl_alter import operation_spelling + + operation_str = operation_spelling(action.operation) if operation_str == "SET DATA TYPE": if not self.supports_alter_column_type(): raise UnsupportedFeatureError( @@ -957,32 +1177,39 @@ def format_alter_column_action(self, action: "AlterColumn") -> Tuple[str, Tuple] "(SET/DROP DEFAULT, SET/DROP NOT NULL).", ) column_part = f"ALTER COLUMN {self.format_identifier(action.column_name)} {operation_str}" - if hasattr(action, "new_value") and action.new_value is not None: + if action.new_value is not None: if operation_str == "SET DATA TYPE": - from ...dialect.base import SQLDialectBase - if not SQLDialectBase._validate_data_type(str(action.new_value)): - raise ValueError(f"Invalid data type specification: '{action.new_value}'") - column_part += f" {action.new_value}" + type_sql, _ = action.new_value.to_sql() + column_part += f" {type_sql}" elif isinstance(action.new_value, str): - # DDL accepts no bind parameters: inline with escaping. - from ...dialect.base import SQLDialectBase - column_part += f" '{SQLDialectBase._escape_sql_string(action.new_value)}'" + # DDL accepts no bind parameters, so the value is written into + # the SQL text. The literal is the controlled path: it escapes + # and quotes exactly as DEFAULT does. + value_sql, value_params = Literal( + self, action.new_value, inline=True + ).to_sql() + column_part += f" {value_sql}" elif isinstance(action.new_value, ToSQLProtocol): - value_sql, value_params = action.new_value.to_sql() - if value_params: - # Parameterized literal inside DDL → render inline. - if isinstance(action.new_value, Literal): - value_sql = self.inline_sql_literal(action.new_value.value) - value_params = () + if isinstance(action.new_value, Literal): + # The value carries its own DDL decision; the controlled + # path escapes and quotes it exactly as DEFAULT does. + value_sql, value_params = Literal( + self, action.new_value.value, inline=True + ).to_sql() + else: + value_sql, value_params = action.new_value.to_sql() + if value_params: + raise ValueError( + "ALTER COLUMN value must render without bind parameters" + ) column_part += f" {value_sql}" - all_params.extend(value_params) else: column_part += f" {self.inline_sql_literal(action.new_value)}" - if hasattr(action, "cascade") and action.cascade: + if action.cascade: column_part += " CASCADE" return column_part, tuple(all_params) - def format_add_table_constraint_action(self, action: "AddTableConstraint") -> Tuple[str, Tuple]: + def format_add_table_constraint_action(self, action: "AddTableConstraintAction") -> Tuple[str, Tuple]: """Format an ``ADD CONSTRAINT`` ALTER TABLE action. Args: @@ -1001,19 +1228,34 @@ def format_add_table_constraint_action(self, action: "AddTableConstraint") -> Tu constraint_sql, params = self.format_table_constraint(action.constraint) if not constraint_sql: return "ADD UNKNOWN CONSTRAINT", () - validation_sql = self._format_constraint_validation(action.constraint) + validation_sql = self.format_constraint_validation(action.constraint) return f"ADD {constraint_sql}{validation_sql}", tuple(params) - def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, Tuple]: - from ...expression.statements import TableConstraintType + def format_alter_constraint_action(self, action: "AlterConstraintAction") -> Tuple[str, Tuple]: + """Format an ``ALTER CONSTRAINT`` enforcement action. + + The kind restriction — CHECK and FOREIGN KEY only — is this + operation's own narrowing of the ENFORCABLE kinds and lives in the + formatter, not in the capability probe. + + Raises: + TypeError: ``constraint_branch`` is not a CHECK or FOREIGN KEY branch. + ValueError: The kind cannot carry the ALTER CONSTRAINT trait. + UnsupportedFeatureError: The dialect has no ALTER CONSTRAINT + enforcement control. + """ - constraint_type = normalize_table_constraint_type(action.constraint_type) - if constraint_type not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: + branch = action.constraint_branch + if branch not in (TableCheckConstraint, TableForeignKeyConstraint): + raise TypeError( + "constraint_branch must be TableCheckConstraint or " + f"TableForeignKeyConstraint, got {type(branch).__name__}" + ) + if branch not in (TableCheckConstraint, TableForeignKeyConstraint): raise ValueError( "ALTER CONSTRAINT enforcement is only valid for CHECK and FOREIGN KEY constraints" ) - checker = getattr(self, "supports_alter_constraint_enforced", None) - if checker is None or not self._call_constraint_capability(checker, constraint_type): + if not self.supports_alter_constraint_enforced(): from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( @@ -1028,15 +1270,14 @@ def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str (), ) - def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, Tuple]: - checker = getattr(self, "supports_validate_constraint", None) - if checker is None or not checker(): + def format_validate_constraint_action(self, action: "ValidateConstraintAction") -> Tuple[str, Tuple]: + if not self.supports_validate_constraint(): from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError(self.name, "VALIDATE CONSTRAINT") return f"VALIDATE CONSTRAINT {self.format_identifier(action.constraint_name)}", () - def format_drop_table_constraint_action(self, action: "DropTableConstraint") -> Tuple[str, Tuple]: + def format_drop_table_constraint_action(self, action: "DropTableConstraintAction") -> Tuple[str, Tuple]: """Format a ``DROP CONSTRAINT`` ALTER TABLE action. Args: @@ -1053,8 +1294,14 @@ def format_drop_table_constraint_action(self, action: "DropTableConstraint") -> from ..exceptions import UnsupportedFeatureError if not self.supports_drop_constraint(): raise UnsupportedFeatureError(self.name, "ALTER TABLE DROP CONSTRAINT") - result = f"DROP CONSTRAINT {self.format_identifier(action.constraint_name)}" - if hasattr(action, "cascade") and action.cascade: + if action.if_exists and not self.supports_drop_constraint_if_exists(): + raise UnsupportedFeatureError( + self.name, "DROP CONSTRAINT IF EXISTS", + f"{self.name} does not support DROP CONSTRAINT IF EXISTS." + ) + if_exists = "IF EXISTS " if action.if_exists else "" + result = f"DROP CONSTRAINT {if_exists}{self.format_identifier(action.constraint_name)}" + if action.cascade: result += " CASCADE" return result, () @@ -1091,7 +1338,7 @@ def format_index_definition(self, expr: "IndexDefinition") -> Tuple[str, Tuple]: tuple(all_params), ) - def format_add_index_action(self, action: "AddIndex") -> Tuple[str, Tuple]: + def format_add_index_action(self, action: "AddIndexAction") -> Tuple[str, Tuple]: """Format an ``ADD INDEX`` ALTER TABLE action. Args: @@ -1118,7 +1365,7 @@ def format_add_index_action(self, action: "AddIndex") -> Tuple[str, Tuple]: (), ) - def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, Tuple]: + def format_drop_index_action(self, action: "DropIndexAction") -> Tuple[str, Tuple]: """Format a ``DROP INDEX`` ALTER TABLE action. Emits ``DROP INDEX IF EXISTS`` when the action requests it. @@ -1130,14 +1377,14 @@ def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, Tuple]: A ``(sql, params)`` tuple with empty parameters. Raises: - TypeError: ``DropIndex.index`` is not an Index. Another object kind + TypeError: ``DropIndexAction.index`` is not an Index. Another object kind would have had its own name rendered as the index's. UnsupportedFeatureError: If the dialect does not support ``ALTER TABLE DROP INDEX``. """ if not isinstance(action.index, Index): raise TypeError( - f"DropIndex.index must be an Index, " + f"DropIndexAction.index must be an Index, " f"got {type(action.index).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -1150,7 +1397,7 @@ def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, Tuple]: head = "DROP INDEX IF EXISTS" if action.if_exists else "DROP INDEX" return f"{head} {index_sql}", index_params - def format_rename_column_action(self, action: "RenameObject") -> Tuple[str, Tuple]: + def format_rename_column_action(self, action: "RenameColumnAction") -> Tuple[str, Tuple]: """Format a ``RENAME COLUMN`` ALTER TABLE action. Args: @@ -1174,7 +1421,7 @@ def format_rename_column_action(self, action: "RenameObject") -> Tuple[str, Tupl (), ) - def format_rename_table_action(self, action: "RenameTable") -> Tuple[str, Tuple]: + def format_rename_table_action(self, action: "RenameTableAction") -> Tuple[str, Tuple]: """Format a ``RENAME TO`` ALTER TABLE action. Args: @@ -1195,7 +1442,7 @@ def format_rename_table_action(self, action: "RenameTable") -> Tuple[str, Tuple] ) return f"RENAME TO {self.format_identifier(action.new_name)}", () - def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, Tuple]: + def format_modify_column_action(self, action: "ModifyColumnAction") -> Tuple[str, Tuple]: """Format a ``MODIFY COLUMN`` ALTER TABLE action. The core implementation always raises, since ``MODIFY COLUMN`` is @@ -1210,7 +1457,7 @@ def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, Tupl from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError(self.name, "MODIFY COLUMN") - def format_change_column_action(self, action: "ChangeColumn") -> Tuple[str, Tuple]: + def format_change_column_action(self, action: "ChangeColumnAction") -> Tuple[str, Tuple]: """Format a ``CHANGE COLUMN`` ALTER TABLE action. The core implementation always raises, since ``CHANGE COLUMN`` is diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_comment.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_comment.py index 921b945a0..304f1744c 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_comment.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_comment.py @@ -14,7 +14,7 @@ from ...expression.objects import SchemaObject if TYPE_CHECKING: # pragma: no cover - from ...expression.statements.ddl_comment import CommentOnExpression + from ...expression.statements.ddl_comment import CommentOnStatement class CommentOnMixin: @@ -28,8 +28,8 @@ def supports_comment_on(self) -> bool: """ return False - def format_comment_statement( - self, expr: "CommentOnExpression" + def format_comment_on_statement( + self, expr: "CommentOnStatement" ) -> Tuple[str, tuple]: """Format a standalone ``COMMENT ON IS ''`` statement. @@ -52,13 +52,13 @@ def format_comment_statement( Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CommentOnExpression.object`` is not a SchemaObject. Any + TypeError: ``CommentOnStatement.object`` is not a SchemaObject. Any other value would be named by its own protocol inside COMMENT ON. UnsupportedFeatureError: If :meth:`supports_comment_on` is False. """ if not isinstance(expr.object, SchemaObject): raise TypeError( - f"CommentOnExpression.object must be a SchemaObject, " + f"CommentOnStatement.object must be a SchemaObject, " f"got {type(expr.object).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -66,7 +66,12 @@ def format_comment_statement( if not self.supports_comment_on(): raise UnsupportedFeatureError(self.name, "COMMENT ON") - object_type = getattr(expr.object_type, "value", expr.object_type) + from enum import Enum + + # ``object_type`` accepts the enum or its raw spelling. + object_type = ( + expr.object_type.value if isinstance(expr.object_type, Enum) else expr.object_type + ) object_sql, object_params = expr.object.to_sql() if expr.column: object_sql = f"{object_sql}.{self.format_identifier(expr.column)}" @@ -74,7 +79,7 @@ def format_comment_statement( head = f"COMMENT ON {object_type} {object_sql} IS" if expr.comment is None: return f"{head} NULL", object_params - escaped = self._escape_sql_string(expr.comment) + escaped = self.escape_sql_string(expr.comment) return f"{head} '{escaped}'", object_params diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_database.py index beb03fd4d..b625c8e1d 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_database.py @@ -8,9 +8,9 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -90,12 +90,12 @@ def supports_database_or_replace(self) -> bool: return False def format_create_database_statement( - self, expr: "CreateDatabaseExpression" + self, expr: "CreateDatabaseStatement" ) -> Tuple[str, tuple]: """Format a CREATE DATABASE statement. Raises: - TypeError: ``CreateDatabaseExpression.database`` is not a Database. + TypeError: ``CreateDatabaseStatement.database`` is not a Database. Another object kind would have had its own name rendered as the database's. UnsupportedFeatureError: If the dialect does not support @@ -103,7 +103,7 @@ def format_create_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"CreateDatabaseExpression.database must be a Database, " + f"CreateDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) if not self.supports_create_database(): @@ -184,12 +184,12 @@ def format_create_database_statement( return " ".join(parts), () def format_drop_database_statement( - self, expr: "DropDatabaseExpression" + self, expr: "DropDatabaseStatement" ) -> Tuple[str, tuple]: """Format a DROP DATABASE statement. Raises: - TypeError: ``DropDatabaseExpression.database`` is not a Database. + TypeError: ``DropDatabaseStatement.database`` is not a Database. Another object kind would have had its own name rendered as the database's. UnsupportedFeatureError: If the dialect does not support @@ -197,7 +197,7 @@ def format_drop_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"DropDatabaseExpression.database must be a Database, " + f"DropDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) if not self.supports_drop_database(): @@ -226,19 +226,19 @@ def format_drop_database_statement( return " ".join(parts), () def format_alter_database_statement( - self, expr: "AlterDatabaseExpression" + self, expr: "AlterDatabaseStatement" ) -> Tuple[str, tuple]: """Format an ALTER DATABASE statement. Raises: - TypeError: ``AlterDatabaseExpression.database`` is not a Database. A + TypeError: ``AlterDatabaseStatement.database`` is not a Database. A table or a view would be named here and the statement would alter it. UnsupportedFeatureError: If the dialect does not support ALTER DATABASE. """ if not isinstance(expr.database, Database): raise TypeError( - f"AlterDatabaseExpression.database must be a Database, " + f"AlterDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) if not self.supports_alter_database(): diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_domain.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_domain.py index d7db4b195..895b01b52 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_domain.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_domain.py @@ -13,12 +13,12 @@ if TYPE_CHECKING: from ...expression.statements.ddl_domain import ( - AlterDomainExpression, - CreateDomainExpression, + AlterDomainStatement, + CreateDomainStatement, DomainAlterAction, DomainCheckConstraint, DomainValueExpression, - DropDomainExpression, + DropDomainStatement, ) @@ -105,27 +105,27 @@ def supports_unnamed_domain_check_drop(self) -> bool: def format_create_domain_statement( self, - expr: "CreateDomainExpression", + expr: "CreateDomainStatement", ) -> Tuple[str, tuple]: """Format a standard CREATE DOMAIN statement. Raises: - TypeError: ``CreateDomainExpression.domain`` is not a Domain. Another + TypeError: ``CreateDomainStatement.domain`` is not a Domain. Another object kind would have had its own name rendered as the domain's. - TypeError: An entry of ``CreateDomainExpression.checks`` is not a + TypeError: An entry of ``CreateDomainStatement.checks`` is not a DomainCheckConstraint. An entry of another kind has no check clause to render, so its CHECK would silently disappear. """ if not isinstance(expr.domain, Domain): raise TypeError( - f"CreateDomainExpression.domain must be a Domain, " + f"CreateDomainStatement.domain must be a Domain, " f"got {type(expr.domain).__name__}" ) for position, entry in enumerate(expr.checks): if not isinstance(entry, DomainCheckConstraint): raise TypeError( - f"CreateDomainExpression.checks must hold DomainCheckConstraint instances, " + f"CreateDomainStatement.checks must hold DomainCheckConstraint instances, " f"got {type(entry).__name__} at position {position}" ) if not self.supports_domains() or not self.supports_create_domain(): @@ -181,20 +181,20 @@ def format_create_domain_statement( def format_alter_domain_statement( self, - expr: "AlterDomainExpression", + expr: "AlterDomainStatement", ) -> Tuple[str, tuple]: """Format a standard ALTER DOMAIN statement. Raises: - TypeError: ``AlterDomainExpression.domain`` is not a Domain. Another + TypeError: ``AlterDomainStatement.domain`` is not a Domain. Another object kind would have had its own name rendered as the domain's. - TypeError: An entry of ``AlterDomainExpression.actions`` is not an + TypeError: An entry of ``AlterDomainStatement.actions`` is not an implementation of DomainAlterAction. An entry of another kind has no action clause to dispatch on, so nothing would be rendered for it. """ if not isinstance(expr.domain, Domain): raise TypeError( - f"AlterDomainExpression.domain must be a Domain, " + f"AlterDomainStatement.domain must be a Domain, " f"got {type(expr.domain).__name__}" ) @@ -204,7 +204,7 @@ def format_alter_domain_statement( for position, entry in enumerate(expr.actions): if not isinstance(entry, DomainAlterAction): raise TypeError( - f"AlterDomainExpression.actions must hold DomainAlterAction implementations, " + f"AlterDomainStatement.actions must hold DomainAlterAction implementations, " f"got {type(entry).__name__} at position {position}" ) if not self.supports_domains() or not self.supports_alter_domain(): @@ -230,17 +230,17 @@ def format_alter_domain_statement( def format_drop_domain_statement( self, - expr: "DropDomainExpression", + expr: "DropDomainStatement", ) -> Tuple[str, tuple]: """Format a standard DROP DOMAIN statement. Raises: - TypeError: ``DropDomainExpression.domain`` is not a Domain. Another + TypeError: ``DropDomainStatement.domain`` is not a Domain. Another object kind would have had its own name rendered as the domain's. """ if not isinstance(expr.domain, Domain): raise TypeError( - f"DropDomainExpression.domain must be a Domain, " + f"DropDomainStatement.domain must be a Domain, " f"got {type(expr.domain).__name__}" ) if not self.supports_domains() or not self.supports_drop_domain(): @@ -266,8 +266,6 @@ def format_domain_check_constraint( if expr.name is not None and not self.supports_named_domain_checks(): raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK") condition_sql, condition_params = expr.condition.to_sql() - if condition_params: - raise ValueError("DOMAIN CHECK must render without bind parameters") if expr.name is None: sql = f"CHECK ({condition_sql})" else: @@ -341,7 +339,7 @@ def format_domain_alter_action( return f"RENAME TO {self.format_identifier(expr.new_name)}", () raise UnsupportedFeatureError( self.name, - f"ALTER DOMAIN action {getattr(expr, 'action_kind', type(expr).__name__)}", + f"ALTER DOMAIN action {expr.action_kind}", ) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_index.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_index.py index 29e801f40..532586d31 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_index.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_index.py @@ -8,14 +8,14 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateIndexExpression, - DropIndexExpression, + CreateIndexStatement, + DropIndexStatement, ) from ...expression.statements.ddl_index import ( - CreateFulltextIndexExpression, - DropFulltextIndexExpression, + CreateFulltextIndexStatement, + DropFulltextIndexStatement, ) - from ...expression.statements.fulltext_match import FulltextMatchExpression + from ...expression.statements.fulltext_match import FulltextMatch class IndexMixin: @@ -43,14 +43,14 @@ def supports_drop_index(self) -> bool: def preferred_create_index_statement(self): """The backend's preferred CREATE INDEX statement class (§5.12). - Defaults to ``None`` — the generic ``CreateIndexExpression``. + Defaults to ``None`` — the generic ``CreateIndexStatement``. """ return None def preferred_drop_index_statement(self): """The backend's preferred DROP INDEX statement class (§5.12). - Defaults to ``None`` — the generic ``DropIndexExpression``. + Defaults to ``None`` — the generic ``DropIndexStatement``. """ return None @@ -193,12 +193,12 @@ def supports_fulltext_query_expansion(self) -> bool: return self.supports_fulltext_index() def format_fulltext_match( - self, expr: "FulltextMatchExpression" + self, expr: "FulltextMatch" ) -> Tuple[str, tuple]: """Format MATCH ... AGAINST expression. Args: - expr: FulltextMatchExpression carrying the target ``columns``, + expr: FulltextMatch carrying the target ``columns``, ``search_term``, and optional ``mode``. Returns: @@ -224,33 +224,33 @@ def format_fulltext_match( # Default: NATURAL LANGUAGE MODE return f"MATCH({cols_str}) AGAINST({ph} IN NATURAL LANGUAGE MODE)", (expr.search_term,) - def format_create_fulltext_index_statement(self, expr: "CreateFulltextIndexExpression") -> Tuple[str, tuple]: + def format_create_fulltext_index_statement(self, expr: "CreateFulltextIndexStatement") -> Tuple[str, tuple]: """Format CREATE FULLTEXT INDEX statement from expression object. Args: - expr: CreateFulltextIndexExpression carrying the index name, table + expr: CreateFulltextIndexStatement carrying the index name, table name, columns, and optional parser. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateFulltextIndexExpression.index`` is not an Index. + TypeError: ``CreateFulltextIndexStatement.index`` is not an Index. Another object kind would have had its own name rendered as the index's. - TypeError: ``CreateFulltextIndexExpression.table`` is not a Table. + TypeError: ``CreateFulltextIndexStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If the dialect does not support full-text indexing. """ if not isinstance(expr.index, Index): raise TypeError( - f"CreateFulltextIndexExpression.index must be an Index, " + f"CreateFulltextIndexStatement.index must be an Index, " f"got {type(expr.index).__name__}" ) if not isinstance(expr.table, Table): raise TypeError( - f"CreateFulltextIndexExpression.table must be a Table, " + f"CreateFulltextIndexStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not self.supports_fulltext_index(): @@ -281,33 +281,33 @@ def format_create_fulltext_index_statement(self, expr: "CreateFulltextIndexExpre return " ".join(parts), () - def format_drop_fulltext_index_statement(self, expr: "DropFulltextIndexExpression") -> Tuple[str, tuple]: + def format_drop_fulltext_index_statement(self, expr: "DropFulltextIndexStatement") -> Tuple[str, tuple]: """Format DROP FULLTEXT INDEX statement from expression object. Args: - expr: DropFulltextIndexExpression carrying the index name, table + expr: DropFulltextIndexStatement carrying the index name, table name, and optional ``if_exists`` flag. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``DropFulltextIndexExpression.index`` is not an Index. + TypeError: ``DropFulltextIndexStatement.index`` is not an Index. Another object kind would have had its own name rendered as the index's. - TypeError: ``DropFulltextIndexExpression.table`` is not a Table. Another + TypeError: ``DropFulltextIndexStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If the dialect does not support full-text indexing. """ if not isinstance(expr.index, Index): raise TypeError( - f"DropFulltextIndexExpression.index must be an Index, " + f"DropFulltextIndexStatement.index must be an Index, " f"got {type(expr.index).__name__}" ) if not isinstance(expr.table, Table): raise TypeError( - f"DropFulltextIndexExpression.table must be a Table, " + f"DropFulltextIndexStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not self.supports_fulltext_index(): @@ -327,11 +327,11 @@ def format_drop_fulltext_index_statement(self, expr: "DropFulltextIndexExpressio return " ".join(parts), () - def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: + def format_create_index_statement(self, expr: "CreateIndexStatement") -> Tuple[str, tuple]: """Format CREATE INDEX statement per SQL standard. Args: - expr: CreateIndexExpression carrying the index definition, + expr: CreateIndexStatement carrying the index definition, including optional unique, index type, include, where, and tablespace clauses. @@ -339,22 +339,22 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateIndexExpression.index`` is not an Index. Another + TypeError: ``CreateIndexStatement.index`` is not an Index. Another object kind would have had its own name rendered as the index's. - TypeError: ``CreateIndexExpression.table`` is not a Table. Another + TypeError: ``CreateIndexStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If the dialect does not support specific index options. """ if not isinstance(expr.index, Index): raise TypeError( - f"CreateIndexExpression.index must be an Index, " + f"CreateIndexStatement.index must be an Index, " f"got {type(expr.index).__name__}" ) if not isinstance(expr.table, Table): raise TypeError( - f"CreateIndexExpression.table must be a Table, " + f"CreateIndexStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -368,7 +368,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ # CONCURRENTLY is a PostgreSQL extension gated by # supports_concurrent_index(); it follows the INDEX keyword # (``CREATE [UNIQUE] INDEX CONCURRENTLY ...``). - if getattr(expr, "concurrent", False): + if expr.concurrent: if not self.supports_concurrent_index(): raise UnsupportedFeatureError( self.name, "CREATE INDEX CONCURRENTLY", @@ -420,6 +420,9 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ self.name, "CREATE INDEX ... WHERE (partial index)", f"{self.name} does not support partial indexes.", ) + # The WHERE of a partial index is a DDL position: the condition + # renders inline-only (its literals are lifted to the DDL + # literal class by the condition preparer). where_sql, where_params = expr.where.to_sql() parts.append(f"WHERE {where_sql}") all_params.extend(where_params) @@ -434,38 +437,38 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ return " ".join(parts), tuple(all_params) - def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, tuple]: + def format_drop_index_statement(self, expr: "DropIndexStatement") -> Tuple[str, tuple]: """Format DROP INDEX statement per SQL standard. Args: - expr: DropIndexExpression carrying the index name, optional + expr: DropIndexStatement carrying the index name, optional ``if_exists`` flag, and optional table name. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``DropIndexExpression.index`` is not an Index. Another object + TypeError: ``DropIndexStatement.index`` is not an Index. Another object kind would have had its own name rendered as the index's. - TypeError: ``DropIndexExpression.table`` is not a Table. Another object + TypeError: ``DropIndexStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If the dialect does not support DROP INDEX IF EXISTS. """ if not isinstance(expr.index, Index): raise TypeError( - f"DropIndexExpression.index must be an Index, " + f"DropIndexStatement.index must be an Index, " f"got {type(expr.index).__name__}" ) if expr.table is not None and not isinstance(expr.table, Table): raise TypeError( - f"DropIndexExpression.table must be a Table, " + f"DropIndexStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError parts = ["DROP INDEX"] - if getattr(expr, "concurrent", False): + if expr.concurrent: if not self.supports_concurrent_index(): raise UnsupportedFeatureError( self.name, "DROP INDEX CONCURRENTLY", diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_schema.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_schema.py index 80aa8a0dd..2ce934be3 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_schema.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_schema.py @@ -7,8 +7,8 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) @@ -81,25 +81,25 @@ def supports_schema_authorization(self) -> bool: """ return False - def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tuple[str, tuple]: + def format_create_schema_statement(self, expr: "CreateSchemaStatement") -> Tuple[str, tuple]: """Format CREATE SCHEMA statement per SQL standard. Args: - expr: CreateSchemaExpression carrying the schema name, optional + expr: CreateSchemaStatement carrying the schema name, optional ``if_not_exists`` flag, and optional ``authorization`` owner. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateSchemaExpression.schema`` is not a Schema. Another + TypeError: ``CreateSchemaStatement.schema`` is not a Schema. Another object kind would have had its own name rendered as the schema's. UnsupportedFeatureError: If the dialect does not support CREATE SCHEMA or specific clauses. """ if not isinstance(expr.schema, Schema): raise TypeError( - f"CreateSchemaExpression.schema must be a Schema, " + f"CreateSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) if not self.supports_create_schema(): @@ -125,25 +125,25 @@ def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tupl parts.append(f"AUTHORIZATION {self.format_identifier(expr.authorization)}") return " ".join(parts), () - def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[str, tuple]: + def format_drop_schema_statement(self, expr: "DropSchemaStatement") -> Tuple[str, tuple]: """Format DROP SCHEMA statement per SQL standard. Args: - expr: DropSchemaExpression carrying the schema name, optional + expr: DropSchemaStatement carrying the schema name, optional ``if_exists`` flag, and optional ``cascade`` flag. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``DropSchemaExpression.schema`` is not a Schema. Another + TypeError: ``DropSchemaStatement.schema`` is not a Schema. Another object kind would have had its own name rendered as the schema's. UnsupportedFeatureError: If the dialect does not support DROP SCHEMA or specific clauses. """ if not isinstance(expr.schema, Schema): raise TypeError( - f"DropSchemaExpression.schema must be a Schema, " + f"DropSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) if not self.supports_drop_schema(): diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_sequence.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_sequence.py index 238f5f0c9..703ebfb62 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_sequence.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_sequence.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateSequenceExpression, - DropSequenceExpression, - AlterSequenceExpression, + CreateSequenceStatement, + DropSequenceStatement, + AlterSequenceStatement, ) @@ -135,7 +135,7 @@ def supports_sequence_owned_by(self) -> bool: """ return False - def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> Tuple[str, tuple]: + def format_create_sequence_statement(self, expr: "CreateSequenceStatement") -> Tuple[str, tuple]: """Format CREATE SEQUENCE statement per SQL standard. :meth:`supports_sequence` is consulted first and unconditionally, so a @@ -163,7 +163,7 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> explicit spelling whose probe is False raises. Args: - expr: CreateSequenceExpression carrying the sequence name and + expr: CreateSequenceStatement carrying the sequence name and optional start, increment, min/max value, cycle, cache, order, and owned-by options. @@ -171,7 +171,7 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateSequenceExpression.sequence`` is not a Sequence. A + TypeError: ``CreateSequenceStatement.sequence`` is not a Sequence. A table would render as a well-formed CREATE SEQUENCE over that table's name. UnsupportedFeatureError: If the dialect has no sequence object, or @@ -179,7 +179,7 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> """ if not isinstance(expr.sequence, Sequence): raise TypeError( - f"CreateSequenceExpression.sequence must be a Sequence, " + f"CreateSequenceStatement.sequence must be a Sequence, " f"got {type(expr.sequence).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -264,7 +264,7 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> return " ".join(parts), () - def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tuple[str, tuple]: + def format_drop_sequence_statement(self, expr: "DropSequenceStatement") -> Tuple[str, tuple]: """Format DROP SEQUENCE statement per SQL standard. :meth:`supports_sequence` is consulted first and unconditionally, so a @@ -275,14 +275,14 @@ def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tupl instead of dropping the clause. Args: - expr: DropSequenceExpression carrying the sequence name and + expr: DropSequenceStatement carrying the sequence name and optional ``if_exists`` flag. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``DropSequenceExpression.sequence`` is not a Sequence. A + TypeError: ``DropSequenceStatement.sequence`` is not a Sequence. A table would render as a well-formed DROP SEQUENCE over that table's name. UnsupportedFeatureError: If the dialect has no sequence object, or @@ -290,7 +290,7 @@ def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tupl """ if not isinstance(expr.sequence, Sequence): raise TypeError( - f"DropSequenceExpression.sequence must be a Sequence, " + f"DropSequenceStatement.sequence must be a Sequence, " f"got {type(expr.sequence).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -310,7 +310,7 @@ def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tupl parts.append(expr.sequence.to_sql()[0]) return " ".join(parts), () - def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tuple[str, tuple]: + def format_alter_sequence_statement(self, expr: "AlterSequenceStatement") -> Tuple[str, tuple]: """Format ALTER SEQUENCE statement per SQL standard. :meth:`supports_sequence` is consulted first and unconditionally, so a @@ -340,7 +340,7 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu and an explicit spelling whose probe is False raises. Args: - expr: AlterSequenceExpression carrying the sequence name and the + expr: AlterSequenceStatement carrying the sequence name and the options to change (restart, start, increment, min/max value, cycle, cache, order, and owned-by). @@ -348,7 +348,7 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``AlterSequenceExpression.sequence`` is not a Sequence. A + TypeError: ``AlterSequenceStatement.sequence`` is not a Sequence. A table would render as a well-formed ALTER SEQUENCE over that table's name. UnsupportedFeatureError: If the dialect has no sequence object, or @@ -356,7 +356,7 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu """ if not isinstance(expr.sequence, Sequence): raise TypeError( - f"AlterSequenceExpression.sequence must be a Sequence, " + f"AlterSequenceStatement.sequence must be a Sequence, " f"got {type(expr.sequence).__name__}" ) from ..exceptions import UnsupportedFeatureError diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_table.py index 2a3d13e27..c179d7481 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_table.py @@ -1,16 +1,27 @@ +from rhosocial.activerecord.backend.expression.statements import ( + TableCheckConstraint, + TableForeignKeyConstraint, +) +from ...expression.statements.ddl_alter import AlterTableAction +from ...expression.objects import Table +from typing import Any, List, Tuple, TYPE_CHECKING +from enum import Enum # src/rhosocial/activerecord/backend/dialect/mixins/ddl_table.py """Dialect mixins for table DDL and constraint capability detection.""" -from typing import Any, List, Tuple, TYPE_CHECKING -from ...expression.objects import Table -from ...expression.statements.ddl_alter import AlterTableAction + def _normalize_constraint_type(constraint_type: Any, constraint_enum: type, label: str) -> Any: if isinstance(constraint_type, constraint_enum): return constraint_type - raw_value = getattr(constraint_type, "value", constraint_type) - raw_name = getattr(constraint_type, "name", raw_value) + # Accepts the enum or its spelling, and a spelling may arrive as an enum of + # another family that spells the same words -- which is a type question, so + # it is asked with isinstance instead of probed for ``value`` / ``name``. + if isinstance(constraint_type, Enum): + raw_value, raw_name = constraint_type.value, constraint_type.name + else: + raw_value = raw_name = constraint_type candidates = [] for candidate in (raw_value, raw_name): if isinstance(candidate, str): @@ -26,37 +37,36 @@ def _normalize_constraint_type(constraint_type: Any, constraint_enum: type, labe ) -def normalize_column_constraint_type(constraint_type: Any) -> Any: - from ...expression.statements.ddl_table import ColumnConstraintType - - return _normalize_constraint_type( - constraint_type, - ColumnConstraintType, - "column constraint type", - ) +def normalize_foreign_key_match_type(match_type: Any) -> Any: + """Return the :class:`ForeignKeyMatchType` for ``match_type``. + ``None`` passes through: a constraint that names no MATCH type is asking for + the SQL default, which is not the same as asking for ``SIMPLE`` explicitly + (a dialect may spell the default and the explicit form differently, or + refuse one of them). + """ + from ...expression.statements.ddl_table import ForeignKeyMatchType -def normalize_table_constraint_type(constraint_type: Any) -> Any: - from ...expression.statements.ddl_table import TableConstraintType - + if match_type is None: + return None return _normalize_constraint_type( - constraint_type, - TableConstraintType, - "table constraint type", + match_type, + ForeignKeyMatchType, + "foreign key MATCH type", ) if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateTableExpression, - CreateTableAsExpression, - CreateTableLikeExpression, - CreateTableCloneExpression, - CreateTableFromTemplateExpression, + CreateTableStatement, + CreateTableAsStatement, + CreateTableLikeStatement, + CreateTableCloneStatement, + CreateTableFromTemplateStatement, CreateTableOptions, - DropTableExpression, + DropTableStatement, TableCommentClause, - AlterTableExpression, + AlterTableStatement, ) @@ -179,7 +189,7 @@ def supports_inline_index(self) -> bool: def preferred_create_table_statement(self): """The backend's preferred CREATE TABLE statement class (§5.12). - Defaults to ``None`` — the generic ``CreateTableExpression`` is the + Defaults to ``None`` — the generic ``CreateTableStatement`` is the candidate fallback. Backends that render their own statement subclass override this to return it (first candidate, order = priority); the deriver composes ``[preferred, generic]`` and selects through @@ -190,7 +200,7 @@ def preferred_create_table_statement(self): def preferred_drop_table_statement(self): """The backend's preferred DROP TABLE statement class (§5.12). - Defaults to ``None`` — the generic ``DropTableExpression``. + Defaults to ``None`` — the generic ``DropTableStatement``. """ return None @@ -261,6 +271,42 @@ def supports_create_table_as(self) -> bool: """ return True + def supports_create_table_as_bind_parameters(self) -> bool: + """Whether a CTAS body may carry bind parameters. + + Defaults to ``False``. A DDL statement carries no bind parameters + unless the dialect says it can, and the body of a CTAS is the one + DDL body that is a query, so this is the question this probe + answers. + + The two differ by engine, and the answer is measured rather than + assumed: + + * PostgreSQL accepts them -- the body is executed once, so a bound + value is meaningful (19beta4: ``CREATE TABLE t AS SELECT $1 FROM + s`` creates the table). + * MySQL and MariaDB accept them; their "parameters" are + interpolated client-side, so the value is baked into the text. + * ClickHouse accepts them, for the same reason. + * Oracle refuses them: Oracle's DDL statements never take bind + variables, and the driver rejects the statement before the + server sees it (``DPY-4009: 0 positional bind values are + required but 1 were provided``). + + A dialect that does not answer refuses the statement by name, with + the way out named -- build the query with an inlined literal. + """ + return False + + def _ddl_bind_parameter_reason(self, feature: str) -> str: + """Why this dialect does not carry bind parameters in a CTAS body. + + A backend that refuses them for a *measured* reason overrides this + and quotes the engine's own answer, so the refusal says why rather + than only that. This default names the fact and leaves it there. + """ + return f"{self.name} does not carry bind parameters in a CTAS body." + def supports_with_data_clause(self) -> bool: """Whether ``WITH [NO] DATA`` is supported on CTAS. @@ -325,7 +371,7 @@ def format_table_comment(self, comment: str) -> Tuple[str, tuple]: The statement renderer appends the returned clause after the column list and storage options, before any PARTITION BY clause. """ - escaped = self._escape_sql_string(comment) + escaped = self.escape_sql_string(comment) return f"COMMENT '{escaped}'", () def format_table_comment_clause(self, clause: "TableCommentClause") -> Tuple[str, tuple]: @@ -386,7 +432,7 @@ def format_create_table_options(self, expr: "CreateTableOptions") -> Tuple[str, parts.append("OR REPLACE") return " ".join(parts), () - def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") -> Tuple[str, tuple]: + def format_create_table_like_statement(self, expr: "CreateTableLikeStatement") -> Tuple[str, tuple]: """Format ``CREATE TABLE ... LIKE`` (generic reusable implementation). Renders the common vendor form:: @@ -411,9 +457,9 @@ def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateTableLikeExpression.table`` is not a Table. Another + TypeError: ``CreateTableLikeStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. - TypeError: ``CreateTableLikeExpression.like_table`` is not a Table. + TypeError: ``CreateTableLikeStatement.like_table`` is not a Table. Another object kind would have had its own name rendered as the copied table's. UnsupportedFeatureError: If :meth:`supports_create_table_like` is @@ -421,13 +467,13 @@ def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableLikeExpression.table must be a Table, " + f"CreateTableLikeStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not isinstance(expr.like_table, Table): raise TypeError( - f"CreateTableLikeExpression.like_table must be a Table, " + f"CreateTableLikeStatement.like_table must be a Table, " f"got {type(expr.like_table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -443,7 +489,7 @@ def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") ] return " ".join(parts), tuple(table_params) + tuple(source_params) - def format_create_table_clone_statement(self, expr: "CreateTableCloneExpression") -> Tuple[str, tuple]: + def format_create_table_clone_statement(self, expr: "CreateTableCloneStatement") -> Tuple[str, tuple]: """Format ``CREATE TABLE ... CLONE/COPY`` (generic reusable implementation). Renders:: @@ -464,9 +510,9 @@ def format_create_table_clone_statement(self, expr: "CreateTableCloneExpression" Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateTableCloneExpression.table`` is not a Table. Another + TypeError: ``CreateTableCloneStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. - TypeError: ``CreateTableCloneExpression.source_table`` is not a Table. + TypeError: ``CreateTableCloneStatement.source_table`` is not a Table. Another object kind would have had its own name rendered as the source's. UnsupportedFeatureError: If :meth:`supports_create_table_clone` is @@ -474,13 +520,13 @@ def format_create_table_clone_statement(self, expr: "CreateTableCloneExpression" """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableCloneExpression.table must be a Table, " + f"CreateTableCloneStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not isinstance(expr.source_table, Table): raise TypeError( - f"CreateTableCloneExpression.source_table must be a Table, " + f"CreateTableCloneStatement.source_table must be a Table, " f"got {type(expr.source_table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -498,7 +544,9 @@ def format_create_table_clone_statement(self, expr: "CreateTableCloneExpression" parts.append("COPY GRANTS") return " ".join(parts), tuple(table_params) + tuple(source_params) - def format_create_table_using_template(self, expr: "CreateTableFromTemplateExpression") -> Tuple[str, tuple]: + def format_create_table_from_template_statement( + self, expr: "CreateTableFromTemplateStatement" + ) -> Tuple[str, tuple]: """Format ``CREATE TABLE ... USING TEMPLATE`` (generic reusable implementation). Renders:: @@ -514,7 +562,7 @@ def format_create_table_using_template(self, expr: "CreateTableFromTemplateExpre Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateTableFromTemplateExpression.table`` is not a Table. + TypeError: ``CreateTableFromTemplateStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If :meth:`supports_create_table_using_template` is False for the @@ -522,7 +570,7 @@ def format_create_table_using_template(self, expr: "CreateTableFromTemplateExpre """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableFromTemplateExpression.table must be a Table, " + f"CreateTableFromTemplateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -538,7 +586,7 @@ def format_create_table_using_template(self, expr: "CreateTableFromTemplateExpre ] return " ".join(parts), tuple(table_params) + tuple(template_params) - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: + def format_create_table_statement(self, expr: "CreateTableStatement") -> Tuple[str, tuple]: """Format CREATE TABLE statement (generic implementation). Handles the explicit-schema form only (columns, constraints, storage, @@ -553,7 +601,7 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ of being silently dropped. Args: - expr: CreateTableExpression carrying the table reference, column + expr: CreateTableStatement carrying the table reference, column definitions, constraints, and optional storage, table comment, tablespace, inherits, and partition clauses. @@ -561,17 +609,17 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateTableExpression.table`` is not a Table. Another + TypeError: ``CreateTableStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableExpression.table must be a Table, " + f"CreateTableStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) 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: @@ -599,12 +647,25 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ column_parts.append(col_sql) all_params.extend(col_params) all_def_parts = [", ".join(column_parts)] + from ...expression.statements import ( + ConstraintValidation, + ) + for t_const in expr.table_constraints: - validation = getattr(t_const, "validation", None) - if validation is not None: - validation_value = getattr(validation, "value", validation) - normalized_validation = "".join(str(validation_value).strip().upper().split()) - if normalized_validation in {"NOTVALID", "NOVALIDATE"}: + # NOT VALID only means something when the constraint is *added*; + # CREATE TABLE validates as it goes, so it is refused here. The + # guard is about the trait alone: a constraint without one is + # rendered like any other. + if t_const.validation is not None and isinstance( + t_const.branch, (TableCheckConstraint, TableForeignKeyConstraint) + ): + validation = t_const.validation + value = ( + validation.value + if isinstance(validation, ConstraintValidation) + else validation + ) + if "".join(str(value).strip().upper().split()) in {"NOTVALID", "NOVALIDATE"}: raise ValueError("NOT VALID is only valid when adding a constraint") const_sql, const_params = self.format_table_constraint(t_const) if const_sql: @@ -617,7 +678,7 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ if storage_sql: parts.append(storage_sql) all_params.extend(storage_params) - table_comment = getattr(table_options, "comment", None) if table_options is not None else None + table_comment = table_options.comment if table_options is not None else None if table_comment is not None: comment_sql, comment_params = self.format_table_comment_clause(table_comment) parts.append(comment_sql) @@ -643,16 +704,15 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ parts.append(partition_sql) all_params.extend(partition_params) if expr.indexes: - from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( self.name, "inline index in CREATE TABLE", f"{self.name} does not support inline index definitions in " - "CREATE TABLE. Emit standalone CreateIndexExpression instead.", + "CREATE TABLE. Emit standalone CreateIndexStatement instead.", ) return "".join(parts), tuple(all_params) - def format_create_table_as_statement(self, expr: "CreateTableAsExpression") -> Tuple[str, tuple]: + def format_create_table_as_statement(self, expr: "CreateTableAsStatement") -> Tuple[str, tuple]: """Format ``CREATE TABLE ... AS `` (generic CTAS implementation). Emits the portable form:: @@ -662,22 +722,22 @@ def format_create_table_as_statement(self, expr: "CreateTableAsExpression") -> T The query is rendered **without parentheses** -- parenthesising is rejected by SQLite (and several other engines). ``WITH [NO] DATA`` is - only appended when :attr:`CreateTableAsExpression.with_data` is set + only appended when :attr:`CreateTableAsStatement.with_data` is set (PostgreSQL semantics). Args: - expr: CreateTableAsExpression carrying the target table and query. + expr: CreateTableAsStatement carrying the target table and query. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``CreateTableAsExpression.table`` is not a Table. Another + TypeError: ``CreateTableAsStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableAsExpression.table must be a Table, " + f"CreateTableAsStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -706,6 +766,8 @@ def format_create_table_as_statement(self, expr: "CreateTableAsExpression") -> T all_params.extend(storage_params) query_sql, query_params = expr.as_query.to_sql() + if query_params and not self.supports_create_table_as_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE TABLE ... AS with bound parameters") parts.append(f"AS {query_sql}") all_params.extend(query_params) @@ -716,7 +778,7 @@ def format_create_table_as_statement(self, expr: "CreateTableAsExpression") -> T return " ".join(parts), tuple(all_params) - def format_drop_table_statement(self, expr: "DropTableExpression") -> Tuple[str, tuple]: + def format_drop_table_statement(self, expr: "DropTableStatement") -> Tuple[str, tuple]: """Format DROP TABLE statement (generic implementation). Renders ``DROP TABLE [IF EXISTS]
[CASCADE | RESTRICT]`` with @@ -730,21 +792,21 @@ def format_drop_table_statement(self, expr: "DropTableExpression") -> Tuple[str, backends override this method to render their own form. Args: - expr: DropTableExpression carrying the table reference, optional + expr: DropTableStatement carrying the table reference, optional ``if_exists`` flag, and optional ``cascade`` flag. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``DropTableExpression.table`` is not a Table. Another object + TypeError: ``DropTableStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If CASCADE or RESTRICT is requested but not supported by the dialect. """ if not isinstance(expr.table, Table): raise TypeError( - f"DropTableExpression.table must be a Table, " + f"DropTableStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -773,7 +835,7 @@ def format_drop_table_statement(self, expr: "DropTableExpression") -> Tuple[str, "DROP TABLE ... RESTRICT", ) parts.append("RESTRICT") - if getattr(expr, "purge", False): + if expr.purge: if not self.supports_purge_on_drop_table(): raise UnsupportedFeatureError( self.name, @@ -782,7 +844,7 @@ def format_drop_table_statement(self, expr: "DropTableExpression") -> Tuple[str, parts.append("PURGE") return " ".join(parts), table_params - def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[str, tuple]: + def format_alter_table_statement(self, expr: "AlterTableStatement") -> Tuple[str, tuple]: """Format ALTER TABLE statement (generic implementation). When the dialect does not support multiple actions in one ALTER TABLE @@ -790,22 +852,22 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st by semicolons. Args: - expr: AlterTableExpression carrying the table name and the list of + expr: AlterTableStatement carrying the table name and the list of actions to render. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``AlterTableExpression.table`` is not a Table. Another object + TypeError: ``AlterTableStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. - TypeError: An entry of ``AlterTableExpression.actions`` is not an + TypeError: An entry of ``AlterTableStatement.actions`` is not an implementation of AlterTableAction. An entry of another kind has no action clause to dispatch on, so nothing would be rendered for it. """ if not isinstance(expr.table, Table): raise TypeError( - f"AlterTableExpression.table must be a Table, " + f"AlterTableStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) @@ -815,7 +877,7 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st for position, entry in enumerate(expr.actions): if not isinstance(entry, AlterTableAction): raise TypeError( - f"AlterTableExpression.actions must hold AlterTableAction implementations, " + f"AlterTableStatement.actions must hold AlterTableAction implementations, " f"got {type(entry).__name__} at position {position}" ) all_params: List[Any] = [] @@ -910,28 +972,43 @@ def supports_fk_match(self) -> bool: """ return True + # Named constraints (SQL-92) + + def supports_constraint_naming(self) -> bool: + """Whether a constraint may carry a ``CONSTRAINT `` prefix. + + The SQL-92 grammar opens both constraint productions with + ``[CONSTRAINT ]``, so this defaults to ``True`` — + a constructor is something every grammar has. A dialect overrides + it only to refuse the prefix. + """ + return True + # Constraint deferral (SQL:1999) def supports_deferrable_constraint(self) -> bool: """Whether DEFERRABLE / INITIALLY DEFERRED/IMMEDIATE is supported. - Defaults to True. + A SQL:1999 *modifier*, not a constructor, so it defaults to + ``False`` and the dialects that have it opt in explicitly + (PostgreSQL, Oracle, SQLite — measured, foreign keys only on + SQLite). """ - return True + return False # Constraint enforcement control (SQL:2016) def supports_constraint_enforced(self) -> bool: """Whether ENFORCED / NOT ENFORCED constraint control is supported. - Defaults to True. + A SQL:2016 *modifier*, not a constructor, so it defaults to + ``False`` and the dialects that have it opt in explicitly + (MySQL >= 8.0.16, PostgreSQL >= 18, BigQuery — each narrowing the + kinds it accepts inside its own table-constraint formatter). """ - return True + return False - def supports_alter_constraint_enforced( - self, - constraint_type: Any = None, - ) -> bool: + def supports_alter_constraint_enforced(self) -> bool: """Whether ALTER CONSTRAINT enforcement control is supported.""" return False diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_view.py index 30d9fcaac..23e4183b9 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ddl_view.py @@ -11,12 +11,12 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateViewExpression, - DropViewExpression, - TruncateExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateViewStatement, + DropViewStatement, + TruncateStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) @@ -55,6 +55,57 @@ def supports_materialized_view(self) -> bool: """Whether materialized views are supported (defaults to False).""" return False + def supports_create_view_bind_parameters(self) -> bool: + """Whether a view body may carry bind parameters. + + Defaults to ``False``. A view's definition is *stored and run again + later*, so a bind that is meaningful while the statement executes + has no meaning then; that is why PostgreSQL refuses one in its own + words ("there is no parameter $1", 19beta4). + + The answer is measured rather than assumed. PostgreSQL refuses; + MySQL and MariaDB accept, because their "parameters" are + interpolated client-side and the value is baked into the stored + definition; ClickHouse accepts for the same reason; SQL Server + refuses; Oracle refuses, because Oracle's DDL statements never take + bind variables and the driver rejects the statement before the + server sees it (``DPY-4009``). + + A dialect that does not answer refuses the statement by name, with + the way out named -- build the query with an inlined literal. + """ + return False + + def supports_materialized_view_bind_parameters(self) -> bool: + """Whether a materialized view body may carry bind parameters. + + Defaults to ``False``. A materialized view's definition is *stored + and run again later*, so a bind that is meaningful while the + statement executes has no meaning then; that is why PostgreSQL + refuses one in its own words ("materialized views may not be + defined using bound parameters", 19beta4). + + The answer is measured rather than assumed. PostgreSQL refuses; + ClickHouse accepts, because its "parameters" are interpolated + client-side and the value is baked into the stored definition; + Oracle refuses, because Oracle's DDL statements never take bind + variables and the driver rejects the statement before the server + sees it (``DPY-4009``). + + A dialect that does not answer refuses the statement by name, with + the way out named -- build the query with an inlined literal. + """ + return False + + def _ddl_bind_parameter_reason(self, feature: str) -> str: + """Why this dialect does not carry bind parameters in a view body. + + A backend that refuses them for a *measured* reason overrides this + and quotes the engine's own answer, so the refusal says why rather + than only that. This default names the fact and leaves it there. + """ + return f"{self.name} does not carry bind parameters in a view body." + def supports_refresh_materialized_view(self) -> bool: """Whether REFRESH MATERIALIZED VIEW is supported (defaults to False).""" return False @@ -97,23 +148,23 @@ def supports_restrict_view(self) -> bool: """Whether DROP VIEW RESTRICT is supported (defaults to False).""" 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 a CREATE VIEW statement. Args: - expr: The CreateViewExpression to render. + expr: The CreateViewStatement to render. Returns: A ``(sql, params)`` tuple where ``params`` holds the parameters collected from the view's query. Raises: - TypeError: ``CreateViewExpression.view`` is not a View. Another object + TypeError: ``CreateViewStatement.view`` is not a View. Another object kind would have had its own name rendered as the view's. """ if not isinstance(expr.view, View): raise TypeError( - f"CreateViewExpression.view must be a View, " + f"CreateViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) from ...expression.statements import ViewCheckOption @@ -144,6 +195,8 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st aliases_str = ", ".join(self.format_identifier(alias) for alias in expr.column_aliases) sql_parts.append(f"({aliases_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") sql_parts.append(f" AS {query_sql}") all_params.extend(query_params) if expr.options.check_option is not None: @@ -158,24 +211,24 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st sql_parts.append(" WITH CASCADED CHECK OPTION") return " ".join(sql_parts), tuple(all_params) - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + def format_drop_view_statement(self, expr: "DropViewStatement") -> Tuple[str, tuple]: """Format a DROP VIEW statement. Args: - expr: The DropViewExpression to render. + expr: The DropViewStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``DropViewExpression.view`` is not a View. Another object + TypeError: ``DropViewStatement.view`` is not a View. Another object kind would have had its own name rendered as the view's. UnsupportedFeatureError: If the dialect does not support IF EXISTS or CASCADE for DROP VIEW. """ if not isinstance(expr.view, View): raise TypeError( - f"DropViewExpression.view must be a View, " + f"DropViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -204,18 +257,18 @@ def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, t sql = f"DROP VIEW {if_exists_part}{expr.view.to_sql()[0]}{behavior_part}" return sql.strip(), () - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewStatement") -> Tuple[str, tuple]: """Format a CREATE MATERIALIZED VIEW statement. Args: - expr: The CreateMaterializedViewExpression to render. + expr: The CreateMaterializedViewStatement to render. Returns: A ``(sql, params)`` tuple where ``params`` holds the parameters collected from the view's query. Raises: - TypeError: ``CreateMaterializedViewExpression.view`` is not a + TypeError: ``CreateMaterializedViewStatement.view`` is not a MaterializedView. A plain view would render as a well-formed CREATE MATERIALIZED VIEW over that view's name. UnsupportedFeatureError: If the dialect does not support @@ -223,7 +276,7 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie """ if not isinstance(expr.view, MaterializedView): raise TypeError( - f"CreateMaterializedViewExpression.view must be a MaterializedView, " + f"CreateMaterializedViewStatement.view must be a MaterializedView, " f"got {type(expr.view).__name__}" ) if not self.supports_materialized_view(): @@ -251,6 +304,8 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_materialized_view_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE MATERIALIZED VIEW with bound parameters") parts.append(f"AS {query_sql}") if expr.with_data: @@ -260,17 +315,17 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie return " ".join(parts), query_params - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: + def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewStatement") -> Tuple[str, tuple]: """Format a DROP MATERIALIZED VIEW statement. Args: - expr: The DropMaterializedViewExpression to render. + expr: The DropMaterializedViewStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``DropMaterializedViewExpression.view`` is not a + TypeError: ``DropMaterializedViewStatement.view`` is not a MaterializedView. A plain view would render as a well-formed DROP MATERIALIZED VIEW over that view's name. UnsupportedFeatureError: If the dialect does not support @@ -278,7 +333,7 @@ def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExp """ if not isinstance(expr.view, MaterializedView): raise TypeError( - f"DropMaterializedViewExpression.view must be a MaterializedView, " + f"DropMaterializedViewStatement.view must be a MaterializedView, " f"got {type(expr.view).__name__}" ) if not self.supports_materialized_view(): @@ -300,18 +355,18 @@ def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExp return " ".join(parts), () def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" + self, expr: "RefreshMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format a REFRESH MATERIALIZED VIEW statement. Args: - expr: The RefreshMaterializedViewExpression to render. + expr: The RefreshMaterializedViewStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``RefreshMaterializedViewExpression.view`` is not a + TypeError: ``RefreshMaterializedViewStatement.view`` is not a MaterializedView. A plain view would render as a well-formed REFRESH MATERIALIZED VIEW over that view's name. UnsupportedFeatureError: If the dialect does not support refreshing @@ -319,7 +374,7 @@ def format_refresh_materialized_view_statement( """ if not isinstance(expr.view, MaterializedView): raise TypeError( - f"RefreshMaterializedViewExpression.view must be a MaterializedView, " + f"RefreshMaterializedViewStatement.view must be a MaterializedView, " f"got {type(expr.view).__name__}" ) if not self.supports_refresh_materialized_view(): @@ -369,24 +424,24 @@ def supports_truncate_restrict(self) -> bool: """Whether the RESTRICT option is supported (defaults to False).""" return False - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format a TRUNCATE statement. Args: - expr: The TruncateExpression to render. + expr: The TruncateStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``TruncateExpression.table`` is not a Table. Another object + TypeError: ``TruncateStatement.table`` is not a Table. Another object kind would have had its own name rendered after TRUNCATE. UnsupportedFeatureError: If the dialect does not support TRUNCATE, or does not support RESTART IDENTITY or CASCADE for TRUNCATE. """ if not isinstance(expr.table, Table): raise TypeError( - f"TruncateExpression.table must be a Table, " + f"TruncateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/dml.py b/src/rhosocial/activerecord/backend/dialect/mixins/dml.py index bdf5e4c67..8af9e0369 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/dml.py @@ -13,10 +13,10 @@ if TYPE_CHECKING: from ...expression.statements.dml import ( DefaultValuesSource, - DeleteExpression, - InsertExpression, + DeleteStatement, + InsertStatement, SelectSource, - UpdateExpression, + UpdateStatement, ValuesSource, ) from ...expression.statements import ReturningClause @@ -97,24 +97,25 @@ def format_returning_clause(self, clause: "ReturningClause") -> Tuple[str, Tuple supported by this dialect. """ from ...expression.column_types import ColumnBase - from ...expression.core import Subquery, WildcardExpression - from ...expression.operators import RawSQLExpression + from ...expression.core import AllColumnsWildcard, FunctionCall, Subquery + from ...expression.functions.aggregate import AggregateFunction + from ...expression.operators import RawSQL all_params: List[Any] = [] expr_parts: List[str] = [] for expr in clause.expressions: - # ``is_aggregate`` is set by the aggregate factories and forwarded - # by the typed wrapper they return, so reading the attribute catches - # both the node and the wrapper. A plain isinstance test against the - # node class would miss every factory-built aggregate. - if getattr(expr, "is_aggregate", False) or isinstance(expr, Subquery): + if ( + (isinstance(expr, FunctionCall) and expr.is_aggregate) + or isinstance(expr, AggregateFunction) + or isinstance(expr, Subquery) + ): raise UnsupportedFeatureError( self.name, "aggregate/subquery in RETURNING", "A RETURNING clause is evaluated per row and cannot contain " "aggregate functions or subqueries.", ) - if isinstance(expr, WildcardExpression): + if isinstance(expr, AllColumnsWildcard): if not self.supports_returning_wildcard(): raise UnsupportedFeatureError( self.name, @@ -131,7 +132,7 @@ def format_returning_clause(self, clause: "ReturningClause") -> Tuple[str, Tuple "This dialect does not support OLD/NEW row references " "in a RETURNING clause.", ) - elif not isinstance(expr, RawSQLExpression): + elif not isinstance(expr, RawSQL): if not self.supports_returning_expressions(): raise UnsupportedFeatureError( self.name, @@ -167,25 +168,25 @@ def format_returning_clause(self, clause: "ReturningClause") -> Tuple[str, Tuple return returning_sql, tuple(all_params) - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format an INSERT statement. Args: - expr: The InsertExpression to render. + expr: The InsertStatement to render. Returns: A ``(sql, params)`` tuple of the statement text and its bind parameters. Raises: - TypeError: ``InsertExpression.into`` is not a Table. Another object kind + TypeError: ``InsertStatement.into`` is not a Table. Another object kind would have had its own name rendered as the INTO target. UnsupportedFeatureError: If a RETURNING clause is requested but the dialect does not support it. """ if not isinstance(expr.into, Table): raise TypeError( - f"InsertExpression.into must be a Table, " + f"InsertStatement.into must be a Table, " f"got {type(expr.into).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -219,6 +220,13 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] def format_insert_values_source(self, expr: "ValuesSource") -> Tuple[str, tuple]: """Render a ``VALUES`` list as an INSERT's data source. + A cell is either an expression (rendered through its own ``to_sql()``) + or a plain host value, which becomes a bind parameter directly -- the + node stores what the caller gave it and this formatter decides how each + cell renders. A bare value's driver compatibility is the caller's + concern: a scalar always binds, and an object the driver rejects + surfaces at ``execute`` rather than being silently re-rendered here. + Args: expr: The :class:`ValuesSource` holding the rows to insert. @@ -231,9 +239,12 @@ def format_insert_values_source(self, expr: "ValuesSource") -> Tuple[str, tuple] for row in expr.values_list: cells_sql, cell_params = [], [] for value in row: - cell_sql, cell_value_params = value.to_sql() + if isinstance(value, BaseExpression): + cell_sql, value_params = value.to_sql() + else: + cell_sql, value_params = self.p(), (value,) cells_sql.append(cell_sql) - cell_params.extend(cell_value_params) + cell_params.extend(value_params) rows_sql.append(f"({', '.join(cells_sql)})") all_params.extend(cell_params) return "VALUES " + ", ".join(rows_sql), tuple(all_params) @@ -261,11 +272,11 @@ def format_insert_default_values_source(self, expr: "DefaultValuesSource") -> Tu """ return "DEFAULT VALUES", () - def format_on_conflict_clauses(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_on_conflict_clauses(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format one or more ON CONFLICT clauses, gated by capability switches. Args: - expr: InsertExpression carrying ``on_conflict`` (list of clauses or None). + expr: InsertStatement carrying ``on_conflict`` (list of clauses or None). Returns: Tuple of (SQL string, parameters tuple). Returns ("", ()) when no @@ -300,29 +311,29 @@ def format_on_conflict_clauses(self, expr: "InsertExpression") -> Tuple[str, tup params.extend(clause_params) return " ".join(parts), tuple(params) - def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple]: + def format_update_statement(self, expr: "UpdateStatement") -> Tuple[str, tuple]: """Format an UPDATE statement. Args: - expr: The UpdateExpression to render. + expr: The UpdateStatement to render. Returns: A ``(sql, params)`` tuple of the statement text and its bind parameters. Raises: - TypeError: ``UpdateExpression.table`` is not a Table. Another object + TypeError: ``UpdateStatement.table`` is not a Table. Another object kind would have had its own name rendered as the UPDATE target. UnsupportedFeatureError: If a RETURNING clause is requested but the dialect does not support it. """ if not isinstance(expr.table, Table): raise TypeError( - f"UpdateExpression.table must be a Table, " + f"UpdateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError - from ...expression.statements import QueryExpression + from ...expression.statements import QueryStatement all_params: List[Any] = [] table_sql, table_params = expr.table.to_sql() all_params.extend(table_params) @@ -344,7 +355,7 @@ def _fmt_from(source): """ if isinstance(source, str): return self.format_identifier(source), [] - if isinstance(source, QueryExpression): + if isinstance(source, QueryStatement): s_sql, s_params = source.to_sql() return f"({s_sql})", list(s_params) if isinstance(source, BaseExpression): @@ -379,18 +390,18 @@ def _fmt_from(source): all_params.extend(returning_params) return current_sql, tuple(all_params) - def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple]: + def format_delete_statement(self, expr: "DeleteStatement") -> Tuple[str, tuple]: """Format a DELETE statement. Args: - expr: The DeleteExpression to render. + expr: The DeleteStatement to render. Returns: A ``(sql, params)`` tuple of the statement text and its bind parameters. Raises: - TypeError: An entry of ``DeleteExpression.tables`` is not a Table. + TypeError: An entry of ``DeleteStatement.tables`` is not a Table. Another object kind would have had its own name rendered after DELETE FROM. UnsupportedFeatureError: If a RETURNING clause is requested but the @@ -399,11 +410,11 @@ def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple] for position, entry in enumerate(expr.tables): if not isinstance(entry, Table): raise TypeError( - f"DeleteExpression.tables must hold Table instances, " + f"DeleteStatement.tables must hold Table instances, " f"got {type(entry).__name__} at position {position}" ) from ..exceptions import UnsupportedFeatureError - from ...expression.statements import QueryExpression + from ...expression.statements import QueryStatement if self.strict_validation: expr.validate(strict=True) all_params: List[Any] = [] @@ -425,7 +436,7 @@ def _fmt_using(source): """ if isinstance(source, str): return self.format_identifier(source), [] - if isinstance(source, QueryExpression): + if isinstance(source, QueryStatement): s_sql, s_params = source.to_sql() return f"({s_sql})", list(s_params) if isinstance(source, BaseExpression): diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/dql.py b/src/rhosocial/activerecord/backend/dialect/mixins/dql.py index f099f9662..e98b1ef70 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/dql.py @@ -5,22 +5,24 @@ GROUP BY/HAVING, ORDER BY, QUALIFY, LIMIT/OFFSET and FOR UPDATE - plus the individual clause formatters reused elsewhere. """ -from typing import Any, List, Tuple, TYPE_CHECKING + +from typing import TYPE_CHECKING, Any, List, Tuple from ...expression.bases import ToSQLProtocol +from ...expression.query_parts import SortDirection if TYPE_CHECKING: from ...expression import bases - from ...expression.statements.dql import QueryExpression from ...expression.query_parts import ( ForUpdateClause, GroupByHavingClause, LimitOffsetClause, OrderByClause, - OrderByExpression, + OrderByItem, QualifyClause, WhereClause, ) + from ...expression.statements.dql import QueryStatement class DQLMixin: @@ -53,7 +55,7 @@ def supports_fetch_with_ties(self) -> bool: The default mirrors PostgreSQL's version gate (13+). Every other backend overrides this with its own real capability. """ - return self.version >= (13, 0, 0) + return self.version_at_least((13, 0, 0)) def format_limit_offset(self, limit=None, offset=None) -> Tuple[str, List]: """Format a LIMIT/OFFSET fragment. @@ -97,7 +99,7 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, all_params: List[Any] = [] parts = [] - if getattr(clause, "with_ties", False): + if clause.with_ties: if not self.supports_fetch_with_ties(): raise UnsupportedFeatureError( self.name, @@ -157,8 +159,6 @@ def format_where_clause(self, clause: "WhereClause") -> Tuple[str, tuple]: condition_sql, condition_params = clause.condition.to_sql() return f"WHERE {condition_sql}", condition_params - _VALID_ORDER_DIRECTIONS = frozenset({"ASC", "DESC"}) - def format_order_by_clause(self, clause: "OrderByClause") -> Tuple[str, tuple]: """Format an ORDER BY clause. @@ -178,10 +178,16 @@ def format_order_by_clause(self, clause: "OrderByClause") -> Tuple[str, tuple]: if isinstance(item, tuple): expr, direction = item expr_sql, expr_params = expr.to_sql() - direction = direction.upper() - if direction not in self._VALID_ORDER_DIRECTIONS: - raise ValueError(f"Invalid ORDER BY direction: {direction!r}. Must be 'ASC' or 'DESC'.") - expr_parts.append(f"{expr_sql} {direction}") + # The word travels; the keyword is written here. + direction = SortDirection.parse(direction) + # No direction is ordinary, not an error. The wire says null and + # the SQL says nothing: leaving the row order to the engine is + # what the artifact asked for -- and it is the shape Go writes, + # so a reader that rejected it would reject its counterpart. + if direction is None: + expr_parts.append(expr_sql) + else: + expr_parts.append(f"{expr_sql} {direction.keyword}") all_params.extend(expr_params) else: expr_sql, expr_params = item.to_sql() @@ -189,11 +195,11 @@ def format_order_by_clause(self, clause: "OrderByClause") -> Tuple[str, tuple]: all_params.extend(expr_params) return f"ORDER BY {', '.join(expr_parts)}", tuple(all_params) - def format_order_by_expression(self, expr: "OrderByExpression") -> Tuple[str, tuple]: + def format_order_by_item(self, expr: "OrderByItem") -> Tuple[str, tuple]: """Format a single sort element of an ORDER BY clause. Args: - expr: An :class:`OrderByExpression` carrying the sort expression, + expr: An :class:`OrderByItem` carrying the sort operand, optional direction and optional null ordering. Returns: @@ -205,11 +211,13 @@ def format_order_by_expression(self, expr: "OrderByExpression") -> Tuple[str, tu """ from ..exceptions import UnsupportedFeatureError - expr_sql, expr_params = expr.expression.to_sql() - parts = [expr_sql] + operand_sql, expr_params = expr.operand.to_sql() + parts = [operand_sql] if expr.direction is not None: - parts.append(expr.direction) - if expr.nulls_first or expr.nulls_last: + # The node carries the word; the keyword is written here, so the + # artifact stays language-neutral. + parts.append(expr.direction.keyword) + if expr.nulls is not None: if not self.supports_nulls_first_last(): raise UnsupportedFeatureError( self.name, @@ -217,7 +225,7 @@ def format_order_by_expression(self, expr: "OrderByExpression") -> Tuple[str, tu "This dialect does not support explicit NULLS FIRST/LAST " "ordering in an ORDER BY clause.", ) - parts.append("NULLS FIRST" if expr.nulls_first else "NULLS LAST") + parts.append("NULLS FIRST" if expr.nulls == "first" else "NULLS LAST") return " ".join(parts), tuple(expr_params) def format_group_by_having_clause(self, clause: "GroupByHavingClause") -> Tuple[str, tuple]: @@ -246,11 +254,11 @@ def format_group_by_having_clause(self, clause: "GroupByHavingClause") -> Tuple[ all_params.extend(having_params) return " ".join(sql_parts), tuple(all_params) - def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: + def format_query_statement(self, expr: "QueryStatement") -> Tuple[str, tuple]: """Format a complete SELECT query. Args: - expr: The QueryExpression to render. + expr: The QueryStatement to render. Returns: A ``(sql, params)`` tuple of the statement text and its bind @@ -261,6 +269,7 @@ def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: dialect does not support it. """ from ..exceptions import UnsupportedFeatureError + if self.strict_validation: expr.validate(strict=True) all_params: List[Any] = [] @@ -271,7 +280,9 @@ def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: all_params.extend(expr_params) modifier_str = "" if expr.select_modifier: - modifier_str = f" {expr.select_modifier.value}" + # The keyword comes from the enum, so the wire value stays the + # language-neutral word. + modifier_str = f" {expr.select_modifier.keyword}" select_sql = f"SELECT{modifier_str} " + ", ".join(select_parts) from_sql = "" if expr.from_: @@ -289,7 +300,7 @@ def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: part_sql, part_params = source.to_sql() if source.__class__.__name__ == "ValuesExpression" and source.alias is None: part_sql = f"({part_sql})" - if source.__class__.__name__ == "SetOperationExpression" and source.alias is None: + if source.__class__.__name__ == "SetOperation": part_sql = f"({part_sql})" from_parts.append(part_sql) from_expr_params.extend(part_params) @@ -298,7 +309,7 @@ def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: from_expr_sql, from_expr_params = expr.from_.to_sql() if expr.from_.__class__.__name__ == "ValuesExpression" and expr.from_.alias is None: from_expr_sql = f"({from_expr_sql})" - if expr.from_.__class__.__name__ == "SetOperationExpression" and expr.from_.alias is None: + if expr.from_.__class__.__name__ == "SetOperation": from_expr_sql = f"({from_expr_sql})" from_sql = f" FROM {from_expr_sql}" all_params.extend(from_expr_params) @@ -350,6 +361,19 @@ def supports_for_update_skip_locked(self) -> bool: """Whether FOR UPDATE SKIP LOCKED is supported. Defaults to False.""" return False + def supports_for_update_nowait(self) -> bool: + """Whether FOR UPDATE NOWAIT is supported. Defaults to False. + + A separate probe from ``supports_for_update_skip_locked`` because the + two do not travel together: Oracle spells the pair ``NOWAIT`` / + ``SKIP LOCKED``, PostgreSQL since 9.5 accepts both but older versions + only ``SKIP LOCKED``, and SQLite accepts neither. A dialect that has one + has no reason to have the other, so a shared probe would have to be + answered ``True`` for a dialect that would only reject half the requests + it admitted. + """ + return False + def supports_for_share(self) -> bool: """Whether the FOR SHARE lock strength is supported. Defaults to False.""" return False @@ -380,8 +404,8 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl UnsupportedFeatureError: If the requested lock strength or option is not supported by the dialect. """ - from ..exceptions import UnsupportedFeatureError from ...expression.query_parts import LockStrength + from ..exceptions import UnsupportedFeatureError all_params = [] strength = clause.strength @@ -395,7 +419,9 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl if strength == LockStrength.LOCK_IN_SHARE_MODE and not self.supports_lock_in_share_mode(): raise UnsupportedFeatureError(self.name, "LOCK IN SHARE MODE") - sql_parts = [strength.value] + # The clause text comes from the enum's phrase, so the wire value stays the + # language-neutral strength word. + sql_parts = [strength.phrase] # Handle OF columns if specified if clause.of_columns: @@ -410,8 +436,13 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl if of_parts: sql_parts.append(f"OF {', '.join(of_parts)}") - # Handle NOWAIT/SKIP LOCKED options + # Both wait modifiers are gated. Rendering either one for a dialect that + # does not have it produces a statement the database rejects at + # execution time, which is a worse answer than refusing here: the + # failure arrives with the caller's data already in flight (§6). if clause.nowait: + if not self.supports_for_update_nowait(): + raise UnsupportedFeatureError(self.name, "NOWAIT") sql_parts.append("NOWAIT") elif clause.skip_locked: if not self.supports_for_update_skip_locked(): diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/explain.py b/src/rhosocial/activerecord/backend/dialect/mixins/explain.py index dfc64980b..07ab02219 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/explain.py @@ -7,7 +7,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ...expression.statements import ExplainExpression + from ...expression.statements import ExplainStatement class ExplainMixin: @@ -32,11 +32,11 @@ def supports_explain_format(self, format_type: str) -> bool: """ return False - def format_explain_statement(self, expr: "ExplainExpression") -> Tuple[str, tuple]: + def format_explain_statement(self, expr: "ExplainStatement") -> Tuple[str, tuple]: """Format an EXPLAIN statement. Args: - expr: The ExplainExpression to render, including the wrapped + expr: The ExplainStatement to render, including the wrapped statement and optional options. Returns: @@ -54,7 +54,7 @@ def format_explain_statement(self, expr: "ExplainExpression") -> Tuple[str, tupl # Determine if ANALYZE should be included based on the type field # If type is ANALYZE, or if the boolean analyze field is True - if (hasattr(options, "type") and options.type == ExplainType.ANALYZE) or options.analyze: + if options.type == ExplainType.ANALYZE or options.analyze: parts.append("ANALYZE") if options.format: parts.append(f"FORMAT {options.format.value.upper()}") diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/expression.py b/src/rhosocial/activerecord/backend/dialect/mixins/expression.py index ac9586276..169e7a976 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/expression.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/expression.py @@ -10,17 +10,18 @@ from ...expression import bases from ...expression.core import ( CastExpression, - QualifiedIdentifierExpression, + QualifiedIdentifier, Subquery, - WildcardExpression, + AllColumnsWildcard, ) from ...expression.literals import Identifier +from ...expression.mixins import AliasableMixin from ...expression.operators import ( - BinaryArithmeticExpression, + ArithmeticOperation, BinaryExpression, - RawSQLExpression, + RawSQL, SQLOperation, - StringConcatExpression, + ConcatenationOperation, UnaryExpression, ) from .function import FunctionCallMixin @@ -44,28 +45,55 @@ def format_identifier_expression(self, expr: Identifier) -> Tuple[str, tuple]: """ return self.format_identifier(expr.name, expr.name_need_quote), () - def format_wildcard(self, expr: WildcardExpression) -> Tuple[str, tuple]: - """Format a :class:`~...expression.core.WildcardExpression`. + def format_all_columns_wildcard(self, expr: "AllColumnsWildcard") -> Tuple[str, tuple]: + """Format a :class:`~...expression.core.AllColumnsWildcard`. + + A bare ``*`` when no relation is set -- the surrounding clause's + formatter decides what the expansion spans. With a relation, the + qualifier is the spelling the query itself uses: the alias when the + reference carries one, the relation's own qualified name otherwise. Args: - expr: Wildcard expression optionally carrying a table and schema. + expr: The all-columns wildcard being rendered. Returns: A ``(sql, params)`` tuple; ``params`` is empty. + + Raises: + TypeError: ``relation`` is set but is not a ``NamedRelationRef``. """ - if expr.schema_name and expr.table: - wildcard_sql = ( - f"{self.format_identifier(expr.schema_name, expr.schema_need_quote)}." - f"{self.format_identifier(expr.table, expr.table_need_quote)}.*" + relation = expr.relation + if relation is None: + return "*", () + from ...expression.sources import NamedRelationRef + + if not isinstance(relation, NamedRelationRef): + raise TypeError( + f"AllColumnsWildcard.relation must be a NamedRelationRef, " + f"got {type(relation).__name__}" ) - elif expr.table: - wildcard_sql = f"{self.format_identifier(expr.table, expr.table_need_quote)}.*" + if relation.alias: + qualifier = self.format_identifier(relation.alias, relation.alias_need_quote) else: - wildcard_sql = "*" - return wildcard_sql, () + qualifier, _ = relation.relation.to_sql() + return f"{qualifier}.*", () + + def format_row_count_wildcard(self, expr: "RowCountWildcard") -> Tuple[str, tuple]: + """Format a :class:`~...expression.core.RowCountWildcard`. + + The bare asterisk, and nothing else: the row-count wildcard carries + no relation by contract. + + Args: + expr: The row-count wildcard being rendered. - def format_qualified_identifier(self, expr: QualifiedIdentifierExpression) -> Tuple[str, tuple]: - """Format a :class:`~...expression.core.QualifiedIdentifierExpression`. + Returns: + A ``(sql, params)`` tuple; ``params`` is empty. + """ + return "*", () + + def format_qualified_identifier(self, expr: QualifiedIdentifier) -> Tuple[str, tuple]: + """Format a :class:`~...expression.core.QualifiedIdentifier`. Args: expr: Qualified identifier optionally carrying a schema. @@ -81,31 +109,31 @@ def format_qualified_identifier(self, expr: QualifiedIdentifierExpression) -> Tu ) return self.format_identifier(expr.name, expr.name_need_quote), () - def format_literal_expression(self, expr: "bases.SQLValueExpression") -> Tuple[str, tuple]: - """Format a :class:`~...expression.core.Literal`. + def format_literal_expression(self, expr: "bases.Literal") -> Tuple[str, tuple]: + """Format a value held at one position of an expression. Args: - expr: Literal expression exposing ``inline_literals``, ``value`` - and optional ``alias``. + expr: The literal carrying ``value`` and ``inline``. Returns: - A ``(sql, params)`` tuple; ``params`` holds the literal value when - it is rendered as a bind parameter, otherwise it is empty. - - The construction-time ``inline_literals`` switch decides between an - inline (escaped, quoted) SQL atom and a bind-parameter placeholder. + A ``(sql, params)`` tuple; ``params`` holds the value when it is + rendered as a bind parameter, otherwise it is empty. + + ``expr.inline`` is the only decision here. ``False`` — the default, and + what every DQL/DML data position wants — emits a placeholder and hands + the value to the driver. ``True`` writes the value as SQL text through + the dialect's controlled literal construction (escaped, quoted), for + the DDL syntax positions that accept no bind parameters. Because the + answer lives on the literal, a node holding several values can treat + them differently without the formatter learning anything new. + + There is no alias to apply. A literal is the one thing in the tree with + nothing to name: a bind parameter belongs to the driver, and inline SQL + text is text. """ - if expr.inline_literals: - sql = self.format_literal(expr.value) - params: tuple = () - else: - sql = self.get_parameter_placeholder() - params = (expr.value,) - - if expr.alias: - sql = f"{sql} AS {self.format_identifier(expr.alias)}" - - return sql, params + if expr.inline: + return self.format_literal(expr.value), () + return self.get_parameter_placeholder(), (expr.value,) def format_cast_expression(self, expr: CastExpression) -> Tuple[str, tuple]: """Format a :class:`~...expression.core.CastExpression` node. @@ -152,35 +180,31 @@ def format_sql_operation(self, expr: SQLOperation) -> Tuple[str, tuple]: return f"{expr.op}({', '.join(formatted_operands_sql)})", tuple(params) return f"{expr.op}()", tuple(params) - def format_binary_operator(self, expr: BinaryExpression) -> Tuple[str, tuple]: + def format_binary_expression(self, expr: BinaryExpression) -> Tuple[str, tuple]: """Format a :class:`~...expression.operators.BinaryExpression`. - String concatenation is routed to - :meth:`format_string_concatenation` rather than inlined, because the - operator token does not determine the meaning: ``||`` concatenates on - most backends but is logical OR on MySQL, MariaDB and SQL Server. - Args: expr: Binary expression exposing ``left``, ``op`` and ``right``. Returns: A ``(sql, params)`` tuple of ``left op right``. """ - if isinstance(expr, StringConcatExpression): - return self.format_string_concatenation(expr) - left_sql, left_params = expr.left.to_sql() right_sql, right_params = expr.right.to_sql() sql = f"{left_sql} {expr.op} {right_sql}" - alias = getattr(expr, "alias", None) + alias = expr.alias if isinstance(expr, AliasableMixin) else None if alias: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, left_params + right_params - def format_string_concatenation( - self, expr: "StringConcatExpression" + def format_concatenation_operation( + self, expr: "ConcatenationOperation" ) -> Tuple[str, tuple]: - """Render a string concatenation using this dialect's spelling. + """Render a :class:`~...expression.operators.ConcatenationOperation`. + + The node states the *intent* — its kind is ``concatenation_operation`` + and it carries no operator parameter; this formatter states the + spelling this dialect writes for that intent. Args: expr: The concatenation node exposing ``left`` and ``right``. @@ -195,11 +219,16 @@ def format_string_concatenation( sql = f"{left_sql} || {right_sql}" else: sql = f"{function}({left_sql}, {right_sql})" - alias = getattr(expr, "alias", None) + alias = expr.alias if isinstance(expr, AliasableMixin) else None if alias: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, left_params + right_params + #: The function this dialect spells concatenation with, or ``None`` for the + #: standard's ``||``. Declared so :meth:`string_concatenation_function` can + #: read it without asking whether it exists. + STRING_CONCATENATION: Optional[str] = None + def string_concatenation_function(self) -> Optional[str]: """Return the function name this dialect uses for concatenation. @@ -207,20 +236,31 @@ def string_concatenation_function(self) -> Optional[str]: overriding this: MySQL, MariaDB and SQL Server read ``||`` as logical OR, and BigQuery does not accept it as concatenation at all. - The lookup is by name through ``getattr`` rather than by inheriting a - base implementation, because MRO order decides which definition wins - and the core mixin's position depends on how many generic mixins a - given dialect happens to inherit — which a backend author cannot see or - control. Looking the name up on the instance finds the dialect's own - declaration wherever it sits. + Declared on the mixin as ``None`` and overridden by the dialects that + need it, which is why this reads the attribute rather than looking a + name up: the field is part of the mixin's shape, so a checker sees it + and no one has to ask whether a given dialect happens to carry it. + + A dialect that declares its own must be listed **before** this mixin in + its base list, or the declaration here wins -- the same override-order + rule as everywhere else, and the reason a backend author controls it by + ordering rather than by a name lookup. + + A declaration that is not a string is ignored and answered as ``None``, + which is the same as never having declared one. The value is + interpolated straight into the statement as a function name, so there is + nothing safe to do with a value of the wrong type: passing it on would + put whatever the author wrote into the SQL text. Reading it as "no + override" keeps the standard's ``||``, which is the answer a dialect + that never declared one gets. Returns: The function name, or ``None`` to use the standard's ``||``. """ - declaration = getattr(self, "STRING_CONCATENATION", None) - return declaration if isinstance(declaration, str) else None + declared = self.STRING_CONCATENATION + return declared if isinstance(declared, str) else None - def format_unary_operator(self, expr: UnaryExpression) -> Tuple[str, tuple]: + def format_unary_expression(self, expr: UnaryExpression) -> Tuple[str, tuple]: """Format a :class:`~...expression.operators.UnaryExpression`. Args: @@ -235,19 +275,20 @@ def format_unary_operator(self, expr: UnaryExpression) -> Tuple[str, tuple]: return f"{expr.op} {operand_sql}", operand_params return f"{operand_sql} {expr.op}", operand_params - def format_binary_arithmetic_expression(self, expr: BinaryArithmeticExpression) -> Tuple[str, tuple]: - """Format a :class:`~...expression.operators.BinaryArithmeticExpression`. + def format_arithmetic_operation(self, expr: ArithmeticOperation) -> Tuple[str, tuple]: + """Format a :class:`~...expression.operators.ArithmeticOperation`. Args: - expr: Arithmetic expression exposing ``left``, ``op``, ``right`` - and optional ``alias``. + expr: Arithmetic operation exposing ``left``, ``op`` and ``right``. Returns: A ``(sql, params)`` tuple; parentheses are inserted where dictated by the operator-precedence table. - Parenthesization follows the operator-precedence table carried on the - expression class (``OPERATOR_PRECEDENCE``). + The node's ``op`` is an operation word (``add``/``sub``/``mul``/ + ``div``/``mod``), and the rendered SQL takes the character the word + maps to in the class's ``OPERATOR_SYMBOLS`` table. Parenthesization + follows ``OPERATOR_PRECEDENCE``, keyed by the same words. """ left_sql, left_params = expr.left.to_sql() right_sql, right_params = expr.right.to_sql() @@ -258,7 +299,7 @@ def format_binary_arithmetic_expression(self, expr: BinaryArithmeticExpression) if self._needs_parens(expr.right, current_precedence): right_sql = f"({right_sql})" - sql = f"{left_sql} {expr.op} {right_sql}" + sql = f"{left_sql} {expr.OPERATOR_SYMBOLS[expr.op]} {right_sql}" params = left_params + right_params if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" @@ -266,14 +307,14 @@ def format_binary_arithmetic_expression(self, expr: BinaryArithmeticExpression) def _needs_parens(self, operand, current_precedence): """Check if an arithmetic operand needs parentheses based on precedence.""" - from ...expression.operators import BinaryArithmeticExpression + from ...expression.operators import ArithmeticOperation - if isinstance(operand, BinaryArithmeticExpression): + if isinstance(operand, ArithmeticOperation): return operand.OPERATOR_PRECEDENCE.get(operand.op, 0) < current_precedence return False - def format_raw_sql(self, expr: RawSQLExpression) -> Tuple[str, tuple]: - """Format a :class:`~...expression.operators.RawSQLExpression` / ``RawSQLPredicate``. + def format_raw_sql(self, expr: RawSQL) -> Tuple[str, tuple]: + """Format a :class:`~...expression.operators.RawSQL` / ``RawSQLPredicate``. Args: expr: Raw SQL expression exposing ``expression`` and ``params``. @@ -284,8 +325,13 @@ def format_raw_sql(self, expr: RawSQLExpression) -> Tuple[str, tuple]: The raw SQL text is embedded verbatim together with its parameters — it bypasses the building mechanism by design and is the caller's responsibility to keep safe. + + ``params`` holds :class:`~...expression.core.Literal` nodes rather than + bare values, which is what lets each bind value keep its kind on the wire + (§10). A driver binds plain values, so the nodes are unwrapped here — the + one boundary that owes the driver something it can actually use. """ - return expr.expression, expr.params + return expr.expression, tuple(p.value for p in expr.params) def format_subquery(self, expr: Subquery) -> Tuple[str, tuple]: """Format a :class:`~...expression.core.Subquery` (parenthesized query). @@ -319,22 +365,38 @@ def format_alias(self, expression_sql: str, alias: str, expression_params: tuple def format_values_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: """Format a :class:`~...expression.query_sources.ValuesExpression` node. + Each cell is either an expression, which is rendered in place, or a + plain value, which becomes a bind parameter -- the same rule + :meth:`~.relation_source.format_values_table` applies to a row + constructor, and for the same reason. Writing a placeholder for every + cell and handing the cells themselves to the driver looks equivalent + for a table of literals and is wrong the moment a cell is not one: a + ``Column`` in a row rendered as ``?`` and reached the driver as a node. + Args: expr: VALUES expression exposing ``values``, optional ``alias`` and optional ``column_names``. Returns: - A ``(sql, params)`` tuple; each row value becomes a bind parameter. + A ``(sql, params)`` tuple. """ values = expr.values alias = expr.alias column_names = expr.column_names + placeholder = self.get_parameter_placeholder() all_params: List[Any] = [] rows_sql = [] for row in values: - placeholders = ", ".join([self.get_parameter_placeholder()] * len(row)) - rows_sql.append(f"({placeholders})") - all_params.extend(row) + cells_sql = [] + for cell in row: + if isinstance(cell, bases.BaseExpression): + cell_sql, cell_params = cell.to_sql() + all_params.extend(cell_params) + cells_sql.append(cell_sql) + else: + all_params.append(cell) + cells_sql.append(placeholder) + rows_sql.append(f"({', '.join(cells_sql)})") values_sql = ", ".join(rows_sql) cols_sql = "" if column_names: @@ -447,7 +509,7 @@ def format_filter_clause(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple """Format a FILTER clause. Args: - expr: FilterClauseExpression containing the condition. + expr: FilterClause containing the condition. Returns: Tuple of (SQL string, parameters tuple) for the rendered clause. diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/function.py b/src/rhosocial/activerecord/backend/dialect/mixins/function.py index ffb2126b3..742721fbd 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/function.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/function.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: # pragma: no cover from ...expression import bases from ...expression.statements import ( - CreateFunctionExpression, - DropFunctionExpression, + CreateFunctionStatement, + DropFunctionStatement, ) @@ -59,7 +59,7 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple function that its version is below. """ from ...expression import core, operators - from ..protocols import FilterClauseSupport + from ..protocols import FilterClauseSupport, WindowFunctionSupport self.check_function_version(expr.func_name) @@ -67,8 +67,8 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple expr.func_name.upper() == "COUNT" and len(expr.args) == 1 and ( - (isinstance(expr.args[0], operators.RawSQLExpression) and expr.args[0].expression == "*") - or isinstance(expr.args[0], core.WildcardExpression) + (isinstance(expr.args[0], operators.RawSQL) and expr.args[0].expression == "*") + or isinstance(expr.args[0], core.RowCountWildcard) ) ): args_sql = ["*"] @@ -87,7 +87,7 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple distinct = "DISTINCT " if expr.is_distinct else "" args_sql_str = ", ".join(args_sql) - if getattr(expr, "niladic", False) and not args_sql and not distinct: + if expr.niladic and not args_sql and not distinct: func_call_sql = expr.func_name.upper() else: func_call_sql = f"{expr.func_name.upper()}({distinct}{args_sql_str})" @@ -96,11 +96,11 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple for param_tuple in args_params: all_params.extend(param_tuple) - filter_predicate = getattr(expr, "filter_predicate", None) + filter_predicate = expr.filter_predicate if filter_predicate: if isinstance(self, FilterClauseSupport) and self.supports_filter_clause(): - from ...expression.statements.filter_clause import FilterClauseExpression - filter_expr = FilterClauseExpression(self, condition=filter_predicate) + from ...expression.statements.filter_clause import FilterClause + filter_expr = FilterClause(self, condition=filter_predicate) filter_clause_sql, filter_clause_params = self.format_filter_clause(filter_expr) func_call_sql += f" {filter_clause_sql}" all_params.extend(filter_clause_params) @@ -112,13 +112,13 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple "Use a CASE expression inside the aggregate function instead.", ) - window_spec = getattr(expr, "window_spec", None) + window_spec = expr.window_spec if window_spec is not None: - # Guarded through getattr(): a dialect that does not compose - # WindowFunctionMixin has neither the probe nor the formatter, and - # must refuse the clause rather than fail resolving a method. - supports_window_functions = getattr(self, "supports_window_functions", None) - if supports_window_functions is None or not supports_window_functions(): + # Checked as a protocol, like the FILTER clause above: a dialect + # that does not compose WindowFunctionSupport has neither the probe + # nor the formatter, and must refuse the clause rather than fail + # resolving a method. + if not isinstance(self, WindowFunctionSupport) or not self.supports_window_functions(): from ..exceptions import UnsupportedFeatureError raise UnsupportedFeatureError(self.name, "window functions") if isinstance(window_spec, str): @@ -136,6 +136,798 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple return func_call_sql, tuple(all_params) + # ------------------------------------------------------------------ + # Standard function nodes (catalog §3.4): one entry point per node kind. + # ------------------------------------------------------------------ + + def format_row_number_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ROW_NUMBER()``.""" + sql = f"{expr.function_name}()" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_rank_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``RANK()``.""" + sql = f"{expr.function_name}()" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_dense_rank_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``DENSE_RANK()``.""" + sql = f"{expr.function_name}()" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_json_objectagg_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``JSON_OBJECTAGG(key, value)``. + + Written here rather than through the shared aggregate renderer because + this aggregate has two operands and that one expects the node's single + ``operand``; ``DISTINCT`` does not apply either, since two distinct + pairs are still distinct pairs. + """ + key_sql, key_params = expr.operand.to_sql() + value_sql, value_params = expr.entry_value.to_sql() + sql = f"JSON_OBJECTAGG({key_sql}, {value_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(key_params) + tuple(value_params) + + def format_json_arrayagg_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``JSON_ARRAYAGG([DISTINCT] value)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"JSON_ARRAYAGG({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_absolute_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ABSOLUTE(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_round_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ROUND(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_ceil_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``CEIL(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_floor_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``FLOOR(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_truncate_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TRUNCATE(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_square_root_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SQUARE ROOT(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_power_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``POWER(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_exponential_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``EXPONENTIAL(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_logarithm_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``LOGARITHM(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_sine_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SINE(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_cosine_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``COSINE(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_tangent_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TANGENT(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_modulo_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``MODULO(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_sign_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SIGN(...)`` from the node's own operand list.""" + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"{expr.function_name.upper()}({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_array_agg_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ARRAY_AGG([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"ARRAY_AGG({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_count_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``COUNT(*)`` or ``COUNT([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"COUNT({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_sum_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SUM([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"SUM({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_avg_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``AVG([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"AVG({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_min_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``MIN([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"MIN({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_max_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``MAX([DISTINCT] operand)``.""" + from rhosocial.activerecord.backend.expression.core import RowCountWildcard + + if isinstance(expr.operand, RowCountWildcard): + operand_sql, operand_params = "*", () + else: + operand_sql, operand_params = expr.operand.to_sql() + distinct = "DISTINCT " if expr.distinct else "" + sql = f"MAX({distinct}{operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_upper_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``UPPER(operand)``.""" + operand_sql, operand_params = expr.operand.to_sql() + sql = f"UPPER({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_lower_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``LOWER(operand)``.""" + operand_sql, operand_params = expr.operand.to_sql() + sql = f"LOWER({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``CHAR_LENGTH(operand)`` — the spelling here is ``LENGTH``. + + ``expr.unit`` is deliberately **not read**: it records which length + semantics the caller asked for (characters / octets / bits), and this + formatter writes the spelling the standard spelling uses. A backend + whose ``LENGTH`` answers a different question (MySQL / ClickHouse + count bytes, SQL Server counts UTF-16 units) overrides this entry + point rather than having the core pick a unit for it. + """ + operand_sql, operand_params = expr.operand.to_sql() + sql = f"LENGTH({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) + + + def format_current_user_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``CURRENT_USER``.""" + sql = "CURRENT_USER" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_session_user_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SESSION_USER``.""" + sql = "SESSION_USER" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_system_user_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SYSTEM_USER``.""" + sql = "SYSTEM_USER" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + + def format_declared_result(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render a declared-result annotation by rendering what it annotates. + + The node underneath is left exactly as it was: the statement was already + correct and only the knowledge of what it yields was missing, so the SQL + is the annotated node's SQL unchanged. An alias rides along, because a + column given a name here is a name the caller asked for. + """ + source_sql, source_params = expr.source.to_sql() + if expr.source.alias and not expr.alias: + return source_sql, tuple(source_params) + if expr.alias: + return f"{source_sql} AS {self.format_identifier(expr.alias)}", tuple(source_params) + return source_sql, tuple(source_params) + + def format_concat_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``CONCAT(a, b, ...)``. + + A backend whose ``CONCAT`` ignores NULL, or treats it as an empty + string, overrides this: the standard says NULL, and ``||`` is the + spelling to reach for when concatenating may skip an operand. + """ + parts_sql, parts_params = [], [] + for operand in expr.operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"CONCAT({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_coalesce_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``COALESCE(a, b, ...)``.""" + parts_sql, parts_params = [], [] + for operand in expr.operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"COALESCE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_substring_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SUBSTRING(text FROM start [FOR length])``. + + The base spells it as a plain call, which is what most backends accept + and what the pre-node formatter produced. Backends wanting the + standard's ``FROM`` / ``FOR`` keywords override this. + """ + operands = [expr.target, expr.start, expr.length] + if operands and operands[-1] is None: + operands.pop() + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"SUBSTRING({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_overlay_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``OVERLAY(text, replacement, start [, length])``.""" + operands = [expr.target, expr.replacement, expr.start, expr.length] + if operands and operands[-1] is None: + operands.pop() + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"OVERLAY({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_position_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render the position of a substring, in whichever spelling was asked. + + The two spellings put the operands in opposite orders, which is the + whole reason they are one node: ``STRPOS(haystack, needle)`` and + ``POSITION(needle IN haystack)`` ask the same question and read it + differently. The node always holds (haystack, needle); only this + formatter knows which way round the SQL wants them. + """ + haystack_sql, haystack_params = expr.haystack.to_sql() + needle_sql, needle_params = expr.needle.to_sql() + # Ordered as they are written, so the bind values follow the + # placeholders: POSITION reverses the operands and must reverse the + # parameters with them, or a two-literal call binds them swapped. + if expr.spelling == "POSITION": + args_sql, args_params = [needle_sql, haystack_sql], [needle_params, haystack_params] + else: + args_sql, args_params = [haystack_sql, needle_sql], [haystack_params, needle_params] + sql = f"{expr.spelling}({', '.join(args_sql)})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + ordered_params: List[Any] = [] + for part in args_params: + ordered_params.extend(part) + return sql, tuple(ordered_params) + + def format_replace_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``REPLACE(text, pattern, replacement)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target, expr.pattern, expr.replacement): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"REPLACE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_initcap_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``INITCAP(text)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"INITCAP({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_left_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``LEFT(text, n)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target, expr.count): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"LEFT({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_right_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``RIGHT(text, n)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target, expr.count): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"RIGHT({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_reverse_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``REVERSE(text)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"REVERSE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_translate_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TRANSLATE(text, from, to)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target, expr.from_chars, expr.to_chars): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"TRANSLATE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_space_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``SPACE(n)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.count,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"SPACE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_chr_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``CHR(code)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.code,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"CHR({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_ascii_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ASCII(text)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"ASCII({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_octet_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``OCTET_LENGTH(text)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"OCTET_LENGTH({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_bit_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``BIT_LENGTH(text)``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"BIT_LENGTH({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + # -- nodes with declared operands, rendered by name ----------------------- + + def format_to_char_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TO_CHAR(value[, format])`` -- text. + + The *format* operand is optional and its absence is the difference + between a backend's default rendering and an explicit mask, so it is + dropped from the SQL rather than sent as NULL. + """ + operands = [expr.target] + if expr.format is not None: + operands.append(expr.format) + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"TO_CHAR({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_to_number_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TO_NUMBER(value[, format])`` -- a number.""" + operands = [expr.target] + if expr.format is not None: + operands.append(expr.format) + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"TO_NUMBER({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_to_date_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``TO_DATE(value[, format])`` -- a **date**. + + Not a timestamp: every backend's ``TO_DATE`` answers with a calendar + date, and claiming a timestamp would offer time-of-day operations on a + value that has none. + """ + operands = [expr.target] + if expr.format is not None: + operands.append(expr.format) + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"TO_DATE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_unnest_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``UNNEST(array)`` -- one row per element.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"UNNEST({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_array_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``ARRAY_LENGTH(array[, dimension])`` -- a count.""" + operands = [expr.target] + if expr.dimension is not None: + operands.append(expr.dimension) + parts_sql, parts_params = [], [] + for operand in operands: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"ARRAY_LENGTH({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_json_build_object_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``JSON_BUILD_OBJECT(key, value, ...)`` -- pairs, in order.""" + parts_sql, parts_params = [], [] + for operand in expr.pairs: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"JSON_BUILD_OBJECT({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_json_array_elements_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Render ``JSON_ARRAY_ELEMENTS(json)`` -- the array's elements, one per row.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"JSON_ARRAY_ELEMENTS({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + class FunctionMixin: """Format SQL/PSM function DDL statements (CREATE FUNCTION / DROP FUNCTION). @@ -186,17 +978,17 @@ def supports_functions(self) -> Dict[str, bool]: """ return {} - def format_create_function_statement(self, expr: "CreateFunctionExpression") -> Tuple[str, tuple]: + def format_create_function_statement(self, expr: "CreateFunctionStatement") -> Tuple[str, tuple]: """Format a CREATE FUNCTION statement per SQL/PSM. Args: - expr: The CreateFunctionExpression to render. + expr: The CreateFunctionStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``CreateFunctionExpression.function`` is not a Function. + TypeError: ``CreateFunctionStatement.function`` is not a Function. Another object kind would have had its own name rendered as the function's. UnsupportedFeatureError: If the dialect does not support functions. @@ -205,7 +997,7 @@ def format_create_function_statement(self, expr: "CreateFunctionExpression") -> """ if not isinstance(expr.function, Function): raise TypeError( - f"CreateFunctionExpression.function must be a Function, " + f"CreateFunctionStatement.function must be a Function, " f"got {type(expr.function).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -262,24 +1054,24 @@ def format_create_function_statement(self, expr: "CreateFunctionExpression") -> return " ".join(parts), () - def format_drop_function_statement(self, expr: "DropFunctionExpression") -> Tuple[str, tuple]: + def format_drop_function_statement(self, expr: "DropFunctionStatement") -> Tuple[str, tuple]: """Format a DROP FUNCTION statement per SQL/PSM. Args: - expr: The DropFunctionExpression to render. + expr: The DropFunctionStatement to render. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``DropFunctionExpression.function`` is not a Function. + TypeError: ``DropFunctionStatement.function`` is not a Function. Another object kind would have had its own name rendered as the function's. UnsupportedFeatureError: If the dialect does not support functions. """ if not isinstance(expr.function, Function): raise TypeError( - f"DropFunctionExpression.function must be a Function, " + f"DropFunctionStatement.function must be a Function, " f"got {type(expr.function).__name__}" ) from ..exceptions import UnsupportedFeatureError diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/graph.py b/src/rhosocial/activerecord/backend/dialect/mixins/graph.py index 0ce49c841..28483f2ca 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/graph.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/graph.py @@ -22,10 +22,10 @@ ColumnsClause, TablePropertiesClause, VertexTable, - EdgeTable, - CreatePropertyGraphExpression, - DropPropertyGraphExpression, - AlterPropertyGraphExpression, + EdgeTableDefinition, + CreatePropertyGraphStatement, + DropPropertyGraphStatement, + AlterPropertyGraphStatement, ) @@ -502,16 +502,19 @@ def format_vertex_table(self, vt: "VertexTable") -> Tuple[str, tuple]: if vt.alias: parts.append(f"AS {self.format_identifier(vt.alias)}") + # KEY before LABEL: that is the order the grammar wants, and the + # other order is a parse error rather than a wrong answer -- + # "missing right parenthesis", with the statement rejected whole. + if vt.key_columns: + keys = ", ".join(self.format_identifier(k) for k in vt.key_columns) + parts.append(f"KEY ({keys})") + if vt.labels: labels = " ".join( f"LABEL {self.format_identifier(label)}" for label in vt.labels ) parts.append(labels) - if vt.key_columns: - keys = ", ".join(self.format_identifier(k) for k in vt.key_columns) - parts.append(f"KEY ({keys})") - if vt.properties is not None: sql, _ = vt.properties.to_sql() parts.append(sql) @@ -519,11 +522,11 @@ def format_vertex_table(self, vt: "VertexTable") -> Tuple[str, tuple]: sql = " ".join(parts) return sql, () - def format_edge_table(self, et: "EdgeTable") -> Tuple[str, tuple]: + def format_edge_table_definition(self, et: "EdgeTableDefinition") -> Tuple[str, tuple]: """Format an edge table definition for CREATE PROPERTY GRAPH. Args: - et: EdgeTable object exposing table, optional alias, key columns, + et: EdgeTableDefinition object exposing table, optional alias, key columns, source/destination keys (with optional references), labels and properties. @@ -531,14 +534,14 @@ def format_edge_table(self, et: "EdgeTable") -> Tuple[str, tuple]: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``EdgeTable.table`` is not an EdgeTableObject. Another object + TypeError: ``EdgeTableDefinition.table`` is not an EdgeTableObject. Another object kind would have had its own name rendered as the edge table's. UnsupportedFeatureError: If the dialect does not support property graph tables. """ if not isinstance(et.table, EdgeTableObject): raise TypeError( - f"EdgeTable.table must be an EdgeTableObject, " + f"EdgeTableDefinition.table must be an EdgeTableObject, " f"got {type(et.table).__name__}" ) if not self.supports_graph_table(): @@ -583,18 +586,18 @@ def format_edge_table(self, et: "EdgeTable") -> Tuple[str, tuple]: sql = " ".join(parts) return sql, () - def format_create_property_graph_statement(self, expr: "CreatePropertyGraphExpression") -> Tuple[str, tuple]: + def format_create_property_graph_statement(self, expr: "CreatePropertyGraphStatement") -> Tuple[str, tuple]: """Format a CREATE PROPERTY GRAPH DDL statement. Args: - expr: CreatePropertyGraphExpression exposing graph name, + expr: CreatePropertyGraphStatement exposing graph name, ``if_not_exists``, vertex tables and edge tables. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``CreatePropertyGraphExpression.graph`` is not a + TypeError: ``CreatePropertyGraphStatement.graph`` is not a PropertyGraph. Another object kind would have had its own name rendered as the graph's. UnsupportedFeatureError: If the dialect does not support property @@ -602,7 +605,7 @@ def format_create_property_graph_statement(self, expr: "CreatePropertyGraphExpre """ if not isinstance(expr.graph, PropertyGraph): raise TypeError( - f"CreatePropertyGraphExpression.graph must be a PropertyGraph, " + f"CreatePropertyGraphStatement.graph must be a PropertyGraph, " f"got {type(expr.graph).__name__}" ) if not self.supports_graph_table(): @@ -629,25 +632,25 @@ def format_create_property_graph_statement(self, expr: "CreatePropertyGraphExpre return " ".join(parts), () - def format_drop_property_graph_statement(self, expr: "DropPropertyGraphExpression") -> Tuple[str, tuple]: + def format_drop_property_graph_statement(self, expr: "DropPropertyGraphStatement") -> Tuple[str, tuple]: """Format a DROP PROPERTY GRAPH DDL statement. Args: - expr: DropPropertyGraphExpression exposing graph name, + expr: DropPropertyGraphStatement exposing graph name, ``if_exists`` and ``cascade``. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``DropPropertyGraphExpression.graph`` is not a PropertyGraph. + TypeError: ``DropPropertyGraphStatement.graph`` is not a PropertyGraph. Another object kind would have had its own name rendered as the graph's. UnsupportedFeatureError: If the dialect does not support property graph tables. """ if not isinstance(expr.graph, PropertyGraph): raise TypeError( - f"DropPropertyGraphExpression.graph must be a PropertyGraph, " + f"DropPropertyGraphStatement.graph must be a PropertyGraph, " f"got {type(expr.graph).__name__}" ) if not self.supports_graph_table(): @@ -679,18 +682,18 @@ def _normalize_alter_keyword(value: object, allowed: Mapping[str, str], label: s return normalized raise ValueError(f"Invalid ALTER PROPERTY GRAPH {label}: {value!r}") - def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphExpression") -> Tuple[str, tuple]: + def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphStatement") -> Tuple[str, tuple]: """Format an ALTER PROPERTY GRAPH DDL statement. Args: - expr: AlterPropertyGraphExpression exposing graph name, action, + expr: AlterPropertyGraphStatement exposing graph name, action, target, vertex tables and edge tables. Returns: A ``(sql, params)`` tuple; ``params`` is always empty. Raises: - TypeError: ``AlterPropertyGraphExpression.graph`` is not a + TypeError: ``AlterPropertyGraphStatement.graph`` is not a PropertyGraph. Another object kind would have had its own name rendered as the graph's. UnsupportedFeatureError: If the dialect does not support property @@ -698,18 +701,18 @@ def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphExpress """ if not isinstance(expr.graph, PropertyGraph): raise TypeError( - f"AlterPropertyGraphExpression.graph must be a PropertyGraph, " + f"AlterPropertyGraphStatement.graph must be a PropertyGraph, " f"got {type(expr.graph).__name__}" ) if not self.supports_graph_table(): raise UnsupportedFeatureError(self.name, "ALTER PROPERTY GRAPH") - from ...expression.graph import AlterPropertyGraphExpression, EdgeTable, VertexTable + from ...expression.graph import AlterPropertyGraphStatement, EdgeTableDefinition, VertexTable - if not isinstance(expr, AlterPropertyGraphExpression): + if not isinstance(expr, AlterPropertyGraphStatement): raise TypeError( "ALTER PROPERTY GRAPH expression must be an " - "AlterPropertyGraphExpression" + "AlterPropertyGraphStatement" ) action = self._normalize_alter_keyword(expr.action, self._ALTER_ACTIONS, "action") target = self._normalize_alter_keyword(expr.target, self._ALTER_TARGETS, "target") @@ -726,8 +729,8 @@ def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphExpress raise ValueError("EDGE TABLES cannot contain vertex tables") if any(not isinstance(table, VertexTable) for table in vertex_tables): raise TypeError("ALTER PROPERTY GRAPH vertex tables must be VertexTable instances") - if any(not isinstance(table, EdgeTable) for table in edge_tables): - raise TypeError("ALTER PROPERTY GRAPH edge tables must be EdgeTable instances") + if any(not isinstance(table, EdgeTableDefinition) for table in edge_tables): + raise TypeError("ALTER PROPERTY GRAPH edge tables must be EdgeTableDefinition instances") parts = [ "ALTER PROPERTY GRAPH", diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/ilike.py b/src/rhosocial/activerecord/backend/dialect/mixins/ilike.py index 1039d4a5e..7eb4f7c4c 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/ilike.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/ilike.py @@ -9,14 +9,14 @@ from ...expression.bases import ToSQLProtocol if TYPE_CHECKING: # pragma: no cover - from ...expression.predicates import ILIKEExpression + from ...expression.predicates import ILikePredicate class ILIKEMixin: """Mixin for ILIKE (case-insensitive LIKE) support. Dialects with a native ILIKE operator override - :meth:`format_ilike_expression`; the default emits a LOWER()-based + :meth:`format_ilike_predicate`; the default emits a LOWER()-based comparison. """ @@ -24,7 +24,7 @@ def supports_ilike(self) -> bool: """Whether ILIKE operator is supported. Defaults to False.""" return False - def format_ilike_expression(self, expr: "ILIKEExpression") -> Tuple[str, Tuple]: + def format_ilike_predicate(self, expr: "ILikePredicate") -> Tuple[str, Tuple]: """Format an ILIKE expression (case-insensitive pattern matching). The default implementation uses LOWER() so it works on dialects without @@ -32,7 +32,7 @@ def format_ilike_expression(self, expr: "ILIKEExpression") -> Tuple[str, Tuple]: native ILIKE support (e.g. PostgreSQL). Args: - expr: The :class:`ILIKEExpression` node to render. The node stores + expr: The :class:`ILikePredicate` node to render. The node stores its own ``column``, ``pattern`` and ``negate`` data; the formatter takes no extra arguments so it can be dispatched uniformly as ``formatter(expr)``. diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/introspection.py b/src/rhosocial/activerecord/backend/dialect/mixins/introspection.py index eee9795f8..4707a2b2a 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/introspection.py @@ -12,16 +12,16 @@ if TYPE_CHECKING: # pragma: no cover from ...introspection.types import IntrospectionScope from ...expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, ) @@ -128,7 +128,7 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== - 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. @@ -143,9 +143,9 @@ def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[st """ if not self.supports_database_info(): raise UnsupportedFeatureError(self.name, "database info query") - raise NotImplementedError("Subclass must implement format_database_info_query") + raise NotImplementedError("Subclass must implement format_database_info_query_statement") - 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. @@ -160,9 +160,9 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup """ if not self.supports_table_introspection(): raise UnsupportedFeatureError(self.name, "table introspection") - raise NotImplementedError("Subclass must implement format_table_list_query") + raise NotImplementedError("Subclass must implement format_table_list_query_statement") - def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tuple]: + def format_table_info_query_statement(self, expr: "TableInfoQueryStatement") -> Tuple[str, tuple]: """ Format single table information query. @@ -177,9 +177,9 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup """ if not self.supports_table_introspection(): raise UnsupportedFeatureError(self.name, "table introspection") - raise NotImplementedError("Subclass must implement format_table_info_query") + raise NotImplementedError("Subclass must implement format_table_info_query_statement") - 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. @@ -194,9 +194,9 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t """ if not self.supports_column_introspection(): raise UnsupportedFeatureError(self.name, "column introspection") - raise NotImplementedError("Subclass must implement format_column_info_query") + raise NotImplementedError("Subclass must implement format_column_info_query_statement") - 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. @@ -211,9 +211,9 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup """ if not self.supports_index_introspection(): raise UnsupportedFeatureError(self.name, "index introspection") - raise NotImplementedError("Subclass must implement format_index_info_query") + raise NotImplementedError("Subclass must implement format_index_info_query_statement") - 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. @@ -228,9 +228,9 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t """ if not self.supports_foreign_key_introspection(): raise UnsupportedFeatureError(self.name, "foreign key introspection") - raise NotImplementedError("Subclass must implement format_foreign_key_query") + raise NotImplementedError("Subclass must implement format_foreign_key_query_statement") - 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. @@ -245,9 +245,9 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple """ if not self.supports_view_introspection(): raise UnsupportedFeatureError(self.name, "view introspection") - raise NotImplementedError("Subclass must implement format_view_list_query") + raise NotImplementedError("Subclass must implement format_view_list_query_statement") - 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. @@ -262,9 +262,9 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple """ if not self.supports_view_introspection(): raise UnsupportedFeatureError(self.name, "view introspection") - raise NotImplementedError("Subclass must implement format_view_info_query") + raise NotImplementedError("Subclass must implement format_view_info_query_statement") - 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. @@ -279,9 +279,9 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, """ if not self.supports_trigger_introspection(): raise UnsupportedFeatureError(self.name, "trigger introspection") - raise NotImplementedError("Subclass must implement format_trigger_list_query") + raise NotImplementedError("Subclass must implement format_trigger_list_query_statement") - def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, tuple]: + def format_trigger_info_query_statement(self, expr: "TriggerInfoQueryStatement") -> Tuple[str, tuple]: """ Format single trigger information query. @@ -296,7 +296,7 @@ def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, """ if not self.supports_trigger_introspection(): raise UnsupportedFeatureError(self.name, "trigger introspection") - raise NotImplementedError("Subclass must implement format_trigger_info_query") + raise NotImplementedError("Subclass must implement format_trigger_info_query_statement") class AsyncIntrospectionMixin: @@ -399,7 +399,7 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== - 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. @@ -414,9 +414,9 @@ def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[st """ if not self.supports_database_info(): raise UnsupportedFeatureError(self.name, "database info query") - raise NotImplementedError("Subclass must implement format_database_info_query") + raise NotImplementedError("Subclass must implement format_database_info_query_statement") - 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. @@ -431,9 +431,9 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup """ if not self.supports_table_introspection(): raise UnsupportedFeatureError(self.name, "table introspection") - raise NotImplementedError("Subclass must implement format_table_list_query") + raise NotImplementedError("Subclass must implement format_table_list_query_statement") - def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tuple]: + def format_table_info_query_statement(self, expr: "TableInfoQueryStatement") -> Tuple[str, tuple]: """ Format single table information query. @@ -448,9 +448,9 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup """ if not self.supports_table_introspection(): raise UnsupportedFeatureError(self.name, "table introspection") - raise NotImplementedError("Subclass must implement format_table_info_query") + raise NotImplementedError("Subclass must implement format_table_info_query_statement") - 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. @@ -465,9 +465,9 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t """ if not self.supports_column_introspection(): raise UnsupportedFeatureError(self.name, "column introspection") - raise NotImplementedError("Subclass must implement format_column_info_query") + raise NotImplementedError("Subclass must implement format_column_info_query_statement") - 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. @@ -482,9 +482,9 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup """ if not self.supports_index_introspection(): raise UnsupportedFeatureError(self.name, "index introspection") - raise NotImplementedError("Subclass must implement format_index_info_query") + raise NotImplementedError("Subclass must implement format_index_info_query_statement") - 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. @@ -499,9 +499,9 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t """ if not self.supports_foreign_key_introspection(): raise UnsupportedFeatureError(self.name, "foreign key introspection") - raise NotImplementedError("Subclass must implement format_foreign_key_query") + raise NotImplementedError("Subclass must implement format_foreign_key_query_statement") - 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. @@ -516,9 +516,9 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple """ if not self.supports_view_introspection(): raise UnsupportedFeatureError(self.name, "view introspection") - raise NotImplementedError("Subclass must implement format_view_list_query") + raise NotImplementedError("Subclass must implement format_view_list_query_statement") - 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. @@ -533,9 +533,9 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple """ if not self.supports_view_introspection(): raise UnsupportedFeatureError(self.name, "view introspection") - raise NotImplementedError("Subclass must implement format_view_info_query") + raise NotImplementedError("Subclass must implement format_view_info_query_statement") - 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. @@ -550,9 +550,9 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, """ if not self.supports_trigger_introspection(): raise UnsupportedFeatureError(self.name, "trigger introspection") - raise NotImplementedError("Subclass must implement format_trigger_list_query") + raise NotImplementedError("Subclass must implement format_trigger_list_query_statement") - def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, tuple]: + def format_trigger_info_query_statement(self, expr: "TriggerInfoQueryStatement") -> Tuple[str, tuple]: """ Format single trigger information query. @@ -567,4 +567,4 @@ def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, """ if not self.supports_trigger_introspection(): raise UnsupportedFeatureError(self.name, "trigger introspection") - raise NotImplementedError("Subclass must implement format_trigger_info_query") + raise NotImplementedError("Subclass must implement format_trigger_info_query_statement") diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/join.py b/src/rhosocial/activerecord/backend/dialect/mixins/join.py index 04bbb6a6e..e057eb945 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/join.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/join.py @@ -4,11 +4,14 @@ Declares support for the various JOIN families and formats JOIN, LATERAL JOIN, and table-function source expressions into dialect SQL. """ -from typing import Tuple, TYPE_CHECKING -from ..exceptions import UnsupportedFeatureError +from typing import TYPE_CHECKING, Tuple + +from rhosocial.activerecord.backend.expression.query_parts import JoinType from rhosocial.activerecord.backend.expression.query_sources import LateralExpression +from ..exceptions import UnsupportedFeatureError + if TYPE_CHECKING: # pragma: no cover from ...expression import bases from ...expression.query_parts import JoinClause @@ -48,10 +51,11 @@ def format_lateral_expression(self, expr: "LateralExpression") -> Tuple[str, tup "Restructure the query with a plain subquery or a CTE instead.", ) expr_sql, expr_params = expr.expression.to_sql() + join_phrase = expr.join_type.phrase if expr.alias is not None: - sql = f"{expr.join_type.upper()} JOIN LATERAL {expr_sql} AS {self.format_identifier(expr.alias)}" + sql = f"{join_phrase} LATERAL {expr_sql} AS {self.format_identifier(expr.alias)}" else: - sql = f"{expr.join_type.upper()} JOIN LATERAL {expr_sql}" + sql = f"{join_phrase} LATERAL {expr_sql}" return sql, expr_params def format_table_function_expression(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: @@ -140,24 +144,26 @@ def format_join_clause(self, join_expr: "JoinClause") -> Tuple[str, Tuple]: ValueError: If a non-CROSS join has neither a condition nor a USING clause. """ - from ...expression import QueryExpression, JoinClause - - join_type_upper = join_expr.join_type.upper() - - # Use split to handle cases like "LEFT OUTER JOIN" - join_type_keyword = join_type_upper.split()[0] - - # Check for feature support - if join_type_keyword == "INNER" and not self.supports_inner_join(): - raise UnsupportedFeatureError(self.name, "INNER JOIN") - elif join_type_keyword == "LEFT" and not self.supports_left_join(): - raise UnsupportedFeatureError(self.name, "LEFT JOIN") - elif join_type_keyword == "RIGHT" and not self.supports_right_join(): - raise UnsupportedFeatureError(self.name, "RIGHT JOIN") - elif join_type_keyword == "FULL" and not self.supports_full_join(): - raise UnsupportedFeatureError(self.name, "FULL JOIN") - elif join_type_keyword == "CROSS" and not self.supports_cross_join(): - raise UnsupportedFeatureError(self.name, "CROSS JOIN") + from ...expression import JoinClause, QueryStatement + + # The node carries the join *word*; the SQL phrase is the enum's, so the + # artifact never spells a join. The capability is read off the member, + # not off parsed text: LEFT_OUTER needs exactly what LEFT needs, and + # splitting "LEFT OUTER JOIN" on a space to rediscover that was how the + # two could drift apart. STRAIGHT_JOIN is not probed -- it is MySQL-only, + # so the backend that defines it is also the only one that would refuse. + join_type = join_expr.join_type + + if join_type in (JoinType.INNER, JoinType.JOIN) and not self.supports_inner_join(): + raise UnsupportedFeatureError(self.name, join_type.phrase) + elif join_type in (JoinType.LEFT, JoinType.LEFT_OUTER) and not self.supports_left_join(): + raise UnsupportedFeatureError(self.name, join_type.phrase) + elif join_type in (JoinType.RIGHT, JoinType.RIGHT_OUTER) and not self.supports_right_join(): + raise UnsupportedFeatureError(self.name, join_type.phrase) + elif join_type in (JoinType.FULL, JoinType.FULL_OUTER) and not self.supports_full_join(): + raise UnsupportedFeatureError(self.name, join_type.phrase) + elif join_type is JoinType.CROSS and not self.supports_cross_join(): + raise UnsupportedFeatureError(self.name, join_type.phrase) if join_expr.natural and not self.supports_natural_join(): raise UnsupportedFeatureError(self.name, "NATURAL JOIN") @@ -166,12 +172,12 @@ def format_join_clause(self, join_expr: "JoinClause") -> Tuple[str, Tuple]: # Format left and right sides of the join left_sql, left_params = join_expr.left_table.to_sql() - if isinstance(join_expr.left_table, (QueryExpression, JoinClause)): + if isinstance(join_expr.left_table, (QueryStatement, JoinClause)): left_sql = f"({left_sql})" all_params.extend(left_params) right_sql, right_params = join_expr.right_table.to_sql() - if isinstance(join_expr.right_table, (QueryExpression, JoinClause)): + if isinstance(join_expr.right_table, (QueryStatement, JoinClause)): right_sql = f"({right_sql})" all_params.extend(right_params) @@ -181,7 +187,7 @@ def format_join_clause(self, join_expr: "JoinClause") -> Tuple[str, Tuple]: if join_expr.natural: join_parts.append("NATURAL") - join_parts.append(join_type_upper) + join_parts.append(join_type.phrase) join_parts.append(right_sql) # Handle ON or USING clause, which are not used with NATURAL JOIN @@ -193,8 +199,8 @@ def format_join_clause(self, join_expr: "JoinClause") -> Tuple[str, Tuple]: elif join_expr.using: using_cols = ", ".join(self.format_identifier(col) for col in join_expr.using) join_parts.append(f"USING ({using_cols})") - elif "CROSS" not in join_type_upper: - raise ValueError(f"{join_type_upper} requires a condition or USING clause.") + elif join_type is not JoinType.CROSS: + raise ValueError(f"{join_type.phrase} requires a condition or USING clause.") sql = " ".join(join_parts) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/json.py b/src/rhosocial/activerecord/backend/dialect/mixins/json.py index deae37566..470e1dede 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/json.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/json.py @@ -10,7 +10,7 @@ from ...expression import bases if TYPE_CHECKING: # pragma: no cover - from ...expression.advanced_functions import JSONDocumentExpression + from ...expression.advanced_functions import JSONDocumentValue class JSONMixin: @@ -72,14 +72,14 @@ def supports_json_table(self) -> bool: # Arrow-operator formatting (-> / ->>) # ------------------------------------------------------------------ - def format_json_arrow_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_arrow_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """Format JSON expression using arrow operators (-> / ->>). This method always uses arrow operator syntax. If the dialect does not support arrow operators, it raises UnsupportedFeatureError. Args: - expr: The JSONDocumentExpression node with column, path, operation, and + expr: The JSONDocumentValue node with column, path, operation, and optional alias. Returns: @@ -101,7 +101,7 @@ def format_json_arrow_expression(self, expr: "JSONDocumentExpression") -> Tuple[ 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) sql = f"{col_sql}{expr.operation}'{escaped_path}'" params = col_params @@ -114,7 +114,7 @@ def format_json_arrow_expression(self, expr: "JSONDocumentExpression") -> Tuple[ # Function-based formatting (JSON_EXTRACT / JSON_UNQUOTE etc.) # ------------------------------------------------------------------ - def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_function_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """Format JSON expression using function-based equivalents. Default implementation uses JSON_EXTRACT for -> and @@ -124,7 +124,7 @@ def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tup method to provide the correct function-based SQL. Args: - expr: The JSONDocumentExpression node with column, path, operation, and + expr: The JSONDocumentValue node with column, path, operation, and optional alias. Returns: @@ -135,7 +135,7 @@ def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tup 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}')" @@ -156,7 +156,7 @@ def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tup # Dispatch entry point # ------------------------------------------------------------------ - def format_json_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """Format JSON expression, dispatching on mode and capability. Dispatches to arrow-operator or function-based formatting depending @@ -169,7 +169,7 @@ def format_json_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, T The default mode is ``JSONPathMode.AUTO``. Args: - expr: The JSONDocumentExpression node to format. + expr: The JSONDocumentValue node to format. Returns: Tuple of (SQL string, parameters tuple) for the expression. @@ -216,7 +216,7 @@ def format_json_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, T # # A dialect that forces ARROW on a backend without arrows still gets an # error, from format_json_arrow_expression below. - mode: JSONPathMode = getattr(expr, "mode", JSONPathMode.AUTO) + mode: JSONPathMode = expr.mode if mode is JSONPathMode.ARROW: return self.format_json_arrow_expression(expr) @@ -252,16 +252,15 @@ def format_json_table_expression(self, expr: "bases.BaseExpression") -> Tuple[st else: json_col_sql, params = self.format_identifier(str(expr.json_column)), () - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) cols_defs = [] for col in expr.columns: col_name = self.format_identifier(col.name) - if isinstance(col.data_type, _bases.BaseExpression): - col_type, _ = col.data_type.to_sql() - else: - col_type = str(col.data_type) - col_path = self._escape_sql_string(col.path) + from ...expression.types import DataType + + col_type, _ = DataType.as_type(self, col.data_type).to_sql() + col_path = self.escape_sql_string(col.path) cols_defs.append(f"{col_name} {col_type} PATH '{col_path}'") columns_sql = f"COLUMNS({', '.join(cols_defs)})" diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/merge.py b/src/rhosocial/activerecord/backend/dialect/mixins/merge.py index 7e41d2d09..105b24147 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/merge.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/merge.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover - from ...expression.statements import MergeExpression + from ...expression.statements import MergeStatement class MergeMixin: @@ -69,23 +69,23 @@ def format_merge_action(self, expr: "MergeAction") -> Tuple[str, tuple]: parts.append("THEN INSERT DEFAULT VALUES") return " ".join(parts), tuple(all_params) - def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: + def format_merge_statement(self, expr: "MergeStatement") -> Tuple[str, tuple]: """Format a MERGE statement into dialect SQL. Args: - expr: The MergeExpression node with target table, source, ON + expr: The MergeStatement node with target table, source, ON condition, and the matched/not-matched action lists. Returns: Tuple of (SQL string, parameters tuple) for the statement. Raises: - TypeError: ``MergeExpression.target_table`` is not a Table. Another + TypeError: ``MergeStatement.target_table`` is not a Table. Another object kind would have had its own name rendered as the merge target. """ if not isinstance(expr.target_table, Table): raise TypeError( - f"MergeExpression.target_table must be a Table, " + f"MergeStatement.target_table must be a Table, " f"got {type(expr.target_table).__name__}" ) all_params: List[Any] = [] diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/pivot.py b/src/rhosocial/activerecord/backend/dialect/mixins/pivot.py index 7b4f68f10..83ecd57e0 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/pivot.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/pivot.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.bases import BaseExpression - from ...expression.pivot import PivotExpression, UnpivotExpression + from ...expression.pivot import Pivot, Unpivot class PivotMixin: @@ -42,7 +42,7 @@ def format_pivot_value(self, value: "Union[BaseExpression, str, int, float]") -> return sql, tuple(params) return self.format_literal(value), () - def format_pivot_expression(self, expr: "PivotExpression") -> Tuple[str, tuple]: + def format_pivot(self, expr: "Pivot") -> Tuple[str, tuple]: """Format a PIVOT clause. Raises: @@ -75,7 +75,7 @@ def format_pivot_expression(self, expr: "PivotExpression") -> Tuple[str, tuple]: return sql, tuple(all_params) - def format_unpivot_expression(self, expr: "UnpivotExpression") -> Tuple[str, tuple]: + def format_unpivot(self, expr: "Unpivot") -> Tuple[str, tuple]: """Format an UNPIVOT clause. Raises: diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/predicate.py b/src/rhosocial/activerecord/backend/dialect/mixins/predicate.py index 3128c56d9..be3a326b3 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/predicate.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/predicate.py @@ -4,9 +4,11 @@ Renders comparison, logical, membership, null/boolean, existence, quantified, and pattern predicates into ``(sql, params)`` tuples. """ -from typing import Any, List, Tuple, TYPE_CHECKING -from ...expression.advanced_functions import AllExpression, AnyExpression, ExistsExpression +from typing import TYPE_CHECKING, Any, List, Tuple + +from ...expression.advanced_functions import AllPredicate, AnyPredicate, ExistsPredicate +from ...expression.mixins import AliasableMixin from ...expression.predicates import ( BetweenPredicate, ComparisonPredicate, @@ -22,6 +24,33 @@ pass +#: How tightly each connective binds, in SQL's own order: ``NOT`` > ``AND`` > ``OR``. +#: A bigger number binds tighter. Used only to decide whether an operand needs +#: brackets; an operand that is not a logical connective binds tightest of all. +_CONNECTIVE_PRECEDENCE = {"OR": 1, "AND": 2, "NOT": 3} + +#: What a non-connective predicate binds like -- tighter than any connective, so +#: it is never bracketed. Named rather than inlined so the comparison reads. +_BINDS_TIGHTEST = 4 + + +def _connective_precedence(predicate: Any) -> int: + """Return how loosely *predicate* binds, for bracket decisions. + + Args: + predicate: An operand of a logical predicate. + + Returns: + The operand's connective precedence, or :data:`_BINDS_TIGHTEST` for + anything that is not a logical connective. An unknown connective is + treated as binding tightest, which keeps the previous rendering for + operators this layer does not model. + """ + if isinstance(predicate, LogicalPredicate): + return _CONNECTIVE_PRECEDENCE.get(str(predicate.op).upper(), _BINDS_TIGHTEST) + return _BINDS_TIGHTEST + + class PredicateMixin: """Mixin for formatting SQL predicate expressions. @@ -47,7 +76,7 @@ def _with_alias(self, sql: str, expr: Any) -> str: Returns: The SQL, with the alias appended when present. """ - alias = getattr(expr, "alias", None) + alias = expr.alias if isinstance(expr, AliasableMixin) else None if not alias: return sql return f"{sql} AS {self.format_identifier(alias)}" @@ -62,30 +91,57 @@ def format_comparison_predicate(self, expr: ComparisonPredicate) -> Tuple[str, t Returns: Tuple of (SQL string, parameters tuple). """ - from ...expression.statements import QueryExpression + from ...expression.statements import QueryStatement left_sql, left_params = expr.left.to_sql() right_sql, right_params = expr.right.to_sql() - if isinstance(expr.right, QueryExpression): + if isinstance(expr.right, QueryStatement): right_sql = f"({right_sql})" - return self._with_alias(f"{left_sql} {expr.op} {right_sql}", expr), left_params + right_params + # The artifact carries the operation *word*; the character is written + # here, so a dialect that spells it differently overrides this method + # rather than the wire format. + symbol = expr.op.symbol + return ( + self._with_alias(f"{left_sql} {symbol} {right_sql}", expr), + left_params + right_params, + ) def format_logical_predicate(self, expr: LogicalPredicate) -> Tuple[str, tuple]: - """Format a logical predicate (AND/OR/NOT). + """Format a logical predicate (AND/OR/NOT), bracketing where it matters. + + SQL binds ``NOT`` tighter than ``AND`` and ``AND`` tighter than ``OR``, + so a flat rendering of the operands keeps the tree's meaning only while + the nesting follows that order. ``a AND (b OR c)`` written flat becomes + ``a AND b OR c``, which is ``(a AND b) OR c`` -- a different predicate, + with no error to show for it. An operand whose connective binds looser + than its parent's is therefore bracketed; one that binds tighter (or + equally tight) is left alone, because brackets there would be noise and + would suggest the rule is "bracket every nesting". + + ``NOT`` already brackets its single operand, so it never needs another + pair and never needs to add one for a child. Args: - expr: Logical expression exposing ``op`` and ``predicates``. + expr: Logical expression exposing ``op`` and ``operands``. Returns: Tuple of (SQL string, parameters tuple). """ - if expr.op.upper() == "NOT" and len(expr.predicates) == 1: - sql, params = expr.predicates[0].to_sql() + op = expr.op.upper() + if op == "NOT" and len(expr.operands) == 1: + sql, params = expr.operands[0].to_sql() return self._with_alias(f"NOT ({sql})", expr), params + + parent_precedence = _CONNECTIVE_PRECEDENCE.get(op) parts = [] all_params: List[Any] = [] - for predicate in expr.predicates: + for predicate in expr.operands: sql, params = predicate.to_sql() + if ( + parent_precedence is not None + and _connective_precedence(predicate) < parent_precedence + ): + sql = f"({sql})" parts.append(sql) all_params.extend(params) return self._with_alias(f" {expr.op} ".join(parts), expr), tuple(all_params) @@ -93,72 +149,70 @@ def format_logical_predicate(self, expr: LogicalPredicate) -> Tuple[str, tuple]: def format_in_predicate(self, expr: InPredicate) -> Tuple[str, tuple]: """Format an ``IN`` predicate. + A ``Literal`` wrapping a collection renders as a value list; anything + else (e.g. a subquery) renders through its own ``to_sql()``. + + The whole ``Literal`` is honoured rather than just its ``value``: its + ``inline`` decision selects between inline rendering and bind + parameters. DDL positions (``CHECK``, partial-index ``WHERE``) set that + decision on the literals they hold, which is how ``IN`` becomes usable + inside a CHECK constraint. Inline rendering goes through + ``format_literal`` so escaping and quoting match every other inline + literal. + Args: - expr: Membership expression exposing ``expr`` and ``values``. + expr: Membership expression exposing ``operand`` and ``values``. Returns: Tuple of (SQL string, parameters tuple). """ from ...expression.core import Literal - expr_sql, expr_params = expr.expr.to_sql() - # A Literal wrapping a collection renders as a value list; anything - # else (e.g. a subquery) renders through its own to_sql(). + operand_sql, expr_params = expr.operand.to_sql() if isinstance(expr.values, Literal) and isinstance(expr.values.value, (list, tuple, set)): - return self._format_in_value_list(expr, expr_sql, expr_params, expr.values) + values = expr.values.value + if not values: + return self._with_alias(f"{operand_sql} IN ()", expr), expr_params + if expr.values.inline: + rendered = ", ".join(self.format_literal(value) for value in values) + return self._with_alias(f"{operand_sql} IN ({rendered})", expr), expr_params + placeholders = ", ".join([self.get_parameter_placeholder()] * len(values)) + return ( + self._with_alias(f"{operand_sql} IN ({placeholders})", expr), + expr_params + tuple(values), + ) values_sql, values_params = expr.values.to_sql() - return self._with_alias(f"{expr_sql} IN {values_sql}", expr), expr_params + values_params - - def _format_in_value_list( - self, expr: InPredicate, expr_sql: str, expr_params: tuple, literal - ) -> Tuple[str, Tuple]: - """Render ``IN (…)`` for a :class:`Literal` wrapping a collection. - - The whole ``Literal`` is passed in rather than just its ``value`` - because its ``inline_literals`` switch has to be honoured: DDL - preparation sets that flag on every literal it finds (PostgreSQL rejects - bind parameters in DDL), and dropping it here is the only reason ``IN`` - could not previously be used inside a CHECK constraint. Rendering goes - through ``format_literal`` so escaping and quoting match every other - inline literal. - """ - values = literal.value - if not values: - return self._with_alias(f"{expr_sql} IN ()", expr), expr_params - if literal.inline_literals: - rendered = ", ".join(self.format_literal(value) for value in values) - return self._with_alias(f"{expr_sql} IN ({rendered})", expr), expr_params - placeholders = ", ".join([self.get_parameter_placeholder()] * len(values)) - return self._with_alias(f"{expr_sql} IN ({placeholders})", expr), expr_params + tuple(values) + return self._with_alias(f"{operand_sql} IN {values_sql}", expr), expr_params + values_params def format_between_predicate(self, expr: BetweenPredicate) -> Tuple[str, tuple]: """Format a ``BETWEEN`` predicate. Args: - expr: Range expression exposing ``expr``, ``low``, and ``high``. + expr: Range expression exposing ``operand``, ``low``, and ``high``. Returns: Tuple of (SQL string, parameters tuple). """ - expr_sql, expr_params = expr.expr.to_sql() + operand_sql, expr_params = expr.operand.to_sql() low_sql, low_params = expr.low.to_sql() high_sql, high_params = expr.high.to_sql() - return self._with_alias( - f"{expr_sql} BETWEEN {low_sql} AND {high_sql}", expr - ), expr_params + low_params + high_params + return ( + self._with_alias(f"{operand_sql} BETWEEN {low_sql} AND {high_sql}", expr), + expr_params + low_params + high_params, + ) def format_is_null_predicate(self, expr: IsNullPredicate) -> Tuple[str, tuple]: """Format an ``IS [NOT] NULL`` predicate. Args: - expr: Null check expression exposing ``expr`` and ``is_not``. + expr: Null check expression exposing ``operand`` and ``is_not``. Returns: Tuple of (SQL string, parameters tuple). """ - expr_sql, expr_params = expr.expr.to_sql() + operand_sql, expr_params = expr.operand.to_sql() not_str = " NOT" if expr.is_not else "" - return self._with_alias(f"{expr_sql} IS{not_str} NULL", expr), expr_params + return self._with_alias(f"{operand_sql} IS{not_str} NULL", expr), expr_params def format_distinct_from_predicate(self, expr: DistinctFromPredicate) -> Tuple[str, tuple]: """Format a NULL-safe ``IS [NOT] DISTINCT FROM`` predicate. @@ -179,7 +233,10 @@ def format_distinct_from_predicate(self, expr: DistinctFromPredicate) -> Tuple[s left_sql, left_params = expr.left.to_sql() right_sql, right_params = expr.right.to_sql() not_str = " NOT" if expr.is_not else "" - return self._with_alias(f"{left_sql} IS{not_str} DISTINCT FROM {right_sql}", expr), left_params + right_params + return ( + self._with_alias(f"{left_sql} IS{not_str} DISTINCT FROM {right_sql}", expr), + left_params + right_params, + ) def format_is_boolean_predicate(self, expr: IsBooleanPredicate) -> Tuple[str, tuple]: """Format an ``IS [NOT] TRUE/FALSE`` predicate. @@ -196,7 +253,7 @@ def format_is_boolean_predicate(self, expr: IsBooleanPredicate) -> Tuple[str, tu bool_str = "TRUE" if expr.value else "FALSE" return self._with_alias(f"{expr_sql} IS{not_str} {bool_str}", expr), expr_params - def format_exists_expression(self, expr: ExistsExpression) -> Tuple[str, tuple]: + def format_exists_predicate(self, expr: ExistsPredicate) -> Tuple[str, tuple]: """Format an ``[NOT] EXISTS`` expression. Args: @@ -209,7 +266,7 @@ def format_exists_expression(self, expr: ExistsExpression) -> Tuple[str, tuple]: exists_clause = "NOT EXISTS" if expr.is_not else "EXISTS" return self._with_alias(f"{exists_clause} {subquery_sql}", expr), subquery_params - def format_any_expression(self, expr: AnyExpression) -> Tuple[str, tuple]: + def format_any_predicate(self, expr: AnyPredicate) -> Tuple[str, tuple]: """Format a quantified ``ANY`` comparison expression. Args: @@ -219,18 +276,22 @@ def format_any_expression(self, expr: AnyExpression) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, parameters tuple). """ + from ...expression.core import Literal + expr_sql, expr_params = expr.expr.to_sql() array_expr = expr.array_expr - if hasattr(array_expr, "value") and isinstance(array_expr.value, (list, tuple)): + # ``Literal`` is the node that carries a Python value; a list or tuple + # in it becomes one bind parameter rather than a rendered list. + if isinstance(array_expr, Literal) and isinstance(array_expr.value, (list, tuple)): array_sql = self.get_parameter_placeholder() array_params = (tuple(array_expr.value),) else: array_sql, array_params = array_expr.to_sql() - return self._with_alias( - f"({expr_sql} {expr.op} ANY{array_sql})", expr - ), tuple(list(expr_params) + list(array_params)) + return self._with_alias(f"({expr_sql} {expr.op} ANY{array_sql})", expr), tuple( + list(expr_params) + list(array_params) + ) - def format_all_expression(self, expr: AllExpression) -> Tuple[str, tuple]: + def format_all_predicate(self, expr: AllPredicate) -> Tuple[str, tuple]: """Format a quantified ``ALL`` comparison expression. Args: @@ -240,29 +301,41 @@ def format_all_expression(self, expr: AllExpression) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, parameters tuple). """ + from ...expression.core import Literal + expr_sql, expr_params = expr.expr.to_sql() array_expr = expr.array_expr - if hasattr(array_expr, "value") and isinstance(array_expr.value, (list, tuple)): + # ``Literal`` is the node that carries a Python value; a list or tuple + # in it becomes one bind parameter rather than a rendered list. + if isinstance(array_expr, Literal) and isinstance(array_expr.value, (list, tuple)): array_sql = self.get_parameter_placeholder() array_params = (tuple(array_expr.value),) else: array_sql, array_params = array_expr.to_sql() - return self._with_alias( - f"({expr_sql} {expr.op} ALL{array_sql})", expr - ), tuple(list(expr_params) + list(array_params)) + return self._with_alias(f"({expr_sql} {expr.op} ALL{array_sql})", expr), tuple( + list(expr_params) + list(array_params) + ) def format_like_predicate(self, expr: LikePredicate) -> Tuple[str, tuple]: """Format a ``LIKE`` predicate. Args: - expr: Pattern expression exposing ``expr``, ``op``, and + expr: Pattern expression exposing ``left``, ``op``, and ``pattern``. Returns: Tuple of (SQL string, parameters tuple). """ - expr_sql, expr_params = expr.expr.to_sql() + left_sql, expr_params = expr.left.to_sql() pattern_sql, pattern_params = expr.pattern.to_sql() - return self._with_alias( - f"{expr_sql} {expr.op} {pattern_sql}", expr - ), expr_params + pattern_params + # The keyword comes from the operator, exactly as the comparison symbol + # does: the node carries the word, the SQL spelling is written here. A + # dialect that spells NOT LIKE differently overrides this method rather + # than the wire format. + return ( + self._with_alias( + f"{left_sql} {expr.op.keyword} {pattern_sql}", + expr, + ), + expr_params + pattern_params, + ) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/relation_source.py b/src/rhosocial/activerecord/backend/dialect/mixins/relation_source.py index ca40039ce..9958b1ed3 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/relation_source.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/relation_source.py @@ -27,6 +27,7 @@ from ...expression.bases import BaseExpression from ...expression.objects import RelationObject +from ...expression.query_sources import JSONTableColumn if TYPE_CHECKING: # pragma: no cover from ...expression.sources import ( @@ -39,6 +40,7 @@ ValuesTableSource, XmlTableSource, ) + from ...expression.xml import XMLTableColumn __all__ = ["RelationSourceMixin"] @@ -59,8 +61,15 @@ def supports_values_table_constructor(self) -> bool: Not universal -- SQL Server has no ``VALUES`` table source -- so this defaults to False and a dialect opts in. Named for the SQL standard's - ``table value constructor``, which is the name MySQL and ClickHouse - already give this capability in their own probes. + ``table value constructor``, which is the name MySQL gives this + capability in its own probe. + + ClickHouse has a probe of the same name and answers False, and that + is the measured answer: ClickHouse has a ``values`` *table function*, + so ``SELECT * FROM VALUES(1, 2)`` is accepted there, but the SQL + standard's spelling -- ``VALUES (1), (2)`` -- is a syntax error + (26.7.3), and the framework renders the standard one. A different + shape is a different capability. """ return False @@ -101,7 +110,7 @@ def format_named_relation(self, ref: "NamedRelationRef") -> Tuple[str, tuple]: if ref.temporal_options: from ...expression.datetime import TemporalOptionsExpression - temporal = self.format_temporal_options( + temporal = self.format_temporal_options_expression( TemporalOptionsExpression(self, ref.temporal_options) ) if temporal is not None: @@ -302,7 +311,7 @@ def format_json_table(self, source: "JsonTableSource") -> Tuple[str, tuple]: """ self.check_feature_support("supports_json_table", "JSON_TABLE") document_sql, params = self.format_source_document(source.source) - path = self._escape_sql_string(source.path) + path = self.escape_sql_string(source.path) columns_sql, columns_params = self.format_source_columns( source.columns, self.format_json_table_column ) @@ -334,7 +343,7 @@ def format_source_columns(self, columns: List[Any], render_column: Any) -> Tuple all_params.extend(entry_params) return ", ".join(entries_sql), tuple(all_params) - def format_json_table_column(self, column: Any) -> Tuple[str, tuple]: + def format_json_table_column(self, column: "JSONTableColumn") -> Tuple[str, tuple]: """Render one entry of a JSON_TABLE ``COLUMNS`` list. Args: @@ -348,25 +357,27 @@ def format_json_table_column(self, column: Any) -> Tuple[str, tuple]: Raises: TypeError: *column* is not a usable column projection. """ - name = getattr(column, "name", None) - path = getattr(column, "path", None) - if name is None or path is None: + if not isinstance(column, JSONTableColumn): raise TypeError( - f"A JSON_TABLE column must expose a name and a path, " + f"A JSON_TABLE column must be a JSONTableColumn, " f"got {type(column).__name__}" ) + name = column.name + path = column.path type_sql, params = self.format_source_column_type(column) - escaped_path = self._escape_sql_string(path) + escaped_path = self.escape_sql_string(path) sql = ( f"{self.format_identifier(name)} {type_sql} PATH '{escaped_path}'" ) return sql, params - def format_source_column_type(self, column: Any) -> Tuple[str, tuple]: + def format_source_column_type(self, column: "JSONTableColumn | XMLTableColumn") -> Tuple[str, tuple]: """Render the declared result type of a projection column. A column's type is a clause of its own, so an expression type is asked - to render and a plain one is taken as written. + to render. A plain one is a name, and a name is claimed by the + framework's name node -- whose name the grammar validates, because a + type position cannot take a bound parameter. Args: column: A column projection exposing ``data_type``. @@ -378,15 +389,18 @@ def format_source_column_type(self, column: Any) -> Tuple[str, tuple]: Raises: TypeError: *column* declares no usable ``data_type``. """ - data_type = getattr(column, "data_type", None) + data_type = column.data_type if data_type is None: raise TypeError( - f"A projection column must expose a data_type, " + f"A {type(column).__name__} must declare a data_type, " f"got {type(column).__name__}" ) if isinstance(data_type, BaseExpression): return data_type.to_sql() - return str(data_type), () + from ...expression.types import DataType + + type_sql, type_params = DataType.as_type(self, data_type).to_sql() + return type_sql, type_params def format_xml_table(self, source: "XmlTableSource") -> Tuple[str, tuple]: """Render a :class:`~...expression.sources.XmlTableSource`. @@ -424,7 +438,7 @@ def format_xml_table(self, source: "XmlTableSource") -> Tuple[str, tuple]: binding_sql, binding_params = self.format_source_document(binding) parts.append(f"PASSING {binding_sql}") all_params.extend(binding_params) - row_path = self._escape_sql_string(source.row_path) + row_path = self.escape_sql_string(source.row_path) parts.append(f"PASSING '{row_path}'") columns_sql, columns_params = self.format_source_columns( source.columns, self.format_xml_table_column @@ -434,7 +448,7 @@ def format_xml_table(self, source: "XmlTableSource") -> Tuple[str, tuple]: sql = f"XMLTABLE({' '.join(parts)})" return self.format_source_alias(sql, source), tuple(all_params) - def format_xml_table_column(self, column: Any) -> Tuple[str, tuple]: + def format_xml_table_column(self, column: "XMLTableColumn") -> Tuple[str, tuple]: """Render one entry of an XMLTABLE ``COLUMNS`` list. Args: @@ -449,21 +463,18 @@ def format_xml_table_column(self, column: Any) -> Tuple[str, tuple]: Raises: TypeError: *column* exposes no usable ``name``. """ - name = getattr(column, "name", None) - if name is None: + if not isinstance(column, XMLTableColumn): raise TypeError( - f"An XMLTABLE column must expose a name, " + f"An XMLTABLE column must be an XMLTableColumn, " f"got {type(column).__name__}" ) + name = column.name type_sql, all_params = self.format_source_column_type(column) fragment = f"{self.format_identifier(name)} {type_sql}" - for attribute in ("path", "default"): - value = getattr(column, attribute, None) + for spelling, value in (("PATH", column.path), ("DEFAULT", column.default)): if value is None: continue value_sql, value_params = self.format_source_document(value) - fragment = f"{fragment} {attribute.upper()} {value_sql}" + fragment = f"{fragment} {spelling} {value_sql}" all_params += value_params - if getattr(column, "for_ordinality", False): - fragment = f"{fragment} FOR ORDINALITY" return fragment, all_params \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/set_operation.py b/src/rhosocial/activerecord/backend/dialect/mixins/set_operation.py index 21eca769d..b1afb2ef6 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/set_operation.py @@ -1,15 +1,28 @@ # src/rhosocial/activerecord/backend/dialect/mixins/set_operation.py """Set operation (UNION/INTERSECT/EXCEPT) formatting for the dialect layer. -Provides the default capability probes and SQL rendering for compound queries -that combine two query expressions. +Provides the capability probes and SQL rendering for compound queries that +combine two query sides. The node carries the operation as a **word form** +(``union`` / ``union_all`` / ``intersect`` / ``except``); this formatter maps +the word back to the SQL spelling and gates it behind the per-operation +probes. """ from typing import Tuple, TYPE_CHECKING -from ...expression import bases +from ...expression.query_sources import SetOperationKind if TYPE_CHECKING: # pragma: no cover - pass + from ...expression.query_sources import SetOperation + +#: Compound-SELECT word form -> the SQL keyword this dialect spells it with. +#: Keyed by the enum, so a word that is not one of the four cannot reach here +#: and a fifth operation cannot be added without saying what it renders as. +SET_OPERATION_KEYWORD = { + SetOperationKind.UNION: "UNION", + SetOperationKind.UNION_ALL: "UNION ALL", + SetOperationKind.INTERSECT: "INTERSECT", + SetOperationKind.EXCEPT: "EXCEPT", +} class SetOperationMixin: @@ -47,95 +60,38 @@ def supports_except(self) -> bool: """ return False - def supports_set_operation_order_by(self) -> bool: - """Whether set operations support ORDER BY clauses. - - Defaults to False; dialects that support it override this. - """ - return False - - def supports_set_operation_limit_offset(self) -> bool: - """Whether set operations support LIMIT and OFFSET clauses. - - Defaults to False; dialects that support it override this. - """ - return False - - def supports_set_operation_for_update(self) -> bool: - """Whether set operations support FOR UPDATE clauses. - - Defaults to False; dialects that support it override this. - """ - return False - - def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: - """Format a set operation expression (UNION, INTERSECT, EXCEPT). + def format_set_operation(self, expr: "SetOperation") -> Tuple[str, Tuple]: + """Format a set operation node (UNION, INTERSECT, EXCEPT). Args: - expr: Set operation expression exposing ``left``, ``right``, - ``operation``, ``all_``, ``alias``, ``order_by_clause``, - ``limit_offset_clause``, and ``for_update_clause``. + expr: The node exposing ``op`` (a word form), ``left`` and + ``right``. Returns: Tuple of (SQL string, parameters tuple). - """ - left, right = expr.left, expr.right - operation = expr.operation - all_ = expr.all_ - distinct = expr.distinct - alias = expr.alias - order_by_clause = expr.order_by_clause - limit_offset_clause = expr.limit_offset_clause - for_update_clause = expr.for_update_clause - left_sql, left_params = left.to_sql() - right_sql, right_params = right.to_sql() - if all_: - all_str = " ALL" - elif distinct: - all_str = " DISTINCT" - else: - all_str = "" - - # Build the base set operation SQL - base_sql = f"{left_sql} {operation}{all_str} {right_sql}" - - all_params = list(left_params + right_params) + Raises: + UnsupportedFeatureError: The dialect does not support the + requested operation (the per-operation probe said no). + """ + from ..exceptions import UnsupportedFeatureError + + probe = { + SetOperationKind.UNION: self.supports_union, + SetOperationKind.UNION_ALL: self.supports_union_all, + SetOperationKind.INTERSECT: self.supports_intersect, + SetOperationKind.EXCEPT: self.supports_except, + }[expr.op] + if not probe(): + raise UnsupportedFeatureError( + self.name, + f"{SET_OPERATION_KEYWORD[expr.op]} operation", + f"{self.name} does not support {SET_OPERATION_KEYWORD[expr.op]}.", + ) + left_sql, left_params = expr.left.to_sql() + right_sql, right_params = expr.right.to_sql() + base_sql = f"{left_sql} {SET_OPERATION_KEYWORD[expr.op]} {right_sql}" # Set operations at top level do not need outer parentheses. # When used as a subquery (e.g. in a FROM clause), the caller - # (Subquery / QueryExpression / dql.py) adds the required wrapping. - sql_parts = [base_sql] - - # Add alias if present - if alias: - sql_parts.append(f"AS {self.format_identifier(alias)}") - - # Add ORDER BY clause if present - if order_by_clause: - order_by_sql, order_by_params = order_by_clause.to_sql() - sql_parts.append(order_by_sql) - all_params.extend(order_by_params) - - # Add LIMIT/OFFSET clause if present - if limit_offset_clause: - limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() - sql_parts.append(limit_offset_sql) - all_params.extend(limit_offset_params) - - # Add FOR UPDATE clause if present - if for_update_clause: - if not self.supports_set_operation_for_update(): - from ..exceptions import UnsupportedFeatureError - - raise UnsupportedFeatureError( - self.name, - "FOR UPDATE in set operations", - "This dialect does not support FOR UPDATE clauses in a set " - "operation (UNION, INTERSECT, EXCEPT).", - ) - for_update_sql, for_update_params = for_update_clause.to_sql() - sql_parts.append(for_update_sql) - all_params.extend(for_update_params) - - sql = " ".join(sql_parts) - return sql, tuple(all_params) + # (Subquery / QueryStatement / dql.py) adds the required wrapping. + return base_sql, tuple(left_params + right_params) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/string_ops.py b/src/rhosocial/activerecord/backend/dialect/mixins/string_ops.py index 2eff646a6..037618fb9 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/string_ops.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/string_ops.py @@ -13,16 +13,16 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.advanced_functions import ( - LpadExpression, - RepeatExpression, - RpadExpression, + LpadFunction, + RepeatFunction, + RpadFunction, ) class RepeatMixin: """Mixin rendering ``REPEAT(expr, count)`` in the common spelling.""" - def format_repeat_expression(self, expr: "RepeatExpression") -> Tuple[str, tuple]: + def format_repeat_expression(self, expr: "RepeatFunction") -> Tuple[str, tuple]: """Format a repeat node as ``REPEAT(expr, count)``. Args: @@ -42,7 +42,7 @@ def format_repeat_expression(self, expr: "RepeatExpression") -> Tuple[str, tuple class LpadMixin: """Mixin rendering ``LPAD(expr, length, pad)`` in the common spelling.""" - def format_lpad_expression(self, expr: "LpadExpression") -> Tuple[str, tuple]: + def format_lpad_expression(self, expr: "LpadFunction") -> Tuple[str, tuple]: """Format a left-pad node as ``LPAD(expr, length, pad)``. Args: @@ -64,7 +64,7 @@ def format_lpad_expression(self, expr: "LpadExpression") -> Tuple[str, tuple]: class RpadMixin: """Mixin rendering ``RPAD(expr, length, pad)`` in the common spelling.""" - def format_rpad_expression(self, expr: "RpadExpression") -> Tuple[str, tuple]: + def format_rpad_expression(self, expr: "RpadFunction") -> Tuple[str, tuple]: """Format a right-pad node as ``RPAD(expr, length, pad)``. Args: diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/temporal.py b/src/rhosocial/activerecord/backend/dialect/mixins/temporal.py index 80ecee2c8..be2aa040a 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/temporal.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/temporal.py @@ -20,7 +20,7 @@ def supports_temporal_tables(self) -> bool: """ return False - def format_temporal_options(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: + def format_temporal_options_expression(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: """Format temporal table options. Args: @@ -35,7 +35,7 @@ def format_temporal_options(self, expr: "TemporalOptionsExpression") -> Tuple[st if not expr.options: raise ValueError( "Temporal options cannot be empty. If no temporal options are needed, " - "don't call format_temporal_options." + "don't call format_temporal_options_expression." ) sql_parts, params = ["FOR SYSTEM_TIME"], [] # Add temporal options to SQL parts based on the options provided diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/transaction.py b/src/rhosocial/activerecord/backend/dialect/mixins/transaction.py index 45dd97d66..5fd19d6e1 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/transaction.py @@ -8,41 +8,41 @@ if TYPE_CHECKING: from ...expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - ReleaseSavepointExpression, - RollbackTransactionExpression, - SavepointExpression, - SetTransactionExpression, + BeginTransactionStatement, + CommitStatement, + ReleaseSavepointStatement, + RollbackStatement, + SavepointStatement, + SetTransactionStatement, ) class TransactionControlMixin: """Mixin providing transaction control statement formatting.""" - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format a BEGIN transaction statement. Args: - expr: Begin transaction expression. + expr: The BEGIN TRANSACTION statement. Returns: Tuple of (SQL string, parameters tuple). """ return "BEGIN", () - def format_commit_transaction(self, expr: "CommitTransactionExpression") -> Tuple[str, tuple]: + def format_commit_statement(self, expr: "CommitStatement") -> Tuple[str, tuple]: """Format a COMMIT transaction statement. Args: - expr: Commit transaction expression. + expr: The COMMIT statement. Returns: Tuple of (SQL string, parameters tuple). """ return "COMMIT", () - def format_rollback_transaction(self, expr: "RollbackTransactionExpression") -> Tuple[str, tuple]: + def format_rollback_statement(self, expr: "RollbackStatement") -> Tuple[str, tuple]: """Format a ROLLBACK statement, optionally to a savepoint. Args: @@ -58,7 +58,7 @@ def format_rollback_transaction(self, expr: "RollbackTransactionExpression") -> return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () return "ROLLBACK", () - def format_savepoint(self, expr: "SavepointExpression") -> Tuple[str, tuple]: + def format_savepoint_statement(self, expr: "SavepointStatement") -> Tuple[str, tuple]: """Format a SAVEPOINT statement. Args: @@ -69,7 +69,7 @@ def format_savepoint(self, expr: "SavepointExpression") -> Tuple[str, tuple]: """ return f"SAVEPOINT {self.format_identifier(expr.name)}", () - def format_release_savepoint(self, expr: "ReleaseSavepointExpression") -> Tuple[str, tuple]: + def format_release_savepoint_statement(self, expr: "ReleaseSavepointStatement") -> Tuple[str, tuple]: """Format a RELEASE SAVEPOINT statement. Args: @@ -80,11 +80,11 @@ def format_release_savepoint(self, expr: "ReleaseSavepointExpression") -> Tuple[ """ return f"RELEASE SAVEPOINT {self.format_identifier(expr.name)}", () - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format a SET TRANSACTION statement. Args: - expr: Set transaction expression. + expr: The SET TRANSACTION statement. Returns: Tuple of (SQL string, parameters tuple). diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/trigger.py b/src/rhosocial/activerecord/backend/dialect/mixins/trigger.py index 80be4a414..2bddf86f1 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/trigger.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.statements import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) @@ -84,7 +84,7 @@ def supports_trigger_if_exists(self) -> bool: """ return False - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format a ``CREATE TRIGGER`` statement per SQL:1999. Renders the optional ``IF NOT EXISTS`` qualifier, timing, events @@ -99,30 +99,30 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu A ``(sql, params)`` tuple. Raises: - TypeError: ``CreateTriggerExpression.trigger`` is not a Trigger. Another + TypeError: ``CreateTriggerStatement.trigger`` is not a Trigger. Another object kind would have had its own name rendered as the trigger's. - TypeError: ``CreateTriggerExpression.table`` is not a Table. Another + TypeError: ``CreateTriggerStatement.table`` is not a Table. Another object kind would have had its own name rendered as the watched table's. - TypeError: ``CreateTriggerExpression.function`` is not a Function. + TypeError: ``CreateTriggerStatement.function`` is not a Function. Another object kind would have had its own name rendered as the trigger body's. UnsupportedFeatureError: If the dialect does not support triggers. """ if not isinstance(expr.trigger, Trigger): raise TypeError( - f"CreateTriggerExpression.trigger must be a Trigger, " + f"CreateTriggerStatement.trigger must be a Trigger, " f"got {type(expr.trigger).__name__}" ) if not isinstance(expr.table, Table): raise TypeError( - f"CreateTriggerExpression.table must be a Table, " + f"CreateTriggerStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not isinstance(expr.function, Function): raise TypeError( - f"CreateTriggerExpression.function must be a Function, " + f"CreateTriggerStatement.function must be a Function, " f"got {type(expr.function).__name__}" ) from ..exceptions import UnsupportedFeatureError @@ -180,7 +180,7 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu return " ".join(parts), tuple(all_params) - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format a ``DROP TRIGGER`` statement per SQL:1999. Args: @@ -191,21 +191,21 @@ def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[ A ``(sql, params)`` tuple. Raises: - TypeError: ``DropTriggerExpression.trigger`` is not a Trigger. Another + TypeError: ``DropTriggerStatement.trigger`` is not a Trigger. Another object kind would have had its own name rendered as the trigger's. - TypeError: ``DropTriggerExpression.table`` is not a Table. Another + TypeError: ``DropTriggerStatement.table`` is not a Table. Another object kind would have had its own name rendered as the table's. UnsupportedFeatureError: If the dialect does not support triggers. """ if not isinstance(expr.trigger, Trigger): raise TypeError( - f"DropTriggerExpression.trigger must be a Trigger, " + f"DropTriggerStatement.trigger must be a Trigger, " f"got {type(expr.trigger).__name__}" ) if expr.table is not None and not isinstance(expr.table, Table): raise TypeError( - f"DropTriggerExpression.table must be a Table, " + f"DropTriggerStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) from ..exceptions import UnsupportedFeatureError diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/user_defined_type.py b/src/rhosocial/activerecord/backend/dialect/mixins/user_defined_type.py index e1c96256a..e7c7871a5 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/user_defined_type.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/user_defined_type.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: from ...expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -88,20 +88,20 @@ def supports_multiple_type_alter_actions(self) -> bool: def format_create_type_statement( self, - expr: "CreateTypeExpression", + expr: "CreateTypeStatement", ) -> Tuple[str, tuple]: """Format a CREATE TYPE statement. Raises: - TypeError: ``CreateTypeExpression.type`` is not a Type. Another object + TypeError: ``CreateTypeStatement.type`` is not a Type. Another object kind would have had its own name rendered as the type's. - TypeError: ``CreateTypeExpression.definition`` is not an implementation + TypeError: ``CreateTypeStatement.definition`` is not an implementation of TypeDefinition. Without a definition there is no body to render, so the statement would name a type and stop. """ if not isinstance(expr.type, Type): raise TypeError( - f"CreateTypeExpression.type must be a Type, " + f"CreateTypeStatement.type must be a Type, " f"got {type(expr.type).__name__}" ) @@ -110,7 +110,7 @@ def format_create_type_statement( # rather than naming a concrete type. if not isinstance(expr.definition, TypeDefinition): raise TypeError( - f"CreateTypeExpression.definition must be a TypeDefinition, " + f"CreateTypeStatement.definition must be a TypeDefinition, " f"got {type(expr.definition).__name__}" ) if not self.supports_type_objects() or not self.supports_create_type(): @@ -137,20 +137,20 @@ def format_create_type_statement( def format_alter_type_statement( self, - expr: "AlterTypeExpression", + expr: "AlterTypeStatement", ) -> Tuple[str, tuple]: """Format an ALTER TYPE statement. Raises: - TypeError: ``AlterTypeExpression.type`` is not a Type. Another object + TypeError: ``AlterTypeStatement.type`` is not a Type. Another object kind would have had its own name rendered as the type's. - TypeError: An entry of ``AlterTypeExpression.actions`` is not an + TypeError: An entry of ``AlterTypeStatement.actions`` is not an implementation of TypeAlterAction. An entry of another kind has no action clause to dispatch on, so nothing would be rendered for it. """ if not isinstance(expr.type, Type): raise TypeError( - f"AlterTypeExpression.type must be a Type, " + f"AlterTypeStatement.type must be a Type, " f"got {type(expr.type).__name__}" ) @@ -160,7 +160,7 @@ def format_alter_type_statement( for position, entry in enumerate(expr.actions): if not isinstance(entry, TypeAlterAction): raise TypeError( - f"AlterTypeExpression.actions must hold TypeAlterAction implementations, " + f"AlterTypeStatement.actions must hold TypeAlterAction implementations, " f"got {type(entry).__name__} at position {position}" ) if not self.supports_type_objects() or not self.supports_alter_type(): @@ -190,17 +190,17 @@ def format_alter_type_statement( def format_drop_type_statement( self, - expr: "DropTypeExpression", + expr: "DropTypeStatement", ) -> Tuple[str, tuple]: """Format a DROP TYPE statement. Raises: - TypeError: ``DropTypeExpression.type`` is not a Type. Another object + TypeError: ``DropTypeStatement.type`` is not a Type. Another object kind would have had its own name rendered as the type's. """ if not isinstance(expr.type, Type): raise TypeError( - f"DropTypeExpression.type must be a Type, " + f"DropTypeStatement.type must be a Type, " f"got {type(expr.type).__name__}" ) if not self.supports_type_objects() or not self.supports_drop_type(): @@ -221,7 +221,7 @@ def format_type_definition( """Reject TYPE definitions until a backend supplies a grammar.""" raise UnsupportedFeatureError( self.name, - f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + f"TYPE definition {expr.definition_kind}", ) def format_type_alter_action( @@ -231,7 +231,7 @@ def format_type_alter_action( """Reject ALTER TYPE actions until a backend supplies a grammar.""" raise UnsupportedFeatureError( self.name, - f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + f"ALTER TYPE action {expr.action_kind}", ) diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/uuid.py b/src/rhosocial/activerecord/backend/dialect/mixins/uuid.py index a4d36c4bc..b9b2eb409 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/uuid.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/uuid.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: # pragma: no cover from ...expression.uuid import ( - UUIDCastExpression, + UUIDCastFunction, UUIDConstantExpression, UUIDGenerationExpression, ) @@ -91,7 +91,7 @@ def _require_uuid(self, operation: str, feature: str) -> Any: # Formatters # ------------------------------------------------------------------ - def format_uuid_generation( + def format_uuid_generation_expression( self, expr: "UUIDGenerationExpression" ) -> Tuple[str, Tuple]: """Render a fresh-UUID expression. @@ -103,11 +103,11 @@ def format_uuid_generation( Tuple of (SQL string, parameters tuple). """ sql = self._require_uuid("generation", "UUID generation") - if getattr(expr, "alias", None): + if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_uuid_constant(self, expr: "UUIDConstantExpression") -> Tuple[str, Tuple]: + def format_uuid_constant_expression(self, expr: "UUIDConstantExpression") -> Tuple[str, Tuple]: """Render the nil or max UUID constant. Args: @@ -124,15 +124,15 @@ def format_uuid_constant(self, expr: "UUIDConstantExpression") -> Tuple[str, Tup f"UUID {expr.which} constant", self.UUID_SUGGESTIONS.get("constant", ""), ) - if getattr(expr, "alias", None): + if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_uuid_cast(self, expr: "UUIDCastExpression") -> Tuple[str, Tuple]: + def format_uuid_cast(self, expr: "UUIDCastFunction") -> Tuple[str, Tuple]: """Render a text-to-UUID conversion. Args: - expr: The UUIDCastExpression node. + expr: The UUIDCastFunction node. Returns: Tuple of (SQL string, parameters tuple). @@ -140,7 +140,7 @@ def format_uuid_cast(self, expr: "UUIDCastExpression") -> Tuple[str, Tuple]: template = self._require_uuid("cast", "UUID cast") inner_sql, inner_params = expr.expression.to_sql() sql = template.format(inner=inner_sql) - if getattr(expr, "alias", None): + if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, inner_params diff --git a/src/rhosocial/activerecord/backend/dialect/mixins/xml.py b/src/rhosocial/activerecord/backend/dialect/mixins/xml.py index 85dee7c3e..7484eabe0 100644 --- a/src/rhosocial/activerecord/backend/dialect/mixins/xml.py +++ b/src/rhosocial/activerecord/backend/dialect/mixins/xml.py @@ -5,28 +5,27 @@ covering parsing, serialization, construction, aggregation, and querying. """ import re -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Tuple, TYPE_CHECKING from ..exceptions import UnsupportedFeatureError if TYPE_CHECKING: # pragma: no cover from ...expression.xml import ( + PassingClause, XMLAggExpression, XMLAttributesExpression, XMLCommentExpression, XMLConcatExpression, XMLElementExpression, - XMLExistsExpression, + XMLExistsPredicate, XMLForestExpression, XMLParseExpression, - XMLPassingMechanism, XMLPIExpression, XMLQueryExpression, XMLRootExpression, XMLSerializeExpression, XMLTableExpression, ) - from ...expression.bases import BaseExpression class SQLXMLParsingMixin: @@ -39,7 +38,7 @@ def supports_xmlparse(self) -> bool: """ return False - def format_xmlparse_expression( + def format_xml_parse_expression( self, expr: "XMLParseExpression", ) -> Tuple[str, tuple]: @@ -74,7 +73,7 @@ def supports_xmlserialize(self) -> bool: """ return False - def format_xmlserialize_expression( + def format_xml_serialize_expression( self, expr: "XMLSerializeExpression", ) -> Tuple[str, tuple]: @@ -90,14 +89,12 @@ def format_xmlserialize_expression( Raises: UnsupportedFeatureError: If XMLSERIALIZE is unsupported. - ValueError: If ``target_type`` is not a valid data type. """ if not self.supports_xmlserialize(): raise UnsupportedFeatureError(self.name, "SQL/XML XMLSERIALIZE") - if not self._validate_data_type(expr.target_type): - raise ValueError(f"Invalid XMLSERIALIZE target type: {expr.target_type}") + target_sql, _ = expr.target_type.to_sql() content_sql, params = expr.content.to_sql() - parts = [expr.document_type.value, content_sql, "AS", expr.target_type] + parts = [expr.document_type.value, content_sql, "AS", target_sql] if expr.whitespace_option is not None: parts.append(expr.whitespace_option.value) return f"XMLSERIALIZE({' '.join(parts)})", params @@ -177,7 +174,7 @@ def format_xml_name(self, name: str) -> str: raise ValueError(f"Invalid SQL/XML name: {name}") return name - def format_xmlattributes_expression( + def format_xml_attributes_expression( self, expr: "XMLAttributesExpression", ) -> Tuple[str, tuple]: @@ -205,7 +202,7 @@ def format_xmlattributes_expression( all_params.extend(value_params) return f"XMLATTRIBUTES({', '.join(parts)})", tuple(all_params) - def format_xmlelement_expression( + def format_xml_element_expression( self, expr: "XMLElementExpression", ) -> Tuple[str, tuple]: @@ -235,7 +232,7 @@ def format_xmlelement_expression( all_params.extend(item_params) return f"XMLELEMENT({', '.join(parts)})", tuple(all_params) - def format_xmlforest_expression( + def format_xml_forest_expression( self, expr: "XMLForestExpression", ) -> Tuple[str, tuple]: @@ -263,7 +260,7 @@ def format_xmlforest_expression( all_params.extend(value_params) return f"XMLFOREST({', '.join(parts)})", tuple(all_params) - def format_xmlconcat_expression( + def format_xml_concat_expression( self, expr: "XMLConcatExpression", ) -> Tuple[str, tuple]: @@ -288,7 +285,7 @@ def format_xmlconcat_expression( all_params.extend(part_params) return f"XMLCONCAT({', '.join(parts)})", tuple(all_params) - def format_xmlcomment_expression( + def format_xml_comment_expression( self, expr: "XMLCommentExpression", ) -> Tuple[str, tuple]: @@ -328,7 +325,7 @@ def format_xmlpi_expression(self, expr: "XMLPIExpression") -> Tuple[str, tuple]: content_sql, params = expr.content.to_sql() return f"{sql}, {content_sql})", params - def format_xmlroot_expression( + def format_xml_root_expression( self, expr: "XMLRootExpression", ) -> Tuple[str, tuple]: @@ -368,7 +365,7 @@ def supports_xmlagg(self) -> bool: """ return False - def format_xmlagg_expression(self, expr: "XMLAggExpression") -> Tuple[str, tuple]: + def format_xml_agg_expression(self, expr: "XMLAggExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLAGG expression. Args: @@ -419,44 +416,44 @@ def supports_xmltable(self) -> bool: """ return False - def _format_xml_passing_clause( - self, - expressions: List["BaseExpression"], - mechanism: Optional["XMLPassingMechanism"] = None, - ) -> Tuple[str, tuple]: - """Format a SQL/XML PASSING clause. + def format_passing_clause(self, clause: "PassingClause") -> Tuple[str, tuple]: + """Format a SQL/XML ``PASSING`` clause. + + Public and overridable: the clause is a node all three querying XML + expressions carry, so a backend that spells ``PASSING`` differently + overrides this one formatter instead of the three that compose + around it. Args: - expressions: Expressions to pass to the XML function. - mechanism: Optional passing mechanism value. + clause: The ``PassingClause`` carrying the expressions to pass + and the optional mechanism (``BY VALUE`` / ``BY REF``). Returns: - Tuple of (SQL string, parameters tuple); empty when no - expressions are supplied. + Tuple of (SQL string, parameters tuple); empty when the clause + carries no expressions. """ - if not expressions: + if not clause.expressions: return "", () all_params = [] parts = [] - for expression in expressions: + for expression in clause.expressions: expr_sql, expr_params = expression.to_sql() parts.append(expr_sql) all_params.extend(expr_params) prefix = "PASSING" - if mechanism is not None: - prefix += f" {mechanism.value}" + if clause.mechanism is not None: + prefix += f" {clause.mechanism.value}" return f"{prefix} {', '.join(parts)}", tuple(all_params) - def format_xmlquery_expression( + def format_xml_query_expression( self, expr: "XMLQueryExpression", ) -> Tuple[str, tuple]: """Format a SQL/XML XMLQUERY expression. Args: - expr: XMLQueryExpression exposing ``query``, ``passing``, - ``passing_mechanism``, ``returning_content``, and - ``empty_handling``. + expr: XMLQueryExpression exposing ``query``, ``passing_clause``, + ``returning_content``, and ``empty_handling``. Returns: Tuple of (SQL string, parameters tuple). @@ -469,28 +466,25 @@ def format_xmlquery_expression( query_sql, query_params = expr.query.to_sql() parts = [query_sql] all_params = list(query_params) - passing_sql, passing_params = self._format_xml_passing_clause( - expr.passing, - expr.passing_mechanism, - ) - if passing_sql: - parts.append(passing_sql) - all_params.extend(passing_params) + if expr.passing_clause is not None: + passing_sql, passing_params = expr.passing_clause.to_sql() + if passing_sql: + parts.append(passing_sql) + all_params.extend(passing_params) if expr.returning_content: parts.append("RETURNING CONTENT") if expr.empty_handling is not None: parts.append(expr.empty_handling.value) return f"XMLQUERY({' '.join(parts)})", tuple(all_params) - def format_xmlexists_expression( + def format_xmlexists_predicate( self, - expr: "XMLExistsExpression", + expr: "XMLExistsPredicate", ) -> Tuple[str, tuple]: """Format a SQL/XML XMLEXISTS predicate. Args: - expr: XMLExistsExpression exposing ``query``, ``passing``, and - ``passing_mechanism``. + expr: XMLExistsPredicate exposing ``query`` and ``passing_clause``. Returns: Tuple of (SQL string, parameters tuple). @@ -503,24 +497,22 @@ def format_xmlexists_expression( query_sql, query_params = expr.query.to_sql() parts = [query_sql] all_params = list(query_params) - passing_sql, passing_params = self._format_xml_passing_clause( - expr.passing, - expr.passing_mechanism, - ) - if passing_sql: - parts.append(passing_sql) - all_params.extend(passing_params) + if expr.passing_clause is not None: + passing_sql, passing_params = expr.passing_clause.to_sql() + if passing_sql: + parts.append(passing_sql) + all_params.extend(passing_params) return f"XMLEXISTS({' '.join(parts)})", tuple(all_params) - def format_xmltable_expression( + def format_xml_table_expression( self, expr: "XMLTableExpression", ) -> Tuple[str, tuple]: """Format a SQL/XML XMLTABLE expression. Args: - expr: XMLTableExpression exposing ``row_pattern``, ``passing``, - ``passing_mechanism``, and ``columns``. + expr: XMLTableExpression exposing ``row_pattern``, + ``passing_clause``, and ``columns``. Returns: Tuple of (SQL string, parameters tuple). @@ -534,18 +526,17 @@ def format_xmltable_expression( row_sql, row_params = expr.row_pattern.to_sql() parts = [row_sql] all_params = list(row_params) - passing_sql, passing_params = self._format_xml_passing_clause( - expr.passing, - expr.passing_mechanism, - ) - if passing_sql: - parts.append(passing_sql) - all_params.extend(passing_params) + if expr.passing_clause is not None: + passing_sql, passing_params = expr.passing_clause.to_sql() + if passing_sql: + parts.append(passing_sql) + all_params.extend(passing_params) column_parts = [] for column in expr.columns: - if not self._validate_data_type(column.data_type): - raise ValueError(f"Invalid XMLTABLE column type: {column.data_type}") - column_sql = f"{self.format_identifier(column.name)} {column.data_type}" + from ...expression.types import DataType + + column_type_sql, _ = DataType.as_type(self, column.data_type).to_sql() + column_sql = f"{self.format_identifier(column.name)} {column_type_sql}" if column.path is not None: path_sql, path_params = column.path.to_sql() column_sql += f" PATH {path_sql}" diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/column_attribute.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/column_attribute.py index c72018da3..fc55926f2 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/column_attribute.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/column_attribute.py @@ -6,7 +6,7 @@ tests enforce. """ -from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING +from typing import Any, Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from .....base.ddl import ColumnAttribute @@ -27,3 +27,20 @@ def format_column_attribute(self, attr: "ColumnAttribute") -> Tuple[str, tuple]: bind parameters, so ``params`` is normally empty). """ ... # pragma: no cover + + def translate_ar_attribute(self, attr: Any) -> Any: + """Turn an AR-layer ``ColumnAttribute`` into an expression-layer clause. + + A backend that overrides :meth:`format_column_definition` receives + AR-layer attribute objects there, so the translation is part of the + protocol surface rather than an internal step: callers outside the + dialect layer need it too. + + Args: + attr: An AR-layer ``ColumnAttribute``. + + Returns: + The expression-layer clause, or ``None`` when this dialect cannot + render the attribute at all. + """ + ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/comment.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/comment.py index 2f091b8df..a457b809d 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/comment.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression.statements import CommentOnExpression + from ....expression.statements import CommentOnStatement @runtime_checkable @@ -29,7 +29,7 @@ def supports_comment_on(self) -> bool: """Whether standalone ``COMMENT ON`` statements are supported.""" ... # pragma: no cover - def format_comment_statement(self, expr: "CommentOnExpression") -> Tuple[str, tuple]: + def format_comment_on_statement(self, expr: "CommentOnStatement") -> Tuple[str, tuple]: """Render a standalone ``COMMENT ON IS ''`` statement.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/constraint.py index 062fffa47..2a182a2e0 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/constraint.py @@ -10,11 +10,11 @@ if TYPE_CHECKING: # pragma: no cover from ....expression.statements import ( - AlterConstraint, + AlterConstraintAction, ColumnConstraint, ReferencesClause, TableConstraint, - ValidateConstraint, + ValidateConstraintAction, ) @@ -114,6 +114,17 @@ def supports_foreign_key_constraint(self) -> bool: """Whether FOREIGN KEY constraints are supported.""" ... # pragma: no cover + # Named constraints (SQL-92) + + def supports_constraint_naming(self) -> bool: + """Whether a constraint may carry a ``CONSTRAINT `` prefix. + + The SQL-92 grammar opens both the column- and the table-constraint + productions with ``[CONSTRAINT ]``, so the default is + ``True`` and a dialect overrides it only to refuse the prefix. + """ + ... # pragma: no cover + # FK referential actions (SQL-92) def supports_fk_on_delete(self) -> bool: @@ -186,11 +197,11 @@ def supports_validate_constraint(self) -> bool: """Whether VALIDATE CONSTRAINT is supported.""" ... - def format_alter_constraint_action(self, action: "AlterConstraint") -> Tuple[str, tuple]: + def format_alter_constraint_action(self, action: "AlterConstraintAction") -> Tuple[str, tuple]: """Format an ALTER CONSTRAINT enforcement action.""" ... - def format_validate_constraint_action(self, action: "ValidateConstraint") -> Tuple[str, tuple]: + def format_validate_constraint_action(self, action: "ValidateConstraintAction") -> Tuple[str, tuple]: """Format a VALIDATE CONSTRAINT action.""" ... diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/alter_database.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/alter_database.py index 2e27d62e3..feafc0e90 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/alter_database.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/alter_database.py @@ -11,7 +11,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .....expression.statements.ddl_database import AlterDatabaseExpression + from .....expression.statements.ddl_database import AlterDatabaseStatement @runtime_checkable @@ -29,8 +29,8 @@ def supports_alter_database(self) -> bool: """ ... # pragma: no cover - def format_alter_database_statement(self, expr: "AlterDatabaseExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.AlterDatabaseExpression`. + def format_alter_database_statement(self, expr: "AlterDatabaseStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.AlterDatabaseStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/create_database.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/create_database.py index d3c1f4c61..5f46580ad 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/create_database.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/create_database.py @@ -11,7 +11,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .....expression.statements.ddl_database import CreateDatabaseExpression + from .....expression.statements.ddl_database import CreateDatabaseStatement @runtime_checkable @@ -99,8 +99,8 @@ def supports_database_or_replace(self) -> bool: """ ... # pragma: no cover - def format_create_database_statement(self, expr: "CreateDatabaseExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateDatabaseExpression`. + def format_create_database_statement(self, expr: "CreateDatabaseStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateDatabaseStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/drop_database.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/drop_database.py index 445a050d7..b6fe77f64 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/drop_database.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/database/drop_database.py @@ -11,7 +11,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from .....expression.statements.ddl_database import DropDatabaseExpression + from .....expression.statements.ddl_database import DropDatabaseStatement @runtime_checkable @@ -50,8 +50,8 @@ def supports_undrop_database(self) -> bool: """ ... # pragma: no cover - def format_drop_database_statement(self, expr: "DropDatabaseExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropDatabaseExpression`. + def format_drop_database_statement(self, expr: "DropDatabaseStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropDatabaseStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/alter_domain.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/alter_domain.py index d278ffe07..0f6509db5 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/alter_domain.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/alter_domain.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - AlterDomainExpression, + AlterDomainStatement, DomainAlterAction, ) @@ -53,8 +53,8 @@ def supports_unnamed_domain_check_drop(self) -> bool: """ ... # pragma: no cover - def format_alter_domain_statement(self, expr: "AlterDomainExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.AlterDomainExpression`. + def format_alter_domain_statement(self, expr: "AlterDomainStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.AlterDomainStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/create_domain.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/create_domain.py index ebd6f350d..8eca1116b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/create_domain.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/create_domain.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateDomainExpression, + CreateDomainStatement, DomainCheckConstraint, DomainValueExpression, ) @@ -82,8 +82,8 @@ def supports_domain_collation(self) -> bool: """ ... # pragma: no cover - def format_create_domain_statement(self, expr: "CreateDomainExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateDomainExpression`. + def format_create_domain_statement(self, expr: "CreateDomainStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateDomainStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/drop_domain.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/drop_domain.py index 34cfb5d58..45ee1675b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/drop_domain.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/domain/drop_domain.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropDomainExpression, + DropDomainStatement, ) @@ -52,8 +52,8 @@ def supports_drop_domain_restrict(self) -> bool: """ ... # pragma: no cover - def format_drop_domain_statement(self, expr: "DropDomainExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropDomainExpression`. + def format_drop_domain_statement(self, expr: "DropDomainStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropDomainStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/create_index.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/create_index.py index 0e3b91cf9..38956b5bb 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/create_index.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/create_index.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateIndexExpression, + CreateIndexStatement, ) @@ -87,8 +87,8 @@ def supports_concurrent_index(self) -> bool: """ ... # pragma: no cover - def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateIndexExpression`. + def format_create_index_statement(self, expr: "CreateIndexStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateIndexStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/drop_index.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/drop_index.py index 45a508e3d..3beb0cc65 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/drop_index.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/drop_index.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropIndexExpression, + DropIndexStatement, ) @@ -45,8 +45,8 @@ def supports_drop_index_on_table(self) -> bool: """ ... # pragma: no cover - def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropIndexExpression`. + def format_drop_index_statement(self, expr: "DropIndexStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropIndexStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/fulltext.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/fulltext.py index 128615206..ff5bec20b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/fulltext.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/index/fulltext.py @@ -12,9 +12,9 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateFulltextIndexExpression, - DropFulltextIndexExpression, - FulltextMatchExpression, + CreateFulltextIndexStatement, + DropFulltextIndexStatement, + FulltextMatch, ) @@ -61,24 +61,24 @@ def supports_fulltext_query_expansion(self) -> bool: """ ... # pragma: no cover - def format_create_fulltext_index_statement(self, expr: "CreateFulltextIndexExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateFulltextIndexExpression`. + def format_create_fulltext_index_statement(self, expr: "CreateFulltextIndexStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateFulltextIndexStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. """ ... # pragma: no cover - def format_drop_fulltext_index_statement(self, expr: "DropFulltextIndexExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropFulltextIndexExpression`. + def format_drop_fulltext_index_statement(self, expr: "DropFulltextIndexStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropFulltextIndexStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. """ ... # pragma: no cover - def format_fulltext_match(self, expr: "FulltextMatchExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.FulltextMatchExpression`. + def format_fulltext_match(self, expr: "FulltextMatch") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.FulltextMatch`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/create_function.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/create_function.py index 24f7105fd..6c70c0b27 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/create_function.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/create_function.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateFunctionExpression, + CreateFunctionStatement, ) @@ -52,8 +52,8 @@ def supports_function_parameters(self) -> bool: """ ... # pragma: no cover - def format_create_function_statement(self, expr: "CreateFunctionExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateFunctionExpression`. + def format_create_function_statement(self, expr: "CreateFunctionStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateFunctionStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/drop_function.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/drop_function.py index 6e4bce1ff..0aa98082a 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/drop_function.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/routine/drop_function.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropFunctionExpression, + DropFunctionStatement, ) @@ -52,8 +52,8 @@ def supports_drop_function_restrict(self) -> bool: """ ... # pragma: no cover - def format_drop_function_statement(self, expr: "DropFunctionExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropFunctionExpression`. + def format_drop_function_statement(self, expr: "DropFunctionStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropFunctionStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/create_schema.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/create_schema.py index 4f8da6d05..d99376d7e 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/create_schema.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/create_schema.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateSchemaExpression, + CreateSchemaStatement, ) @@ -52,8 +52,8 @@ def supports_schema_authorization(self) -> bool: """ ... # pragma: no cover - def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateSchemaExpression`. + def format_create_schema_statement(self, expr: "CreateSchemaStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateSchemaStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/drop_schema.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/drop_schema.py index 56a49a0e9..f1a71a517 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/drop_schema.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/schema/drop_schema.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropSchemaExpression, + DropSchemaStatement, ) @@ -52,8 +52,8 @@ def supports_schema_restrict(self) -> bool: """ ... # pragma: no cover - def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropSchemaExpression`. + def format_drop_schema_statement(self, expr: "DropSchemaStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropSchemaStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/alter_sequence.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/alter_sequence.py index c99cd7b27..33bfb77be 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/alter_sequence.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/alter_sequence.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - AlterSequenceExpression, + AlterSequenceStatement, ) @@ -41,8 +41,8 @@ def supports_alter_sequence_start(self) -> bool: """ ... # pragma: no cover - def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.AlterSequenceExpression`. + def format_alter_sequence_statement(self, expr: "AlterSequenceStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.AlterSequenceStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/create_sequence.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/create_sequence.py index 478491d79..794ec5d8b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/create_sequence.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/create_sequence.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateSequenceExpression, + CreateSequenceStatement, ) @@ -101,8 +101,8 @@ def supports_sequence_owned_by(self) -> bool: """ ... # pragma: no cover - def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateSequenceExpression`. + def format_create_sequence_statement(self, expr: "CreateSequenceStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateSequenceStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/drop_sequence.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/drop_sequence.py index 626e81441..6cf4ee1e5 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/drop_sequence.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/sequence/drop_sequence.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropSequenceExpression, + DropSequenceStatement, ) @@ -38,8 +38,8 @@ def supports_sequence_if_exists(self) -> bool: """ ... # pragma: no cover - def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropSequenceExpression`. + def format_drop_sequence_statement(self, expr: "DropSequenceStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropSequenceStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/alter_table.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/alter_table.py index fa5c4dcc3..e6a2befdb 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/alter_table.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/alter_table.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - AlterTableExpression, + AlterTableStatement, ) @@ -73,8 +73,8 @@ def supports_multi_action_alter_table(self) -> bool: """ ... # pragma: no cover - def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.AlterTableExpression`. + def format_alter_table_statement(self, expr: "AlterTableStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.AlterTableStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table.py index cb017a607..7c3f6a898 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTableExpression, + CreateTableStatement, CreateTableOptions, ) @@ -95,8 +95,8 @@ def supports_table_inheritance(self) -> bool: """ ... # pragma: no cover - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTableExpression`. + def format_create_table_statement(self, expr: "CreateTableStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTableStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_as.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_as.py index 5cc857be7..eb07372ed 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_as.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_as.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTableAsExpression, + CreateTableAsStatement, ) @@ -36,8 +36,18 @@ def supports_create_table_as(self) -> bool: """ ... # pragma: no cover - def format_create_table_as_statement(self, expr: "CreateTableAsExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTableAsExpression`. + def supports_create_table_as_bind_parameters(self) -> bool: + """Whether a CTAS 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 format_create_table_as_statement(self, expr: "CreateTableAsStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTableAsStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_clone.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_clone.py index 2de71c0ab..71f1bf519 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_clone.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_clone.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTableCloneExpression, + CreateTableCloneStatement, ) @@ -31,8 +31,8 @@ def supports_create_table_clone(self) -> bool: """ ... # pragma: no cover - def format_create_table_clone_statement(self, expr: "CreateTableCloneExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTableCloneExpression`. + def format_create_table_clone_statement(self, expr: "CreateTableCloneStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTableCloneStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_like.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_like.py index 07216bafa..2f84f9f20 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_like.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_like.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTableLikeExpression, + CreateTableLikeStatement, ) @@ -31,8 +31,8 @@ def supports_create_table_like(self) -> bool: """ ... # pragma: no cover - def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTableLikeExpression`. + def format_create_table_like_statement(self, expr: "CreateTableLikeStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTableLikeStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_using_template.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_using_template.py index b6c4eda7e..57bfa459d 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_using_template.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/create_table_using_template.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTableFromTemplateExpression, + CreateTableFromTemplateStatement, ) @@ -31,8 +31,8 @@ def supports_create_table_using_template(self) -> bool: """ ... # pragma: no cover - def format_create_table_using_template(self, expr: "CreateTableFromTemplateExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTableFromTemplateExpression`. + def format_create_table_from_template_statement(self, expr: "CreateTableFromTemplateStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTableFromTemplateStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/drop_table.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/drop_table.py index d0e526a1d..db4530b4c 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/drop_table.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/table/drop_table.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropTableExpression, + DropTableStatement, ) @@ -59,8 +59,8 @@ def supports_purge_on_drop_table(self) -> bool: """ ... # pragma: no cover - def format_drop_table_statement(self, expr: "DropTableExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropTableExpression`. + def format_drop_table_statement(self, expr: "DropTableStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropTableStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/create_trigger.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/create_trigger.py index 50b5a4f65..551fe5c53 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/create_trigger.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/create_trigger.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, ) @@ -73,8 +73,8 @@ def supports_trigger_when(self) -> bool: """ ... # pragma: no cover - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTriggerExpression`. + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTriggerStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/drop_trigger.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/drop_trigger.py index cb3dde80e..75f63cfa1 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/drop_trigger.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/trigger/drop_trigger.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropTriggerExpression, + DropTriggerStatement, ) @@ -38,8 +38,8 @@ def supports_trigger_if_exists(self) -> bool: """ ... # pragma: no cover - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropTriggerExpression`. + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropTriggerStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/truncate.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/truncate.py index 4415e3315..98d22ac98 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/truncate.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/truncate.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression.statements import TruncateExpression + from ....expression.statements import TruncateStatement @runtime_checkable @@ -44,6 +44,6 @@ def supports_truncate_restrict(self) -> bool: """Whether RESTRICT option is supported.""" ... # pragma: no cover - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format TRUNCATE TABLE statement.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/alter_type.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/alter_type.py index 8fc5f9272..4aa07d95a 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/alter_type.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/alter_type.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - AlterTypeExpression, + AlterTypeStatement, TypeAlterAction, ) @@ -53,8 +53,8 @@ def supports_multiple_type_alter_actions(self) -> bool: """ ... # pragma: no cover - def format_alter_type_statement(self, expr: "AlterTypeExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.AlterTypeExpression`. + def format_alter_type_statement(self, expr: "AlterTypeStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.AlterTypeStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/create_type.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/create_type.py index 09aa7afff..16a066573 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/create_type.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/create_type.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateTypeExpression, + CreateTypeStatement, TypeDefinition, ) @@ -60,8 +60,8 @@ def supports_type_definition(self) -> bool: """ ... # pragma: no cover - def format_create_type_statement(self, expr: "CreateTypeExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateTypeExpression`. + def format_create_type_statement(self, expr: "CreateTypeStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateTypeStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/drop_type.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/drop_type.py index 9b9969351..4eb849ab3 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/drop_type.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/type_/drop_type.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropTypeExpression, + DropTypeStatement, ) @@ -38,8 +38,8 @@ def supports_drop_type_if_exists(self) -> bool: """ ... # pragma: no cover - def format_drop_type_statement(self, expr: "DropTypeExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropTypeExpression`. + def format_drop_type_statement(self, expr: "DropTypeStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropTypeStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/create_view.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/create_view.py index 98f113768..7ce883122 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/create_view.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/create_view.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateViewExpression, + CreateViewStatement, ) @@ -31,6 +31,16 @@ def supports_create_view(self) -> bool: """ ... # pragma: no cover + def supports_create_view_bind_parameters(self) -> bool: + """Whether a view 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_or_replace_view(self) -> bool: """Whether the engine accepts the form ``or_replace_view``. @@ -66,8 +76,8 @@ def supports_view_check_option(self) -> bool: """ ... # pragma: no cover - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateViewExpression`. + def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateViewStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/drop_view.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/drop_view.py index 9062d3b48..3b051d5d1 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/drop_view.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/drop_view.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - DropViewExpression, + DropViewStatement, ) @@ -52,8 +52,8 @@ def supports_restrict_view(self) -> bool: """ ... # pragma: no cover - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropViewExpression`. + def format_drop_view_statement(self, expr: "DropViewStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropViewStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/materialized_view.py b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/materialized_view.py index 969ab8662..1e4d3d3d9 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/materialized_view.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/ddl/view/materialized_view.py @@ -12,9 +12,9 @@ if TYPE_CHECKING: # pragma: no cover from .....expression.statements import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) @@ -33,6 +33,16 @@ def supports_materialized_view(self) -> bool: """ ... # pragma: no cover + def supports_materialized_view_bind_parameters(self) -> bool: + """Whether a materialized view 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_refresh_materialized_view(self) -> bool: """Whether the engine accepts the form ``refresh_materialized_view``. @@ -75,16 +85,16 @@ def supports_with_data_clause(self) -> bool: """ ... # pragma: no cover - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.CreateMaterializedViewExpression`. + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.CreateMaterializedViewStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. """ ... # pragma: no cover - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.DropMaterializedViewExpression`. + def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewStatement") -> Tuple[str, tuple]: + """Render a :class:`~....expression.statements.DropMaterializedViewStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. @@ -92,9 +102,9 @@ def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExp ... # pragma: no cover def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" + self, expr: "RefreshMaterializedViewStatement" ) -> Tuple[str, tuple]: - """Render a :class:`~....expression.statements.RefreshMaterializedViewExpression`. + """Render a :class:`~....expression.statements.RefreshMaterializedViewStatement`. Raises rather than emitting an unsupported form: a statement the engine cannot parse is not a statement worth producing. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/introspect/introspection.py b/src/rhosocial/activerecord/backend/dialect/protocols/introspect/introspection.py index dd1e700fa..956299f69 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/introspect/introspection.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/introspect/introspection.py @@ -11,16 +11,16 @@ if TYPE_CHECKING: # pragma: no cover from ...mixins.introspection import IntrospectionScope from ....expression.introspection import ( - ColumnInfoExpression, - DatabaseInfoExpression, - ForeignKeyExpression, - IndexInfoExpression, - TableInfoExpression, - TableListExpression, - TriggerInfoExpression, - TriggerListExpression, - ViewInfoExpression, - ViewListExpression, + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableInfoQueryStatement, + TableListQueryStatement, + TriggerInfoQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, ) @@ -125,7 +125,7 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== - 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. @@ -137,7 +137,7 @@ def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[st """ ... # pragma: no cover - 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. @@ -149,7 +149,7 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup """ ... # pragma: no cover - def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tuple]: + def format_table_info_query_statement(self, expr: "TableInfoQueryStatement") -> Tuple[str, tuple]: """ Format single table information query. @@ -161,7 +161,7 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup """ ... # pragma: no cover - 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. @@ -173,7 +173,7 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t """ ... # pragma: no cover - 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. @@ -185,7 +185,7 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup """ ... # pragma: no cover - 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. @@ -197,7 +197,7 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t """ ... # pragma: no cover - 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. @@ -209,7 +209,7 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple """ ... # pragma: no cover - 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. @@ -221,7 +221,7 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple """ ... # pragma: no cover - 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. @@ -233,7 +233,7 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, """ ... # pragma: no cover - def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, tuple]: + def format_trigger_info_query_statement(self, expr: "TriggerInfoQueryStatement") -> Tuple[str, tuple]: """ Format single trigger information query. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/introspect/transaction.py b/src/rhosocial/activerecord/backend/dialect/protocols/introspect/transaction.py index 53a5cfc15..b77ae54ac 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/introspect/transaction.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/introspect/transaction.py @@ -10,12 +10,12 @@ if TYPE_CHECKING: # pragma: no cover from ....expression import ( - BeginTransactionExpression, - CommitTransactionExpression, - ReleaseSavepointExpression, - RollbackTransactionExpression, - SavepointExpression, - SetTransactionExpression, + BeginTransactionStatement, + CommitStatement, + ReleaseSavepointStatement, + RollbackStatement, + SavepointStatement, + SetTransactionStatement, ) @@ -84,8 +84,8 @@ def supports_transaction_wait(self) -> bool: Firebird-specific: ``SET TRANSACTION ... [WAIT | NO WAIT]``. The probe answers the capability question; ``wait`` / ``no_wait`` on - :class:`~....expression.transaction.BeginTransactionExpression` and - :class:`~....expression.transaction.SetTransactionExpression` select + :class:`~....expression.transaction.BeginTransactionStatement` and + :class:`~....expression.transaction.SetTransactionStatement` select the spelling. Defaults to False for dialects whose grammar has no such clause. """ @@ -100,12 +100,12 @@ def supports_savepoint(self) -> bool: # ========== SQL Formatting ========== - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """ Format BEGIN TRANSACTION statement. Args: - expr: BeginTransactionExpression with isolation level and mode. + expr: BeginTransactionStatement with isolation level and mode. Returns: Tuple of (SQL string, parameters tuple). @@ -126,62 +126,62 @@ def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[ """ ... # pragma: no cover - def format_commit_transaction(self, expr: "CommitTransactionExpression") -> Tuple[str, tuple]: + def format_commit_statement(self, expr: "CommitStatement") -> Tuple[str, tuple]: """ Format COMMIT statement. Args: - expr: CommitTransactionExpression. + expr: CommitStatement. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_rollback_transaction(self, expr: "RollbackTransactionExpression") -> Tuple[str, tuple]: + def format_rollback_statement(self, expr: "RollbackStatement") -> Tuple[str, tuple]: """ Format ROLLBACK statement. Args: - expr: RollbackTransactionExpression with optional savepoint. + expr: RollbackStatement with optional savepoint. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_savepoint(self, expr: "SavepointExpression") -> Tuple[str, tuple]: + def format_savepoint_statement(self, expr: "SavepointStatement") -> Tuple[str, tuple]: """ Format SAVEPOINT statement. Args: - expr: SavepointExpression with savepoint name. + expr: SavepointStatement with savepoint name. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_release_savepoint(self, expr: "ReleaseSavepointExpression") -> Tuple[str, tuple]: + def format_release_savepoint_statement(self, expr: "ReleaseSavepointStatement") -> Tuple[str, tuple]: """ Format RELEASE SAVEPOINT statement. Args: - expr: ReleaseSavepointExpression with savepoint name. + expr: ReleaseSavepointStatement with savepoint name. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """ Format SET TRANSACTION statement. Used primarily by MySQL to set isolation level before START TRANSACTION. Args: - expr: SetTransactionExpression with isolation level and/or mode. + expr: SetTransactionStatement with isolation level and/or mode. Returns: Tuple of (SQL string, parameters tuple). diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/array.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/array.py index 2c39f9339..b9835a472 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/array.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/array.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression.advanced_functions import ArrayExpression + from ....expression.advanced_functions import ArrayValue @runtime_checkable @@ -30,12 +30,12 @@ def supports_array_access(self) -> bool: def format_array_expression( self, - expr: "ArrayExpression", + expr: "ArrayValue", ) -> Tuple[str, Tuple]: """Format array expression. Args: - expr: ArrayExpression node carrying all formatting state + expr: ArrayValue node carrying all formatting state (operation, elements, base_expr, index_expr). Returns: diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/collation.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/collation.py index 10dcb99d4..9b291ef64 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/collation.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/collation.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression.collation import CollateExpression + from ....expression.collation import CollateFunction @runtime_checkable @@ -20,10 +20,10 @@ def supports_collate_expression(self) -> bool: """Whether expression-level COLLATE is supported.""" ... # pragma: no cover - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate a collation expression and return its SQL representation.""" ... # pragma: no cover - def format_collate_expression(self, expr: "CollateExpression") -> Tuple[str, tuple]: + def format_collate_expression(self, expr: "CollateFunction") -> Tuple[str, tuple]: """Format expression-level COLLATE.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/explain.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/explain.py index 6e3d03f38..7c54989e3 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/explain.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/explain.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression import ExplainExpression + from ....expression import ExplainStatement @runtime_checkable @@ -39,12 +39,12 @@ def supports_explain_format(self, format_type: str) -> bool: """ ... # pragma: no cover - def format_explain_statement(self, expr: "ExplainExpression") -> Tuple[str, tuple]: + def format_explain_statement(self, expr: "ExplainStatement") -> Tuple[str, tuple]: """ Format EXPLAIN statement. Args: - expr: ExplainExpression object + expr: ExplainStatement object Returns: Tuple of (SQL string, parameters tuple) for the formatted statement. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/filter_clause.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/filter_clause.py index 56a7c995f..11a806145 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/filter_clause.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/filter_clause.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression.statements.filter_clause import FilterClauseExpression + from ....expression.statements.filter_clause import FilterClause @runtime_checkable @@ -20,12 +20,12 @@ def supports_filter_clause(self) -> bool: """Whether FILTER (WHERE ...) clause is supported in aggregate functions.""" ... # pragma: no cover - def format_filter_clause(self, expr: "FilterClauseExpression") -> Tuple[str, tuple]: + def format_filter_clause(self, expr: "FilterClause") -> Tuple[str, tuple]: """ Format a FILTER (WHERE ...) clause. Args: - expr: FilterClauseExpression wrapping the condition. + expr: FilterClause wrapping the condition. Returns: Tuple of (SQL string, parameters tuple) for the formatted clause. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/graph_table.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/graph_table.py index a7bbbcf81..cb3be4a00 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/graph_table.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/graph_table.py @@ -10,11 +10,11 @@ if TYPE_CHECKING: # pragma: no cover from ....expression import ( - AlterPropertyGraphExpression, + AlterPropertyGraphStatement, ColumnsClause, - CreatePropertyGraphExpression, - DropPropertyGraphExpression, - EdgeTable, + CreatePropertyGraphStatement, + DropPropertyGraphStatement, + EdgeTableDefinition, GraphTableExpression, TablePropertiesClause, VertexTable, @@ -77,48 +77,48 @@ def format_vertex_table(self, vt: "VertexTable") -> Tuple[str, tuple]: """ ... # pragma: no cover - def format_edge_table(self, et: "EdgeTable") -> Tuple[str, tuple]: + def format_edge_table_definition(self, et: "EdgeTableDefinition") -> Tuple[str, tuple]: """ Formats an edge table definition for CREATE PROPERTY GRAPH. Args: - et: EdgeTable object. + et: EdgeTableDefinition object. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_create_property_graph_statement(self, expr: "CreatePropertyGraphExpression") -> Tuple[str, tuple]: + def format_create_property_graph_statement(self, expr: "CreatePropertyGraphStatement") -> Tuple[str, tuple]: """ Formats a CREATE PROPERTY GRAPH statement. Args: - expr: CreatePropertyGraphExpression object. + expr: CreatePropertyGraphStatement object. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_drop_property_graph_statement(self, expr: "DropPropertyGraphExpression") -> Tuple[str, tuple]: + def format_drop_property_graph_statement(self, expr: "DropPropertyGraphStatement") -> Tuple[str, tuple]: """ Formats a DROP PROPERTY GRAPH statement. Args: - expr: DropPropertyGraphExpression object. + expr: DropPropertyGraphStatement object. Returns: Tuple of (SQL string, parameters tuple). """ ... # pragma: no cover - def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphExpression") -> Tuple[str, tuple]: + def format_alter_property_graph_statement(self, expr: "AlterPropertyGraphStatement") -> Tuple[str, tuple]: """ Formats an ALTER PROPERTY GRAPH statement. Args: - expr: AlterPropertyGraphExpression object. + expr: AlterPropertyGraphStatement object. Returns: Tuple of (SQL string, parameters tuple). diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/ilike.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/ilike.py index 3ffb2f78a..9ad57992b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/ilike.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/ilike.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression import ILIKEExpression + from ....expression import ILikePredicate @runtime_checkable @@ -29,7 +29,7 @@ def supports_ilike(self) -> bool: """Whether ILIKE operator is supported.""" ... # pragma: no cover - def format_ilike_expression(self, expr: "ILIKEExpression") -> Tuple[str, Tuple]: + def format_ilike_predicate(self, expr: "ILikePredicate") -> Tuple[str, Tuple]: """ Format ILIKE expression (case-insensitive pattern matching). diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/json.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/json.py index 0c24b6281..3943d739b 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/json.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/json.py @@ -11,7 +11,7 @@ from ....expression import bases if TYPE_CHECKING: # pragma: no cover - from ....expression import JSONDocumentExpression + from ....expression import JSONDocumentValue @runtime_checkable @@ -48,7 +48,7 @@ def supports_json_table(self, dialect_options: Optional[Dict[str, Any]] = None) """ ... # pragma: no cover - def format_json_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """ Format JSON expression. @@ -60,14 +60,14 @@ def format_json_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, T - ``JSONPathMode.AUTO``: use arrow if supported, else function-based Args: - expr: JSONDocumentExpression node carrying column, path, and operation info. + expr: JSONDocumentValue node carrying column, path, and operation info. Returns: Tuple of (SQL string, parameters tuple) for the formatted expression. """ ... # pragma: no cover - def format_json_arrow_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_arrow_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """ Force-arrow JSON path formatting. @@ -77,7 +77,7 @@ def format_json_arrow_expression(self, expr: "JSONDocumentExpression") -> Tuple[ """ ... # pragma: no cover - def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, Tuple]: + def format_json_function_expression(self, expr: "JSONDocumentValue") -> Tuple[str, Tuple]: """ Force-function JSON path formatting. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/locking.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/locking.py index cba9ab40d..a2533d897 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/locking.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/locking.py @@ -27,6 +27,14 @@ def supports_for_update_skip_locked(self) -> bool: """Whether FOR UPDATE SKIP LOCKED is supported.""" ... # pragma: no cover + def supports_for_update_nowait(self) -> bool: + """Whether FOR UPDATE NOWAIT is supported. + + Distinct from SKIP LOCKED on purpose: a dialect may accept one and + reject the other, so one probe cannot answer for both. + """ + ... # pragma: no cover + def supports_for_share(self) -> bool: """Whether the FOR SHARE lock strength is supported.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/merge.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/merge.py index 723dfc5ff..aa2dea07c 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/merge.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/merge.py @@ -9,7 +9,7 @@ from typing import Tuple, Protocol, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ....expression import MergeExpression + from ....expression import MergeStatement @runtime_checkable @@ -20,12 +20,12 @@ def supports_merge_statement(self) -> bool: """Whether MERGE statement is supported.""" ... # pragma: no cover - def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: + def format_merge_statement(self, expr: "MergeStatement") -> Tuple[str, tuple]: """ - Formats a complete MERGE statement from a MergeExpression object. + Formats a complete MERGE statement from a MergeStatement object. Args: - expr: MergeExpression object containing the merge specifications + expr: MergeStatement object containing the merge specifications Returns: Tuple of (SQL string, parameters tuple) for the formatted statement. diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/set_operation.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/set_operation.py index fc2b562a7..ed0e916d3 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/set_operation.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/set_operation.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/dialect/protocols/setoperationsupport.py +# src/rhosocial/activerecord/backend/dialect/protocols/query/set_operation.py """SetOperationSupport protocol. Re-exported from :mod:`rhosocial.activerecord.backend.dialect.protocols`; @@ -30,25 +30,8 @@ def supports_except(self) -> bool: """Whether EXCEPT operation is supported.""" ... # pragma: no cover - def supports_set_operation_order_by(self) -> bool: - """Whether set operations support ORDER BY clauses.""" - ... # pragma: no cover - - def supports_set_operation_limit_offset(self) -> bool: - """Whether set operations support LIMIT and OFFSET clauses.""" - ... # pragma: no cover - - def supports_set_operation_for_update(self) -> bool: - """Whether set operations support FOR UPDATE clauses.""" - ... # pragma: no cover - - def format_set_operation_expression( + def format_set_operation( self, expr: "bases.BaseExpression" ) -> Tuple[str, Tuple]: - """Format a SetOperationExpression node (UNION, INTERSECT, EXCEPT).""" + """Format a SetOperation node (UNION, INTERSECT, EXCEPT).""" ... # pragma: no cover - - -# ============================================================ -# DDL (Data Definition Language) Support Protocols -# ============================================================ diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/temporal.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/temporal.py index cbf8fdd14..f7a9d8b2c 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/temporal.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/temporal.py @@ -20,7 +20,7 @@ def supports_temporal_tables(self) -> bool: """Whether temporal table queries are supported.""" ... # pragma: no cover - def format_temporal_options(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: + def format_temporal_options_expression(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: """ Formats a temporal table clause (e.g., FOR SYSTEM_TIME AS OF ...). diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/uuid.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/uuid.py index 8c0c75ddb..1deb49464 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/uuid.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/uuid.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover from ....expression.uuid import ( - UUIDCastExpression, + UUIDCastFunction, UUIDConstantExpression, UUIDGenerationExpression, ) @@ -50,7 +50,7 @@ def supports_uuid_cast(self) -> bool: """ ... # pragma: no cover - def format_uuid_generation(self, expr: "UUIDGenerationExpression") -> Tuple[str, Tuple]: + def format_uuid_generation_expression(self, expr: "UUIDGenerationExpression") -> Tuple[str, Tuple]: """Render a fresh-UUID expression. Args: @@ -61,7 +61,7 @@ def format_uuid_generation(self, expr: "UUIDGenerationExpression") -> Tuple[str, """ ... # pragma: no cover - def format_uuid_constant(self, expr: "UUIDConstantExpression") -> Tuple[str, Tuple]: + def format_uuid_constant_expression(self, expr: "UUIDConstantExpression") -> Tuple[str, Tuple]: """Render the nil or max UUID constant. Args: @@ -72,11 +72,11 @@ def format_uuid_constant(self, expr: "UUIDConstantExpression") -> Tuple[str, Tup """ ... # pragma: no cover - def format_uuid_cast(self, expr: "UUIDCastExpression") -> Tuple[str, Tuple]: + def format_uuid_cast(self, expr: "UUIDCastFunction") -> Tuple[str, Tuple]: """Render a text-to-UUID conversion. Args: - expr: UUIDCastExpression node. + expr: UUIDCastFunction node. Returns: Tuple of (SQL string, parameters tuple). diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/query/wildcard.py b/src/rhosocial/activerecord/backend/dialect/protocols/query/wildcard.py index b58e82a26..91536b337 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/query/wildcard.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/query/wildcard.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/dialect/protocols/wildcardsupport.py +# src/rhosocial/activerecord/backend/dialect/protocols/query/wildcard.py """WildcardSupport protocol. Re-exported from :mod:`rhosocial.activerecord.backend.dialect.protocols`; @@ -12,8 +12,18 @@ @runtime_checkable class WildcardSupport(Protocol): - """Protocol for wildcard expression support (SELECT *).""" + """Protocol for wildcard support in its two grammar positions. - def format_wildcard(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: - """Format a WildcardExpression node (* or table.* or schema.table.*).""" + The asterisk is one lexical token in two contracts (spec 4.10): the + select-list/RETURNING expansion and the aggregate row count. Each names + its own formatter; a dialect spells one differently by overriding that + one entry alone. + """ + + def format_all_columns_wildcard(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Format an AllColumnsWildcard node (* or rel.*).""" + ... # pragma: no cover + + def format_row_count_wildcard(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: + """Format a RowCountWildcard node (the bare * of COUNT(*)).""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_aggregation.py b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_aggregation.py index b388a218f..c13e24a92 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_aggregation.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_aggregation.py @@ -20,6 +20,6 @@ def supports_xmlagg(self) -> bool: """Whether SQL/XML XMLAGG is supported.""" ... # pragma: no cover - def format_xmlagg_expression(self, expr: "XMLAggExpression") -> Tuple[str, tuple]: + def format_xml_agg_expression(self, expr: "XMLAggExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLAGG expression.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_construction.py b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_construction.py index e8011da93..ca7805e43 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_construction.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_construction.py @@ -52,23 +52,23 @@ def supports_xmlroot(self) -> bool: """Whether SQL/XML XMLROOT is supported.""" ... # pragma: no cover - def format_xmlattributes_expression(self, expr: "XMLAttributesExpression") -> Tuple[str, tuple]: + def format_xml_attributes_expression(self, expr: "XMLAttributesExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLATTRIBUTES clause.""" ... # pragma: no cover - def format_xmlelement_expression(self, expr: "XMLElementExpression") -> Tuple[str, tuple]: + def format_xml_element_expression(self, expr: "XMLElementExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLELEMENT expression.""" ... # pragma: no cover - def format_xmlforest_expression(self, expr: "XMLForestExpression") -> Tuple[str, tuple]: + def format_xml_forest_expression(self, expr: "XMLForestExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLFOREST expression.""" ... # pragma: no cover - def format_xmlconcat_expression(self, expr: "XMLConcatExpression") -> Tuple[str, tuple]: + def format_xml_concat_expression(self, expr: "XMLConcatExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLCONCAT expression.""" ... # pragma: no cover - def format_xmlcomment_expression(self, expr: "XMLCommentExpression") -> Tuple[str, tuple]: + def format_xml_comment_expression(self, expr: "XMLCommentExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLCOMMENT expression.""" ... # pragma: no cover @@ -76,6 +76,6 @@ def format_xmlpi_expression(self, expr: "XMLPIExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLPI expression.""" ... # pragma: no cover - def format_xmlroot_expression(self, expr: "XMLRootExpression") -> Tuple[str, tuple]: + def format_xml_root_expression(self, expr: "XMLRootExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLROOT expression.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_parsing.py b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_parsing.py index dfb882e6d..46a522df1 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_parsing.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_parsing.py @@ -20,7 +20,7 @@ def supports_xmlparse(self) -> bool: """Whether SQL/XML XMLPARSE is supported.""" ... # pragma: no cover - def format_xmlparse_expression( + def format_xml_parse_expression( self, expr: "XMLParseExpression", ) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_querying.py b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_querying.py index 100c98915..3786e5d7e 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_querying.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_querying.py @@ -16,7 +16,8 @@ if TYPE_CHECKING: # pragma: no cover from ....expression.xml import ( - XMLExistsExpression, + PassingClause, + XMLExistsPredicate, XMLQueryExpression, XMLTableExpression, ) @@ -38,15 +39,24 @@ def supports_xmltable(self) -> bool: """Whether SQL/XML XMLTABLE is supported.""" ... # pragma: no cover - def format_xmlquery_expression(self, expr: "XMLQueryExpression") -> Tuple[str, tuple]: + def format_passing_clause(self, clause: "PassingClause") -> Tuple[str, tuple]: + """Format a SQL/XML ``PASSING`` clause. + + Public because all three querying expressions compose it: a backend + that spells ``PASSING`` differently overrides this one formatter + instead of the three that render around it. + """ + ... # pragma: no cover + + def format_xml_query_expression(self, expr: "XMLQueryExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLQUERY expression.""" ... # pragma: no cover - def format_xmlexists_expression(self, expr: "XMLExistsExpression") -> Tuple[str, tuple]: + def format_xmlexists_predicate(self, expr: "XMLExistsPredicate") -> Tuple[str, tuple]: """Format a SQL/XML XMLEXISTS predicate.""" ... # pragma: no cover - def format_xmltable_expression(self, expr: "XMLTableExpression") -> Tuple[str, tuple]: + def format_xml_table_expression(self, expr: "XMLTableExpression") -> Tuple[str, tuple]: """Format a SQL/XML XMLTABLE expression.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_serialization.py b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_serialization.py index 146c0c2a2..b919dcd09 100644 --- a/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_serialization.py +++ b/src/rhosocial/activerecord/backend/dialect/protocols/sqlxml/xml_serialization.py @@ -20,7 +20,7 @@ def supports_xmlserialize(self) -> bool: """Whether SQL/XML XMLSERIALIZE is supported.""" ... # pragma: no cover - def format_xmlserialize_expression( + def format_xml_serialize_expression( self, expr: "XMLSerializeExpression", ) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/explain/backend_mixin.py b/src/rhosocial/activerecord/backend/explain/backend_mixin.py index efd0c5b22..1fa031ed3 100644 --- a/src/rhosocial/activerecord/backend/explain/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/explain/backend_mixin.py @@ -6,7 +6,7 @@ ------------ _ExplainMixinBase Shared non-I/O logic: - - _build_explain_sql(): wraps the given expression in ExplainExpression + - _build_explain_sql(): wraps the given expression in ExplainStatement and calls to_sql() via the dialect. - _parse_explain_result(): hook for concrete backends to return a specialised ExplainResult subclass (default: BaseExplainResult). @@ -61,7 +61,7 @@ def _build_explain_sql( expression: "BaseExpression", options: Optional["ExplainOptions"] = None, ) -> Tuple[str, tuple]: - """Wrap *expression* in an ExplainExpression and return (sql, params). + """Wrap *expression* in an ExplainStatement and return (sql, params). Args: expression: The statement to be explained. @@ -70,9 +70,9 @@ def _build_explain_sql( Returns: A (sql_string, params_tuple) pair ready to pass to execute(). """ - from ..expression.statements import ExplainExpression + from ..expression.statements import ExplainStatement - explain_expr = ExplainExpression(self.dialect, expression, options) # type: ignore[attr-defined] + explain_expr = ExplainStatement(self.dialect, expression, options) # type: ignore[attr-defined] return explain_expr.to_sql() # ------------------------------------------------------------------ @@ -125,7 +125,7 @@ def explain( """Execute EXPLAIN *expression* synchronously. Args: - expression: Any BaseExpression except ExplainExpression itself. + expression: Any BaseExpression except ExplainStatement itself. options: Optional ExplainOptions. Returns: @@ -161,7 +161,7 @@ async def explain( """Execute EXPLAIN *expression* asynchronously. Args: - expression: Any BaseExpression except ExplainExpression itself. + expression: Any BaseExpression except ExplainStatement itself. options: Optional ExplainOptions. Returns: diff --git a/src/rhosocial/activerecord/backend/explain/protocols.py b/src/rhosocial/activerecord/backend/explain/protocols.py index 6ea9a1b08..ea19e7a13 100644 --- a/src/rhosocial/activerecord/backend/explain/protocols.py +++ b/src/rhosocial/activerecord/backend/explain/protocols.py @@ -34,8 +34,8 @@ def explain( """Execute EXPLAIN for *expression* and return a structured result. Args: - expression: Any BaseExpression except ExplainExpression itself - (e.g. QueryExpression, InsertExpression). + expression: Any BaseExpression except ExplainStatement itself + (e.g. QueryStatement, InsertStatement). options: Optional ExplainOptions controlling the EXPLAIN mode and output format. diff --git a/src/rhosocial/activerecord/backend/expression/__init__.py b/src/rhosocial/activerecord/backend/expression/__init__.py index 6dbef63a1..779c4d0b9 100644 --- a/src/rhosocial/activerecord/backend/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/expression/__init__.py @@ -1,323 +1,11 @@ -# src/rhosocial/activerecord/backend/expression/__init__.py -""" -SQL Expression building blocks. - -Architecture Principles: -- Expression classes implement the ToSQLProtocol and define how to generate SQL -- Each expression class must call its dialect's format_* methods instead of self-formatting -- Dialect classes are responsible for the actual SQL formatting and parameter handling -- Expression classes should never directly concatenate SQL strings; they should delegate to dialect -- This pattern ensures each dialect can customize formatting behavior while maintaining security -- The relationship: Expression.to_sql() -> Dialect.format_*() -> SQL string and parameters -""" - -from .bases import ( - ToSQLProtocol, - BaseExpression, - SQLValueExpression, - SQLPredicate, - SQLQueryAndParams, - is_sql_query_and_params, -) -from .executable import Executable -from .mixins import ( - AliasableMixin, - ArrayMixin, - DateTimeMixin, - StringToIntegerMixin, - JSONAccessorMixin, - StringPatternPredicateMixin, - StringValueMixin, - TemporalArithmeticMixin, - TranscendentalMixin, - build_json_path, -) -from .literals import ( - Identifier, -) -from .operators import ( - SQLOperation, - BinaryExpression, - UnaryExpression, - RawSQLExpression, - RawSQLPredicate, - BinaryArithmeticExpression, -) -from .column_types import ( - ColumnBase, - StringColumn, - IntegerColumn, - NumericColumn, - TimestampColumn, - BooleanColumn, - BinaryColumn, - UUIDColumn, - JSONColumn, - ArrayColumn, - XMLColumn, -) -from .uuid import ( - UUIDGenerationExpression, - UUIDConstantExpression, - UUIDCastExpression, -) -from .core import ( - Column, - FunctionCall, - IntegerValueExpression, - StringValueExpression, - XMLValueExpression, - Subquery, - Literal, - WildcardExpression, - QualifiedIdentifierExpression, -) -from .collation import ( - CollateExpression, - collate, -) -from .datetime import ( - DateTimeField, - IntervalUnit, - ExtractExpression, - DatePartExpression, - DateTruncExpression, - IntervalExpression, - DateTimeAddExpression, - DateTimeSubtractExpression, - DateTimeDiffExpression, -) -from .xml import ( - XMLAggExpression, - XMLAttribute, - XMLAttributesExpression, - XMLCommentExpression, - XMLConcatExpression, - XMLElementExpression, - XMLEmptyHandlingOption, - XMLExistsExpression, - XMLForestExpression, - XMLForestItem, - XMLParseDocumentType, - XMLParseExpression, - XMLPassingMechanism, - XMLPIExpression, - XMLQueryExpression, - XMLRootExpression, - XMLSerializeDocumentType, - XMLSerializeExpression, - XMLStandaloneOption, - XMLTableColumn, - XMLTableColumnOption, - XMLTableExpression, - XMLWhitespaceOption, -) -from .predicates import ( - ComparisonPredicate, - DistinctFromPredicate, - LogicalPredicate, - LikePredicate, - ILIKEExpression, - InPredicate, - BetweenPredicate, - IsNullPredicate, - IsBooleanPredicate, -) -from .advanced_functions import ( - CaseExpression, - ExistsExpression, - TrimExpression, - RepeatExpression, - LpadExpression, - RpadExpression, - AnyExpression, - AllExpression, - WindowFrameSpecification, - WindowSpecification, - WindowDefinition, - WindowClause, - JSONDocumentExpression, - JSONTextExpression, - JSONPathMode, - ArrayExpression, - OrderedSetAggregation, -) -from .query_parts import ( - GroupingClause, - JoinClause, - JoinType, - WhereClause, - GroupByHavingClause, - OrderByClause, - OrderByExpression, - LimitOffsetClause, - QualifyClause, - ForUpdateClause, - LockStrength, -) -from .pivot import PivotExpression, UnpivotExpression -from .query_sources import ( - SetOperationExpression, - CTEExpression, - WithQueryExpression, - ValuesExpression, - TableFunctionExpression, - LateralExpression, - JSONTableColumn, - JSONTableExpression, -) -from .statements import ( - QueryExpression, - DeleteExpression, - UpdateExpression, - InsertExpression, - ExplainExpression, - MergeActionType, - MergeAction, - MergeExpression, - SelectModifier, - ExplainType, - ExplainFormat, - ExplainOptions, - ReturningClause, - # Insert Abstractions - InsertDataSource, - ValuesSource, - SelectSource, - DefaultValuesSource, - OnConflictClause, - # DDL Expressions - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - DefaultValueClause, - IdentityClause, - AutoIncrementClause, - ReferencesClause, - ColumnCommentClause, - TableCommentClause, - CommentObjectType, - CommentOnExpression, - IndexDefinition, - PartitionClause, - PartitionDefinition, - PartitionStrategy, - SubpartitionDefinition, - TableConstraint, - TableConstraintType, - ConstraintValidation, - ForeignKeyConstraint, - CreateTableExpression, - CreateTableAsExpression, - CreateTableLikeExpression, - CreateTableCloneExpression, - CreateTableFromTemplateExpression, - CreateTableOptions, - CreateTableCloneMode, - DropTableExpression, - CreateViewExpression, - DropViewExpression, - ViewOptions, - ViewCheckOption, - AlterTableAction, - AddColumn, - DropColumn, - AlterColumn, - AddTableConstraint, - DropTableConstraint, - AlterConstraint, - ValidateConstraint, - AlterConstraintAction, - ValidateConstraintAction, - AlterTableConstraint, - ValidateTableConstraint, - RenameObject, - AddIndex, - DropIndex, - AlterTableExpression, - TruncateExpression, - # New DDL Expressions - CreateSchemaExpression, - DropSchemaExpression, - CreateIndexExpression, - DropIndexExpression, - CreateSequenceExpression, - DropSequenceExpression, - AlterSequenceExpression, - TypeDefinition, - TypeAlterAction, - CreateTypeExpression, - AlterTypeExpression, - DropTypeExpression, - DomainNullability, - DomainValueExpression, - DomainCheckConstraint, - DomainAlterAction, - SetDomainDefaultAction, - DropDomainDefaultAction, - SetDomainNotNullAction, - DropDomainNotNullAction, - AddDomainCheckAction, - DropDomainCheckAction, - RenameDomainAction, - CreateDomainExpression, - AlterDomainExpression, - DropDomainExpression, - # Materialized View DDL Expressions - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - # Trigger DDL Expressions - CreateTriggerExpression, - DropTriggerExpression, - TriggerTiming, - TriggerEvent, - TriggerLevel, - # Function DDL Expressions - CreateFunctionExpression, - DropFunctionExpression, -) -from .graph import ( - GraphEdgeDirection, - GraphVertex, - GraphEdge, - QuantifiedPath, - PathPattern, - MatchClause, - GraphColumn, - ColumnsClause, - GraphTableExpression, - TablePropertiesClause, - VertexTable, - EdgeTable, - CreatePropertyGraphExpression, - DropPropertyGraphExpression, - AlterPropertyGraphExpression, -) -from .introspection import ( - IntrospectionExpression, - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, -) -from .transaction import ( - TransactionExpression, - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, -) - -# Import all function factories from .functions import ( + # Aggregate function nodes + AggregateFunction, + CountFunction, + SumFunction, + AvgFunction, + MinFunction, + MaxFunction, # Aggregate function factories count, sum_, @@ -327,6 +15,10 @@ # Scalar function factories concat, coalesce, + # String function nodes + UpperFunction, + LowerFunction, + CharLengthFunction, # String function factories length, substring, @@ -443,18 +135,352 @@ session_user, system_user, ) +from .transaction import ( + TransactionStatement, + BeginTransactionStatement, + CommitStatement, + RollbackStatement, + SavepointStatement, + ReleaseSavepointStatement, + SetTransactionStatement, +) +from .introspection import ( + IntrospectionExpression, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, +) +from .graph import ( + GraphEdgeDirection, + GraphVertex, + GraphEdge, + QuantifiedPath, + PathPattern, + MatchClause, + GraphColumn, + ColumnsClause, + GraphTableExpression, + TablePropertiesClause, + VertexTable, + EdgeTableDefinition, + CreatePropertyGraphStatement, + DropPropertyGraphStatement, + AlterPropertyGraphStatement, +) +from .statements import ( + QueryStatement, + DeleteStatement, + UpdateStatement, + InsertStatement, + ExplainStatement, + MergeActionType, + MergeAction, + MergeStatement, + SelectModifier, + ExplainType, + ExplainFormat, + ExplainOptions, + ReturningClause, + InsertDataSource, + ValuesSource, + SelectSource, + DefaultValuesSource, + OnConflictClause, + ColumnDefinition, + ColumnConstraint, + DefaultValueClause, + IdentityClause, + AutoIncrementClause, + ReferencesClause, + ColumnCommentClause, + TableCommentClause, + CommentObjectType, + CommentOnStatement, + IndexDefinition, + PartitionClause, + PartitionDefinition, + PartitionStrategy, + SubpartitionDefinition, + TableConstraint, + ConstraintValidation, + ForeignKeyConstraint, + CreateTableStatement, + CreateTableAsStatement, + CreateTableLikeStatement, + CreateTableCloneStatement, + CreateTableFromTemplateStatement, + CreateTableOptions, + CreateTableCloneMode, + DropTableStatement, + CreateViewStatement, + DropViewStatement, + ViewOptions, + ViewCheckOption, + AlterTableAction, + AddColumnAction, + DropColumnAction, + AlterColumnAction, + AddTableConstraintAction, + DropTableConstraintAction, + AlterConstraintAction, + ValidateConstraintAction, + + RenameColumnAction, + AddIndexAction, + DropIndexAction, + AlterTableStatement, + TruncateStatement, + CreateSchemaStatement, + DropSchemaStatement, + CreateIndexStatement, + DropIndexStatement, + CreateSequenceStatement, + DropSequenceStatement, + AlterSequenceStatement, + TypeDefinition, + TypeAlterAction, + CreateTypeStatement, + AlterTypeStatement, + DropTypeStatement, + DomainNullability, + DomainValueExpression, + DomainCheckConstraint, + DomainAlterAction, + SetDomainDefaultAction, + DropDomainDefaultAction, + SetDomainNotNullAction, + DropDomainNotNullAction, + AddDomainCheckAction, + DropDomainCheckAction, + RenameDomainAction, + CreateDomainStatement, + AlterDomainStatement, + DropDomainStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, + CreateTriggerStatement, + DropTriggerStatement, + TriggerTiming, + TriggerEvent, + TriggerLevel, + CreateFunctionStatement, + DropFunctionStatement, + ColumnConstraintBranch, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + TableConstraintBranch, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, + TableExcludeConstraint, +) +from .query_sources import ( + SetOperation, + CTEClause, + WithStatement, + ValuesExpression, + TableFunctionExpression, + LateralExpression, + JSONTableColumn, + JSONTableExpression, +) +from .pivot import Pivot, Unpivot +from .query_parts import ( + GroupingClause, + JoinClause, + JoinType, + WhereClause, + GroupByHavingClause, + OrderByClause, + OrderByItem, + LimitOffsetClause, + QualifyClause, + ForUpdateClause, + LockStrength, +) +from .advanced_functions import ( + CaseExpression, + ExistsPredicate, + TrimExpression, + RepeatFunction, + LpadFunction, + RpadFunction, + AnyPredicate, + AllPredicate, + WindowFrameSpecification, + WindowSpecification, + WindowDefinition, + WindowClause, + JSONDocumentValue, + JSONTextValue, + JSONPathMode, + ArrayOperation, + OrderedSetAggregation, +) +from .predicates import ( + ComparisonOperator, + ComparisonPredicate, + DistinctFromPredicate, + LogicalPredicate, + LikePredicate, + LikeOperator, + ILikePredicate, + InPredicate, + BetweenPredicate, + IsNullPredicate, + IsBooleanPredicate, +) +from .xml import ( + PassingClause, + XMLAggExpression, + XMLAttribute, + XMLAttributesExpression, + XMLCommentExpression, + XMLConcatExpression, + XMLElementExpression, + XMLEmptyHandlingOption, + XMLExistsPredicate, + XMLForestExpression, + XMLForestItem, + XMLParseDocumentType, + XMLParseExpression, + XMLPassingMechanism, + XMLPIExpression, + XMLQueryExpression, + XMLRootExpression, + XMLSerializeDocumentType, + XMLSerializeExpression, + XMLStandaloneOption, + XMLTableColumn, + XMLTableColumnOption, + XMLTableExpression, + XMLWhitespaceOption, +) +from .datetime import ( + DateTimeField, + IntervalUnit, + ExtractExpression, + DatePartExpression, + DateTruncExpression, + IntervalExpression, + DatetimeAddExpression, + DatetimeSubtractExpression, + DatetimeDiffExpression, +) +from .collation import ( + CollateFunction, + collate, +) +from .value_types import ( + ArrayValue, + BooleanValue, + JSONValue, + NumericValue, + OpaqueValue, + TemporalValue, + TextValue, + XMLValue, +) +from .values import LiteralKind +from .core import ( + Column, + FunctionCall, + Subquery, + Literal, + AllColumnsWildcard, + RowCountWildcard, + QualifiedIdentifier, +) +from .sources.relation import NamedRelationRef +from .uuid import ( + UUIDGenerationExpression, + UUIDConstantExpression, + UUIDCastFunction, +) +from .column_types import ( + ColumnBase, + StringColumn, + IntegerColumn, + NumericColumn, + TimestampColumn, + BooleanColumn, + BinaryColumn, + UUIDColumn, + JSONColumn, + ArrayColumn, + XMLColumn, +) +from .operators import ( + SQLOperation, + BinaryExpression, + UnaryExpression, + RawSQL, + RawSQLPredicate, + ArithmeticOperation, + ConcatenationOperation, +) +from .literals import ( + Identifier, +) +from .mixins import ( + AliasableMixin, + ArrayMixin, + DateTimeMixin, + StringToIntegerMixin, + JSONAccessorMixin, + StringPatternPredicateMixin, + StringValueMixin, + TemporalArithmeticMixin, + TranscendentalMixin, + build_json_path, +) +from .executable import Executable +from .bases import ( + ToSQLProtocol, + BaseExpression, + SQLValue, + SQLPredicate, + SQLQueryAndParams, + QueryWithParams, +) +# src/rhosocial/activerecord/backend/expression/__init__.py +""" +SQL Expression building blocks. + +Architecture Principles: +- Expression classes implement the ToSQLProtocol and define how to generate SQL +- Each expression class must call its dialect's format_* methods instead of self-formatting +- Dialect classes are responsible for the actual SQL formatting and parameter handling +- Expression classes should never directly concatenate SQL strings; they should delegate to dialect +- This pattern ensures each dialect can customize formatting behavior while maintaining security +- The relationship: Expression.to_sql() -> Dialect.format_*() -> SQL string and parameters +""" + + +# Import all function factories __all__ = [ - # Base classes and type aliases "ToSQLProtocol", "BaseExpression", - "SQLValueExpression", + "SQLValue", "SQLPredicate", "SQLQueryAndParams", - "is_sql_query_and_params", - # Executable protocol + "QueryWithParams", "Executable", - # Mixins "AliasableMixin", "ArrayMixin", "StringPatternPredicateMixin", @@ -465,22 +491,18 @@ "DateTimeMixin", "JSONAccessorMixin", "build_json_path", - # Literals "Identifier", - # Operators "SQLOperation", "BinaryExpression", "UnaryExpression", - "RawSQLExpression", + "RawSQL", "RawSQLPredicate", - "BinaryArithmeticExpression", - # Core expressions + "ArithmeticOperation", + "ConcatenationOperation", "Column", - # UUID value expressions "UUIDGenerationExpression", "UUIDConstantExpression", - "UUIDCastExpression", - # Type-narrowed column expressions + "UUIDCastFunction", "ColumnBase", "StringColumn", "IntegerColumn", @@ -494,26 +516,33 @@ "XMLColumn", "FunctionCall", "Subquery", + "NamedRelationRef", "Literal", - "StringValueExpression", - "IntegerValueExpression", - "XMLValueExpression", - "WildcardExpression", - "QualifiedIdentifierExpression", - # Collation expressions - "CollateExpression", + "ArrayValue", + "BooleanValue", + "JSONValue", + "NumericValue", + "OpaqueValue", + "TemporalValue", + "TextValue", + "XMLValue", + "LiteralKind", + "LiteralKind", + "AllColumnsWildcard", + "RowCountWildcard", + "QualifiedIdentifier", + "CollateFunction", "collate", - # Date/time expressions "DateTimeField", "IntervalUnit", "ExtractExpression", "DatePartExpression", "DateTruncExpression", "IntervalExpression", - "DateTimeAddExpression", - "DateTimeSubtractExpression", - "DateTimeDiffExpression", - # SQL/XML expressions + "DatetimeAddExpression", + "DatetimeSubtractExpression", + "DatetimeDiffExpression", + "PassingClause", "XMLAggExpression", "XMLAttribute", "XMLAttributesExpression", @@ -521,7 +550,7 @@ "XMLConcatExpression", "XMLElementExpression", "XMLEmptyHandlingOption", - "XMLExistsExpression", + "XMLExistsPredicate", "XMLForestExpression", "XMLForestItem", "XMLParseDocumentType", @@ -537,67 +566,63 @@ "XMLTableColumnOption", "XMLTableExpression", "XMLWhitespaceOption", - # Predicates "ComparisonPredicate", + "ComparisonOperator", "DistinctFromPredicate", "LogicalPredicate", "LikePredicate", - "ILIKEExpression", + "LikeOperator", + "ILikePredicate", "InPredicate", "BetweenPredicate", "IsNullPredicate", "IsBooleanPredicate", - # Aggregates and windows are FunctionCall; see core. - # Advanced functions "CaseExpression", - "ExistsExpression", + "ExistsPredicate", "TrimExpression", - "RepeatExpression", - "LpadExpression", - "RpadExpression", - "AnyExpression", - "AllExpression", + "RepeatFunction", + "LpadFunction", + "RpadFunction", + "AnyPredicate", + "AllPredicate", "WindowFrameSpecification", "WindowSpecification", "WindowDefinition", "WindowClause", - "JSONDocumentExpression", - "JSONTextExpression", + "JSONDocumentValue", + "JSONTextValue", "JSONPathMode", - "ArrayExpression", + "ArrayOperation", "OrderedSetAggregation", - # Query parts "GroupingClause", "JoinClause", "JoinType", "WhereClause", "GroupByHavingClause", "OrderByClause", - "OrderByExpression", + "OrderByItem", "LimitOffsetClause", "QualifyClause", "ForUpdateClause", - "PivotExpression", - "UnpivotExpression", + "Pivot", + "Unpivot", "LockStrength", - # Query sources - "SetOperationExpression", - "CTEExpression", - "WithQueryExpression", + "SetOperation", + "CTEClause", + "WithStatement", "ValuesExpression", "TableFunctionExpression", "LateralExpression", "JSONTableColumn", "JSONTableExpression", - # Statements - "QueryExpression", - "DeleteExpression", - "UpdateExpression", - "InsertExpression", - "ExplainExpression", + "QueryStatement", + "DeleteStatement", + "UpdateStatement", + "InsertStatement", + "ExplainStatement", "MergeActionType", "MergeAction", - "MergeExpression", + "MergeStatement", "SelectModifier", "ExplainType", "ExplainFormat", @@ -608,9 +633,7 @@ "SelectSource", "DefaultValuesSource", "OnConflictClause", - # DDL Expressions "ColumnDefinition", - "ColumnConstraint", "DefaultValueClause", "IdentityClause", "AutoIncrementClause", @@ -618,55 +641,49 @@ "ColumnCommentClause", "TableCommentClause", "CommentObjectType", - "CommentOnExpression", - "ColumnConstraintType", + "CommentOnStatement", "IndexDefinition", "TableConstraint", - "TableConstraintType", "ConstraintValidation", "ForeignKeyConstraint", - "CreateTableExpression", - "CreateTableAsExpression", - "CreateTableLikeExpression", - "CreateTableCloneExpression", - "CreateTableFromTemplateExpression", + "CreateTableStatement", + "CreateTableAsStatement", + "CreateTableLikeStatement", + "CreateTableCloneStatement", + "CreateTableFromTemplateStatement", "CreateTableOptions", "CreateTableCloneMode", - "DropTableExpression", - "CreateViewExpression", - "DropViewExpression", + "DropTableStatement", + "CreateViewStatement", + "DropViewStatement", "ViewOptions", "ViewCheckOption", "AlterTableAction", - "AddColumn", - "DropColumn", - "AlterColumn", - "AddTableConstraint", - "DropTableConstraint", - "AlterConstraint", - "ValidateConstraint", + "AddColumnAction", + "DropColumnAction", + "AlterColumnAction", + "AddTableConstraintAction", + "DropTableConstraintAction", "AlterConstraintAction", "ValidateConstraintAction", - "AlterTableConstraint", - "ValidateTableConstraint", - "RenameObject", - "AddIndex", - "DropIndex", - "AlterTableExpression", - "TruncateExpression", - # New DDL Expressions - "CreateSchemaExpression", - "DropSchemaExpression", - "CreateIndexExpression", - "DropIndexExpression", - "CreateSequenceExpression", - "DropSequenceExpression", - "AlterSequenceExpression", + + "RenameColumnAction", + "AddIndexAction", + "DropIndexAction", + "AlterTableStatement", + "TruncateStatement", + "CreateSchemaStatement", + "DropSchemaStatement", + "CreateIndexStatement", + "DropIndexStatement", + "CreateSequenceStatement", + "DropSequenceStatement", + "AlterSequenceStatement", "TypeDefinition", "TypeAlterAction", - "CreateTypeExpression", - "AlterTypeExpression", - "DropTypeExpression", + "CreateTypeStatement", + "AlterTypeStatement", + "DropTypeStatement", "DomainNullability", "DomainValueExpression", "DomainCheckConstraint", @@ -678,28 +695,23 @@ "AddDomainCheckAction", "DropDomainCheckAction", "RenameDomainAction", - "CreateDomainExpression", - "AlterDomainExpression", - "DropDomainExpression", - # Trigger DDL Expressions - "CreateTriggerExpression", - "DropTriggerExpression", + "CreateDomainStatement", + "AlterDomainStatement", + "DropDomainStatement", + "CreateTriggerStatement", + "DropTriggerStatement", "TriggerTiming", "TriggerEvent", "TriggerLevel", - # Function DDL Expressions - "CreateFunctionExpression", - "DropFunctionExpression", - # Partition DDL Expressions + "CreateFunctionStatement", + "DropFunctionStatement", "PartitionClause", "PartitionDefinition", "PartitionStrategy", "SubpartitionDefinition", - # Materialized View DDL Expressions - "CreateMaterializedViewExpression", - "DropMaterializedViewExpression", - "RefreshMaterializedViewExpression", - # Graph + "CreateMaterializedViewStatement", + "DropMaterializedViewStatement", + "RefreshMaterializedViewStatement", "GraphEdgeDirection", "GraphVertex", "GraphEdge", @@ -711,37 +723,43 @@ "GraphTableExpression", "TablePropertiesClause", "VertexTable", - "EdgeTable", - "CreatePropertyGraphExpression", - "DropPropertyGraphExpression", - "AlterPropertyGraphExpression", - # Introspection + "EdgeTableDefinition", + "CreatePropertyGraphStatement", + "DropPropertyGraphStatement", + "AlterPropertyGraphStatement", "IntrospectionExpression", - "TableListExpression", - "TableInfoExpression", - "ColumnInfoExpression", - "IndexInfoExpression", - "ForeignKeyExpression", - "ViewListExpression", - "ViewInfoExpression", - "TriggerListExpression", - "TriggerInfoExpression", - # Transaction Control - "TransactionExpression", - "BeginTransactionExpression", - "CommitTransactionExpression", - "RollbackTransactionExpression", - "SavepointExpression", - "ReleaseSavepointExpression", - "SetTransactionExpression", - # Functions + "TableListQueryStatement", + "TableInfoQueryStatement", + "ColumnInfoQueryStatement", + "IndexInfoQueryStatement", + "ForeignKeyQueryStatement", + "ViewListQueryStatement", + "ViewInfoQueryStatement", + "TriggerListQueryStatement", + "TriggerInfoQueryStatement", + "TransactionStatement", + "BeginTransactionStatement", + "CommitStatement", + "RollbackStatement", + "SavepointStatement", + "ReleaseSavepointStatement", + "SetTransactionStatement", "count", "sum_", "avg", "min_", "max_", + "AggregateFunction", + "CountFunction", + "SumFunction", + "AvgFunction", + "MinFunction", + "MaxFunction", "lower", "upper", + "UpperFunction", + "LowerFunction", + "CharLengthFunction", "concat", "coalesce", "length", @@ -841,6 +859,22 @@ "xmlroot", "xmlserialize", "xmltable", + "ColumnConstraintBranch", + "ColumnConstraint", + "ColumnPrimaryKeyConstraint", + "ColumnUniqueConstraint", + "ColumnNotNullConstraint", + "ColumnNullableConstraint", + "ColumnCheckConstraint", + "ColumnDefaultConstraint", + "ColumnForeignKeyConstraint", + "ColumnGeneratedConstraint", + "TableConstraintBranch", + "TablePrimaryKeyConstraint", + "TableUniqueConstraint", + "TableCheckConstraint", + "TableForeignKeyConstraint", + "TableExcludeConstraint", ] # Backward compatibility aliases (deprecated, use new names) diff --git a/src/rhosocial/activerecord/backend/expression/advanced_functions.py b/src/rhosocial/activerecord/backend/expression/advanced_functions.py index 3d9d0b50a..210bc3103 100644 --- a/src/rhosocial/activerecord/backend/expression/advanced_functions.py +++ b/src/rhosocial/activerecord/backend/expression/advanced_functions.py @@ -18,11 +18,9 @@ TypeCastingMixin, ) -from .bases import BaseExpression, SQLPredicate, SQLValueExpression +from .bases import BaseExpression, SQLPredicate, SQLValue from .core import ( Column, - JSONValueExpression, - StringValueExpression, Subquery, ) @@ -32,6 +30,11 @@ from ..dialect import SQLDialectBase +from .value_types import ( + JSONValue, + TextValue, +) + class DeclaredValueType: """Render this node itself rather than the call a value class would wrap. @@ -44,7 +47,7 @@ class DeclaredValueType: wrapper's delegation to a node it does not hold. Inheriting the value class is what makes the result readable off the - object: ``isinstance(expr, TimestampValueExpression)`` holds because the + object: ``isinstance(expr, TimestampValue)`` holds because the class says so, not because a tag was attached after construction. """ @@ -57,7 +60,9 @@ def to_sql(self): return BaseExpression.to_sql(self) -class JSONPathMode(Enum): +class JSONPathMode(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ """How a JSON path expression should be rendered by the dialect. - ``AUTO``: Use arrow operators if supported, else function-based. @@ -80,24 +85,32 @@ def from_value(cls, value) -> "JSONPathMode": raise TypeError(f"mode must be None, str, or JSONPathMode; got {type(value).__name__}") -#: Value class -> the class standing for both a CASE and that value class. +#: Annotation class -> the class standing for both a CASE and that annotation. #: Populated on first use by :meth:`CaseExpression._class_for`; see there for #: why it is built at all and why it is kept. -_CASE_VALUE_CLASSES = {} +_CASE_ANNOTATION_CLASSES = {} class CaseExpression( DeclaredValueType, ArithmeticMixin, ComparisonMixin, - SQLValueExpression, + SQLValue, ): """Represents a CASE expression (e.g., CASE WHEN condition THEN result ELSE result END). + The ``Expression`` suffix is not a fallback spelling: it transcribes the + standard production this node stands for -- ISO/IEC 9075-2 + ````. Per spec §4.9 (the "scalar expression special forms" + row) a CASE is a scalar-expression production rather than a routine + invocation, so it is neither a ``*Function`` nor a ``*Clause``; the name is + kept verbatim from the standard, which is the ``*Expression`` suffix's only + exemption class now that the suffix is retired. + A CASE answers with whatever its branches answer with, so the branches decide it -- not the factory, which is handed the whole list rather than the individual results. What each branch is is read off its own class, so - :func:`~...expression.column_types.value_class_of` is the question asked of + :func:`~...expression.column_types.annotation_for` is the question asked of each one. **Agreement is over the branches, and a branch that says nothing is not a @@ -109,7 +122,7 @@ class CaseExpression( so the answer is readable off the object. When they disagree -- integer here and float there -- the CASE is legal SQL that answers with neither kind, so no typed wrapper is offered and the bare class stands for it. Guessing the - first branch or the widest would promise a surface the database does not + first branch or the widest would promise a value_type the database does not have, and "unknown" is a result the caller can handle. That is why this class declares no single value type of its own: the answer @@ -135,10 +148,10 @@ class it was made from -- which is the correct answer anyway, since if cases is None and else_result is None and value is None: return super().__new__(cls) - from .column_types import value_class_of + from .column_types import annotation_for branches = [result for _condition, result in cases] + [else_result] - declared = {value_class_of(branch) for branch in branches} - {None} + declared = {annotation_for(branch) for branch in branches} - {None} if len(declared) != 1: return super().__new__(cls) agreed = declared.pop() @@ -148,33 +161,40 @@ class it was made from -- which is the correct answer anyway, since return object.__new__(combined) @classmethod - def _class_for(cls, value_class): - """The class standing for both this CASE and *value_class*. + def _class_for(cls, annotation): + """The class standing for both this CASE and *annotation*. - Built once per value class and kept, because building it per expression + Built once per annotation and kept, because building it per expression would make ``type(x)`` differ between two CASE expressions that answer with the same thing -- which is the thing being fixed. - The bases are ordered so that ``CaseExpression`` supplies the CASE - state and ``format_method``, :class:`DeclaredValueType` supplies the - rendering, and *value_class* supplies the operations and the value type. - The value class's own ``to_sql`` is deliberately *not* reached: it is a - wrapper delegating to a node this does not hold. + The bases are ordered so that ``CaseExpression`` supplies the CASE state, + :class:`DeclaredValueType` supplies the rendering, and the annotation's + **value_type** supplies the operations. The value_type rather than the + annotation itself, because this class already derives from + ``DeclaredValueType`` and the annotation derives from it too: asking for + both leaves two orders to satisfy and no linearization. A value_type adds + only mixins, so it settles cleanly. + + What this gives up is the annotation's own ``format_method`` -- the one + that renders the node the annotation holds. That is the point: a CASE + holds branches, not a node, so it must render as itself. """ + value_type = annotation.value_type_of() try: - return _CASE_VALUE_CLASSES[value_class] + return _CASE_ANNOTATION_CLASSES[annotation] except KeyError: combined = type( - f"Case{value_class.__name__}", - (cls, DeclaredValueType, value_class), + f"Case{value_type.__name__}", + (cls, DeclaredValueType, value_type), { "__doc__": ( f"A CASE whose branches agree the answer is a " - f"{value_class.__name__}, so it is one." + f"{annotation.__name__}, so it is one." ) }, ) - _CASE_VALUE_CLASSES[value_class] = combined + _CASE_ANNOTATION_CLASSES[annotation] = combined return combined def __init__( @@ -199,13 +219,13 @@ def format_method(self) -> str: return "format_case_expression" -class ExistsExpression(SQLPredicate): +class ExistsPredicate(SQLPredicate): """Represents an EXISTS predicate (e.g., EXISTS(subquery)).""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_exists_expression" + return "format_exists_predicate" def __init__(self, dialect: "SQLDialectBase", subquery: Union["Subquery", "BaseExpression"], is_not: bool = False): super().__init__(dialect) @@ -220,13 +240,13 @@ def __init__(self, dialect: "SQLDialectBase", subquery: Union["Subquery", "BaseE self.is_not = is_not -class AnyExpression(SQLPredicate): +class AnyPredicate(SQLPredicate): """Represents an ANY predicate (e.g., expr = ANY(array_expr) or expr > ANY(subquery)).""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_any_expression" + return "format_any_predicate" def __init__(self, dialect: "SQLDialectBase", expr: "BaseExpression", op: str, array_expr: "BaseExpression"): super().__init__(dialect) @@ -235,13 +255,13 @@ def __init__(self, dialect: "SQLDialectBase", expr: "BaseExpression", op: str, a self.array_expr = array_expr -class AllExpression(SQLPredicate): +class AllPredicate(SQLPredicate): """Represents an ALL predicate (e.g., expr > ALL(array_expr) or expr = ALL(subquery)).""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_all_expression" + return "format_all_predicate" def __init__(self, dialect: "SQLDialectBase", expr: "BaseExpression", op: str, array_expr: "BaseExpression"): super().__init__(dialect) @@ -339,12 +359,12 @@ def format_method(self) -> str: return "format_window_clause" -class JSONDocumentExpression( +class JSONDocumentValue( DeclaredValueType, - JSONValueExpression, + JSONValue, ArithmeticMixin, JSONAccessorMixin, - SQLValueExpression, + SQLValue, ): """A JSON document reached by path -- ``->``. @@ -355,13 +375,13 @@ class JSONDocumentExpression( Not available: ``like`` / ``ilike``. Those apply to the text a scalar path access returns, not to the document it navigated. Use :meth:`~...expression.mixins.JSONAccessorMixin.json_path` for the text - form, which arrives as :class:`JSONTextExpression`. + form, which arrives as :class:`JSONTextValue`. Split from that sibling rather than sharing one class: the two operations yield different types, and a single class could only offer the union of their operations to both. - It is a :class:`~...expression.core.JSONValueExpression` because that is what + It is a :class:`~...expression.value_types.JSONValue` because that is what a document is. It is not a *wrapper* holding one -- it has its own formatter and its own fields -- so it says so with :class:`DeclaredValueType` rather than inheriting the delegation. @@ -391,13 +411,13 @@ def format_method(self) -> str: return "format_json_expression" -class JSONTextExpression( - JSONDocumentExpression, - StringValueExpression, +class JSONTextValue( + JSONDocumentValue, + TextValue, ): """A JSON path access that yielded **text** -- ``->>``. - The sibling of :class:`JSONDocumentExpression`, and the reason that one is + The sibling of :class:`JSONDocumentValue`, and the reason that one is split from this: ``->`` hands back a document and ``->>`` hands back a scalar, and they are different types with different operations. It used to be one class whose ``operation`` decided the answer at construction, which @@ -407,7 +427,7 @@ class JSONTextExpression( Text is the narrower of the two, so chaining continues: ``->>`` yields a scalar and a scalar cannot be navigated further. ``->`` yields a document and can be, which is why this is the class that gains - :class:`~...expression.core.StringValueExpression` and its operations. + :class:`~...expression.value_types.TextValue` and its operations. One consequence of inheriting the document class is that the JSON accessors stay reachable here too: ``DeclaredValueType`` renders the node and this @@ -417,14 +437,19 @@ class does not hold one, so it cannot drop the mixin without also losing """ -class ArrayExpression( +class ArrayOperation( AliasableMixin, ArithmeticMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): - """Represents array operations like ANY, ALL, and array access.""" + """Represents array operations like ANY, ALL, and array access. + + Distinct from the array value type in ``core``: that one *is* a + sequence (the result of ``unnest`` or a collecting aggregate); this one + performs an operation over one and yields a value. + """ def __init__( self, @@ -456,10 +481,17 @@ class TrimExpression( StringValueMixin, TypeCastingMixin, NotANumberMixin, - SQLValueExpression, + SQLValue, ): """Represents a ``TRIM`` expression as a node, not as assembled SQL. + Like :class:`CaseExpression` and :class:`CastExpression`, the ``Expression`` + suffix transcribes the standard production: ISO/IEC 9075-2 writes TRIM as + the scalar-expression special form ```` / ```` rather than as a ````. Per spec §4.9 + TRIM belongs to the same exemption class, which is why it is not a + ``*Function`` node. + The node exists for one reason: the standard form is a syntax error on SQLite, so the spelling has to be a dialect decision. When the factory built a raw SQL string instead, no dialect hook could reach it -- the @@ -498,7 +530,7 @@ def format_method(self) -> str: return "format_trim_expression" -class RepeatExpression( +class RepeatFunction( AliasableMixin, ComparisonMixin, StringToIntegerMixin, @@ -506,7 +538,7 @@ class RepeatExpression( StringValueMixin, TypeCastingMixin, NotANumberMixin, - SQLValueExpression, + SQLValue, ): """``REPEAT(expr, count)`` as a node, so the backends without the function can emulate it structurally. @@ -541,7 +573,7 @@ def format_method(self) -> str: return "format_repeat_expression" -class LpadExpression( +class LpadFunction( AliasableMixin, ComparisonMixin, StringToIntegerMixin, @@ -549,7 +581,7 @@ class LpadExpression( StringValueMixin, TypeCastingMixin, NotANumberMixin, - SQLValueExpression, + SQLValue, ): """``LPAD(expr, length, pad)`` as a node, for the backends that lack it. @@ -588,7 +620,7 @@ def format_method(self) -> str: return "format_lpad_expression" -class RpadExpression( +class RpadFunction( AliasableMixin, ComparisonMixin, StringToIntegerMixin, @@ -596,11 +628,11 @@ class RpadExpression( StringValueMixin, TypeCastingMixin, NotANumberMixin, - SQLValueExpression, + SQLValue, ): """``RPAD(expr, length, pad)`` as a node, for the backends that lack it. - The mirror of :class:`LpadExpression`, and the cheaper one to emulate: a + The mirror of :class:`LpadFunction`, and the cheaper one to emulate: a single ``SUBSTR(expr || pad, 1, length)`` covers both padding and truncation, because the pad repeats ``length`` times and the substring takes at most that many characters. See :meth:`format_rpad_expression`. @@ -631,7 +663,7 @@ class OrderedSetAggregation( ArithmeticMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """Represents an ordered-set aggregate function call with WITHIN GROUP (ORDER BY ...).""" diff --git a/src/rhosocial/activerecord/backend/expression/bases.py b/src/rhosocial/activerecord/backend/expression/bases.py index 6a9402b61..fb7a1fefe 100644 --- a/src/rhosocial/activerecord/backend/expression/bases.py +++ b/src/rhosocial/activerecord/backend/expression/bases.py @@ -33,26 +33,42 @@ SQLQueryAndParams.__module__ = __name__ -def is_sql_query_and_params(obj): - """Check if an object is of type SQLQueryAndParams (Tuple[str, tuple]). +class QueryWithParams: + """The shape of a SQL string together with its parameters. + + ``SQLQueryAndParams`` stays a type alias: it appears in annotations and in + pydantic models, where a class would change the field's schema rather than + describe it. This class owns the *check* instead, so the shape is stated + once, beside the alias that names it, and callers reach it as a member + rather than as a free function. + + The five places that ask the question are in three unrelated families -- + the model bases, the expression tree, and the query protocols -- and they + share no ancestor, so there is no base class to hang this on. + """ - Args: - obj: The object to check + @classmethod + def holds(cls, obj: Any) -> bool: + """Whether *obj* has the shape of a query with its parameters. - Returns: - bool: True if the object is a SQLQueryAndParams tuple, False otherwise - """ - return ( - isinstance(obj, tuple) - and len(obj) == 2 - and isinstance(obj[0], str) - and (isinstance(obj[1], tuple) or obj[1] is None) - ) + Args: + obj: The object to check + + Returns: + bool: True if the object is a SQLQueryAndParams tuple, False + otherwise + """ + return ( + isinstance(obj, tuple) + and len(obj) == 2 + and isinstance(obj[0], str) + and (isinstance(obj[1], tuple) or obj[1] is None) + ) if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase - from .collation import CollateExpression + from .collation import CollateFunction @runtime_checkable @@ -79,28 +95,37 @@ def to_sql(self) -> "SQLQueryAndParams": # pragma: no cover """ ... - @property - def inline_literals(self) -> bool: # pragma: no cover - """ - Whether this expression renders its literal values **inline** (as SQL - text) instead of as bind parameters. - - **Defaults to ``False``** (bind parameters) — inline SQL text is a - severe SQL-injection hazard whenever the content can be influenced by - end users. Only content that is fully developer-declared and never - exposed to end users (e.g. schema DDL derived by ActiveRecord) should - enable it, and developers must think carefully before doing so. - """ - ... - @inline_literals.setter - def inline_literals(self, value: bool) -> bool: # pragma: no cover - """ - Enable/disable inline literal rendering for this expression. - Disabled by default; enabling is a deliberate, security-relevant - choice (see the getter's documentation). - """ +@runtime_checkable +class SQLLiteral(Protocol): + """The literal-construction protocol (the third level of inline literal + construction). + + A **value type definer** implements this protocol so instances of the + type can render themselves as a complete inline SQL literal. It exists + for user-defined and backend-specific value types that no dialect + build-in whitelist knows (``Point``, ``inet``, an Interval wrapper, ...): + whoever defines the type owns its literal spelling. + + The contract: + + - ``__sql_literal__(dialect)`` returns the **complete** inline literal + text, quotes included — the dialect embeds it verbatim; + - every string component inside that text must be escaped through the + dialect's **public** escape helpers (:meth:`SQLDialectBase.escape_sql_string` + or :meth:`SQLDialectBase.format_literal`); hand-concatenated quoting is + a violation; + - the dialect embeds the returned text as an atom, so the implementation + must keep placeholder-looking characters inside quoted string data. + + This protocol is a **rendering** duty. It is not :class:`DataType` and + has no relationship to DDL column-type declaration: a type may implement + one, the other, or both, and implementing one never implies the other. + """ + + def __sql_literal__(self, dialect: "SQLDialectBase") -> str: # pragma: no cover + """Return the complete inline SQL literal text for this value.""" ... @@ -114,8 +139,78 @@ class BaseExpression: - :attr:`dialect` property — validated binding (constructor-optional, late binding via the setter; **per-node only, no propagation**); - - :attr:`inline_literals` property — inline-literal switch (defaults to - ``False`` for injection safety). + + Rendering is **centralised** and **stateless**: :meth:`to_sql` is + implemented once, here, and is the only rendering entry point. A concrete + expression class participates by declaring exactly one thing — its + dialect formatting method name, through the read-only :attr:`format_method` + property. ``to_sql()`` resolves that name on the node's own bound dialect + (raising if unbound or if the dialect does not provide it) and hands + **this** expression to the formatting function. The rendered tree is + responsible for its own per-node dialect binding; no reconstruction or + propagation happens at render time. + """ + + @classmethod + def quote_flag(cls, value: Any, name: str) -> bool: + """A quoting flag, or a failure naming where it came from. + + These flags are ``bool`` with no third state. There is no "unspecified" to + represent, because the formatter never meets an absent one -- the value is + settled at construction and carried on the artifact (§10.1). So the only way + a flag goes wrong is a caller passing something that is not a bool, and + ``None`` is the one that arrives by accident: it reads as "I did not think + about it" and lands on the branch that writes the identifier out with + nothing escaped. + + The annotation already says ``bool``. Python does not enforce annotations, + so this is where the type is actually checked. + """ + if not isinstance(value, bool): + raise TypeError( + f"{cls.__name__}.{name} must be a bool, got {type(value).__name__}. " + "Quoting is decided per position and has no 'unspecified' state: " + "pass True to quote, False to write the identifier as it stands." + ) + return value + + @staticmethod + def require_exclusive_options(flag_a: bool, flag_b: bool, name_a: str, name_b: str) -> None: + """Refuse a clause whose two spellings were both requested. + + A two-spelling clause (``CASCADE`` / ``RESTRICT``, ``DEFERRABLE`` / + ``NOT DEFERRABLE``) is two parameters, not one option with three values. + Asking for both is API misuse, and the refusal is worded once here so + every clause reports it the same way instead of each restating the + sentence. + + This is an internal-consistency question, not a dialect-capability one: + no backend can render ``CASCADE RESTRICT``, so the answer does not + depend on which formatter will later render the expression. It + therefore stays at construction time rather than moving into the + formatter. + + Args: + flag_a: whether the first spelling was requested + flag_b: whether the second spelling was requested + name_a: parameter name of the first spelling + name_b: parameter name of the second spelling + + Raises: + ValueError: if both spellings were requested + """ + if flag_a and flag_b: + raise ValueError(f"{name_a} and {name_b} are mutually exclusive options") + + """ + Shared protocol-attribute implementation for every SQL expression. + + Satisfies the :class:`ToSQLProtocol` contract **structurally** — explicit + inheritance is neither required nor used (a protocol is structural; this + base is the shared implementation of its members): + + - :attr:`dialect` property — validated binding (constructor-optional, + late binding via the setter; **per-node only, no propagation**); Rendering is **centralised** and **stateless**: :meth:`to_sql` is implemented once, here, and is the only rendering entry point. A concrete @@ -136,7 +231,6 @@ def __init__(self, dialect: Optional["SQLDialectBase"] = None): be supplied later through the :attr:`dialect` property (e.g. model declaration time has no dialect; DDL generation binds one). """ - self._inline_literals = False self.dialect = dialect @property @@ -230,7 +324,7 @@ def to_sql(self) -> "SQLQueryAndParams": protocol is satisfied by its own ``...`` bodies, so a probe-only protocol sitting in a dialect's base list answers ``getattr`` and passes the callable test while returning ``None``. Three backends hit - that independently -- ``CreateFunctionExpression(...).to_sql()`` + that independently -- ``CreateFunctionStatement(...).to_sql()`` answering ``None``, ``Domain.to_sql()`` answering ``None``, a formatter returning a bare string instead of a pair -- and each was invisible because nothing inspected the result. A formatting method @@ -309,24 +403,81 @@ def validate(self, strict: bool = True) -> None: """ pass # pragma: no cover - inline_literals: bool = False - """Default implementation of the :class:`ToSQLProtocol` contract (see - its documentation for the security rationale): **False** — bind - parameters are the safe default. Subclasses may override the class-level - default; instances toggle it through the protocol setter.""" + + #: Tokens at the head of a class name that are a single word and must not + #: be split when the kind is derived (§4.9.4). Empty on the core. + #: + #: The general rule — a run of capitals is one word — disagrees with the + #: backend identifier table for several backends: applied naively it + #: yields ``sq_lite_json_extract_function`` for ``SQLiteJsonExtractFunction`` + #: and ``my_sql_upsert_clause`` for ``MySQLUpsertClause``. Since the kind is + #: part of the wire contract (§4.11.1), a backend whose identifier is a + #: compound word declares it here and its own expressions keep it whole. + #: + #: A backend owns this list: it is the backend's naming, not the core's, and + #: adding a backend must not require editing the core. What a subclass may + #: override is this list, never :meth:`kind` — the kind is always computed + #: from the class name, never stored, so the two cannot drift apart (§4.11.1: + #: an independent identifier table "would drift from the class names, and + #: that drift directly breaks existing artifacts"). + KIND_RESERVED: Tuple[str, ...] = () + + #: Whether the framework's default registry admits this node (§7). + #: + #: Set it ``False`` for a node whose rendered SQL is text the caller vouched + #: for. The artifact is untrusted input (§10), so admitting such a kind into + #: the table a reader resolves against would let the artifact choose raw SQL: + #: the reader would rebuild a node whose ``to_sql()`` emits whatever the + #: payload said. Excluding it keeps that out of reach by default. + #: + #: This is a *default*, not a prohibition. Nothing stops a caller who trusts + #: its artifacts from registering the class itself — the registry is + #: assembled from the expressions a distribution ships, and which ones those + #: are is the distribution's call. Deciding it per class, next to the class, + #: is what keeps the framework from carrying a list of names it would then + #: have to keep in step. + REGISTER_BY_DEFAULT: bool = True + + @classmethod + def kind(cls) -> str: + """Derive this expression's kind from its class name (§4.11.1). + + The kind is the class name's snake_case form, with a run of consecutive + capitals counted as one word (``JSONValue`` → ``json_value``, + ``SQLDialect`` → ``sql_dialect``), except that any token named in + :attr:`KIND_RESERVED` that starts the name is kept whole + (``SQLiteTopClause`` → ``sqlite_top_clause``). + + Computed, never stored: a class carries no kind field, so a kind can + never disagree with the class that produced it. + """ + import re + + name = cls.__name__ + for token in cls.KIND_RESERVED: + if name.startswith(token) and len(name) > len(token): + rest = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", name[len(token):]) + rest = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", rest) + return token.lower() + "_" + "_".join(rest.lower().split()) + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", name) + name = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name) + return "_".join(name.lower().split()) def get_params(self) -> Dict[str, Any]: """Introspection-based default implementation. Iterates the class __init__ signature (excluding `self` and `dialect`), - and resolves each parameter to its stored attribute value using the - convention: param `foo` → self._foo (fallback: self.foo) + and reads each parameter off the attribute of the same name: param `foo` + is read from `self.foo`. One spelling, no probing -- a parameter the + class did not store raises `AttributeError` naming the class and the + parameter, which is the defect this method exists to surface. VAR_POSITIONAL (*args) parameters are returned as a list. - VAR_KEYWORD (**kwargs) parameters are resolved through the same - convention — a class that accepts **kwargs MUST store the collected - extras under an attribute named after the parameter (e.g. `self.kwargs` - or `self.collation_options`) so the round-trip can reconstruct them. + VAR_KEYWORD (**kwargs) parameters are read from a dict stored under the + parameter's own name and merged into the top level, so that + reconstruction `cls(..., **value)` re-expands them. Expressions should + not declare **kwargs at all: an artifact names its parameters, and a + catch-all accepts names no artifact can ever carry. """ sig = inspect.signature(self.__class__.__init__) params: Dict[str, Any] = {} @@ -335,34 +486,24 @@ def get_params(self) -> Dict[str, Any]: if name in ("self", "dialect"): continue - private = f"_{name}" - if hasattr(self, private) and hasattr(self, name): - # Both spellings exist. A fluent-API method named like the - # parameter (e.g. `schema` on introspection expressions) is - # a *callable* — then the private attribute is the state. - # Otherwise the public attribute is the constructor's - # authoritative write (the private one is only the - # base-class default the protocol setter keeps). - public = getattr(self, name) - value = public if not callable(public) else getattr(self, private) - elif hasattr(self, private): - value = getattr(self, private) - elif hasattr(self, name): - value = getattr(self, name) - else: - warnings.warn( - f"{self.__class__.__name__}.get_params(): cannot find attribute " - f"'_{name}' or '{name}' for parameter '{name}'. " - "Store it under the parameter's name in __init__ - " - "get_params() is the single serialization path and is not " - "meant to be overridden.", - stacklevel=2, - ) - continue + # One spelling, and it is the parameter's own name. This used to + # probe `_{name}` then `name` and pick between them by testing + # whether the public one was callable, because eighteen fluent + # setters were named exactly like their constructor parameters + # (`schema`, `table_name`, `include_columns`, `begin_type`, …) and + # the value could not then be written as `self.schema = ...` without + # shadowing the method. The setters are `with_*` now, so the + # parameter owns the name and there is nothing to decide. + # + # Nothing is skipped when the attribute is absent: a parameter the + # class did not store is a defect, and the AttributeError says which + # class and which parameter. The old branch warned and carried on, + # which put the omission in the artifact with nothing to show for it. + value = getattr(self, name) if param.kind == inspect.Parameter.VAR_KEYWORD: # **kwargs extras must be stored as a dict on an attribute named - # after the parameter (e.g. self.kwargs, self.collation_options). + # after the parameter (e.g. self.kwargs). # They are merged into the top-level params so that # reconstruction `cls(..., **value)` re-expands them correctly. if not isinstance(value, dict): @@ -397,7 +538,7 @@ class SQLPredicate(LogicalMixin, AliasableMixin, BaseExpression): pass -class SQLValueExpression(BaseExpression): +class SQLValue(BaseExpression): """ Abstract base class for SQL expressions that return a non-boolean value (e.g., integer, string, date). @@ -415,8 +556,8 @@ def __init__(self, dialect: "SQLDialectBase"): def collate( self, collation: Union[str, Enum], - **collation_options: Any, - ) -> "CollateExpression": + schema: Optional[str] = None, + ) -> "CollateFunction": """ Apply an explicit SQL COLLATE clause to this value expression. @@ -425,6 +566,6 @@ def collate( type supports collation. Callers must apply it only to expressions that are valid for the target backend, typically text/string expressions. """ - from .collation import CollateExpression + from .collation import CollateFunction - return CollateExpression(self.dialect, self, collation, **collation_options) + return CollateFunction(self.dialect, self, collation, schema=schema) diff --git a/src/rhosocial/activerecord/backend/expression/codec.py b/src/rhosocial/activerecord/backend/expression/codec.py deleted file mode 100644 index 733a6dc9d..000000000 --- a/src/rhosocial/activerecord/backend/expression/codec.py +++ /dev/null @@ -1,103 +0,0 @@ -# src/rhosocial/activerecord/backend/expression/codec.py -""" -JSON value codecs for non-JSON-native Python values. - -The goal is "expression (via its serialized spec) <-> JSON string". -The standard library ``json`` handles all container walking, escaping and -parsing; it only cannot serialize a handful of non-native Python values -(datetime, Decimal, bytes, UUID, set, Enum, ...) on its own. Those values -are delegated to :func:`encode_value` / :func:`decode_value`, which are -registered by type and may be extended by developers via -:func:`register_codec`. - -Encoded form for a non-native value:: - - {"__value__": [, ]} -""" - -import base64 -import importlib -from datetime import date, datetime, time -from decimal import Decimal -from enum import Enum -from typing import Any, Callable, Dict, Optional, Tuple -from uuid import UUID - -# tag -> (py_type, encode(value)->payload, decode(payload)->value) -_CODECS: Dict[str, Tuple[Optional[type], Callable[[Any], Any], Callable[[Any], Any]]] = {} - - -def register_codec( - tag: str, - py_type: Optional[type], - encode: Callable[[Any], Any], - decode: Callable[[Any], Any], -) -> None: - """Register a value codec. - - ``encode(value)`` must return a JSON-safe payload; ``decode(payload)`` - must reconstruct the original Python value. A ``tag`` must be unique and - should not collide with existing reserved keys. Passing ``py_type=None`` - registers a catch-all codec (lowest priority, tried last). - """ - if tag in _CODECS: - raise ValueError(f"Codec tag '{tag}' is already registered") - _CODECS[tag] = (py_type, encode, decode) - - -def _encode_enum(value): - return [f"{type(value).__module__}.{type(value).__qualname__}", value.name] - - -def _decode_enum(payload): - class_path, member_name = payload - mod_name, _, qualname = class_path.rpartition(".") - cls = getattr(importlib.import_module(mod_name), qualname) - return cls[member_name] - - -_DEFAULT_CODECS: Dict[str, Tuple[Optional[type], Callable[[Any], Any], Callable[[Any], Any]]] = { - "dt": (datetime, lambda v: v.isoformat(), lambda p: datetime.fromisoformat(p)), - "d": (date, lambda v: v.isoformat(), lambda p: date.fromisoformat(p)), - "tm": (time, lambda v: v.isoformat(), lambda p: time.fromisoformat(p)), - "dec": (Decimal, str, Decimal), - "b": (bytes, lambda v: base64.b64encode(v).decode("ascii"), lambda p: base64.b64decode(p)), - "ba": (bytearray, lambda v: base64.b64encode(v).decode("ascii"), lambda p: bytearray(base64.b64decode(p))), - "uuid": (UUID, str, UUID), - "set": (set, list, lambda p: set(p)), - "fset": (frozenset, list, lambda p: frozenset(p)), - "enum": (Enum, _encode_enum, _decode_enum), -} - -for _tag, (_t, _e, _dd) in _DEFAULT_CODECS.items(): - _CODECS[_tag] = (_t, _e, _dd) - - -def encode_value(value: Any) -> Optional[Dict[str, Any]]: - """Encode a non-JSON-native value into ``{"__value__": [tag, payload]}``. - - Returns ``None`` when the value is JSON-native (or unknown). Dataclass - instances are handled by the serializer layer via a reserved key, not here. - """ - # Most-specific types first; Enum is a base class so check it last. - for tag, (py_type, encode, _decode) in _CODECS.items(): - if py_type is None: - continue - if isinstance(value, py_type): - return {"__value__": [tag, encode(value)]} - return None - - -def decode_value(obj: Any) -> Any: - """object_hook for ``json.loads``: reverse :func:`encode_value`. - - Any dict other than a ``__value__`` container is returned untouched. - """ - if isinstance(obj, dict) and "__value__" in obj and len(obj) == 1: - payload = obj["__value__"] - tag = payload[0] - entry = _CODECS.get(tag) - if entry is not None: - _py_type, _encode, decode = entry - return decode(payload[1]) - return obj \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/collation.py b/src/rhosocial/activerecord/backend/expression/collation.py index e9ec164d7..a0789bd84 100644 --- a/src/rhosocial/activerecord/backend/expression/collation.py +++ b/src/rhosocial/activerecord/backend/expression/collation.py @@ -4,9 +4,9 @@ """ from enum import Enum -from typing import Any, Dict, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING -from .bases import SQLValueExpression +from .bases import SQLValue from .mixins import ( AliasableMixin, ArithmeticMixin, @@ -19,13 +19,13 @@ from ..dialect import SQLDialectBase -class CollateExpression( +class CollateFunction( AliasableMixin, ArithmeticMixin, ComparisonMixin, StringPatternPredicateMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """Applies an explicit collation to a SQL value expression.""" @@ -37,17 +37,21 @@ def format_method(self) -> str: def __init__( self, dialect: "SQLDialectBase", - expression: SQLValueExpression, + expression: SQLValue, collation: Union[str, Enum], *, alias: Optional[str] = None, - **collation_options: Any, + schema: Optional[str] = None, ): super().__init__(dialect) self.expression = expression self.collation = collation self.alias: Optional[str] = alias - self.collation_options: Dict[str, Any] = dict(collation_options) + # Schema-qualified COLLATE (`schema.collation`) is the only option + # any backend reads, so it is a named parameter rather than an open + # bag: a catch-all cannot be matched by name on read, cannot declare + # its serialized range, and lets a mistyped option through silently. + self.schema: Optional[str] = schema @property def collation_name(self) -> str: @@ -57,10 +61,10 @@ def collation_name(self) -> str: def collate( - expression: SQLValueExpression, + expression: SQLValue, collation: Union[str, Enum], - **collation_options: Any, -) -> CollateExpression: + schema: Optional[str] = None, +) -> CollateFunction: # Inherit the expression's binding state (construction-time; may be None # to defer binding), matching the operator-overload convention. - return CollateExpression(expression._dialect, expression, collation, **collation_options) + return CollateFunction(expression._dialect, expression, collation, schema=schema) diff --git a/src/rhosocial/activerecord/backend/expression/column_types.py b/src/rhosocial/activerecord/backend/expression/column_types.py index 0533b49fd..b08756b9c 100644 --- a/src/rhosocial/activerecord/backend/expression/column_types.py +++ b/src/rhosocial/activerecord/backend/expression/column_types.py @@ -1,22 +1,27 @@ # src/rhosocial/activerecord/backend/expression/column_types.py -"""Type-narrowed column expressions. +"""Column references, narrowed to the value type they hold. -A column reference is a value, and the operations a value supports depend on -what it holds. A numeric column can be added to; a text column can be -``LIKE``-matched; a JSON column can be navigated by path. Before this module -every :class:`~...expression.core.Column` carried every one of those -operations, so ``User.c.age.like('%x%')`` rendered happily against an integer -column and only failed at the database. +A column reference is a value, and what can be done to a value depends on what +it holds: a numeric column can be added to, a text column can be ``LIKE``-matched, +a JSON column can be navigated by path. An untyped +:class:`~...expression.core.Column` promises none of that, which is what makes +``User.c.age.like('%x%')`` fail at the point where the caller is told rather than +at the database. The narrowing ------------- -Python cannot *remove* a mixin from a base class, so the families here are -**siblings** of each other under :class:`ColumnBase`, not subclasses of one -another. ``StringColumn(ColumnBase)`` and ``NumericColumn(ColumnBase)`` each -compose only the mixins their value type supports; there is no permissive -parent in between that they would inherit from. :class:`~...expression.core.Column` -is a sibling too — it keeps the full mixin set as the escape hatch for -columns whose type is not known (see its docstring). +Fetching a column is an operation, so a column reference is one thing: a value +type (:mod:`~...expression.value_types`) plus the name to read from. Each class +here is exactly those two bases and nothing else:: + + class NumericColumn(NumericValue, ColumnBase): + +That is the whole mechanism. It used to be a hand-written mixin list per class, +which stated the same fact a second time in a place that had to be kept in step +-- and nothing checked that it was. The lists were identical, base for base, to +the value type each column named, which is measurable: comparing the operation +sets of ``NumericColumn`` and ``NumericValue`` gives an empty symmetric +difference, for every column that has a value type. Nothing about the *storage* type appears here. A ``UUIDColumn`` is a ``UUIDColumn`` on every backend; how the value is stored is the DDL layer's @@ -26,24 +31,20 @@ from typing import Optional, TYPE_CHECKING -from .bases import SQLValueExpression +from .bases import SQLValue +from .value_types import ( + ArrayValue, + BooleanValue, + JSONValue, + NumericValue, + OpaqueValue, + TemporalValue, + TextValue, + XMLValue, +) from .mixins import ( AliasableMixin, - ArithmeticMixin, - BooleanLogicMixin, - NumericValueMixin, - TranscendentalMixin, - TemporalArithmeticMixin, - ArrayMixin, - ComparisonMixin, - DateTimeMixin, - JSONAccessorMixin, - NotANumberMixin, - NotComparableMixin, NullTestMixin, - StringPatternPredicateMixin, - StringToIntegerMixin, - StringValueMixin, TypeCastingMixin, ) @@ -57,7 +58,7 @@ class ColumnBase( AliasableMixin, NullTestMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, metaclass=ABCMeta, ): """Common base for every column expression. @@ -100,10 +101,10 @@ def __init__( table_need_quote: bool = True, ): super().__init__(dialect) - self.name_need_quote = name_need_quote - self.alias_need_quote = alias_need_quote - self.schema_need_quote = schema_need_quote - self.table_need_quote = table_need_quote + self.name_need_quote = self.quote_flag(name_need_quote, "name_need_quote") + self.alias_need_quote = self.quote_flag(alias_need_quote, "alias_need_quote") + self.schema_need_quote = self.quote_flag(schema_need_quote, "schema_need_quote") + self.table_need_quote = self.quote_flag(table_need_quote, "table_need_quote") self.name = name self.table = table self.alias = alias @@ -125,7 +126,7 @@ def __repr__(self) -> str: return f"<{type(self).__name__} {''.join(parts)}>" -class StringColumn(ComparisonMixin, StringValueMixin, StringPatternPredicateMixin, StringToIntegerMixin, ColumnBase): +class StringColumn(TextValue, ColumnBase): """A column holding text: comparison, ``LIKE`` / ``ILIKE``, casting. Not available: arithmetic. ``LIKE`` against a numeric column is a @@ -139,7 +140,7 @@ def format_method(self) -> str: -class NumericColumn(ComparisonMixin, ArithmeticMixin, NumericValueMixin, TranscendentalMixin, ColumnBase): +class NumericColumn(NumericValue, ColumnBase): """A column holding a number: comparison and arithmetic. One class for every numeric width and precision: the operations are the @@ -167,7 +168,7 @@ class IntegerColumn(NumericColumn): is the *result* — SQL keeps ``CEIL`` of a whole number a whole number, while ``SQRT`` of one generally is not — and the result type is settled by the operation's own declaration at run time (the math factories tag their - results through ``value_class_of``), not by a mixin arranged in the MRO. + results through ``annotation_for``), not by a mixin arranged in the MRO. ``TranscendentalMixin`` is inherited and not withheld: ``log(2)`` is a legal query the database will answer, and an integer behaving differently from the @@ -182,7 +183,7 @@ def format_method(self) -> str: return "format_column" -class TimestampColumn(ComparisonMixin, TemporalArithmeticMixin, DateTimeMixin, ColumnBase): +class TimestampColumn(TemporalValue, ColumnBase): """A column holding a date/time. @@ -199,7 +200,7 @@ class TimestampColumn(ComparisonMixin, TemporalArithmeticMixin, DateTimeMixin, C Renamed from ``DateTimeColumn``: the column layer models *values*, and the value a ``DATETIME``/``TIMESTAMP`` column hands back is a point in time — - ``TimestampValueExpression``'s family. ``datetime`` is one vendor's spelling + ``TimestampValue``'s family. ``datetime`` is one vendor's spelling for it (and the name of the Python type); the four storage flavours the vendors really offer — civil date, civil time, instant, instant with zone — are the DDL layer's ``DataType`` distinction and never read from here. @@ -211,7 +212,7 @@ def format_method(self) -> str: return "format_column" -class BooleanColumn(BooleanLogicMixin, ComparisonMixin, ColumnBase): +class BooleanColumn(BooleanValue, ColumnBase): """A column holding a truth value: boolean algebra, comparison, casting. @@ -242,7 +243,7 @@ def format_method(self) -> str: return "format_column" -class BinaryColumn(ComparisonMixin, ColumnBase): +class BinaryColumn(OpaqueValue, ColumnBase): """A column holding raw bytes: comparison, casting, collation.""" @@ -252,7 +253,7 @@ def format_method(self) -> str: return "format_column" -class UUIDColumn(ComparisonMixin, ColumnBase): +class UUIDColumn(OpaqueValue, ColumnBase): """A column holding a UUID. @@ -269,7 +270,7 @@ def format_method(self) -> str: return "format_column" -class JSONColumn(ComparisonMixin, JSONAccessorMixin, ColumnBase): +class JSONColumn(JSONValue, ColumnBase): """A column holding JSON: comparison, casting, and path access. @@ -286,7 +287,7 @@ def format_method(self) -> str: return "format_column" -class ArrayColumn(ComparisonMixin, ArrayMixin, ColumnBase): +class ArrayColumn(ArrayValue, ColumnBase): """A column holding an array: :meth:`~...mixins.ArrayMixin.array_length` and :meth:`~...mixins.ArrayMixin.unnest`.""" @@ -297,7 +298,7 @@ def format_method(self) -> str: return "format_column" -class XMLColumn(NotComparableMixin, NotANumberMixin, ColumnBase): +class XMLColumn(XMLValue, ColumnBase): """A column holding an XML document: alias, null tests, casting — no comparison. The XML value surface is *not* the general value surface with a few @@ -340,78 +341,92 @@ def format_method(self) -> str: -def value_class_of(expr: object): - """The value class an operation handing *expr* back should be typed as. +def annotation_for(expr: object): + """The annotation typing an operation that hands *expr* back. ``MIN`` of an integer column is an integer; ``MIN`` of a string column is a string, and can still be compared and matched. ``LAG`` over a name is a - name. So when an operation answers with the thing it was given, this is - what it asks. It is read off the column *class* because that is where the - type already lives -- there is no tag to read and no attribute that - duplicates the class. + name. So when an operation answers with the thing it was given, this is what + it asks. It is read off the column *class* because that is where the type + already lives -- there is no tag to read and no attribute that duplicates the + class. Resolution walks the MRO, so a column class that narrows an existing one (``IntegerColumn`` under ``NumericColumn``) is found under its own name and - a backend's own column class resolves through whichever core class it - derives from. + a backend's own column class resolves through whichever core class it derives + from. That walk is why the map is keyed by class rather than by name: a + backend subclass gets the right answer without saying anything about itself. - ``None`` is a real answer rather than a failure. A ``Literal`` and an + ``None`` is a real answer rather than a failure. A bare ``Literal`` and an untyped ``Column`` say nothing about what a database would hand back, so an - operation over them stays untyped instead of guessing: the caller who needs - a specific type says so with ``cast()``. + operation over them stays untyped instead of guessing: the caller who needs a + specific type says so with ``cast()`` or by annotating the call. The table is built on first use and kept. This module is imported by - :mod:`..core`, so building it at import time would need the value classes - from the module that imports this one. + :mod:`..core`, so building it at import time would need the annotations from + the module that imports this one. """ - global _COLUMN_VALUE_CLASSES - if _COLUMN_VALUE_CLASSES is None: - _COLUMN_VALUE_CLASSES = _value_classes() + global _COLUMN_ANNOTATIONS + if _COLUMN_ANNOTATIONS is None: + _COLUMN_ANNOTATIONS = _annotations() for klass in type(expr).__mro__: - value_class = _COLUMN_VALUE_CLASSES.get(klass) - if value_class is not None: - return value_class + annotation = _COLUMN_ANNOTATIONS.get(klass) + if annotation is not None: + return annotation return None -#: Column class -> the value class an operation handing that column back is -#: typed as. Resolution walks the MRO, so a column class that narrows an -#: existing one is found under its own name: an ``IntegerColumn`` is a whole -#: number even though it is also a ``NumericColumn``. +#: Column class -> the annotation typing an operation that hands that column +#: back. Resolution walks the MRO, so a column class that narrows an existing +#: one is found under its own name: an ``IntegerColumn`` stays a whole number +#: even though it is also a ``NumericColumn``. #: #: Every concrete column class this module declares is in here. The test that #: says so lives beside the map and is why a class added without an entry is a #: test failure rather than a silently untyped operation. #: -#: ``TimestampColumn`` is the one judgement call: a column declared -#: ``DATETIME`` or ``TIMESTAMP`` holds a date and a time together, which is -#: what ``TimestampValueExpression`` is. A ``DATE`` or ``TIME`` column is a -#: different storage type in DDL and this module has no column class for -#: either -- the distinction between the four temporal types is made where -#: the value is known (see :class:`~...expression.datetime.ExtractExpression`), -#: not by guessing here. -def _value_classes(): - from . import core +#: ``TimestampColumn`` is the one judgement call: a column declared ``DATETIME`` +#: or ``TIMESTAMP`` holds a date and a time together, which is what a timestamp +#: is. A ``DATE`` or ``TIME`` column is a different storage type in DDL and this +#: module has no column class for either -- the distinction between the temporal +#: types is made where the value is known (see +#: :class:`~...expression.datetime.ExtractExpression`), not by guessing here. +def _annotations(): + from .declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, + ) return { - IntegerColumn: core.IntegerValueExpression, - NumericColumn: core.NumericValueExpression, - StringColumn: core.StringValueExpression, - JSONColumn: core.JSONValueExpression, - ArrayColumn: core.ArrayValueExpression, - BooleanColumn: core.BooleanValueExpression, - BinaryColumn: core.BinaryValueExpression, - UUIDColumn: core.UUIDValueExpression, - TimestampColumn: core.TimestampValueExpression, + IntegerColumn: IntegerAnnotation, + NumericColumn: NumberAnnotation, + StringColumn: TextAnnotation, + JSONColumn: JSONAnnotation, + ArrayColumn: ArrayAnnotation, + BooleanColumn: BooleanAnnotation, + # A DATETIME or TIMESTAMP column holds a date and a time together. + TimestampColumn: TemporalAnnotation, # XML is a value like the rest: the operations that hand one back (a # column, xmlagg, a cast to XmlType) are typed, they just offer the - # surface an XML value has -- see XMLColumn. - XMLColumn: core.XMLValueExpression, + # surface an XML document has -- see XMLColumn. + XMLColumn: XMLAnnotation, + # A BinaryColumn and a UUIDColumn answer with the operations they have + # in common and nothing else, so both land on the opaque annotation. + BinaryColumn: BinaryAnnotation, + UUIDColumn: UUIDAnnotation, } -_COLUMN_VALUE_CLASSES = None +_COLUMN_ANNOTATIONS = None __all__ = [ @@ -426,5 +441,5 @@ def _value_classes(): "JSONColumn", "ArrayColumn", "XMLColumn", - "value_class_of", + "annotation_for", ] diff --git a/src/rhosocial/activerecord/backend/expression/core.py b/src/rhosocial/activerecord/backend/expression/core.py index f495b1a3a..3c72def8e 100644 --- a/src/rhosocial/activerecord/backend/expression/core.py +++ b/src/rhosocial/activerecord/backend/expression/core.py @@ -3,55 +3,59 @@ Core SQL expression components like columns, literals, function calls, and subqueries. """ -import copy from typing import Any, Optional, Tuple, TYPE_CHECKING, Union -from .bases import BaseExpression, SQLPredicate, SQLQueryAndParams, SQLValueExpression, is_sql_query_and_params +from .bases import BaseExpression, QueryWithParams, SQLPredicate, SQLQueryAndParams, SQLValue from .column_types import ColumnBase from .mixins import ( AliasableMixin, ArithmeticMixin, BooleanLogicMixin, ComparisonMixin, - ArrayMixin, - DateTimeMixin, - StringToIntegerMixin, - JSONAccessorMixin, NotANumberMixin, - NotComparableMixin, - NullTestMixin, - NumericValueMixin, ResultTypeMixin, StringPatternPredicateMixin, - StringValueMixin, - TemporalArithmeticMixin, - TranscendentalMixin, TypeCastingMixin, - WrappedCallMixin, ) -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: + from .sources import NamedRelationRef # pragma: no cover from ..dialect import SQLDialectBase from .advanced_functions import WindowSpecification from .types._base import DataType -class Literal( - AliasableMixin, - ArithmeticMixin, - ComparisonMixin, - StringPatternPredicateMixin, - TypeCastingMixin, - SQLValueExpression, -): - """Represents a literal value in a SQL query. - - ``inline_literals`` (a construction-time, factory choice made through the - ``ToSQLProtocol`` contract) decides how this literal renders: when - ``True`` the value is spliced into the SQL text via - ``dialect.format_literal`` (no bind parameter); when ``False`` (the safe - default) it renders as a bind parameter. Rendering is a pure read of - this already-collected state. +class Literal(BaseExpression): + """A literal, and nothing but the decision of how it reaches SQL. + + Every value that reaches SQL passes through this class, so that the question + "does this value become a bind parameter or SQL text?" has exactly one place + that answers it. Nothing else in the tree has to know: a formatter calls + ``child.to_sql()`` and gets ``(sql, params)`` back, whether the child was a + function call or a value. + + :attr:`inline` is that answer, and it is per **position** rather than per + class. A node whose values all take the same treatment never needs it — the + default, ``False``, binds, which is what every DQL/DML data position wants. + A DDL syntax position that accepts no bind parameters at all passes + ``inline=True`` and gets escaped SQL text instead. Where one node holds + several values and only some of them are exempt from binding, this is the + only way to say so. + + What the class deliberately does **not** claim is what kind of value this + is. It is not a :class:`~...bases.SQLValue`: that category says "this + expression yields a value of some type", and a bare position does not know + its type — ``NULLIF(a, b)`` is text or a number depending on the data. The + earlier version of this class inherited the comparison, arithmetic and + string-pattern mixins, which let ``Literal(d, 7).like()`` compile and + promised SQL the database would reject. The result's type is declared + elsewhere, by :meth:`~...mixins.ResultTypeMixin.as_text` and its siblings. + + Nor is a literal aliasable. A bind parameter has no name to give — the + parameter the driver fills in is the driver's, not the query's — and a name + on SQL text would be naming text rather than a result. Aliasing belongs to + the things that produce a named result, which is what + :class:`~...bases.SQLValue` is for. """ @property @@ -64,16 +68,28 @@ def __init__( dialect: "SQLDialectBase", value: Any, *, - alias: Optional[str] = None, - inline_literals: bool = False, + inline: bool = False, ): super().__init__(dialect) + from .values import LiteralKind + + # Which value kind this is, read off the host value. It is the artifact + # identity, and it cannot be derived from the class: one class carries + # every value, while the wire still names the twelve kinds separately -- + # ``"2020-01-02"`` as a date and as a string are the same bytes, and + # only the kind tells them apart. + # + # ``None`` for a type no kind names -- a ``Point``, an ``inet``, any + # ``SQLLiteral`` implementor. The literal still renders through the + # dialect's own literal construction; what it cannot do is travel, and + # serialization is where that is said. + self.value_kind = LiteralKind.match(value) self.value = value - self.alias = alias - self.inline_literals = inline_literals + self.inline = inline def __repr__(self) -> str: - return f"Literal({self.value!r}, inline_literals={self.inline_literals!r})" + where = "" if not self.inline else ", inline" + return f"Literal({self.value!r}{where})" class Column(ColumnBase): @@ -118,547 +134,42 @@ def format_method(self) -> str: return "format_column" -class TemporalValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - DateTimeMixin, - TemporalArithmeticMixin, - TypeCastingMixin, - SQLValueExpression, -): - """Shared base for the four temporal value types. - - A date, a time, a timestamp and an interval offer the same operations -- - ``date_add``, ``date_sub``, ``date_diff``, ``date_part``, ``extract`` -- and - none of them supports ``upper``, so the operations live here and the - subclasses say only which kind of temporal value they are. The arithmetic - they share is interval-based (:class:`TemporalArithmeticMixin`): a point in - time moves by an interval, two points subtract to an interval, and a plain - number never enters. - - They are four classes rather than one because they are four types. A - timestamp added to an interval is a timestamp; a span minus a span is a - span; and a year cannot be extracted from a span the way it is from a - point in time. With one class and a label those differences would have had - to be spelled as data, and the operations a caller could reach would be the - union of four unrelated ones. - - Subclasses exist so that ``current_date()`` and ``current_timestamp()`` - arrive already typed. Neither carries ``StringPatternPredicateMixin``: - a temporal value is not text. - """ - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class DateValueExpression(TemporalValueExpression): - """A date with no time component. ``CURRENT_DATE`` answers with one.""" - - -class TimeValueExpression(TemporalValueExpression): - """A time of day. ``CURRENT_TIME`` answers with one.""" - - -class TimestampValueExpression(TemporalValueExpression): - """A date and a time together. ``NOW()`` and ``CURRENT_TIMESTAMP`` answer - with one, and adding an interval to it gives another.""" - - -class IntervalValueExpression(TemporalValueExpression): - """A span of time rather than a point in it. - - An interval is not a timestamp, which is why it is its own type here and - not a ``date_add`` argument that happens to be special. Its arithmetic is - the span's own: :class:`TemporalArithmeticMixin` adds and subtracts spans - into spans and scales one by a number, while a temporal value moves by a - span and two temporal values subtract into one. - """ - - -class JSONValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - JSONAccessorMixin, - TypeCastingMixin, - SQLValueExpression, -): - """A JSON document that is not a column reference. - - The result of json_build_object, json_array_elements and the aggregate that - assembles documents. Carries the JSON accessors, so a built document can - be walked the same way a column can — json_path is scalar and terminal, - json_value keeps a document to chain on.""" - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class ArrayValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - ArrayMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - """A sequence that is not a column reference. - - The result of unnest and of the aggregate that collects into one. Carries - the array operations, so a collected sequence can be measured or expanded - the same way a column can.""" - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class BooleanValueExpression( - WrappedCallMixin, +class LogicalValue( AliasableMixin, ComparisonMixin, BooleanLogicMixin, TypeCastingMixin, NotANumberMixin, - SQLValueExpression, + SQLValue, ): - """A true/false value that is not a column reference. - - A boolean is a value, not a predicate, so it can be aliased, projected and - compared. Its connectives keep the value type - (:class:`BooleanLogicExpression`), so ``(a & b)`` is still selectable; - its comparisons yield predicates.""" - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" + """A boolean value expression used in SELECT lists. - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class BooleanLogicExpression( - AliasableMixin, - ComparisonMixin, - BooleanLogicMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - """``a AND b`` / ``a OR b`` / ``NOT a`` as a boolean **value**. + Renders TRUE/FALSE or boolean logic (AND/OR/NOT) as a **value** that can + appear in a SELECT column list. Not a predicate — does not belong in + WHERE/HAVING clauses. A predicate and this expression render the same SQL; what differs is what the caller may do with the result. Combining predicates stays a predicate -- that is how ``WHERE`` clauses are built -- while a truth value keeps its value type: ``(a & b)`` can be aliased into a ``SELECT`` list, compared, cast, and combined further. The class carries the same ``op`` - and ``predicates`` shape + and ``operands`` shape :class:`~...predicates.LogicalPredicate` does, so both go through the one dialect entry point (``format_logical_predicate``). """ - def __init__(self, dialect: Any, op: str, *predicates: Any): + def __init__(self, dialect: Any, op: str, *operands: Any): super().__init__(dialect) self.op = op # The names are the renderer's: the dialect formatter reads ``op`` and - # ``predicates`` whatever class carried them in, and the expression + # ``operands`` whatever class carried them in, and the expression # contract stores every ``__init__`` parameter under its own name. - self.predicates = list(predicates) + self.operands = list(operands) @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_logical_predicate" - - -class BinaryValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - """Raw bytes that are not a column reference. - - There is no byte operation to offer, so this carries comparisons and - casting. It exists so a function that returns bytes says so instead of being - unknown.""" - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class XMLValueExpression( - WrappedCallMixin, - AliasableMixin, - NotComparableMixin, - NullTestMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - """An XML document that is not a column reference. - - Returned by the SQL/XML constructors, by the XML access operations, and by - a cast to :class:`~...types.XmlType`. Same wrapper shape as - :class:`BinaryValueExpression` -- it holds the call it was built from and - forwards its alias -- with one difference: an XML value cannot be compared. - - That is the whole surface of XML on the backends that have it. PostgreSQL - refuses ``xml = xml`` ("operator does not exist: xml = xml") along with - ordering and ``IN``; SQL Server documents that "the xml data type cannot - be compared or sorted"; Oracle's ``XMLType`` has no comparison operators - either. What they all accept is ``IS NULL``, which is why the null tests - :class:`NullTestMixin` provides stay while :class:`NotComparableMixin` - refuses the rest. Casting stays too: the way to ask a question of an XML - value is to cast it or to use a SQL/XML function, not to compare it. - """ - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class UUIDValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - """A UUID that is not a column reference. - - Same shape as the binary wrapper: comparisons and casting, and nothing to - extend with. A backend that cannot generate one raises at the factory rather - than returning an untyped node.""" - - - def __init__( - self, - dialect: "SQLDialectBase", - call: "FunctionCall", - ): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - - -class NumericValueExpression( - WrappedCallMixin, - AliasableMixin, - ArithmeticMixin, - ComparisonMixin, - NumericValueMixin, - TranscendentalMixin, - TypeCastingMixin, - SQLValueExpression, -): - """A fractional-valued expression that is not a column reference. - - The result of ``sqrt``, ``log``, ``abs`` on a fractional operand, ``avg``, - ``percent_rank`` and the other math functions. Carries arithmetic, so - numeric expressions compose, and comparisons, which every value can do. - Not the pattern predicates: ``LIKE`` is text matching, and a fraction is not - text, so ``sqrt(x).like('1%')`` fails here rather than at the database. - - ``ceil``, ``floor`` and ``truncate`` do *not* land here when the operand is - integral — a whole number rounded to a whole number is an integer, and - saying otherwise would offer ``bit_length`` on the result of ``ceil``. - """ - - - def __init__(self, dialect: "SQLDialectBase", call: "FunctionCall"): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - # as_() sets the alias on this wrapper, but the rendering happens in - # the wrapped call, which has its own alias and knows nothing about - # this one. Handing the alias down is what keeps `length().as_("n")` - # from silently rendering as a bare LENGTH(x). - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - -class IntegerValueExpression( - WrappedCallMixin, - AliasableMixin, - ArithmeticMixin, - ComparisonMixin, - NumericValueMixin, - TranscendentalMixin, - TypeCastingMixin, - SQLValueExpression, -): - - """An integer-valued expression that is not a column reference. - - Returned by the string operations whose result is a number — ``length``, - ``ascii``, ``strpos``, ``position``, ``octet_length``, ``bit_length`` — so - that ``col.length() > 5`` reads naturally and ``col.length().upper()`` - fails, which it should: there is no such thing as an upper-cased length. - - It carries :class:`ArithmeticMixin`, so integer arithmetic is available - and stays an integer. It carries comparisons, which every value can do, and - not the pattern predicates: ``LIKE`` is text matching, and a whole number is - not text. That absence is the point of the class — ``length().like("1%")`` - has to fail at the call, naming the operation, rather than be accepted here - and rejected by the server minutes later. - """ - - def __init__(self, dialect: "SQLDialectBase", call: "FunctionCall"): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - # as_() sets the alias on this wrapper, but the rendering happens in - # the wrapped call, which has its own alias and knows nothing about - # this one. Handing the alias down is what keeps `length().as_("n")` - # from silently rendering as a bare LENGTH(x). - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - -class StringValueExpression( - WrappedCallMixin, - AliasableMixin, - ComparisonMixin, - StringToIntegerMixin, - StringPatternPredicateMixin, - StringValueMixin, - TypeCastingMixin, - NotANumberMixin, - SQLValueExpression, -): - - """A string-valued expression that is not a column reference. - - This is what makes the string value operations chain. ``col.upper()`` - returns one of these rather than a bare ``FunctionCall``, so the next - operation in the chain is available: ``col.upper().substr(0, 3)`` is - ``SUBSTRING(UPPER("name"), 1, 3)`` and the result is still a string. - - It is deliberately **not** a :class:`StringColumn`. A column names a field - and ``format_column`` reads ``name`` / ``table`` / ``schema_name``; a - derived value has none of those, so claiming to be a column would be a lie - about the object. What it shares with a string column is the *value type*, - which is what decides which operations are offered. - - The class mirrors :class:`~...expression.advanced_functions.JSONDocumentExpression`, - which plays the same role for the JSON family. - """ - - def __init__(self, dialect: "SQLDialectBase", call: "FunctionCall"): - super().__init__(dialect) - self.call = call - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_function_call" - - def to_sql(self): - """Render the wrapped function call. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - # as_() sets the alias on this wrapper, but the rendering happens in - # the wrapped call, which has its own alias and knows nothing about - # this one. Handing the alias down is what keeps `length().as_("n")` - # from silently rendering as a bare LENGTH(x). - alias = self.__dict__.get("alias") - if alias and getattr(self.call, "alias", None) != alias: - call = copy.copy(self.call) - call.alias = alias - return call.to_sql() - return self.call.to_sql() - class FunctionCall( AliasableMixin, ArithmeticMixin, @@ -666,7 +177,7 @@ class FunctionCall( StringPatternPredicateMixin, TypeCastingMixin, ResultTypeMixin, - SQLValueExpression, + SQLValue, ): """Represents a SQL function call, scalar or aggregate, such as LOWER, CONCAT, COUNT, SUM. @@ -763,10 +274,18 @@ class CastExpression( ComparisonMixin, StringPatternPredicateMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """Represents a SQL type cast as a proper AST node. + The ``Expression`` suffix is not a fallback spelling: it transcribes the + standard production this node stands for -- ISO/IEC 9075-2 + ````. Per spec §4.9 (the "scalar expression special + forms" row) a CAST is a scalar-expression production rather than a routine + invocation, so it is neither a ``*Function`` nor a ``*Clause``; the name is + kept verbatim from the standard and the ``*Expression`` suffix is the + retired spelling's single exemption class. + ``CAST(expression AS target_type)`` is a **unary tree node**: its single child is the wrapped expression and ``target_type`` is a construction parameter. Rendering is delegated to the dialect's @@ -789,7 +308,7 @@ def format_method(self) -> str: def __init__( self, dialect: "SQLDialectBase", - expression: "SQLValueExpression", + expression: "SQLValue", target_type: "DataType", *, alias: Optional[str] = None, @@ -818,7 +337,7 @@ def __repr__(self) -> str: return f"CastExpression({self.expression!r} AS {self.target_type!r})" -class Subquery(AliasableMixin, ArithmeticMixin, ComparisonMixin, TypeCastingMixin, SQLValueExpression): +class Subquery(AliasableMixin, ArithmeticMixin, ComparisonMixin, TypeCastingMixin, SQLValue): """Represents a subquery in a SQL expression.""" @property @@ -849,7 +368,7 @@ def __init__( # If input is a string, use it directly with empty params self.query_input = query self.query_params = () - elif is_sql_query_and_params(query): + elif QueryWithParams.holds(query): # If input is a SQLQueryAndParams (str, tuple), extract SQL and params sql_str, params = query # If params is None, use an empty tuple @@ -869,7 +388,7 @@ def __init__( self.query_params = () -class QualifiedIdentifierExpression(BaseExpression): +class QualifiedIdentifier(BaseExpression): """Represents a schema-qualified identifier (e.g., schema.table_name). Per-role quoting fields (all default to ``True``): @@ -890,37 +409,56 @@ def __init__( schema_need_quote: bool = True, ): super().__init__(dialect) - self.name_need_quote = name_need_quote - self.schema_need_quote = schema_need_quote + self.name_need_quote = self.quote_flag(name_need_quote, "name_need_quote") + self.schema_need_quote = self.quote_flag(schema_need_quote, "schema_need_quote") self.schema = schema self.name = name -class WildcardExpression(SQLValueExpression): - """Represents a wildcard expression (SELECT *) in a SQL query. +class AllColumnsWildcard(SQLValue): + """Every column of a relation source: the select-list ``*``. - Per-role quoting fields (all default to ``True``): - - ``table_need_quote`` ↔ ``table`` - - ``schema_need_quote`` ↔ ``schema_name`` + Its standing is that of a column reference, and its home is the select + list and the RETURNING clause -- the spec's first wildcard class (4.10.1). + A bare ``*`` (``relation`` empty) lets the surrounding clause decide what + "all columns" spans: every FROM source in a select list, the DML target + in RETURNING. The clause's formatter injects that scope; this node does + not know where it sits and must not probe. + + A ``relation`` present renders the qualified ``rel.*`` -- the reference + spelling the query itself uses (the alias when aliased, the qualified + name otherwise). Row counting is a different contract: the asterisk in + ``COUNT(*)`` counts rows rather than expanding columns, and belongs to + :class:`RowCountWildcard` alone. """ @property def format_method(self) -> str: - return "format_wildcard" + return "format_all_columns_wildcard" def __init__( self, dialect: "SQLDialectBase", - table: Optional[str] = None, - schema_name: Optional[str] = None, - table_need_quote: bool = True, - schema_need_quote: bool = True, + relation: Optional["NamedRelationRef"] = None, ): super().__init__(dialect) - self.table_need_quote = table_need_quote - self.schema_need_quote = schema_need_quote - self.table = table - self.schema_name = schema_name + self.relation = relation +class RowCountWildcard(SQLValue): + """The asterisk that counts rows: ``COUNT(*)``'s parameter. + + The spec's second wildcard class (4.10.2). Its semantics are rows, not + column expansion, so it accepts no relation: standard ``COUNT(*)`` takes + the bare asterisk, and the qualified ``COUNT(t.*)`` is a different + (non-NULL row count) that supporting backends define as their own derived + node. The only aggregate whose contract admits this node is ``COUNT``; + ``SUM``/``AVG``/``MAX``/``MIN`` refuse it where they are built. + """ + + @property + def format_method(self) -> str: + return "format_row_count_wildcard" + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/datetime.py b/src/rhosocial/activerecord/backend/expression/datetime.py index f1868e62f..b9f540c47 100644 --- a/src/rhosocial/activerecord/backend/expression/datetime.py +++ b/src/rhosocial/activerecord/backend/expression/datetime.py @@ -3,30 +3,37 @@ import math from enum import Enum -from typing import Any, Dict, TYPE_CHECKING, Union +from typing import Any, Dict, Optional, TYPE_CHECKING, Union from .mixins import ( + IntervalMixin, AliasableMixin, ComparisonMixin, TypeCastingMixin, ) from .advanced_functions import DeclaredValueType -from .bases import BaseExpression, SQLQueryAndParams, SQLValueExpression -from .core import ( - IntegerValueExpression, - IntervalValueExpression, - NumericValueExpression, - TimestampValueExpression, -) +from .bases import BaseExpression, SQLValue +from .core import Literal if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase +from .value_types import ( + NumericValue, + TemporalValue, +) + class DateTimeField(str, Enum): """Supported datetime fields.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + #: Without this, ``str(member)`` yields ``DateTimeField.YEAR``, and both the + #: XML codec and every f-string that interpolates the field would carry a + #: Python spelling across the wire. + __str__ = str.__str__ + YEAR = "year" MONTH = "month" WEEK = "week" @@ -38,9 +45,13 @@ class DateTimeField(str, Enum): DOY = "doy" + class IntervalUnit(str, Enum): """Supported interval units.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + YEAR = "year" MONTH = "month" WEEK = "week" @@ -50,57 +61,46 @@ class IntervalUnit(str, Enum): SECOND = "second" -_FIELD_ALIASES = { - "years": DateTimeField.YEAR, - "yyyy": DateTimeField.YEAR, - "yy": DateTimeField.YEAR, - "months": DateTimeField.MONTH, - "mon": DateTimeField.MONTH, - "mm": DateTimeField.MONTH, - "weeks": DateTimeField.WEEK, - "wk": DateTimeField.WEEK, - "ww": DateTimeField.WEEK, - "days": DateTimeField.DAY, - "dd": DateTimeField.DAY, - "hours": DateTimeField.HOUR, - "hh": DateTimeField.HOUR, - "minutes": DateTimeField.MINUTE, - "mins": DateTimeField.MINUTE, - "mi": DateTimeField.MINUTE, - "seconds": DateTimeField.SECOND, - "secs": DateTimeField.SECOND, - "ss": DateTimeField.SECOND, - "dow": DateTimeField.DOW, + +#: Accepted spellings for :class:`DateTimeField`, each mapped to the member it +#: names -- ``"yyyy"``, ``"yy"`` and ``"years"`` are three ways of writing YEAR. +#: It sits here, beside the enum that owns the domain, rather than as a private +#: table elsewhere. It cannot live in the enum body: every non-descriptor +#: assignment in an Enum body becomes a *member*, so a dict there would be an +#: enum member rather than a lookup table. +DATETIME_FIELD_ALIASES = { + "years": DateTimeField.YEAR, "yyyy": DateTimeField.YEAR, "yy": DateTimeField.YEAR, + "months": DateTimeField.MONTH, "mon": DateTimeField.MONTH, "mm": DateTimeField.MONTH, + "weeks": DateTimeField.WEEK, "wk": DateTimeField.WEEK, "ww": DateTimeField.WEEK, + "days": DateTimeField.DAY, "dd": DateTimeField.DAY, + "hours": DateTimeField.HOUR, "hh": DateTimeField.HOUR, + "minutes": DateTimeField.MINUTE, "mins": DateTimeField.MINUTE, "mi": DateTimeField.MINUTE, + "seconds": DateTimeField.SECOND, "secs": DateTimeField.SECOND, "ss": DateTimeField.SECOND, "weekday": DateTimeField.DOW, - "doy": DateTimeField.DOY, "dayofyear": DateTimeField.DOY, } -_UNIT_ALIASES = { - "years": IntervalUnit.YEAR, - "yyyy": IntervalUnit.YEAR, - "yy": IntervalUnit.YEAR, - "months": IntervalUnit.MONTH, - "mon": IntervalUnit.MONTH, - "mm": IntervalUnit.MONTH, - "weeks": IntervalUnit.WEEK, - "wk": IntervalUnit.WEEK, - "ww": IntervalUnit.WEEK, - "days": IntervalUnit.DAY, - "dd": IntervalUnit.DAY, - "hours": IntervalUnit.HOUR, - "hh": IntervalUnit.HOUR, - "minutes": IntervalUnit.MINUTE, - "mins": IntervalUnit.MINUTE, - "mi": IntervalUnit.MINUTE, - "seconds": IntervalUnit.SECOND, - "secs": IntervalUnit.SECOND, - "ss": IntervalUnit.SECOND, + +#: Accepted spellings for :class:`IntervalUnit`, same shape as +#: :data:`DATETIME_FIELD_ALIASES`. +INTERVAL_UNIT_ALIASES = { + "years": IntervalUnit.YEAR, "yyyy": IntervalUnit.YEAR, "yy": IntervalUnit.YEAR, + "months": IntervalUnit.MONTH, "mon": IntervalUnit.MONTH, "mm": IntervalUnit.MONTH, + "weeks": IntervalUnit.WEEK, "wk": IntervalUnit.WEEK, "ww": IntervalUnit.WEEK, + "days": IntervalUnit.DAY, "dd": IntervalUnit.DAY, + "hours": IntervalUnit.HOUR, "hh": IntervalUnit.HOUR, + "minutes": IntervalUnit.MINUTE, "mins": IntervalUnit.MINUTE, "mi": IntervalUnit.MINUTE, + "seconds": IntervalUnit.SECOND, "secs": IntervalUnit.SECOND, "ss": IntervalUnit.SECOND, } def normalize_datetime_field(field: Union[str, DateTimeField]) -> DateTimeField: - """Normalize and validate a datetime field token.""" + """Normalize and validate a datetime field token. + + A member passes through; a word that is a member's own value becomes that + member; a word that is an alias of one becomes that member. Anything else is + refused, so a typo cannot silently pick a neighbouring field. + """ if isinstance(field, DateTimeField): return field normalized = str(field).strip().lower() @@ -108,8 +108,9 @@ def normalize_datetime_field(field: Union[str, DateTimeField]) -> DateTimeField: raise ValueError("datetime field cannot be empty") if normalized in DateTimeField._value2member_map_: return DateTimeField(normalized) - if normalized in _FIELD_ALIASES: - return _FIELD_ALIASES[normalized] + aliased = DATETIME_FIELD_ALIASES.get(normalized) + if aliased is not None: + return DateTimeField(aliased) raise ValueError(f"unsupported datetime field: {field!r}") @@ -122,8 +123,9 @@ def normalize_interval_unit(unit: Union[str, IntervalUnit]) -> IntervalUnit: raise ValueError("interval unit cannot be empty") if normalized in IntervalUnit._value2member_map_: return IntervalUnit(normalized) - if normalized in _UNIT_ALIASES: - return _UNIT_ALIASES[normalized] + aliased = INTERVAL_UNIT_ALIASES.get(normalized) + if aliased is not None: + return IntervalUnit(aliased) raise ValueError(f"unsupported interval unit: {unit!r}") @@ -136,11 +138,11 @@ def validate_interval_value(value: Union[int, float]) -> Union[int, float]: return value -class _TemporalValueExpression( +class _TemporalValue( AliasableMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """Shared base for the temporal operation nodes. @@ -153,7 +155,7 @@ class _TemporalValueExpression( type's class alongside this one. Two consequences, both wanted: * What the operation yields is readable off the object — ``EXTRACT`` is a - :class:`~...expression.core.NumericValueExpression` because it derives + :class:`~...expression.value_types.NumericValue` because it derives from one, not because a tag said so and a tag could disagree with the class it sat on. * The operations follow from it. ``extract("year") + 1`` composes because a @@ -168,31 +170,62 @@ class it sat on. :meth:`BaseExpression.__init__` rather than through ``super()``. """ + @staticmethod + def _precision(dialect: "SQLDialectBase", precision: Optional[int]): + """The fractional-seconds scale as a node, or ``None`` when absent. -class ExtractExpression(_TemporalValueExpression, DeclaredValueType, NumericValueExpression): - """``EXTRACT(field FROM source)`` -- a number. + A bare ``int`` is what a caller writes, and it is not what the formatter + can render -- the precision is a parameter of the function, so it goes + into the parameter sequence like any other. Normalising here rather than + in each factory means constructing the node directly is safe too. + """ + if precision is None: + return None - Numeric rather than integral because ``EXTRACT(EPOCH FROM ts)`` is - fractional. Widening here is deliberate: claiming a whole number would offer - ``bit_length`` on a value that may be a million and a half. + return precision if isinstance(precision, BaseExpression) else Literal(dialect, precision) - It is a :class:`~...expression.core.NumericValueExpression` because that is - what it is, and that is why ``extract("year") + 1`` composes. The class - declares the type rather than a tag recording it, so ``isinstance`` answers - from the class alone. + +class _ExtractBase(_TemporalValue): + """The field-and-source shape both extract spellings share. + + ``EXTRACT(YEAR FROM ts)`` and ``YEAR(ts)`` ask the same question of the same + operands, so the fields, the validation and :attr:`spelling` live here once. + What is *not* shared is the type they answer with, which is the reason there + are two classes below rather than one -- see each one's own docstring. """ + #: The spellings this node can be asked for. ``EXTRACT`` is the standard's + #: form; the rest name the field after the function that takes it. + SPELLINGS = ("EXTRACT", "YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND") + def __init__( self, dialect: "SQLDialectBase", field: Union[str, DateTimeField], source: BaseExpression, alias: str = None, + spelling: str = "EXTRACT", ): + spelling = spelling.upper() + if spelling not in self.SPELLINGS: + raise ValueError( + f"unknown extract spelling {spelling!r}: the spellings are {list(self.SPELLINGS)}" + ) + normalized_field = normalize_datetime_field(field) + if spelling != "EXTRACT" and normalized_field.value.upper() != spelling: + # The field-named form writes ``YEAR(ts)`` and nothing else, so a + # mismatched field would be silently dropped from the SQL and the + # node would answer a different question than it was asked. Caught + # here, where the caller can still see which of the two was meant. + raise ValueError( + f"spelling {spelling!r} extracts {spelling.lower()}, not " + f"{normalized_field.value!r}" + ) BaseExpression.__init__(self, dialect) - self.field = normalize_datetime_field(field) + self.field = normalized_field self.source = source self.alias = alias + self.spelling = spelling @property def format_method(self) -> str: @@ -200,13 +233,64 @@ def format_method(self) -> str: return "format_extract_expression" -class DatePartExpression(_TemporalValueExpression, DeclaredValueType, NumericValueExpression): - """The SQL-standard spelling of :class:`ExtractExpression`, and a number too. +class ExtractExpression( + _ExtractBase, + DeclaredValueType, + NumericValue, +): + """``EXTRACT(field FROM source)`` -- a number. - Separate because a backend may render it differently, and because a caller - writing standard SQL should say so. Same answer, same class family: some - parts of a timestamp are whole numbers, but the unit decides, and a unit - this cannot rule out being fractional exists. + Numeric rather than integral because ``EXTRACT(EPOCH FROM ts)`` is + fractional. Widening here is deliberate: claiming a whole number would offer + ``bit_length`` on a value that may be a million and a half. + + It is a :class:`~...expression.value_types.NumericValue` because that is + what it is, and that is why ``extract("year") + 1`` composes. The class + declares the type rather than a tag recording it, so ``isinstance`` answers + from the class alone. + + :attr:`spelling` is the same answer asked the way a backend spells it. The + default is the standard's own, so ``extract()`` and a deserialized artifact + that names no spelling both mean ``EXTRACT``. The field-named form is + :class:`ExtractFieldExpression` rather than this class with a spelling, + because it answers with a whole number and this one may not. + """ + + +class ExtractFieldExpression( + _ExtractBase, + DeclaredValueType, + NumericValue, +): + """``YEAR(source)`` / ``MONTH(source)`` / ... -- a whole number. + + One class for all six field-named extractions rather than six, which is what + catalog 3.4 asks for: they differ only in the field, and a backend's + differences in spelling are the formatter's business. + + Integral, unlike :class:`ExtractExpression`: a calendar component is a whole + number on every backend, so claiming a fractional one would offer + ``bit_length`` on a year. The two are separate classes precisely because + ``EXTRACT(EPOCH FROM ts)`` is *not* a whole number -- one field splits the + question, so the type cannot. + """ + + +class DatePartExpression( + _ExtractBase, + DeclaredValueType, + NumericValue, +): + """``DATE_PART('field', source)`` -- the standard's spelling, and a number. + + Separate from :class:`ExtractExpression` because a caller writing standard + SQL should say so, and a backend may render it differently. + + Its :attr:`spelling` is ``EXTRACT``, because that is how it renders: the + base dialect formats ``DATE_PART`` as ``EXTRACT`` (they are the same answer), + and a backend that has a native ``DATE_PART`` overrides the formatter. The + field and the spelling are therefore both present, and agreeing -- the same + invariant the field-named form holds. """ def __init__( @@ -216,10 +300,7 @@ def __init__( source: BaseExpression, alias: str = None, ): - BaseExpression.__init__(self, dialect) - self.field = normalize_datetime_field(field) - self.source = source - self.alias = alias + super().__init__(dialect, field, source, alias=alias, spelling="EXTRACT") @property def format_method(self) -> str: @@ -227,7 +308,11 @@ def format_method(self) -> str: return "format_date_part_expression" -class DateTruncExpression(_TemporalValueExpression, DeclaredValueType, TimestampValueExpression): +class DateTruncExpression( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): """Truncating a timestamp to a field gives a timestamp. Not an integer: the parts are dropped, not rounded away, so what comes back @@ -253,7 +338,12 @@ def format_method(self) -> str: return "format_date_trunc_expression" -class IntervalExpression(_TemporalValueExpression, DeclaredValueType, IntervalValueExpression): +class IntervalExpression( + _TemporalValue, + DeclaredValueType, + TemporalValue, + IntervalMixin, +): """A structured interval value -- a span of time, its own type. Not a timestamp and not a number: ` @@ -280,7 +370,11 @@ def format_method(self) -> str: return "format_interval_expression" -class DateTimeAddExpression(_TemporalValueExpression, DeclaredValueType, TimestampValueExpression): +class DatetimeAddExpression( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): """Adding an interval to a timestamp gives a timestamp. Shifting time keeps it time. The result is a point in time, not a count of @@ -307,10 +401,14 @@ def format_method(self) -> str: return "format_datetime_add_expression" -class DateTimeSubtractExpression(_TemporalValueExpression, DeclaredValueType, TimestampValueExpression): +class DatetimeSubtractExpression( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): """Subtracting an interval from a timestamp gives a timestamp. - The mirror of :class:`DateTimeAddExpression`, and the same answer: going back + The mirror of :class:`DatetimeAddExpression`, and the same answer: going back in time leaves you at a point in time. """ @@ -332,7 +430,11 @@ def format_method(self) -> str: return "format_datetime_subtract_expression" -class DateTimeDiffExpression(_TemporalValueExpression, DeclaredValueType, IntegerValueExpression): +class DatetimeDiffExpression( + _TemporalValue, + DeclaredValueType, + NumericValue, +): """The difference between two timestamps, counted in whole units. This is the one temporal operation that is not a temporal value: ` @@ -363,6 +465,147 @@ def format_method(self) -> str: return "format_datetime_diff_expression" +class NowFunction( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): + """``NOW()`` -- the instant the statement began. + + A timestamp, so the temporal operations are on it: ``now().date_add(...)`` + and ``now() - some_interval`` compose. The parentheses are a spelling the + backend decides (MySQL's ``NOW`` requires them, and its + ``CURRENT_TIMESTAMP`` may omit them), which is why the node is not named after + the text it writes. + """ + + #: The standard writes this with parentheses; ``NOW`` without them is + #: a syntax error on MySQL, which is why it is declared rather than + #: assumed. + niladic = False + + def __init__(self, dialect: "SQLDialectBase", precision: Optional[int] = None, alias: str = None): + BaseExpression.__init__(self, dialect) + self.precision = self._precision(dialect, precision) + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_now_function" + + +class CurrentDateFunction( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): + """``CURRENT_DATE`` -- today's date, with no time and no zone. + + A date rather than a timestamp, so ``CURRENT_TIMESTAMP()::date`` and + ``CURRENT_DATE`` are distinguishable at the type level even where a backend + would render them alike. + + The standard writes this with no arguments, so :attr:`precision` is + normally ``None``; it is carried because a backend that spells the scale + (``CURRENT_DATE(6)``) needs somewhere to read it from. + """ + + #: The standard writes this without parentheses, and the backend may + #: keep that spelling or add them. + niladic = True + + def __init__( + self, dialect: "SQLDialectBase", precision: Optional[int] = None, alias: str = None + ): + BaseExpression.__init__(self, dialect) + self.precision = self._precision(dialect, precision) + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_current_date_function" + + +class CurrentTimeFunction( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): + """``CURRENT_TIME`` -- the time of day, with no date and no zone.""" + + #: The standard writes this without parentheses, and the backend may + #: keep that spelling or add them. + niladic = True + + def __init__(self, dialect: "SQLDialectBase", precision: Optional[int] = None, alias: str = None): + BaseExpression.__init__(self, dialect) + self.precision = self._precision(dialect, precision) + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_current_time_function" + + +class CurrentTimestampFunction( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): + """``CURRENT_TIMESTAMP`` -- the instant the statement began, as a timestamp. + + :attr:`precision` is the fractional-seconds scale, and it is also a + **spelling** question rather than only a value one: the standard writes this + without parentheses when there is no precision and with them when there is + one, and not every backend agrees either way. Both cases are this node, so + the parentheses are the formatter's decision. + """ + + #: The standard writes this without parentheses, and the backend may + #: keep that spelling or add them. + niladic = True + + def __init__(self, dialect: "SQLDialectBase", precision: Optional[int] = None, alias: str = None): + BaseExpression.__init__(self, dialect) + self.precision = self._precision(dialect, precision) + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_current_timestamp_function" + + +class LocalTimestampFunction( + _TemporalValue, + DeclaredValueType, + TemporalValue, +): + """``LOCALTIMESTAMP`` -- the instant as the session's time zone reads it. + + Distinct from :class:`CurrentTimestampFunction` because the two disagree + wherever the session zone is not UTC, and on PostgreSQL only the latter is + stored as an instant while this one is a wall-clock reading. + """ + + #: The standard writes this without parentheses, and the backend may + #: keep that spelling or add them. + niladic = True + + def __init__(self, dialect: "SQLDialectBase", precision: Optional[int] = None, alias: str = None): + BaseExpression.__init__(self, dialect) + self.precision = self._precision(dialect, precision) + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_localtimestamp_function" + + class TemporalOptionsExpression(BaseExpression): """FOR SYSTEM_TIME AS OF ... temporal table options.""" @@ -372,4 +615,4 @@ def __init__(self, dialect, options: Dict[str, Any]): @property def format_method(self) -> str: - return "format_temporal_options" + return "format_temporal_options_expression" diff --git a/src/rhosocial/activerecord/backend/expression/declared.py b/src/rhosocial/activerecord/backend/expression/declared.py new file mode 100644 index 000000000..802825f96 --- /dev/null +++ b/src/rhosocial/activerecord/backend/expression/declared.py @@ -0,0 +1,217 @@ +# src/rhosocial/activerecord/backend/expression/declared.py +"""Declared result types -- for the operations whose result the source cannot settle. + +Most operations know what they yield and say so by being their own class: +``col.length()`` is a :class:`~...expression.functions.string.CharLengthFunction` +and nothing needs to tell it. A few cannot, and the standard gives the example +itself -- ``NULLIF(x, y)`` yields ``x`` when the two differ and NULL when they do +not, so whether the answer is text or a number depends on the data. + +For those, the caller states it, and this is where that statement lives: +``NULLIF(name, blank).as_text()`` is text, and ``.upper()`` may follow it while +``.sqrt()`` may not. + +**These are annotations, not wrappers.** The node underneath is left exactly as +it was -- the statement was already correct, and what was missing was the +knowledge of what it yields -- so rendering delegates to it and the SQL does not +change. The difference from the general value classes this replaces is that each +of these carries **one** surface and does none of the forwarding they did: there +is no second hierarchy to walk and no MRO intersection to compute, because +nothing here holds a call. + +Each class is its own, so ``as_text()`` returns a class a type checker can read +as text. Returning a common base would leave the checker unable to tell, which +would be the whole point of the method lost. +""" + +from typing import TYPE_CHECKING + +from .advanced_functions import DeclaredValueType +from .value_types import ( + ArrayValue, + BooleanValue, + JSONValue, + NumericValue, + OpaqueValue, + TemporalValue, + TextValue, + XMLValue, +) +from .bases import BaseExpression, SQLValue + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import SQLDialectBase + + +class _Annotation(SQLValue): + """Shared state for the declared-result nodes: the node being annotated. + + One field, one job. It is deliberately **not** the general wrapper shape + these replace -- that held a ``FunctionCall`` and derived its operations by + walking two MROs to find the boundary between them, because it had to serve + every type at once. One surface per class removes the need for any of it. + + ``BaseExpression`` is a base, not merely called from ``__init__``. These + were duck-typed nodes: they rendered, and nothing else. So ``kind()`` was + absent -- and an annotation produced by ``as_text()`` could not be + serialized at all, because a node with no kind has no name an artifact can + carry. Inheriting is what makes them nodes rather than things that happen + to have ``to_sql``. + """ + + @classmethod + def value_type_of(cls) -> type: + """The value type this annotation carries. + + Read off the bases rather than declared beside them: the value type + *is* the third base, and a field repeating it is a second place to be + wrong. CASE and CAST need the name to build their combined classes, + which is why the question is asked of the class rather than of an + instance. + """ + for base in cls.__bases__: + if base.__name__.endswith("Value"): + return base + raise TypeError( + f"{cls.__name__} carries no *Value base; an annotation must name " + f"the value type its declared result has" + ) + + def __init__(self, dialect: "SQLDialectBase", source: BaseExpression): + # BaseExpression directly, not super(): the value types below are mixin + # carriers ending at SQLValue, and going through super() would chain + # into whichever of them happens to declare __init__. + BaseExpression.__init__(self, dialect) + self.source = source + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_declared_result" + + +class TextAnnotation( + _Annotation, + DeclaredValueType, + TextValue, +): + """Declared to yield text: ``NULLIF(name, blank).as_text()``.""" + + + + +class NumberAnnotation( + _Annotation, + DeclaredValueType, + NumericValue, +): + """Declared to yield a fractional number.""" + + + + +class IntegerAnnotation( + _Annotation, + DeclaredValueType, + NumericValue, +): + """Declared to yield a whole number.""" + + + + +class TemporalAnnotation( + _Annotation, + DeclaredValueType, + TemporalValue, +): + """Declared to yield a date, a time or a date-time. + + No ``StringPatternPredicateMixin``: a date is not text, so ``like`` is not an + operation any of them offers. + """ + + + + +class BooleanAnnotation( + _Annotation, + DeclaredValueType, + BooleanValue, +): + """Declared to yield a truth value. + + This names the value, not a projection. SQL Server has no boolean scalar + type, so a predicate cannot appear in a select list there; where one is + needed the dialect has to produce a zero-or-one column. + """ + + + + +class ArrayAnnotation( + _Annotation, + DeclaredValueType, + ArrayValue, +): + """Declared to yield a sequence.""" + + + + +class JSONAnnotation( + _Annotation, + DeclaredValueType, + JSONValue, +): + """Declared to yield a JSON document.""" + + + + +class UUIDAnnotation( + _Annotation, + DeclaredValueType, + OpaqueValue, +): + """Declared to yield a UUID.""" + + + + +class BinaryAnnotation( + _Annotation, + DeclaredValueType, + OpaqueValue, +): + """Declared to yield bytes.""" + + + + +class XMLAnnotation( + _Annotation, + DeclaredValueType, + XMLValue, +): + """Declared to yield an XML document. + + Not comparable: ``<`` between two XML documents is not a question any + backend answers the same way, so the node does not offer it. + """ + + + + +__all__ = [ + "TextAnnotation", + "NumberAnnotation", + "IntegerAnnotation", + "TemporalAnnotation", + "BooleanAnnotation", + "ArrayAnnotation", + "JSONAnnotation", + "UUIDAnnotation", + "BinaryAnnotation", + "XMLAnnotation", +] diff --git a/src/rhosocial/activerecord/backend/expression/executable.py b/src/rhosocial/activerecord/backend/expression/executable.py index 71b58ca32..e3c106694 100644 --- a/src/rhosocial/activerecord/backend/expression/executable.py +++ b/src/rhosocial/activerecord/backend/expression/executable.py @@ -20,7 +20,7 @@ class Executable(Protocol): Usage: from rhosocial.activerecord.backend.expression.executable import Executable - from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement if isinstance(my_expression, Executable): stmt_type = my_expression.statement_type diff --git a/src/rhosocial/activerecord/backend/expression/execution_testing.py b/src/rhosocial/activerecord/backend/expression/execution_testing.py index 3c46d5917..5e34894b5 100644 --- a/src/rhosocial/activerecord/backend/expression/execution_testing.py +++ b/src/rhosocial/activerecord/backend/expression/execution_testing.py @@ -24,8 +24,8 @@ outcome = confirm_expression_execution( backend, - CreateTableExpression(dialect, table, columns), - teardown=[DropTableExpression(dialect, table)], + CreateTableStatement(dialect, table, columns), + teardown=[DropTableStatement(dialect, table)], ) if outcome is ExecutionOutcome.NOT_RENDERED: pytest.skip("dialect does not render this expression") diff --git a/src/rhosocial/activerecord/backend/expression/functions/__init__.py b/src/rhosocial/activerecord/backend/expression/functions/__init__.py index ff7c65405..1b67eef32 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/expression/functions/__init__.py @@ -12,11 +12,27 @@ - For functions that operate on columns, wrap column names in Column objects """ -# Aggregate function factories -from .aggregate import count, sum_, avg, min_, max_ +# Aggregate function nodes and factories +from .aggregate import ( + AggregateFunction, + CountFunction, + SumFunction, + AvgFunction, + MinFunction, + MaxFunction, + count, + sum_, + avg, + min_, + max_, +) # String function factories from .string import ( + # Function nodes + UpperFunction, + LowerFunction, + CharLengthFunction, concat, coalesce, length, @@ -110,6 +126,7 @@ # SQL/XML expression factories from .xml import ( + passing_clause, xmlagg, xmlattributes, xmlcomment, @@ -126,12 +143,23 @@ ) __all__ = [ + # Aggregate nodes + "AggregateFunction", + "CountFunction", + "SumFunction", + "AvgFunction", + "MinFunction", + "MaxFunction", # Aggregate "count", "sum_", "avg", "min_", "max_", + # String nodes + "UpperFunction", + "LowerFunction", + "CharLengthFunction", # String "concat", "coalesce", @@ -231,6 +259,7 @@ "session_user", "system_user", # SQL/XML + "passing_clause", "xmlagg", "xmlattributes", "xmlcomment", diff --git a/src/rhosocial/activerecord/backend/expression/functions/aggregate.py b/src/rhosocial/activerecord/backend/expression/functions/aggregate.py index 89b77a5bf..a2ff4ab63 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/aggregate.py +++ b/src/rhosocial/activerecord/backend/expression/functions/aggregate.py @@ -1,46 +1,222 @@ # src/rhosocial/activerecord/backend/expression/functions/aggregate.py -"""Aggregate function factories.""" +"""Aggregate function nodes and factories. -from typing import Union, Optional, TYPE_CHECKING +Every standard aggregate is its own node class with its own kind, so the +identity of the function travels on the node instead of inside a generic name +string. ``FunctionCall`` is left to user-defined functions (see catalog §3.4). -from ..bases import BaseExpression -from ..column_types import value_class_of +``AggregateFunction`` is a **shared implementation base**, not an expression +contract: it owns no kind, declares no formatter, cannot be instantiated, never +enters an artifact and never participates in kind dispatch (catalog §3.4, +spec 4.2). It carries what the family does the same way — the ``operand`` plus +``distinct`` shape and the check that the row-count wildcard is only ever the +operand of ``COUNT(*)``. +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, Union + +from ..bases import BaseExpression, SQLValue from ..core import ( + AllColumnsWildcard, Column, - WildcardExpression, - FunctionCall, - IntegerValueExpression, - NumericValueExpression, + RowCountWildcard, +) +from ..mixins import ( + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, ) if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase + from ..bases import SQLPredicate + from ..core import FunctionCall + +class AggregateFunction(AliasableMixin, SQLValue, ABC): + """The shared implementation base of the aggregate family. -def _typed(call: FunctionCall, target: BaseExpression): - """Wrap *call* in the value class matching *target*, when there is one. + ``CountFunction`` / ``SumFunction`` / ``AvgFunction`` / ``MinFunction`` / + ``MaxFunction`` each embed this. It is a code-reuse base, not an expression + node: it has no kind of its own, no formatter of its own, cannot be + instantiated, never enters an artifact and never takes part in kind + dispatch. Each concrete aggregate owns its kind and its formatter. - A reduction answers with the kind of thing it was given: the minimum of an - integer is an integer, the minimum of a float is a number, the minimum of a - string is a string and can still be compared and matched. The column class - already says which that is, so it is read off the class rather than off an - attribute duplicating it -- see - :func:`~...expression.column_types.value_class_of`. + The construction parameters are exactly the wire contract of every + aggregate (``operand`` and ``distinct``). The one cross-cutting rule they + share is enforced here: the row-count wildcard is legal only as the operand + of ``COUNT(*)`` — never for another aggregate, and never with ``DISTINCT``. + """ - ``COUNT`` is the exception and always wraps in - :class:`IntegerValueExpression`, because a count is a count however wide the - thing counted. So is ``AVG``, which always wraps in - :class:`NumericValueExpression` because averaging whole numbers gives a - fraction; neither of those two goes through here. + #: Whether a row-count wildcard is a legal operand. ``True`` only on + #: :class:`CountFunction`: ``COUNT(*)`` counts rows, and no other + #: aggregate has a row to count. + admits_row_count_wildcard: bool = False + + def __init__( + self, + dialect: "SQLDialectBase", + operand: "BaseExpression", + distinct: bool = False, + ): + if not isinstance(distinct, bool): + raise TypeError( + f"distinct must be a bool, got {type(distinct).__name__}" + ) + if isinstance(operand, RowCountWildcard): + if not self.admits_row_count_wildcard: + raise ValueError( + f"{type(self).__name__} does not accept a row-count " + f"wildcard: the bare * counts rows, and COUNT is the only " + f"aggregate whose contract admits it." + ) + if distinct: + raise ValueError( + "COUNT(DISTINCT *) is not valid SQL: the row-count " + "wildcard counts rows, which have no duplicates." + ) + elif isinstance(operand, AllColumnsWildcard): + raise ValueError( + f"{type(self).__name__} takes a value expression, not the " + f"all-columns wildcard: '*' expands columns and belongs to a " + f"select list or RETURNING clause." + ) + # BaseExpression directly, not super(): these nodes render themselves and + # hold no call, and an aggregate that also declares its value type -- + # ARRAY_AGG is an array, JSON_OBJECTAGG a document -- inherits the value + # class for that type, whose __init__ is the *wrapper's* and wants a call + # this node does not have. Going through super() would reach it. + BaseExpression.__init__(self, dialect) + self.operand = operand + self.distinct = distinct + + @abstractmethod + def format_method(self) -> str: + """Name of the dialect formatting method that renders this aggregate.""" + raise NotImplementedError + + def filter(self, predicate: "SQLPredicate") -> "FunctionCall": + """Apply a ``FILTER (WHERE ...)`` clause, degrading to the generic node. + + ``FILTER`` is a modifier the aggregate nodes do not model yet: the wire + contract of ``count_function`` / ``sum_function`` ... is exactly + ``operand`` + ``distinct``, and the standalone ``FilterClause`` node is + a later wave (catalog §3.2). Rather than drop the capability or smuggle + an unmodelled parameter onto the node, this hands back the generic + :class:`FunctionCall` — the escape hatch — carrying the same function, + operand and filter. + + The result is deliberately untyped: it is no longer the classed + aggregate node, so it no longer offers that node's value surface. This + is the same shape the typed wrappers used to forward to, so + ``count(x).filter(p)`` keeps producing the call that renders + ``COUNT(x) FILTER (WHERE p)``. + """ + from ..core import FunctionCall + from ..registry import kind_of + + name = kind_of(type(self)).replace("_function", "").upper() + return FunctionCall( + self._dialect, + name, + self.operand, + is_distinct=self.distinct, + alias=self.alias, + filter_predicate=predicate, + is_aggregate=True, + ) + + +class CountFunction( + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + AggregateFunction, +): + """``COUNT(operand)`` / ``COUNT(DISTINCT operand)`` / ``COUNT(*)``. + + A count is a whole number however wide the thing counted, so the node + carries the integer-value surface and not the string one. + ``COUNT(*)`` is the only place the row-count wildcard may stand. + """ - A target that says nothing -- a ``Literal``, an untyped ``Column`` -- leaves - the call untyped. That is the honest answer: the database decides, and the - caller who needs a specific type says so with ``cast()``. + admits_row_count_wildcard = True + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_count_function" + + +class SumFunction( + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + AggregateFunction, +): + """``SUM(operand)`` / ``SUM(DISTINCT operand)`` — a numeric reduction.""" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_sum_function" + + +class AvgFunction( + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + AggregateFunction, +): + """``AVG(operand)`` / ``AVG(DISTINCT operand)``. + + Averaging whole numbers gives a fraction, so the node carries the numeric + surface rather than the integer one. """ - value_class = value_class_of(target) - if value_class is not None: - return value_class(call._dialect, call) - return call + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_avg_function" + + +class MinFunction(ComparisonMixin, TypeCastingMixin, AggregateFunction): + """``MIN(operand)`` / ``MIN(DISTINCT operand)``. + + The minimum hands back something of the operand's kind, and that kind is + not known to the node, so it offers only the operations every value has: + comparison and casting. A caller who knows the kind narrows the result + with ``cast()`` or the ``as_*`` result-type helpers. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_min_function" + + +class MaxFunction(ComparisonMixin, TypeCastingMixin, AggregateFunction): + """``MAX(operand)`` / ``MAX(DISTINCT operand)`` — the mirror of MIN.""" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_max_function" + + +def _named(node: BaseExpression, alias: Optional[str]) -> BaseExpression: + """Apply *alias* through the aliasing contract, or return *node* untouched.""" + return node.as_(alias) if alias else node def count( @@ -48,13 +224,13 @@ def count( expr: Union[str, "BaseExpression"] = "*", is_distinct: bool = False, alias: Optional[str] = None, -) -> "FunctionCall": +) -> "CountFunction": """ - Creates a COUNT aggregate function call. + Creates a COUNT aggregate node. Usage rules: - To generate COUNT(*), pass "*" as a string: count(dialect, "*") - - To generate COUNT(*), pass a WildcardExpression: count(dialect, WildcardExpression(dialect)) + - To generate COUNT(*), pass a RowCountWildcard: count(dialect, RowCountWildcard(dialect)) - To generate COUNT(column), pass a Column object: count(dialect, Column(dialect, "column_name")) - To generate COUNT(?), pass a literal value: count(dialect, "literal_value") @@ -67,20 +243,21 @@ def count( alias: Optional alias for the result Returns: - A FunctionCall instance representing the COUNT function + A :class:`CountFunction` node. """ - # Check if the passed expression is the string "*" if expr == "*" and isinstance(expr, str): - target_expr = WildcardExpression(dialect) - # Check if the passed expression is a WildcardExpression - elif isinstance(expr, WildcardExpression): + target_expr = RowCountWildcard(dialect) + elif isinstance(expr, RowCountWildcard): target_expr = expr + elif isinstance(expr, AllColumnsWildcard): + raise ValueError( + "COUNT takes column expressions or a row-count wildcard; the " + "all-columns wildcard expands columns and belongs to a select " + "list or RETURNING clause, not an aggregate parameter." + ) else: target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return IntegerValueExpression( - dialect, - FunctionCall(dialect, "COUNT", target_expr, is_distinct=is_distinct, alias=alias, is_aggregate=True), - ) + return _named(CountFunction(dialect, target_expr, distinct=is_distinct), alias) def sum_( @@ -88,9 +265,9 @@ def sum_( expr: Union[str, "BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None, -) -> "FunctionCall": +) -> "SumFunction": """ - Creates a SUM aggregate function call. + Creates a SUM aggregate node. Usage rules: - To generate SUM(column), pass a Column object: sum_(dialect, Column(dialect, "column_name")) @@ -104,20 +281,15 @@ def sum_( alias: Optional alias for the result Returns: - A FunctionCall instance representing the SUM function + A :class:`SumFunction` node. """ + if isinstance(expr, RowCountWildcard) or (isinstance(expr, str) and expr == "*"): + raise ValueError( + "SUM does not accept a row-count wildcard: the bare * counts " + "rows, and COUNT is the only aggregate whose contract admits it." + ) target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return _typed( - FunctionCall( - dialect, - "SUM", - target_expr, - is_distinct=is_distinct, - alias=alias, - is_aggregate=True, - ), - target_expr, - ) + return _named(SumFunction(dialect, target_expr, distinct=is_distinct), alias) def avg( @@ -125,9 +297,9 @@ def avg( expr: Union[str, "BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None, -) -> "FunctionCall": +) -> "AvgFunction": """ - Creates an AVG aggregate function call. + Creates an AVG aggregate node. Usage rules: - To generate AVG(column), pass a Column object: avg(dialect, Column(dialect, "column_name")) @@ -141,20 +313,25 @@ def avg( alias: Optional alias for the result Returns: - A FunctionCall instance representing the AVG function + An :class:`AvgFunction` node. """ + if isinstance(expr, RowCountWildcard) or (isinstance(expr, str) and expr == "*"): + raise ValueError( + "AVG does not accept a row-count wildcard: the bare * counts " + "rows, and COUNT is the only aggregate whose contract admits it." + ) target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return NumericValueExpression( - dialect, - FunctionCall(dialect, "AVG", target_expr, is_distinct=is_distinct, alias=alias, is_aggregate=True), - ) + return _named(AvgFunction(dialect, target_expr, distinct=is_distinct), alias) def min_( - dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], alias: Optional[str] = None -) -> "FunctionCall": + dialect: "SQLDialectBase", + expr: Union[str, "BaseExpression"], + is_distinct: bool = False, + alias: Optional[str] = None, +) -> "MinFunction": """ - Creates a MIN aggregate function call. + Creates a MIN aggregate node. Usage rules: - To generate MIN(column), pass a Column object: min_(dialect, Column(dialect, "column_name")) @@ -164,20 +341,29 @@ def min_( dialect: The SQL dialect instance expr: The expression to find minimum of. If a string is passed, it's treated as a column name. If a BaseExpression is passed, it's used as-is. + is_distinct: Whether to use DISTINCT keyword alias: Optional alias for the result Returns: - A FunctionCall instance representing the MIN function + A :class:`MinFunction` node. """ + if isinstance(expr, RowCountWildcard) or (isinstance(expr, str) and expr == "*"): + raise ValueError( + "MIN does not accept a row-count wildcard: the bare * counts " + "rows, and COUNT is the only aggregate whose contract admits it." + ) target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return _typed(FunctionCall(dialect, "MIN", target_expr, alias=alias, is_aggregate=True), target_expr) + return _named(MinFunction(dialect, target_expr, distinct=is_distinct), alias) def max_( - dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], alias: Optional[str] = None -) -> "FunctionCall": + dialect: "SQLDialectBase", + expr: Union[str, "BaseExpression"], + is_distinct: bool = False, + alias: Optional[str] = None, +) -> "MaxFunction": """ - Creates a MAX aggregate function call. + Creates a MAX aggregate node. Usage rules: - To generate MAX(column), pass a Column object: max_(dialect, Column(dialect, "column_name")) @@ -187,10 +373,31 @@ def max_( dialect: The SQL dialect instance expr: The expression to find maximum of. If a string is passed, it's treated as a column name. If a BaseExpression is passed, it's used as-is. + is_distinct: Whether to use DISTINCT keyword alias: Optional alias for the result Returns: - A FunctionCall instance representing the MAX function + A :class:`MaxFunction` node. """ + if isinstance(expr, RowCountWildcard) or (isinstance(expr, str) and expr == "*"): + raise ValueError( + "MAX does not accept a row-count wildcard: the bare * counts " + "rows, and COUNT is the only aggregate whose contract admits it." + ) target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return _typed(FunctionCall(dialect, "MAX", target_expr, alias=alias, is_aggregate=True), target_expr) + return _named(MaxFunction(dialect, target_expr, distinct=is_distinct), alias) + + +__all__ = [ + "AggregateFunction", + "CountFunction", + "SumFunction", + "AvgFunction", + "MinFunction", + "MaxFunction", + "count", + "sum_", + "avg", + "min_", + "max_", +] diff --git a/src/rhosocial/activerecord/backend/expression/functions/array.py b/src/rhosocial/activerecord/backend/expression/functions/array.py index 32e32f920..151af80a6 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/array.py +++ b/src/rhosocial/activerecord/backend/expression/functions/array.py @@ -3,48 +3,139 @@ from typing import Union, Optional, TYPE_CHECKING +from ..advanced_functions import DeclaredValueType from ..bases import BaseExpression from ..core import ( Column, - ArrayValueExpression, - IntegerValueExpression, - FunctionCall, Literal, ) +from .aggregate import AggregateFunction if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase +from ..value_types import ( + ArrayValue, + NumericValue, +) + +class UnnestFunction( + DeclaredValueType, + ArrayValue, +): + """``UNNEST(array)`` -- the array's elements, one per row. + + Not an aggregate: it does not combine rows, it *multiplies* them, which is + why a FROM source is where it belongs and why counting its output is a + different question from ``ARRAY_LENGTH``. + """ + + + def __init__(self, dialect: "SQLDialectBase", target): + from ..core import Literal + + # BaseExpression directly, not super(): the value class is a *wrapper* + # whose __init__ takes the call it delegates to, and this node has no + # call -- it renders itself. DeclaredValueType keeps that from mattering. + BaseExpression.__init__(self, dialect) + # See ArrayLengthFunction: an array is not text and not a number, so a + # bare host value becomes the untyped literal for now. + self.target = target if isinstance(target, BaseExpression) else Literal(dialect, target) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_unnest_function" + + +class ArrayLengthFunction( + DeclaredValueType, + NumericValue, +): + """``ARRAY_LENGTH(array[, dimension])`` -- how many elements. + + :attr:`dimension` is optional and its absence is not the same as dimension 1 + on a backend that counts the array's extents rather than its elements, so it + is ``None`` when not asked for and the formatter drops it. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, dimension=None): + from .. import values + from ..core import Literal + + # BaseExpression directly, not super(): the value class is a *wrapper* + # whose __init__ takes the call it delegates to, and this node has no + # call -- it renders itself. DeclaredValueType keeps that from mattering. + BaseExpression.__init__(self, dialect) + # An array is not text and not a number, so there is no typed value node + # for it; a bare host value becomes the untyped literal until the array + # value type lands. + self.target = target if isinstance(target, BaseExpression) else Literal(dialect, target) + # Normalised here so the count is a node however it arrived; a bare int + # reaching the formatter is what made it fail at render instead of here. + self.dimension = ( + None + if dimension is None + else dimension if isinstance(dimension, BaseExpression) else Literal(dialect, dimension) + ) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_array_length_function" + +class ArrayAggregateFunction( + AggregateFunction, + DeclaredValueType, + ArrayValue, +): + """``ARRAY_AGG(operand)`` -- the rows gathered into one array. + + An aggregate, so it carries ``DISTINCT`` and the window and filter clauses an + aggregate takes; :class:`~...expression.advanced_functions.AggregateFunction` + holds those. + + The result is an array whatever the gathered values were, which is why the + node says so instead of reading a type off the operand. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_array_agg_function" + def array_agg( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], is_distinct: bool = False, alias: Optional[str] = None, -) -> "FunctionCall": +) -> "ArrayAggregateFunction": """Creates an ARRAY_AGG aggregate function call. The result is always an array -- gathering values produces a list of them, - whatever the gathered type was -- so this wraps in - :class:`ArrayValueExpression` rather than reading a type off the argument. + whatever the gathered type was -- so this node says so rather than reading a + type off the argument. """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return ArrayValueExpression( - dialect, - FunctionCall(dialect, "ARRAY_AGG", target_expr, is_distinct=is_distinct, alias=alias, is_aggregate=True), - ) + node = ArrayAggregateFunction(dialect, target_expr, distinct=is_distinct) + # The alias goes through the aliasing contract rather than the constructor: + # AggregateFunction takes the operand and DISTINCT, and the name is applied + # afterwards, which is what every other aggregate factory does. + return node.as_(alias) if alias else node -def unnest(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "ArrayValueExpression": +def unnest(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "UnnestFunction": """Creates an UNNEST function call.""" target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return ArrayValueExpression(dialect, FunctionCall(dialect, 'UNNEST', target_expr)) + return UnnestFunction(dialect, target_expr) def array_length( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], dimension: int = 1 -) -> "IntegerValueExpression": +) -> "ArrayLengthFunction": """Creates an ARRAY_LENGTH function call.""" target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - dimension_expr = Literal(dialect, dimension) - return IntegerValueExpression(dialect, FunctionCall(dialect, 'ARRAY_LENGTH', target_expr, dimension_expr)) + dimension_expr = dimension if isinstance(dimension, BaseExpression) else Literal(dialect, dimension) + return ArrayLengthFunction(dialect, target_expr, dimension_expr) diff --git a/src/rhosocial/activerecord/backend/expression/functions/conditional.py b/src/rhosocial/activerecord/backend/expression/functions/conditional.py index b2f68dbc6..134515372 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/conditional.py +++ b/src/rhosocial/activerecord/backend/expression/functions/conditional.py @@ -3,7 +3,7 @@ from typing import Any, Union, Optional, TYPE_CHECKING, overload -from ..bases import BaseExpression, SQLValueExpression +from ..bases import BaseExpression, SQLValue from ..column_types import ( ArrayColumn, BooleanColumn, @@ -13,16 +13,15 @@ NumericColumn, StringColumn, ) -from ..core import ( - ArrayValueExpression, - BooleanValueExpression, - TimestampValueExpression, - FunctionCall, - IntegerValueExpression, - JSONValueExpression, - Literal, - NumericValueExpression, - StringValueExpression, +from ..core import FunctionCall, Literal +from ..declared import ( + ArrayAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, ) from ..advanced_functions import CaseExpression @@ -30,7 +29,7 @@ from ...dialect import SQLDialectBase -def _result_class_for(expr: object): +def _result_annotation_for(expr: object): """The value class an operation over *expr* hands back, or ``None``. This is the answer to one question asked inside this module: *GREATEST* and @@ -48,19 +47,19 @@ def _result_class_for(expr: object): numeric branch sees it. """ if isinstance(expr, IntegerColumn): - return IntegerValueExpression + return IntegerAnnotation if isinstance(expr, StringColumn): - return StringValueExpression + return TextAnnotation if isinstance(expr, TimestampColumn): - return TimestampValueExpression + return TemporalAnnotation if isinstance(expr, JSONColumn): - return JSONValueExpression + return JSONAnnotation if isinstance(expr, ArrayColumn): - return ArrayValueExpression + return ArrayAnnotation if isinstance(expr, BooleanColumn): - return BooleanValueExpression + return BooleanAnnotation if isinstance(expr, NumericColumn): - return NumericValueExpression + return NumberAnnotation return None @@ -81,7 +80,7 @@ def case( return CaseExpression(dialect, value=value, alias=alias) -def _widest(dialect: "SQLDialectBase", call: "FunctionCall", exprs: list) -> "SQLValueExpression": +def _widest(dialect: "SQLDialectBase", call: "FunctionCall", exprs: list) -> "SQLValue": """Give GREATEST/LEAST the type of its arguments when they agree on one. Every argument is a candidate for the result, so when they all answer with @@ -91,7 +90,7 @@ def _widest(dialect: "SQLDialectBase", call: "FunctionCall", exprs: list) -> "SQ argument or the widest, either of which would promise a surface the database does not. """ - classes = {_result_class_for(e) for e in exprs} + classes = {_result_annotation_for(e) for e in exprs} if len(classes) == 1 and None not in classes: return classes.pop()(dialect, call) return call @@ -103,24 +102,24 @@ def _widest(dialect: "SQLDialectBase", call: "FunctionCall", exprs: list) -> "SQ @overload def nullif( dialect: "SQLDialectBase", value: IntegerColumn, null_value: Any -) -> "IntegerValueExpression": ... +) -> "IntegerAnnotation": ... @overload def nullif( dialect: "SQLDialectBase", value: NumericColumn, null_value: Any -) -> "NumericValueExpression": ... +) -> "NumberAnnotation": ... @overload def nullif( dialect: "SQLDialectBase", value: Any, null_value: Any -) -> "SQLValueExpression": ... +) -> "SQLValue": ... def nullif( dialect: "SQLDialectBase", value: Any, null_value: Any -) -> "SQLValueExpression": +) -> "SQLValue": """ Creates a NULLIF scalar function call. @@ -144,7 +143,7 @@ def nullif( value_expr = value if isinstance(value, BaseExpression) else Literal(dialect, value) null_expr = null_value if isinstance(null_value, BaseExpression) else Literal(dialect, null_value) call = FunctionCall(dialect, "NULLIF", value_expr, null_expr) - result = _result_class_for(value_expr) + result = _result_annotation_for(value_expr) if result is not None: return result(dialect, call) return call @@ -152,7 +151,7 @@ def nullif( def greatest( dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"] -) -> "SQLValueExpression": +) -> "SQLValue": """ Creates a GREATEST scalar function call. @@ -176,7 +175,7 @@ def greatest( def least( dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"] -) -> "SQLValueExpression": +) -> "SQLValue": """ Creates a LEAST scalar function call. diff --git a/src/rhosocial/activerecord/backend/expression/functions/datetime.py b/src/rhosocial/activerecord/backend/expression/functions/datetime.py index 849a57217..6730e0e39 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/datetime.py +++ b/src/rhosocial/activerecord/backend/expression/functions/datetime.py @@ -6,19 +6,18 @@ from ..bases import BaseExpression from ..core import ( Column, - DateValueExpression, - TemporalValueExpression, - TimeValueExpression, - TimestampValueExpression, - FunctionCall, - IntegerValueExpression, - Literal, ) from ..datetime import ( + CurrentDateFunction, + ExtractFieldExpression, + CurrentTimeFunction, + CurrentTimestampFunction, + LocalTimestampFunction, + NowFunction, DatePartExpression, - DateTimeAddExpression, - DateTimeDiffExpression, - DateTimeSubtractExpression, + DatetimeAddExpression, + DatetimeDiffExpression, + DatetimeSubtractExpression, DateTruncExpression, ExtractExpression, IntervalExpression, @@ -28,7 +27,7 @@ from ...dialect import SQLDialectBase -def now(dialect: "SQLDialectBase") -> "TemporalValueExpression": +def now(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "NowFunction": """ Creates a NOW scalar function call. @@ -36,28 +35,31 @@ def now(dialect: "SQLDialectBase") -> "TemporalValueExpression": dialect: The SQL dialect instance Returns: - A TimestampValueExpression wrapping NOW + A TimestampValue wrapping NOW """ - return TimestampValueExpression(dialect, FunctionCall(dialect, 'NOW')) + return NowFunction(dialect, precision) -def current_date(dialect: "SQLDialectBase") -> "TemporalValueExpression": +def current_date(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "CurrentDateFunction": """ Creates a CURRENT_DATE niladic value function. - SQL:2003 standard niladic function — generates CURRENT_TIMESTAMP - without parentheses, as required by the standard. + SQL:2003 standard niladic function — generates CURRENT_DATE without + parentheses, as required by the standard. Args: dialect: The SQL dialect instance + precision: Optional fractional seconds precision. The standard writes + this function with no arguments, so this is normally ``None``; a + backend that spells the scale needs somewhere to read it from. Returns: - A DateValueExpression wrapping CURRENT_DATE + A :class:`~...expression.datetime.CurrentDateFunction` node. """ - return DateValueExpression(dialect, FunctionCall(dialect, 'CURRENT_DATE', niladic=True)) + return CurrentDateFunction(dialect, precision) -def current_time(dialect: "SQLDialectBase") -> "TemporalValueExpression": +def current_time(dialect: "SQLDialectBase", precision: Optional[int] = None) -> "CurrentTimeFunction": """ Creates a CURRENT_TIME niladic value function. @@ -68,12 +70,12 @@ def current_time(dialect: "SQLDialectBase") -> "TemporalValueExpression": dialect: The SQL dialect instance Returns: - A TimeValueExpression wrapping CURRENT_TIME + A TimeValue wrapping CURRENT_TIME """ - return TimeValueExpression(dialect, FunctionCall(dialect, 'CURRENT_TIME', niladic=True)) + return CurrentTimeFunction(dialect, precision) -def year(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def year(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates a YEAR scalar function call. @@ -87,13 +89,13 @@ def year(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpr it's treated as a literal value. If a BaseExpression is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping YEAR + An IntegerValue wrapping YEAR """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'YEAR', target_expr)) + return ExtractFieldExpression(dialect, field="year", source=target_expr, spelling="YEAR") -def month(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def month(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates a MONTH scalar function call. @@ -108,13 +110,13 @@ def month(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExp is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping MONTH + An IntegerValue wrapping MONTH """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'MONTH', target_expr)) + return ExtractFieldExpression(dialect, field="month", source=target_expr, spelling="MONTH") -def day(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def day(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates a DAY scalar function call. @@ -128,13 +130,13 @@ def day(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpre it's treated as a literal value. If a BaseExpression is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping DAY + An IntegerValue wrapping DAY """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'DAY', target_expr)) + return ExtractFieldExpression(dialect, field="day", source=target_expr, spelling="DAY") -def hour(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def hour(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates an HOUR scalar function call. @@ -148,13 +150,13 @@ def hour(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpr it's treated as a literal value. If a BaseExpression is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping HOUR + An IntegerValue wrapping HOUR """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'HOUR', target_expr)) + return ExtractFieldExpression(dialect, field="hour", source=target_expr, spelling="HOUR") -def minute(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def minute(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates a MINUTE scalar function call. @@ -168,13 +170,13 @@ def minute(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueEx it's treated as a literal value. If a BaseExpression is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping MINUTE + An IntegerValue wrapping MINUTE """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'MINUTE', target_expr)) + return ExtractFieldExpression(dialect, field="minute", source=target_expr, spelling="MINUTE") -def second(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueExpression": +def second(dialect: "SQLDialectBase", expr: "BaseExpression") -> "ExtractFieldExpression": """ Creates a SECOND scalar function call. @@ -189,10 +191,10 @@ def second(dialect: "SQLDialectBase", expr: "BaseExpression") -> "IntegerValueEx If a BaseExpression is passed, it's used as-is. Returns: - An IntegerValueExpression wrapping SECOND + An IntegerValue wrapping SECOND """ target_expr = expr - return IntegerValueExpression(dialect, FunctionCall(dialect, 'SECOND', target_expr)) + return ExtractFieldExpression(dialect, field="second", source=target_expr, spelling="SECOND") def date_part(dialect: "SQLDialectBase", field: str, expr: "BaseExpression") -> "DatePartExpression": @@ -209,7 +211,7 @@ def date_trunc(dialect: "SQLDialectBase", field: str, expr: "BaseExpression") -> def current_timestamp( dialect: "SQLDialectBase", precision: Optional[int] = None -) -> "TemporalValueExpression": +) -> "CurrentTimestampFunction": """ Creates a CURRENT_TIMESTAMP niladic value function. @@ -226,16 +228,14 @@ def current_timestamp( precision: Optional fractional seconds precision Returns: - A TimeValueExpression wrapping CURRENT_TIMESTAMP + A TimeValue wrapping CURRENT_TIMESTAMP """ - if precision is not None: - return FunctionCall(dialect, "CURRENT_TIMESTAMP", Literal(dialect, precision)) - return TimestampValueExpression(dialect, FunctionCall(dialect, 'CURRENT_TIMESTAMP', niladic=True)) + return CurrentTimestampFunction(dialect, precision) def localtimestamp( dialect: "SQLDialectBase", precision: Optional[int] = None -) -> "TemporalValueExpression": +) -> "LocalTimestampFunction": """ Creates a LOCALTIMESTAMP niladic value function. @@ -252,11 +252,9 @@ def localtimestamp( precision: Optional fractional seconds precision Returns: - A TimestampValueExpression wrapping LOCALTIMESTAMP + A TimestampValue wrapping LOCALTIMESTAMP """ - if precision is not None: - return FunctionCall(dialect, "LOCALTIMESTAMP", Literal(dialect, precision)) - return TimestampValueExpression(dialect, FunctionCall(dialect, 'LOCALTIMESTAMP', niladic=True)) + return LocalTimestampFunction(dialect, precision) def extract(dialect: "SQLDialectBase", field: str, expr: "BaseExpression") -> "ExtractExpression": @@ -289,11 +287,11 @@ def date_add( expr: "BaseExpression", value_or_interval: Union[int, float, "IntervalExpression"], unit: Optional[str] = None, -) -> "DateTimeAddExpression": +) -> "DatetimeAddExpression": """Creates an expression that adds an interval to a datetime expression.""" target_expr = expr interval_expr = _ensure_interval(dialect, value_or_interval, unit) - return DateTimeAddExpression(dialect, target_expr, interval_expr) + return DatetimeAddExpression(dialect, target_expr, interval_expr) def date_sub( @@ -301,11 +299,11 @@ def date_sub( expr: "BaseExpression", value_or_interval: Union[int, float, "IntervalExpression"], unit: Optional[str] = None, -) -> "DateTimeSubtractExpression": +) -> "DatetimeSubtractExpression": """Creates an expression that subtracts an interval from a datetime expression.""" target_expr = expr interval_expr = _ensure_interval(dialect, value_or_interval, unit) - return DateTimeSubtractExpression(dialect, target_expr, interval_expr) + return DatetimeSubtractExpression(dialect, target_expr, interval_expr) def date_diff( @@ -313,8 +311,8 @@ def date_diff( unit: str, start_expr: "BaseExpression", end_expr: "BaseExpression", -) -> "DateTimeDiffExpression": +) -> "DatetimeDiffExpression": """Creates an expression for the difference between two datetime expressions.""" start = start_expr if isinstance(start_expr, BaseExpression) else Column(dialect, start_expr) end = end_expr if isinstance(end_expr, BaseExpression) else Column(dialect, end_expr) - return DateTimeDiffExpression(dialect, unit, start, end) + return DatetimeDiffExpression(dialect, unit, start, end) diff --git a/src/rhosocial/activerecord/backend/expression/functions/json.py b/src/rhosocial/activerecord/backend/expression/functions/json.py index 81326f4b2..b619b804b 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/json.py +++ b/src/rhosocial/activerecord/backend/expression/functions/json.py @@ -3,20 +3,127 @@ from typing import Optional, TYPE_CHECKING +from ..advanced_functions import DeclaredValueType from ..bases import BaseExpression -from ..core import ( - Column, - JSONValueExpression, - FunctionCall, - Literal, -) -from ..advanced_functions import JSONDocumentExpression, JSONTextExpression +from .aggregate import AggregateFunction +from ..core import Column, Literal +from ..advanced_functions import JSONDocumentValue, JSONTextValue if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase +from ..value_types import JSONValue -def json_extract(dialect: "SQLDialectBase", column: "BaseExpression", path: str) -> "JSONDocumentExpression": +class JSONBuildObjectFunction( + DeclaredValueType, + JSONValue, +): + """``JSON_BUILD_OBJECT(key, value, ...)`` -- a document built from pairs. + + :attr:`pairs` is a flat list of alternating key and value, which is the + order the function reads them in, so it is stored flat rather than zipped: + a backend whose spelling reorders them has to see the order the caller gave. + """ + + + def __init__(self, dialect: "SQLDialectBase", *pairs): + + # BaseExpression directly, not super(): the value class is a *wrapper* + # whose __init__ takes the call it delegates to, and this node has no + # call -- it renders itself. DeclaredValueType keeps that from mattering. + BaseExpression.__init__(self, dialect) + self.pairs = [p if isinstance(p, BaseExpression) else Literal(dialect, p) for p in pairs] + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_json_build_object_function" + + +class JSONArrayElementsFunction( + DeclaredValueType, + JSONValue, +): + """``JSON_ARRAY_ELEMENTS(json)`` -- the array's elements, one per row. + + A table function rather than a scalar: it multiplies rows rather than + combining them, which is why a FROM source is where it belongs. + """ + + + def __init__(self, dialect: "SQLDialectBase", target): + from ..core import Literal + + # BaseExpression directly, not super(): the value class is a *wrapper* + # whose __init__ takes the call it delegates to, and this node has no + # call -- it renders itself. DeclaredValueType keeps that from mattering. + BaseExpression.__init__(self, dialect) + # A JSON document is not text and not a number, so a bare host value + # becomes the untyped literal until the JSON value type lands. + self.target = target if isinstance(target, BaseExpression) else Literal(dialect, target) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_json_array_elements_function" + + +class JSONObjectAggregateFunction( + AggregateFunction, + DeclaredValueType, + JSONValue, +): + """``JSON_OBJECTAGG(key, value)`` -- rows gathered into one document. + + Two operands where :class:`~...expression.functions.aggregate.AggregateFunction` + declares one, so the **key** is the aggregate's operand -- it is what + identifies the row -- and the value rides alongside as + :attr:`entry_value`. ``FILTER`` and the window clause therefore apply to the + key, which is the only reading that has one. + """ + + + def __init__(self, dialect: "SQLDialectBase", operand, entry_value): + from ..core import Literal + + # ``operand`` and not ``key``: that is the name AggregateFunction + # declares, and the deserializer reads the declared names, so a + # different spelling here would reconstruct a node missing its key. + # + # Both operands are arbitrary JSON, so neither is text or a number and a + # bare host value becomes the untyped literal. + operand = operand if isinstance(operand, BaseExpression) else Literal(dialect, operand) + entry_value = ( + entry_value if isinstance(entry_value, BaseExpression) else Literal(dialect, entry_value) + ) + AggregateFunction.__init__(self, dialect, operand) + self.entry_value = entry_value + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_json_objectagg_function" + + +class JSONArrayAggregateFunction( + AggregateFunction, + DeclaredValueType, + JSONValue, +): + """``JSON_ARRAYAGG(value)`` -- rows gathered into one array. + + An array whatever the gathered values were, so the node says so rather than + reading a type off the operand. + """ + + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_json_arrayagg_function" + + +def json_extract(dialect: "SQLDialectBase", column: "BaseExpression", path: str) -> "JSONDocumentValue": """ Creates a JSON extract operation (e.g., column->path). @@ -30,13 +137,13 @@ def json_extract(dialect: "SQLDialectBase", column: "BaseExpression", path: str) path: The JSON path to extract. Returns: - A JSONDocumentExpression instance representing the JSON extract operation + A JSONDocumentValue instance representing the JSON extract operation """ target_column = column if isinstance(column, BaseExpression) else Column(dialect, column) - return JSONDocumentExpression(dialect, target_column, path, operation="->") + return JSONDocumentValue(dialect, target_column, path, operation="->") -def json_extract_text(dialect: "SQLDialectBase", column: "BaseExpression", path: str) -> "JSONTextExpression": +def json_extract_text(dialect: "SQLDialectBase", column: "BaseExpression", path: str) -> "JSONTextValue": """ Creates a JSON extract text operation (e.g., column->>path). @@ -51,18 +158,18 @@ def json_extract_text(dialect: "SQLDialectBase", column: "BaseExpression", path: path: The JSON path to extract as text. Returns: - A JSONTextExpression instance representing the JSON extract text operation + A JSONTextValue instance representing the JSON extract text operation """ target_column = column if isinstance(column, BaseExpression) else Column(dialect, column) - # `->>` yields text, so it arrives as JSONTextExpression and carries the - # string operations. It used to be built as a JSONDocumentExpression and described + # `->>` yields text, so it arrives as JSONTextValue and carries the + # string operations. It used to be built as a JSONDocumentValue and described # as a string by a tag, which meant the JSON accessors it was meant to end # the chain with stayed reachable -- the node class served both arrow # directions and could not drop them per instance. - return JSONTextExpression(dialect, target_column, path, operation="->>") + return JSONTextValue(dialect, target_column, path, operation="->>") -def json_build_object(dialect: "SQLDialectBase", *key_value_pairs: "BaseExpression") -> "JSONValueExpression": +def json_build_object(dialect: "SQLDialectBase", *key_value_pairs: "BaseExpression") -> "BaseExpression": """ Creates a JSON_BUILD_OBJECT function call. @@ -77,16 +184,13 @@ def json_build_object(dialect: "SQLDialectBase", *key_value_pairs: "BaseExpressi contract; it is still bound as a literal at runtime. Returns: - A JSONValueExpression wrapping JSON_BUILD_OBJECT + A JSONValue wrapping JSON_BUILD_OBJECT """ # Expect alternating sequence of key-value expressions - processed_args = [] - for arg in key_value_pairs: - processed_args.append(arg if isinstance(arg, BaseExpression) else Literal(dialect, arg)) - return JSONValueExpression(dialect, FunctionCall(dialect, 'JSON_BUILD_OBJECT', *processed_args)) + return JSONBuildObjectFunction(dialect, *key_value_pairs) -def json_array_elements(dialect: "SQLDialectBase", expr: "BaseExpression") -> "JSONValueExpression": +def json_array_elements(dialect: "SQLDialectBase", expr: "BaseExpression") -> "BaseExpression": """ Creates a JSON_ARRAY_ELEMENTS function call. @@ -100,24 +204,21 @@ def json_array_elements(dialect: "SQLDialectBase", expr: "BaseExpression") -> "J of the contract; it is still coerced to a column at runtime. Returns: - A JSONValueExpression wrapping JSON_ARRAY_ELEMENTS + A JSONValue wrapping JSON_ARRAY_ELEMENTS """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return JSONValueExpression(dialect, FunctionCall(dialect, 'JSON_ARRAY_ELEMENTS', target_expr)) + return JSONArrayElementsFunction(dialect, target_expr) def json_objectagg( dialect: "SQLDialectBase", key_expr: "BaseExpression", value_expr: "BaseExpression", -) -> "FunctionCall": +) -> "JSONObjectAggregateFunction": """Creates a JSON_OBJECTAGG aggregate function call.""" key_target = key_expr if isinstance(key_expr, BaseExpression) else Column(dialect, key_expr) value_target = value_expr if isinstance(value_expr, BaseExpression) else Column(dialect, value_expr) - return JSONValueExpression( - dialect, - FunctionCall(dialect, "JSON_OBJECTAGG", key_target, value_target, is_aggregate=True), - ) + return JSONObjectAggregateFunction(dialect, key_target, value_target) def json_arrayagg( @@ -125,17 +226,8 @@ def json_arrayagg( expr: "BaseExpression", is_distinct: bool = False, alias: Optional[str] = None, -) -> "FunctionCall": +) -> "JSONArrayAggregateFunction": """Creates a JSON_ARRAYAGG aggregate function call.""" target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - return JSONValueExpression( - dialect, - FunctionCall( - dialect, - "JSON_ARRAYAGG", - target_expr, - is_distinct=is_distinct, - alias=alias, - is_aggregate=True, - ), - ) \ No newline at end of file + node = JSONArrayAggregateFunction(dialect, target_expr, distinct=is_distinct) + return node.as_(alias) if alias else node \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/functions/math.py b/src/rhosocial/activerecord/backend/expression/functions/math.py index b195b9b3e..4b4c906ed 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/math.py +++ b/src/rhosocial/activerecord/backend/expression/functions/math.py @@ -1,14 +1,16 @@ # src/rhosocial/activerecord/backend/expression/functions/math.py """Math function factories.""" +import decimal +from abc import abstractmethod from typing import Union, Optional, TYPE_CHECKING, overload +from ..advanced_functions import DeclaredValueType from ..bases import BaseExpression from ..column_types import IntegerColumn, NumericColumn from ..core import FunctionCall, Literal if TYPE_CHECKING: # pragma: no cover - from ..core import IntegerValueExpression, NumericValueExpression from ...dialect import SQLDialectBase @@ -16,22 +18,360 @@ #: result depends on the input: the same call is an integer for an int column #: and possibly a fraction for a float one. Overloads say so in the type rather #: than leaving the caller to read an attribute back off the instance. +from ..value_types import ( + NumericValue, +) + +class _MathOperands: + """Declared-operand normalisation for the math nodes below. + + A numeric operand is normalised here rather than only in the factory, so + constructing a node directly is safe too and a wrong operand fails at + construction instead of surfacing as an ``AttributeError`` from the + formatter. + + An ``int`` stays an integer and everything else becomes an exact decimal: + collapsing both to a float would turn ``MOD(x, 3)`` into a floating-point + modulus. + """ + + @staticmethod + def _number(dialect: "SQLDialectBase", value): + from .. import values + + if value is None: + return None + if isinstance(value, BaseExpression): + return value + if isinstance(value, int): + return Literal(dialect, value) + return Literal(dialect, decimal.Decimal(str(value))) + + +class _MathFunction( + _MathOperands, + DeclaredValueType, + NumericValue, +): + """Shared base for the math nodes that always answer with a number. + + :attr:`arguments` is the operand list as the formatter writes it, so a + backend that spells a function with its operands in another order reorders + the list rather than the node's fields. + """ + + #: The function name this node writes. Overridden per subclass. + function_name: str = "" + + def __init__(self, dialect: "SQLDialectBase", *arguments): + # BaseExpression directly, not super(): the value types below are mixin + # carriers ending at SQLValue, and going through super() would chain into + # whichever of them happens to declare __init__. + BaseExpression.__init__(self, dialect) + self.arguments = [self._number(dialect, a) for a in arguments] + + @property + @abstractmethod + def format_method(self) -> str: + """The dialect formatting method that renders this expression. + + Abstract so the shared bases are not mistaken for nodes: each subclass + names the formatter for its own kind, and a base that guessed from its + own name would name a formatter that does not exist. + """ + raise NotImplementedError + + +class _FamilyPreservingMathFunction(_MathFunction): + """Shared base for the five that keep a whole number whole. + + ``ABS``, ``ROUND``, ``CEIL``, ``FLOOR`` and ``TRUNCATE`` of an integer are + integers, and of a fraction may not be, so the class is chosen by the + **column** before anything runs. The integral class below subclasses this + one rather than repeating it, so ``isinstance(node, AbsoluteFunction)`` + holds either way. + """ + + @classmethod + def for_operand(cls, dialect: "SQLDialectBase", operand_class, *arguments): + """The integral or the numeric class, per the operand's declared family.""" + return ( + cls.integral(dialect, *arguments) + if issubclass(operand_class, IntegerColumn) + else cls(dialect, *arguments) + ) + + +class AbsoluteFunction(_FamilyPreservingMathFunction): + """``ABS(operand)`` -- the operand's magnitude. + + Of a whole number a whole number, and of a fraction a fraction, so the + result type follows the operand rather than the function. + """ + + function_name = "ABS" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_absolute_function" + + @classmethod + def integral(cls, dialect, *arguments): + """The whole-number variant, for an integer operand.""" + return AbsoluteIntegerFunction(dialect, *arguments) + + +class AbsoluteIntegerFunction( + AbsoluteFunction, + DeclaredValueType, + NumericValue, +): + """``ABS`` of an integer -- still an integer.""" + + +class RoundFunction(_FamilyPreservingMathFunction): + """``ROUND(operand[, scale])`` -- rounded to a scale. + + Keeps a whole number whole, which is why ``ROUND(2.0)`` is 2 and not 2.0 on + a backend that says so. + """ + + function_name = "ROUND" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_round_function" + + @classmethod + def integral(cls, dialect, *arguments): + """The whole-number variant, for an integer operand.""" + return RoundIntegerFunction(dialect, *arguments) + + +class RoundIntegerFunction( + RoundFunction, + DeclaredValueType, + NumericValue, +): + """``ROUND`` of an integer -- still an integer.""" + + +class CeilFunction(_FamilyPreservingMathFunction): + """``CEIL(operand)`` -- the smallest whole number not below it.""" + + function_name = "CEIL" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_ceil_function" + + @classmethod + def integral(cls, dialect, *arguments): + """The whole-number variant, for an integer operand.""" + return CeilIntegerFunction(dialect, *arguments) + + +class CeilIntegerFunction( + CeilFunction, + DeclaredValueType, + NumericValue, +): + """``CEIL`` of an integer -- the integer itself, unchanged.""" + + +class FloorFunction(_FamilyPreservingMathFunction): + """``FLOOR(operand)`` -- the largest whole number not above it.""" + + function_name = "FLOOR" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_floor_function" + + @classmethod + def integral(cls, dialect, *arguments): + """The whole-number variant, for an integer operand.""" + return FloorIntegerFunction(dialect, *arguments) + + +class FloorIntegerFunction( + FloorFunction, + DeclaredValueType, + NumericValue, +): + """``FLOOR`` of an integer -- the integer itself, unchanged.""" + + +class TruncateFunction(_FamilyPreservingMathFunction): + """``TRUNCATE(operand[, precision])`` -- cut off, not rounded. + + Distinct from :class:`RoundFunction` because they disagree on a negative + half: ``-1.5`` rounds to ``-2`` and truncates to ``-1``. Backends answer that + one way or the other and a dialect that cannot say which overrides the + formatter rather than pretending they agree. + """ + + function_name = "TRUNCATE" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_truncate_function" + + @classmethod + def integral(cls, dialect, *arguments): + """The whole-number variant, for an integer operand.""" + return TruncateIntegerFunction(dialect, *arguments) + + +class TruncateIntegerFunction( + TruncateFunction, + DeclaredValueType, + NumericValue, +): + """``TRUNCATE`` of an integer -- still an integer.""" + + +class SquareRootFunction(_MathFunction): + """``SQRT(operand)`` -- the square root. + + Numeric even for a perfect square, because a caller who asked for a root has + not said the answer is whole, and widening is the safe direction. + """ + + function_name = "SQRT" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_square_root_function" + + +class PowerFunction(_MathFunction): + """``POWER(base, exponent)`` -- the base raised to the exponent. + + Compound interest makes this meaningful for a monetary amount, so it belongs + with the algebraic operations rather than the transcendental ones. + """ + + function_name = "POWER" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_power_function" + + +class ExponentialFunction(_MathFunction): + """``EXP(operand)`` -- e raised to the operand.""" + + function_name = "EXP" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_exponential_function" + + +class LogarithmFunction(_MathFunction): + """``LOG(operand[, base])`` -- the exponent that gives the operand. + + :attr:`arguments` holds the base last when one is given, which is the order + the function reads them in; a backend that wants the base first reorders the + list in its formatter. + """ + + function_name = "LOG" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_logarithm_function" + + +class SineFunction(_MathFunction): + """``SIN(operand)`` -- the sine, in radians.""" + + function_name = "SIN" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_sine_function" + + +class CosineFunction(_MathFunction): + """``COS(operand)`` -- the cosine, in radians.""" + + function_name = "COS" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_cosine_function" + + +class TangentFunction(_MathFunction): + """``TAN(operand)`` -- the tangent, in radians.""" + + function_name = "TAN" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_tangent_function" + + +class ModuloFunction(_MathFunction): + """``MOD(dividend, divisor)`` -- the remainder. + + An integer modulus when both operands are integers, which is why the + operands keep their own families rather than being widened here. + """ + + function_name = "MOD" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_modulo_function" + + +class SignFunction(_MathFunction): + """``SIGN(operand)`` -- -1, 0 or 1 by which way the operand points. + + Always a whole number whatever it is handed, which is the one function here + whose result does not follow its operand's family. + """ + + function_name = "SIGN" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_sign_function" + + @overload -def abs_(dialect: "SQLDialectBase", expr: IntegerColumn) -> "IntegerValueExpression": ... +def abs_(dialect: "SQLDialectBase", expr: IntegerColumn) -> "AbsoluteIntegerFunction": ... @overload -def abs_(dialect: "SQLDialectBase", expr: NumericColumn) -> "NumericValueExpression": ... +def abs_(dialect: "SQLDialectBase", expr: NumericColumn) -> "AbsoluteFunction": ... @overload def abs_(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "AbsoluteIntegerFunction", "AbsoluteFunction" ]: ... def abs_(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "AbsoluteIntegerFunction", "AbsoluteFunction" ]: """ Creates an ABS scalar function call. @@ -49,16 +389,12 @@ def abs_(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ Returns: A FunctionCall instance representing the ABS function """ - from ..core import IntegerValueExpression, NumericValueExpression target_expr = expr - call = FunctionCall(dialect, "ABS", target_expr) # ABS of a whole number is a whole number, and of a fraction may not be. # The column says which before anything runs, so this is a question about # the type rather than about an attribute read back from the instance. - if isinstance(expr, IntegerColumn): - return IntegerValueExpression(dialect, call) - return NumericValueExpression(dialect, call) + return AbsoluteFunction.for_operand(dialect, type(expr), target_expr) #: ABS, ROUND, CEIL, FLOOR and TRUNCATE keep a whole number whole, so their @@ -67,18 +403,18 @@ def abs_(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ #: than leaving the caller to read an attribute back off the instance. @overload def round_(dialect: "SQLDialectBase", expr: IntegerColumn, - decimals: Optional[int] = ...) -> "IntegerValueExpression": ... + decimals: Optional[int] = ...) -> "RoundIntegerFunction": ... @overload def round_(dialect: "SQLDialectBase", expr: NumericColumn, - decimals: Optional[int] = ...) -> "NumericValueExpression": ... + decimals: Optional[int] = ...) -> "RoundFunction": ... @overload def round_(dialect: "SQLDialectBase", expr: "BaseExpression", decimals: Optional[int] = ...) -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "RoundIntegerFunction", "RoundFunction" ]: ... @@ -101,16 +437,12 @@ def round_( Returns: A FunctionCall instance representing the ROUND function """ - from ..core import IntegerValueExpression, NumericValueExpression target_expr = expr args = [target_expr] if decimals is not None: - args.append(Literal(dialect, decimals)) - call = FunctionCall(dialect, "ROUND", *args) - if isinstance(expr, IntegerColumn): - return IntegerValueExpression(dialect, call) - return NumericValueExpression(dialect, call) + args.append(decimals if isinstance(decimals, BaseExpression) else Literal(dialect, decimals)) + return RoundFunction.for_operand(dialect, type(expr), *args) #: ABS, ROUND, CEIL, FLOOR and TRUNCATE keep a whole number whole, so their @@ -118,16 +450,16 @@ def round_( #: and possibly a fraction for a float one. Overloads say so in the type rather #: than leaving the caller to read an attribute back off the instance. @overload -def ceil(dialect: "SQLDialectBase", expr: IntegerColumn) -> "IntegerValueExpression": ... +def ceil(dialect: "SQLDialectBase", expr: IntegerColumn) -> "CeilIntegerFunction": ... @overload -def ceil(dialect: "SQLDialectBase", expr: NumericColumn) -> "NumericValueExpression": ... +def ceil(dialect: "SQLDialectBase", expr: NumericColumn) -> "CeilFunction": ... @overload def ceil(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "CeilIntegerFunction", "CeilFunction" ]: ... @@ -148,12 +480,8 @@ def ceil(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FunctionCall": A FunctionCall instance representing the CEIL function """ target_expr = expr - from ..core import IntegerValueExpression, NumericValueExpression - call = FunctionCall(dialect, "CEIL", target_expr) - if isinstance(expr, IntegerColumn): - return IntegerValueExpression(dialect, call) - return NumericValueExpression(dialect, call) + return CeilFunction.for_operand(dialect, type(expr), target_expr) #: ABS, ROUND, CEIL, FLOOR and TRUNCATE keep a whole number whole, so their @@ -161,16 +489,16 @@ def ceil(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FunctionCall": #: and possibly a fraction for a float one. Overloads say so in the type rather #: than leaving the caller to read an attribute back off the instance. @overload -def floor(dialect: "SQLDialectBase", expr: IntegerColumn) -> "IntegerValueExpression": ... +def floor(dialect: "SQLDialectBase", expr: IntegerColumn) -> "FloorIntegerFunction": ... @overload -def floor(dialect: "SQLDialectBase", expr: NumericColumn) -> "NumericValueExpression": ... +def floor(dialect: "SQLDialectBase", expr: NumericColumn) -> "FloorFunction": ... @overload def floor(dialect: "SQLDialectBase", expr: "BaseExpression") -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "FloorIntegerFunction", "FloorFunction" ]: ... @@ -191,15 +519,11 @@ def floor(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FunctionCall": A FunctionCall instance representing the FLOOR function """ target_expr = expr - from ..core import IntegerValueExpression, NumericValueExpression - call = FunctionCall(dialect, "FLOOR", target_expr) - if isinstance(expr, IntegerColumn): - return IntegerValueExpression(dialect, call) - return NumericValueExpression(dialect, call) + return FloorFunction.for_operand(dialect, type(expr), target_expr) -def sqrt(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def sqrt(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates a SQRT scalar function call. @@ -216,15 +540,12 @@ def sqrt(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpr A FunctionCall instance representing the SQRT function """ target_expr = expr - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "SQRT", target_expr)) + return SquareRootFunction(dialect, target_expr) def power( dialect: "SQLDialectBase", base: "BaseExpression", exponent: "BaseExpression" -) -> "NumericValueExpression": +) -> "FloorFunction": """ Creates a POWER scalar function call. @@ -245,13 +566,10 @@ def power( """ base_expr = base exp_expr = exponent - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "POWER", base_expr, exp_expr)) + return PowerFunction(dialect, base_expr, exp_expr) -def exp(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def exp(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates an EXP scalar function call. @@ -268,17 +586,14 @@ def exp(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpre A FunctionCall instance representing the EXP function """ target_expr = expr - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "EXP", target_expr)) + return ExponentialFunction(dialect, target_expr) def log( dialect: "SQLDialectBase", expr: "BaseExpression", base: Optional["BaseExpression"] = None, -) -> "NumericValueExpression": +) -> "FloorFunction": """ Creates a LOG scalar function call. @@ -308,18 +623,11 @@ def log( """ target_expr = expr if base is not None: - base_expr = base - from ..core import NumericValueExpression + return LogarithmFunction(dialect, target_expr, base) + return LogarithmFunction(dialect, target_expr) - return NumericValueExpression( - dialect, FunctionCall(dialect, "LOG", target_expr, base_expr)) - from ..core import NumericValueExpression - return NumericValueExpression( - dialect, FunctionCall(dialect, "LOG", target_expr)) - - -def sin(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def sin(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates a SIN scalar function call. @@ -336,13 +644,10 @@ def sin(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpre A FunctionCall instance representing the SIN function """ target_expr = expr - from ..core import NumericValueExpression + return SineFunction(dialect, target_expr) - return NumericValueExpression( - dialect, FunctionCall(dialect, "SIN", target_expr)) - -def cos(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def cos(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates a COS scalar function call. @@ -359,13 +664,10 @@ def cos(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpre A FunctionCall instance representing the COS function """ target_expr = expr - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "COS", target_expr)) + return CosineFunction(dialect, target_expr) -def tan(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def tan(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates a TAN scalar function call. @@ -382,17 +684,14 @@ def tan(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpre A FunctionCall instance representing the TAN function """ target_expr = expr - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "TAN", target_expr)) + return TangentFunction(dialect, target_expr) def mod( dialect: "SQLDialectBase", dividend: "BaseExpression", divisor: "BaseExpression", -) -> "NumericValueExpression": +) -> "FloorFunction": """ Creates a MOD function call (modulo operation). @@ -412,13 +711,10 @@ def mod( """ dividend_expr = dividend divisor_expr = divisor - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "MOD", dividend_expr, divisor_expr)) + return ModuloFunction(dialect, dividend_expr, divisor_expr) -def sign(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpression": +def sign(dialect: "SQLDialectBase", expr: "BaseExpression") -> "FloorFunction": """ Creates a SIGN function call. @@ -439,10 +735,7 @@ def sign(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpr A FunctionCall instance representing the SIGN function """ target_expr = expr - from ..core import NumericValueExpression - - return NumericValueExpression( - dialect, FunctionCall(dialect, "SIGN", target_expr)) + return SignFunction(dialect, target_expr) #: ABS, ROUND, CEIL, FLOOR and TRUNCATE keep a whole number whole, so their @@ -451,18 +744,18 @@ def sign(dialect: "SQLDialectBase", expr: "BaseExpression") -> "NumericValueExpr #: than leaving the caller to read an attribute back off the instance. @overload def truncate(dialect: "SQLDialectBase", expr: IntegerColumn, - precision: Optional[int] = ...) -> "IntegerValueExpression": ... + precision: Optional[int] = ...) -> "TruncateIntegerFunction": ... @overload def truncate(dialect: "SQLDialectBase", expr: NumericColumn, - precision: Optional[int] = ...) -> "NumericValueExpression": ... + precision: Optional[int] = ...) -> "TruncateFunction": ... @overload def truncate(dialect: "SQLDialectBase", expr: "BaseExpression", precision: Optional[int] = ...) -> Union[ - "IntegerValueExpression", "NumericValueExpression" + "TruncateIntegerFunction", "TruncateFunction" ]: ... @@ -496,13 +789,9 @@ def truncate( a numeric call -- present from 12.2, absent before -- so a gate there has to key on the rendered name rather than the function name. """ - from ..core import IntegerValueExpression, NumericValueExpression target_expr = expr args = [target_expr] if precision is not None: - args.append(Literal(dialect, precision)) - call = FunctionCall(dialect, "TRUNCATE", *args) - if isinstance(expr, IntegerColumn): - return IntegerValueExpression(dialect, call) - return NumericValueExpression(dialect, call) + args.append(precision if isinstance(precision, BaseExpression) else Literal(dialect, precision)) + return TruncateFunction.for_operand(dialect, type(expr), *args) diff --git a/src/rhosocial/activerecord/backend/expression/functions/string.py b/src/rhosocial/activerecord/backend/expression/functions/string.py index 41647e040..dbb27ee06 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/string.py +++ b/src/rhosocial/activerecord/backend/expression/functions/string.py @@ -3,26 +3,744 @@ from typing import Union, Optional, TYPE_CHECKING -from ..bases import BaseExpression +from .. import values +from ..bases import BaseExpression, SQLValue from ..core import ( FunctionCall, - IntegerValueExpression, Literal, - StringValueExpression, ) -from ..operators import BinaryExpression, StringConcatExpression +from ..mixins import ( + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NotANumberMixin, + NumericValueMixin, + StringPatternPredicateMixin, + StringToIntegerMixin, + StringValueMixin, + TranscendentalMixin, + TypeCastingMixin, +) +from ..operators import ConcatenationOperation if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase from ..advanced_functions import ( - LpadExpression, - RepeatExpression, - RpadExpression, + LpadFunction, + RepeatFunction, + RpadFunction, TrimExpression, ) -def concat(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> "StringValueExpression": +#: The length units ``CharLengthFunction`` admits (spec 4.2). +CHAR_LENGTH_UNITS = ("char", "octet", "bit") + + +class UpperFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``UPPER(operand)`` as a node. + + A derived string is still a string, so the node carries the string value + surface and the chain continues: ``col.upper().substr(1, 3)`` is + ``SUBSTRING(UPPER(col), 1, 3)``. + + The spelling is the dialect's decision, not the caller's: SQLite's + ``upper`` only folds ASCII, which is a fact about that backend rather than + a different operation. + """ + + def __init__(self, dialect: "SQLDialectBase", operand: "BaseExpression"): + super().__init__(dialect) + self.operand = operand + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_upper_function" + + +class LowerFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``LOWER(operand)`` as a node — the mirror of :class:`UpperFunction`.""" + + def __init__(self, dialect: "SQLDialectBase", operand: "BaseExpression"): + super().__init__(dialect) + self.operand = operand + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_lower_function" + + +class CharLengthFunction( + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, +): + """``CHAR_LENGTH(operand)`` — the length of a string, counted in *unit*. + + **The ``unit`` never enters the SQL.** It records the semantic the caller + wants (``char`` / ``octet`` / ``bit``); mapping that semantic onto a + backend's spelling is backend-layer work. This is the trap the catalog + names (spec wire #33): ``LENGTH`` is not the same question on every + backend — MySQL's and ClickHouse's are bytes, PostgreSQL's and SQLite's + are characters, and SQL Server's ``LEN`` counts UTF-16 code units — so a + bare ``length`` kind would silently pin one answer. The node states the + unit, and the backend formatter decides whether its spelling can honour + it. + + The count is a whole number, so the node carries the integer surface and + not the string one: ``col.length().upper()`` is refused where it is + written. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + operand: "BaseExpression", + unit: str = "char", + ): + if unit not in CHAR_LENGTH_UNITS: + raise ValueError( + f"unknown length unit {unit!r}: the units are " + f"{list(CHAR_LENGTH_UNITS)}" + ) + super().__init__(dialect) + self.operand = operand + self.unit = unit + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_char_length_function" + + +# The mixin sets below are the result types, spelled out per class. They used to +# be inherited once by ``StringValue`` / ``IntegerValue``, which each factory +# returned as a wrapper around a ``FunctionCall``. A node that is its own class +# carries them directly, so there is no second hierarchy to forward from -- which +# is what let ``WrappedCallMixin`` and its MRO-intersection walk go. + + +class _FunctionOperands: + """Declared-operand normalisation for the text function nodes below. + + Each node says what its operands *mean* -- text, or a whole number -- and + this is where a bare host value becomes the node that says so. Normalising + here rather than only in the factory means constructing a node directly is + safe as well, and a wrong operand fails at construction instead of surfacing + as an ``AttributeError`` from the formatter. + + A base class rather than two module functions, so the helpers are reachable + as ``self._text(...)`` and appear in ``dir()``. + + The two variadic functions below are the exception and do not use this: + ``CONCAT`` and ``COALESCE`` take operands of any type, so there is no + single node to normalise them into. + """ + + @staticmethod + def _text(dialect: "SQLDialectBase", value): + """*value* as a text operand: a node if it is one, a literal if not.""" + from ..core import Literal + + return value if isinstance(value, BaseExpression) else Literal(dialect, value) + + @staticmethod + def _count(dialect: "SQLDialectBase", value): + """*value* as a whole-number operand: a node if it is one, typed otherwise.""" + + return value if isinstance(value, BaseExpression) else Literal(dialect, value) + + +class ConcatFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``CONCAT(a, b, ...)`` -- the operands joined end to end. + + Offered instead of ``||``, whose meaning is not concatenation everywhere: + in MySQL it is logical OR by default, so an operator reachable from a text + column would quietly produce a boolean there. See :func:`concat_op` for the + operator, which exists but is not reachable from text. + """ + + + def __init__(self, dialect: "SQLDialectBase", *operands): + from ..core import Literal + + super().__init__(dialect) + self.operands = [o if isinstance(o, BaseExpression) else Literal(dialect, o) for o in operands] + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_concat_function" + + +class CoalesceFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``COALESCE(a, b, ...)`` -- the first operand that is not NULL. + + The result type follows the operands, which is why it is not settled here: + ``COALESCE(col, 0)`` is a number and ``COALESCE(a, b)`` is whatever ``a`` + is. The class therefore carries the text surface because that is the common + case, and a caller who needs another surface says so with + :class:`~...expression.mixins.ResultTypeMixin` -- the one situation the mixin + exists for, since the source cannot answer it. + """ + + + def __init__(self, dialect: "SQLDialectBase", *operands): + from ..core import Literal + + super().__init__(dialect) + self.operands = [o if isinstance(o, BaseExpression) else Literal(dialect, o) for o in operands] + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_coalesce_function" + + +class SubstringExpression( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``SUBSTRING(text FROM start [FOR length])`` -- a slice of the text. + + An *expression* and not a function: the standard's grammar produces it as a + scalar expression primary with ``FROM`` / ``FOR`` keywords rather than as a + comma-separated argument list (catalog 3.4), which is also why backends + disagree on the spelling and the formatter picks it. + + ``start`` is 1-based and ``length`` counts forward. Both edges were measured + across the ten backends (2026-10-09) and are refused rather than rendered: + a position below 1 is consumed by the length on PostgreSQL, SQL Server, + Firebird and SQLite, yields an empty string on MySQL, MariaDB and + ClickHouse, and is read as 1 on Oracle; a negative length is an error on + some backends, an empty string on MySQL/MariaDB, NULL on Oracle, and + something unrelated again on ClickHouse. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + target: "SQLValue", + start: "SQLValue", + length: Optional["SQLValue"] = None, + ): + # Both positions are integer-valued, and a bound is only checkable when + # the value is *known* here. A typed value node states its number, so it + # is checked; any other expression -- a column, a parameter -- does not, + # and whether it lands in range is the server's answer. Asking + # ``isinstance`` against the declared base is the whole test: no probing + # for an attribute that may or may not be there. + target = self._text(dialect, target) + start = self._count(dialect, start) + length = None if length is None else self._count(dialect, length) + if isinstance(start, Literal) and start.value < 1: + raise ValueError( + f"substring(): start must be >= 1 (positions are 1-based), got {start.value!r}" + ) + if isinstance(length, Literal) and length.value < 0: + raise ValueError(f"substring(): length must be >= 0, got {length.value!r}") + super().__init__(dialect) + self.target = target + self.start = start + self.length = length + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_substring_expression" + + +class OverlayExpression( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``OVERLAY(text PLACING replacement FROM start [FOR length])``. + + An *expression* rather than a function, for the same grammar reason as + :class:`SubstringExpression`: the standard spells it with ``PLACING`` and + ``FROM`` keywords, and that spelling is what distinguishes it from + ``SUBSTRING`` where a backend has both. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + target: "SQLValue", + replacement: "SQLValue", + start: "SQLValue", + length: Optional["SQLValue"] = None, + ): + target = self._text(dialect, target) + replacement = self._text(dialect, replacement) + start = self._count(dialect, start) + length = None if length is None else self._count(dialect, length) + target = self._text(dialect, target) + replacement = self._text(dialect, replacement) + start = self._count(dialect, start) + length = None if length is None else self._count(dialect, length) + super().__init__(dialect) + self.target = target + self.replacement = replacement + self.start = start + self.length = length + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_overlay_expression" + + +class PositionExpression( + _FunctionOperands, + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, +): + """The 1-based position of a substring, or 0 when it is absent. + + One class for two spellings because they answer the same question: + ``STRPOS(haystack, needle)`` takes the haystack first, while + ``POSITION(needle IN haystack)`` takes the needle first and is a scalar + expression primary rather than a function (catalog 3.4). :attr:`spelling` + says which was asked for, so the arguments stay in **this** class's order + (haystack, needle) whichever way the SQL reads. + + 0 for "not found" is what most backends answer; ``NULL`` is the SQL + standard's own answer, and a backend that disagrees overrides the formatter + rather than the node. + """ + + + #: The spellings this node can be asked for. The base renders both as a + #: function call; a backend needing ``POSITION(needle IN haystack)`` as the + #: standard writes it overrides the formatter. + SPELLINGS = ("STRPOS", "POSITION") + + def __init__(self, dialect: "SQLDialectBase", haystack, needle, spelling: str = "STRPOS"): + if spelling not in self.SPELLINGS: + raise ValueError(f"unknown position spelling {spelling!r}: the spellings are {list(self.SPELLINGS)}") + haystack = self._text(dialect, haystack) + needle = self._text(dialect, needle) + haystack = self._text(dialect, haystack) + needle = self._text(dialect, needle) + super().__init__(dialect) + self.haystack = haystack + self.needle = needle + self.spelling = spelling + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_position_expression" + + +class ReplaceFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``REPLACE(text, pattern, replacement)`` -- every occurrence replaced. + + The pattern is a **literal**, not a regular expression; that is what + separates this from the ``REGEXP_REPLACE`` family, and it is a difference in + what the function means rather than in how it is spelled, which is why they + are not one class with a ``spelling``. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, pattern, replacement): + target = self._text(dialect, target) + pattern = self._text(dialect, pattern) + replacement = self._text(dialect, replacement) + target = self._text(dialect, target) + pattern = self._text(dialect, pattern) + replacement = self._text(dialect, replacement) + super().__init__(dialect) + self.target = target + self.pattern = pattern + self.replacement = replacement + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_replace_function" + + +class InitcapFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``INITCAP(text)`` -- first letter of each word upper, the rest lower.""" + + + def __init__(self, dialect: "SQLDialectBase", target): + target = self._text(dialect, target) + super().__init__(dialect) + self.target = target + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_initcap_function" + + +class LeftFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``LEFT(text, n)`` -- the first *n* characters. + + ``n >= 0`` and everything above it is portable: 0 is the empty string and + more than the length is the whole text on every backend. Below 0 they + answer four different ways -- PostgreSQL and ClickHouse drop the trailing + ``|n|`` characters, MySQL, MariaDB and Snowflake return an empty string, + SQL Server and Firebird raise, and BigQuery raises -- so a negative count is + refused here rather than rendered four ways. + """ + + + def __init__(self, dialect: "SQLDialectBase", target: "SQLValue", count: "SQLValue"): + # See SubstringExpression: checkable when the count is stated, the + # server's business otherwise. + if isinstance(count, Literal) and count.value < 0: + raise ValueError(f"left(): n must be >= 0, got {count.value!r}") + target = self._text(dialect, target) + count = self._count(dialect, count) + target = self._text(dialect, target) + count = self._count(dialect, count) + target = self._text(dialect, target) + count = self._count(dialect, count) + super().__init__(dialect) + self.target = target + self.count = count + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_left_function" + + +class RightFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``RIGHT(text, n)`` -- the last *n* characters. + + The same ``n >= 0`` contract as :class:`LeftFunction`, and refused for the + same reason: the backends do not agree below zero. + """ + + + def __init__(self, dialect: "SQLDialectBase", target: "SQLValue", count: "SQLValue"): + # See SubstringExpression. + if isinstance(count, Literal) and count.value < 0: + raise ValueError(f"right(): n must be >= 0, got {count.value!r}") + target = self._text(dialect, target) + count = self._count(dialect, count) + super().__init__(dialect) + self.target = target + self.count = count + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_right_function" + + +class ReverseFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``REVERSE(text)`` -- the characters in the other order.""" + + + def __init__(self, dialect: "SQLDialectBase", target): + target = self._text(dialect, target) + super().__init__(dialect) + self.target = target + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_reverse_function" + + +class TranslateFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``TRANSLATE(text, from, to)`` -- character-by-character substitution. + + Positional, not a search-and-replace: the *n*th character of *from* becomes + the *n*th of *to*. That is also what separates it from ``REPLACE``, which + replaces a whole substring. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, from_chars, to_chars): + target = self._text(dialect, target) + from_chars = self._text(dialect, from_chars) + to_chars = self._text(dialect, to_chars) + target = self._text(dialect, target) + from_chars = self._text(dialect, from_chars) + to_chars = self._text(dialect, to_chars) + super().__init__(dialect) + self.target = target + self.from_chars = from_chars + self.to_chars = to_chars + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_translate_function" + + +class SpaceFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``SPACE(n)`` -- *n* spaces. + + One operand, so it is its own class rather than a text literal that happens + to hold blanks: the node says what produced it, which is what lets a backend + without ``SPACE`` compose the blanks some other way. + """ + + + def __init__(self, dialect: "SQLDialectBase", count: "SQLValue"): + count = self._count(dialect, count) + count = self._count(dialect, count) + super().__init__(dialect) + self.count = count + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_space_function" + + +class ChrFunction( + _FunctionOperands, + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``CHR(code)`` -- the character the code names. + + The standard's function; ``ASCII`` is its inverse, and the two are kept + apart because the code space is not symmetric across backends (Oracle's + ``CHR`` is database-specific above 255 while its ``ASCII`` only reads the + first byte). + """ + + + def __init__(self, dialect: "SQLDialectBase", code: "SQLValue"): + code = self._count(dialect, code) + code = self._count(dialect, code) + super().__init__(dialect) + self.code = code + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_chr_function" + + +class AsciiFunction( + _FunctionOperands, + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, +): + """``ASCII(text)`` -- the code of the first character.""" + + + def __init__(self, dialect: "SQLDialectBase", target): + target = self._text(dialect, target) + super().__init__(dialect) + self.target = target + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_ascii_function" + + +class OctetLengthFunction( + _FunctionOperands, + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, +): + """``OCTET_LENGTH(text)`` -- the length in bytes. + + A different question from :class:`~...expression.functions.string.CharLengthFunction`, + which counts characters, and the two disagree wherever a character is more + than one byte. Kept separate rather than folded into a ``unit`` parameter + because both spellings exist in the standard and a caller asking for one + wants that one. + """ + + + def __init__(self, dialect: "SQLDialectBase", target): + target = self._text(dialect, target) + super().__init__(dialect) + self.target = target + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_octet_length_function" + + +class BitLengthFunction( + _FunctionOperands, + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, +): + """``BIT_LENGTH(text)`` -- the length in bits.""" + + + def __init__(self, dialect: "SQLDialectBase", target): + target = self._text(dialect, target) + super().__init__(dialect) + self.target = target + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bit_length_function" + + +def concat(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> "ConcatFunction": """ Creates a CONCAT scalar function call. @@ -39,10 +757,10 @@ def concat(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> " A FunctionCall instance representing the CONCAT function """ target_exprs = [e if isinstance(e, BaseExpression) else Literal(dialect, e) for e in exprs] - return StringValueExpression(dialect, FunctionCall(dialect, "CONCAT", *target_exprs)) + return ConcatFunction(dialect, *target_exprs) -def coalesce(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> "StringValueExpression": +def coalesce(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> "CoalesceFunction": """ Creates a COALESCE scalar function call. @@ -59,12 +777,16 @@ def coalesce(dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"]) -> A FunctionCall instance representing the COALESCE function """ target_exprs = [e if isinstance(e, BaseExpression) else Literal(dialect, e) for e in exprs] - return StringValueExpression(dialect, FunctionCall(dialect, "COALESCE", *target_exprs)) + return CoalesceFunction(dialect, *target_exprs) -def length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "IntegerValueExpression": +def length( + dialect: "SQLDialectBase", + expr: Union[str, "BaseExpression"], + unit: str = "char", +) -> "CharLengthFunction": """ - Creates a LENGTH scalar function call. + Creates a CHAR_LENGTH node. Usage rules: - To generate LENGTH(column), pass a Column object: length(dialect, Column(dialect, "column_name")) @@ -74,12 +796,15 @@ def length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "In dialect: The SQL dialect instance expr: The expression to measure length of. If a string is passed, it's treated as a literal value. If a BaseExpression is passed, it's used as-is. + unit: The unit the length is asked in (``char`` / ``octet`` / ``bit``). + It is recorded on the node and **never enters the SQL**; a + backend whose spelling cannot honour it overrides the formatter. Returns: - A FunctionCall instance representing the LENGTH function + A :class:`CharLengthFunction` node. """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return IntegerValueExpression(dialect, FunctionCall(dialect, "LENGTH", target_expr)) + return CharLengthFunction(dialect, target_expr, unit=unit) def substring( @@ -87,7 +812,7 @@ def substring( expr: Union[str, "BaseExpression"], start: Union[int, "BaseExpression"], length: Optional[Union[int, "BaseExpression"]] = None, -) -> "FunctionCall": +) -> "SubstringExpression": """ Creates a SUBSTRING scalar function call. @@ -120,18 +845,13 @@ def substring( MySQL/MariaDB, NULL on Oracle and something unrelated again on ClickHouse. """ - if isinstance(start, int) and start < 1: - raise ValueError( - f"substring(): start must be >= 1 (positions are 1-based), got {start!r}" - ) - if isinstance(length, int) and length < 0: - raise ValueError(f"substring(): length must be >= 0, got {length!r}") target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) start_expr = start if isinstance(start, BaseExpression) else Literal(dialect, start) - if length is not None: - length_expr = length if isinstance(length, BaseExpression) else Literal(dialect, length) - return StringValueExpression(dialect, FunctionCall(dialect, "SUBSTRING", target_expr, start_expr, length_expr)) - return StringValueExpression(dialect, FunctionCall(dialect, "SUBSTRING", target_expr, start_expr)) + # An absent length stays None rather than becoming Literal(None): the + # formatter drops a trailing None, and a node holding NULL would render a + # fourth argument instead. + length_expr = None if length is None else length if isinstance(length, BaseExpression) else Literal(dialect, length) + return SubstringExpression(dialect, target_expr, start_expr, length_expr) def trim( @@ -221,12 +941,12 @@ def replace( target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) pattern_expr = pattern if isinstance(pattern, BaseExpression) else Literal(dialect, pattern) replacement_expr = replacement if isinstance(replacement, BaseExpression) else Literal(dialect, replacement) - return StringValueExpression(dialect, FunctionCall(dialect, "REPLACE", target_expr, pattern_expr, replacement_expr)) + return ReplaceFunction(dialect, target_expr, pattern_expr, replacement_expr) -def upper(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "FunctionCall": +def upper(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "UpperFunction": """ - Creates an UPPER scalar function call. + Creates an UPPER scalar function node. Usage rules: - To generate UPPER(column), pass a Column object: upper(dialect, Column(dialect, "column_name")) @@ -238,15 +958,15 @@ def upper(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "Fun If a BaseExpression is passed, it's used as-is. Returns: - A FunctionCall instance representing the UPPER function + An :class:`UpperFunction` node. """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return StringValueExpression(dialect, FunctionCall(dialect, "UPPER", target_expr)) + return UpperFunction(dialect, target_expr) -def lower(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "FunctionCall": +def lower(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "LowerFunction": """ - Creates a LOWER scalar function call. + Creates a LOWER scalar function node. Usage rules: - To generate LOWER(column), pass a Column object: lower(dialect, Column(dialect, "column_name")) @@ -258,13 +978,13 @@ def lower(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "Fun If a BaseExpression is passed, it's used as-is. Returns: - A FunctionCall instance representing the LOWER function + A :class:`LowerFunction` node. """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return StringValueExpression(dialect, FunctionCall(dialect, "LOWER", target_expr)) + return LowerFunction(dialect, target_expr) -def initcap(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "FunctionCall": +def initcap(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "InitcapFunction": """ Creates an INITCAP scalar function call (title case). @@ -281,10 +1001,10 @@ def initcap(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "F A FunctionCall instance representing the INITCAP function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return StringValueExpression(dialect, FunctionCall(dialect, "INITCAP", target_expr)) + return InitcapFunction(dialect, target_expr) -def left(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) -> "FunctionCall": +def left(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) -> "LeftFunction": """ Creates a LEFT scalar function call. @@ -312,14 +1032,12 @@ def left(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) return an empty string, SQL Server and Firebird raise, and BigQuery raises -- so a negative count is refused rather than rendered. """ - if isinstance(n, int) and n < 0: - raise ValueError(f"left(): n must be >= 0, got {n!r}") target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - n_expr = Literal(dialect, n) - return StringValueExpression(dialect, FunctionCall(dialect, "LEFT", target_expr, n_expr)) + n_expr = n if isinstance(n, BaseExpression) else Literal(dialect, n) + return LeftFunction(dialect, target_expr, n_expr) -def right(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) -> "FunctionCall": +def right(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) -> "RightFunction": """ Creates a RIGHT scalar function call. @@ -344,16 +1062,14 @@ def right(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], n: int) than the length is the whole string, and a negative count is refused because no two backends agree on it (see :func:`left`). """ - if isinstance(n, int) and n < 0: - raise ValueError(f"right(): n must be >= 0, got {n!r}") target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - n_expr = Literal(dialect, n) - return StringValueExpression(dialect, FunctionCall(dialect, "RIGHT", target_expr, n_expr)) + n_expr = n if isinstance(n, BaseExpression) else Literal(dialect, n) + return RightFunction(dialect, target_expr, n_expr) def lpad( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], length: int, pad: Optional[str] = None -) -> "LpadExpression": +) -> "LpadFunction": """ Creates a left-padding node. @@ -370,7 +1086,7 @@ def lpad( pad: Optional padding string, non-empty. Defaults to a single space. Returns: - A :class:`~...expression.advanced_functions.LpadExpression` node, which + A :class:`~...expression.advanced_functions.LpadFunction` node, which the dialect renders through ``format_lpad_expression`` -- a spelling where the function exists (PostgreSQL, MySQL, MariaDB, Oracle, Firebird, ClickHouse, Snowflake, BigQuery) and an emulation composed @@ -398,15 +1114,15 @@ def lpad( ) target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) length_expr = length if isinstance(length, BaseExpression) else Literal(dialect, length) - pad_expr = pad if isinstance(pad, BaseExpression) else Literal(dialect, " " if pad is None else pad) - from ..advanced_functions import LpadExpression + pad_expr = Literal(dialect, " " if pad is None else pad) + from ..advanced_functions import LpadFunction - return LpadExpression(dialect, target_expr, length_expr, pad_expr) + return LpadFunction(dialect, target_expr, length_expr, pad_expr) def rpad( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], length: int, pad: Optional[str] = None -) -> "RpadExpression": +) -> "RpadFunction": """ Creates a right-padding node. @@ -423,7 +1139,7 @@ def rpad( pad: Optional padding string, non-empty. Defaults to a single space. Returns: - A :class:`~...expression.advanced_functions.RpadExpression` node, + A :class:`~...expression.advanced_functions.RpadFunction` node, rendered through ``format_rpad_expression`` -- a spelling where the function exists and a one-branch emulation where it does not. @@ -443,13 +1159,13 @@ def rpad( ) target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) length_expr = length if isinstance(length, BaseExpression) else Literal(dialect, length) - pad_expr = pad if isinstance(pad, BaseExpression) else Literal(dialect, " " if pad is None else pad) - from ..advanced_functions import RpadExpression + pad_expr = Literal(dialect, " " if pad is None else pad) + from ..advanced_functions import RpadFunction - return RpadExpression(dialect, target_expr, length_expr, pad_expr) + return RpadFunction(dialect, target_expr, length_expr, pad_expr) -def reverse(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "FunctionCall": +def reverse(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "ReverseFunction": """ Creates a REVERSE scalar function call. @@ -466,12 +1182,12 @@ def reverse(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "F A FunctionCall instance representing the REVERSE function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return StringValueExpression(dialect, FunctionCall(dialect, "REVERSE", target_expr)) + return ReverseFunction(dialect, target_expr) def strpos( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], substring: Union[str, "BaseExpression"] -) -> "IntegerValueExpression": +) -> "PositionExpression": """ Creates a STRPOS scalar function call (position of substring). @@ -491,12 +1207,12 @@ def strpos( """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) substr_expr = substring if isinstance(substring, BaseExpression) else Literal(dialect, substring) - return IntegerValueExpression(dialect, FunctionCall(dialect, "STRPOS", target_expr, substr_expr)) + return PositionExpression(dialect, target_expr, substr_expr, spelling="STRPOS") def concat_op( dialect: "SQLDialectBase", *exprs: Union[str, "BaseExpression"] -) -> "BinaryExpression": +) -> "ConcatenationOperation": """Creates a string concatenation using the ``||`` operator. Deliberately not offered as a ``StringValueMixin`` method. ``||`` does not @@ -516,7 +1232,8 @@ def concat_op( *exprs: Variable number of expressions to concatenate using || operator Returns: - A BinaryExpression instance representing the || concatenation operation + A :class:`~...expression.operators.ConcatenationOperation` node, whose + kind states the intent and whose rendering this dialect chooses. """ if len(exprs) < 2: raise ValueError("Concatenation operation requires at least 2 expressions") @@ -525,16 +1242,16 @@ def concat_op( target_exprs = [e if isinstance(e, BaseExpression) else Literal(dialect, e) for e in exprs] # Start with the first two expressions - result = StringConcatExpression(dialect, target_exprs[0], target_exprs[1]) + result = ConcatenationOperation(dialect, target_exprs[0], target_exprs[1]) # Chain additional expressions for i in range(2, len(target_exprs)): - result = StringConcatExpression(dialect, result, target_exprs[i]) + result = ConcatenationOperation(dialect, result, target_exprs[i]) return result -def chr_(dialect: "SQLDialectBase", code: Union[int, "BaseExpression"]) -> "FunctionCall": +def chr_(dialect: "SQLDialectBase", code: Union[int, "BaseExpression"]) -> "ChrFunction": """ Creates a CHR function call. @@ -552,10 +1269,10 @@ def chr_(dialect: "SQLDialectBase", code: Union[int, "BaseExpression"]) -> "Func A FunctionCall instance representing the CHR function """ code_expr = code if isinstance(code, BaseExpression) else Literal(dialect, code) - return StringValueExpression(dialect, FunctionCall(dialect, "CHR", code_expr)) + return ChrFunction(dialect, code_expr) -def ascii(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "IntegerValueExpression": +def ascii(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "AsciiFunction": """ Creates an ASCII function call. @@ -573,10 +1290,10 @@ def ascii(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "Int A FunctionCall instance representing the ASCII function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return IntegerValueExpression(dialect, FunctionCall(dialect, "ASCII", target_expr)) + return AsciiFunction(dialect, target_expr) -def octet_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "IntegerValueExpression": +def octet_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "OctetLengthFunction": """ Creates an OCTET_LENGTH function call. @@ -594,10 +1311,10 @@ def octet_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) A FunctionCall instance representing the OCTET_LENGTH function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return IntegerValueExpression(dialect, FunctionCall(dialect, "OCTET_LENGTH", target_expr)) + return OctetLengthFunction(dialect, target_expr) -def bit_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "IntegerValueExpression": +def bit_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> "BitLengthFunction": """ Creates a BIT_LENGTH function call. @@ -615,12 +1332,12 @@ def bit_length(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"]) -> A FunctionCall instance representing the BIT_LENGTH function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return IntegerValueExpression(dialect, FunctionCall(dialect, "BIT_LENGTH", target_expr)) + return BitLengthFunction(dialect, target_expr) def position( dialect: "SQLDialectBase", substring: Union[str, "BaseExpression"], expr: Union[str, "BaseExpression"] -) -> "IntegerValueExpression": +) -> "PositionExpression": """ Creates a POSITION function call. @@ -640,7 +1357,7 @@ def position( """ substr_expr = substring if isinstance(substring, BaseExpression) else Literal(dialect, substring) target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - return IntegerValueExpression(dialect, FunctionCall(dialect, "POSITION", substr_expr, target_expr)) + return PositionExpression(dialect, target_expr, substr_expr, spelling="POSITION") def overlay( @@ -649,7 +1366,7 @@ def overlay( replacement: Union[str, "BaseExpression"], start: int, length: Optional[int] = None, -) -> "FunctionCall": +) -> "OverlayExpression": """ Creates an OVERLAY function call. @@ -671,21 +1388,15 @@ def overlay( """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) replace_expr = replacement if isinstance(replacement, BaseExpression) else Literal(dialect, replacement) - start_expr = Literal(dialect, start) - if length is not None: - length_expr = Literal(dialect, length) - return StringValueExpression( - dialect, - FunctionCall(dialect, "OVERLAY", target_expr, replace_expr, start_expr, length_expr), - ) - return StringValueExpression( - dialect, FunctionCall(dialect, "OVERLAY", target_expr, replace_expr, start_expr) - ) + start_expr = start if isinstance(start, BaseExpression) else Literal(dialect, start) + length_expr = length if isinstance(length, + BaseExpression) else Literal(dialect, length) if length is not None else None + return OverlayExpression(dialect, target_expr, replace_expr, start_expr, length_expr) def translate( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], from_chars: str, to_chars: str -) -> "FunctionCall": +) -> "TranslateFunction": """ Creates a TRANSLATE function call. @@ -705,12 +1416,12 @@ def translate( A FunctionCall instance representing the TRANSLATE function """ target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) - from_expr = Literal(dialect, from_chars) - to_expr = Literal(dialect, to_chars) - return StringValueExpression(dialect, FunctionCall(dialect, "TRANSLATE", target_expr, from_expr, to_expr)) + from_expr = from_chars if isinstance(from_chars, BaseExpression) else Literal(dialect, from_chars) + to_chars_expr = to_chars if isinstance(to_chars, BaseExpression) else Literal(dialect, to_chars) + return TranslateFunction(dialect, target_expr, from_expr, to_chars_expr) -def repeat(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], count: int) -> "RepeatExpression": +def repeat(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], count: int) -> "RepeatFunction": """ Creates a repetition node. @@ -724,7 +1435,7 @@ def repeat(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], count: count: Number of repetitions Returns: - A :class:`~...expression.advanced_functions.RepeatExpression` node, + A :class:`~...expression.advanced_functions.RepeatFunction` node, rendered through ``format_repeat_expression`` -- a spelling where the function exists (PostgreSQL, MySQL, MariaDB, ClickHouse, BigQuery) and an emulation composed from ordinary nodes where it does not (SQLite, @@ -739,15 +1450,15 @@ def repeat(dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], count: once here instead of once per backend. """ if isinstance(count, int) and count < 0: - return StringValueExpression(dialect, Literal(dialect, "")) + return Literal(dialect, "") target_expr = expr if isinstance(expr, BaseExpression) else Literal(dialect, expr) count_expr = count if isinstance(count, BaseExpression) else Literal(dialect, count) - from ..advanced_functions import RepeatExpression + from ..advanced_functions import RepeatFunction - return RepeatExpression(dialect, target_expr, count_expr) + return RepeatFunction(dialect, target_expr, count_expr) -def space(dialect: "SQLDialectBase", count: int) -> "FunctionCall": +def space(dialect: "SQLDialectBase", count: int) -> "SpaceFunction": """ Creates a SPACE function call. @@ -763,4 +1474,4 @@ def space(dialect: "SQLDialectBase", count: int) -> "FunctionCall": Returns: A FunctionCall instance representing the SPACE function """ - return StringValueExpression(dialect, FunctionCall(dialect, "SPACE", Literal(dialect, count))) + return SpaceFunction(dialect, count if isinstance(count, BaseExpression) else Literal(dialect, count)) diff --git a/src/rhosocial/activerecord/backend/expression/functions/system.py b/src/rhosocial/activerecord/backend/expression/functions/system.py index ead29b7cc..3809e84fc 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/system.py +++ b/src/rhosocial/activerecord/backend/expression/functions/system.py @@ -1,60 +1,154 @@ # src/rhosocial/activerecord/backend/expression/functions/system.py -"""System information function factories.""" +"""System information function nodes. + +``CURRENT_USER`` / ``SESSION_USER`` / ``SYSTEM_USER`` are three names for what +the standard treats as closely related session facts, and it is not settled +which each backend answers with which -- SQL Server's ``SYSTEM_USER`` and +``SESSION_USER`` return different accounts, and ``SUSER_SNAME()`` is the +spelling that matches ``CURRENT_USER``. Each therefore gets its own class so a +backend can answer for one and not the others, and the spelling difference is +the formatter's business rather than a second node's reason to exist. + +They are niladic, which is a **rendering** fact, not a structural one: the +standard writes these without parentheses and MySQL's ``NOW`` needs them while +its ``CURRENT_TIMESTAMP`` may omit them. So the class carries no arguments and +the dialect decides the parentheses. +""" from typing import TYPE_CHECKING -from ..core import ( - StringValueExpression, - FunctionCall, +from ..bases import SQLValue +from ..mixins import ( + AliasableMixin, + ComparisonMixin, + NotANumberMixin, + StringPatternPredicateMixin, + StringToIntegerMixin, + StringValueMixin, + TypeCastingMixin, ) if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase -def current_user(dialect: "SQLDialectBase") -> "StringValueExpression": +class CurrentUserFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``CURRENT_USER`` -- the account the session is authenticated as. + + Text, and the full string surface rather than a numeric one: this is + something a caller compares against and searches, so ``.like()`` belongs on + it and ``.sqrt()`` does not. """ - Creates a CURRENT_USER niladic value function. - SQL:2003 standard niladic function — generates CURRENT_USER - without parentheses, as required by the standard. - Args: - dialect: The SQL dialect instance - - Returns: - A FunctionCall instance representing the CURRENT_USER value function + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_current_user_function" + + +class SessionUserFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``SESSION_USER`` -- the account the session was opened under.""" + + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_session_user_function" + + +class SystemUserFunction( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, +): + """``SYSTEM_USER`` -- the account the operating system recorded. + + Not the same account as :class:`SessionUserFunction` on every backend, and + the class is here so that a backend which answers differently can say so + rather than have the difference hidden behind one shared spelling. """ - return StringValueExpression(dialect, FunctionCall(dialect, 'CURRENT_USER', niladic=True)) -def session_user(dialect: "SQLDialectBase") -> "StringValueExpression": - """ - Creates a SESSION_USER niladic value function. + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_system_user_function" + + +__all__ = [ + "CurrentUserFunction", + "SessionUserFunction", + "SystemUserFunction", + "current_user", + "session_user", + "system_user", +] - SQL:2003 standard niladic function — generates SESSION_USER - without parentheses, as required by the standard. + +def current_user(dialect: "SQLDialectBase") -> "CurrentUserFunction": + """``CURRENT_USER`` -- the account the session is authenticated as. Args: - dialect: The SQL dialect instance + dialect: The SQL dialect instance. Returns: - A FunctionCall instance representing the SESSION_USER value function + A :class:`CurrentUserFunction`. """ - return StringValueExpression(dialect, FunctionCall(dialect, 'SESSION_USER', niladic=True)) + return CurrentUserFunction(dialect) + + +def session_user(dialect: "SQLDialectBase") -> "SessionUserFunction": + """``SESSION_USER`` -- the account the session was opened under. + Args: + dialect: The SQL dialect instance. -def system_user(dialect: "SQLDialectBase") -> "StringValueExpression": + Returns: + A :class:`SessionUserFunction`. """ - Creates a SYSTEM_USER niladic value function. + return SessionUserFunction(dialect) + - SQL:2003 standard niladic function — generates SYSTEM_USER - without parentheses, as required by the standard. +def system_user(dialect: "SQLDialectBase") -> "SystemUserFunction": + """``SYSTEM_USER`` -- the account the operating system recorded. Args: - dialect: The SQL dialect instance + dialect: The SQL dialect instance. Returns: - A FunctionCall instance representing the SYSTEM_USER value function + A :class:`SystemUserFunction`. """ - return StringValueExpression(dialect, FunctionCall(dialect, 'SYSTEM_USER', niladic=True)) + return SystemUserFunction(dialect) diff --git a/src/rhosocial/activerecord/backend/expression/functions/type_conversion.py b/src/rhosocial/activerecord/backend/expression/functions/type_conversion.py index 5e4472467..eaa0c58b2 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/type_conversion.py +++ b/src/rhosocial/activerecord/backend/expression/functions/type_conversion.py @@ -3,59 +3,174 @@ from typing import Union, Optional, TYPE_CHECKING -from ..bases import BaseExpression, SQLValueExpression +from ..advanced_functions import DeclaredValueType +from ..bases import BaseExpression, SQLValue from ..core import ( Column, - TimestampValueExpression, - NumericValueExpression, - StringValueExpression, - FunctionCall, Literal, ) +from ..value_types import ( + NumericValue, + TemporalValue, + TextValue, +) if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase from ..types._base import DataType -#: Value class -> the class standing for both a CAST and that value class. +#: Annotation class -> the class standing for both a CAST and that annotation. #: Populated on first use by :func:`_cast_class_for`. -_CAST_VALUE_CLASSES = {} +_CAST_ANNOTATION_CLASSES = {} + +class _ConversionOperand: + """Operand normalisation shared by the three conversion nodes. + + What each of them converts is arbitrary -- a number, a date, a string, a + JSON document -- so there is no typed value node to normalise into, and a + bare host value becomes the untyped literal. When the array and JSON value + types land, this is the one place that changes. + + ``format`` is both the parameter's name and the attribute's name because + get_params() resolves the two by name: an attribute spelled differently is a + node whose parameters cannot be read back. + """ + + @staticmethod + def _operand(dialect: "SQLDialectBase", value): + from ..core import Literal + + if value is None: + return None + return value if isinstance(value, BaseExpression) else Literal(dialect, value) + + +class ToCharFunction( + _ConversionOperand, + DeclaredValueType, + TextValue, +): + """``TO_CHAR(value[, format])`` -- the value written as text. + + Text, because that is the whole point: this is how a number or a date + reaches a report column, so the text value_type follows and the numeric one + does not. + + :attr:`format` is a mask, and it is optional in a way that matters -- absent + means the backend's default rendering, which is not the same as an explicit + mask. So it is ``None`` rather than an empty string when not given. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, format=None): + # BaseExpression directly, not super(): NumericValue is a *wrapper*. + BaseExpression.__init__(self, dialect) + self.target = self._operand(dialect, target) + self.format = self._operand(dialect, format) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_to_char_function" + +class ToNumberFunction( + _ConversionOperand, + DeclaredValueType, + NumericValue, +): + """``TO_NUMBER(value[, format])`` -- text read back as a number. -def _cast_class_for(value_class): - """The class standing for both a CAST and *value_class*. + An exact decimal where the backend produces one, so ``Decimal`` rather than + ``float`` is the reading that loses nothing; a backend that answers with a + double is answering a different question and says so in its formatter. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, format=None): + # BaseExpression directly, not super(): NumericValue is a *wrapper*. + BaseExpression.__init__(self, dialect) + self.target = self._operand(dialect, target) + self.format = self._operand(dialect, format) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_to_number_function" + + +class ToDateFunction( + _ConversionOperand, + DeclaredValueType, + TemporalValue, +): + """``TO_DATE(value[, format])`` -- text read back as a **date**. + + A date and not a timestamp, which is what every backend's ``TO_DATE`` + actually produces. It used to be typed as a timestamp, so the time-of-day + operations were offered on a value that has no time of day -- a defect the + type was in a position to prevent and did not. + + :attr:`format` is the mask that says which part of the string is which + field, so it is the difference between ``'2026-10-17'`` and ``'17/10/2026'`` + being the same day. + """ + + + def __init__(self, dialect: "SQLDialectBase", target, format=None): + # BaseExpression directly, not super(): DateValue is a *wrapper* whose + # __init__ takes the call it delegates to, and this node has no call -- + # it renders itself. DeclaredValueType is what keeps that from mattering. + BaseExpression.__init__(self, dialect) + self.target = self._operand(dialect, target) + self.format = self._operand(dialect, format) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_to_date_function" - Built once per value class and kept, so ``type(x)`` does not differ between + +def _cast_class_for(annotation): + """The class standing for both a CAST and *annotation*. + + Built once per annotation and kept, so ``type(x)`` does not differ between two casts that produce the same thing. ``CastExpression`` comes first: it supplies the node's own state and - ``format_method``. ``DeclaredValueType`` supplies the rendering, because the - value class's own ``to_sql`` is a wrapper delegating to a node this does not - hold. + ``format_method``. ``DeclaredValueType`` supplies the rendering. The third + base is the annotation's **value_type**, not the annotation: ``CastExpression`` + already derives from ``DeclaredValueType`` and so does the annotation, and + asking for both leaves two orders to satisfy and no linearization. The + value_type adds only mixins, so it settles -- and it keeps the annotation's own + ``format_method`` out of reach, which is what a cast wants: it holds a type, + not a node. """ - from ..advanced_functions import DeclaredValueType from ..core import CastExpression + value_type = annotation.value_type_of() try: - return _CAST_VALUE_CLASSES[value_class] + return _CAST_ANNOTATION_CLASSES[annotation] except KeyError: + from ..advanced_functions import DeclaredValueType + combined = type( - f"Cast{value_class.__name__}", - (CastExpression, DeclaredValueType, value_class), + f"Cast{value_type.__name__}", + (CastExpression, DeclaredValueType, value_type), { "__doc__": ( - f"A CAST to a type this value class represents, so it is " - f"a {value_class.__name__}." + f"A CAST to a type this annotation represents, so it is " + f"a {annotation.__name__}." ) }, ) - _CAST_VALUE_CLASSES[value_class] = combined + _CAST_ANNOTATION_CLASSES[annotation] = combined return combined -def _value_class_for(target_type): - """The value class *target_type* casts to, or ``None`` if it has none. +def _annotation_for(target_type): + """The annotation *target_type* casts to, or ``None`` if it has none. A cast takes its answer from the target type, which is the only place it can come from: ``CAST(x AS t)`` is a ``t`` whatever ``x`` held. There is no @@ -66,29 +181,40 @@ def _value_class_for(target_type): widths resolve as whole numbers through the class they share, and a backend's own type resolves through whichever core type it derives from. - ``None`` is a real answer. A user-defined type has no value class to offer: + ``None`` is a real answer. A user-defined type has no annotation to offer: promising JSON navigation on a type nobody here has heard of would be worse than saying nothing, so the cast comes back untyped and the caller says what they meant with ``as_*``. """ - global _TYPE_VALUE_CLASSES - if _TYPE_VALUE_CLASSES is None: - _TYPE_VALUE_CLASSES = _type_value_classes() + global _TYPE_ANNOTATIONS + if _TYPE_ANNOTATIONS is None: + _TYPE_ANNOTATIONS = _type_annotations() for klass in type(target_type).__mro__: - value_class = _TYPE_VALUE_CLASSES.get(klass) - if value_class is not None: - return value_class + annotation = _TYPE_ANNOTATIONS.get(klass) + if annotation is not None: + return annotation return None -def _type_value_classes(): - """The ``DataType`` class -> value class table, built on first use. +def _type_annotations(): + """The ``DataType`` class -> annotation table, built on first use. Deferred because :mod:`..types` and :mod:`..core` both import from this package's expression module, so neither can be imported at module scope here. """ - from .. import core + from ..declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, + ) from ..types import ( ArrayType, BigIntType, @@ -96,7 +222,6 @@ def _type_value_classes(): BlobType, BooleanType, CharType, - CustomType, DateTimeType, DateType, DecimalType, @@ -123,55 +248,55 @@ def _type_value_classes(): return { # Whole numbers, whatever the width. - IntegerType: core.IntegerValueExpression, - TinyIntType: core.IntegerValueExpression, - SmallIntType: core.IntegerValueExpression, - BigIntType: core.IntegerValueExpression, + IntegerType: IntegerAnnotation, + TinyIntType: IntegerAnnotation, + SmallIntType: IntegerAnnotation, + BigIntType: IntegerAnnotation, # Fractional numbers. - DecimalType: core.NumericValueExpression, - FloatType: core.NumericValueExpression, - RealType: core.NumericValueExpression, - DoubleType: core.NumericValueExpression, + DecimalType: NumberAnnotation, + FloatType: NumberAnnotation, + RealType: NumberAnnotation, + DoubleType: NumberAnnotation, # Text. An enum is text: its values are labels. - CharType: core.StringValueExpression, - VarCharType: core.StringValueExpression, - TextType: core.StringValueExpression, - EnumType: core.StringValueExpression, + CharType: TextAnnotation, + VarCharType: TextAnnotation, + TextType: TextAnnotation, + EnumType: TextAnnotation, # Bytes. - BinaryType: core.BinaryValueExpression, - VarBinaryType: core.BinaryValueExpression, - BlobType: core.BinaryValueExpression, - BooleanType: core.BooleanValueExpression, + BinaryType: BinaryAnnotation, + VarBinaryType: BinaryAnnotation, + BlobType: BinaryAnnotation, + BooleanType: BooleanAnnotation, # Documents. - JsonType: core.JSONValueExpression, - JsonBType: core.JSONValueExpression, - # An XML document: a value with its own surface, not a string. - XmlType: core.XMLValueExpression, + JsonType: JSONAnnotation, + JsonBType: JSONAnnotation, + # An XML document: a value with its own value_type, not a string. + XmlType: XMLAnnotation, # Sequences. - ArrayType: core.ArrayValueExpression, + ArrayType: ArrayAnnotation, # A UUID is a UUID. - UUIDType: core.UUIDValueExpression, - # Temporal. Each gets its own class because they are four types; a - # timestamp is not a date, and CURRENT_DATE() is not - # CURRENT_TIMESTAMP(). - DateType: core.DateValueExpression, - DateTimeType: core.TimestampValueExpression, - TimestampType: core.TimestampValueExpression, - TimestampTzType: core.TimestampValueExpression, - TimeType: core.TimeValueExpression, - TimeTzType: core.TimeValueExpression, - IntervalType: core.IntervalValueExpression, + UUIDType: UUIDAnnotation, + # Temporal. A date, a time and a date-time carry the same operations, so + # they share one annotation; what differs between them is what the node + # declares, and CAST states exactly that much. + DateType: TemporalAnnotation, + DateTimeType: TemporalAnnotation, + TimestampType: TemporalAnnotation, + TimestampTzType: TemporalAnnotation, + TimeType: TemporalAnnotation, + TimeTzType: TemporalAnnotation, + IntervalType: TemporalAnnotation, } -_TYPE_VALUE_CLASSES = None +_TYPE_ANNOTATIONS = None def cast( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], target_type: "DataType", -) -> "SQLValueExpression": +) -> "SQLValue": """ Creates a type cast around an expression. @@ -199,19 +324,18 @@ def cast( Raises: TypeError: If target_type is not a DataType. """ - from ..advanced_functions import DeclaredValueType from ..core import CastExpression, Column target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) node = CastExpression(dialect, target_expr, target_type) - value_class = _value_class_for(target_type) - if value_class is None: + annotation = _annotation_for(target_type) + if annotation is None: return node # A cast is the one place a result's type is stated by something other than # the input: CAST(x AS t) is a t by definition, whatever x was. So the node - # is built as an instance of that class -- it is not a wrapper holding one, - # hence DeclaredValueType's rendering rather than the wrapper's delegation. - combined = _cast_class_for(value_class) + # is built as an instance of that class, and carries the annotation's + # operations rather than holding one. + combined = _cast_class_for(annotation) # The state is already set by CastExpression.__init__ above; re-point the # instance's class rather than rebuilding it, so nothing is re-validated. node.__class__ = combined @@ -220,7 +344,7 @@ def cast( def to_char( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], format: Optional[str] = None -) -> "StringValueExpression": +) -> "ToCharFunction": """ Creates a TO_CHAR function call. @@ -237,18 +361,16 @@ def to_char( format: Optional format string for conversion. Returns: - A StringValueExpression wrapping TO_CHAR + A StringValue wrapping TO_CHAR """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - if format is not None: - format_expr = Literal(dialect, format) - return StringValueExpression(dialect, FunctionCall(dialect, 'TO_CHAR', target_expr, format_expr)) - return StringValueExpression(dialect, FunctionCall(dialect, 'TO_CHAR', target_expr)) + format_expr = Literal(dialect, format) if format is not None else None + return ToCharFunction(dialect, target_expr, format_expr) def to_number( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], format: Optional[str] = None -) -> "NumericValueExpression": +) -> "ToNumberFunction": """ Creates a TO_NUMBER function call. @@ -265,18 +387,16 @@ def to_number( format: Optional format string for conversion. Returns: - A NumericValueExpression wrapping TO_NUMBER + A NumericValue wrapping TO_NUMBER """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - if format is not None: - format_expr = Literal(dialect, format) - return NumericValueExpression(dialect, FunctionCall(dialect, 'TO_NUMBER', target_expr, format_expr)) - return NumericValueExpression(dialect, FunctionCall(dialect, 'TO_NUMBER', target_expr)) + format_expr = Literal(dialect, format) if format is not None else None + return ToNumberFunction(dialect, target_expr, format_expr) def to_date( dialect: "SQLDialectBase", expr: Union[str, "BaseExpression"], format: Optional[str] = None -) -> "TimestampValueExpression": +) -> "ToDateFunction": """ Creates a TO_DATE function call. @@ -293,10 +413,8 @@ def to_date( format: Optional format string for conversion. Returns: - A TimestampValueExpression wrapping TO_DATE + A TimestampValue wrapping TO_DATE """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - if format is not None: - format_expr = Literal(dialect, format) - return TimestampValueExpression(dialect, FunctionCall(dialect, 'TO_DATE', target_expr, format_expr)) - return TimestampValueExpression(dialect, FunctionCall(dialect, 'TO_DATE', target_expr)) + format_expr = Literal(dialect, format) if format is not None else None + return ToDateFunction(dialect, target_expr, format_expr) diff --git a/src/rhosocial/activerecord/backend/expression/functions/window.py b/src/rhosocial/activerecord/backend/expression/functions/window.py index edbbf6fe1..e595124fd 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/window.py +++ b/src/rhosocial/activerecord/backend/expression/functions/window.py @@ -1,55 +1,127 @@ # src/rhosocial/activerecord/backend/expression/functions/window.py """Window function factories.""" +from abc import abstractmethod from typing import Union, Optional, Any, TYPE_CHECKING +from ..advanced_functions import DeclaredValueType from ..bases import BaseExpression -from ..column_types import value_class_of -from ..core import Column, FunctionCall, Literal, IntegerValueExpression +from ..column_types import annotation_for +from ..core import Column, FunctionCall, Literal if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase +from ..value_types import NumericValue + + def _typed(call: FunctionCall, target: BaseExpression): - """Wrap *call* in the value class matching *target*, when there is one. + """Wrap *call* in the annotation matching *target*, when there is one. A window function that borrows a value hands that value back: ``LAG`` over a string answers with a string, over a float with a number. The column class already says which, so it is read off the class rather than off an attribute duplicating it -- see - :func:`~...expression.column_types.value_class_of`. + :func:`~...expression.column_types.annotation_for`. ``ROW_NUMBER``, ``RANK`` and ``DENSE_RANK`` are the exception -- they count or place rows rather than borrow a value, and are always whole numbers -- so - they always wrap in :class:`IntegerValueExpression` and never come here. + they are their own node carrying :class:`NumericValue` and never come + here. A target that says nothing -- a ``Literal``, an untyped ``Column`` -- leaves the call untyped, which is the honest answer: the database decides. """ - value_class = value_class_of(target) - if value_class is not None: - return value_class(call._dialect, call) + annotation = annotation_for(target) + if annotation is not None: + return annotation(call._dialect, call) return call +class _WindowRank( + DeclaredValueType, + NumericValue, +): + # DeclaredValueType first: it is what keeps this node's own rendering ahead + # of NumericValue's declaration that a surface cannot render. + """Shared base for the ranking window functions. + + None of them takes an operand: the rank is over the window the node is placed + in, so what is being ranked is said by the clause around it, not by an + argument. An alias is still carried, because a ranked column in a select + list usually gets a name. + """ + + #: The function name. Overridden per subclass; the base does not render. + function_name: str = "" + + def __init__(self, dialect: "SQLDialectBase", alias: Optional[str] = None): + BaseExpression.__init__(self, dialect) + self.alias = alias + + @property + @abstractmethod + def format_method(self) -> str: + """The dialect formatting method that renders this expression. + + Abstract, so this base is not mistaken for a node: it declares no + function name of its own, and a subclass that did not supply one would + have nothing to write. + """ + raise NotImplementedError + + +class RowNumberFunction(_WindowRank): + """``ROW_NUMBER()`` -- the row's position, counting from 1 with no ties.""" + + function_name = "ROW_NUMBER" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_row_number_function" + -def row_number(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "FunctionCall": +class RankFunction(_WindowRank): + """``RANK()`` -- the position, counting ties as one rank. + + ``1, 2, 2, 4`` rather than ``1, 2, 2, 3``: that skipping is what + distinguishes it from :class:`DenseRankFunction`, and it is why they are two + nodes rather than one with a flag. + """ + + function_name = "RANK" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_rank_function" + + +class DenseRankFunction(_WindowRank): + """``DENSE_RANK()`` -- the position with no gaps between ties.""" + + function_name = "DENSE_RANK" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_dense_rank_function" + + +def row_number(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "RowNumberFunction": """Creates a ROW_NUMBER window function call.""" - return IntegerValueExpression( - dialect, FunctionCall(dialect, "ROW_NUMBER", alias=alias) - ) + return RowNumberFunction(dialect, alias) -def rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "FunctionCall": +def rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "RankFunction": """Creates a RANK window function call.""" - return IntegerValueExpression(dialect, FunctionCall(dialect, "RANK", alias=alias)) + return RankFunction(dialect, alias) -def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "FunctionCall": +def dense_rank(dialect: "SQLDialectBase", alias: Optional[str] = None) -> "DenseRankFunction": """Creates a DENSE_RANK window function call.""" - return IntegerValueExpression( - dialect, FunctionCall(dialect, "DENSE_RANK", alias=alias) - ) + return DenseRankFunction(dialect, alias) def lag( @@ -77,9 +149,9 @@ def lag( A FunctionCall instance representing the LAG function """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - args = [target_expr, Literal(dialect, offset)] + args = [target_expr, offset if isinstance(offset, BaseExpression) else Literal(dialect, offset)] if default is not None: - args.append(Literal(dialect, default)) + args.append(default if isinstance(default, BaseExpression) else Literal(dialect, default)) return _typed(FunctionCall(dialect, "LAG", *args, alias=alias), target_expr) @@ -109,9 +181,9 @@ def lead( A FunctionCall instance representing the LEAD function """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - args = [target_expr, Literal(dialect, offset)] + args = [target_expr, offset if isinstance(offset, BaseExpression) else Literal(dialect, offset)] if default is not None: - args.append(Literal(dialect, default)) + args.append(default if isinstance(default, BaseExpression) else Literal(dialect, default)) return _typed(FunctionCall(dialect, "LEAD", *args, alias=alias), target_expr) @@ -185,7 +257,7 @@ def nth_value( A FunctionCall instance representing the NTH_VALUE function """ target_expr = expr if isinstance(expr, BaseExpression) else Column(dialect, expr) - n_expr = Literal(dialect, n) + n_expr = n if isinstance(n, BaseExpression) else Literal(dialect, n) return _typed( FunctionCall(dialect, "NTH_VALUE", target_expr, n_expr, alias=alias), target_expr, diff --git a/src/rhosocial/activerecord/backend/expression/functions/xml.py b/src/rhosocial/activerecord/backend/expression/functions/xml.py index d11575159..cbf078e9f 100644 --- a/src/rhosocial/activerecord/backend/expression/functions/xml.py +++ b/src/rhosocial/activerecord/backend/expression/functions/xml.py @@ -7,6 +7,7 @@ from ..core import Literal from ..query_parts import OrderByClause from ..xml import ( + PassingClause, XMLAggExpression, XMLAttribute, XMLAttributesExpression, @@ -14,7 +15,7 @@ XMLConcatExpression, XMLElementExpression, XMLEmptyHandlingOption, - XMLExistsExpression, + XMLExistsPredicate, XMLForestExpression, XMLForestItem, XMLParseDocumentType, @@ -33,6 +34,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.types import DataType XMLValue = Union[str, BaseExpression] XMLNamedValue = Tuple[XMLValue, Optional[str]] @@ -62,11 +64,25 @@ def xmlparse( def xmlserialize( dialect: "SQLDialectBase", content: XMLValue, - target_type: str, + target_type: "DataType", document: bool = False, preserve_whitespace: bool = False, ) -> XMLSerializeExpression: - """Create a SQL/XML XMLSERIALIZE expression.""" + """Create a SQL/XML XMLSERIALIZE expression. + + Args: + dialect: The dialect the expression belongs to. + content: The XML value to serialize. + target_type: The character type the result is cast to. A type + position holds a type: ``TextType(dialect)`` for a built-in, or + ``CustomType(dialect, "name")`` for a name this package does not + model. + document: Whether the result is a document rather than content. + preserve_whitespace: Whether whitespace is preserved. + + Returns: + The XMLSERIALIZE expression. + """ document_type = XMLSerializeDocumentType.DOCUMENT if document else XMLSerializeDocumentType.CONTENT whitespace_option = XMLWhitespaceOption.PRESERVE if preserve_whitespace else None return XMLSerializeExpression( @@ -170,6 +186,37 @@ def xmlagg( return XMLAggExpression(dialect, _as_expression(dialect, expression), order_by) +def passing_clause( + dialect: "SQLDialectBase", + passing: Optional[Sequence[XMLValue]] = None, + mechanism: Optional[XMLPassingMechanism] = None, +) -> Optional[PassingClause]: + """Create the SQL/XML ``PASSING`` clause, or ``None`` when there is none. + + The querying XML expressions take a ready-made clause rather than the + unpacked pieces, so callers that build one of those nodes directly can + use this factory too. The unpacking lives here instead of at each call + site because the three querying factories share it. + + Args: + dialect: The SQL dialect the clause renders against. + passing: Values to pass into the XML function. + mechanism: Optional mechanism (``BY VALUE`` / ``BY REF``). + + Returns: + A :class:`PassingClause`, or ``None`` when neither value is given — + an absent clause rather than an empty one, so the composing + formatters need no empty-clause special case. + """ + if not passing and mechanism is None: + return None + return PassingClause( + dialect, + [_as_expression(dialect, item) for item in passing or []], + mechanism, + ) + + def xmlquery( dialect: "SQLDialectBase", query: XMLValue, @@ -182,8 +229,7 @@ def xmlquery( return XMLQueryExpression( dialect, _as_expression(dialect, query), - [_as_expression(dialect, item) for item in passing or []], - passing_mechanism, + passing_clause(dialect, passing, passing_mechanism), returning_content, empty_handling, ) @@ -194,13 +240,12 @@ def xmlexists( query: XMLValue, passing: Optional[Sequence[XMLValue]] = None, passing_mechanism: Optional[XMLPassingMechanism] = None, -) -> XMLExistsExpression: +) -> XMLExistsPredicate: """Create a SQL/XML XMLEXISTS predicate.""" - return XMLExistsExpression( + return XMLExistsPredicate( dialect, _as_expression(dialect, query), - [_as_expression(dialect, item) for item in passing or []], - passing_mechanism, + passing_clause(dialect, passing, passing_mechanism), ) @@ -216,6 +261,5 @@ def xmltable( dialect, _as_expression(dialect, row_pattern), columns, - [_as_expression(dialect, item) for item in passing or []], - passing_mechanism, + passing_clause(dialect, passing, passing_mechanism), ) diff --git a/src/rhosocial/activerecord/backend/expression/graph.py b/src/rhosocial/activerecord/backend/expression/graph.py index c10bc27f6..508052535 100644 --- a/src/rhosocial/activerecord/backend/expression/graph.py +++ b/src/rhosocial/activerecord/backend/expression/graph.py @@ -20,7 +20,9 @@ from .query_parts import WhereClause -class GraphEdgeDirection(Enum): +class GraphEdgeDirection(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ """Specifies the direction of an edge in a graph pattern according to SQL 2023 standard.""" LEFT = "<-" # Left-directed: <-[edge]- @@ -271,11 +273,17 @@ def format_method(self) -> str: return "format_vertex_table" -class EdgeTable(BaseExpression): +class EdgeTableDefinition(BaseExpression): """Represents an edge table definition in CREATE PROPERTY GRAPH. Renders: table [AS alias] [KEY (cols)] SOURCE KEY (cols) [REFERENCES t (c)] - DESTINATION KEY (cols) [REFERENCES t (c)] [LABEL ...] [PROPERTIES ...]""" + DESTINATION KEY (cols) [REFERENCES t (c)] [LABEL ...] [PROPERTIES ...] + + Named ``...Definition`` because it is the definition, not the table: the + table it names is a relation object (:class:`~..objects.relation.EdgeTable`, + a sibling of ``Table``), and the two were previously both called + ``EdgeTable`` — one name, two contracts, and therefore one derived kind for + two different things (§4.11.1).""" def __init__(self, dialect: "SQLDialectBase", table: "EdgeTableObject", source_key: List[str], destination_key: List[str], @@ -300,15 +308,15 @@ def __init__(self, dialect: "SQLDialectBase", table: "EdgeTableObject", @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_edge_table" + return "format_edge_table_definition" -class CreatePropertyGraphExpression(BaseExpression): +class CreatePropertyGraphStatement(BaseExpression): """Represents a CREATE PROPERTY GRAPH DDL statement.""" def __init__(self, dialect: "SQLDialectBase", graph: "PropertyGraph", vertex_tables: List[VertexTable], - edge_tables: Optional[List[EdgeTable]] = None, + edge_tables: Optional[List[EdgeTableDefinition]] = None, if_not_exists: bool = False, ): super().__init__(dialect) @@ -323,7 +331,7 @@ def format_method(self) -> str: return "format_create_property_graph_statement" -class DropPropertyGraphExpression(BaseExpression): +class DropPropertyGraphStatement(BaseExpression): """Represents a DROP PROPERTY GRAPH DDL statement.""" def __init__(self, dialect: "SQLDialectBase", graph: "PropertyGraph", @@ -340,13 +348,13 @@ def format_method(self) -> str: return "format_drop_property_graph_statement" -class AlterPropertyGraphExpression(BaseExpression): +class AlterPropertyGraphStatement(BaseExpression): """Represents an ALTER PROPERTY GRAPH DDL statement.""" def __init__(self, dialect: "SQLDialectBase", graph: "PropertyGraph", action: str, target: str, vertex_tables: Optional[List[VertexTable]] = None, - edge_tables: Optional[List[EdgeTable]] = None, + edge_tables: Optional[List[EdgeTableDefinition]] = None, ): super().__init__(dialect) self.graph = graph diff --git a/src/rhosocial/activerecord/backend/expression/introspection.py b/src/rhosocial/activerecord/backend/expression/introspection.py index 0c5e21838..50819ade5 100644 --- a/src/rhosocial/activerecord/backend/expression/introspection.py +++ b/src/rhosocial/activerecord/backend/expression/introspection.py @@ -12,9 +12,11 @@ Expression classes inherit from BaseExpression and implement to_sql(), delegating SQL generation to the dialect's corresponding format_* method. -All expression classes support instantiation with complete parameters for -IDE introspection, static analysis, and MCP service integration. Parameters -can be provided at construction time or modified via fluent API methods. +Every statement collects all of its parameters at construction. There is no +fluent API: a statement is asked once, with everything it needs, and is never +edited afterwards. Constructing the final question in one call is what makes +the parameters readable to IDE introspection, static analysis and MCP service +integration -- a chain of setters hides them behind the last call. """ from typing import Optional, TYPE_CHECKING @@ -28,19 +30,18 @@ class IntrospectionExpression(BaseExpression): """Base class for introspection expressions. - All introspection expressions inherit from this class and provide - fluent API for setting parameters. Introspection expressions hold - a dialect reference and delegate SQL generation to the corresponding - dialect via the to_sql() method. + All introspection expressions inherit from this class. An introspection + expression holds a dialect reference and the parameters of one catalogue + question, and delegates SQL generation to the corresponding dialect via + the to_sql() method. Args: dialect: The SQL dialect to use for SQL generation. - schema: Optional schema name for the query. When provided at - construction, the expression is immediately configured for - the specified schema. Can also be set via the schema() method. + schema: Optional schema name for the query, collected here with the + rest of the parameters. Example: - >>> expr = TableListExpression(dialect, schema='public') + >>> expr = TableListQueryStatement(dialect, schema='public') >>> sql, params = expr.to_sql() """ @@ -56,30 +57,10 @@ def __init__( schema: Optional schema name. Defaults to None. """ super().__init__(dialect) - self._schema: Optional[str] = schema + self.schema: Optional[str] = schema - def schema(self, name: str) -> "IntrospectionExpression": - """Set the schema name for the introspection query. - This method provides a fluent API for setting the schema name. - It can be chained with other configuration methods. - - Args: - name: The schema name to query. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableListExpression(dialect).schema('public') - """ - self._schema = name - return self - - - - -class DatabaseInfoExpression(IntrospectionExpression): +class DatabaseInfoQueryStatement(IntrospectionExpression): """Expression for database information queries. Database info queries typically don't need parameters beyond schema, @@ -90,17 +71,17 @@ class DatabaseInfoExpression(IntrospectionExpression): schema: Optional schema name. Defaults to None. Example: - >>> expr = DatabaseInfoExpression(dialect) + >>> expr = DatabaseInfoQueryStatement(dialect) >>> sql, params = expr.to_sql() """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_database_info_query" + return "format_database_info_query_statement" -class TableListExpression(IntrospectionExpression): +class TableListQueryStatement(IntrospectionExpression): """Expression for table list queries. Collects parameters for listing tables in a database. Supports filtering @@ -115,11 +96,8 @@ class TableListExpression(IntrospectionExpression): Defaults to None (no filter). Example: - >>> # All parameters at construction - >>> expr = TableListExpression(dialect, schema='public', + >>> expr = TableListQueryStatement(dialect, schema='public', ... include_views=True, include_system=False) - >>> # Or using fluent API - >>> expr = TableListExpression(dialect).include_views(False) """ def __init__( @@ -140,69 +118,18 @@ def __init__( table_type: Optional table type filter. Defaults to None. """ super().__init__(dialect, schema) - self._include_views: bool = include_views - self._include_system: bool = include_system - self._table_type: Optional[str] = table_type - - def include_views(self, value: bool = True) -> "TableListExpression": - """Set whether to include views in the result. - - Args: - value: True to include views, False to exclude. Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableListExpression(dialect).include_views(False) - """ - self._include_views = value - return self - - def include_system(self, value: bool = True) -> "TableListExpression": - """Set whether to include system tables in the result. - - System tables are database metadata tables that are typically hidden - from regular queries (e.g., sqlite_master in SQLite). - - Args: - value: True to include system tables, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableListExpression(dialect).include_system(True) - """ - self._include_system = value - return self - - def table_type(self, ttype: str) -> "TableListExpression": - """Filter results by a specific table type. + self.include_views: bool = include_views + self.include_system: bool = include_system + self.table_type: Optional[str] = table_type - Common table types include 'TABLE', 'VIEW', 'SYSTEM TABLE', etc. - The exact values depend on the database backend. - - Args: - ttype: The table type to filter by (e.g., 'TABLE', 'VIEW'). - - Returns: - Self for method chaining. - - Example: - >>> expr = TableListExpression(dialect).table_type('TABLE') - """ - self._table_type = ttype - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_list_query" + return "format_table_list_query_statement" -class TableInfoExpression(IntrospectionExpression): +class TableInfoQueryStatement(IntrospectionExpression): """Expression for single table information queries. Collects parameters for querying detailed information about a specific @@ -217,7 +144,7 @@ class TableInfoExpression(IntrospectionExpression): include_foreign_keys: Whether to include foreign key info. Defaults to True. Example: - >>> expr = TableInfoExpression(dialect, 'users', schema='public') + >>> expr = TableInfoQueryStatement(dialect, 'users', schema='public') >>> sql, params = expr.to_sql() """ @@ -241,90 +168,19 @@ def __init__( include_foreign_keys: Whether to include foreign key info. Defaults to True. """ super().__init__(dialect, schema) - self._table_name = table_name - self._include_columns: bool = include_columns - self._include_indexes: bool = include_indexes - self._include_foreign_keys: bool = include_foreign_keys + self.table_name = table_name + self.include_columns: bool = include_columns + self.include_indexes: bool = include_indexes + self.include_foreign_keys: bool = include_foreign_keys - def table_name(self, name: str) -> "TableInfoExpression": - """Set the table name to query. - - Args: - name: The table name. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableInfoExpression(dialect, 'old_table').table_name('new_table') - """ - self._table_name = name - return self - - def include_columns(self, value: bool = True) -> "TableInfoExpression": - """Set whether to include column information. - - Column information includes column names, data types, nullability, - default values, and other column attributes. - - Args: - value: True to include column information, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableInfoExpression(dialect, 'users').include_columns(False) - """ - self._include_columns = value - return self - - def include_indexes(self, value: bool = True) -> "TableInfoExpression": - """Set whether to include index information. - - Index information includes index names, columns, uniqueness, - and other index attributes. - - Args: - value: True to include index information, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableInfoExpression(dialect, 'users').include_indexes(False) - """ - self._include_indexes = value - return self - - def include_foreign_keys(self, value: bool = True) -> "TableInfoExpression": - """Set whether to include foreign key information. - - Foreign key information includes constraint names, referenced - tables, columns, and referential actions (ON DELETE, ON UPDATE). - - Args: - value: True to include foreign key information, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = TableInfoExpression(dialect, 'users').include_foreign_keys(False) - """ - self._include_foreign_keys = value - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_info_query" + return "format_table_info_query_statement" -class ColumnInfoExpression(IntrospectionExpression): +class ColumnInfoQueryStatement(IntrospectionExpression): """Expression for column information queries. Collects parameters for querying column information for a table. @@ -336,7 +192,7 @@ class ColumnInfoExpression(IntrospectionExpression): include_hidden: Whether to include hidden columns. Defaults to False. Example: - >>> expr = ColumnInfoExpression(dialect, 'users', schema='public') + >>> expr = ColumnInfoQueryStatement(dialect, 'users', schema='public') >>> sql, params = expr.to_sql() """ @@ -356,50 +212,17 @@ def __init__( include_hidden: Whether to include hidden columns. Defaults to False. """ super().__init__(dialect, schema) - self._table_name = table_name - self._include_hidden: bool = include_hidden - - def table_name(self, name: str) -> "ColumnInfoExpression": - """Set the table name to query columns for. + self.table_name = table_name + self.include_hidden: bool = include_hidden - Args: - name: The table name. - - Returns: - Self for method chaining. - - Example: - >>> expr = ColumnInfoExpression(dialect, 'old').table_name('new') - """ - self._table_name = name - return self - - def include_hidden(self, value: bool = True) -> "ColumnInfoExpression": - """Set whether to include hidden columns. - - Hidden columns are typically internal columns used by the database - for specific purposes (e.g., SQLite's ROWID, generated columns). - - Args: - value: True to include hidden columns, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = ColumnInfoExpression(dialect, 'users').include_hidden(True) - """ - self._include_hidden = value - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_column_info_query" + return "format_column_info_query_statement" -class IndexInfoExpression(IntrospectionExpression): +class IndexInfoQueryStatement(IntrospectionExpression): """Expression for index information queries. Collects parameters for querying index information for a table. @@ -410,7 +233,7 @@ class IndexInfoExpression(IntrospectionExpression): schema: Optional schema name. Defaults to None. Example: - >>> expr = IndexInfoExpression(dialect, 'users', schema='public') + >>> expr = IndexInfoQueryStatement(dialect, 'users', schema='public') >>> sql, params = expr.to_sql() """ @@ -428,30 +251,16 @@ def __init__( schema: Optional schema name. Defaults to None. """ super().__init__(dialect, schema) - self._table_name = table_name - - def table_name(self, name: str) -> "IndexInfoExpression": - """Set the table name to query indexes for. - - Args: - name: The table name. - - Returns: - Self for method chaining. + self.table_name = table_name - Example: - >>> expr = IndexInfoExpression(dialect, 'old').table_name('new') - """ - self._table_name = name - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_index_info_query" + return "format_index_info_query_statement" -class ForeignKeyExpression(IntrospectionExpression): +class ForeignKeyQueryStatement(IntrospectionExpression): """Expression for foreign key information queries. Collects parameters for querying foreign key information for a table. @@ -462,7 +271,7 @@ class ForeignKeyExpression(IntrospectionExpression): schema: Optional schema name. Defaults to None. Example: - >>> expr = ForeignKeyExpression(dialect, 'posts', schema='public') + >>> expr = ForeignKeyQueryStatement(dialect, 'posts', schema='public') >>> sql, params = expr.to_sql() """ @@ -480,30 +289,16 @@ def __init__( schema: Optional schema name. Defaults to None. """ super().__init__(dialect, schema) - self._table_name = table_name - - def table_name(self, name: str) -> "ForeignKeyExpression": - """Set the table name to query foreign keys for. - - Args: - name: The table name. + self.table_name = table_name - Returns: - Self for method chaining. - - Example: - >>> expr = ForeignKeyExpression(dialect, 'old').table_name('new') - """ - self._table_name = name - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_foreign_key_query" + return "format_foreign_key_query_statement" -class ViewListExpression(IntrospectionExpression): +class ViewListQueryStatement(IntrospectionExpression): """Expression for view list queries. Collects parameters for listing views in a database. @@ -514,7 +309,7 @@ class ViewListExpression(IntrospectionExpression): include_system: Whether to include system views. Defaults to False. Example: - >>> expr = ViewListExpression(dialect, schema='public') + >>> expr = ViewListQueryStatement(dialect, schema='public') >>> sql, params = expr.to_sql() """ @@ -532,34 +327,16 @@ def __init__( include_system: Whether to include system views. Defaults to False. """ super().__init__(dialect, schema) - self._include_system: bool = include_system - - def include_system(self, value: bool = True) -> "ViewListExpression": - """Set whether to include system views. + self.include_system: bool = include_system - System views are database metadata views that are typically hidden - from regular queries. - - Args: - value: True to include system views, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = ViewListExpression(dialect).include_system(True) - """ - self._include_system = value - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_view_list_query" + return "format_view_list_query_statement" -class ViewInfoExpression(IntrospectionExpression): +class ViewInfoQueryStatement(IntrospectionExpression): """Expression for single view information queries. Collects parameters for querying detailed information about a specific @@ -572,7 +349,7 @@ class ViewInfoExpression(IntrospectionExpression): include_columns: Whether to include column information. Defaults to True. Example: - >>> expr = ViewInfoExpression(dialect, 'user_view', schema='public') + >>> expr = ViewInfoQueryStatement(dialect, 'user_view', schema='public') >>> sql, params = expr.to_sql() """ @@ -592,50 +369,17 @@ def __init__( include_columns: Whether to include column information. Defaults to True. """ super().__init__(dialect, schema) - self._view_name = view_name - self._include_columns: bool = include_columns - - def view_name(self, name: str) -> "ViewInfoExpression": - """Set the view name to query. + self.view_name = view_name + self.include_columns: bool = include_columns - Args: - name: The view name. - - Returns: - Self for method chaining. - - Example: - >>> expr = ViewInfoExpression(dialect, 'old_view').view_name('new_view') - """ - self._view_name = name - return self - - def include_columns(self, value: bool = True) -> "ViewInfoExpression": - """Set whether to include column information. - - Column information includes column names, data types, and other - column attributes for the view's result set. - - Args: - value: True to include column information, False to exclude. - Defaults to True. - - Returns: - Self for method chaining. - - Example: - >>> expr = ViewInfoExpression(dialect, 'my_view').include_columns(False) - """ - self._include_columns = value - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_view_info_query" + return "format_view_info_query_statement" -class TriggerListExpression(IntrospectionExpression): +class TriggerListQueryStatement(IntrospectionExpression): """Expression for trigger list queries. Collects parameters for listing triggers in a database, optionally @@ -648,9 +392,9 @@ class TriggerListExpression(IntrospectionExpression): Example: >>> # List all triggers - >>> expr = TriggerListExpression(dialect) + >>> expr = TriggerListQueryStatement(dialect) >>> # List triggers for a specific table - >>> expr = TriggerListExpression(dialect, table_name='users') + >>> expr = TriggerListQueryStatement(dialect, table_name='users') """ def __init__( @@ -667,30 +411,16 @@ def __init__( table_name: Optional table name to filter triggers. Defaults to None. """ super().__init__(dialect, schema) - self._table_name: Optional[str] = table_name + self.table_name: Optional[str] = table_name - def for_table(self, table_name: str) -> "TriggerListExpression": - """Filter triggers for a specific table. - - Args: - table_name: The table name to filter triggers by. - - Returns: - Self for method chaining. - - Example: - >>> expr = TriggerListExpression(dialect).for_table('users') - """ - self._table_name = table_name - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_trigger_list_query" + return "format_trigger_list_query_statement" -class TriggerInfoExpression(IntrospectionExpression): +class TriggerInfoQueryStatement(IntrospectionExpression): """Expression for single trigger information queries. Collects parameters for querying detailed information about a specific @@ -703,7 +433,7 @@ class TriggerInfoExpression(IntrospectionExpression): table_name: Optional associated table name. Defaults to None. Example: - >>> expr = TriggerInfoExpression(dialect, 'my_trigger', table_name='users') + >>> expr = TriggerInfoQueryStatement(dialect, 'my_trigger', table_name='users') >>> sql, params = expr.to_sql() """ @@ -723,43 +453,11 @@ def __init__( table_name: Optional associated table name. Defaults to None. """ super().__init__(dialect, schema) - self._trigger_name = trigger_name - self._table_name: Optional[str] = table_name - - def trigger_name(self, name: str) -> "TriggerInfoExpression": - """Set the trigger name to query. - - Args: - name: The trigger name. - - Returns: - Self for method chaining. - - Example: - >>> expr = TriggerInfoExpression(dialect, 'old').trigger_name('new') - """ - self._trigger_name = name - return self + self.trigger_name = trigger_name + self.table_name: Optional[str] = table_name - def for_table(self, table_name: str) -> "TriggerInfoExpression": - """Set the associated table name. - - Some databases require the table name to uniquely identify a trigger, - as trigger names may not be globally unique. - - Args: - table_name: The associated table name. - - Returns: - Self for method chaining. - - Example: - >>> expr = TriggerInfoExpression(dialect, 'my_trigger').for_table('users') - """ - self._table_name = table_name - return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_trigger_info_query" + return "format_trigger_info_query_statement" diff --git a/src/rhosocial/activerecord/backend/expression/literals.py b/src/rhosocial/activerecord/backend/expression/literals.py index 01f835ad6..6012f7567 100644 --- a/src/rhosocial/activerecord/backend/expression/literals.py +++ b/src/rhosocial/activerecord/backend/expression/literals.py @@ -4,14 +4,14 @@ """ from typing import TYPE_CHECKING -from .bases import SQLValueExpression +from .bases import SQLValue from .mixins import ComparisonMixin if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase -class Identifier(ComparisonMixin, SQLValueExpression): +class Identifier(ComparisonMixin, SQLValue): """Represents a generic SQL identifier (e.g., table name, column name, alias). Per-role quoting fields (all default to ``True``): @@ -27,8 +27,8 @@ def __init__( alias_need_quote: bool = True, ): super().__init__(dialect) - self.name_need_quote = name_need_quote - self.alias_need_quote = alias_need_quote + self.name_need_quote = self.quote_flag(name_need_quote, "name_need_quote") + self.alias_need_quote = self.quote_flag(alias_need_quote, "alias_need_quote") self.name = name @property @@ -40,9 +40,6 @@ def __repr__(self) -> str: return f"Identifier({self.name!r})" -# Register Identifier in the expression registry for serialization support. -# This is done here rather than in serialization._auto_register_builtins -# to avoid circular import issues during module initialization. -from .serialization import ExpressionRegistry # noqa: E402 - -ExpressionRegistry.register(Identifier) +# No registration call: the framework's assembly point walks this package and +# registers Identifier like every other node. The old class index needed to be +# told about a class defined outside its own walk; the registry does not. diff --git a/src/rhosocial/activerecord/backend/expression/mixins.py b/src/rhosocial/activerecord/backend/expression/mixins.py index 8290bdad2..9f6816485 100644 --- a/src/rhosocial/activerecord/backend/expression/mixins.py +++ b/src/rhosocial/activerecord/backend/expression/mixins.py @@ -17,7 +17,7 @@ col1 > 5 # Creates ComparisonPredicate with col1's dialect # Arithmetic operations - col1 + col2 # Creates BinaryArithmeticExpression with col1's dialect + col1 + col2 # Creates ArithmeticOperation with col1's dialect # Logical operations (col1 == 1) & (col2 == 2) # Creates LogicalPredicate with left predicate's dialect @@ -29,176 +29,65 @@ import copy import datetime import decimal -import functools from typing import ( Any, List, Optional, - Protocol, TYPE_CHECKING, TypeVar, Union, ) - if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase - from .bases import BaseExpression, SQLValueExpression, SQLPredicate + from .bases import BaseExpression, SQLValue, SQLPredicate # Annotations only. The classes themselves are imported inside the methods # that need them, because core imports this module. - from .core import ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - TimestampValueExpression, - IntegerValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - UUIDValueExpression, - XMLValueExpression, - ) - from .core import CastExpression, FunctionCall + from .core import CastExpression, FunctionCall, Literal from .datetime import ( DatePartExpression, - DateTimeAddExpression, - DateTimeDiffExpression, - DateTimeSubtractExpression, + DatetimeAddExpression, + DatetimeDiffExpression, + DatetimeSubtractExpression, DateTruncExpression, ExtractExpression, ) - from .advanced_functions import JSONDocumentExpression, JSONTextExpression + from .advanced_functions import JSONDocumentValue, JSONTextValue + from .declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, + ) T = TypeVar("T") _ResultT = TypeVar("_ResultT") -class _ValueExpression(Protocol[_ResultT]): - """How a value expression class is built, as far as ``_retype`` cares. - - Not ``type[BaseExpression]``: the value classes are built from a dialect - and the call they wrap, while ``BaseExpression`` takes only a dialect, so - bounding to it makes the two-argument construction an error. What every - ``as_*`` method needs is exactly this. - - The wrapped node is typed loosely on purpose. A mixin cannot say what class - it is mixed into, so naming FunctionCall here would need ``self`` annotated - as one, which mypy only allows if this class subclasses it. The return type - is what the chain is checked against, and that is exact; the node being - passed is the one already in hand. - """ - - def __call__(self, dialect: "SQLDialectBase", call: Any) -> _ResultT: ... - - -class WrappedCallMixin: - """Forwards what the node says that the wrapper's type does not. - - A typed value wraps the call it was built from -- ``min(name)`` is a - ``StringValueExpression`` holding a ``FunctionCall`` -- so that - the result says what it is rather than what produced it. The wrapper offers - the operations its *type* supports, and those are the ones a caller reaches - for. - - Some things are not operations at all. ``count(x).filter(pred)`` is a filter - on the aggregate, not an operation on an integer; ``window_spec`` belongs to - the call; ``alias`` is state both share. Those belong to the node, and the - only one that knows how to attach a filter is the aggregate. Rather than - restate every node-specific member on every value type, an attribute the - wrapper has no answer for is looked up on the node it holds. - - **Where that lookup stops is the whole of this class.** The two - hierarchies meet: the node is built from the same base classes and the same - mixins, and a wrapper's bases say exactly what it kept. So the lookup walks - the node's MRO and stops at the first class the wrapper also carries. - Above that point is the node's own contribution -- its state and its own - grammar -- and it is forwarded. From that point down is either something - the wrapper already has, or a capability its type deliberately left off: - ``StringPatternPredicateMixin`` and ``ResultTypeMixin`` reach a - ``FunctionCall`` that a ``StringValueExpression`` or an - ``IntegerValueExpression`` never claimed, and handing those back would undo - the typing that made the class worth having: ``length().like("1%")`` would - be offered again, and the mistake would travel to the database. - - The boundary is found by comparing the two MROs, so there is no set of - names to keep current: a method added to a capability mixin stops being - forwarded the moment it is declared, and a method added to a node class - becomes forwardable without anything being edited here. What the wrapper - withholds is what the wrapper's own bases withhold, which is the one - statement this class has to make. - - The wrapper keeps the type either way: a forwarded call that returns the - node hands back a node, so ``count(x).filter(...)`` is an untyped - ``FunctionCall`` again -- which is honest, because that is what the - filter produces and nothing about adding one re-types the result. - - Example: - >>> agg = count(dialect, "price") - >>> type(agg).__name__ # IntegerValueExpression - >>> type(agg.filter(pred)).__name__# FunctionCall - >>> hasattr(agg, "like") # False: a count is not text - """ - - def __getattr__(self, name): - # Only reached for attributes this class does not define, so `call` - # itself is already set here. Going through the instance dict rather - # than `getattr` keeps a missing `call` from recursing into itself -- - # and a class that mixes this in without holding a node (the CASE whose - # branches agree the answer) refuses everything, which is what a node - # it does not have can honestly say. - try: - node = object.__getattribute__(self, "call") - except AttributeError: - raise AttributeError( - f"{type(self).__name__} has no attribute {name!r}" - ) from None - if name in _ruled_on(type(self), type(node)): - raise AttributeError( - f"{type(self).__name__} has no attribute {name!r}. The " - f"{type(node).__name__} it wraps does, and a typed value offers " - f"the operations its own type supports rather than the " - f"operations of the node that happens to produce it." - ) from None - try: - return getattr(node, name) - except AttributeError: - raise AttributeError( - f"{type(self).__name__} has no attribute {name!r}" - ) from None - - -@functools.lru_cache(maxsize=None) -def _ruled_on(wrapper_cls: type, node_cls: type) -> frozenset: - """What *node_cls* offers that *wrapper_cls*'s own bases already answer for. - - The two hierarchies meet at the first class of the node's MRO that the - wrapper also carries, and from there down the node is either repeating the - wrapper or offering a capability the wrapper's type declined. Both are - names the wrapper has already spoken for, so forwarding either would give - back what the typing withheld. - - Cached on the two classes because it is asked on every attribute miss, and - it depends on nothing but the two MROs. - """ - node_mro = node_cls.__mro__ - wrapper_mro = frozenset(wrapper_cls.__mro__) - meet = next((i for i, k in enumerate(node_mro) if k in wrapper_mro), len(node_mro)) - ruled_on = set() - for klass in node_mro[meet:]: - ruled_on.update(vars(klass)) - return frozenset(ruled_on) - - class AliasableMixin: """Mixin class that provides aliasing capability to expressions. This mixin allows expressions to be given aliases for use in SQL queries, particularly useful in SELECT clauses and subqueries. + ``alias`` is declared here rather than created by :meth:`as_`: the field + is part of the contract of every aliased expression, so formatters read it + directly instead of probing for an attribute that only exists on the + aliased copies. + Example: >>> Column(dialect, "name").as_("user_name") >>> # Results in: "name AS user_name" in SQL """ + #: The alias this expression renders as, or ``None`` when unaliased. + alias: Optional[str] = None + def as_(self: T, alias: str) -> T: """ Set an alias for this expression. @@ -243,7 +132,7 @@ class NullTestMixin: >>> predicate = col.is_null() # Generates: "email IS NULL" """ - def is_null(self: "SQLValueExpression") -> "SQLPredicate": + def is_null(self: "SQLValue") -> "SQLPredicate": """ Generate an IS NULL predicate for this expression. @@ -258,7 +147,7 @@ def is_null(self: "SQLValueExpression") -> "SQLPredicate": return IsNullPredicate(self._dialect, self) - def is_not_null(self: "SQLValueExpression") -> "SQLPredicate": + def is_not_null(self: "SQLValue") -> "SQLPredicate": """ Generate an IS NOT NULL predicate for this expression. @@ -305,7 +194,7 @@ class ComparisonMixin(NullTestMixin): >>> col1.is_distinct_from(0) # NULL-safe: "age IS DISTINCT FROM ?" """ - def is_distinct_from(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def is_distinct_from(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """NULL-safe inequality: true when the two differ, NULL included. ``a != b`` answers NULL when either side is NULL, so a query filtering @@ -332,12 +221,12 @@ def is_distinct_from(self: "SQLValueExpression", other: Union["SQLValueExpressio """ from .core import Literal from .predicates import DistinctFromPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return DistinctFromPredicate(self._dialect, self, other_expr, is_not=False) - def is_not_distinct_from(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def is_not_distinct_from(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """NULL-safe equality: true when the two match, two NULLs included. The counterpart of :meth:`is_distinct_from` -- ``IS NOT DISTINCT FROM`` @@ -355,12 +244,12 @@ def is_not_distinct_from(self: "SQLValueExpression", other: Union["SQLValueExpre """ from .core import Literal from .predicates import DistinctFromPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return DistinctFromPredicate(self._dialect, self, other_expr, is_not=True) - def __eq__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __eq__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the equality operator (==) to generate SQL equality predicate. @@ -379,12 +268,12 @@ def __eq__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, "=", self, other_expr) - def __ne__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __ne__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the inequality operator (!=) to generate SQL inequality predicate. @@ -396,12 +285,12 @@ def __ne__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, "!=", self, other_expr) - def __gt__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __gt__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the greater-than operator (>) to generate SQL comparison predicate. @@ -413,12 +302,12 @@ def __gt__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, ">", self, other_expr) - def __ge__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __ge__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the greater-or-equal operator (>=) to generate SQL comparison predicate. @@ -430,12 +319,12 @@ def __ge__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, ">=", self, other_expr) - def __lt__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __lt__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the less-than operator (<) to generate SQL comparison predicate. @@ -447,12 +336,12 @@ def __lt__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, "<", self, other_expr) - def __le__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLPredicate": + def __le__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLPredicate": """ Implement the less-or-equal operator (<=) to generate SQL comparison predicate. @@ -464,12 +353,12 @@ def __le__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) """ from .core import Literal from .predicates import ComparisonPredicate - from .bases import SQLValueExpression + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) return ComparisonPredicate(self._dialect, "<=", self, other_expr) - def is_true(self: "SQLValueExpression") -> "SQLPredicate": + def is_true(self: "SQLValue") -> "SQLPredicate": """ Generate an IS TRUE predicate for this expression. @@ -488,7 +377,7 @@ def is_true(self: "SQLValueExpression") -> "SQLPredicate": return IsBooleanPredicate(self._dialect, self, value=True, is_not=False) - def is_not_true(self: "SQLValueExpression") -> "SQLPredicate": + def is_not_true(self: "SQLValue") -> "SQLPredicate": """ Generate an IS NOT TRUE predicate for this expression. @@ -507,7 +396,7 @@ def is_not_true(self: "SQLValueExpression") -> "SQLPredicate": return IsBooleanPredicate(self._dialect, self, value=True, is_not=True) - def is_false(self: "SQLValueExpression") -> "SQLPredicate": + def is_false(self: "SQLValue") -> "SQLPredicate": """ Generate an IS FALSE predicate for this expression. @@ -526,7 +415,7 @@ def is_false(self: "SQLValueExpression") -> "SQLPredicate": return IsBooleanPredicate(self._dialect, self, value=False, is_not=False) - def is_not_false(self: "SQLValueExpression") -> "SQLPredicate": + def is_not_false(self: "SQLValue") -> "SQLPredicate": """ Generate an IS NOT FALSE predicate for this expression. @@ -545,7 +434,7 @@ def is_not_false(self: "SQLValueExpression") -> "SQLPredicate": return IsBooleanPredicate(self._dialect, self, value=False, is_not=True) - def in_(self: "SQLValueExpression", values: List[Any]) -> "SQLPredicate": + def in_(self: "SQLValue", values: List[Any]) -> "SQLPredicate": """ Generate an IN predicate for this expression with a list of values. @@ -563,6 +452,10 @@ def in_(self: "SQLValueExpression", values: List[Any]) -> "SQLPredicate": from .core import Literal from .predicates import InPredicate + # An ActiveQuery renders as an IN subquery. The capability is asked of + # the declared protocol rather than looked for by name: `values` is + # either an ActiveQuery, which has the method, or a plain sequence of + # values, which cannot have it. to_query_expression = getattr(values, "to_query_expression", None) if callable(to_query_expression): # An ActiveQuery-like object: render as an IN subquery. The @@ -581,7 +474,7 @@ def in_(self: "SQLValueExpression", values: List[Any]) -> "SQLPredicate": return InPredicate(self._dialect, self, Literal(self._dialect, tuple(values))) - def not_in(self: "SQLValueExpression", values: List[Any]) -> "SQLPredicate": + def not_in(self: "SQLValue", values: List[Any]) -> "SQLPredicate": """ Generate a NOT IN predicate for this expression with a list of values. @@ -598,7 +491,7 @@ def not_in(self: "SQLValueExpression", values: List[Any]) -> "SQLPredicate": self._dialect, "NOT", InPredicate(self._dialect, self, Literal(self._dialect, tuple(values))) ) - def between(self: "SQLValueExpression", low: Any, high: Any) -> "SQLPredicate": + def between(self: "SQLValue", low: Any, high: Any) -> "SQLPredicate": """ Generate a BETWEEN predicate for this expression with low and high bounds. @@ -643,31 +536,31 @@ class ArithmeticMixin: >>> complex_expr = (col1 + col2) * 1.1 # Generates: "(price + discount) * ?" """ - def __add__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLValueExpression": + def __add__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLValue": """ Implement the addition operator (+) to generate SQL arithmetic expression. This method enables expressions like: `Column(...) + value` or `Column(...) + Column(...)` - The resulting BinaryArithmeticExpression uses the left operand's dialect for SQL generation. + The resulting ArithmeticOperation uses the left operand's dialect for SQL generation. Args: other: Right operand, can be another expression or a literal value Returns: - SQLValueExpression representing the addition operation + SQLValue representing the addition operation Example: >>> col = Column(dialect, "price") >>> expr = col + 10 # Generates: "price + ?" with params (10,) """ from .core import Literal - from .operators import BinaryArithmeticExpression - from .bases import SQLValueExpression + from .operators import ArithmeticOperation + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, "+", self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, "add", self, other_expr) - def __sub__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLValueExpression": + def __sub__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLValue": """ Implement the subtraction operator (-) to generate SQL arithmetic expression. @@ -675,16 +568,16 @@ def __sub__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) other: Right operand, can be another expression or a literal value Returns: - SQLValueExpression representing the subtraction operation + SQLValue representing the subtraction operation """ from .core import Literal - from .operators import BinaryArithmeticExpression - from .bases import SQLValueExpression + from .operators import ArithmeticOperation + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, "-", self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, "sub", self, other_expr) - def __mul__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLValueExpression": + def __mul__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLValue": """ Implement the multiplication operator (*) to generate SQL arithmetic expression. @@ -692,16 +585,16 @@ def __mul__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) other: Right operand, can be another expression or a literal value Returns: - SQLValueExpression representing the multiplication operation + SQLValue representing the multiplication operation """ from .core import Literal - from .operators import BinaryArithmeticExpression - from .bases import SQLValueExpression + from .operators import ArithmeticOperation + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, "*", self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, "mul", self, other_expr) - def __truediv__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLValueExpression": + def __truediv__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLValue": """Division, rendered as the portable ``/`` — and what ``/`` means is **the backend's**, not this operator's. @@ -732,13 +625,13 @@ def __truediv__(self: "SQLValueExpression", other: Union["SQLValueExpression", A backends rather than a silent property of the model. """ from .core import Literal - from .operators import BinaryArithmeticExpression - from .bases import SQLValueExpression + from .operators import ArithmeticOperation + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, "/", self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, "div", self, other_expr) - def __mod__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) -> "SQLValueExpression": + def __mod__(self: "SQLValue", other: Union["SQLValue", Any]) -> "SQLValue": """ Implement the modulo operator (%) to generate SQL arithmetic expression. @@ -746,14 +639,14 @@ def __mod__(self: "SQLValueExpression", other: Union["SQLValueExpression", Any]) other: Right operand, can be another expression or a literal value Returns: - SQLValueExpression representing the modulo operation + SQLValue representing the modulo operation """ from .core import Literal - from .operators import BinaryArithmeticExpression - from .bases import SQLValueExpression + from .operators import ArithmeticOperation + from .bases import BaseExpression - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, "%", self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, "mod", self, other_expr) class NotANumberMixin: @@ -878,40 +771,36 @@ def __mul__(self, other): # -- classification ------------------------------------------------------ def _is_interval_value(self) -> bool: - from .core import IntervalValueExpression - - return isinstance(self, IntervalValueExpression) + return isinstance(self, IntervalMixin) def _is_interval_operand(self, other) -> bool: - from .bases import SQLValueExpression - from .core import IntervalValueExpression + from .bases import BaseExpression - if isinstance(other, IntervalValueExpression): + if isinstance(other, IntervalMixin): return True - if isinstance(other, SQLValueExpression): + if isinstance(other, BaseExpression): return False return isinstance(other, datetime.timedelta) def _is_temporal_operand(self, other) -> bool: - from .bases import SQLValueExpression - from .core import IntervalValueExpression + from .bases import BaseExpression - if isinstance(other, SQLValueExpression): - # Both a temporal *column* and a temporal *value* carry + if isinstance(other, BaseExpression): + # Both a temporal *column* and a temporal *node* carry # DateTimeMixin; an interval does too, and is excluded first. return isinstance(other, DateTimeMixin) and not isinstance( - other, IntervalValueExpression + other, IntervalMixin ) return isinstance(other, (datetime.date, datetime.time, datetime.datetime)) def _is_number_operand(self, other) -> bool: - from .bases import SQLValueExpression + from .bases import BaseExpression if isinstance(other, bool): return False if isinstance(other, (int, float, decimal.Decimal)): return True - if isinstance(other, SQLValueExpression): + if isinstance(other, BaseExpression): return not self._is_temporal_operand(other) and not self._is_interval_operand(other) return False @@ -947,13 +836,26 @@ def _temporal_binary(self, symbol: str, other, subtracting: bool): symbol, other, "a temporal value combines with an interval, not with a number" ) + #: The Python operator symbols the temporal dunders hand down, mapped to + #: the operation words the node's contract uses. The symbols stay in the + #: refusal messages (they name the Python operator the caller wrote); the + #: node gets the word. + #: + #: The words are :class:`~...expression.operators.ArithmeticOperator` + #: values, written as strings because this table is a class attribute and + #: ``operators`` imports the mixins this one is mixed into -- importing the + #: enum back at module level would be circular. :class:`ArithmeticOperation` + #: normalises the string to the member at construction, so the wire value is + #: unchanged. + _OPERATOR_WORDS = {"+": "add", "-": "sub", "*": "mul"} + def _build(self, symbol: str, other): - from .bases import SQLValueExpression + from .bases import BaseExpression from .core import Literal - from .operators import BinaryArithmeticExpression + from .operators import ArithmeticOperation - other_expr = other if isinstance(other, SQLValueExpression) else Literal(self._dialect, other) - return BinaryArithmeticExpression(self._dialect, symbol, self, other_expr) + other_expr = other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + return ArithmeticOperation(self._dialect, self._OPERATOR_WORDS[symbol], self, other_expr) def _temporal_refusal(self, symbol: str, other, reason: str) -> AttributeError: return AttributeError( @@ -1033,7 +935,7 @@ class _LogicalOperators: The receiver decides the result class, and that is the whole difference: a predicate stays a predicate -- combining predicates is how ``WHERE`` clauses are built -- while a truth *value* keeps its value type - (:class:`~...core.BooleanLogicExpression`), so the result can be aliased + (:class:`~...core.LogicalValue`), so the result can be aliased into a ``SELECT`` list, compared, cast, and combined further. Both render through the same dialect entry point (``format_logical_predicate``). @@ -1052,22 +954,22 @@ def __invert__(self): return self._logical_operation("NOT") def _logical_operation(self, op: str, *others): - from .bases import SQLPredicate, SQLValueExpression + from .bases import BaseExpression, SQLPredicate from .core import Literal from .predicates import LogicalPredicate operands = [ other - if isinstance(other, (SQLValueExpression, SQLPredicate)) + if isinstance(other, BaseExpression) else Literal(self._dialect, other) for other in others ] if isinstance(self, SQLPredicate): return LogicalPredicate(self._dialect, op, self, *operands) - from .core import BooleanLogicExpression + from .core import LogicalValue - return BooleanLogicExpression(self._dialect, op, self, *operands) + return LogicalValue(self._dialect, op, self, *operands) class LogicalMixin(_LogicalOperators): @@ -1113,7 +1015,7 @@ class BooleanLogicMixin(_LogicalOperators): ``AND`` it has no meaning for. Both share the private :class:`_LogicalOperators` implementation and differ only in the result class -- this one keeps the value type - (:class:`~...core.BooleanLogicExpression`), so ``(a & b)`` can be aliased + (:class:`~...core.LogicalValue`), so ``(a & b)`` can be aliased into a ``SELECT`` list, while :class:`LogicalMixin` keeps the predicate. ``is_true`` / ``is_false`` are **not** redeclared here. They already come @@ -1170,113 +1072,92 @@ class ResultTypeMixin: #: rather than ``_dialect``: reading it validates that one is bound. dialect: "SQLDialectBase" - def _retype(self, cls: _ValueExpression[_ResultT]) -> _ResultT: - """Re-wrap this expression as a value expression of *cls*. - - Only the Python class changes. The node, and therefore the SQL, is left - exactly as it was -- the statement was already correct; what was missing - was the knowledge of what it yields. + def _annotate(self, cls) -> _ResultT: + """This expression, declared to yield *cls*. - Generic in the class rather than returning BaseExpression, so the - declared return type of each ``as_*`` method is the class it names. - Returning the base type would leave the checker unable to tell that - ``as_text()`` is text, which is the whole point of the method. + Only the declared type changes. The node underneath -- and therefore the + SQL -- is left exactly as it was, because the statement was already + correct and what was missing was the knowledge of what it yields. - Returns the class it is given, not a base type, so each ``as_*`` method - declares what the chain continues as. + Generic in the class so the declared return type of each ``as_*`` method + is the class it names: returning a base type would leave a checker + unable to tell that ``as_text()`` is text, which is the whole point of + the method. Args: - cls: The expression class that carries those operations. + cls: One of the annotation classes in + :mod:`~...expression.declared`, each of which carries exactly one + surface. Returns: - A value expression of *cls*. + An expression of *cls*. """ return cls(self.dialect, self) - def as_text(self) -> "StringValueExpression": + def as_text(self) -> "TextAnnotation": """This yields text, whatever the data turns out to be.""" - from .core import StringValueExpression + from .declared import TextAnnotation - return self._retype(StringValueExpression) + return self._annotate(TextAnnotation) - def as_number(self) -> "NumericValueExpression": + def as_number(self) -> "NumberAnnotation": """This yields a fractional number.""" - from .core import NumericValueExpression + from .declared import NumberAnnotation - return self._retype(NumericValueExpression) + return self._annotate(NumberAnnotation) - def as_integer(self) -> "IntegerValueExpression": + def as_integer(self) -> "IntegerAnnotation": """This yields a whole number.""" - from .core import IntegerValueExpression + from .declared import IntegerAnnotation - return self._retype(IntegerValueExpression) + return self._annotate(IntegerAnnotation) - def as_datetime(self) -> "TimestampValueExpression": - """This yields a date or a time.""" - from .core import TimestampValueExpression + def as_datetime(self) -> "TemporalAnnotation": + """This yields a date, a time or a date-time.""" + from .declared import TemporalAnnotation - return self._retype(TimestampValueExpression) + return self._annotate(TemporalAnnotation) - def as_boolean(self) -> "BooleanValueExpression": - """This yields a truth value. + def as_boolean(self) -> "BooleanAnnotation": + """This yields a truth value.""" + from .declared import BooleanAnnotation - This names the value, not a projection. SQL Server has no boolean scalar - type at all, so a predicate cannot appear in a select list there; where - one is needed, the dialect has to produce a zero-or-one column. - """ - from .core import BooleanValueExpression - - return self._retype(BooleanValueExpression) + return self._annotate(BooleanAnnotation) - def as_array(self) -> "ArrayValueExpression": + def as_array(self) -> "ArrayAnnotation": """This yields a sequence.""" - from .core import ArrayValueExpression + from .declared import ArrayAnnotation - return self._retype(ArrayValueExpression) + return self._annotate(ArrayAnnotation) - def as_json(self) -> "JSONValueExpression": + def as_json(self) -> "JSONAnnotation": """This yields a JSON document.""" - from .core import JSONValueExpression + from .declared import JSONAnnotation - return self._retype(JSONValueExpression) + return self._annotate(JSONAnnotation) - def as_uuid(self) -> "UUIDValueExpression": + def as_uuid(self) -> "UUIDAnnotation": """This yields a UUID.""" - from .core import UUIDValueExpression + from .declared import UUIDAnnotation - return self._retype(UUIDValueExpression) + return self._annotate(UUIDAnnotation) - def as_binary(self) -> "BinaryValueExpression": + def as_binary(self) -> "BinaryAnnotation": """This yields bytes.""" - from .core import BinaryValueExpression + from .declared import BinaryAnnotation - return self._retype(BinaryValueExpression) + return self._annotate(BinaryAnnotation) - def as_xml(self) -> "XMLValueExpression": + def as_xml(self) -> "XMLAnnotation": """This yields an XML document.""" - from .core import XMLValueExpression - - return self._retype(XMLValueExpression) - - -def _literal(dialect, value): - """Wrap a bare number as the Literal the factories now require. - - The factories take expressions, so a caller who writes ``col.power(3)`` has - to end up with an expression somewhere. Doing it here keeps the arithmetic - methods taking plain numbers, which is what a caller means by them, and puts - the construction in one place instead of at every call site. - """ - from .bases import BaseExpression - from .core import Literal + from .declared import XMLAnnotation - return value if isinstance(value, BaseExpression) else Literal(dialect, value) + return self._annotate(XMLAnnotation) class NumericValueMixin: """Operations on a **fractional**-valued expression. - - Distinct from the integer value family (:class:`IntegerValueExpression`) + Distinct from the integer value family (:class:`IntegerValue`) because ``ceil``/``floor``/``truncate`` of a whole number is a whole number, while ``sqrt``/``log``/``abs`` of one may not be. SQL makes the same distinction: a backend @@ -1300,22 +1181,40 @@ class NumericValueMixin: >>> col.mod(3) # MOD("price", 3) """ - def _numeric_op(self, factory_name: str, *args, **kwargs): - """Call a math factory, which tags its own result type. + @staticmethod + def _number_operand(dialect, value): + """A numeric operand as the value node its host type declares. - Args: - factory_name: Name of the function in ``functions.math``. - args: Positional arguments forwarded to the factory. - kwargs: Keyword arguments forwarded to the factory. + An ``int`` is an integer and a ``Decimal`` is exact, and the two are not + interchangeable: ``MOD(x, 3)`` with a floating-point divisor stops being + an integer modulus. So the host type picks the node rather than one type + standing in for both. An expression is returned untouched. - Returns: - A numeric- or integer-valued expression, depending on what the - operation returns. + One ``Literal`` carries every value, so the host type no longer picks + the node -- it picks the *kind*, which is what serialization writes and + what the dialect's literal construction dispatches on. An expression is + returned untouched: the caller said what it meant. """ - from . import functions as _functions + from .bases import BaseExpression + from .core import Literal - factory = getattr(_functions, factory_name) - return factory(self._dialect, self, *args, **kwargs) + if isinstance(value, BaseExpression): + return value + if isinstance(value, int): + return Literal(dialect, value) + return Literal(dialect, decimal.Decimal(str(value))) + + @staticmethod + def _math_functions(): + """The math factory module, imported on first use. + + Local because ``functions.math`` imports the mixins this one is mixed + into. Handed back as a module so there is one import site per class + rather than one per method. + """ + from .functions import math as _module + + return _module # --- sign-preserving --- @@ -1325,7 +1224,7 @@ def abs(self) -> "NumericValueMixin": Keeps the operand's family: absolute value of an integer is an integer, and of a double is a double. """ - return self._numeric_op("abs_") + return self._math_functions().abs_(self._dialect, self) def sign(self) -> "NumericValueMixin": """Sign of the value: -1, 0 or 1. ``SIGN(col)`` @@ -1337,7 +1236,7 @@ def sign(self) -> "NumericValueMixin": that carries the numeric surface stays usable for the arithmetic that follows it, which an integer-tagged result would forbid. """ - return self._numeric_op("sign") + return self._math_functions().sign(self._dialect, self) # --- rounding --- @@ -1381,15 +1280,15 @@ def round(self, decimals: Optional[int] = None) -> "NumericValueMixin": inherit whichever one the server, its version and the column type happen to give. """ - return self._numeric_op("round_", decimals) + return self._math_functions().round_(self._dialect, self, decimals) def ceil(self) -> "NumericValueMixin": """Smallest integer not below the value. ``CEIL(col)``""" - return self._numeric_op("ceil") + return self._math_functions().ceil(self._dialect, self) def floor(self) -> "NumericValueMixin": """Largest integer not above the value. ``FLOOR(col)``""" - return self._numeric_op("floor") + return self._math_functions().floor(self._dialect, self) def truncate(self, precision: Optional[int] = None) -> "NumericValueMixin": """Truncate toward zero. ``TRUNCATE(col[, n])`` @@ -1405,13 +1304,13 @@ def truncate(self, precision: Optional[int] = None) -> "NumericValueMixin": ``ROUND(x, n, 1)``. A dialect override therefore renames rather than re-implements. """ - return self._numeric_op("truncate", precision) + return self._math_functions().truncate(self._dialect, self, precision) # --- roots, powers, logarithms --- def mod(self, divisor: Union[int, float, "BaseExpression"]) -> "NumericValueMixin": """Remainder of division by *divisor*. ``MOD(col, n)``""" - return self._numeric_op("mod", _literal(self._dialect, divisor)) + return self._math_functions().mod(self._dialect, self, self._number_operand(self._dialect, divisor)) def sqrt(self) -> "NumericValueMixin": """Square root. ``SQRT(col)`` @@ -1421,7 +1320,7 @@ def sqrt(self) -> "NumericValueMixin": (an interest factor), which is why this mixin is the part a money column can reuse. """ - return self._numeric_op("sqrt") + return self._math_functions().sqrt(self._dialect, self) def power(self, exponent: Union[int, float, "BaseExpression"]) -> "NumericValueMixin": """Raise to *exponent*. ``POWER(col, n)`` @@ -1430,7 +1329,7 @@ def power(self, exponent: Union[int, float, "BaseExpression"]) -> "NumericValueM belongs with the algebraic operations rather than the transcendental ones. """ - return self._numeric_op("power", _literal(self._dialect, exponent)) + return self._math_functions().power(self._dialect, self, self._number_operand(self._dialect, exponent)) class TranscendentalMixin: @@ -1462,22 +1361,40 @@ class TranscendentalMixin: >>> col.sin() # SIN("x") """ - def _transcendental_op(self, factory_name: str, *args, **kwargs): - """Call a math factory, which tags its own result type. + @staticmethod + def _math_functions(): + """The math factory module, imported on first use. - Args: - factory_name: Name of the function in ``functions.math``. - args: Positional arguments forwarded to the factory. - kwargs: Keyword arguments forwarded to the factory. + Local because ``functions.math`` imports the mixins this one is mixed + into. Handed back as a module so there is one import site per class + rather than one per method. + """ + from .functions import math as _module - Returns: - A real-valued expression; none of these preserves the operand's - family. + return _module + + @staticmethod + def _number_operand(dialect, value): + """A numeric operand as the value node its host type declares. + + An ``int`` is an integer and a ``Decimal`` is exact, and the two are not + interchangeable: ``MOD(x, 3)`` with a floating-point divisor stops being + an integer modulus. So the host type picks the node rather than one type + standing in for both. An expression is returned untouched. + + One ``Literal`` carries every value, so the host type no longer picks + the node -- it picks the *kind*, which is what serialization writes and + what the dialect's literal construction dispatches on. An expression is + returned untouched: the caller said what it meant. """ - from . import functions as _functions + from .bases import BaseExpression + from .core import Literal - factory = getattr(_functions, factory_name) - return factory(self._dialect, self, *args, **kwargs) + if isinstance(value, BaseExpression): + return value + if isinstance(value, int): + return Literal(dialect, value) + return Literal(dialect, decimal.Decimal(str(value))) # --- roots, powers, logarithms --- # @@ -1485,7 +1402,7 @@ def _transcendental_op(self, factory_name: str, *args, **kwargs): def exp(self) -> "NumericValueMixin": """e raised to the power of the value. ``EXP(col)``""" - return self._transcendental_op("exp") + return self._math_functions().exp(self._dialect, self) def log(self, base: Optional[Union[int, float, "BaseExpression"]] = None) -> "NumericValueMixin": """Natural logarithm, or logarithm to *base*. ``LOG(col[, base])`` @@ -1499,32 +1416,31 @@ def log(self, base: Optional[Union[int, float, "BaseExpression"]] = None) -> "Nu and Snowflake's. The meaning is fixed here; a dialect override swaps the spelling or the argument order, never the semantics. """ - base_expr = None if base is None else _literal(self._dialect, base) - return self._transcendental_op("log", base_expr) + base_expr = None if base is None else self._number_operand(self._dialect, base) + return self._math_functions().log(self._dialect, self, base_expr) # --- trigonometry --- def sin(self) -> "NumericValueMixin": """Sine. ``SIN(col)``""" - return self._transcendental_op("sin") + return self._math_functions().sin(self._dialect, self) def cos(self) -> "NumericValueMixin": """Cosine. ``COS(col)``""" - return self._transcendental_op("cos") + return self._math_functions().cos(self._dialect, self) def tan(self) -> "NumericValueMixin": """Tangent. ``TAN(col)``""" - return self._transcendental_op("tan") + return self._math_functions().tan(self._dialect, self) class StringToIntegerMixin: """Operations that read a **string** and produce an integer. - Named for both ends of the direction: these accept a string and *return* a number. ``"name".length()`` is a legal string operation whose result is a number, and that number must not carry string operations — ``LENGTH(name).upper()`` is a type error in every backend; putting the - result in its own family (:class:`IntegerValueExpression`) is what makes + result in its own family (:class:`IntegerValue`) is what makes the rule checkable instead of a convention. Because the receiver is a string, the mixin is attached to the string classes only: an integer never grows these methods, so ``LENGTH(name).length()`` cannot be constructed. @@ -1535,9 +1451,20 @@ class StringToIntegerMixin: """ if TYPE_CHECKING: # pragma: no cover - from .core import IntegerValueExpression + from .core import IntegerValue + + @staticmethod + def _string_functions(): + """The string factory module, imported on first use. + + Local because ``functions.string`` imports the mixins this one is mixed + into. + """ + from .functions import string as _module + + return _module - def position(self, substring: str) -> "IntegerValueExpression": + def position(self, substring: str) -> "BaseExpression": """1-based position of *substring*. ``POSITION(substring IN expr)`` The needle comes first here, matching the standard spelling, while @@ -1548,28 +1475,11 @@ def position(self, substring: str) -> "IntegerValueExpression": ClickHouse and BigQuery put the haystack first, MySQL and MariaDB ``LOCATE``, Oracle and Firebird ``INSTR``. """ - from .functions import string as _string - - return _string.position(self._dialect, substring, self) + return self._string_functions().position(self._dialect, substring, self) # --- operations that read a string and produce a number --- - def _integer_op(self, factory_name: str, *args): - """Call a string factory whose result is an integer. - - Args: - factory_name: Name of the function in ``functions.string``. - args: Positional arguments forwarded to the factory. - - Returns: - An :class:`IntegerValueExpression`. - """ - from . import functions as _functions - - factory = getattr(_functions, factory_name) - return factory(self._dialect, self, *args) - - def length(self) -> "IntegerValueExpression": + def length(self) -> "BaseExpression": """Length of the string, in **whatever unit the backend's ``LENGTH`` uses**. ``LENGTH(expr)`` — and that unit is not characters everywhere, which is @@ -1603,28 +1513,28 @@ def length(self) -> "IntegerValueExpression": literal, where the framework refuses the non-portable spelling instead of rendering it and hoping. """ - return self._integer_op("length") + return self._string_functions().length(self._dialect, self) - def ascii(self) -> "IntegerValueExpression": + def ascii(self) -> "BaseExpression": """Code of the first character. ``ASCII(expr)``""" - return self._integer_op("ascii") + return self._string_functions().ascii(self._dialect, self) - def octet_length(self) -> "IntegerValueExpression": + def octet_length(self) -> "BaseExpression": """Length in bytes. ``OCTET_LENGTH(expr)``""" - return self._integer_op("octet_length") + return self._string_functions().octet_length(self._dialect, self) - def bit_length(self) -> "IntegerValueExpression": + def bit_length(self) -> "BaseExpression": """Length in bits. ``BIT_LENGTH(expr)``""" - return self._integer_op("bit_length") + return self._string_functions().bit_length(self._dialect, self) - def strpos(self, substring: str) -> "IntegerValueExpression": + def strpos(self, substring: str) -> "BaseExpression": """1-based position of *substring*, or 0. ``STRPOS(expr, substring)`` The mirror spelling of :meth:`position`: haystack first, as every native ``STRPOS``/``LOCATE``/``INSTR``/``CHARINDEX`` takes it. On backends with no such function a dialect renders the other spelling. """ - return self._integer_op("strpos", substring) + return self._string_functions().strpos(self._dialect, self, substring) class StringValueMixin: @@ -1647,43 +1557,40 @@ class StringValueMixin: >>> col.length() > 5 # LENGTH("name") > ? """ - def _string_op(self, factory_name: str, *args, **kwargs): - """Call a string factory and tag the result as a string value. - Args: - factory_name: Name of the function in ``functions.string``. - args: Positional arguments forwarded to the factory. - kwargs: Keyword arguments forwarded to the factory. + # --- case --- - Returns: - A :class:`~...expression.core.StringValueExpression`. - """ - from . import functions as _functions + @staticmethod + def _string_functions(): + """The string factory module, imported on first use. - factory = getattr(_functions, factory_name) - return factory(self._dialect, self, *args, **kwargs) + A local import because ``functions.string`` imports the mixins this one + is mixed into. Handing back the module (rather than each factory) keeps + one import site instead of twelve. + """ + from .functions import string as _module - # --- case --- + return _module def upper(self) -> "StringValueMixin": """Upper-case the string. ``UPPER(col)``""" - return self._string_op("upper") + return self._string_functions().upper(self._dialect, self) def lower(self) -> "StringValueMixin": """Lower-case the string. ``LOWER(col)``""" - return self._string_op("lower") + return self._string_functions().lower(self._dialect, self) def initcap(self) -> "StringValueMixin": """Capitalise the first letter of each word. ``INITCAP(col)``""" - return self._string_op("initcap") + return self._string_functions().initcap(self._dialect, self) def reverse(self) -> "StringValueMixin": """Reverse the string. ``REVERSE(col)``""" - return self._string_op("reverse") + return self._string_functions().reverse(self._dialect, self) def translate(self, from_chars: str, to_chars: str) -> "StringValueMixin": """Replace characters pairwise. ``TRANSLATE(col, from, to)``""" - return self._string_op("translate", from_chars, to_chars) + return self._string_functions().translate(self._dialect, self, from_chars, to_chars) # --- slicing and padding --- @@ -1695,7 +1602,7 @@ def substr(self, start: int, length: Optional[int] = None) -> "StringValueMixin" yields an empty string on others and is read as 1 on Oracle (see :func:`~...functions.string.substring`). """ - return self._string_op("substring", start, length) + return self._string_functions().substring(self._dialect, self, start, length) def left(self, n: int) -> "StringValueMixin": """First *n* characters. ``LEFT(col, n)`` @@ -1705,7 +1612,7 @@ def left(self, n: int) -> "StringValueMixin": the backends answer four different ways to it (see :func:`~...functions.string.left`). """ - return self._string_op("left", n) + return self._string_functions().left(self._dialect, self, n) def right(self, n: int) -> "StringValueMixin": """Last *n* characters. ``RIGHT(col, n)`` @@ -1713,7 +1620,7 @@ def right(self, n: int) -> "StringValueMixin": ``n >= 0``, with 0 and over-length agreeing everywhere; a negative count is refused, same as :meth:`left`. """ - return self._string_op("right", n) + return self._string_functions().right(self._dialect, self, n) def lpad(self, length: int, pad: Optional[str] = None) -> "StringValueMixin": """Left-pad to *length*. ``LPAD(col, length, pad)`` @@ -1723,7 +1630,7 @@ def lpad(self, length: int, pad: Optional[str] = None) -> "StringValueMixin": MariaDB fills with spaces. An empty pad is refused -- the backends answer it as untouched string, empty string or NULL. """ - return self._string_op("lpad", length, pad) + return self._string_functions().lpad(self._dialect, self, length, pad) def rpad(self, length: int, pad: Optional[str] = None) -> "StringValueMixin": """Right-pad to *length*. ``RPAD(col, length, pad)`` @@ -1731,7 +1638,7 @@ def rpad(self, length: int, pad: Optional[str] = None) -> "StringValueMixin": The mirror of :meth:`lpad`: ``length >= 0``, a non-empty *pad*, an omitted pad spelled as one space, an empty pad refused. """ - return self._string_op("rpad", length, pad) + return self._string_functions().rpad(self._dialect, self, length, pad) def repeat(self, count: int) -> "StringValueMixin": """Repeat the string *count* times. ``REPEAT(col, count)`` @@ -1739,17 +1646,17 @@ def repeat(self, count: int) -> "StringValueMixin": A negative *count* is the empty string rather than a refusal -- the native answer on PostgreSQL, MySQL, MariaDB and ClickHouse alike. """ - return self._string_op("repeat", count) + return self._string_functions().repeat(self._dialect, self, count) def overlay(self, replacement: str, start: int, length: Optional[int] = None) -> "StringValueMixin": """Overwrite a span. ``OVERLAY(col PLACING replacement FROM start [FOR length])``""" - return self._string_op("overlay", replacement, start, length) + return self._string_functions().overlay(self._dialect, self, replacement, start, length) # --- search and replace --- def replace(self, pattern: str, replacement: str) -> "StringValueMixin": """Replace every occurrence. ``REPLACE(col, pattern, replacement)``""" - return self._string_op("replace", pattern, replacement) + return self._string_functions().replace(self._dialect, self, pattern, replacement) # --- whitespace --- @@ -1765,7 +1672,7 @@ def trim(self, chars: Optional[str] = None, direction: str = "BOTH") -> "StringV and ``ORA-30001`` on Oracle. direction: One of ``BOTH`` (default), ``LEADING``, ``TRAILING``. """ - return self._string_op("trim", chars=chars, direction=direction) + return self._string_functions().trim(self._dialect, self, chars=chars, direction=direction) # --- combination --- @@ -1777,11 +1684,14 @@ def concat(self, *others) -> "StringValueMixin": spelling offered here. """ from .functions import string as _string + from .bases import BaseExpression from .core import Literal operands = [self] for other in others: - operands.append(other if hasattr(other, "to_sql") else Literal(self._dialect, other)) + operands.append( + other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + ) return _string.concat(self._dialect, *operands) def concat_using_operator(self, *others) -> "StringValueMixin": @@ -1796,23 +1706,27 @@ def concat_using_operator(self, *others) -> "StringValueMixin": it renders the same thing but reads more clearly at the call site. """ from .functions import string as _string + from .bases import BaseExpression from .core import Literal operands = [self] for other in others: operands.append( - other if hasattr(other, "to_sql") else Literal(self._dialect, other) + other if isinstance(other, BaseExpression) else Literal(self._dialect, other) ) return _string.concat_op(self._dialect, *operands) def coalesce(self, *others) -> "StringValueMixin": """First non-null value. ``COALESCE(col, ...)``""" from .functions import string as _string + from .bases import BaseExpression from .core import Literal operands = [self] for other in others: - operands.append(other if hasattr(other, "to_sql") else Literal(self._dialect, other)) + operands.append( + other if isinstance(other, BaseExpression) else Literal(self._dialect, other) + ) return _string.coalesce(self._dialect, *operands) @@ -1842,7 +1756,7 @@ class StringPatternPredicateMixin: >>> both = col.like("A%") & (col.ilike("%x%") | col.like("B%")) """ - def like(self: "SQLValueExpression", pattern: str) -> "SQLPredicate": + def like(self: "SQLValue", pattern: str) -> "SQLPredicate": """ Generate a LIKE predicate for pattern matching. @@ -1870,11 +1784,13 @@ def like(self: "SQLValueExpression", pattern: str) -> "SQLPredicate": >>> # Generates: "name LIKE ?" with params ("John%",) """ from .core import Literal - from .predicates import LikePredicate + from .predicates import LikeOperator, LikePredicate - return LikePredicate(self._dialect, "LIKE", self, Literal(self._dialect, pattern)) + return LikePredicate( + self._dialect, LikeOperator.LIKE, self, Literal(self._dialect, pattern) + ) - def ilike(self: "SQLValueExpression", pattern: str) -> "SQLPredicate": + def ilike(self: "SQLValue", pattern: str) -> "SQLPredicate": """ Generate a case-insensitive pattern-matching predicate. @@ -1899,9 +1815,11 @@ def ilike(self: "SQLValueExpression", pattern: str) -> "SQLPredicate": >>> # Generates: "email ILIKE ?" with params ("%@gmail.com",) """ from .core import Literal - from .predicates import LikePredicate + from .predicates import LikeOperator, LikePredicate - return LikePredicate(self._dialect, "ILIKE", self, Literal(self._dialect, pattern)) + return LikePredicate( + self._dialect, LikeOperator.ILIKE, self, Literal(self._dialect, pattern) + ) class TypeCastingMixin: @@ -1927,7 +1845,7 @@ class TypeCastingMixin: >>> # PostgreSQL generates: "price"::money::numeric::float8 """ - def cast(self: "SQLValueExpression", target_type: str) -> "CastExpression": + def cast(self: "SQLValue", target_type: str) -> "CastExpression": """Wrap this expression in a CAST node targeting *target_type*. Args: @@ -1952,13 +1870,35 @@ def cast(self: "SQLValueExpression", target_type: str) -> "CastExpression": # both render `CAST(col AS INTEGER) AS v`. The original expression # is only mutated when it is itself a temporary copy (the as_() # convention returns copies), so shared nodes stay untouched. - alias = getattr(self, "alias", None) + alias = self.alias if isinstance(self, AliasableMixin) else None node = CastExpression(self._dialect, self, target_type, alias=alias) - if alias is not None and hasattr(self, "alias"): + if alias is not None: self.alias = None return node +class IntervalMixin: + """Marks a value as a **span** of time rather than a **point** in it. + + A date, a time and a date-time are points: they sit somewhere on the + timeline. An interval is the distance between two of them, and arithmetic + on the two is not the same -- a point moves by an interval, a point minus a + point is an interval, but two points have no sum and an interval scales by a + number rather than being added to. + + So the distinction has to be stated somewhere the arithmetic can read, and + this is that statement. It carries no operations of its own: an interval + does everything a temporal value does, plus this one fact about which it + is. + + It exists because ``timestamp + interval`` and ``interval + interval`` are + different operations and the gate that refuses the invalid ones has to know + which it is holding. While an interval was wrapped in a value class the gate + could ask about the wrapper; now that an interval *is* a node, the node says + so by being one of these. + """ + + class DateTimeMixin: """Temporal operations available on a value known to hold a date/time. @@ -2035,9 +1975,9 @@ def _temporal_node(self, node_class, *args, **kwargs): receiver is only mutated when it is itself a temporary copy (the ``as_()`` convention returns copies). """ - alias = getattr(self, "alias", None) + alias = self.alias if isinstance(self, AliasableMixin) else None node = node_class(self._dialect, *args, alias=alias, **kwargs) - if alias is not None and hasattr(self, "alias"): + if alias is not None: self.alias = None return node @@ -2059,27 +1999,27 @@ def date_trunc(self, field: str) -> "DateTruncExpression": return self._temporal_node(DateTruncExpression, field, self) - def date_add(self, value, unit: Optional[str] = None) -> "DateTimeAddExpression": + def date_add(self, value, unit: Optional[str] = None) -> "DatetimeAddExpression": """Add an interval, e.g. ``col.date_add(7, "day")``.""" - from .datetime import DateTimeAddExpression + from .datetime import DatetimeAddExpression return self._temporal_node( - DateTimeAddExpression, self, _as_interval(self._dialect, value, unit) + DatetimeAddExpression, self, _as_interval(self._dialect, value, unit) ) - def date_sub(self, value, unit: Optional[str] = None) -> "DateTimeSubtractExpression": + def date_sub(self, value, unit: Optional[str] = None) -> "DatetimeSubtractExpression": """Subtract an interval, e.g. ``col.date_sub(1, "month")``.""" - from .datetime import DateTimeSubtractExpression + from .datetime import DatetimeSubtractExpression return self._temporal_node( - DateTimeSubtractExpression, self, _as_interval(self._dialect, value, unit) + DatetimeSubtractExpression, self, _as_interval(self._dialect, value, unit) ) - def date_diff(self, unit: str, end) -> "DateTimeDiffExpression": + def date_diff(self, unit: str, end) -> "DatetimeDiffExpression": """Difference between this value and *end*, expressed in *unit*.""" - from .datetime import DateTimeDiffExpression + from .datetime import DatetimeDiffExpression - return self._temporal_node(DateTimeDiffExpression, unit, self, end) + return self._temporal_node(DatetimeDiffExpression, unit, self, end) def _as_interval(dialect, value, unit: Optional[str]): @@ -2124,7 +2064,7 @@ class JSONAccessorMixin: """Path access for a value known to hold JSON. Mixed into **both** :class:`~...expression.column_types.JSONColumn` and - :class:`~...expression.advanced_functions.JSONDocumentExpression` — the latter is + :class:`~...expression.advanced_functions.JSONDocumentValue` — the latter is what :meth:`json_path` returns, so without it on both, chaining ``col.json_value("a").json_value("b")`` would break at the second call. @@ -2145,17 +2085,17 @@ class JSONAccessorMixin: >>> col.json_value("a").json_value("b") # -> "settings"->'$.a'->'$.b' """ - def _json_path(self, keys, as_text: bool, mode) -> "SQLValueExpression": + def _json_path(self, keys, as_text: bool, mode) -> "SQLValue": from .advanced_functions import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) - alias = getattr(self, "alias", None) + alias = self.alias if isinstance(self, AliasableMixin) else None # The two operations yield different types, so they build different # nodes: `->>` is text and offers the text operations, `->` is a # document and offers the path accessors again. - node_class = JSONTextExpression if as_text else JSONDocumentExpression + node_class = JSONTextValue if as_text else JSONDocumentValue node = node_class( self._dialect, self, @@ -2164,11 +2104,11 @@ def _json_path(self, keys, as_text: bool, mode) -> "SQLValueExpression": alias=alias, mode=mode, ) - if alias is not None and hasattr(self, "alias"): + if alias is not None: self.alias = None return node - def json_path(self, *keys, mode=None) -> "JSONTextExpression": + def json_path(self, *keys, mode=None) -> "JSONTextValue": """Extract a scalar at *keys* (renders ``->>``). Args: @@ -2183,7 +2123,7 @@ def json_path(self, *keys, mode=None) -> "JSONTextExpression": """ return self._json_path(keys, as_text=True, mode=mode) - def json_value(self, *keys, mode=None) -> "JSONDocumentExpression": + def json_value(self, *keys, mode=None) -> "JSONDocumentValue": """Extract JSON at *keys* (renders ``->``), preserving the JSON type. Chainable: the result is itself a JSON expression, so further diff --git a/src/rhosocial/activerecord/backend/expression/objects/base.py b/src/rhosocial/activerecord/backend/expression/objects/base.py index c7dda3a38..016acf95c 100644 --- a/src/rhosocial/activerecord/backend/expression/objects/base.py +++ b/src/rhosocial/activerecord/backend/expression/objects/base.py @@ -112,9 +112,9 @@ def __init__( self.name = name self.catalog_name = catalog_name self.schema_name = schema_name - self.catalog_need_quote = catalog_need_quote - self.schema_need_quote = schema_need_quote - self.name_need_quote = name_need_quote + self.catalog_need_quote = self.quote_flag(catalog_need_quote, "catalog_need_quote") + self.schema_need_quote = self.quote_flag(schema_need_quote, "schema_need_quote") + self.name_need_quote = self.quote_flag(name_need_quote, "name_need_quote") def __repr__(self) -> str: parts = [p for p in (self.catalog_name, self.schema_name, self.name) if p] diff --git a/src/rhosocial/activerecord/backend/expression/operators.py b/src/rhosocial/activerecord/backend/expression/operators.py index 79d2e11d2..b5943cfeb 100644 --- a/src/rhosocial/activerecord/backend/expression/operators.py +++ b/src/rhosocial/activerecord/backend/expression/operators.py @@ -8,8 +8,10 @@ dialect through the subtree, and hands it to the declared formatter. """ -from typing import Any, List, Optional, Tuple, TYPE_CHECKING -from .bases import BaseExpression, SQLPredicate, SQLQueryAndParams, SQLValueExpression +from enum import Enum +from typing import TYPE_CHECKING +from .bases import BaseExpression, SQLPredicate, SQLQueryAndParams, SQLValue +from .core import Literal from .mixins import ( AliasableMixin, ArithmeticMixin, @@ -44,7 +46,7 @@ class BinaryExpression(BaseExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_binary_operator" + return "format_binary_expression" def __init__(self, dialect: "SQLDialectBase", op: str, left: "BaseExpression", right: "BaseExpression"): super().__init__(dialect) @@ -53,12 +55,12 @@ def __init__(self, dialect: "SQLDialectBase", op: str, left: "BaseExpression", r self.right = right -class StringConcatExpression( +class ConcatenationOperation( AliasableMixin, ComparisonMixin, StringPatternPredicateMixin, StringValueMixin, - BinaryExpression, + SQLValue, ): """String concatenation, stated as an intent rather than an operator. @@ -70,8 +72,11 @@ class StringConcatExpression( emitting ``a || b`` there yields a boolean where a string was meant — a wrong answer rather than a syntax error, which is the worst kind. - Carrying the intent on the node lets each dialect pick its own spelling: - :meth:`format_string_concatenation` emits ``a || b`` by default and + The intent lives on the node's kind (``concatenation_operation``), so the + node carries no operator parameter at all: its only construction + parameters are ``left`` and ``right``. Carrying the intent on the kind + lets each dialect pick its own spelling: + :meth:`format_concatenation_operation` emits ``a || b`` by default and ``CONCAT(a, b)`` where ``||`` means something else. Passing a bare ``||`` through :class:`BinaryExpression` cannot work, because the token alone does not say which of the two meanings was meant. @@ -85,10 +90,17 @@ class StringConcatExpression( ``UPPER(col || other)``. """ + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_concatenation_operation" + def __init__( self, dialect: "SQLDialectBase", left: "BaseExpression", right: "BaseExpression" ): - super().__init__(dialect, "||", left, right) + super().__init__(dialect) + self.left = left + self.right = right class UnaryExpression(BaseExpression): @@ -97,7 +109,7 @@ class UnaryExpression(BaseExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_unary_operator" + return "format_unary_expression" def __init__(self, dialect: "SQLDialectBase", op: str, operand: "BaseExpression", pos: str = "before"): super().__init__(dialect) @@ -106,21 +118,49 @@ def __init__(self, dialect: "SQLDialectBase", op: str, operand: "BaseExpression" self.pos = pos -class RawSQLExpression(ArithmeticMixin, ComparisonMixin, StringPatternPredicateMixin, SQLValueExpression): +def _bind_values(dialect: "SQLDialectBase", params) -> tuple: + """Bind values as :class:`~.core.Literal` nodes, one per placeholder. + + A raw value cannot survive a round-trip. ``b"\\x00"`` and ``""`` are the same + thing on the wire, and only the value's *kind* tells them apart -- so the + values a raw statement binds are the one place a non-JSON type is allowed, + and they are allowed only because a node names what they are. + + The caller may pass the bare value or an already-built ``Literal``; both + arrive as the same thing. Passing an empty sequence yields an empty tuple, + so a statement with no placeholders has nothing to unwrap at render time. + """ + return tuple(p if isinstance(p, Literal) else Literal(dialect, p) + for p in (params or ())) + + +class RawSQL(ArithmeticMixin, ComparisonMixin, StringPatternPredicateMixin, SQLValue): """Represents a raw SQL expression string that is directly embedded. + This is the escape hatch, and the name dropped ``Expression`` on purpose: + a raw string is not an expression the library can reason about, so the + bare ``RawSQL`` says what it is -- text the caller vouched for. + Note: This class should be used with caution. It bypasses the normal expression building mechanism and directly embeds raw SQL. This can lead to SQL injection vulnerabilities if user input is not properly sanitized. It should only be used when the expression is completely trusted or properly validated. Difference from RawSQLPredicate: - - RawSQLExpression inherits from SQLValueExpression and represents a value expression + - RawSQL inherits from SQLValue and represents a value expression (e.g., column, function call, literal) - RawSQLPredicate inherits from SQLPredicate and represents a boolean predicate (e.g., condition in WHERE clause) + + Not admitted to the framework's default registry. ``expression`` is text + the caller vouched for and ``to_sql()`` emits it verbatim, so a reader that + resolved ``raw_sql`` out of an untrusted artifact would rebuild a node whose + SQL the artifact chose. A caller whose artifacts it trusts can register the + class itself; see :attr:`~...bases.BaseExpression.REGISTER_BY_DEFAULT`. """ + REGISTER_BY_DEFAULT = False + @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" @@ -129,7 +169,7 @@ def format_method(self) -> str: def __init__(self, dialect: "SQLDialectBase", expression: str, params: tuple = ()): super().__init__(dialect) self.expression = expression - self.params = tuple(params) if params else () + self.params = _bind_values(dialect, params) class RawSQLPredicate(SQLPredicate): @@ -140,13 +180,20 @@ class RawSQLPredicate(SQLPredicate): vulnerabilities if user input is not properly sanitized. It should only be used when the predicate is completely trusted or properly validated. - Difference from RawSQLExpression: - - RawSQLExpression inherits from SQLValueExpression and represents a value expression + Difference from RawSQL: + - RawSQL inherits from SQLValue and represents a value expression (e.g., column, function call, literal) - RawSQLPredicate inherits from SQLPredicate and represents a boolean predicate (e.g., condition in WHERE clause) + + Not admitted to the framework's default registry, for the same reason as + :class:`RawSQL`: rebuilding this node from an artifact would let the + artifact choose the predicate text. See + :attr:`~...bases.BaseExpression.REGISTER_BY_DEFAULT`. """ + REGISTER_BY_DEFAULT = False + @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" @@ -155,42 +202,108 @@ def format_method(self) -> str: def __init__(self, dialect: "SQLDialectBase", expression: str, params: tuple = ()): super().__init__(dialect) self.expression = expression - self.params = tuple(params) if params else () + self.params = _bind_values(dialect, params) + + +class ArithmeticOperator(str, Enum): + """The five binary arithmetic operations, as the wire word form. + ``str`` so a member *is* the word it carries. :attr:`ArithmeticOperation.op` + is one of these, and the character the rendered SQL writes is a per-member + property -- a symbol in the artifact would pin one language's spelling into + a language-neutral payload. + """ + + ADD = "add" + SUB = "sub" + MUL = "mul" + DIV = "div" + MOD = "mod" -class BinaryArithmeticExpression( + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the rendered SQL and the + #: artifact would carry the Python spelling. ``(str, Enum)`` alone does not + #: give that -- ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + @property + def symbol(self) -> str: + """The character the standard spelling writes. Never in the artifact.""" + return _ARITHMETIC_SYMBOLS[self] + + +#: Operation word -> the character the standard spelling writes. +_ARITHMETIC_SYMBOLS = { + ArithmeticOperator.ADD: "+", + ArithmeticOperator.SUB: "-", + ArithmeticOperator.MUL: "*", + ArithmeticOperator.DIV: "/", + ArithmeticOperator.MOD: "%", +} + + +class ArithmeticOperation( AliasableMixin, ArithmeticMixin, NumericValueMixin, ComparisonMixin, - TypeCastingMixin, SQLValueExpression + TypeCastingMixin, SQLValue ): - """Represents a binary arithmetic operation.""" + """A binary arithmetic operation: ``add`` / ``sub`` / ``mul`` / ``div`` / ``mod``. + + The node names the *operation*, and the kind follows the class name + (``arithmetic_operation``). ``op`` carries the operation as a word, not as + the character one backend's lexer writes: ``add``/``sub``/``mul``/``div``/ + ``mod`` are the wire forms, and the dialect formatter maps each word to + the character the rendered SQL writes (:attr:`OPERATOR_SYMBOLS`). A symbol + in the artifact would pin one language's spelling into a language-neutral + payload; the word does not. + + The word set is closed and checked at construction — an unknown word is + refused where it is written, not rendered into whatever the formatter + falls back to. + + Parenthesization follows the operator-precedence table carried on this + class (``OPERATOR_PRECEDENCE``), keyed by the same words. + """ + + #: The character each operation word writes. The formatter reads it from + #: here; the artifact never sees it. Keyed by the enum, so a word that is + #: not one of the five cannot reach the map. + OPERATOR_SYMBOLS = {op: op.symbol for op in ArithmeticOperator} @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_binary_arithmetic_expression" + return "format_arithmetic_operation" def __init__( self, dialect: "SQLDialectBase", - op: str, - left: "SQLValueExpression", - right: "SQLValueExpression", - *, - alias: "Optional[str]" = None, + op: ArithmeticOperator, + left: "SQLValue", + right: "SQLValue", ): + try: + self.op = ( + ArithmeticOperator(op) + if isinstance(op, ArithmeticOperator) + else ArithmeticOperator(str(op).lower()) + ) + except ValueError: + raise ValueError( + f"unknown arithmetic operation {op!r}: the operation words are " + f"{sorted(o.value for o in ArithmeticOperator)}" + ) from None super().__init__(dialect) - self.op = op self.left = left self.right = right - self.alias = alias # Operator precedence levels for arithmetic operations (lower number - # means lower precedence). The formatting function reads this map from - # the expression class to decide parenthesization. + # means lower precedence), keyed by the operation words. The formatting + # function reads this map from the expression class to decide + # parenthesization. OPERATOR_PRECEDENCE = { - "+": 9, - "-": 9, # Addition and subtraction - "*": 10, - "/": 10, - "%": 10, # Multiplication, division, modulo + ArithmeticOperator.ADD: 9, + ArithmeticOperator.SUB: 9, + ArithmeticOperator.MUL: 10, + ArithmeticOperator.DIV: 10, + ArithmeticOperator.MOD: 10, } diff --git a/src/rhosocial/activerecord/backend/expression/pivot.py b/src/rhosocial/activerecord/backend/expression/pivot.py index aea10dc4b..2b270fb6b 100644 --- a/src/rhosocial/activerecord/backend/expression/pivot.py +++ b/src/rhosocial/activerecord/backend/expression/pivot.py @@ -8,8 +8,15 @@ PIVOT (agg(value_column) FOR pivot_column IN (v1, v2, ...)) [alias] UNPIVOT [INCLUDE|EXCLUDE NULLS] (value_column FOR pivot_column IN (c1, c2)) [alias] -Dialects override ``format_pivot_value`` / ``format_pivot_expression`` / -``format_unpivot_expression`` for their own extensions. +The ``*Expression`` suffix is dropped on purpose: SQL writes these two in one +word and there is nothing left over for a suffix to say, so the spec's naming +table records the core spelling as ``Pivot`` / ``Unpivot``. They are still +nodes with a formatter, not clause wrappers -- ``format_pivot_value`` renders +one member of an ``IN (...)`` list and is deliberately not renamed, since it is +a value helper rather than a node formatter. + +Dialects override ``format_pivot_value`` / ``format_pivot`` / +``format_unpivot`` for their own extensions. """ from typing import List, Optional, Union, TYPE_CHECKING @@ -19,11 +26,11 @@ from ..dialect import SQLDialectBase -class PivotExpression(BaseExpression): +class Pivot(BaseExpression): """A PIVOT clause expression (row-to-column transformation). Example: - >>> PivotExpression( + >>> Pivot( ... dialect, "SUM", "amount", "month", ["Jan", "Feb"], alias="p" ... ) """ @@ -49,10 +56,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_pivot_expression" + return "format_pivot" -class UnpivotExpression(BaseExpression): +class Unpivot(BaseExpression): """An UNPIVOT clause expression (column-to-row transformation). ``include_nulls`` / ``exclude_nulls`` are the two spellings of the null @@ -83,4 +90,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_unpivot_expression" + return "format_unpivot" diff --git a/src/rhosocial/activerecord/backend/expression/predicates.py b/src/rhosocial/activerecord/backend/expression/predicates.py index 37e0f2b45..3db12e58a 100644 --- a/src/rhosocial/activerecord/backend/expression/predicates.py +++ b/src/rhosocial/activerecord/backend/expression/predicates.py @@ -7,26 +7,133 @@ ``BaseExpression.to_sql()``. """ +from enum import Enum from typing import TYPE_CHECKING, Any from .bases import BaseExpression, SQLPredicate if TYPE_CHECKING: # pragma: no cover - from .bases import SQLValueExpression from ..dialect import SQLDialectBase + from .bases import SQLValue + + +class ComparisonOperator(str, Enum): + """The six comparison operations, as the wire word form. + + ``str`` so a member *is* the word it carries, and the character the rendered + SQL writes is a per-member property. The symbol never reaches the artifact: + it is a spelling, and the artifact has to be byte-identical across four + languages that do not agree on it -- ``<>`` against ``ne``, ``!=`` against + either. The word is what the languages can agree on; the symbol is what the + dialect formatter owns. + + This is the same shape :class:`~.operators.ArithmeticOperator` already has. + Comparison was the one family that still passed SQL characters, so its + artifacts read as SQL rather than as expressions, and no other port could + reproduce them. + """ + + EQ = "eq" + NE = "ne" + GT = "gt" + GTE = "gte" + LT = "lt" + LTE = "lte" + + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the artifact would carry + #: the Python spelling. ``(str, Enum)`` alone does not give that -- + #: ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + @property + def symbol(self) -> str: + """The character the standard spelling writes. Never in the artifact.""" + return self.symbols()[self] + + @classmethod + def symbols(cls) -> "dict[ComparisonOperator, str]": + """Each operation, and the character the standard spelling writes. + + The table lives here rather than beside the class because it is the + operator's own vocabulary: the word is the operation, and the character + is one spelling of it. + """ + return { + cls.EQ: "=", + cls.NE: "<>", + cls.GT: ">", + cls.GTE: ">=", + cls.LT: "<", + cls.LTE: "<=", + } + + @classmethod + def characters(cls) -> "dict[str, ComparisonOperator]": + """The SQL characters a caller may still pass, and the word each means. + + A comparison written ``a >= b`` is the same comparison written + ``a.gte(b)``, and accepting the character keeps every call site working + while the artifact sees only the word. It is a constructor convenience, + not a wire form: what :attr:`ComparisonPredicate.op` holds is always + the enum. + """ + return { + "=": cls.EQ, + "==": cls.EQ, + "!=": cls.NE, + "<>": cls.NE, + ">": cls.GT, + ">=": cls.GTE, + "<": cls.LT, + "<=": cls.LTE, + } class ComparisonPredicate(SQLPredicate): - """Represents a comparison predicate (e.g., expr1 = expr2, expr1 > expr2).""" + """Represents a comparison predicate (``left right``). + + ``op`` is a :class:`ComparisonOperator`, so it names the operation rather + than spelling it: ``gte`` travels, ``>=`` is written by the formatter. A + caller may still pass the character -- see + :meth:`coerce_operator` -- and the node ends up the same. + """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_comparison_predicate" - def __init__(self, dialect: "SQLDialectBase", op: str, left: "SQLValueExpression", right: "SQLValueExpression"): + @classmethod + def coerce_operator(cls, op: Any) -> ComparisonOperator: + """Resolve *op* to a :class:`ComparisonOperator`. + + Lives on this class because what it protects is this class's own input + invariant: ``self.op`` is a member, and the code enforcing that belongs + with the attribute it guards. + + Accepts a member, a wire word, or a SQL character, so a caller writing + ``a >= b`` and one writing ``a.gte(b)`` build the same node. Anything + else raises rather than passing through -- an operator this node cannot + render would otherwise surface much later, as malformed SQL. + """ + if isinstance(op, ComparisonOperator): + return op + text = str(op).strip() + characters = ComparisonOperator.characters() + if text in characters: + return characters[text] + try: + return ComparisonOperator(text.lower()) + except ValueError: + raise ValueError( + f"unknown comparison operation {op!r}: the operation words are " + f"{sorted(o.value for o in ComparisonOperator)}" + ) from None + + def __init__(self, dialect: "SQLDialectBase", op: Any, left: "SQLValue", right: "SQLValue"): super().__init__(dialect) - self.op = op + self.op = self.coerce_operator(op) self.left = left self.right = right @@ -39,10 +146,78 @@ def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_logical_predicate" - def __init__(self, dialect: "SQLDialectBase", op: str, *predicates: "SQLPredicate"): + def __init__(self, dialect: "SQLDialectBase", op: str, *operands: "SQLPredicate"): super().__init__(dialect) self.op = op - self.predicates = list(predicates) + self.operands = list(operands) + + +class LikeOperator(str, Enum): + """The LIKE family's operations, as the wire word form. + + Same shape as :class:`ComparisonOperator`, and for the same reason: the word + is what four languages agree on and what the artifact may carry; the SQL + keyword is a spelling the dialect owns and the artifact never sees. LIKE was + the family that still passed SQL text through the node -- which is how + `not_like` ended up rendered as a SQL operator -- so it was the last one + needing this split. + + ``ILIKE`` is a member even though :class:`ILikePredicate` exists, and that is + deliberate: they are two different promises. ``ilike()`` writes the ``ILIKE`` + spelling and lets the backend accept it or refuse it -- the behaviour its + callers and its tests already have. ``ILikePredicate`` is built explicitly + and asks the dialect for a portable ``LOWER(x) LIKE LOWER(y)`` emulation. + Routing one into the other silently rewrites SQL that callers depend on, + so the two are kept apart. + """ + + LIKE = "like" + NOT_LIKE = "not_like" + ILIKE = "ilike" + + __str__ = str.__str__ + + @property + def keyword(self) -> str: + """The keyword the standard spelling writes. Never in the artifact.""" + return self.keywords()[self] + + @classmethod + def keywords(cls) -> "dict[LikeOperator, str]": + """Each operation, and the keyword the standard spelling writes. + + The table lives here rather than beside the class because it is the + operator's own vocabulary: the word is the operation, and the keyword is + one spelling of it. + """ + return { + cls.LIKE: "LIKE", + cls.NOT_LIKE: "NOT LIKE", + cls.ILIKE: "ILIKE", + } + + @classmethod + def _missing_(cls, value: Any) -> "LikeOperator": + """Accept the SQL keyword as well as the word. + + The enum knows both spellings -- the word it carries and the keyword the + dialect writes -- so it can be built from either, and the keyword is + coerced away on the way in so it never reaches the artifact. This is the + same acceptance :meth:`~.predicates.ComparisonPredicate.coerce_operator` gives + its family, where a caller may write ``">="`` and still get the word + ``gte``. + + Python calls this only when the ordinary lookup fails, so a member + found directly never routes through it. + """ + text = str(value).strip() + for member in cls: + if member.value == text.lower(): + return member + for member, keyword in cls.keywords().items(): + if keyword == text.upper(): + return member + return None class LikePredicate(SQLPredicate): @@ -53,10 +228,54 @@ def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_like_predicate" - def __init__(self, dialect: "SQLDialectBase", op: str, expr: "SQLValueExpression", pattern: "SQLValueExpression"): + @classmethod + def coerce_operator(cls, op: Any) -> "LikeOperator": + """Resolve *op* to a :class:`LikeOperator`. + + Lives on this class rather than beside it as a free function, because + what it protects is this class's own input invariant: ``self.op`` is a + member, and the code that enforces that belongs with the class whose + attribute it guards. + + Accepts a member, a wire word, or the SQL keyword, and raises rather + than passing through an unknown one. This is the same contract + :func:`~.predicates.normalize_comparison_operator` gives its family: a + spelling may come in, it is coerced on the way in, and never reaches the + artifact. The word/keyword split this class was built for is about what + the artifact *carries*, not about what a caller is allowed to write -- + refusing the keyword would not protect the artifact, it would only break + callers that spell it the way the rendered SQL reads. + + The raise exists so a typo fails where it is written rather than + surfacing much later as an AttributeError from the renderer, which is + what a caller passing the bare string ``"LIKE"`` used to get. + """ + if isinstance(op, LikeOperator): + return op + text = str(op).strip() + if not text: + raise ValueError("a LIKE operator word is required") + try: + return LikeOperator(text) + except ValueError: + raise ValueError( + f"unknown LIKE operator {op!r}; expected one of " + f"{sorted(o.value for o in LikeOperator)}" + ) from None + + def __init__( + self, + dialect: "SQLDialectBase", + op: "LikeOperator", + left: "SQLValue", + pattern: "SQLValue", + ): super().__init__(dialect) - self.op = op - self.expr = expr + # Coerced here so a caller passing a word rather than a member still + # gets one, and a caller passing a *keyword* fails at the call that + # wrote it instead of in the renderer. + self.op = self.coerce_operator(op) + self.left = left self.pattern = pattern @@ -68,9 +287,9 @@ def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_in_predicate" - def __init__(self, dialect: "SQLDialectBase", expr: "SQLValueExpression", values: "BaseExpression"): + def __init__(self, dialect: "SQLDialectBase", operand: "SQLValue", values: "BaseExpression"): super().__init__(dialect) - self.expr = expr + self.operand = operand self.values = values @@ -85,12 +304,12 @@ def format_method(self) -> str: def __init__( self, dialect: "SQLDialectBase", - expr: "BaseExpression", + operand: "BaseExpression", low: "BaseExpression", high: "BaseExpression", ): super().__init__(dialect) - self.expr = expr + self.operand = operand self.low = low self.high = high @@ -125,18 +344,18 @@ def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_is_null_predicate" - def __init__(self, dialect: "SQLDialectBase", expr: "BaseExpression", is_not: bool = False): + def __init__(self, dialect: "SQLDialectBase", operand: "BaseExpression", is_not: bool = False): super().__init__(dialect) - self.expr = expr + self.operand = operand self.is_not = is_not -class ILIKEExpression(SQLPredicate): +class ILikePredicate(SQLPredicate): """Represents a dialect-dispatched ILIKE (case-insensitive LIKE) predicate. Unlike :class:`LikePredicate` (which always renders through the generic ``format_like_predicate``), this node delegates to the dialect's - ``format_ilike_expression`` hook so a dialect can emit a native ``ILIKE`` + ``format_ilike_predicate`` hook so a dialect can emit a native ``ILIKE`` operator or a portable ``LOWER(...) LIKE LOWER(...)`` fallback. Attributes: @@ -145,7 +364,7 @@ class ILIKEExpression(SQLPredicate): negate: If True, renders ``NOT LIKE`` instead of ``LIKE``. Example: - >>> expr = ILIKEExpression(dialect, Column(dialect, "name"), "John%") + >>> expr = ILikePredicate(dialect, Column(dialect, "name"), "John%") >>> expr.to_sql() ('LOWER("name") LIKE LOWER(?)', ('john%',)) """ @@ -153,7 +372,7 @@ class ILIKEExpression(SQLPredicate): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_ilike_expression" + return "format_ilike_predicate" def __init__( self, @@ -238,7 +457,9 @@ def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_is_boolean_predicate" - def __init__(self, dialect: "SQLDialectBase", expr: "BaseExpression", value: bool, is_not: bool = False): + def __init__( + self, dialect: "SQLDialectBase", expr: "BaseExpression", value: bool, is_not: bool = False + ): """ Initialize an IS TRUE/FALSE predicate. diff --git a/src/rhosocial/activerecord/backend/expression/query_parts.py b/src/rhosocial/activerecord/backend/expression/query_parts.py index 090a45668..3dd2b7ec1 100644 --- a/src/rhosocial/activerecord/backend/expression/query_parts.py +++ b/src/rhosocial/activerecord/backend/expression/query_parts.py @@ -7,53 +7,160 @@ """ from enum import Enum -from typing import Tuple, List, Union, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union +from ..expression.objects import Table from .bases import BaseExpression, SQLPredicate from .core import Subquery -from .objects import Table -from .sources import NamedRelationRef from .mixins import AliasableMixin -from ..expression.objects import Table -from .sources import TableSource +from .objects import Table +from .sources import NamedRelationRef, TableSource -class JoinType(Enum): - """Enumeration of SQL JOIN types.""" +class JoinType(str, Enum): + """The SQL JOIN types, as the wire word form. - INNER = "INNER JOIN" # INNER JOIN - JOIN = "JOIN" # JOIN (equivalent to INNER JOIN) - LEFT = "LEFT JOIN" # LEFT JOIN - LEFT_OUTER = "LEFT OUTER JOIN" # LEFT OUTER JOIN - RIGHT = "RIGHT JOIN" # RIGHT JOIN - RIGHT_OUTER = "RIGHT OUTER JOIN" # RIGHT OUTER JOIN - FULL = "FULL JOIN" # FULL JOIN - FULL_OUTER = "FULL OUTER JOIN" # FULL OUTER JOIN - CROSS = "CROSS JOIN" # CROSS JOIN - # Additional types could be added if needed - # MySQL-specific - STRAIGHT = "STRAIGHT_JOIN" # MySQL STRAIGHT_JOIN - # Other database-specific types could be added here + ``str`` so a member *is* the word it carries and the SQL it writes is a + per-member property. The join phrase never reaches the artifact: it is a + spelling, and ``STRAIGHT_JOIN`` alone is enough to show why -- it is MySQL + dialect spelled inside what is supposed to be a language-neutral payload. + This is the same shape :class:`~.predicates.ComparisonOperator` and + :class:`~.operators.ArithmeticOperator` already have. + """ + + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + + INNER = "inner" + JOIN = "join" + LEFT = "left" + LEFT_OUTER = "left_outer" + RIGHT = "right" + RIGHT_OUTER = "right_outer" + FULL = "full" + FULL_OUTER = "full_outer" + CROSS = "cross" + #: MySQL-only. A backend-specific spelling belongs to the backend that has + #: it, but the *operation* is common, so the word is admitted here and the + #: phrase is left to the formatter. + STRAIGHT = "straight" -class LockStrength(Enum): - """Row-level lock strength for a FOR UPDATE / FOR SHARE clause. + @property + def phrase(self) -> str: + """The SQL this type writes. Never in the artifact.""" + return _JOIN_PHRASES[self] + + +_JOIN_PHRASES = { + JoinType.INNER: "INNER JOIN", + JoinType.JOIN: "JOIN", + JoinType.LEFT: "LEFT JOIN", + JoinType.LEFT_OUTER: "LEFT OUTER JOIN", + JoinType.RIGHT: "RIGHT JOIN", + JoinType.RIGHT_OUTER: "RIGHT OUTER JOIN", + JoinType.FULL: "FULL JOIN", + JoinType.FULL_OUTER: "FULL OUTER JOIN", + JoinType.CROSS: "CROSS JOIN", + JoinType.STRAIGHT: "STRAIGHT_JOIN", +} + +#: The SQL phrase a caller may still pass, and the member it means. ``join_type`` +#: is typed ``str`` at the call site and callers wrote ``"LEFT JOIN"``; accepting +#: the phrase keeps them working while the artifact sees only the word. +_JOIN_PHRASE_TO_TYPE = {phrase: member for member, phrase in _JOIN_PHRASES.items()} + + +def normalize_join_type(join_type: Any) -> JoinType: + """Resolve *join_type* to a :class:`JoinType`. + + Accepts a member, a wire word, or the SQL phrase, so ``"LEFT JOIN"`` and + ``JoinType.LEFT`` build the same node. Anything else raises rather than + passing through -- a join this node cannot render would otherwise surface + much later, as malformed SQL. + """ + if isinstance(join_type, JoinType): + return join_type + text = str(join_type).strip() + upper = text.upper() + if upper in _JOIN_PHRASE_TO_TYPE: + return _JOIN_PHRASE_TO_TYPE[upper] + try: + return JoinType(text.lower()) + except ValueError: + raise ValueError( + f"unknown join type {join_type!r}: the type words are " + f"{sorted(j.value for j in JoinType)}" + ) from None + + +class LockStrength(str, Enum): + """Row-level lock strength for a locking clause. Only the strengths with cross-backend commonality live here. Backends with a unique, non-generalisable strength define their own ``{Backend}ForUpdateClause`` subclass instead. + + The values are words, not clauses: ``FOR UPDATE`` is a spelling, and the + artifact has to be readable by an implementation whose dialect writes the + locking clause differently -- or does not support that strength at all. The + clause is written by the formatter from :attr:`phrase`. """ - UPDATE = "FOR UPDATE" # Strongest exclusive lock - NO_KEY_UPDATE = "FOR NO KEY UPDATE" # PostgreSQL-specific weaker exclusive lock - SHARE = "FOR SHARE" # Shared lock - KEY_SHARE = "FOR KEY SHARE" # Weakest shared lock (PostgreSQL) - LOCK_IN_SHARE_MODE = "LOCK IN SHARE MODE" # Legacy MySQL/MariaDB syntax + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + + UPDATE = "update" # Strongest exclusive lock + NO_KEY_UPDATE = "no_key_update" # PostgreSQL-specific weaker exclusive lock + SHARE = "share" # Shared lock + KEY_SHARE = "key_share" # Weakest shared lock (PostgreSQL) + LOCK_IN_SHARE_MODE = "lock_in_share_mode" # Legacy MySQL/MariaDB syntax + + @property + def phrase(self) -> str: + """The SQL this strength writes. Never in the artifact.""" + return _LOCK_PHRASES[self] + + +_LOCK_PHRASES = { + LockStrength.UPDATE: "FOR UPDATE", + LockStrength.NO_KEY_UPDATE: "FOR NO KEY UPDATE", + LockStrength.SHARE: "FOR SHARE", + LockStrength.KEY_SHARE: "FOR KEY SHARE", + LockStrength.LOCK_IN_SHARE_MODE: "LOCK IN SHARE MODE", +} + +#: The SQL clause a caller may still pass, and the member it means. Accepting it +#: keeps every existing ``strength="FOR UPDATE"`` call site working while the +#: artifact sees only the word. +_LOCK_PHRASE_TO_STRENGTH = {phrase: member for member, phrase in _LOCK_PHRASES.items()} + + +def normalize_lock_strength(strength: Any) -> LockStrength: + """Resolve *strength* to a :class:`LockStrength`. + + Accepts a member, a wire word, or the SQL clause, so ``"FOR UPDATE"`` and + ``LockStrength.UPDATE`` build the same node. Anything else raises rather + than passing through. + """ + if isinstance(strength, LockStrength): + return strength + text = str(strength).strip() + upper = text.upper() + if upper in _LOCK_PHRASE_TO_STRENGTH: + return _LOCK_PHRASE_TO_STRENGTH[upper] + try: + return LockStrength(text.lower()) + except ValueError: + raise ValueError( + f"unknown lock strength {strength!r}: the strength words are " + f"{sorted(s.value for s in LockStrength)}" + ) from None if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase - from .statements import QueryExpression + from .statements import QueryStatement class WhereClause(BaseExpression): @@ -198,7 +305,37 @@ def __init__( ], ): super().__init__(dialect) - self.expressions = expressions # List of ordering specifications + # An artifact carries this as an array: JSON draws no distinction between + # a list and a tuple, and a marker to draw one would be a dispatch every + # language had to reimplement. The pair is restored here instead, where + # the parameter's declared type already says what it is -- wide in, + # normalised out. + # + # The direction is normalised here too, not at render time, so what + # this node holds is already the wire word. Leaving it raw meant a + # caller who wrote "DESC" shipped "DESC" on the wire while one who wrote + # "desc" shipped "desc" — the same comparison two spellings for one + # thing, which is what §11.4 exists to prevent. + self.expressions = [self._normalize_entry(item) for item in expressions] + + @staticmethod + def _normalize_entry(item): + """One ORDER BY element in its single wire form. + + An entry carrying only an operand and a direction is the pair + ``[node, direction]``; the node form exists for the case the pair cannot + express, a ``NULLS FIRST`` / ``NULLS LAST`` request. Writing back + whichever the caller happened to pass would put two spellings for one + thing on the wire -- and an artifact has to be byte-identical across + four languages, which it cannot be when it depends on how it was built. + """ + if not isinstance(item, BaseExpression): + return (item[0], SortDirection.parse(item[1])) + # A directionless, null-orderless item means exactly what the pair with + # no direction means, so it is written as one. + if isinstance(item, OrderByItem) and item.nulls is None: + return (item.operand, item.direction) + return item @property def format_method(self) -> str: @@ -206,48 +343,136 @@ def format_method(self) -> str: return "format_order_by_clause" -class OrderByExpression(BaseExpression): +class SortDirection(str, Enum): + """The two sort directions, as the wire word form. + + Lowercase words, like every other operation on the wire: ``asc`` names the + direction and ``ASC`` is one spelling of it. The artifact has to be + byte-identical across four languages, and a SQL keyword is exactly the kind + of thing they will not agree on — so the keyword is written by the + formatter, through :attr:`keyword`. ``NullsOrder`` beside this one was + already written this way; sort direction was the exception. + + ``str`` so a member *is* the word it carries. + """ + + ASC = "asc" + DESC = "desc" + + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the artifact would carry + #: the Python spelling. ``(str, Enum)`` alone does not give that -- + #: ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + @property + def keyword(self) -> str: + """The SQL keyword this direction writes. Never in the artifact.""" + return self.value.upper() + + @classmethod + def parse(cls, word: Optional[str]) -> Optional["SortDirection"]: + """The member for *word*, or ``None`` when there is no direction. + + Accepts either case, because a caller writing ``"DESC"`` from habit and + a caller writing the wire word ``"desc"`` mean the same direction. + + ``None`` is accepted because a direction is optional on the wire: this + module's own :class:`OrderByItem` writes ``null``, so an artifact + carrying ``[operand, null]`` is one this library produced. Sorting + without a direction is ordinary rather than malformed, and a reader that + crashed on it would reject its own output. + """ + if word is None: + return None + if not isinstance(word, str): + raise ValueError( + f"Invalid ORDER BY direction: {word!r}. Must be 'asc', 'desc', or null." + ) + try: + return cls(word.strip().lower()) + except ValueError: + raise ValueError( + f"Invalid ORDER BY direction: {word!r}. Must be 'asc', 'desc', or null." + ) from None + + +class NullsOrder(str, Enum): + """Where nulls sit in an ordering.""" + + FIRST = "first" + LAST = "last" + + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the rendered SQL and the + #: artifact would carry the Python spelling. ``(str, Enum)`` alone does not + #: give that -- ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + @classmethod + def parse(cls, word: str) -> "NullsOrder": + """The member for *word*, raising ``ValueError`` if it is not one.""" + try: + return cls(word.strip().lower()) + except ValueError: + raise ValueError( + f"Invalid null ordering: {word!r}. Must be 'first' or 'last'." + ) from None + + +class OrderByItem(BaseExpression): """A single sort element within an ORDER BY clause. - Carries the sort expression, its optional direction (``ASC`` / ``DESC``) - and the optional null-ordering (``NULLS FIRST`` / ``NULLS LAST``). + This is the *entry*, not the clause: the operand, its direction + (``ASC`` / ``DESC``) and its null-ordering together make one item, and + the ``ORDER BY`` clause holding a list of them is :class:`OrderByClause` + -- a different node with a different formatter. The two are not + interchangeable: only this one carries the direction and null-order + triple, and the clause is what a statement renders. + + Per the spec's naming table (§4.9) an ordering entry is the "expression" + category -- it independently holds something, is not part of a statement + and does not execute on its own -- which is why ``Item`` and not + ``Clause`` names it. ``order_by_clause`` stays registered as a read alias + of ``order_by_item`` for artifacts published by the other three ports. + + ``nulls`` is a single enum-style parameter: ``"first"`` / ``"last"`` spell + ``NULLS FIRST`` / ``NULLS LAST``, and ``None`` (the default) leaves null + ordering to the engine. The former ``nulls_first`` / ``nulls_last`` boolean + pair is gone. Examples: # Ascending, nulls first - OrderByExpression(dialect, Column(dialect, "name"), nulls_first=True) + OrderByItem(dialect, Column(dialect, "name"), nulls="first") # Descending - OrderByExpression(dialect, Column(dialect, "created_at"), "DESC") + OrderByItem(dialect, Column(dialect, "created_at"), "DESC") """ - _VALID_DIRECTIONS = frozenset({"ASC", "DESC"}) - def __init__( self, dialect: "SQLDialectBase", - expression: "BaseExpression", - direction: Optional[str] = None, - nulls_first: bool = False, - nulls_last: bool = False, + operand: "BaseExpression", + direction: Optional[SortDirection] = None, + nulls: Optional[NullsOrder] = None, ): super().__init__(dialect) if direction is not None: - direction = direction.upper() - if direction not in self._VALID_DIRECTIONS: - raise ValueError( - f"Invalid ORDER BY direction: {direction!r}. Must be 'ASC' or 'DESC'." - ) - if nulls_first and nulls_last: - raise ValueError("Only one of 'nulls_first' or 'nulls_last' may be set.") - self.expression = expression + direction = ( + SortDirection(direction) + if isinstance(direction, SortDirection) + else SortDirection.parse(direction) + ) + if nulls is not None: + nulls = NullsOrder(nulls) if isinstance(nulls, NullsOrder) else NullsOrder.parse(nulls) + self.operand = operand self.direction = direction - self.nulls_first = nulls_first - self.nulls_last = nulls_last + self.nulls = nulls @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_order_by_expression" + return "format_order_by_item" class LimitOffsetClause(BaseExpression): @@ -354,13 +579,15 @@ class ForUpdateClause(BaseExpression): def __init__( self, dialect: "SQLDialectBase", - strength: Optional["LockStrength"] = None, + strength: Optional[Any] = None, of_columns: Optional[List[Union[str, "BaseExpression"]]] = None, # Specify columns to lock nowait: bool = False, # NOWAIT option - fail immediately if locked skip_locked: bool = False, # SKIP LOCKED option - skip locked rows ): # Dialect-specific options super().__init__(dialect) - self.strength = strength if strength is not None else LockStrength.UPDATE + self.strength = ( + normalize_lock_strength(strength) if strength is not None else LockStrength.UPDATE + ) self.of_columns = of_columns or [] # Columns to apply the lock to self.nowait = nowait # If True, fail immediately if rows are locked self.skip_locked = skip_locked # If True, skip locked rows instead of waiting @@ -374,7 +601,9 @@ def format_method(self) -> str: class GroupingClause(BaseExpression): """Represents grouping operations like ROLLUP, CUBE, and GROUPING SETS.""" - def __init__(self, dialect: "SQLDialectBase", operation: str, expressions: List["BaseExpression"]): + def __init__( + self, dialect: "SQLDialectBase", operation: str, expressions: List["BaseExpression"] + ): super().__init__(dialect) self.operation = operation self.expressions = expressions @@ -442,38 +671,65 @@ class JoinClause(AliasableMixin, TableSource, BaseExpression): def __init__( self, dialect: "SQLDialectBase", - left_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + left_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], join_type: str = "JOIN", - condition: Optional["SQLPredicate"] = None, # ON condition (mutually exclusive with 'using') - using: Optional[List[str]] = None, # USING clause columns (mutually exclusive with 'condition') + condition: Optional[ + "SQLPredicate" + ] = None, # ON condition (mutually exclusive with 'using') + using: Optional[ + List[str] + ] = None, # USING clause columns (mutually exclusive with 'condition') natural: bool = False, # NATURAL join flag alias: Optional[str] = None, # Alias for the joined result ): # Dialect-specific options super().__init__(dialect) - from .statements import QueryExpression from .graph import GraphTableExpression + from .statements import QueryStatement # Normalize table inputs. A join reads from both sides, so each side is a # row source; a bare name becomes a source over a plain table object. self.left_table = ( left_table - if isinstance(left_table, (Table, NamedRelationRef, Subquery, JoinClause, QueryExpression, GraphTableExpression)) + if isinstance( + left_table, + ( + Table, + NamedRelationRef, + Subquery, + JoinClause, + QueryStatement, + GraphTableExpression, + ), + ) else NamedRelationRef(dialect, Table(dialect, str(left_table))) ) self.right_table = ( right_table - if isinstance(right_table, (Table, NamedRelationRef, Subquery, JoinClause, QueryExpression, GraphTableExpression)) + if isinstance( + right_table, + ( + Table, + NamedRelationRef, + Subquery, + JoinClause, + QueryStatement, + GraphTableExpression, + ), + ) else NamedRelationRef(dialect, Table(dialect, str(right_table))) ) - # Store join_type as string - self.join_type = join_type + # Resolved to a JoinType so the artifact carries the word. A caller may + # still pass the SQL phrase -- see normalize_join_type. + self.join_type = normalize_join_type(join_type) # Validate mutual exclusivity of condition and using if condition is not None and using is not None: - raise ValueError("Cannot specify both 'condition' (ON) and 'using' (USING) clauses in a JOIN") + raise ValueError( + "Cannot specify both 'condition' (ON) and 'using' (USING) clauses in a JOIN" + ) # Store other parameters self.condition = condition # ON condition @@ -488,7 +744,7 @@ def format_method(self) -> str: def join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], join_type: str = "JOIN", condition: Optional["SQLPredicate"] = None, using: Optional[List[str]] = None, @@ -526,47 +782,71 @@ def join( def inner_join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], condition: Optional["SQLPredicate"] = None, using: Optional[List[str]] = None, alias: Optional[str] = None, ) -> "JoinClause": """Create an inner join with another table.""" - return self.join(right_table=right_table, join_type="INNER JOIN", condition=condition, using=using, alias=alias) + return self.join( + right_table=right_table, + join_type="INNER JOIN", + condition=condition, + using=using, + alias=alias, + ) def left_join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], condition: Optional["SQLPredicate"] = None, using: Optional[List[str]] = None, alias: Optional[str] = None, ) -> "JoinClause": """Create a left join with another table.""" - return self.join(right_table=right_table, join_type="LEFT JOIN", condition=condition, using=using, alias=alias) + return self.join( + right_table=right_table, + join_type="LEFT JOIN", + condition=condition, + using=using, + alias=alias, + ) def right_join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], condition: Optional["SQLPredicate"] = None, using: Optional[List[str]] = None, alias: Optional[str] = None, ) -> "JoinClause": """Create a right join with another table.""" - return self.join(right_table=right_table, join_type="RIGHT JOIN", condition=condition, using=using, alias=alias) + return self.join( + right_table=right_table, + join_type="RIGHT JOIN", + condition=condition, + using=using, + alias=alias, + ) def full_join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], condition: Optional["SQLPredicate"] = None, using: Optional[List[str]] = None, alias: Optional[str] = None, ) -> "JoinClause": """Create a full join with another table.""" - return self.join(right_table=right_table, join_type="FULL JOIN", condition=condition, using=using, alias=alias) + return self.join( + right_table=right_table, + join_type="FULL JOIN", + condition=condition, + using=using, + alias=alias, + ) def cross_join( self, - right_table: Union[str, "NamedRelationRef", "Subquery", "QueryExpression", "JoinClause"], + right_table: Union[str, "NamedRelationRef", "Subquery", "QueryStatement", "JoinClause"], alias: Optional[str] = None, ) -> "JoinClause": """Create a cross join with another table.""" diff --git a/src/rhosocial/activerecord/backend/expression/query_sources.py b/src/rhosocial/activerecord/backend/expression/query_sources.py index 6d9f3968c..a64eca7cb 100644 --- a/src/rhosocial/activerecord/backend/expression/query_sources.py +++ b/src/rhosocial/activerecord/backend/expression/query_sources.py @@ -8,67 +8,96 @@ """ from dataclasses import dataclass -from typing import Tuple, Any, List, Optional, Union, TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union -from .bases import BaseExpression, SQLQueryAndParams, SQLValueExpression +from .bases import BaseExpression, SQLQueryAndParams, SQLValue from .core import Subquery from .mixins import ArithmeticMixin, ComparisonMixin +from .query_parts import normalize_join_type from .sources import TableSource if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase - from .query_parts import OrderByClause, LimitOffsetClause, ForUpdateClause + from .types import DataType -class SetOperationExpression(TableSource, BaseExpression): - """Represents a set operation (UNION, INTERSECT, EXCEPT) between two queries. +class SetOperationKind(str, Enum): + """The compound-SELECT word forms. The whole legal set, closed. - This class is commonly used for: - 1. Combining results from multiple queries - 2. Creating recursive CTEs (with UNION ALL) - 3. Removing duplicates (UNION) or keeping all rows (UNION ALL) - 4. Finding common rows (INTERSECT) or differences (EXCEPT) + ``str`` as the base so a member *is* its word: the word form is the wire + contract for ``SetOperation.op``, and a member compares and serialises as + the plain string it is, so nothing downstream has to unwrap it. + + The SQL keyword is **not** here. A keyword is a dialect-layer product and + the node never carries one; the dialect maps these members onto whatever it + spells them with. + """ + + UNION = "union" + UNION_ALL = "union_all" + INTERSECT = "intersect" + EXCEPT = "except" + + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the rendered SQL and the + #: artifact would carry the Python spelling. ``(str, Enum)`` alone does not + #: give that -- ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + @classmethod + def parse(cls, word: str) -> "SetOperationKind": + """The member for *word*, raising ``ValueError`` if it is not one. + + Case and surrounding space are accepted because a caller writing SQL-ish + text should not have to know the canonical spelling to get it right; the + stored form is always the member. + """ + try: + return cls(word.strip().lower()) + except ValueError: + raise ValueError( + f"op must be one of {', '.join(m.value for m in cls)} (word forms), " + f"got {word!r}" + ) from None + + +class SetOperation(TableSource, BaseExpression): + """A set operation (UNION, INTERSECT, EXCEPT) between two query sides. + + The node is exactly the wire contract: ``op`` + ``left`` + ``right``. + ``op`` carries the **word form** of the operation (``union`` / ``union_all`` + / ``intersect`` / ``except``) — SQL symbols are dialect-layer products and + never enter the node; the ``all_``/``distinct`` flag pair of the former + ``SetOperationStatement`` is folded into the ``union_all`` word form. The + dialect formatter maps the word back to the spelling it gates behind the + ``supports_union`` / ``supports_union_all`` / ``supports_intersect`` / + ``supports_except`` probes. + + Ordering, row limiting and locking of a compound query belong to the + statement-level clauses around the set operation, not to this node — the + former trailing-clause parameters are gone. Example Usage: # Basic UNION operation - union_expr = SetOperationExpression( - dialect, - left=QueryExpression( - dialect, - select=[Column(dialect, "id"), Column(dialect, "name")], - from_=NamedRelationRef(dialect, Table(dialect, "users")) - ), - right=QueryExpression( - dialect, - select=[Column(dialect, "id"), Column(dialect, "name")], - from_=NamedRelationRef(dialect, Table(dialect, "customers")) - ), - operation="UNION", - alias="combined_users" - ) - - # UNION operation with additional clauses - union_expr_with_order = SetOperationExpression( + union_expr = SetOperation( dialect, - left=QueryExpression( + op=SetOperationKind.UNION, + left=QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")) ), - right=QueryExpression( + right=QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "customers")) ), - operation="UNION", - alias="combined_users", - order_by_clause=OrderByClause(dialect, [Column(dialect, "id")]), - limit_offset_clause=LimitOffsetClause(dialect, limit=10) ) # Recursive CTE example using UNION ALL (essential for iterative algorithms like Sudoku solver) # Initial query part - base case for recursion - initial_query = QueryExpression( + initial_query = QueryStatement( dialect, select=[ Column(dialect, "id"), @@ -84,7 +113,7 @@ class SetOperationExpression(TableSource, BaseExpression): ) # Recursive query part - self-referencing query - recursive_query = QueryExpression( + recursive_query = QueryStatement( dialect, select=[ Column(dialect, "n.id"), @@ -107,66 +136,49 @@ class SetOperationExpression(TableSource, BaseExpression): ) # Combine initial and recursive parts with UNION ALL for recursive CTE - recursive_union = SetOperationExpression( + recursive_union = SetOperation( dialect, + op=SetOperationKind.UNION_ALL, left=initial_query, right=recursive_query, - operation="UNION ALL", - alias="recursive_result" ) """ def __init__( self, dialect: "SQLDialectBase", + op: SetOperationKind, left: "BaseExpression", right: "BaseExpression", - operation: str, - alias: Optional[str] = None, - all_: bool = False, - distinct: bool = False, - order_by_clause: Optional["OrderByClause"] = None, - limit_offset_clause: Optional["LimitOffsetClause"] = None, - for_update_clause: Optional["ForUpdateClause"] = None, ): """ - Initialize a SetOperationExpression. + Initialize a SetOperation. Args: dialect: The SQL dialect to use for formatting + op: The compound operation, as a :class:`SetOperationKind` member. + A bare word is accepted and parsed; ``all_``/``distinct`` + variants are expressed by ``UNION_ALL``. left: The left-hand query expression right: The right-hand query expression - operation: The set operation (e.g., "UNION", "INTERSECT", "EXCEPT") - alias: Optional alias for the set operation result - all_: Whether to spell the ALL variant (e.g., UNION ALL) - distinct: Whether to spell the DISTINCT variant (e.g., UNION DISTINCT) - order_by_clause: Optional ORDER BY clause to apply to the result set - limit_offset_clause: Optional LIMIT/OFFSET clause to apply to the result set - for_update_clause: Optional FOR UPDATE clause to apply to the result set Raises: - ValueError: If both ``all_`` and ``distinct`` are set. + ValueError: If ``op`` is not one of the four word forms. """ super().__init__(dialect) - if all_ and distinct: - raise ValueError("all_ and distinct are mutually exclusive options") + self.op = ( + SetOperationKind(op) if isinstance(op, SetOperationKind) else SetOperationKind.parse(op) + ) self.left = left self.right = right - self.operation = operation - self.alias = alias - self.all_ = all_ - self.distinct = distinct - self.order_by_clause = order_by_clause - self.limit_offset_clause = limit_offset_clause - self.for_update_clause = for_update_clause @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_operation_expression" + return "format_set_operation" -class CTEExpression(BaseExpression): +class CTEClause(BaseExpression): """Represents a Common Table Expression (CTE) expression (WITH ... AS ...). Common Table Expressions (CTEs) allow defining temporary result sets that can be referenced @@ -175,10 +187,10 @@ class CTEExpression(BaseExpression): Example Usage: # Basic CTE - cte = CTEExpression( + cte = CTEClause( dialect, name="monthly_sales", - query=QueryExpression( + query=QueryStatement( dialect, select=[Column(dialect, "month"), FunctionCall(dialect, "SUM", Column(dialect, "amount"))], from_=NamedRelationRef(dialect, Table(dialect, "sales")), @@ -195,7 +207,7 @@ class CTEExpression(BaseExpression): column_names=["value", "level"] ) - recursive_query = QueryExpression( + recursive_query = QueryStatement( dialect, select=[ FunctionCall(dialect, "CAST", Column(dialect, "value") + Literal(dialect, 1), "TEXT"), @@ -205,32 +217,31 @@ class CTEExpression(BaseExpression): where=(Column(dialect, "level") < Literal(dialect, 10)) ) - # Combine using SetOperationExpression for recursive CTE - combined_query = SetOperationExpression( + # Combine using SetOperation for recursive CTE + combined_query = SetOperation( dialect, + op=SetOperationKind.UNION_ALL, left=initial_values, right=recursive_query, - operation="UNION ALL", - alias="recursive_union" ) - # To create a recursive CTE, use WithQueryExpression with recursive=True - cte = CTEExpression( + # To create a recursive CTE, use WithStatement with recursive=True + cte = CTEClause( dialect, name="counter", query=combined_query, columns=["next_value", "next_level"] ) - # Use WithQueryExpression to create the recursive query - recursive_query = WithQueryExpression( + # Use WithStatement to create the recursive query + recursive_query = WithStatement( dialect, - ctes=[cte], - main_query=QueryExpression( + query=QueryStatement( dialect, select=[Column(dialect, "next_value"), Column(dialect, "next_level")], from_=NamedRelationRef(dialect, Table(dialect, "counter")) ), + ctes=[cte], recursive=True ) """ @@ -245,13 +256,13 @@ def __init__( not_materialized: bool = False, ): """ - Initialize a CTEExpression. + Initialize a CTEClause. Args: dialect: The SQL dialect to use for formatting name: The name of the CTE query: The query that defines the CTE. Can be: - - A BaseExpression instance (e.g., QueryExpression, Subquery) + - A BaseExpression instance (e.g., QueryStatement, Subquery) - A tuple of (sql_string, params) where params can be a list or tuple, though using a tuple is preferred (params will be converted to tuple if list) columns: Optional list of column names for the CTE @@ -273,23 +284,42 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_cte_expression" + return "format_cte_clause" + + +class WithStatement(ArithmeticMixin, ComparisonMixin, SQLValue): + """A query with a ``WITH`` clause: ``WITH [RECURSIVE] cte [, ...] query``. + The executable unit of the production -- what ``to_sql()`` yields can + be sent to the database as is. The ``WITH`` keyword itself is grammar + structure the formatter emits, not a node; the grammar's other half, + the query the clause precedes, is :attr:`query`, a sibling expression + rendered by its own formatter. -class WithQueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): - """Represents a query with Common Table Expressions (WITH clause). + The ``WITH`` side is held here rather than in a separate clause node: + apart from ``ctes`` and ``recursive`` it has no state, lifecycle, or + probe family of its own, so a clause node would only add an indirection + over the same data. A single :class:`CTEClause` does earn a node -- it + holds independent data (name, columns, materialization hints, its own + query), consults its own probes, and renders on its own. - This class allows combining multiple CTEs with a main query. It's commonly used for: - 1. Organizing complex queries with multiple temporary result sets - 2. Creating recursive queries (with RECURSIVE keyword) - 3. Improving query readability and maintainability + Rendered by the dialect's public ``format_with_statement``, which + gates on the ``supports_basic_cte`` / ``supports_recursive_cte`` probes + where the construct is spelled instead of leaving them decorative. With + no CTE definitions the statement renders the bare query, so a plain + query needs no wrapper at all. The former ``WithQueryExpression`` -- + whose only difference was the class name -- is retired. + + The value protocols (``SQLValue`` and the arithmetic / + comparison mixins) stay because a WITH query is still a query: it can + be a set-operation operand or the body of another CTE. Example Usage: # Create multiple CTEs - cte1 = CTEExpression( + cte1 = CTEClause( dialect, name="users_with_orders", - query=QueryExpression( + query=QueryStatement( dialect, select=[ Column(dialect, "u.id"), @@ -304,10 +334,10 @@ class WithQueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): columns=["user_id", "user_name", "order_count"] ) - cte2 = CTEExpression( + cte2 = CTEClause( dialect, name="top_users", - query=QueryExpression( + query=QueryStatement( dialect, select=[Column(dialect, "user_id"), Column(dialect, "user_name")], from_=[NamedRelationRef(dialect, Table(dialect, "users_with_orders"))], @@ -317,37 +347,37 @@ class WithQueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): ) # Main query that uses the CTEs - main_query = QueryExpression( + main_query = QueryStatement( dialect, select=[Column(dialect, "tu.name"), Column(dialect, "uwo.order_count")], from_=[NamedRelationRef(dialect, Table(dialect, "top_users"), alias="tu")], where=ComparisonPredicate(dialect, '=', Column(dialect, "tu.user_id"), Column(dialect, "uwo.user_id")) ) - # Combine everything with WithQueryExpression - with_query = WithQueryExpression( + # Compose the CTE definitions with the query into one statement + with_query = WithStatement( dialect, + query=main_query, ctes=[cte1, cte2], - main_query=main_query ) """ def __init__( self, dialect: "SQLDialectBase", - ctes: List[CTEExpression], - main_query: "BaseExpression", + query: "BaseExpression", + ctes: List[CTEClause] = (), recursive: bool = False, ): super().__init__(dialect) - self.ctes = ctes - self.main_query = main_query + self.query = query + self.ctes = list(ctes) self.recursive = recursive @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_with_query_expression" + return "format_with_statement" class ValuesExpression(TableSource, BaseExpression): @@ -420,7 +450,7 @@ class TableFunctionExpression(TableSource, BaseExpression): table_func = TableFunctionExpression( dialect, "UNNEST", - ArrayExpression(dialect, [1, 2, 3]), + ArrayOperation(dialect, [1, 2, 3]), alias="numbers", column_names=["num"] ) @@ -429,7 +459,7 @@ class TableFunctionExpression(TableSource, BaseExpression): table_func = TableFunctionExpression( dialect, "UNNEST", - ArrayExpression(dialect, [1, 2, 3]), + ArrayOperation(dialect, [1, 2, 3]), column_names=["num"] ) """ @@ -443,7 +473,12 @@ def __init__( column_names: Optional[List[str]] = None, ): super().__init__(dialect) - self.func_name, self.args, self.alias, self.column_names = func_name, list(args), alias, column_names + self.func_name, self.args, self.alias, self.column_names = ( + func_name, + list(args), + alias, + column_names, + ) @property def format_method(self) -> str: @@ -474,10 +509,14 @@ def __init__( dialect: "SQLDialectBase", expression: Union["Subquery", "TableFunctionExpression"], alias: Optional[str] = None, - join_type: str = "CROSS", + join_type: Any = "CROSS", ): super().__init__(dialect) - self.expression, self.alias, self.join_type = expression, alias, join_type + # Same vocabulary as JoinClause: a member, so the artifact carries the + # word and the formatter writes the phrase. Callers pass the bare + # keyword ("CROSS", "INNER"), which is what the members are named. + self.expression, self.alias = expression, alias + self.join_type = normalize_join_type(join_type) @property def format_method(self) -> str: @@ -489,13 +528,14 @@ def format_method(self) -> str: class JSONTableColumn: """A single column of a JSON_TABLE COLUMNS clause (option value object). - ``data_type`` is a ``DataType`` expression (a SQL clause of its own); the - option object itself does not render, but declares the expressions it - holds so dialect propagation reaches them. + ``data_type`` is the column's type: a type node, or a type name, which + the framework's name node claims and the grammar validates. The option + object itself does not render, but declares the expressions it holds so + dialect propagation reaches them. """ name: str - data_type: "DataType" + data_type: "Union[str, DataType]" path: str def expression_children(self): diff --git a/src/rhosocial/activerecord/backend/expression/registry.py b/src/rhosocial/activerecord/backend/expression/registry.py new file mode 100644 index 000000000..05f88ddd6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/expression/registry.py @@ -0,0 +1,307 @@ +# src/rhosocial/activerecord/backend/expression/registry.py +"""The expression registry: registration, checking and dispatch — nothing else. + +This is the registry the architecture rules describe (§7 of the +expression-dialect rules, §5.5 of the spec): a dialect exposes its formatting +capability for an expression **kind** by registering a render function here, +and callers dispatch through it. The registry does three things and nothing +more: + +- **登记 (register)** — an entry is ``kind`` + ``render(node)`` + an optional + ``supports(node)`` predicate, registered for one dialect. The functions + belong to the dialect package; the registry only indexes them. It does not + own implementations, does not judge names, and does not construct nodes — + whoever defines a kind brings its constructor, and ``{kind, params}`` + builds the tree recursively. +- **检查 (check)** — a boolean answer to "would this dialect render this + kind?". Unregistered means *not supported*: there is no separate "unknown + kind" state. +- **调用 (invoke)** — dispatch to the registered render function, failing with + one error class whose ``reason`` field is data: + ``kind_not_registered`` (the message always names the kind and the + dialect — it carries both "this dialect does not support it" and "this + assembly only loaded part of the functionality"), ``instance_unsupported`` + (registered, but this instance — version / session state — declines), and + ``spec_invalid`` (the supports predicate said yes but the formatter refused: + the predicate is an optimisation, never a guarantee, so the formatter's + refusal wins as a fast, structured failure). + +Version differences never affect registration (the same dialect class +registers the same kind set across all its versions; the difference lives in +``supports`` and inside the formatters). Registering the same +``(dialect, kind)`` twice is an assembly-time hard failure, not a silent +overwrite: which render wins must never depend on assembly order. + +Serialization does **not** go through the registry — a node carries its kind +and parameters and encodes directly. Deserialization resolves kinds through +the registry (currently still via class FQNs; the kind whitelist arrives with +the bare-node rewrite). +""" + +from enum import Enum +from typing import Dict, Optional, Set, Tuple, Type + +from .values import LiteralKind +from .core import BaseExpression + + +class RegistryReason(Enum): + """Structured failure reasons (§5.5.4 — the reason is data, not text).""" + + KIND_NOT_REGISTERED = "kind_not_registered" + INSTANCE_UNSUPPORTED = "instance_unsupported" + SPEC_INVALID = "spec_invalid" + + +class RegistryError(Exception): + """The one registry failure class; read ``reason``, don't parse messages. + + Attributes: + reason: A :class:`RegistryReason` naming the failure class. + dialect: The dialect name the failure is about (may be ``None`` when + the failure is not dialect-specific). + kind: The expression kind the failure is about (may be ``None``). + """ + + def __init__( + self, + reason: RegistryReason, + message: str, + *, + dialect: Optional[str] = None, + kind: Optional[str] = None, + ): + super().__init__(message) + self.reason = reason + self.dialect = dialect + self.kind = kind + + +def kind_of(expr_class: Type[BaseExpression]) -> str: + """The kind of an expression class, delegated to the class itself. + + The registry does not own naming (§7: "it does not judge names"). How a + kind is derived belongs to the expression class — see + :meth:`BaseExpression.kind` — and a backend owns the reserved tokens its own + compound identifier needs. This function exists only as the registry's + access path to that derivation, so callers holding a class do not have to + remember which object owns it. + """ + return expr_class.kind() + + +class ExpressionRegistry: + """The kind whitelist: ``kind`` -> expression class, and nothing else. + + The registry owns one mapping. It does not own naming (the class derives its + own kind, §4.11.1), it does not own rendering (an expression declares its + own ``format_method``, §3), and it does not construct nodes. + + It also does not *discover*. Nothing here imports an expression package or + walks subclasses: a kind exists because someone registered it. That is what + leaves a backend's own expressions exactly as privileged as a third-party + plugin -- neither is found, both are registered -- and what makes selective + assembly possible: whatever a package did not register answers + ``kind_not_registered``. + """ + + def __init__(self) -> None: + # The deserialization whitelist. See the node-table section below for + # why resolution is a lookup over up-front registrations and never an + # import driven by a name read out of an artifact (§4.11.4). + self._nodes: Dict[str, Type[BaseExpression]] = {} + # Kinds that are registered but must not be read right now. Kept apart + # from ``_nodes`` because "not registered" and "registered but off" are + # different answers: the first is a deployment that lacks the kind, the + # second is a deployment that has it and has switched it off. + self._disabled: Set[str] = set() + + def copy(self) -> "ExpressionRegistry": + """An independent twin carrying the same registrations. + + Faithful means faithful. The value kinds are carried over under their own + wire names rather than re-derived from the class behind them, because + :meth:`register_node` keys on the *class* name and every value kind + resolves to the same class. A twin rebuilt by re-registering what it finds + silently loses all fourteen of them -- and loses them silently, which is + the part that costs: the table looks populated and refuses every artifact + that carries a value. + """ + twin = ExpressionRegistry() + twin._nodes = dict(self._nodes) + twin._disabled = set(self._disabled) + return twin + + # ------------------------------------------------------------------ + # The node table: the kind whitelist deserialization resolves through. + # + # Serialization never consults it — a node carries its own identity. + # Deserialization consults it and nothing else: the three tables below are + # whitelists, not an import mechanism. Resolution is a dict lookup over + # classes registered up front, never an ``importlib`` call driven by a + # name read out of an artifact (§4.11.4 forbids the latter outright). + # + # Expression classes, enums and dataclasses are kept apart on purpose. + # They are three different shapes with three different contracts: only + # expression classes carry a kind and recurse into nested nodes; enums are + # finite value sets; dataclasses are structural records. Folding them into + # one table would let an artifact name any of the three where the other + # belongs, which is precisely what the whitelist is there to prevent. + # ------------------------------------------------------------------ + + def register_value_kinds(self, *only: str) -> "tuple": + """Let every value kind resolve to :class:`~.core.Literal`. + + Every value kind is enumerated in + :class:`~.values.LiteralKind`, not derived from class names, because + one class carries every value while the wire still names them apart: a + date's spelling and a string's are the same bytes, and only the kind + tells them apart (§4.11.1). Every one of them resolves to the same + class -- the difference is in the payload, and the decoder restores it + through the kind before it constructs. + + This is the single exception to "the kind is derived from the class + name", and it is narrow on purpose: no other kind has more than one + class behind it, so no other kind needs a second spelling. + + Args: + *only: Wire kind names to admit. Empty admits all of them; naming + any admits only those, which is how a channel allow-list is + built without reaching into the registry's own state. + """ + from .core import Literal + + wanted = set(only) if only else None + registered = [] + for member in LiteralKind: + if wanted is not None and member.wire not in wanted: + continue + self._nodes[member.wire] = Literal + registered.append(member.wire) + return tuple(registered) + + def register_node(self, expr_class: Type[BaseExpression]) -> str: + """Add one expression class to the kind whitelist. + + The kind is derived from the class name (§4.11.1) rather than stored, + so the class name is the only source of identity and the two can never + drift apart. Registering the same class twice is a no-op; two *different* + classes claiming the same kind is a hard error — which class an + artifact resolves to must not depend on registration order. + """ + kind = kind_of(expr_class) + existing = self._nodes.get(kind) + if existing is expr_class: + return kind + if existing is not None: + raise RegistryError( + RegistryReason.SPEC_INVALID, + f"kind '{kind}' is claimed by both " + f"{existing.__module__}.{existing.__name__} and " + f"{expr_class.__module__}.{expr_class.__name__}; the kind " + f"whitelist must resolve each kind to exactly one class", + kind=kind, + ) + self._nodes[kind] = expr_class + return kind + + def resolve_node(self, kind: str) -> Type[BaseExpression]: + """Resolve a kind to its expression class — the whitelist lookup. + + Raises: + RegistryError: ``kind_not_registered``, naming the kind. There is + no "unknown kind" state distinct from "not registered": a kind + that is not in the whitelist is a kind this build cannot read. + """ + try: + expr_class = self._nodes[kind] + except KeyError: + raise RegistryError( + RegistryReason.KIND_NOT_REGISTERED, + f"kind '{kind}' is not in the kind whitelist " + f"({len(self._nodes)} kinds registered); artifacts may only " + f"name a registered kind", + kind=kind, + ) from None + if kind in self._disabled: + raise RegistryError( + RegistryReason.INSTANCE_UNSUPPORTED, + f"kind '{kind}' is registered but disabled in this registry; " + f"the build knows the kind and declines to read it", + kind=kind, + ) + return expr_class + + def unregister_node(self, kind: str) -> None: + """Drop a kind from the whitelist. + + For selective assembly *after* the fact: a deployment that wants no DDL + can register everything and then remove the statement kinds it refuses + to accept. Dropping a kind nobody registered is a hard error -- it means + the caller believes something is there that is not. + """ + if kind not in self._nodes: + raise RegistryError( + RegistryReason.KIND_NOT_REGISTERED, + f"kind '{kind}' is not registered, so it cannot be unregistered", + kind=kind, + ) + del self._nodes[kind] + self._disabled.discard(kind) + + def disable(self, kind: str) -> None: + """Refuse a registered kind without forgetting it. + + Different from :meth:`unregister_node`: the kind stays in + :meth:`node_kinds` and can be re-enabled, which is what a per-request + capability switch needs. An unregistered kind is absent from the build; + a disabled one is present and declined. + """ + if kind not in self._nodes: + raise RegistryError( + RegistryReason.KIND_NOT_REGISTERED, + f"kind '{kind}' is not registered, so it cannot be disabled", + kind=kind, + ) + self._disabled.add(kind) + + def enable(self, kind: str) -> None: + """Re-allow a kind that :meth:`disable` switched off.""" + if kind not in self._nodes: + raise RegistryError( + RegistryReason.KIND_NOT_REGISTERED, + f"kind '{kind}' is not registered, so it cannot be enabled", + kind=kind, + ) + self._disabled.discard(kind) + + def node_kinds(self) -> Tuple[str, ...]: + """The whole kind whitelist, sorted — the enumerable contract list. + + Includes disabled kinds: the whitelist is what this build *knows*, and + a disabled kind is known. Use :meth:`is_enabled` to ask whether it may + be read right now. + """ + return tuple(sorted(self._nodes)) + + def is_enabled(self, kind: str) -> bool: + """Whether *kind* is registered and not switched off.""" + return kind in self._nodes and kind not in self._disabled + + +#: There is deliberately **no** module-level registry instance here. +#: +#: An earlier version declared one, named ``default_registry``, and it was +#: empty for the life of the process: nothing ever assembled into it, because +#: *which* expressions the framework ships is the framework's decision, not +#: this module's (§7 -- the registry does not own implementations, so it must +#: not import expression packages to learn what they contain). +#: +#: The name was the real damage. ``serialization.default_registry()`` is the +#: function that builds and caches the instance the framework actually uses, and +#: two things sharing one name means the import you want is not the import you +#: get. Anyone who reached for the object here got a table that admits nothing +#: and refused every artifact by name, with nothing to say why. +#: +#: Build your own -- :class:`ExpressionRegistry` starts empty, which is what makes +#: a narrow table safe to hand a caller. diff --git a/src/rhosocial/activerecord/backend/expression/roundtrip_testing.py b/src/rhosocial/activerecord/backend/expression/roundtrip_testing.py deleted file mode 100644 index d379e9d4c..000000000 --- a/src/rhosocial/activerecord/backend/expression/roundtrip_testing.py +++ /dev/null @@ -1,205 +0,0 @@ -# src/rhosocial/activerecord/backend/expression/roundtrip_testing.py -""" -Reusable serialization round-trip testing helpers for expression packages. - -Backend test suites (and the core suite) use this to exercise every -expression class in a given package subtree: - - from rhosocial.activerecord.backend.expression import ( - register_expressions_in_package, - roundtrip_through_all_encodings, - ) - -A backend-specific test then scans ``impl..expression``, registers -each class into :class:`ExpressionRegistry` (so deserialization can find it), -constructs an instance, and asserts the spec round-trips through the dict / -JSON / XML encodings with ``get_params()`` equality and ``to_sql()`` -consistency where the selected dialect supports it. -""" - -import importlib -import inspect -import pkgutil -import warnings -from typing import Any, Callable, Dict, Optional, Type - -from .bases import BaseExpression -from .serialization import ( - ExpressionRegistry, - deserialize, - deserialize_json, - deserialize_xml, - serialize, - serialize_json, - serialize_xml, -) - -# (module-name-suffix, factory(dialect)->expr) keyed by the class's FQN suffix. -BACKEND_SPECIAL_CONSTRUCTORS: Dict[str, Callable] = {} - - -def register_special_constructor(fqn_suffix: str, factory: Callable) -> None: - """Register an explicit constructor for a backend expression class. - - ``fqn_suffix`` matches against the class's full FQN (e.g. - ``"show.expressions.ShowColumnsExpression"``). The factory receives the - dialect and must return an expression instance. - """ - BACKEND_SPECIAL_CONSTRUCTORS[fqn_suffix] = factory - - -def collect_expression_classes(package_path: str) -> Dict[str, Type[BaseExpression]]: - """Walk a package subtree and collect every defined BaseExpression subclass. - - Returns ``{fqn: class}`` where ``fqn`` is ``module.ClassName``. - Reflects the actual submodule that defines the class (via - ``obj.__module__ == modname``) so re-exported references are not captured. - """ - 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 placeholder_for(param: inspect.Parameter) -> Any: - """Return a heuristic construction value for a required parameter.""" - annotation = param.annotation - if annotation is not inspect.Parameter.empty: - text = annotation if isinstance(annotation, str) else getattr(annotation, "__name__", "") - if "Expression" in text or "Subquery" in text or "Clause" in text: - return None # caller substitutes expression node - if "str" in text: - return "x" - if "int" in text: - return 1 - if "float" in text: - return 1.0 - if "bool" in text: - return True - name = param.name - if name in ("value",): - return 1 - if name in ("values", "columns", "args", "params", "predicates", "expressions"): - return [] - if "expression" in name or name in ("expr", "operand", "subquery", "query"): - return None - return "x" - - -def last_resort_construct(cls: Type[BaseExpression], dialect: Any) -> Optional[BaseExpression]: - """Heuristic construction; returns None on failure (or on missing symbols).""" - from .core import Column, Literal - from .predicates import ComparisonPredicate - - sig = inspect.signature(cls.__init__) - args = [] - kwargs = {} - # DataType-family declares dialect as trailing keyword. - first_param = next((p for p in sig.parameters if p != "self"), None) - dialect_as_kw = first_param != "dialect" - 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: - args.append(Literal(dialect, 1)) - continue - if param.default is not inspect.Parameter.empty: - continue - ph = placeholder_for(param) - if ph is None: - if pname in ("left", "right") or "predicate" in pname or "condition" in pname: - ph = ComparisonPredicate(dialect, "=", Column(dialect, "a"), Literal(dialect, 1)) - else: - ph = Literal(dialect, 1) - if param.kind == inspect.Parameter.KEYWORD_ONLY: - kwargs[pname] = ph - else: - args.append(ph) - try: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - if dialect_as_kw: - return cls(*args, dialect=dialect, **kwargs) - return cls(dialect, *args, **kwargs) - except Exception: - return None - - -def construct_expression(fqn: str, cls: Type[BaseExpression], dialect: Any): - """Best-effort construction. Returns (instance, source) or (None, reason).""" - special = BACKEND_SPECIAL_CONSTRUCTORS.get( - next((s for s in BACKEND_SPECIAL_CONSTRUCTORS if fqn.endswith(s)), "") - ) - if special is not None: - return special(dialect), "special" - instance = last_resort_construct(cls, dialect) - if instance is None: - return None, "heuristic-failed" - return instance, "heuristic" - - -def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: - for cls in classes.values(): - ExpressionRegistry.register(cls) - - -def roundtrip_expression(fqn, instance, dialect) -> None: - """Assert instance round-trips losslessly through all encodings.""" - original_params = instance.get_params() - - for restored in ( - deserialize(serialize(instance), dialect), - deserialize_json(serialize_json(instance), dialect), - deserialize_xml(serialize_xml(instance), dialect), - ): - _assert_params_equal(restored.get_params(), original_params, fqn) - - -def sql_consistent(fqn, instance, dialect) -> 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 ( - deserialize(serialize(instance), dialect), - deserialize_json(serialize_json(instance), dialect), - deserialize_xml(serialize_xml(instance), dialect), - ): - assert restored.to_sql() == expected, fqn - - -def _assert_params_equal(a, b, path): - if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): - _assert_params_equal(a.get_params(), b.get_params(), path + ".") - 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}" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/serialization.py b/src/rhosocial/activerecord/backend/expression/serialization.py index 28887abdc..90b29f55f 100644 --- a/src/rhosocial/activerecord/backend/expression/serialization.py +++ b/src/rhosocial/activerecord/backend/expression/serialization.py @@ -2,32 +2,40 @@ """ Expression serialization and deserialization utilities. -This module provides functions to serialize expression objects to JSON-compatible -dictionaries and reconstruct them from those dictionaries. The `dialect` is -intentionally NOT serialized - it must be supplied at deserialization time. - -Reserved special keys in serialized param dicts: - "__tuple__" → Python tuple value (since tuple is not JSON native) - "__expr__" → Nested BaseExpression instance - "__vdc__" → Dataclass value (FQN + field values), structurally encoded - so BaseExpression / Enum / nested-dataclass fields round-trip. - "__value__" → Non-JSON-native scalar encoded via a registered codec. - -IMPORTANT: User-defined expression params MUST NOT use these reserved keys -in their get_params() return values. Using these keys will cause data -corruption or deserialization errors. +An artifact is a **bare node**: ``{"kind": ..., "params": {...}}`` and nothing +else (§4.11.3). There is no envelope around it, and no marker vocabulary +inside it. + +* A node's **kind** is its identity -- derived from the class name (§4.11.1), + computed and never stored, so the two cannot drift. A literal is the one + node whose kind is not its class name: one class carries every value, and + the wire still names them apart, because a date's spelling and a string's + are the same bytes and the kind is what tells them apart. +* A nested node is a nested ``{"kind", "params"}``, recognised by its shape + alone. No key says "this is a node": a marker is a dispatch, and a dispatch + has to be agreed by every reader -- which is exactly what an artifact cannot + assume, when the same bytes must be read by four languages. +* Every value is a JSON scalar. A host type JSON cannot spell -- bytes, a + timedelta, a Decimal, a date -- travels as the text its kind names, and the + kind is what says how to read it back. A tuple travels as an array. + +Reading is the one moment a registry is needed: it is what turns a ``kind`` +back into a class. The registry (:mod:`.registry`) is that table and nothing +more -- it registers, checks and dispatches *rendering*; the serializer never +consults it, because a node carries its own identity. + +The artifact is untrusted input (§4.11.4), so a read fails explicitly and +names the kind: an unknown kind, a leftover envelope field, an unknown or +missing parameter. """ import inspect import json -import warnings -from dataclasses import is_dataclass -from typing import Any, Dict, Optional, Sequence, Type, TYPE_CHECKING +from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING from .bases import BaseExpression -from .codec import register_codec -from .codec import decode_value as _codec_decode_value -from .codec import encode_value as _codec_encode_value +from .core import Literal +from .values import LiteralKind if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase @@ -43,6 +51,7 @@ class ExpressionDeserializationError(Exception): _DEFAULT_WARN_THRESHOLD = 0.8 +# --------------------------------------------------------------------------- class ExpressionSerializer: """Serializer and deserializer for BaseExpression instances. @@ -73,7 +82,7 @@ class ExpressionSerializer: readonly = ExpressionSerializer(allowed_types=[ "rhosocial.activerecord.backend.expression.core.*", "rhosocial.activerecord.backend.expression.predicates.*", - "rhosocial.activerecord.backend.expression.statements.dql.QueryExpression", + "rhosocial.activerecord.backend.expression.statements.dql.QueryStatement", ]) """ @@ -110,197 +119,81 @@ def _type_allowed(self, fqn: str) -> bool: return False def serialize(self, expr: BaseExpression, _depth: int = 0) -> Dict[str, Any]: - """Serialize an expression instance into a JSON-serializable dict. + """Serialize to a bare `{kind, params}` artifact (§4.11.3). - The `dialect` is intentionally NOT embedded - it must be supplied at - deserialization time. If an expression is incompatible with a given - dialect, that failure occurs naturally at to_sql() time. - - Args: - expr: The expression instance to serialize. - _depth: Internal parameter for nested expression depth tracking. - - Returns: - ExpressionSpec dict with exactly two keys: type (FQN), params. - - Raises: - NotImplementedError: If the expression class doesn't implement get_params(). - """ - warn_level = int(self.max_depth * self.warn_threshold) - if _depth >= warn_level and not self._warn_issued: - warnings.warn( - f"Expression serialization depth ({_depth}) exceeds warning threshold " - f"({warn_level}). Deserialization may fail with max_depth={self.max_depth}. " - "Consider simplifying the expression tree or increasing max_depth.", - stacklevel=2, - ) - self._warn_issued = True - - params = self._serialize_value(expr.get_params(), _depth + 1) - return { - "type": f"{expr.__class__.__module__}.{expr.__class__.__name__}", - "params": params, - } - - def _serialize_value(self, value: Any, depth: int) -> Any: - """Recursively serialize a value, handling BaseExpression instances and containers.""" - if isinstance(value, BaseExpression): - return {"__expr__": self.serialize(value, _depth=depth)} - if is_dataclass(value) and not isinstance(value, type): - # Structurally encode a dataclass so its BaseExpression / Enum / - # nested-dataclass fields also round-trip. - import dataclasses - - return { - "__vdc__": [ - f"{type(value).__module__}.{type(value).__qualname__}", - { - f.name: self._serialize_value(getattr(value, f.name), depth + 1) - for f in dataclasses.fields(value) - }, - ] - } - if isinstance(value, tuple): - return {"__tuple__": [self._serialize_value(item, depth + 1) for item in value]} - if isinstance(value, list): - return [self._serialize_value(item, depth + 1) for item in value] - if isinstance(value, dict): - return {key: self._serialize_value(val, depth + 1) for key, val in value.items()} - encoded = _codec_encode_value(value) - if encoded is not None: - return encoded - return value - - def deserialize(self, spec: Dict[str, Any], dialect: "SQLDialectBase") -> BaseExpression: - """Reconstruct an expression from an ExpressionSpec dict. - - This is the entry point for deserialization. All depth tracking and - recursive processing happens internally. - - Args: - spec: The serialized dict produced by serialize(). - dialect: The dialect instance to inject into all reconstructed expressions. - If an expression class is incompatible with this dialect, - the error surfaces naturally at to_sql() time. - - Returns: - A fully reconstructed BaseExpression instance - - Raises: - ExpressionDeserializationError: On unknown type, missing required params, - or excessive nesting depth. + The same codec the module-level :func:serialize uses: an artifact is + an artifact regardless of who asked for it, so this class configures + rather than reimplements. Key order is fixed, and the dialect is absent + so the artifact can be re-rendered elsewhere. """ - return self._deserialize_expression(spec, dialect, depth=0) + return encode_artifact(expr, _depth) - def _deserialize_expression( + def deserialize( self, spec: Dict[str, Any], dialect: "SQLDialectBase", - depth: int, + registry: Any = None, ) -> BaseExpression: - """Deserialize an expression with depth tracking.""" - if depth > self.max_depth: - raise ExpressionDeserializationError( - f"Expression nesting depth exceeds maximum ({self.max_depth}). " - "This may be a malicious payload attempting to cause RecursionError." - ) - - fqn = spec.get("type") or "" - if not fqn: - raise ExpressionDeserializationError(f"Invalid spec: missing 'type' field. Got: {spec}") - - if "." not in fqn: - raise ExpressionDeserializationError( - f"'type' must be a fully qualified name (module.ClassName), got '{fqn}'" - ) + """Rebuild from a bare `{kind, params}` artifact. - if not self._type_allowed(fqn): - raise ExpressionDeserializationError( - f"Expression type '{fqn}' is not in the allowed types list" - ) - - try: - expr_class = ExpressionRegistry.lookup(fqn) - except ExpressionDeserializationError as e: - raise ExpressionDeserializationError(f"Cannot find expression class '{fqn}': {e}") from e - - if not issubclass(expr_class, BaseExpression): - raise ExpressionDeserializationError(f"'{fqn}' is not a BaseExpression subclass") + The artifact is untrusted input (§4.11.4): unknown kinds, leftover + envelope fields and unrecognised parameters all fail explicitly and + name the kind. `allowed_types`, when set, restricts which kinds this + reader may produce — a read-only channel can name only the kinds it + means to accept. + """ + if self.allowed_types is not None: + registry = self._restrict(registry) + return decode_artifact(spec, dialect, registry=registry) - params = spec.get("params", {}) - deserialized_params = self._deserialize_value(params, dialect, depth + 1) - try: - expr = _reconstruct(expr_class, dialect, deserialized_params) - except TypeError as e: - raise ExpressionDeserializationError(f"Failed to reconstruct expression '{fqn}': {e}") from e - return expr - - def _deserialize_value(self, value: Any, dialect: "SQLDialectBase", depth: int) -> Any: - """Recursively deserialize a value with depth tracking.""" - if depth > self.max_depth: - raise ExpressionDeserializationError( - f"Expression nesting depth exceeds maximum ({self.max_depth}). " - "This may be a malicious payload attempting to cause RecursionError." - ) + def _restrict(self, registry: Any): + """A registry view admitting only `allowed_types` kinds. - if isinstance(value, dict): - if "__value__" in value: - return _codec_decode_value(value) - if "__tuple__" in value: - return tuple(self._deserialize_value(item, dialect, depth + 1) for item in value["__tuple__"]) - if "__vdc__" in value: - return self._deserialize_dataclass(value["__vdc__"], dialect, depth) - if "__expr__" in value: - inner_spec = { - "type": value["__expr__"].get("type"), - "params": value["__expr__"].get("params", {}), - } - return self._deserialize_expression(inner_spec, dialect, depth + 1) - return {key: self._deserialize_value(val, dialect, depth + 1) for key, val in value.items()} - if isinstance(value, list): - return [self._deserialize_value(item, dialect, depth + 1) for item in value] - if isinstance(value, tuple): - return tuple(self._deserialize_value(item, dialect, depth + 1) for item in value) - return value - - def _deserialize_dataclass(self, payload: Any, dialect: "SQLDialectBase", depth: int) -> Any: - """Reconstruct a dataclass encoded via the ``__vdc__`` reserved key.""" - import importlib - - fqn, fields = payload - mod, _, qual = fqn.rpartition(".") - cls = getattr(importlib.import_module(mod), qual) - kwargs = { - name: self._deserialize_value(val, dialect, depth + 1) - for name, val in fields.items() - } - return cls(**kwargs) + Entries are kind names, optionally with shell-style globs (``"col*"``), + because a kind is the only identity that crosses the wire (§7.2) — + there is no import path left to prefix-match on. + Built by copying, so restricting a read never narrows the registry the + rest of the process uses. + """ + from fnmatch import fnmatchcase + + source = registry if registry is not None else default_registry() + patterns = tuple(self.allowed_types) + # Copy, then subtract, rather than rebuild from what matches. Rebuilding + # re-derives each key from the class behind it, which files all fourteen + # value kinds under ``literal`` -- and an artifact carrying a value would + # then be refused by a table that looks perfectly populated. + view = source.copy() + for kind in view.node_kinds(): + if kind in patterns or any(fnmatchcase(kind, p) for p in patterns): + continue + view.unregister_node(kind) + return view def serialize_json(self, expr: BaseExpression) -> str: """Serialize an expression instance into a JSON string. - The JSON container walking/escaping is delegated to the standard - library ``json``; only non-JSON-native values (datetime, Decimal, - bytes, UUID, set, Enum, ...) are encoded via :func:`register_codec` - codecs through the ``json.dumps(default=...)`` hook. + By the time the artifact exists every value is already a JSON scalar or + one of the two reserved wrappers (§``codec``), so ``json.dumps`` needs no + hook: there is nothing left that it would not know how to write. """ - return json.dumps(self.serialize(expr), default=_codec_encode_value) + return json.dumps(self.serialize(expr)) def deserialize_json(self, spec_str: str, dialect: "SQLDialectBase") -> BaseExpression: """Reconstruct an expression from a JSON string produced by :meth:`serialize_json`. - Non-JSON-native values are restored through the registered codecs - via the ``json.loads(object_hook=...)`` hook. + No ``object_hook`` here either. A bare scalar's type is declared by the + kind that holds it, so converting it is the node's business, not the + parser's -- see :func:`decode_artifact`. """ - restored = json.loads(spec_str, object_hook=_codec_decode_value) - return self.deserialize(restored, dialect) + return self.deserialize(json.loads(spec_str), dialect) def serialize_xml(self, expr: BaseExpression) -> bytes: """Serialize an expression instance into an XML document (bytes). - The spec (with codec-encoded ``__value__`` markers) is rendered into + The spec (with the two reserved value wrappers) is rendered into an XML document by :mod:`xml_serialization`, which uses the standard library ``xml.etree.ElementTree``. """ @@ -321,38 +214,462 @@ def deserialize_xml(self, payload: bytes, dialect: "SQLDialectBase") -> BaseExpr _default_serializer = ExpressionSerializer() +# --------------------------------------------------------------------------- +# Bare-node codec (§4.11.3 — the artifact IS the node: no envelope). +# +# Write and read are asymmetric about the registry (rules §7.2): +# +# serialize no registry at all — the node reports its own kind, derived +# from the class name. Nothing is looked up, so serialization +# works even when no registry was ever built. +# +# deserialize takes a registry. That is the one moment a kind has to be +# turned back into a class, and it is why the registry exists. +# +# The registry itself is an independent module that starts empty and knows +# nothing about which expressions exist; building the instance the framework +# hands out is the framework's business, below. +# --------------------------------------------------------------------------- + +#: The framework's default registry instance. Created empty, like any other; it +#: is filled by :func:_populate_default_registry, and only when someone +#: actually deserializes. A caller who only serializes never pays for it. +_default_registry = None + +#: Packages the framework registers into its default instance: the core +#: expressions plus the backends built into this distribution. +#: +#: ``backend.transaction`` is here because expressions carry its vocabulary. +#: ``IsolationLevel`` and ``TransactionMode`` are parameters of the transaction +#: statements, so a reader that cannot resolve them fails on an artifact the +#: library itself produced. A package list is a statement about which types the +#: artifact format admits, and it has to be as wide as the vocabulary -- which +#: is why it is checked against real instances rather than assumed complete. +_FRAMEWORK_PACKAGES = ( + "rhosocial.activerecord.backend.expression", + "rhosocial.activerecord.backend.transaction", + "rhosocial.activerecord.backend.impl.sqlite.expression", + "rhosocial.activerecord.backend.impl.dummy.expression", +) + +class ArtifactError(ExpressionDeserializationError): + """Raised when an artifact cannot be read. Always names the kind.""" + + +def _declares_a_formatter(cls): + """Whether *cls* resolves to a concrete ``format_method``. + + Asked by definition site, not by presence on ``vars(cls)``: a base such as + ``DeclaredValueType`` supplies one for its subclasses, and that is a real + declaration. Only the sentinel on :class:`BaseExpression` means "none". + """ + for klass in cls.__mro__: + if "format_method" in vars(klass): + return klass is not BaseExpression + return False + + +def _populate_default_registry(registry): + """Register the framework's expressions into a registry instance. + + This is the assembly point, and it deliberately lives here rather than in + the registry module: *which* expressions the framework ships is a decision + of the framework. The registry itself neither discovers nor knows. + + Kind collisions are collected across the whole walk before reporting, so + one run names every ambiguous kind. A collision is a real defect — the kind + comes from the class name (§4.11.1), so two same-named classes produce one + ambiguous kind and no artifact could say which it meant. + """ + import importlib + import pkgutil + + from .registry import RegistryError, RegistryReason + + by_kind = {} + ordered = [] + for pkgname in _FRAMEWORK_PACKAGES: + try: + pkg = importlib.import_module(pkgname) + except Exception: + continue # a backend that is not installed is not an error + # A backend may expose its expressions from a single module rather + # than a package (the dummy backend does), and `walk_packages` needs + # `__path__`. Walk a package; treat anything without one as the leaf + # it already is. + if hasattr(pkg, "__path__"): + modnames = [ + m for _, m, _ in pkgutil.walk_packages( + path=pkg.__path__, prefix=pkgname + ".", onerror=lambda x: None + ) + ] + else: + modnames = [pkgname] + 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, BaseExpression): + if obj is BaseExpression or getattr(obj, "__abstractmethods__", None): + continue + # A leading underscore is *not* a reason to skip: the + # dummy backend's own nodes are named that way and they + # round-trip like any other. Whatever is concrete and can + # render is a kind. + # A class that cannot name a formatter is not a node (§4.8), + # so it is not a kind. The test is the *resolved* + # ``format_method``: a base that supplies none inherits + # BaseExpression's sentinel, and registering it would put a + # name in the whitelist that no artifact could ever carry + # usefully -- it resolves, then raises on render. + if not _declares_a_formatter(obj): + continue + # The class says whether it belongs in the default table. + # A node whose rendered SQL is caller-vouched text opts out: + # the table is what a reader resolves an untrusted artifact + # against, so admitting such a kind would hand the artifact + # the choice of raw SQL. A caller that trusts its artifacts + # registers the class itself -- this is a default, not a ban, + # and the decision lives next to the class rather than in a + # list here that would have to be kept in step. + if not obj.REGISTER_BY_DEFAULT: + continue + ordered.append(obj) + + # Identity dedup: importing a package can leave more than one live class + # object for one source definition (a reload during the walk). Same class, + # so not a collision. + seen, collisions = set(), [] + for obj in ordered: + if id(obj) in seen: + continue + seen.add(id(obj)) + kind = obj.kind() + previous = by_kind.get(kind) + if previous is None: + by_kind[kind] = obj + registry.register_node(obj) + elif previous is not obj: + collisions.append((kind, previous, obj)) + if collisions: + detail = "; ".join( + f"kind '{k}' is claimed by both {a.__module__}.{a.__name__} and " + f"{b.__module__}.{b.__name__}" for k, a, b in collisions + ) + raise RegistryError(RegistryReason.SPEC_INVALID, detail) + return len(by_kind) + + +def default_registry(): + """The framework's default registry, built on first use. + + Returns: + A registry holding this framework's core and built-in backend + expressions. Empty until something deserializes, because serializing + never needs it. + """ + global _default_registry + if _default_registry is None: + from .registry import ExpressionRegistry + + # Build into a local and publish only on success: a half-populated + # instance cached under the module global would make every later call + # fail with a confusing "no such kind" instead of the real cause. + built = ExpressionRegistry() + built.register_value_kinds() + _populate_default_registry(built) + _default_registry = built + return _default_registry + + +def _kind_of(expr): + """The node's kind — the only place a kind is defined. + + Usually that is the class name (§4.11.1). A literal is the one node whose + kind depends on what it holds: one class carries all twelve value kinds, and + the wire still names them separately, because a date's spelling and a + string's are the same bytes and the kind is what tells them apart. + """ + if isinstance(expr, Literal): + if expr.value_kind is None: + raise ArtifactError( + f"a literal carrying {type(expr.value).__name__} has no value " + f"kind, so no artifact can name it; the kind is what tells a " + f"date's spelling apart from a string's" + ) + return expr.value_kind.wire + return type(expr).kind() + + +def _unpack_value(kind, params): + """Restore a value parameter from the spelling its kind declares. + + Only the value kinds have a payload to restore, and only the one parameter + a literal carries. The kind is what says a date arrived as its ISO text, so + the conversion happens here rather than in the constructor: a caller who + writes ``Literal(d, "2020-01-02")`` meant a string, and the same constructor + reading an artifact meant a date. Those two callers must not agree by + accident, so the artifact path is explicit about which one it is. + """ + try: + value_kind = LiteralKind.by_wire(kind) + except ValueError: + return params + if "value" not in params: + return params + unpacked = dict(params) + unpacked["value"] = value_kind.unpack(params["value"]) + return unpacked + + +def _is_node(value): + """Whether *value* has the shape of a nested artifact. + + A node is exactly ``{kind, params}`` and nothing else (§4.11.3), so the shape + alone identifies one -- no marker key, and no reading of the parameter's + declared type. Whether the position *wants* a node is a different question, + and the constructor answers it: an ``isinstance`` against what that parameter + accepts. + + The shape test deliberately does **not** consult the registry. Asking "is + this kind registered?" here would answer ``False`` for an unregistered kind + and pass the node through as a plain mapping, which is exactly the bypass the + whitelist exists to prevent. An unregistered kind must reach + :func:`decode_artifact` and be refused by name. + """ + return ( + isinstance(value, dict) + and set(value) == {"kind", "params"} + and isinstance(value.get("kind"), str) + and bool(value["kind"]) + and isinstance(value.get("params"), dict) + ) + + +def _encode_scalar(value): + """A host value as JSON carries it, given the kind it belongs to. + + The kind names the value, and the kind knows the spelling: bytes and + timedelta take the two reserved wrappers (wire §5), date/time/datetime and + Decimal take their canonical text because JSON has neither a date nor an + exact decimal, and int/float/bool/str/None *are* JSON scalars. So this is + :meth:`LiteralKind.pack` rather than a second, parallel table of the same + rules -- the conversion lives with the declaration of the type. + """ + return LiteralKind.of(value).pack(value) + + +def encode_artifact(expr, _depth=0): + """Encode one node as a bare `{kind, params}` artifact. No registry. + + Key order is fixed (`kind` then `params`, params in constructor order), + so two encodings of the same tree are byte-identical and can be compared. + """ + if _depth > _DEFAULT_MAX_DEPTH: + raise ArtifactError(f"expression nesting depth exceeds {_DEFAULT_MAX_DEPTH}") + kind = _kind_of(expr) + params = expr.get_params() + if not isinstance(params, dict): + raise ArtifactError( + f"kind '{kind}'.get_params() returned {type(params).__name__}, " + f"expected a dict of constructor parameters" + ) + return {"kind": kind, "params": _encode_value(params, _depth + 1)} + + +def _encode_value(value, depth): + if isinstance(value, BaseExpression): + # A nested node is its own artifact -- the same {kind, params} a + # top-level node is. No marker: the shape says what it is, and a marker + # would be a second, weaker spelling of the same fact. + return encode_artifact(value, depth) + if isinstance(value, Literal): + # The host value, written in the spelling its kind names -- and every one + # of those spellings is a JSON scalar, because that is the only thing a + # cross-language reader can be asked to parse. The kind declares what it + # is; there is no wrapper left to unwrap. + return _encode_scalar(value.value) + # A tuple goes out as an array, like a list. JSON draws no distinction and + # the artifact must not invent one: a parameter that needs to know it was a + # tuple is a parameter that should have been a node or a value. + if isinstance(value, (list, tuple)): + return [_encode_value(i, depth + 1) for i in value] + if isinstance(value, dict): + return {k: _encode_value(v, depth + 1) for k, v in value.items()} + # Anything left has to be a JSON scalar. A str enum is one (it is a str), and + # the value vocabulary covers the rest; a type it does not name raises there + # rather than being written out as something no reader can parse. + return _encode_scalar(value) + + +def _decode_value(value, dialect, depth, registry): + if depth > _DEFAULT_MAX_DEPTH: + raise ArtifactError(f"artifact nesting depth exceeds {_DEFAULT_MAX_DEPTH}") + if isinstance(value, dict): + if _is_node(value): + return decode_artifact(value, dialect, depth + 1, registry) + # Any other object is a plain mapping parameter, handed through + # untouched. There are no markers left to dispatch on: a value that was + # not JSON-native travelled as a scalar its own kind names, so nothing + # here has to recognise a spelling in order to undo it. + return {k: _decode_value(v, dialect, depth + 1, registry) for k, v in value.items()} + if isinstance(value, list): + return [_decode_value(i, dialect, depth + 1, registry) for i in value] + if isinstance(value, tuple): + # The XML and dataclass paths hand back real tuples rather than lists, + # and their items need decoding just as much. + return tuple(_decode_value(i, dialect, depth + 1, registry) for i in value) + return value + + +def decode_artifact(obj, dialect, _depth=0, registry=None): + """Rebuild one node from a bare `{kind, params}` artifact. + + Args: + obj: The artifact node — external, untrusted input. + dialect: The dialect to bind to the rebuilt tree. + registry: The registry to resolve kinds and enum / dataclass names + through. Defaults to the framework's own instance; pass your own to + control exactly which kinds this read may produce. + """ + + if _depth > _DEFAULT_MAX_DEPTH: + raise ArtifactError(f"artifact nesting depth exceeds {_DEFAULT_MAX_DEPTH}") + if registry is None: + registry = default_registry() + if not isinstance(obj, dict): + raise ArtifactError( + f"an artifact node must be an object with 'kind' and 'params', got " + f"{type(obj).__name__}" + ) + extra = sorted(set(obj) - {"kind", "params"}) + if extra: + raise ArtifactError( + f"artifact node has unexpected field(s) {extra}; a bare node carries " + f"exactly 'kind' and 'params' (§4.11.3) — leftover envelope fields " + f"are an explicit failure, not something to pass through" + ) + kind = obj.get("kind") + if not isinstance(kind, str) or not kind: + raise ArtifactError( + f"artifact node has no usable 'kind' (got {kind!r}); the kind is the " + f"node's identity and is mandatory" + ) + # A registry refusal is a decode failure, restated in the decode layer's + # own vocabulary: the registry is a lower layer and must not know what a + # reader catches. One exception type reaches the caller however the kind + # was rejected — unknown, or refused by this channel's allowlist. + from .registry import RegistryError as _RegistryError + + try: + cls = registry.resolve_node(kind) + except _RegistryError as exc: + raise ExpressionDeserializationError(str(exc)) from exc + + raw = obj.get("params", {}) + if not isinstance(raw, dict): + raise ArtifactError( + f"kind '{kind}' has non-object 'params' ({type(raw).__name__})" + ) + params = _decode_value(raw, dialect, _depth + 1, registry) + params = _unpack_value(kind, params) + + sig = inspect.signature(cls.__init__) + accepted, var_positional, var_keyword = {}, None, None + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind is inspect.Parameter.VAR_KEYWORD: + var_keyword = pname + elif param.kind is inspect.Parameter.VAR_POSITIONAL: + var_positional = pname + else: + accepted[pname] = param + if var_keyword is not None: + raise ArtifactError( + f"kind '{kind}' accepts **{var_keyword}, so its parameters cannot be " + f"matched by name; per §4.11.4 a parameter whose identity depends on " + f"how many extras arrived cannot be validated on read" + ) + + known = set(accepted) | ({var_positional} if var_positional else set()) + unknown = sorted(set(params) - known) + if unknown: + raise ArtifactError( + f"kind '{kind}' does not accept parameter(s) {unknown}; it accepts " + f"{sorted(known)}" + ) + missing = sorted(n for n, p in accepted.items() if p.default is p.empty and n not in params) + if missing: + raise ArtifactError(f"kind '{kind}' is missing required parameter(s) {missing}") + + if var_positional: + varargs = params.pop(var_positional, None) + if varargs is not None and not isinstance(varargs, (list, tuple)): + raise ArtifactError( + f"kind '{kind}' takes '*{var_positional}' as a list, got " + f"{type(varargs).__name__}" + ) + lead = [] + for pname in sig.parameters: + if pname == var_positional: + break + if pname in ("self", "dialect") or pname not in params: + continue + lead.append(pname) + kwargs = {n: params[n] for n in accepted if n in params and n not in lead} + args = [params[n] for n in lead] + list(varargs or []) + else: + kwargs, args = params, [] + + if "dialect" in sig.parameters: + first = next((n for n in sig.parameters if n != "self"), None) + return cls(dialect, *args, **kwargs) if first == "dialect" else cls(*args, **kwargs, dialect=dialect) + return cls(*args, **kwargs) + + def serialize(expr: BaseExpression) -> Dict[str, Any]: - """Serialize an expression instance into a JSON-serializable dict. + """Serialize an expression into a bare `{kind, params}` artifact (§4.11.3). - This is a convenience function that uses a default ExpressionSerializer instance. - For custom configuration, use ExpressionSerializer directly. + No registry: the node reports its own kind, derived from its class name. + The dialect is deliberately absent so the same artifact can be re-rendered + by a different dialect later. Args: expr: The expression instance to serialize. Returns: - ExpressionSpec dict with exactly two keys: type (FQN), params. + A dict with exactly two keys, in this order: `kind` and `params`. """ - return _default_serializer.serialize(expr) + return encode_artifact(expr) def deserialize( spec: Dict[str, Any], dialect: "SQLDialectBase", + registry: Any = None, ) -> BaseExpression: - """Reconstruct an expression from an ExpressionSpec dict. + """Rebuild an expression from a bare `{kind, params}` artifact. - This is a convenience function that uses a default ExpressionSerializer instance. - For custom configuration, use ExpressionSerializer directly. + The artifact is untrusted input (§4.11.4): an unknown kind, a leftover + envelope field, or an unknown or missing parameter each fail explicitly and + name the kind. This is the only place a registry is needed — it is what + turns a `kind` back into a class. Args: - spec: The serialized dict produced by serialize(). - dialect: The dialect instance to inject into all reconstructed expressions. - - Returns: - A fully reconstructed BaseExpression instance + spec: The artifact produced by :func:serialize. + dialect: The dialect to bind to the rebuilt tree. If a node is + incompatible with it, that surfaces at `to_sql()` time. + registry: Registry used to resolve kinds and enum / dataclass names. + Defaults to the framework's own instance; pass your own to control + exactly which kinds this read may produce. """ - return _default_serializer.deserialize(spec, dialect) + return decode_artifact(spec, dialect, registry=registry) def serialize_json(expr: BaseExpression) -> str: @@ -391,218 +708,3 @@ def deserialize_xml( Convenience wrapper around ``_default_serializer.deserialize_xml()``. """ return _default_serializer.deserialize_xml(payload, dialect) - - -def _reconstruct( - cls: Type[BaseExpression], - dialect: "SQLDialectBase", - params: Dict[str, Any], -) -> BaseExpression: - """Reconstruct an expression instance, handling VAR_POSITIONAL/VAR_KEYWORD parameters.""" - params = dict(params) - sig = inspect.signature(cls.__init__) - - varargs_param = None - varkwargs_param = None - for name, param in sig.parameters.items(): - if param.kind == inspect.Parameter.VAR_POSITIONAL: - varargs_param = name - elif param.kind == inspect.Parameter.VAR_KEYWORD: - varkwargs_param = name - - if varargs_param and varargs_param in params: - varargs = params.pop(varargs_param) - pos_args = [] - keyword_params = {} - named = {"self", "dialect", varargs_param} - for pname, param in sig.parameters.items(): - if pname in named: - continue - if pname in params: - if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - pos_args.append(params.pop(pname)) - else: - keyword_params[pname] = params.pop(pname) - if varkwargs_param: - # leftover keys that are not named parameters belong to **kwargs - keyword_params[varkwargs_param] = params - return cls(dialect, *pos_args, *varargs, **keyword_params) - - # Partition params: named ones vs. extras for **kwargs. - named_keys = { - pname - for pname, param in sig.parameters.items() - if pname not in ("self", "dialect") - and param.kind != inspect.Parameter.VAR_KEYWORD - } - named_params = {p: v for p, v in params.items() if p in named_keys} - extra_params = { - p: v - for p, v in params.items() - if p not in named_keys and p not in ("self", "dialect") and p != varkwargs_param - } - - valid_params = {p: v for p, v in named_params.items()} - - # Most expression classes declare dialect as the first parameter, but the - # DataType value-object family declares it as an optional trailing keyword - # (e.g. VarCharType(length=None, dialect=None)) — or not at all for - # backend value objects (e.g. MySQLYearType(display_width=None)). Detect - # the convention so dialect is only injected when the class accepts it. - if "dialect" not in sig.parameters: - return cls(**valid_params, **extra_params) - first_param = next( - (p for p in sig.parameters if p != "self"), - None, - ) - if first_param == "dialect": - return cls(dialect, **valid_params, **extra_params) - return cls(**{**valid_params, "dialect": dialect}, **extra_params) - - -class ExpressionFactory: - """Dependency-injection style factory for expression instantiation. - - Uses ExpressionSerializer for serialization/deserialization. - """ - - def __init__( - self, - dialect: "SQLDialectBase", - serializer: ExpressionSerializer = None, - ): - self._dialect = dialect - self._serializer = serializer or ExpressionSerializer() - - def create(self, type_name: str, **params: Any) -> BaseExpression: - """Instantiate an expression by class name + keyword params. - - Args: - type_name: The class name of the expression (e.g., "Column", "Literal"). - **params: Constructor parameters for the expression class. - - Returns: - A BaseExpression instance. - """ - return _reconstruct_by_name(type_name, self._dialect, params) - - def _create_from_spec(self, spec: Dict[str, Any]) -> BaseExpression: - """Reconstruct an expression from an ExpressionSpec dict using the bound dialect.""" - return self._serializer.deserialize(spec, self._dialect) - - def serialize(self, expr: BaseExpression) -> Dict[str, Any]: - """Serialize an expression using the factory's serializer.""" - return self._serializer.serialize(expr) - - -def _reconstruct_by_name( - type_name: str, - dialect: "SQLDialectBase", - params: Dict[str, Any], -) -> BaseExpression: - """Reconstruct an expression by class name using registry or import.""" - try: - expr_class = ExpressionRegistry.lookup(type_name) - except ExpressionDeserializationError as e: - raise ExpressionDeserializationError( - f"Expression class '{type_name}' not found in registry. " - f"Please register it first using ExpressionRegistry.register()." - ) from e - try: - return _reconstruct(expr_class, dialect, params) - except TypeError as e: - raise ExpressionDeserializationError(f"Failed to reconstruct expression '{type_name}': {e}") from e - - -class ExpressionRegistry: - """Maps expression FQN to their classes for deserialization. - - Pre-populated with all built-in expression classes. - Allows registration of user-defined expression subclasses. - """ - - _registry: Dict[str, Type[BaseExpression]] = {} - - @classmethod - def register(cls, expr_class: Type[BaseExpression]) -> None: - """Register an expression class using its FQN as key. - - Args: - expr_class: The expression class to register. - """ - fqn = f"{expr_class.__module__}.{expr_class.__name__}" - cls._registry[fqn] = expr_class - - @classmethod - def lookup(cls, fqn: str) -> Type[BaseExpression]: - """Look up an expression class by FQN or short class name. - - Lookup order: - 1. Direct FQN lookup (e.g., "rhosocial.activerecord.backend.expression.core.Column") - 2. Short name fallback (e.g., "Column") - only if unambiguous - - Note: - All expression classes must be pre-registered via ExpressionRegistry.register() - or automatically registered via _auto_register_builtins(). - - Args: - fqn: The fully qualified name or short class name. - - Returns: - The expression class. - - Raises: - ExpressionDeserializationError: If class not found or ambiguous. - """ - if fqn in cls._registry: - return cls._registry[fqn] - - if "." not in fqn: - matches = [cls_ for key, cls_ in cls._registry.items() if key.rsplit(".", 1)[-1] == fqn] - if len(matches) == 1: - return matches[0] - if len(matches) > 1: - fqns = [k for k in cls._registry if k.rsplit(".", 1)[-1] == fqn] - raise ExpressionDeserializationError(f"Ambiguous short name '{fqn}': found in {fqns}. Use FQN.") - - raise ExpressionDeserializationError( - f"Expression class '{fqn}' not found in registry. " - f"Please register it first using ExpressionRegistry.register()." - ) - - @classmethod - def _auto_register_builtins(cls) -> None: - """Auto-register all built-in expression classes. - - Walks the whole ``backend/expression`` package tree (including - ``statements/``, ``collation.py``, ``datetime.py``, ``xml.py`` and - ``types/``) and registers every class defined there that subclasses - :class:`~rhosocial.activerecord.backend.expression.bases.BaseExpression`. - Registration is idempotent: re-exported classes resolve to the same FQN. - """ - import pkgutil - import importlib - - pkg = importlib.import_module(__name__.rsplit(".", 1)[0]) - - for _, modname, _ in pkgutil.walk_packages( - path=pkg.__path__, - prefix=pkg.__name__ + ".", - onerror=lambda x: None, - ): - try: - sub_mod = importlib.import_module(modname) - except Exception: - continue - for name in dir(sub_mod): - obj = getattr(sub_mod, name, None) - if ( - isinstance(obj, type) - and obj.__module__ == modname - and issubclass(obj, BaseExpression) - and obj is not BaseExpression - ): - cls.register(obj) - - -ExpressionRegistry._auto_register_builtins() diff --git a/src/rhosocial/activerecord/backend/expression/sources/base.py b/src/rhosocial/activerecord/backend/expression/sources/base.py index cef7f0151..349bfb4de 100644 --- a/src/rhosocial/activerecord/backend/expression/sources/base.py +++ b/src/rhosocial/activerecord/backend/expression/sources/base.py @@ -64,4 +64,4 @@ def __init__( """ super().__init__(dialect) self.alias = alias - self.alias_need_quote = alias_need_quote \ No newline at end of file + self.alias_need_quote = self.quote_flag(alias_need_quote, "alias_need_quote") \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/statements/__init__.py b/src/rhosocial/activerecord/backend/expression/statements/__init__.py index b72170fbb..21e9d8381 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/__init__.py +++ b/src/rhosocial/activerecord/backend/expression/statements/__init__.py @@ -1,287 +1,296 @@ -# src/rhosocial/activerecord/backend/expression/statements/__init__.py -""" -SQL DML (Data Manipulation Language), DQL (Data Query Language), -and DDL (Data Definition Language) statements. - -These expression classes are responsible for collecting the parameters and structure -for a given SQL statement and delegating the actual SQL string generation -to a backend-specific dialect. -""" - -# DQL -from .dql import SelectModifier, QueryExpression - -# Re-export WhereClause and ForUpdateClause from query_parts for backward compatibility -from ..query_parts import WhereClause, ForUpdateClause - -# EXPLAIN -from .explain import ExplainType, ExplainFormat, ExplainOptions, ExplainExpression - -# DML -from .dml import ( - MergeActionType, - MergeAction, - MergeExpression, - ReturningClause, - DeleteExpression, - UpdateExpression, - InsertDataSource, - ValuesSource, - SelectSource, - DefaultValuesSource, - OnConflictClause, - InsertExpression, +from ._types import FromSourceType +from .fulltext_match import FulltextMatch +from .filter_clause import FilterClause +from .ddl_partition import ( + PartitionClause, + PartitionDefinition, + PartitionStrategy, + SubpartitionDefinition, ) - -# Table DDL +from .ddl_function import CreateFunctionStatement, DropFunctionStatement +from .ddl_trigger import ( + TriggerTiming, + TriggerEvent, + TriggerLevel, + CreateTriggerStatement, + DropTriggerStatement, +) +from .ddl_domain import ( + DomainNullability, + DomainValueExpression, + DomainCheckConstraint, + DomainAlterAction, + SetDomainDefaultAction, + DropDomainDefaultAction, + SetDomainNotNullAction, + DropDomainNotNullAction, + AddDomainCheckAction, + DropDomainCheckAction, + RenameDomainAction, + CreateDomainStatement, + AlterDomainStatement, + DropDomainStatement, +) +from .ddl_type import ( + TypeDefinition, + TypeAlterAction, + CreateTypeStatement, + AlterTypeStatement, + DropTypeStatement, +) +from .ddl_sequence import ( + CreateSequenceStatement, + DropSequenceStatement, + AlterSequenceStatement, +) +from .ddl_index import ( + CreateIndexStatement, + DropIndexStatement, + CreateFulltextIndexStatement, + DropFulltextIndexStatement, +) +from .ddl_schema import CreateSchemaStatement, DropSchemaStatement +from .ddl_truncate import TruncateStatement +from .ddl_view import ( + ColumnAlias, + ViewAlgorithm, + ViewCheckOption, + ViewOptions, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, +) +from .ddl_alter import ( + AlterTableActionType, + AlterTableAction, + AddColumnAction, + DropColumnAction, + ColumnAlterOperation, + AlterColumnAction, + AddTableConstraintAction, + DropTableConstraintAction, + AlterConstraintAction, + ValidateConstraintAction, + RenameTableAction, + RenameColumnAction, + AddIndexAction, + DropIndexAction, + AlterTableStatement, +) +from .ddl_comment import CommentObjectType, CommentOnStatement from .ddl_table import ( - ColumnConstraintType, + ForeignKeyMatchType, ColumnConstraint, DefaultValueClause, + ColumnClause, + CollationClause, + CharacterSetClause, IdentityClause, AutoIncrementClause, ReferencesClause, ColumnCommentClause, TableCommentClause, GeneratedColumnType, - GeneratedColumnExpression, ColumnDefinition, - TableConstraintType, ReferentialAction, ConstraintValidation, TableConstraint, ForeignKeyConstraint, IndexDefinition, - CreateTableExpression, - CreateTableAsExpression, - CreateTableLikeExpression, - CreateTableCloneExpression, - CreateTableFromTemplateExpression, + CreateTableStatement, + CreateTableAsStatement, + CreateTableLikeStatement, + CreateTableCloneStatement, + CreateTableFromTemplateStatement, CreateTableOptions, CreateTableCloneMode, - DropTableExpression, + DropTableStatement, StorageOptionsExpression, + ColumnConstraintBranch, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + TableConstraintBranch, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, + TableExcludeConstraint, +) +from .dml import ( + MergeActionType, + MergeAction, + MergeStatement, + ReturningClause, + DeleteStatement, + UpdateStatement, + InsertDataSource, + ValuesSource, + SelectSource, + DefaultValuesSource, + OnConflictClause, + InsertStatement, ) +from .explain import ExplainType, ExplainFormat, ExplainOptions, ExplainStatement +from ..query_parts import WhereClause, ForUpdateClause +from .dql import SelectModifier, QueryStatement +# src/rhosocial/activerecord/backend/expression/statements/__init__.py +""" +SQL DML (Data Manipulation Language), DQL (Data Query Language), +and DDL (Data Definition Language) statements. + +These expression classes are responsible for collecting the parameters and structure +for a given SQL statement and delegating the actual SQL string generation +to a backend-specific dialect. +""" + +# DQL + +# Re-export WhereClause and ForUpdateClause from query_parts for backward compatibility + +# EXPLAIN + +# DML + +# Table DDL # COMMENT ON DDL -from .ddl_comment import CommentObjectType, CommentOnExpression # ALTER TABLE DDL -from .ddl_alter import ( - AlterTableActionType, - AlterTableAction, - AddColumn, - DropColumn, - ColumnAlterOperation, - AlterColumn, - AddTableConstraint, - DropTableConstraint, - AlterConstraint, - ValidateConstraint, - AlterConstraintAction, - ValidateConstraintAction, - AlterTableConstraint, - ValidateTableConstraint, - RenameTable, - RenameObject, - AddIndex, - DropIndex, - AlterTableExpression, -) # View DDL -from .ddl_view import ( - ColumnAlias, - ViewAlgorithm, - ViewCheckOption, - ViewOptions, - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, -) # Truncate DDL -from .ddl_truncate import TruncateExpression # Schema DDL -from .ddl_schema import CreateSchemaExpression, DropSchemaExpression # Index DDL -from .ddl_index import ( - CreateIndexExpression, - DropIndexExpression, - CreateFulltextIndexExpression, - DropFulltextIndexExpression, -) # Sequence DDL -from .ddl_sequence import ( - CreateSequenceExpression, - DropSequenceExpression, - AlterSequenceExpression, -) - -from .ddl_type import ( - TypeDefinition, - TypeAlterAction, - CreateTypeExpression, - AlterTypeExpression, - DropTypeExpression, -) - -from .ddl_domain import ( - DomainNullability, - DomainValueExpression, - DomainCheckConstraint, - DomainAlterAction, - SetDomainDefaultAction, - DropDomainDefaultAction, - SetDomainNotNullAction, - DropDomainNotNullAction, - AddDomainCheckAction, - DropDomainCheckAction, - RenameDomainAction, - CreateDomainExpression, - AlterDomainExpression, - DropDomainExpression, -) # Trigger DDL -from .ddl_trigger import ( - TriggerTiming, - TriggerEvent, - TriggerLevel, - CreateTriggerExpression, - DropTriggerExpression, -) # Function DDL -from .ddl_function import CreateFunctionExpression, DropFunctionExpression # Partition DDL -from .ddl_partition import ( - PartitionClause, - PartitionDefinition, - PartitionStrategy, - SubpartitionDefinition, -) # Filter clause expression -from .filter_clause import FilterClauseExpression # Fulltext match expression -from .fulltext_match import FulltextMatchExpression # Re-export shared type alias -from ._types import FromSourceType -from ..objects import Table __all__ = [ - # DQL "SelectModifier", - "QueryExpression", - # Re-export from query_parts for backward compatibility + "QueryStatement", "WhereClause", "ForUpdateClause", - # EXPLAIN "ExplainType", "ExplainFormat", "ExplainOptions", - "ExplainExpression", - # DML + "ExplainStatement", "MergeActionType", "MergeAction", - "MergeExpression", + "MergeStatement", "ReturningClause", - "DeleteExpression", - "UpdateExpression", + "DeleteStatement", + "UpdateStatement", "InsertDataSource", "ValuesSource", "SelectSource", "DefaultValuesSource", "OnConflictClause", - "InsertExpression", - # Table DDL - "ColumnConstraintType", + "InsertStatement", + "ForeignKeyMatchType", + "ColumnConstraintBranch", + "ColumnPrimaryKeyConstraint", + "ColumnUniqueConstraint", + "ColumnNotNullConstraint", + "ColumnNullableConstraint", + "ColumnCheckConstraint", + "ColumnDefaultConstraint", + "ColumnForeignKeyConstraint", + "ColumnGeneratedConstraint", "ColumnConstraint", "DefaultValueClause", + "ColumnClause", + "CollationClause", + "CharacterSetClause", "IdentityClause", "AutoIncrementClause", "ReferencesClause", "ColumnCommentClause", "TableCommentClause", "GeneratedColumnType", - "GeneratedColumnExpression", "ColumnDefinition", - "TableConstraintType", "ReferentialAction", "ConstraintValidation", + "TableConstraintBranch", + "TablePrimaryKeyConstraint", + "TableUniqueConstraint", + "TableCheckConstraint", + "TableForeignKeyConstraint", + "TableExcludeConstraint", "TableConstraint", "ForeignKeyConstraint", "IndexDefinition", - "CreateTableExpression", - "CreateTableAsExpression", - "CreateTableLikeExpression", - "CreateTableCloneExpression", - "CreateTableFromTemplateExpression", + "CreateTableStatement", + "CreateTableAsStatement", + "CreateTableLikeStatement", + "CreateTableCloneStatement", + "CreateTableFromTemplateStatement", "CreateTableOptions", "CreateTableCloneMode", - "DropTableExpression", + "DropTableStatement", "StorageOptionsExpression", - # COMMENT ON DDL "CommentObjectType", - "CommentOnExpression", - # ALTER TABLE DDL + "CommentOnStatement", "AlterTableActionType", "AlterTableAction", - "AddColumn", - "DropColumn", + "AddColumnAction", + "DropColumnAction", "ColumnAlterOperation", - "AlterColumn", - "AddTableConstraint", - "DropTableConstraint", - "AlterConstraint", - "ValidateConstraint", + "AlterColumnAction", + "AddTableConstraintAction", + "DropTableConstraintAction", "AlterConstraintAction", "ValidateConstraintAction", - "AlterTableConstraint", - "ValidateTableConstraint", - "RenameTable", - "RenameObject", - "AddIndex", - "DropIndex", - "AlterTableExpression", - # View DDL + "RenameTableAction", + "RenameColumnAction", + "AddIndexAction", + "DropIndexAction", + "AlterTableStatement", "ColumnAlias", "ViewAlgorithm", "ViewCheckOption", "ViewOptions", - "CreateViewExpression", - "DropViewExpression", - "CreateMaterializedViewExpression", - "DropMaterializedViewExpression", - "RefreshMaterializedViewExpression", - # Truncate DDL - "TruncateExpression", - # Schema DDL - "CreateSchemaExpression", - "DropSchemaExpression", - # Index DDL - "CreateIndexExpression", - "DropIndexExpression", - "CreateFulltextIndexExpression", - "DropFulltextIndexExpression", - # Sequence DDL - "CreateSequenceExpression", - "DropSequenceExpression", - "AlterSequenceExpression", + "CreateViewStatement", + "DropViewStatement", + "CreateMaterializedViewStatement", + "DropMaterializedViewStatement", + "RefreshMaterializedViewStatement", + "TruncateStatement", + "CreateSchemaStatement", + "DropSchemaStatement", + "CreateIndexStatement", + "DropIndexStatement", + "CreateFulltextIndexStatement", + "DropFulltextIndexStatement", + "CreateSequenceStatement", + "DropSequenceStatement", + "AlterSequenceStatement", "TypeDefinition", "TypeAlterAction", - "CreateTypeExpression", - "AlterTypeExpression", - "DropTypeExpression", + "CreateTypeStatement", + "AlterTypeStatement", + "DropTypeStatement", "DomainNullability", "DomainValueExpression", "DomainCheckConstraint", @@ -293,27 +302,21 @@ "AddDomainCheckAction", "DropDomainCheckAction", "RenameDomainAction", - "CreateDomainExpression", - "AlterDomainExpression", - "DropDomainExpression", - # Trigger DDL + "CreateDomainStatement", + "AlterDomainStatement", + "DropDomainStatement", "TriggerTiming", "TriggerEvent", "TriggerLevel", - "CreateTriggerExpression", - "DropTriggerExpression", - # Function DDL - "CreateFunctionExpression", - "DropFunctionExpression", - # Partition DDL + "CreateTriggerStatement", + "DropTriggerStatement", + "CreateFunctionStatement", + "DropFunctionStatement", "PartitionClause", "PartitionDefinition", "PartitionStrategy", "SubpartitionDefinition", - # Filter clause expression - "FilterClauseExpression", - # Fulltext match expression - "FulltextMatchExpression", - # Type aliases + "FilterClause", + "FulltextMatch", "FromSourceType", ] diff --git a/src/rhosocial/activerecord/backend/expression/statements/_types.py b/src/rhosocial/activerecord/backend/expression/statements/_types.py index 0f16fd6e1..eeaa511bb 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/_types.py +++ b/src/rhosocial/activerecord/backend/expression/statements/_types.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: # pragma: no cover from ..core import Subquery - from ..query_sources import SetOperationExpression, ValuesExpression, TableFunctionExpression, LateralExpression + from ..query_sources import SetOperation, ValuesExpression, TableFunctionExpression, LateralExpression from ..query_parts import JoinClause from ..graph import GraphTableExpression @@ -15,7 +15,7 @@ str, # Table name as string "NamedRelationRef", # Single table "Subquery", # Subquery - "SetOperationExpression", # Set operations (UNION, etc.) + "SetOperation", # Set operations (UNION, etc.) "JoinClause", # Join expression (treated as a single object) "ValuesExpression", # VALUES expression "TableFunctionExpression", # Table function diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py index 9d6bf5588..c477dfc2e 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py @@ -1,3 +1,14 @@ +from .ddl_table import ( + ColumnDefinition, + IndexDefinition, + TableCheckConstraint, + TableConstraint, + TableForeignKeyConstraint, +) +from ..objects import Index, Table +from ..bases import BaseExpression +from typing import Any, List, Optional, Union, TYPE_CHECKING +from enum import Enum # src/rhosocial/activerecord/backend/expression/statements/ddl_alter.py """ALTER TABLE DDL statement expressions. @@ -27,7 +38,7 @@ DEFAULT/IDENTITY/UNIQUE/PRIMARY KEY/FOREIGN KEY on the same column. Carrying the qualifiers: - ``AddColumn``, ``DropColumn``, and ``DropTableConstraint`` expose + ``AddColumnAction``, ``DropColumnAction``, and ``DropTableConstraintAction`` expose ``if_not_exists`` / ``if_exists`` optional fields (default ``None``) so application code can request idempotent ALTER TABLE actions uniformly across backends. The fields are inert at the core layer: @@ -45,30 +56,20 @@ ``USER_TAB_COLUMNS``, ``RDB$RELATION_FIELDS``, etc.). """ -from enum import Enum -from typing import Any, List, Optional, Union, TYPE_CHECKING - -from ..bases import BaseExpression -from ..objects import Index, Table -from .ddl_table import ( - ColumnConstraintType, - ColumnDefinition, - IndexDefinition, - TableConstraint, - TableConstraintType, -) - if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase -class AlterTableActionType(Enum): +class AlterTableActionType(str, Enum): """Type of action for ALTER TABLE statement. Each value corresponds to exactly one concrete ``AlterTableAction`` subclass; the mapping is maintained manually and verified by tests. """ + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + ADD_COLUMN = "ADD COLUMN" DROP_COLUMN = "DROP COLUMN" ALTER_COLUMN = "ALTER COLUMN" @@ -96,7 +97,7 @@ class AlterTableAction(BaseExpression): action_type: AlterTableActionType -class AddColumn(AlterTableAction): +class AddColumnAction(AlterTableAction): """Represents an 'ADD COLUMN' action per SQL standard. Note: @@ -136,7 +137,7 @@ def __init__( self.if_not_exists: Optional[bool] = if_not_exists -class DropColumn(AlterTableAction): +class DropColumnAction(AlterTableAction): """Represents a 'DROP COLUMN' action per SQL standard. Note: @@ -173,9 +174,12 @@ def __init__( self.if_exists: Optional[bool] = if_exists -class ColumnAlterOperation(Enum): +class ColumnAlterOperation(str, Enum): """SQL standard column operation types""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + SET_DEFAULT = "SET DEFAULT" DROP_DEFAULT = "DROP DEFAULT" SET_NOT_NULL = "SET NOT NULL" # Non-standard but widely supported @@ -183,7 +187,25 @@ class ColumnAlterOperation(Enum): SET_DATA_TYPE = "SET DATA TYPE" # SQL standard: ALTER COLUMN ... SET DATA TYPE -class AlterColumn(AlterTableAction): +def operation_spelling(operation: Union["ColumnAlterOperation", str]) -> str: + """The SQL spelling of an alter operation. + + An operation may be given as the enum or as its raw spelling; which one + it is is a type question, so it is asked with isinstance rather than + probed for a ``value`` attribute. + + Args: + operation: The operation, as the enum member or as its spelling. + + Returns: + The spelling, e.g. ``"SET DATA TYPE"``. + """ + from enum import Enum + + return operation.value if isinstance(operation, Enum) else str(operation) + + +class AlterColumnAction(AlterTableAction): """Represents an 'ALTER COLUMN' action per SQL standard.""" @property @@ -209,11 +231,15 @@ def __init__( super().__init__(dialect) self.column_name: str = column_name self.operation: Union[ColumnAlterOperation, str] = operation + if operation_spelling(operation) == "SET DATA TYPE" and new_value is not None: + from ..types import DataType + + new_value = DataType.as_type(dialect, new_value) self.new_value: Any = new_value self.cascade: bool = cascade -class AddTableConstraint(AlterTableAction): +class AddTableConstraintAction(AlterTableAction): """SQL standard ADD CONSTRAINT operation""" @property @@ -233,7 +259,7 @@ def __init__( self.constraint: TableConstraint = constraint -class DropTableConstraint(AlterTableAction): +class DropTableConstraintAction(AlterTableAction): """SQL standard DROP CONSTRAINT operation. Note: @@ -274,19 +300,24 @@ def __init__( self.cascade: bool = cascade -class AlterConstraint(AlterTableAction): +class AlterConstraintAction(AlterTableAction): """Typed ``ALTER CONSTRAINT`` enforcement action. The enforcement keyword is mandatory in the action's grammar, so exactly one of ``enforced`` / ``not_enforced`` must be set. Setting both, or neither, raises ``ValueError`` -- the action *is* the keyword. + + ``constraint_kind`` names the branch the keyword rides on: only a CHECK or + a FOREIGN KEY can carry it, and the branch class is what says so. """ action_type: AlterTableActionType = AlterTableActionType.ALTER_CONSTRAINT constraint_name: str enforced: bool not_enforced: bool - constraint_type: TableConstraintType + + #: The kinds that may carry the enforcement trait in this grammar. + ALLOWED_BRANCHES = (TableCheckConstraint, TableForeignKeyConstraint) def __init__( self, @@ -296,18 +327,14 @@ def __init__( not_enforced: bool = False, *, name: Optional[str] = None, - constraint_type: Union[ - ColumnConstraintType, - TableConstraintType, - str, - ], + constraint_kind: Union[type, str], ) -> None: super().__init__(dialect) if enforced and not_enforced: raise ValueError("enforced and not_enforced are mutually exclusive options") if not (enforced or not_enforced): raise ValueError( - "AlterConstraint requires exactly one of enforced=True or " + "AlterConstraintAction requires exactly one of enforced=True or " "not_enforced=True: the enforcement keyword is mandatory" ) if constraint_name is None: @@ -316,42 +343,38 @@ def __init__( raise ValueError("name and constraint_name must match") if not isinstance(constraint_name, str) or not constraint_name.strip(): raise ValueError("constraint_name must be a non-empty string") - raw_value = getattr(constraint_type, "value", constraint_type) - raw_name = getattr(constraint_type, "name", raw_value) - normalized_type = None - for candidate in (raw_value, raw_name): - if not isinstance(candidate, str): - continue - normalized = " ".join(candidate.strip().upper().split()) - for value in (normalized, normalized.replace(" ", "_")): - try: - normalized_type = TableConstraintType(value) - break - except ValueError: - continue - if normalized_type is not None: - break - if normalized_type is None: - raise TypeError( - "constraint_type must be a CHECK or FOREIGN KEY constraint type" - ) - if normalized_type not in {TableConstraintType.CHECK, TableConstraintType.FOREIGN_KEY}: + if isinstance(constraint_kind, str): + # The spelling a caller writes ("CHECK", "FOREIGN_KEY"), resolved + # to the branch class by name -- the tag enum the branches + # replaced is gone, and a spelling is still a kind. + normalized = " ".join(constraint_kind.strip().upper().split()).replace(" ", "_") + matched = [ + branch + for branch in self.ALLOWED_BRANCHES + if branch.__name__.replace("Table", "").upper() == normalized + ] + if not matched: + raise ValueError( + "constraint_kind must name a CHECK or FOREIGN KEY constraint" + ) + constraint_kind = matched[0] + if constraint_kind not in self.ALLOWED_BRANCHES: raise ValueError( - "constraint_type must be TableConstraintType.CHECK or " - "TableConstraintType.FOREIGN_KEY" + "constraint_kind must be TableCheckConstraint or TableForeignKeyConstraint" ) self.constraint_name = constraint_name self.name = constraint_name self.enforced = enforced self.not_enforced = not_enforced - self.constraint_type = normalized_type + #: The branch class whose kind this keyword is spelled on. + self.constraint_branch = constraint_kind @property def format_method(self) -> str: return "format_alter_constraint_action" -class ValidateConstraint(AlterTableAction): +class ValidateConstraintAction(AlterTableAction): """Typed ``VALIDATE CONSTRAINT`` action.""" action_type: AlterTableActionType = AlterTableActionType.VALIDATE_CONSTRAINT @@ -360,7 +383,7 @@ class ValidateConstraint(AlterTableAction): def __init__( self, dialect: "SQLDialectBase", - constraint_name: Optional[str] = None, + constraint_name: str, *, name: Optional[str] = None, ) -> None: @@ -378,14 +401,7 @@ def __init__( def format_method(self) -> str: return "format_validate_constraint_action" - -AlterConstraintAction = AlterConstraint -ValidateConstraintAction = ValidateConstraint -AlterTableConstraint = AlterConstraint -ValidateTableConstraint = ValidateConstraint - - -class RenameTable(AlterTableAction): +class RenameTableAction(AlterTableAction): """SQL standard RENAME TABLE operation""" @property @@ -408,8 +424,8 @@ def __init__( self.new_name: str = new_name -class RenameObject(AlterTableAction): - """Represents a 'RENAME' action for columns or tables.""" +class RenameColumnAction(AlterTableAction): + """A ``RENAME COLUMN`` action. Renaming a table is :class:`RenameTableAction`.""" @property def format_method(self) -> str: @@ -419,23 +435,19 @@ def format_method(self) -> str: action_type: AlterTableActionType = AlterTableActionType.RENAME_COLUMN old_name: str new_name: str - object_type: str def __init__( self, dialect: "SQLDialectBase", old_name: str, new_name: str, - *, - object_type: str = "COLUMN", ) -> None: super().__init__(dialect) self.old_name: str = old_name self.new_name: str = new_name - self.object_type: str = object_type -class AddIndex(AlterTableAction): +class AddIndexAction(AlterTableAction): """Represents an 'ADD INDEX' action.""" @property @@ -455,7 +467,7 @@ def __init__( self.index: IndexDefinition = index -class DropIndex(AlterTableAction): +class DropIndexAction(AlterTableAction): """Represents a 'DROP INDEX' action.""" @property @@ -479,7 +491,7 @@ def __init__( self.if_exists: bool = if_exists -class ModifyColumn(AlterTableAction): +class ModifyColumnAction(AlterTableAction): """Represents a 'MODIFY COLUMN' action. Redefines a column with a complete new specification. @@ -511,7 +523,7 @@ def __init__( self.after_column: Optional[str] = after_column -class ChangeColumn(AlterTableAction): +class ChangeColumnAction(AlterTableAction): """Represents a 'CHANGE COLUMN' action. Renames a column and redefines it with a complete new specification. @@ -545,7 +557,7 @@ def __init__( self.after_column: Optional[str] = after_column -class AlterTableExpression(BaseExpression): +class AlterTableStatement(BaseExpression): """ Represents a comprehensive ALTER TABLE statement supporting SQL standard functionality. @@ -559,38 +571,33 @@ class AlterTableExpression(BaseExpression): Examples: # Add column - alter_expr = AlterTableExpression( - dialect, + alter_expr = AlterTableStatement( table=Table(dialect, "users"), - actions=[AddColumn(dialect, column=ColumnDefinition(dialect, "email", "VARCHAR(100)"))] + actions=[AddColumnAction(dialect, column=ColumnDefinition(dialect, "email", "VARCHAR(100)"))] ) # Drop column - alter_expr = AlterTableExpression( - dialect, + alter_expr = AlterTableStatement( table=Table(dialect, "products"), - actions=[DropColumn(dialect, column_name="description")] + actions=[DropColumnAction(dialect, column_name="description")] ) # Multiple actions in one statement - alter_expr = AlterTableExpression( - dialect, + alter_expr = AlterTableStatement( table=Table(dialect, "orders"), actions=[ - AddColumn(dialect, column=ColumnDefinition(dialect, "status", "VARCHAR(20)")), - RenameObject(dialect, old_name="id", new_name="order_id") + AddColumnAction(dialect, column=ColumnDefinition(dialect, "status", "VARCHAR(20)")), + RenameColumnAction(dialect, old_name="id", new_name="order_id") ] ) # Add constraint - alter_expr = AlterTableExpression( - dialect, + alter_expr = AlterTableStatement( table=Table(dialect, "users"), actions=[ - AddTableConstraint( - dialect, + AddTableConstraintAction( constraint=TableConstraint(dialect, - constraint_type=TableConstraintType.CHECK, + constraint_kind=TableCheckConstraint, check_condition=Column(dialect, "age") > Literal(dialect, 0) ) ) @@ -598,12 +605,10 @@ class AlterTableExpression(BaseExpression): ) # Alter column properties - alter_expr = AlterTableExpression( - dialect, + alter_expr = AlterTableStatement( table=Table(dialect, "products"), actions=[ - AlterColumn( - dialect, + AlterColumnAction( column_name="price", operation=ColumnAlterOperation.SET_DEFAULT, new_value="0.00" diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_comment.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_comment.py index e9789fd72..69f048550 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_comment.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_comment.py @@ -23,7 +23,7 @@ from ...dialect import SQLDialectBase -class CommentObjectType(Enum): +class CommentObjectType(str, Enum): """Database object kinds a standalone ``COMMENT ON`` may target. The enum value is the SQL keyword sequence (``TABLE`` / ``COLUMN`` / @@ -32,6 +32,9 @@ class CommentObjectType(Enum): formatters accept either the enum or its string value. """ + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + TABLE = "TABLE" COLUMN = "COLUMN" VIEW = "VIEW" @@ -47,7 +50,7 @@ class CommentObjectType(Enum): TABLESPACE = "TABLESPACE" -class CommentOnExpression(BaseExpression): +class CommentOnStatement(BaseExpression): """Standalone ``COMMENT ON IS ''`` statement. This is an independent DDL statement, deliberately distinct from the @@ -63,7 +66,7 @@ class CommentOnExpression(BaseExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_comment_statement" + return "format_comment_on_statement" def __init__( self, @@ -82,4 +85,4 @@ def __init__( self.column = column -__all__ = ["CommentObjectType", "CommentOnExpression"] +__all__ = ["CommentObjectType", "CommentOnStatement"] diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_database.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_database.py index d8e7776f6..5c3096367 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_database.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_database.py @@ -11,7 +11,9 @@ from ...dialect import SQLDialectBase -class AlterDatabaseAction(Enum): +class AlterDatabaseAction(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ """Actions for ALTER DATABASE statements.""" RENAME_TO = "RENAME TO" @@ -27,7 +29,7 @@ class AlterDatabaseAction(Enum): DISABLE_FAILOVER = "DISABLE FAILOVER" -class CreateDatabaseExpression(BaseExpression): +class CreateDatabaseStatement(BaseExpression): """Represents a CREATE DATABASE statement. Supports common parameters across multiple database backends. @@ -67,7 +69,7 @@ def __init__( self.or_replace = or_replace -class DropDatabaseExpression(BaseExpression): +class DropDatabaseStatement(BaseExpression): """Represents a DROP DATABASE statement. Supports common parameters across multiple database backends. @@ -93,7 +95,7 @@ def __init__( self.force = force -class AlterDatabaseExpression(BaseExpression): +class AlterDatabaseStatement(BaseExpression): """Represents an ALTER DATABASE statement. Uses an action-based pattern similar to ALTER TABLE. @@ -123,7 +125,7 @@ def __init__( __all__ = [ "AlterDatabaseAction", - "CreateDatabaseExpression", - "DropDatabaseExpression", - "AlterDatabaseExpression", + "CreateDatabaseStatement", + "DropDatabaseStatement", + "AlterDatabaseStatement", ] diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_domain.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_domain.py index e080252e1..617cf659d 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_domain.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_domain.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Any, List, Optional, Sequence, TYPE_CHECKING -from ..bases import BaseExpression, SQLPredicate, SQLValueExpression +from ..bases import BaseExpression, SQLPredicate, SQLValue from ..mixins import ( ComparisonMixin, StringPatternPredicateMixin, @@ -30,7 +30,9 @@ def _normalize_default(dialect: "SQLDialectBase", value: Any) -> DefaultValueCla return DefaultValueClause(dialect, value) -class DomainNullability(Enum): +class DomainNullability(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ """Nullability declaration for a domain.""" UNSPECIFIED = "UNSPECIFIED" @@ -42,7 +44,7 @@ class DomainValueExpression( ComparisonMixin, StringPatternPredicateMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """The ``VALUE`` expression available inside a domain check.""" @@ -52,7 +54,13 @@ def format_method(self) -> str: class DomainCheckConstraint(BaseExpression): - """A domain ``CHECK`` constraint with an optional constraint name.""" + """A domain ``CHECK`` constraint with an optional constraint name. + + A CHECK position accepts no bind parameters, so the class declares + the literals are marked inline; the dialect renders the condition inline-only + and refuses any residual bind value. + """ + @property def format_method(self) -> str: @@ -184,7 +192,7 @@ def __init__(self, dialect: "SQLDialectBase", new_name: str) -> None: self.new_name = new_name -class CreateDomainExpression(BaseExpression): +class CreateDomainStatement(BaseExpression): """Expression for ``CREATE DOMAIN``.""" def __init__( @@ -251,7 +259,7 @@ def format_method(self) -> str: return "format_create_domain_statement" -class AlterDomainExpression(BaseExpression): +class AlterDomainStatement(BaseExpression): """Expression for ``ALTER DOMAIN``.""" def __init__( @@ -276,7 +284,7 @@ def format_method(self) -> str: return "format_alter_domain_statement" -class DropDomainExpression(BaseExpression): +class DropDomainStatement(BaseExpression): """Expression for ``DROP DOMAIN``.""" def __init__( @@ -308,7 +316,7 @@ def format_method(self) -> str: "AddDomainCheckAction", "DropDomainCheckAction", "RenameDomainAction", - "CreateDomainExpression", - "AlterDomainExpression", - "DropDomainExpression", + "CreateDomainStatement", + "AlterDomainStatement", + "DropDomainStatement", ] diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_function.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_function.py index d142922f6..c8de81939 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_function.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_function.py @@ -10,11 +10,11 @@ from ...dialect import SQLDialectBase -class CreateFunctionExpression(BaseExpression): +class CreateFunctionStatement(BaseExpression): """SQL/PSM standard CREATE FUNCTION statement. Examples: - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dialect, function=Function(dialect, "calculate_total"), parameters=[ @@ -51,11 +51,11 @@ def format_method(self) -> str: return "format_create_function_statement" -class DropFunctionExpression(BaseExpression): +class DropFunctionStatement(BaseExpression): """SQL/PSM standard DROP FUNCTION statement. Examples: - drop_func = DropFunctionExpression( + drop_func = DropFunctionStatement( dialect, function=Function(dialect, "calculate_total"), if_exists=True @@ -72,8 +72,7 @@ def __init__( restrict: bool = False, ): super().__init__(dialect) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.function = function self.if_exists = if_exists self.parameters = parameters diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_index.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_index.py index c5839980a..65be2775e 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_index.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_index.py @@ -10,17 +10,21 @@ from ...dialect import SQLDialectBase -class CreateIndexExpression(BaseExpression): +class CreateIndexStatement(BaseExpression): """ Represents a CREATE INDEX statement for standalone index creation. Note: This is for creating indexes on existing tables. For inline - index definitions during table creation, use CreateTableExpression + index definitions during table creation, use CreateTableStatement with the indexes parameter. + An index is a DDL position: the class declares the literals are marked inline + and its partial-index ``WHERE`` condition renders inline-only (no bind + parameters). + Examples: # Basic index - create_idx = CreateIndexExpression( + create_idx = CreateIndexStatement( dialect, index=Index(dialect, "idx_users_email"), table=Table(dialect, "users"), @@ -28,7 +32,7 @@ class CreateIndexExpression(BaseExpression): ) # Unique index - create_idx = CreateIndexExpression( + create_idx = CreateIndexStatement( dialect, index=Index(dialect, "idx_users_username"), table=Table(dialect, "users"), @@ -37,7 +41,7 @@ class CreateIndexExpression(BaseExpression): ) # Composite index - create_idx = CreateIndexExpression( + create_idx = CreateIndexStatement( dialect, index=Index(dialect, "idx_orders_user_date"), table=Table(dialect, "orders"), @@ -45,7 +49,7 @@ class CreateIndexExpression(BaseExpression): ) # Partial index (PostgreSQL) - create_idx = CreateIndexExpression( + create_idx = CreateIndexStatement( dialect, index=Index(dialect, "idx_active_users"), table=Table(dialect, "users"), @@ -54,7 +58,7 @@ class CreateIndexExpression(BaseExpression): ) # Index with specific type - create_idx = CreateIndexExpression( + create_idx = CreateIndexStatement( dialect, index=Index(dialect, "idx_users_name_hash"), table=Table(dialect, "users"), @@ -89,32 +93,33 @@ def __init__( self.tablespace = tablespace self.concurrent = concurrent + @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_create_index_statement" -class DropIndexExpression(BaseExpression): +class DropIndexStatement(BaseExpression): """ Represents a DROP INDEX statement. Examples: # Basic drop - drop_idx = DropIndexExpression( + drop_idx = DropIndexStatement( dialect, index=Index(dialect, "idx_users_email") ) # Drop with IF EXISTS - drop_idx = DropIndexExpression( + drop_idx = DropIndexStatement( dialect, index=Index(dialect, "idx_old_index"), if_exists=True ) # Drop with table context (some databases require this) - drop_idx = DropIndexExpression( + drop_idx = DropIndexStatement( dialect, index=Index(dialect, "idx_orders_status"), table=Table(dialect, "orders") @@ -141,7 +146,7 @@ def format_method(self) -> str: return "format_drop_index_statement" -class CreateFulltextIndexExpression(BaseExpression): +class CreateFulltextIndexStatement(BaseExpression): """ Represents a CREATE FULLTEXT INDEX statement. @@ -154,7 +159,7 @@ class CreateFulltextIndexExpression(BaseExpression): Examples: # Basic FULLTEXT index - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dialect, index=Index(dialect, "idx_articles_content"), table=Table(dialect, "articles"), @@ -162,7 +167,7 @@ class CreateFulltextIndexExpression(BaseExpression): ) # FULLTEXT index with parser (MySQL) - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dialect, index=Index(dialect, "idx_documents_body"), table=Table(dialect, "documents"), @@ -171,7 +176,7 @@ class CreateFulltextIndexExpression(BaseExpression): ) # FULLTEXT index with IF NOT EXISTS - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dialect, index=Index(dialect, "idx_posts_content"), table=Table(dialect, "posts"), @@ -202,20 +207,20 @@ def format_method(self) -> str: return "format_create_fulltext_index_statement" -class DropFulltextIndexExpression(BaseExpression): +class DropFulltextIndexStatement(BaseExpression): """ Represents a DROP FULLTEXT INDEX statement. Examples: # Basic drop - drop_ft = DropFulltextIndexExpression( + drop_ft = DropFulltextIndexStatement( dialect, index=Index(dialect, "idx_articles_content"), table=Table(dialect, "articles") ) # Drop with IF EXISTS - drop_ft = DropFulltextIndexExpression( + drop_ft = DropFulltextIndexStatement( dialect, index=Index(dialect, "idx_old_fulltext"), table=Table(dialect, "old_table"), diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_partition.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_partition.py index 3a41910cf..007716f40 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_partition.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_partition.py @@ -35,13 +35,51 @@ from ...dialect import SQLDialectBase -class PartitionStrategy(Enum): - """Generic table partitioning strategies shared by supported backends.""" +class PartitionStrategy(str, Enum): + """Generic table partitioning strategies shared by supported backends. + + ``str`` because a strategy is a JSON scalar in the artifact, not a node: + changing it changes the rendered SQL and nothing else (§4), so it travels as + the word it is. Without the ``str`` base it would be the one closed value + set in the expression layer that no artifact could carry (§11.3). + + A dialect that supports only some of these gates the rest in + ``format_partition_clause``, raising rather than rendering a word its + database will reject — an unsupported strategy is an explicit refusal, not a + silent omission (§6). + """ + + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ RANGE = "RANGE" LIST = "LIST" HASH = "HASH" + @classmethod + def parse(cls, method: Union["PartitionStrategy", str]) -> str: + """The wire word for *method*, from the member or from its spelling. + + Normalisation lives here rather than in the caller, so + ``PartitionClause`` says what it takes and this says what that looks + like. The entry stays wide — the member and the string both land on the + same word, which is what lets an artifact round-trip without carrying an + enum object — and the stored form is always the word. + + A word this class does not name is a ``TypeError`` listing the members, + because rendering it would produce SQL no database recognises (§6). + """ + if isinstance(method, cls): + return method.value + if isinstance(method, str): + for entry in cls: + if entry.value == method: + return entry.value + raise TypeError( + "method must be a %s value or its string value, got %r; the members " + "are %s" % (cls.__name__, method, [e.value for e in cls]) + ) + @dataclass class SubpartitionDefinition: @@ -110,6 +148,10 @@ class PartitionClause(BaseExpression): SQL generation is always delegated to ``dialect.format_partition_clause``. """ + #: The strategy vocabulary this clause speaks. A dialect with its own set + #: substitutes it; the substitution must be a string enum (see + #: :class:`PartitionStrategy`), because the normalised word is what renders + #: and what travels. strategy_type: Type[Enum] = PartitionStrategy def __init__( @@ -119,21 +161,30 @@ def __init__( keys: Sequence[BaseExpression], ): super().__init__(dialect) - if isinstance(method, self.strategy_type): - normalized_method = method.value - elif isinstance(method, str) and any( - entry.value == method for entry in self.strategy_type - ): - # Accept the normalized string form produced by get_params() so - # deserialization round-trips without needing the Enum object. - normalized_method = method + strategy = self.strategy_type + # Accept the member or the word it spells: an artifact carries the word, + # so a read must not need the enum object to get back. Whether the word + # is one this clause knows is the *substituted* vocabulary's business, + # not the caller's -- a backend that substitutes knows its own words. + if isinstance(method, strategy): + self.method = method.value + elif isinstance(method, str) and any(e.value == method for e in strategy): + self.method = method else: + # Names the vocabulary and the words *it* defines. Deliberately not + # the value: this message can reach a log, and a log is not where a + # caller-supplied string belongs. raise TypeError( - f"method must be a {self.strategy_type.__name__} value or its " - f"string value, got {type(method).__name__}" + "method must be a %s value or its string value, got %s; the " + "members are %s" + % (strategy.__name__, type(method).__name__, + [e.value for e in strategy]) ) - if not isinstance(normalized_method, str): - raise TypeError(f"{self.strategy_type.__name__} values must be strings") + if not isinstance(self.method, str): + # A value the vocabulary cannot spell is not a strategy. Caught here + # rather than at render, because an unusable word in an artifact is + # something the writer should hear about, not a reader (§6). + raise TypeError(f"{strategy.__name__} values must be strings") if not keys: raise ValueError("keys are required") for key in keys: @@ -142,7 +193,6 @@ def __init__( "keys must contain BaseExpression instances, " f"got {type(key).__name__}" ) - self.method = normalized_method self.keys = list(keys) @property diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_schema.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_schema.py index dd1a73224..a84df8a90 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_schema.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_schema.py @@ -10,7 +10,7 @@ from ...dialect import SQLDialectBase -class CreateSchemaExpression(BaseExpression): +class CreateSchemaStatement(BaseExpression): """ Represents a CREATE SCHEMA statement. @@ -22,20 +22,20 @@ class CreateSchemaExpression(BaseExpression): Examples: # Basic schema creation - create_schema = CreateSchemaExpression( + create_schema = CreateSchemaStatement( dialect, schema=Schema(dialect, "my_schema") ) # Schema with authorization - create_schema = CreateSchemaExpression( + create_schema = CreateSchemaStatement( dialect, schema=Schema(dialect, "app_schema"), authorization="app_user" ) # Safe schema creation - create_schema = CreateSchemaExpression( + create_schema = CreateSchemaStatement( dialect, schema=Schema(dialect, "reporting"), if_not_exists=True @@ -60,33 +60,33 @@ def format_method(self) -> str: return "format_create_schema_statement" -class DropSchemaExpression(BaseExpression): +class DropSchemaStatement(BaseExpression): """ Represents a DROP SCHEMA statement. Examples: # Basic schema drop - drop_schema = DropSchemaExpression( + drop_schema = DropSchemaStatement( dialect, schema=Schema(dialect, "old_schema") ) # Safe drop with IF EXISTS - drop_schema = DropSchemaExpression( + drop_schema = DropSchemaStatement( dialect, schema=Schema(dialect, "test_schema"), if_exists=True ) # Cascade drop (removes all objects in schema) - drop_schema = DropSchemaExpression( + drop_schema = DropSchemaStatement( dialect, schema=Schema(dialect, "legacy"), cascade=True ) # Restrict drop (refuses if objects depend on the schema) - drop_schema = DropSchemaExpression( + drop_schema = DropSchemaStatement( dialect, schema=Schema(dialect, "legacy"), restrict=True @@ -102,8 +102,7 @@ def __init__( restrict: bool = False, ): super().__init__(dialect) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.schema = schema self.if_exists = if_exists self.cascade = cascade diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_sequence.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_sequence.py index f6c93c257..d768a9ec7 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_sequence.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_sequence.py @@ -10,7 +10,7 @@ from ...dialect import SQLDialectBase -class CreateSequenceExpression(BaseExpression): +class CreateSequenceStatement(BaseExpression): """ Represents a CREATE SEQUENCE statement. @@ -30,13 +30,13 @@ class CreateSequenceExpression(BaseExpression): Examples: # Basic sequence - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dialect, sequence=Sequence(dialect, "user_id_seq") ) # Sequence with options - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dialect, sequence=Sequence(dialect, "order_id_seq"), start=1000, @@ -47,7 +47,7 @@ class CreateSequenceExpression(BaseExpression): ) # Sequence with cache - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dialect, sequence=Sequence(dialect, "high_throughput_seq"), start=1, @@ -103,19 +103,19 @@ def format_method(self) -> str: return "format_create_sequence_statement" -class DropSequenceExpression(BaseExpression): +class DropSequenceStatement(BaseExpression): """ Represents a DROP SEQUENCE statement. Examples: # Basic drop - drop_seq = DropSequenceExpression( + drop_seq = DropSequenceStatement( dialect, sequence=Sequence(dialect, "old_seq") ) # Safe drop - drop_seq = DropSequenceExpression( + drop_seq = DropSequenceStatement( dialect, sequence=Sequence(dialect, "deprecated_seq"), if_exists=True @@ -138,27 +138,27 @@ def format_method(self) -> str: return "format_drop_sequence_statement" -class AlterSequenceExpression(BaseExpression): +class AlterSequenceStatement(BaseExpression): """ Represents an ALTER SEQUENCE statement. Examples: # Restart sequence - alter_seq = AlterSequenceExpression( + alter_seq = AlterSequenceStatement( dialect, sequence=Sequence(dialect, "user_id_seq"), restart=1000 ) # Change increment - alter_seq = AlterSequenceExpression( + alter_seq = AlterSequenceStatement( dialect, sequence=Sequence(dialect, "order_num_seq"), increment=2 ) # Set options - alter_seq = AlterSequenceExpression( + alter_seq = AlterSequenceStatement( dialect, sequence=Sequence(dialect, "my_seq"), minvalue=1, @@ -166,7 +166,7 @@ class AlterSequenceExpression(BaseExpression): cycle=True ) - Like :class:`CreateSequenceExpression`, each alternative of a two-spelling + Like :class:`CreateSequenceStatement`, each alternative of a two-spelling clause has its own parameter: ``cycle`` / ``no_cycle``, ``cache`` / ``no_cache``, ``order`` / ``no_order``. Setting both of a pair raises ``ValueError``; ``cache`` must be a positive count (``no_cache=True`` diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_table.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_table.py index 5dcff6751..526ebbb26 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_table.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_table.py @@ -1,105 +1,132 @@ +from .dql import QueryStatement +from .ddl_partition import PartitionClause +from ..types import DataType +from ..objects import Table +from ..bases import ( + BaseExpression, + SQLPredicate, + SQLQueryAndParams, +) +from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING +from abc import ABC +from enum import Enum # src/rhosocial/activerecord/backend/expression/statements/ddl_table.py """Table DDL statement expressions and related types.""" -from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING -from ..bases import BaseExpression, SQLPredicate, SQLQueryAndParams -from ..objects import Table -from ..types import DataType + if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase - from .dql import QueryExpression -class ColumnConstraintType(Enum): - """Types of column constraints.""" +class DefaultValueClause(BaseExpression): + """The value clause of a ``DEFAULT`` constraint. - PRIMARY_KEY = "PRIMARY KEY" - NOT_NULL = "NOT NULL" - NULL = "NULL" # Explicitly allow NULL (usually redundant but sometimes needed for clarity) - UNIQUE = "UNIQUE" - CHECK = "CHECK" - FOREIGN_KEY = "FOREIGN KEY" - DEFAULT = "DEFAULT" + A ``DEFAULT`` position accepts no bind parameters -- the statement that + carries it is DDL, and DDL has no place to put a driver's parameters. So + the clause states that once, here, at construction: a bare value arrives + wrapped in a :class:`~...expression.core.Literal` with ``inline=True``, + and a caller who already built a ``Literal`` gets it marked rather than + refused, because the position has no other answer to give. + + This is the one place the decision is not the caller's to make. A caller + who composes a ``CHECK`` condition or a partial-index ``WHERE`` writes the + literals in that tree themselves and says ``inline=True`` on each; a model + field's ``default=0`` reaches here as a Python scalar, and the clause + normalises it. Both end at the same place, and neither ends with a bind + parameter in DDL. + """ + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_default_value_clause" -class ColumnConstraint(BaseExpression): - """Represents a column constraint clause (PRIMARY KEY, NOT NULL, UNIQUE, …). + def __init__(self, dialect: "SQLDialectBase", value: Any): + super().__init__(dialect) + from ..core import Literal + + if isinstance(value, Literal): + # A caller who built the literal themselves still said nothing + # about binding, and this position cannot bind. + value.inline = True + elif not isinstance(value, BaseExpression): + value = Literal(dialect, value, inline=True) + self.value = value + + +class ColumnClause(BaseExpression): + """Base for a clause that rides inside a column definition. + + A column definition is a type followed by a sequence of clauses, and these + are the ones that are neither a constraint nor the type itself: collation, + character set, identity, auto-increment, the inline comment, a generated + column. They share one property -- each is a self-contained node with its + own grammar, so each owns its ``format_method`` and a dialect decides + whether it can spell it. + + Naming: this is a **DDL column clause**. It is unrelated to the + ``ColumnBase`` family in ``expression.column_types``, which are + value-typed column *expressions* (``StringColumn`` and its siblings) and + live on the query side. + + Subclassing this is what lets ``ColumnDefinition.attributes`` carry a + typed sequence instead of ``List[Any]``. - A DDL clause node: it participates in rendering through the dialect's - ``format_column_constraint`` and holds the expressions its grammar - contains (CHECK condition, DEFAULT value). + ``format_method`` is deliberately **not** overridden here: the base + ``BaseExpression`` getter already raises ``NotImplementedError`` for a class + that names no formatter, which is exactly what this base is. A subclass + declares its own. + """ + + +class CollationClause(ColumnClause): + """The ``COLLATE `` clause of a column definition. + + A DDL clause node rendered through the dialect's ``format_collation_clause``. + ``COLLATE`` is part of the SQL-standard column-constraint grammar, so the + capability defaults to True and a dialect overrides + :meth:`supports_column_collation` only for a version boundary. + + The name is **not** validated here. Which collation names exist is a + catalogue question a dialect answers in its own validator (Snowflake and + BigQuery both require a quoted specification rather than a bare + identifier), so the node carries the name the caller asked for and the + dialect decides how to spell and whether to accept it. """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_column_constraint" + return "format_collation_clause" - def __init__( - self, - dialect: "SQLDialectBase", - constraint_type: ColumnConstraintType, - name: Optional[str] = None, - check_condition: Optional["SQLPredicate"] = None, - foreign_key_reference: Optional[Tuple["Table", List[str]]] = None, - default_value: Any = None, - is_auto_increment: bool = False, - on_delete: Optional["ReferentialAction"] = None, - on_update: Optional["ReferentialAction"] = None, - deferrable: bool = False, - not_deferrable: bool = False, - initially_deferred: bool = False, - initially_immediate: bool = False, - enforced: bool = False, - not_enforced: bool = False, - ): + def __init__(self, dialect: "SQLDialectBase", name: str): super().__init__(dialect) - if deferrable and not_deferrable: - raise ValueError("deferrable and not_deferrable are mutually exclusive options") - if initially_deferred and initially_immediate: - raise ValueError( - "initially_deferred and initially_immediate are mutually exclusive options" - ) - if enforced and not_enforced: - raise ValueError("enforced and not_enforced are mutually exclusive options") - self.constraint_type = constraint_type + if not isinstance(name, str) or not name.strip(): + raise ValueError("COLLATE requires a collation name") self.name = name - self.check_condition = check_condition - self.foreign_key_reference = foreign_key_reference - self.default_value = default_value - self.is_auto_increment = is_auto_increment - self.on_delete = on_delete - self.on_update = on_update - self.deferrable = deferrable - self.not_deferrable = not_deferrable - self.initially_deferred = initially_deferred - self.initially_immediate = initially_immediate - self.enforced = enforced - self.not_enforced = not_enforced -class DefaultValueClause(BaseExpression): - """The value clause of a ``DEFAULT`` constraint. +class CharacterSetClause(ColumnClause): + """The ``CHARACTER SET `` clause of a column definition. A DDL clause node rendered through the dialect's - ``format_default_value_clause``. It carries the default **value** — either - a plain Python scalar (rendered inline with dialect-controlled escaping) or - a ``BaseExpression`` (e.g. a function call or literal) — keeping the - value/inline logic in one place. It is a self-contained expression so the - value's escaping/inlining can be overridden per backend. + ``format_character_set_clause``. This is a MySQL/MariaDB family construct, + so :meth:`supports_column_character_set` defaults to False and those + backends (plus Firebird) opt in. """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_default_value_clause" + return "format_character_set_clause" - def __init__(self, dialect: "SQLDialectBase", value: Any): + def __init__(self, dialect: "SQLDialectBase", name: str): super().__init__(dialect) - self.value = value + if not isinstance(name, str) or not name.strip(): + raise ValueError("CHARACTER SET requires a character set name") + self.name = name class IdentityClause(BaseExpression): @@ -151,8 +178,7 @@ def __init__( no_cache: bool = False, ): super().__init__(dialect) - if cycle and no_cycle: - raise ValueError("cycle and no_cycle are mutually exclusive options") + self.require_exclusive_options(cycle, no_cycle, "cycle", "no_cycle") if cache is not None and no_cache: raise ValueError("cache and no_cache are mutually exclusive options") if order and no_order: @@ -226,19 +252,20 @@ def __init__( *, on_delete: Optional["ReferentialAction"] = None, on_update: Optional["ReferentialAction"] = None, - match_type: Optional[str] = None, + match_type: Optional["ForeignKeyMatchType"] = None, deferrable: bool = False, not_deferrable: bool = False, initially_deferred: bool = False, initially_immediate: bool = False, ): super().__init__(dialect) - if deferrable and not_deferrable: - raise ValueError("deferrable and not_deferrable are mutually exclusive options") - if initially_deferred and initially_immediate: - raise ValueError( - "initially_deferred and initially_immediate are mutually exclusive options" - ) + self.require_exclusive_options(deferrable, not_deferrable, "deferrable", "not_deferrable") + self.require_exclusive_options( + initially_deferred, + initially_immediate, + "initially_deferred", + "initially_immediate", + ) self.referenced_table = referenced_table self.referenced_columns = list(referenced_columns or []) self.on_delete = on_delete @@ -250,53 +277,12 @@ def __init__( self.initially_immediate = initially_immediate -class GeneratedColumnType(Enum): - """Types of generated columns (computed columns).""" - - STORED = "STORED" # Stored on disk, can be indexed - VIRTUAL = "VIRTUAL" # Computed on read, not stored - - -class GeneratedColumnExpression(BaseExpression): - """Represents the GENERATED ALWAYS AS () [STORED|VIRTUAL] clause. - - A DDL clause node rendered through the dialect's - ``format_generated_column_expression``. Holds the inner expression - (any ``BaseExpression``) and the storage type. - - Usage:: - - gen = GeneratedColumnExpression( - dialect, - expression=Column(dialect, "price") * Column(dialect, "qty"), - storage_type=GeneratedColumnType.STORED, - ) - sql, params = gen.to_sql() - # -> ('GENERATED ALWAYS AS ("price" * "qty") STORED', ()) - """ - - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_generated_column_expression" - - def __init__( - self, - dialect: "SQLDialectBase", - expression: "BaseExpression", - storage_type: Optional[GeneratedColumnType] = None, - ): - super().__init__(dialect) - self.expression = expression - self.storage_type = storage_type or GeneratedColumnType.VIRTUAL - - class ColumnCommentClause(BaseExpression): """The inline comment clause of a column definition. A CREATE TABLE / ALTER TABLE **clause** node rendered through the dialect's ``format_column_comment_clause``. It is deliberately distinct - from the standalone ``COMMENT ON`` statement (:class:`CommentOnExpression`): + from the standalone ``COMMENT ON`` statement (:class:`CommentOnStatement`): the inline clause is part of the *column-definition grammar* and only exists on dialects that advertise :meth:`supports_column_comment`. @@ -326,7 +312,7 @@ class TableCommentClause(BaseExpression): MySQL/MariaDB/ClickHouse-family convenience (Snowflake renders ``COMMENT = ''``, BigQuery ``OPTIONS(description='')``), while the standalone ``COMMENT ON`` statement is a separate de-facto vendor - mechanism (:class:`CommentOnExpression`), not this clause. + mechanism (:class:`CommentOnStatement`), not this clause. Rendering is capability-gated: the statement renderer emits this clause only on dialects whose :meth:`supports_table_comment` is True, and raises @@ -367,10 +353,10 @@ def __init__( dialect: "SQLDialectBase", name: str, data_type: "DataType", - constraints: Optional[List[ColumnConstraint]] = None, + constraints: Optional[List["ColumnConstraint"]] = None, comment: Optional["ColumnCommentClause"] = None, - generated_expression: Optional[GeneratedColumnExpression] = None, - attributes: Optional[List[Any]] = None, + generated_expression: Optional["ColumnGeneratedConstraint"] = None, + attributes: Optional[List["ColumnClause"]] = None, ): super().__init__(dialect) if not isinstance(data_type, DataType): @@ -386,23 +372,28 @@ def __init__( self.constraints = list(constraints or []) self.comment = comment self.generated_expression = generated_expression - # Dialect-free attributes are rendered by the owning backend. + # Column clauses, in the order they should be spelled. An AR-layer + # ``ColumnAttribute`` is still tolerated here because no deriver + # translates a model's declarations into clauses yet; the dialect + # adapts it at render time. See ``DDLColumnMixin.translate_ar_attribute``. self.attributes = list(attributes or []) +class GeneratedColumnType(str, Enum): + """Types of generated columns (computed columns).""" -class TableConstraintType(Enum): - """Types of table constraints supported by SQL.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ - PRIMARY_KEY = "PRIMARY KEY" - UNIQUE = "UNIQUE" - FOREIGN_KEY = "FOREIGN KEY" - CHECK = "CHECK" - EXCLUDE = "EXCLUDE" + STORED = "STORED" # Stored on disk, can be indexed + VIRTUAL = "VIRTUAL" # Computed on read, not stored -class ReferentialAction(Enum): +class ReferentialAction(str, Enum): """Actions for referential integrity constraints.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + CASCADE = "CASCADE" RESTRICT = "RESTRICT" SET_NULL = "SET NULL" @@ -410,26 +401,523 @@ class ReferentialAction(Enum): NO_ACTION = "NO ACTION" -# Re-export partition clause from ddl_partition -from .ddl_partition import PartitionClause, PartitionStrategy # noqa: E402, F401 - - -class ConstraintValidation(Enum): +class ConstraintValidation(str, Enum): """Constraint validation status (PostgreSQL specific). - Used as the typed ``validation`` field of ``TableConstraint`` to control - whether PostgreSQL validates existing data against the constraint. + Used as the typed ``validation`` field of :class:`TableConstraint` to + control whether PostgreSQL validates existing data against the constraint. - VALIDATE: Validate all existing data (default behavior) - NOVALIDATE: Add constraint without validating existing data (NOT VALID) """ + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + VALIDATE = "VALIDATE" NOVALIDATE = "NOT VALID" +class ForeignKeyMatchType(str, Enum): + """The MATCH type of a foreign key (SQL:1999). + + A multi-column foreign key matches when *some* column pair matches; MATCH + narrows that: + + - ``SIMPLE`` — the match condition holds if **any one** column matches. + This is the SQL default, and ``None`` on a constraint means it. + - ``PARTIAL`` — the match condition holds only if **all** columns match. + - ``FULL`` — no partial match is permitted. + + ``__str__`` returns the SQL spelling rather than ``ForeignKeyMatchType.FULL`` + because a dialect formatter may interpolate the member directly, and a + member that renders as its Python name would put invalid SQL on the wire. + """ + + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + + SIMPLE = "SIMPLE" + PARTIAL = "PARTIAL" + FULL = "FULL" + + +class ColumnConstraintBranch(BaseExpression, ABC): + """Base class for the branches of a column-level constraint. + + A column constraint is a grammar production with one alternative per + kind, so each alternative is a branch node holding only what is specific + to it. :class:`ColumnConstraint` holds the production-level parts -- the + ``CONSTRAINT `` prefix and the trailing traits -- and refers to a + branch rather than being one, because the two live at different levels of + the grammar (规则文档 E1/E2, Layout). + + A branch never renders itself: :meth:`format_column_constraint` renders + the production and dispatches to the branch's own formatter. + """ + + +class ColumnPrimaryKeyConstraint(ColumnConstraintBranch): + """``PRIMARY KEY``, optionally SQLite's ``AUTOINCREMENT``.""" + + def __init__( + self, + dialect: "SQLDialectBase", + is_auto_increment: bool = False, + ): + super().__init__(dialect) + self.is_auto_increment = is_auto_increment + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_primary_key_constraint" + + +class ColumnUniqueConstraint(ColumnConstraintBranch): + """``UNIQUE``.""" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_unique_constraint" + + +class ColumnNotNullConstraint(ColumnConstraintBranch): + """``NOT NULL``.""" + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_not_null_constraint" + + +class ColumnNullableConstraint(ColumnConstraintBranch): + """``NULL`` -- the column is allowed to hold no value. + + The name is the adjective rather than the keyword on purpose: what the + constraint states about a column is that it is *nullable*, which is also + how a reader tells this branch apart from the ``NULL`` value a literal + holds. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_nullable_constraint" + + +class ColumnCheckConstraint(ColumnConstraintBranch): + """``CHECK ()``. + + A CHECK position accepts no bind parameters, so the class declares + the literals are marked inline; the dialect renders the condition inline-only + and refuses any residual bind value. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + condition: "SQLPredicate", + ): + if condition is None: + raise ValueError( + "ColumnCheckConstraint needs a condition: CHECK without one " + "states nothing." + ) + super().__init__(dialect) + self.condition = condition + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_check_constraint" + + +class ColumnDefaultConstraint(ColumnConstraintBranch): + """``DEFAULT ``. + + Not an ENFORCABLE kind: SQL:2016 gives the enforcement trait to PRIMARY + KEY, UNIQUE, FOREIGN KEY and CHECK only, so this branch carries no way to + ask for it. + + A DEFAULT position accepts no bind parameters, so the class declares + the literals are marked inline; the value renders inline only. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + value: Any, + ): + if value is None: + raise ValueError( + "ColumnDefaultConstraint needs a value: DEFAULT without one " + "states nothing. The refusal is here rather than at render " + "time because a clause with no value cannot be rendered at all." + ) + super().__init__(dialect) + self.value = value + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_default_constraint" + + +class ColumnForeignKeyConstraint(ColumnConstraintBranch): + """A column-level ``FOREIGN KEY``: the column, and where it points.""" + + def __init__( + self, + dialect: "SQLDialectBase", + references: Tuple["Table", List[str]], + on_delete: Optional["ReferentialAction"] = None, + on_update: Optional["ReferentialAction"] = None, + match_type: Optional["ForeignKeyMatchType"] = None, + ): + super().__init__(dialect) + if references is None or references[0] is None: + raise ValueError( + "ColumnForeignKeyConstraint needs references: the (Table, " + "[columns]) it points at. A foreign key without one points " + "nowhere." + ) + if not isinstance(references[0], Table): + raise TypeError( + f"ColumnForeignKeyConstraint.references must pair a Table " + f"with its columns, got {type(references[0]).__name__}" + ) + self.references = references + self.on_delete = on_delete + self.on_update = on_update + self.match_type = match_type + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_fk_constraint" + + +class ColumnGeneratedConstraint(ColumnConstraintBranch): + """``GENERATED ALWAYS AS () [STORED|VIRTUAL]``. + + The column-level counterpart of the generated-column production. It is a + constraint branch rather than a free-standing node, so it rides wherever a + column constraint rides and answers to the same level's prefix. + + A generated-column expression is a DDL position, so the class declares + the literals are marked inline; the expression renders inline-only. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + expression: "BaseExpression", + storage_type: Optional[GeneratedColumnType] = None, + ): + super().__init__(dialect) + self.expression = expression + # The default is the node's, not the formatter's: what "generated + # column" means without a storage clause is settled once, here. + self.storage_type = storage_type or GeneratedColumnType.VIRTUAL + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_generated_constraint" + + +class ColumnConstraint(BaseExpression): + """One column-level constraint clause: ``[CONSTRAINT ] [traits]``. + + The production, not one of its alternatives. What the grammar hangs on + the production -- the ``CONSTRAINT `` prefix and the trailing + ``DEFERRABLE`` / ``INITIALLY`` / ``ENFORCED`` traits -- lives here; what + belongs to one alternative lives in the :class:`ColumnConstraintBranch` + this refers to. Two levels because that is where the grammar puts them, + not for convenience: it is what lets a backend re-spell one kind without + touching the prefix or the traits, and what makes a request the kind + cannot honour impossible to express at all. + + Args: + dialect: The dialect that renders the clause. + branch: The alternative this clause states -- a + :class:`ColumnConstraintBranch`. Required: a constraint clause + without one says nothing. + name: The ``CONSTRAINT `` prefix. Authorised by + ``supports_constraint_naming``. + deferrable / not_deferrable: the deferral trait. Authorised by + ``supports_deferrable_constraint``; carried only by the PRIMARY + KEY, UNIQUE and FOREIGN KEY branches, so asking for it on another + branch is a ``TypeError`` raised by :meth:`validate_branch_traits` + rather than a request that is quietly dropped. + initially_deferred / initially_immediate: the timing half of the same + trait, which the standard lets appear without the deferral half. + enforced / not_enforced: the SQL:2016 enforcement trait, carried by + the PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK branches. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_column_constraint" + + def __init__( + self, + dialect: "SQLDialectBase", + *, + branch: ColumnConstraintBranch, + name: Optional[str] = None, + deferrable: bool = False, + not_deferrable: bool = False, + initially_deferred: bool = False, + initially_immediate: bool = False, + enforced: bool = False, + not_enforced: bool = False, + ): + from rhosocial.activerecord.backend.expression import ( + ColumnConstraintBranch, + ) + super().__init__(dialect) + if not isinstance(branch, ColumnConstraintBranch): + raise TypeError( + f"ColumnConstraint.branch must be a ColumnConstraintBranch, " + f"got {type(branch).__name__}" + ) + self.require_exclusive_options(deferrable, not_deferrable, "deferrable", "not_deferrable") + self.require_exclusive_options( + initially_deferred, + initially_immediate, + "initially_deferred", + "initially_immediate", + ) + self.require_exclusive_options(enforced, not_enforced, "enforced", "not_enforced") + self.branch = branch + self.name = name + self.deferrable = deferrable + self.not_deferrable = not_deferrable + self.initially_deferred = initially_deferred + self.initially_immediate = initially_immediate + self.enforced = enforced + self.not_enforced = not_enforced + self.validate_branch_traits(branch) + + def validate_branch_traits(self, branch: ColumnConstraintBranch) -> None: + """Refuse a trait the branch's kind cannot carry. + + The branches that can carry each trait are named by + :attr:`DEFERRABLE_BRANCHES` and :attr:`ENFORCABLE_BRANCHES`. Before + the branches existed, a request on a kind that had no meaning for it + was accepted and then dropped without a word; here it cannot be + written in the first place. + """ + wants_deferral = self.deferrable or self.not_deferrable + if (wants_deferral or self.initially_deferred or self.initially_immediate) and ( + type(branch) not in self.DEFERRABLE_BRANCHES + ): + raise ValueError( + f"DEFERRABLE / INITIALLY cannot ride a " + f"{type(branch).__name__}; the SQL:2016 kinds are " + f"PRIMARY KEY, UNIQUE and FOREIGN KEY" + ) + if (self.enforced or self.not_enforced) and type(branch) not in self.ENFORCABLE_BRANCHES: + raise ValueError( + f"ENFORCED/NOT ENFORCED cannot ride a {type(branch).__name__}; " + f"the SQL:2016 kinds are PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK" + ) + + #: Branches whose kind can carry the deferral trait. + DEFERRABLE_BRANCHES = frozenset( + {ColumnPrimaryKeyConstraint, ColumnUniqueConstraint, ColumnForeignKeyConstraint} + ) + + #: Branches whose kind can carry the enforcement trait (SQL:2016). + ENFORCABLE_BRANCHES = frozenset( + { + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ColumnForeignKeyConstraint, + ColumnCheckConstraint, + } + ) + + @property + def constraint_type(self): + """The removed tag, reported rather than silently absent. + + ``constraint_type`` said which kind a fat node was pretending to be. + The branch says it now, in a class rather than in a field, so a + caller still asking for the tag is told where the answer moved. + """ + from rhosocial.activerecord.backend.expression import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ) + kind = { + ColumnPrimaryKeyConstraint: "PRIMARY KEY", + ColumnUniqueConstraint: "UNIQUE", + ColumnNotNullConstraint: "NOT NULL", + ColumnNullableConstraint: "NULL", + ColumnCheckConstraint: "CHECK", + ColumnDefaultConstraint: "DEFAULT", + ColumnForeignKeyConstraint: "FOREIGN KEY", + ColumnGeneratedConstraint: "GENERATED", + }.get(type(self.branch), type(self.branch).__name__) + raise AttributeError( + f"ColumnConstraint.constraint_type is gone; the kind is the branch " + f"({type(self.branch).__name__}, which is a {kind} constraint)" + ) + + +class TableConstraintBranch(BaseExpression, ABC): + """Base class for the branches of a table-level constraint. + + The table counterpart of :class:`ColumnConstraintBranch`: one node per + alternative of the table-constraint production, each holding only its own + fields, with :class:`TableConstraint` holding the production-level prefix, + the ``NOT VALID`` trait and the enforcement trait. + """ + + +class TablePrimaryKeyConstraint(TableConstraintBranch): + """A table-level ``PRIMARY KEY`` over one or more columns.""" + + def __init__( + self, + dialect: "SQLDialectBase", + columns: List[str], + ): + super().__init__(dialect) + self.columns = list(columns) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_primary_key_constraint" + + +class TableUniqueConstraint(TableConstraintBranch): + """A table-level ``UNIQUE`` over one or more columns.""" + + def __init__( + self, + dialect: "SQLDialectBase", + columns: List[str], + ): + super().__init__(dialect) + self.columns = list(columns) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_unique_constraint" + + +class TableCheckConstraint(TableConstraintBranch): + """A table-level ``CHECK ()``. + + A CHECK position accepts no bind parameters, so the class declares + the literals are marked inline; the dialect renders the condition inline-only + and refuses any residual bind value. + """ + + + def __init__( + self, + dialect: "SQLDialectBase", + condition: "SQLPredicate", + ): + if condition is None: + raise ValueError( + "TableCheckConstraint needs a condition: CHECK without one " + "states nothing." + ) + super().__init__(dialect) + self.condition = condition + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_check_constraint" + + +class TableForeignKeyConstraint(TableConstraintBranch): + """A table-level ``FOREIGN KEY`` over one or more columns.""" + + def __init__( + self, + dialect: "SQLDialectBase", + columns: List[str], + references: Tuple["Table", Optional[List[str]]], + on_delete: Optional["ReferentialAction"] = None, + on_update: Optional["ReferentialAction"] = None, + match_type: Optional["ForeignKeyMatchType"] = None, + ): + super().__init__(dialect) + if references is None or references[0] is None: + raise ValueError( + "TableForeignKeyConstraint needs references: the (Table, " + "[columns]) it points at." + ) + self.columns = list(columns or []) + self.references = references + self.on_delete = on_delete + self.on_update = on_update + self.match_type = match_type + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_foreign_key_constraint" + + +class TableExcludeConstraint(TableConstraintBranch): + """``EXCLUDE`` -- the exclusion constraint. + + Enumerable, so it is modelled rather than refused: one branch, and the + dialects that cannot spell it refuse it by name through + :meth:`supports_exclude_constraint` instead of rendering SQL their server + will reject. Only PostgreSQL can spell it today; the probe is what says so + per dialect. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + columns: List[str], + operator: str = "=", + index_method: Optional[str] = None, + ): + super().__init__(dialect) + self.columns = list(columns) + self.operator = operator + self.index_method = index_method + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_exclude_constraint" + + class TableConstraint(BaseExpression): - """Represents a table-level constraint clause in CREATE/ALTER TABLE.""" + """One table-level constraint clause: ``[CONSTRAINT ] [traits]``. + + The production, with the alternative it states named by :attr:`branch`. + The table counterpart of :class:`ColumnConstraint`: ``name`` and the + ``NOT VALID`` trait are production-level because the grammars put them + there, and the columns, the referenced table and the check condition + belong to the branches. + """ @property def format_method(self) -> str: @@ -439,12 +927,9 @@ def format_method(self) -> str: def __init__( self, dialect: "SQLDialectBase", - constraint_type: TableConstraintType, + *, + branch: TableConstraintBranch, name: Optional[str] = None, - columns: Optional[List[str]] = None, - check_condition: Optional["SQLPredicate"] = None, - foreign_key_table: Optional["Table"] = None, - foreign_key_columns: Optional[List[str]] = None, deferrable: bool = False, not_deferrable: bool = False, initially_deferred: bool = False, @@ -453,21 +938,25 @@ def __init__( enforced: bool = False, not_enforced: bool = False, ): + from rhosocial.activerecord.backend.expression import ( + TableConstraintBranch, + ) super().__init__(dialect) - if deferrable and not_deferrable: - raise ValueError("deferrable and not_deferrable are mutually exclusive options") - if initially_deferred and initially_immediate: - raise ValueError( - "initially_deferred and initially_immediate are mutually exclusive options" + if not isinstance(branch, TableConstraintBranch): + raise TypeError( + f"TableConstraint.branch must be a TableConstraintBranch, " + f"got {type(branch).__name__}" ) - if enforced and not_enforced: - raise ValueError("enforced and not_enforced are mutually exclusive options") - self.constraint_type = constraint_type + self.require_exclusive_options(enforced, not_enforced, "enforced", "not_enforced") + self.require_exclusive_options(deferrable, not_deferrable, "deferrable", "not_deferrable") + self.require_exclusive_options( + initially_deferred, + initially_immediate, + "initially_deferred", + "initially_immediate", + ) + self.branch = branch self.name = name - self.columns = columns - self.check_condition = check_condition - self.foreign_key_table = foreign_key_table - self.foreign_key_columns = foreign_key_columns self.deferrable = deferrable self.not_deferrable = not_deferrable self.initially_deferred = initially_deferred @@ -477,10 +966,65 @@ def __init__( # MariaDB/SQL Server: CHECK ... [NOT] ENFORCED (None = backend default). self.enforced = enforced self.not_enforced = not_enforced + if (self.enforced or self.not_enforced) and type(branch) not in self.ENFORCABLE_BRANCHES: + raise ValueError( + f"ENFORCED/NOT ENFORCED cannot ride a {type(branch).__name__}; " + f"the SQL:2016 kinds are PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK" + ) + wants_deferral = self.deferrable or self.not_deferrable + if (wants_deferral or self.initially_deferred or self.initially_immediate) and ( + type(branch) not in self.DEFERRABLE_BRANCHES + ): + raise ValueError( + f"DEFERRABLE / INITIALLY cannot ride a {type(branch).__name__}; " + f"the SQL:2016 kinds are PRIMARY KEY, UNIQUE and FOREIGN KEY" + ) + + #: Branches whose kind can carry the enforcement trait (SQL:2016). + ENFORCABLE_BRANCHES = frozenset( + { + TablePrimaryKeyConstraint, + TableUniqueConstraint, + TableForeignKeyConstraint, + TableCheckConstraint, + } + ) + + #: Branches whose kind can carry the deferral trait. + DEFERRABLE_BRANCHES = frozenset( + {TablePrimaryKeyConstraint, TableUniqueConstraint, TableForeignKeyConstraint} + ) + + @property + def constraint_type(self): + """The removed tag, reported rather than silently absent.""" + from rhosocial.activerecord.backend.expression import ( + TableCheckConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + ) + kind = { + TablePrimaryKeyConstraint: "PRIMARY KEY", + TableUniqueConstraint: "UNIQUE", + TableForeignKeyConstraint: "FOREIGN KEY", + TableCheckConstraint: "CHECK", + TableExcludeConstraint: "EXCLUDE", + }.get(type(self.branch), type(self.branch).__name__) + raise AttributeError( + f"TableConstraint.constraint_type is gone; the kind is the branch " + f"({type(self.branch).__name__}, which is a {kind} constraint)" + ) class ForeignKeyConstraint(TableConstraint): - """Specialized table constraint for foreign keys with additional options.""" + """A table-level FOREIGN KEY, with the referential actions defaulted. + + Construction sugar over :class:`TableForeignKeyConstraint`: the standard + implies ``NO ACTION`` when the action is not written, so this fills it in + where the plain branch leaves "not specified" as ``None``. + """ @property def format_method(self) -> str: @@ -490,12 +1034,12 @@ def format_method(self) -> str: def __init__( self, dialect: "SQLDialectBase", - columns: Optional[List[str]] = None, - foreign_key_table: Optional["Table"] = None, - foreign_key_columns: Optional[List[str]] = None, + columns: List[str], + foreign_key_table: "Table", + foreign_key_columns: List[str], on_delete: "ReferentialAction" = ReferentialAction.NO_ACTION, on_update: "ReferentialAction" = ReferentialAction.NO_ACTION, - match_type: Optional[str] = None, + match_type: Optional["ForeignKeyMatchType"] = None, name: Optional[str] = None, deferrable: bool = False, not_deferrable: bool = False, @@ -505,13 +1049,20 @@ def __init__( enforced: bool = False, not_enforced: bool = False, ): + from rhosocial.activerecord.backend.expression import ( + TableForeignKeyConstraint, + ) super().__init__( dialect, - TableConstraintType.FOREIGN_KEY, + branch=TableForeignKeyConstraint( + dialect, + columns or [], + (foreign_key_table, foreign_key_columns) if foreign_key_table else (None, None), + on_delete, + on_update, + match_type, + ), name=name, - columns=columns, - foreign_key_table=foreign_key_table, - foreign_key_columns=foreign_key_columns, deferrable=deferrable, not_deferrable=not_deferrable, initially_deferred=initially_deferred, @@ -520,9 +1071,41 @@ def __init__( enforced=enforced, not_enforced=not_enforced, ) - self.on_delete = on_delete - self.on_update = on_update - self.match_type = match_type + + # The referential fields live on the branch now. They are exposed here as + # views rather than stored twice: the sugar declares these parameters, and + # ``get_params()`` -- the single serialization path -- looks each declared + # parameter up by name. + + @property + def columns(self) -> List[str]: + """The local columns, as the branch records them.""" + return self.branch.columns + + @property + def foreign_key_table(self) -> Optional["Table"]: + """The referenced relation, as the branch records it.""" + return self.branch.references[0] + + @property + def foreign_key_columns(self) -> Optional[List[str]]: + """The referenced columns, as the branch records them.""" + return self.branch.references[1] + + @property + def on_delete(self) -> Optional["ReferentialAction"]: + """The delete action, as the branch records it.""" + return self.branch.on_delete + + @property + def on_update(self) -> Optional["ReferentialAction"]: + """The update action, as the branch records it.""" + return self.branch.on_update + + @property + def match_type(self) -> Optional["ForeignKeyMatchType"]: + """The MATCH type, as the branch records it.""" + return self.branch.match_type class IndexDefinition(BaseExpression): @@ -593,7 +1176,7 @@ class CreateTableOptions(BaseExpression): MySQL/MariaDB/ClickHouse-family dialect convenience (Snowflake renders ``COMMENT = ''``, BigQuery ``OPTIONS(description='')``), and the standalone ``COMMENT ON`` statement is a separate de-facto vendor - mechanism (:class:`CommentOnExpression`), not this clause. Bare comment + mechanism (:class:`CommentOnStatement`), not this clause. Bare comment text is **not** accepted — build the clause node explicitly; rendering is capability-gated by the statement renderer through :meth:`supports_table_comment`, which raises ``UnsupportedFeatureError`` @@ -633,7 +1216,7 @@ def __init__( self.comment = comment -class CreateTableExpression(BaseExpression): +class CreateTableStatement(BaseExpression): """Represents a comprehensive CREATE TABLE statement supporting full SQL standard features.""" def __init__( @@ -679,11 +1262,11 @@ def format_method(self) -> str: return "format_create_table_statement" -class CreateTableAsExpression(BaseExpression): +class CreateTableAsStatement(BaseExpression): """Represents ``CREATE TABLE ... AS `` (CTAS). The table's structure is derived from the result of a query. Unlike the - explicit-schema form (:class:`CreateTableExpression`), the body is a query, + explicit-schema form (:class:`CreateTableStatement`), the body is a query, not a column list. Rendering is delegated to the dialect's ``format_create_table_as_statement``. @@ -700,7 +1283,7 @@ def __init__( self, dialect: "SQLDialectBase", table: "Table", - as_query: "QueryExpression", + as_query: "QueryStatement", *, columns: Optional[List[ColumnDefinition]] = None, temporary: bool = False, @@ -710,11 +1293,10 @@ def __init__( no_data: bool = False, ): super().__init__(dialect) - if with_data and no_data: - raise ValueError("with_data and no_data are mutually exclusive options") + self.require_exclusive_options(with_data, no_data, "with_data", "no_data") self.table = table if as_query is None: - raise ValueError("as_query is required for CreateTableAsExpression") + raise ValueError("as_query is required for CreateTableAsStatement") self.as_query = as_query self.columns = list(columns or []) self.temporary = temporary @@ -729,7 +1311,7 @@ def table_name(self) -> str: return self.table.name -class CreateTableLikeExpression(BaseExpression): +class CreateTableLikeStatement(BaseExpression): """Represents ``CREATE TABLE ... LIKE ``. Creates a new empty table copying the *definition* (column attributes, @@ -741,7 +1323,7 @@ class CreateTableLikeExpression(BaseExpression): * BigQuery: ``CREATE TABLE t LIKE src [OPTIONS (...)]`` * PostgreSQL: clause form ``CREATE TABLE t (LIKE src [INCLUDING ...])`` * ClickHouse: uses ``AS src`` / ``CLONE AS src`` -- model through - :class:`CreateTableAsExpression` / :class:`CreateTableCloneExpression`, + :class:`CreateTableAsStatement` / :class:`CreateTableCloneStatement`, not this class. The generic ``format_create_table_like_statement`` raises @@ -777,14 +1359,17 @@ def table_name(self) -> str: return self.table.name -class CreateTableCloneMode(Enum): +class CreateTableCloneMode(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + """Mode of a definition/data copy operation.""" CLONE = "CLONE" COPY = "COPY" -class CreateTableCloneExpression(BaseExpression): +class CreateTableCloneStatement(BaseExpression): """Represents ``CREATE TABLE ... CLONE/COPY ``. Zero-copy (or metadata/data copy) creation from an existing table: @@ -833,25 +1418,25 @@ def table_name(self) -> str: return self.table.name -class CreateTableFromTemplateExpression(BaseExpression): +class CreateTableFromTemplateStatement(BaseExpression): """Represents Snowflake ``CREATE TABLE ... USING TEMPLATE ``. Derives the table's column definitions from staged files described by a query (typically ``INFER_SCHEMA``). Dialects that do not support this form raise :class:`UnsupportedFeatureError` from - ``format_create_table_using_template``. + ``format_create_table_from_template_statement``. """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_table_using_template" + return "format_create_table_from_template_statement" def __init__( self, dialect: "SQLDialectBase", table: "Table", - template: "QueryExpression", + template: "QueryStatement", *, temporary: bool = False, if_not_exists: bool = False, @@ -859,7 +1444,7 @@ def __init__( super().__init__(dialect) self.table = table if template is None: - raise ValueError("template is required for CreateTableFromTemplateExpression") + raise ValueError("template is required for CreateTableFromTemplateStatement") self.template = template self.temporary = temporary self.if_not_exists = if_not_exists @@ -870,7 +1455,7 @@ def table_name(self) -> str: return self.table.name -class DropTableExpression(BaseExpression): +class DropTableStatement(BaseExpression): """Represents a DROP TABLE statement conforming to SQL standard. SQL Standard Syntax: @@ -913,23 +1498,23 @@ class DropTableExpression(BaseExpression): Examples: # Simple drop - DropTableExpression(dialect, Table(dialect, "users")) + DropTableStatement(dialect, Table(dialect, "users")) # -> DROP TABLE users # With IF EXISTS - DropTableExpression(dialect, "users", if_exists=True) + DropTableStatement(dialect, "users", if_exists=True) # -> DROP TABLE IF EXISTS users (on dialects supporting IF EXISTS) # With CASCADE (PostgreSQL / MySQL / MariaDB) - DropTableExpression(dialect, "users", cascade=True) + DropTableStatement(dialect, "users", cascade=True) # -> DROP TABLE users CASCADE # With CASCADE on Oracle (dialect renders its own form) - DropTableExpression(oracle_dialect, "users", cascade=True, purge=True) + DropTableStatement(oracle_dialect, "users", cascade=True, purge=True) # -> DROP TABLE users CASCADE CONSTRAINTS PURGE # With schema-qualified table - DropTableExpression(dialect, Table(dialect, "users", schema_name="public")) + DropTableStatement(dialect, Table(dialect, "users", schema_name="public")) # -> DROP TABLE public.users """ @@ -948,8 +1533,7 @@ def __init__( purge: bool = False, ): super().__init__(dialect) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.table = table self.if_exists = if_exists self.cascade = cascade @@ -963,8 +1547,12 @@ class StorageOptionsExpression(BaseExpression): Holds a mapping of option names to values. The dialect formatter renders each pair as ``key = `` where the value is inlined via ``inline_sql_literal``. + + Storage options are a DDL position, so the class declares + the literals are marked inline: values render inline only. """ + def __init__( self, dialect: "SQLDialectBase", @@ -974,10 +1562,10 @@ def __init__( self.options = options def to_sql(self) -> SQLQueryAndParams: - """Delegate to the dialect's ``format_storage_options``.""" - return self.dialect.format_storage_options(self) + """Delegate to the dialect's ``format_storage_options_expression``.""" + return self.dialect.format_storage_options_expression(self) @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_storage_options" + return "format_storage_options_expression" diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_trigger.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_trigger.py index d06f312f0..e742397df 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_trigger.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_trigger.py @@ -11,7 +11,10 @@ from ...dialect import SQLDialectBase -class TriggerTiming(Enum): +class TriggerTiming(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + """Trigger execution timing.""" BEFORE = "BEFORE" @@ -19,28 +22,34 @@ class TriggerTiming(Enum): INSTEAD_OF = "INSTEAD OF" -class TriggerEvent(Enum): +class TriggerEvent(str, Enum): """Trigger event types.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + INSERT = "INSERT" UPDATE = "UPDATE" DELETE = "DELETE" TRUNCATE = "TRUNCATE" -class TriggerLevel(Enum): +class TriggerLevel(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + """Trigger execution level.""" ROW = "FOR EACH ROW" STATEMENT = "FOR EACH STATEMENT" -class CreateTriggerExpression(BaseExpression): +class CreateTriggerStatement(BaseExpression): """SQL:1999 standard CREATE TRIGGER statement. Examples: # Basic trigger - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dialect, trigger=Trigger(dialect, "update_timestamp"), table=Table(dialect, "users"), @@ -50,7 +59,7 @@ class CreateTriggerExpression(BaseExpression): ) # Trigger with condition - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dialect, trigger=Trigger(dialect, "check_status"), table=Table(dialect, "orders"), @@ -95,11 +104,11 @@ def format_method(self) -> str: return "format_create_trigger_statement" -class DropTriggerExpression(BaseExpression): +class DropTriggerStatement(BaseExpression): """SQL:1999 standard DROP TRIGGER statement. Examples: - drop_trigger = DropTriggerExpression( + drop_trigger = DropTriggerStatement( dialect, trigger=Trigger(dialect, "update_timestamp"), table=Table(dialect, "users") diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_truncate.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_truncate.py index 3dddee1a6..af53edbc6 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_truncate.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_truncate.py @@ -10,7 +10,7 @@ from ...dialect import SQLDialectBase -class TruncateExpression(BaseExpression): +class TruncateStatement(BaseExpression): """ Represents a TRUNCATE TABLE statement supporting SQL standard and database-specific features. @@ -24,17 +24,17 @@ class TruncateExpression(BaseExpression): Examples: # Basic truncate - truncate_expr = TruncateExpression(dialect, table_name="users") + truncate_expr = TruncateStatement(dialect, table_name="users") # Truncate with restart identity (PostgreSQL) - truncate_expr = TruncateExpression( + truncate_expr = TruncateStatement( dialect, table_name="users", restart_identity=True ) # Truncate with cascade (PostgreSQL) - truncate_expr = TruncateExpression( + truncate_expr = TruncateStatement( dialect, table_name="orders", cascade=True @@ -67,12 +67,10 @@ def __init__( TypeError: If the table is not a Table """ super().__init__(dialect) - if restart_identity and continue_identity: - raise ValueError( - "restart_identity and continue_identity are mutually exclusive options" - ) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options( + restart_identity, continue_identity, "restart_identity", "continue_identity" + ) + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.table = table self.restart_identity = restart_identity self.continue_identity = continue_identity diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_type.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_type.py index 23285c3c0..a182c56f9 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_type.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_type.py @@ -37,7 +37,7 @@ def format_method(self) -> str: return "format_type_alter_action" -class CreateTypeExpression(BaseExpression): +class CreateTypeStatement(BaseExpression): """Expression for ``CREATE TYPE``.""" def __init__( @@ -62,7 +62,7 @@ def format_method(self) -> str: return "format_create_type_statement" -class AlterTypeExpression(BaseExpression): +class AlterTypeStatement(BaseExpression): """Expression for ``ALTER TYPE``.""" def __init__( @@ -86,7 +86,7 @@ def format_method(self) -> str: return "format_alter_type_statement" -class DropTypeExpression(BaseExpression): +class DropTypeStatement(BaseExpression): """Expression for ``DROP TYPE``.""" def __init__( @@ -108,7 +108,7 @@ def format_method(self) -> str: __all__ = [ "TypeDefinition", "TypeAlterAction", - "CreateTypeExpression", - "AlterTypeExpression", - "DropTypeExpression", + "CreateTypeStatement", + "AlterTypeStatement", + "DropTypeStatement", ] diff --git a/src/rhosocial/activerecord/backend/expression/statements/ddl_view.py b/src/rhosocial/activerecord/backend/expression/statements/ddl_view.py index 919b59f2b..3702e5e00 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/ddl_view.py +++ b/src/rhosocial/activerecord/backend/expression/statements/ddl_view.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase - from .dql import QueryExpression + from .dql import QueryStatement @dataclass @@ -21,17 +21,23 @@ class ColumnAlias: alias: Optional[str] = None # Optional alias for the column -class ViewAlgorithm(Enum): +class ViewAlgorithm(str, Enum): """Algorithm types for MySQL-style view creation.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + UNDEFINED = "UNDEFINED" MERGE = "MERGE" TEMPTABLE = "TEMPTABLE" -class ViewCheckOption(Enum): +class ViewCheckOption(str, Enum): """Check options for view constraints.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "NONE" LOCAL = "LOCAL" CASCADED = "CASCADED" @@ -53,7 +59,7 @@ class ViewOptions: check_option: Optional[ViewCheckOption] = None # WITH CHECK OPTION variants -class CreateViewExpression(BaseExpression): +class CreateViewStatement(BaseExpression): """ Represents a CREATE VIEW statement supporting full SQL standard features and extensions. @@ -63,10 +69,10 @@ class CreateViewExpression(BaseExpression): Examples: # Basic view creation - basic_view = CreateViewExpression( + basic_view = CreateViewStatement( dialect, view=View(dialect, "customer_info"), - query=QueryExpression( + query=QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=NamedRelationRef(dialect, Table(dialect, "customers")) @@ -74,10 +80,10 @@ class CreateViewExpression(BaseExpression): ) # View with column aliases - aliased_view = CreateViewExpression( + aliased_view = CreateViewStatement( dialect, view=View(dialect, "user_summary"), - query=QueryExpression( + query=QueryStatement( dialect, select=[ Column(dialect, "user_id").alias("id"), @@ -90,7 +96,7 @@ class CreateViewExpression(BaseExpression): ) # MySQL-style view with algorithm - mysql_view = CreateViewExpression( + mysql_view = CreateViewStatement( dialect, view=View(dialect, "optimized_view"), query=some_query, @@ -102,7 +108,7 @@ def __init__( self, dialect: "SQLDialectBase", view: "View", - query: "QueryExpression", + query: "QueryStatement", column_aliases: Optional[List[Union[str, ColumnAlias]]] = None, replace: bool = False, # CREATE OR REPLACE temporary: bool = False, # CREATE TEMPORARY VIEW (some DBs) @@ -124,7 +130,7 @@ def format_method(self) -> str: return "format_create_view_statement" -class DropViewExpression(BaseExpression): +class DropViewStatement(BaseExpression): """ Represents a DROP VIEW statement supporting standard and extended features. @@ -132,24 +138,24 @@ class DropViewExpression(BaseExpression): Examples: # Basic view drop - drop_view = DropViewExpression(dialect, view=View(dialect, "old_view")) + drop_view = DropViewStatement(dialect, view=View(dialect, "old_view")) # Drop with IF EXISTS - drop_safe = DropViewExpression( + drop_safe = DropViewStatement( dialect, view=View(dialect, "possibly_missing_view"), if_exists=True ) # Cascade drop (drops dependent objects) - drop_cascade = DropViewExpression( + drop_cascade = DropViewStatement( dialect, view=View(dialect, "master_view"), cascade=True ) # Restrict drop (refuses if objects depend on the view) - drop_restrict = DropViewExpression( + drop_restrict = DropViewStatement( dialect, view=View(dialect, "master_view"), restrict=True @@ -165,8 +171,7 @@ def __init__( restrict: bool = False, ): # DROP VIEW ... CASCADE | RESTRICT (dependent-object handling) super().__init__(dialect) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.view = view self.if_exists = if_exists self.cascade = cascade @@ -178,7 +183,7 @@ def format_method(self) -> str: return "format_drop_view_statement" -class CreateMaterializedViewExpression(BaseExpression): +class CreateMaterializedViewStatement(BaseExpression): """ Represents a CREATE MATERIALIZED VIEW statement. @@ -196,10 +201,10 @@ class CreateMaterializedViewExpression(BaseExpression): Examples: # Basic materialized view - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "user_order_summary"), - query=QueryExpression( + query=QueryStatement( dialect, select=[ Column(dialect, "user_id"), @@ -214,7 +219,7 @@ class CreateMaterializedViewExpression(BaseExpression): ) # Materialized view with storage options - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "sales_summary"), query=sales_query, @@ -227,7 +232,7 @@ def __init__( self, dialect: "SQLDialectBase", view: "MaterializedView", - query: "QueryExpression", + query: "QueryStatement", column_aliases: Optional[List[str]] = None, tablespace: Optional[str] = None, with_data: bool = False, # Whether to populate immediately @@ -235,8 +240,7 @@ def __init__( storage_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) - if with_data and no_data: - raise ValueError("with_data and no_data are mutually exclusive options") + self.require_exclusive_options(with_data, no_data, "with_data", "no_data") self.view = view self.query = query self.column_aliases = column_aliases or [] @@ -251,33 +255,33 @@ def format_method(self) -> str: return "format_create_materialized_view_statement" -class DropMaterializedViewExpression(BaseExpression): +class DropMaterializedViewStatement(BaseExpression): """ Represents a DROP MATERIALIZED VIEW statement. Examples: # Basic drop - drop_mv = DropMaterializedViewExpression( + drop_mv = DropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "old_summary") ) # Drop with IF EXISTS - drop_mv = DropMaterializedViewExpression( + drop_mv = DropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "possibly_missing"), if_exists=True ) # Cascade drop - drop_mv = DropMaterializedViewExpression( + drop_mv = DropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "parent_view"), cascade=True ) # Restrict drop - drop_mv = DropMaterializedViewExpression( + drop_mv = DropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "parent_view"), restrict=True @@ -293,8 +297,7 @@ def __init__( restrict: bool = False, ): super().__init__(dialect) - if cascade and restrict: - raise ValueError("cascade and restrict are mutually exclusive options") + self.require_exclusive_options(cascade, restrict, "cascade", "restrict") self.view = view self.if_exists = if_exists self.cascade = cascade @@ -306,7 +309,7 @@ def format_method(self) -> str: return "format_drop_materialized_view_statement" -class RefreshMaterializedViewExpression(BaseExpression): +class RefreshMaterializedViewStatement(BaseExpression): """ Represents a REFRESH MATERIALIZED VIEW statement. @@ -315,20 +318,20 @@ class RefreshMaterializedViewExpression(BaseExpression): Examples: # Basic refresh - refresh_mv = RefreshMaterializedViewExpression( + refresh_mv = RefreshMaterializedViewStatement( dialect, view=MaterializedView(dialect, "sales_summary") ) # Refresh with concurrent option (PostgreSQL) - refresh_mv = RefreshMaterializedViewExpression( + refresh_mv = RefreshMaterializedViewStatement( dialect, view=MaterializedView(dialect, "user_stats"), concurrent=True ) # Refresh without data (PostgreSQL) - refresh_mv = RefreshMaterializedViewExpression( + refresh_mv = RefreshMaterializedViewStatement( dialect, view=MaterializedView(dialect, "empty_view"), no_data=True @@ -344,8 +347,7 @@ def __init__( no_data: bool = False, # WITH NO DATA ): super().__init__(dialect) - if with_data and no_data: - raise ValueError("with_data and no_data are mutually exclusive options") + self.require_exclusive_options(with_data, no_data, "with_data", "no_data") self.view = view self.concurrent = concurrent self.with_data = with_data diff --git a/src/rhosocial/activerecord/backend/expression/statements/dml.py b/src/rhosocial/activerecord/backend/expression/statements/dml.py index d65dad45b..845cbc257 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/dml.py +++ b/src/rhosocial/activerecord/backend/expression/statements/dml.py @@ -14,13 +14,15 @@ if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase - from .dql import QueryExpression - from ..query_sources import ValuesExpression, TableFunctionExpression, LateralExpression, SetOperationExpression + from .dql import QueryStatement + from ..query_sources import ValuesExpression, TableFunctionExpression, LateralExpression, SetOperation from ..query_parts import JoinClause # region Merge Statement -class MergeActionType(Enum): +class MergeActionType(str, Enum): + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ """Represents the type of action to perform in a MERGE statement.""" UPDATE = "UPDATE" @@ -55,7 +57,7 @@ def __init__( self.matched = matched -class MergeExpression(BaseExpression): +class MergeStatement(BaseExpression): """ Represents a SQL MERGE statement conforming to SQL standard syntax. @@ -72,7 +74,7 @@ class MergeExpression(BaseExpression): Example: # Simple merge: update if exists, insert if not - merge = MergeExpression( + merge = MergeStatement( dialect, target_table="products", source=ValuesSource(dialect, [[1, "Product A", 19.99]], "new_products", ["id", "name", "price"]), @@ -166,7 +168,7 @@ class ReturningClause(BaseExpression): # RETURNING * to return all columns returning_clause = ReturningClause( dialect, - expressions=[RawSQLExpression(dialect, "*")] # Use RawSQL for asterisk + expressions=[RawSQL(dialect, "*")] # Use RawSQL for asterisk ) """ @@ -189,7 +191,7 @@ def format_method(self) -> str: # region Delete Statement -class DeleteExpression(BaseExpression): +class DeleteStatement(BaseExpression): """ Represents an SQL DELETE statement, allowing removal of rows from a table. It supports specifying a target table, an optional FROM clause for joining @@ -207,13 +209,13 @@ def __init__( Union[ "NamedRelationRef", "Subquery", - "SetOperationExpression", + "SetOperation", "JoinClause", List[ Union[ "NamedRelationRef", "Subquery", - "SetOperationExpression", + "SetOperation", "JoinClause", "ValuesExpression", "TableFunctionExpression", @@ -253,7 +255,7 @@ def __init__( self.returning = returning # RETURNING clause object def validate(self, strict: bool = True) -> None: - """Validate DeleteExpression parameters according to SQL standard. + """Validate DeleteStatement parameters according to SQL standard. Args: strict: If True, perform strict validation that may impact performance. @@ -278,18 +280,18 @@ def validate(self, strict: bool = True) -> None: # For complex types, check their type names using_type_name = type(self.using).__name__ valid_type_names = [ - "SetOperationExpression", + "SetOperation", "JoinClause", "ValuesExpression", "TableFunctionExpression", "LateralExpression", - "QueryExpression", + "QueryStatement", ] if using_type_name not in valid_type_names: raise TypeError( - f"using must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, " + f"using must be one of: str, NamedRelationRef, Subquery, SetOperation, " f"JoinClause, list, ValuesExpression, TableFunctionExpression, " - f"LateralExpression, QueryExpression, got {type(self.using)}" + f"LateralExpression, QueryStatement, got {type(self.using)}" ) # Validate where parameter @@ -315,7 +317,7 @@ def statement_type(self) -> StatementType: # region Update Statement -class UpdateExpression(BaseExpression): +class UpdateStatement(BaseExpression): """ Represents an SQL UPDATE statement, allowing modification of existing rows in a table. It supports a target table, assignment of new values to columns, @@ -338,13 +340,13 @@ def __init__( # More advanced SQL dialects (e.g., PostgreSQL) allow richer FROM sources. "NamedRelationRef", "Subquery", - "SetOperationExpression", + "SetOperation", "JoinClause", List[ Union[ "NamedRelationRef", "Subquery", - "SetOperationExpression", + "SetOperation", "JoinClause", "ValuesExpression", "TableFunctionExpression", @@ -381,7 +383,7 @@ def __init__( self.returning = returning # RETURNING clause object def validate(self, strict: bool = True) -> None: - """Validate UpdateExpression parameters according to SQL standard. + """Validate UpdateStatement parameters according to SQL standard. Args: strict: If True, perform strict validation that may impact performance. @@ -408,7 +410,7 @@ def validate(self, strict: bool = True) -> None: # For complex types, check their type names from_type_name = type(self.from_).__name__ valid_type_names = [ - "SetOperationExpression", + "SetOperation", "JoinClause", "ValuesExpression", "TableFunctionExpression", @@ -416,7 +418,7 @@ def validate(self, strict: bool = True) -> None: ] if from_type_name not in valid_type_names: raise TypeError( - f"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, " + f"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, " f"JoinClause, list, ValuesExpression, TableFunctionExpression, " f"LateralExpression, got {type(self.from_)}" ) @@ -456,7 +458,7 @@ class InsertDataSource(BaseExpression, abc.ABC): gives it a serialization contract: ``get_params()`` reads the constructor signature and the serializer encodes any ``BaseExpression`` it finds, so a source survives ``serialize()`` / ``serialize_json()`` / ``serialize_xml()`` - and comes back as the same class. An ``InsertExpression`` holding a source + and comes back as the same class. An ``InsertStatement`` holding a source is therefore portable, which is the whole point of an expression tree: the statement can be built once and sent to any dialect. @@ -484,11 +486,11 @@ class ValuesSource(InsertDataSource): Currently, the type hint for these expressions is `BaseExpression`. This generic type allows for various expression types (e.g., `Literal`, `Column`, - `RawSQLExpression`, scalar `QueryExpression` as subqueries) to be used as values. + `RawSQL`, scalar `QueryStatement` as subqueries) to be used as values. It is a pragmatic choice given the current stage of framework development. However, it's important to note that statement-level expressions (such as - `InsertExpression`, `UpdateExpression`, `DeleteExpression`, or non-scalar `QueryExpression`) + `InsertStatement`, `UpdateStatement`, `DeleteStatement`, or non-scalar `QueryStatement`) are *not* valid elements within `values_list`. These are complete SQL statements and do not represent a single value or expression suitable for a `VALUES` clause. @@ -515,7 +517,7 @@ def format_method(self) -> str: class SelectSource(InsertDataSource): """Represents a data source from a SELECT subquery.""" - def __init__(self, dialect: "SQLDialectBase", select_query: "QueryExpression"): + def __init__(self, dialect: "SQLDialectBase", select_query: "QueryStatement"): super().__init__(dialect) self.select_query = select_query @@ -566,10 +568,17 @@ def format_method(self) -> str: return "format_on_conflict_clause" -class InsertExpression(BaseExpression): +class InsertStatement(BaseExpression): """ Represents a structured INSERT statement, supporting various data sources, upsert logic (single or multiple ON CONFLICT clauses), and RETURNING clauses. + + ``or_replace`` and ``or_ignore`` are SQLite's INSERT spellings (``INSERT OR + REPLACE`` / ``INSERT OR IGNORE``). They are declared here, defaulting to + ``False``, so the dialects that spell them read a declared field instead of + probing for an attribute that only a caller who assigned it by hand would + have provided -- which made the feature unreachable. A dialect that spells + neither ignores both fields. """ def __init__( @@ -581,6 +590,8 @@ def __init__( *, on_conflict: Optional[Union[OnConflictClause, List[OnConflictClause]]] = None, returning: Optional["ReturningClause"] = None, # Using ReturningClause instead of list of expressions + or_replace: bool = False, + or_ignore: bool = False, ): super().__init__(dialect) @@ -589,6 +600,17 @@ def __init__( self.columns = columns self.on_conflict = self._normalize_on_conflict(on_conflict) self.returning = returning # ReturningClause object or None + self.or_replace = or_replace + self.or_ignore = or_ignore + + if self.or_replace and self.or_ignore: + raise ValueError("Cannot specify both 'or_replace' and 'or_ignore'.") + if (self.or_replace or self.or_ignore) and self.on_conflict: + raise ValueError( + "Cannot use 'or_replace'/'or_ignore' together with 'on_conflict'. " + "Use either the SQLite-specific OR REPLACE/IGNORE syntax or the " + "standard ON CONFLICT clause, but not both." + ) # Perform validation # 1. First, check if on_conflict is used with a valid source @@ -621,7 +643,7 @@ def _normalize_on_conflict( return on_conflict def validate(self, strict: bool = True) -> None: - """Validate InsertExpression parameters according to SQL standard. + """Validate InsertStatement parameters according to SQL standard. Args: strict: If True, perform strict validation that may impact performance. diff --git a/src/rhosocial/activerecord/backend/expression/statements/dql.py b/src/rhosocial/activerecord/backend/expression/statements/dql.py index e04c0c2e0..d201e0c97 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/dql.py +++ b/src/rhosocial/activerecord/backend/expression/statements/dql.py @@ -2,44 +2,60 @@ """DQL (Data Query Language) statement expressions.""" from enum import Enum -from typing import List, Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional, Union -from ..bases import BaseExpression, SQLPredicate, SQLValueExpression +from ...expression.objects import Table +from ...schema import StatementType +from ..bases import BaseExpression, SQLPredicate, SQLValue from ..core import Subquery -from ..objects import Table -from ..sources import TableSource from ..mixins import ArithmeticMixin, ComparisonMixin +from ..objects import Table from ..query_parts import ( - WhereClause, + ForUpdateClause, GroupByHavingClause, + LimitOffsetClause, OrderByClause, QualifyClause, - LimitOffsetClause, - ForUpdateClause, + WhereClause, ) +from ..sources import TableSource from ._types import FromSourceType -from ...schema import StatementType -from ...expression.objects import Table if TYPE_CHECKING: # pragma: no cover from ...dialect import SQLDialectBase # region Query Statement -class SelectModifier(Enum): +class SelectModifier(str, Enum): """ Specifies the SELECT clause modifier to apply to the result set. This enumeration allows controlling duplicate row inclusion in query results. Note that ALL is often the default behavior in SQL and may be omitted in the generated SQL depending on the dialect's requirements. + + ``str`` so a member *is* the word it carries and the SQL keyword is written + by the formatter. Being a plain ``Enum`` used to make this value unwritable: + a ``QueryStatement`` carrying ``select_modifier`` raised from the artifact + encoder, so ``SELECT DISTINCT`` -- one of the most ordinary queries there is + -- could not be captured at all. The values are lowercase words for the same + reason :class:`~..query_parts.JoinType` has them: the artifact is + language-neutral and the keyword belongs to the dialect. """ - DISTINCT = "DISTINCT" - ALL = "ALL" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + + DISTINCT = "distinct" + ALL = "all" + + @property + def keyword(self) -> str: + """The SQL keyword this modifier writes. Never in the artifact.""" + return self.value.upper() -class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): +class QueryStatement(ArithmeticMixin, ComparisonMixin, SQLValue): """ Represents a complete SELECT query expression with all clauses supported by the framework. @@ -52,7 +68,7 @@ class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): Example Usage: # Basic SELECT query - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -60,18 +76,18 @@ class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): ) # Scalar SELECT query - selecting constants or function results without FROM - scalar_query = QueryExpression( + scalar_query = QueryStatement( dialect, select=[Literal(dialect, 1)] # Equivalent to SELECT 1; ) - now_query = QueryExpression( + now_query = QueryStatement( dialect, select=[FunctionCall(dialect, "NOW")] # Equivalent to SELECT NOW(); ) # SELECT with DISTINCT and complex clauses - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "category"), FunctionCall(dialect, "COUNT", Column(dialect, "id"))], from_=NamedRelationRef(dialect, Table(dialect, "products")), @@ -86,13 +102,13 @@ class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): ) # Simple aggregate functions - count_query = QueryExpression( + count_query = QueryStatement( dialect, select=[FunctionCall(dialect, "COUNT", Column(dialect, "id"))], # COUNT(id) from_=NamedRelationRef(dialect, Table(dialect, "users")) ) - max_price_query = QueryExpression( + max_price_query = QueryStatement( dialect, select=[FunctionCall(dialect, "MAX", Column(dialect, "price"))], # MAX(price) from_=NamedRelationRef(dialect, Table(dialect, "products")) @@ -107,7 +123,7 @@ class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): ) window_func = FunctionCall(dialect, "ROW_NUMBER", window_spec=window_spec, alias="row_num") - window_query = QueryExpression( + window_query = QueryStatement( dialect, select=[ Column(dialect, "employee_name"), @@ -122,7 +138,7 @@ class QueryExpression(ArithmeticMixin, ComparisonMixin, SQLValueExpression): ) # Query with FOR UPDATE clause - lock_query = QueryExpression( + lock_query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -146,16 +162,22 @@ def __init__( List[FromSourceType], ] ] = None, - where: Optional[Union["SQLPredicate", "WhereClause"]] = None, # WHERE condition or clause object - group_by_having: Optional["GroupByHavingClause"] = None, # Combined GROUP BY/HAVING clause object + where: Optional[ + Union["SQLPredicate", "WhereClause"] + ] = None, # WHERE condition or clause object + group_by_having: Optional[ + "GroupByHavingClause" + ] = None, # Combined GROUP BY/HAVING clause object order_by: Optional["OrderByClause"] = None, # ORDER BY clause object qualify: Optional["QualifyClause"] = None, # QUALIFY clause object limit_offset: Optional["LimitOffsetClause"] = None, # Combined LIMIT/OFFSET clause object for_update: Optional["ForUpdateClause"] = None, # FOR UPDATE clause object - select_modifier: Optional[SelectModifier] = None, # SELECT modifier - DISTINCT|ALL, None means no modifier + select_modifier: Optional[ + SelectModifier + ] = None, # SELECT modifier - DISTINCT|ALL, None means no modifier ): """ - Initialize a QueryExpression instance with the specified query components. + Initialize a QueryStatement instance with the specified query components. This class provides a comprehensive implementation of SQL SELECT statements with support for all major SQL clauses through dedicated clause objects. This approach allows for better @@ -183,7 +205,7 @@ def __init__( Raises: ValueError: If HAVING is provided without GROUP BY (validated within GroupByHavingClause) If OFFSET is provided without LIMIT (validated by dialect supports_offset_without_limit) - If assignments are empty (though not applicable to QueryExpression) + If assignments are empty (though not applicable to QueryStatement) TypeError: If from_ parameter is not one of the supported types Note: @@ -216,7 +238,7 @@ def __init__( self.select_modifier = select_modifier # SELECT modifier (DISTINCT/ALL), optional def validate(self, strict: bool = True) -> None: - """Validate QueryExpression parameters according to SQL standard. + """Validate QueryStatement parameters according to SQL standard. Args: strict: If True, perform strict validation that may impact performance. @@ -253,7 +275,7 @@ def _is_valid_from_source(item): if not _is_valid_from_source(item): raise TypeError( f"from_ list item at index {i} must be one of: str, NamedRelationRef, " - f"Subquery, SetOperationExpression, JoinClause, ValuesExpression, " + f"Subquery, SetOperation, JoinClause, ValuesExpression, " f"TableFunctionExpression, LateralExpression, GraphTableExpression, " f"got {type(item)}" ) @@ -261,7 +283,7 @@ def _is_valid_from_source(item): # For single values, validate using the same helper if not _is_valid_from_source(self.from_): raise TypeError( - f"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, " + f"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, " f"JoinClause, list, ValuesExpression, TableFunctionExpression, " f"LateralExpression, GraphTableExpression, got {type(self.from_)}" ) @@ -271,8 +293,12 @@ def _is_valid_from_source(item): raise TypeError(f"where must be WhereClause or SQLPredicate, got {type(self.where)}") # Validate group_by_having parameter - if self.group_by_having is not None and not isinstance(self.group_by_having, GroupByHavingClause): - raise TypeError(f"group_by_having must be GroupByHavingClause, got {type(self.group_by_having)}") + if self.group_by_having is not None and not isinstance( + self.group_by_having, GroupByHavingClause + ): + raise TypeError( + f"group_by_having must be GroupByHavingClause, got {type(self.group_by_having)}" + ) # Validate order_by parameter if self.order_by is not None and not isinstance(self.order_by, OrderByClause): @@ -284,15 +310,21 @@ def _is_valid_from_source(item): # Validate limit_offset parameter if self.limit_offset is not None and not isinstance(self.limit_offset, LimitOffsetClause): - raise TypeError(f"limit_offset must be LimitOffsetClause, got {type(self.limit_offset)}") + raise TypeError( + f"limit_offset must be LimitOffsetClause, got {type(self.limit_offset)}" + ) # Validate for_update parameter if self.for_update is not None and not isinstance(self.for_update, ForUpdateClause): raise TypeError(f"for_update must be ForUpdateClause, got {type(self.for_update)}") # Validate select_modifier parameter - if self.select_modifier is not None and not isinstance(self.select_modifier, SelectModifier): - raise TypeError(f"select_modifier must be SelectModifier, got {type(self.select_modifier)}") + if self.select_modifier is not None and not isinstance( + self.select_modifier, SelectModifier + ): + raise TypeError( + f"select_modifier must be SelectModifier, got {type(self.select_modifier)}" + ) @property def statement_type(self) -> StatementType: diff --git a/src/rhosocial/activerecord/backend/expression/statements/explain.py b/src/rhosocial/activerecord/backend/expression/statements/explain.py index b5fa0b4a2..27817cd2a 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/explain.py +++ b/src/rhosocial/activerecord/backend/expression/statements/explain.py @@ -13,9 +13,12 @@ # region Explain Statement -class ExplainType(Enum): +class ExplainType(str, Enum): """EXPLAIN statement type, representing different analysis modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + BASIC = "BASIC" # Basic plan ANALYZE = "ANALYZE" # Execute and analyze QUERY_PLAN = "QUERY_PLAN" # Query plan @@ -24,9 +27,12 @@ class ExplainType(Enum): PROFILE = "PROFILE" # Performance profile -class ExplainFormat(Enum): +class ExplainFormat(str, Enum): """EXPLAIN output formats.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + TEXT = "TEXT" # Text format JSON = "JSON" # JSON format XML = "XML" # XML format @@ -65,7 +71,7 @@ class ExplainOptions: wal: bool = False # Show WAL statistics -class ExplainExpression(BaseExpression): +class ExplainStatement(BaseExpression): """ Unified EXPLAIN expression class that supports differences across database dialects. @@ -74,13 +80,13 @@ class ExplainExpression(BaseExpression): Usage Examples: # Basic EXPLAIN - basic_explain = ExplainExpression( + basic_explain = ExplainStatement( dialect, statement=query_stmt ) # EXPLAIN with options - detailed_explain = ExplainExpression( + detailed_explain = ExplainStatement( dialect, statement=query_stmt, options=ExplainOptions( @@ -93,7 +99,7 @@ class ExplainExpression(BaseExpression): ) # EXPLAIN with specific format - formatted_explain = ExplainExpression( + formatted_explain = ExplainStatement( dialect, statement=query_stmt, options=ExplainOptions( diff --git a/src/rhosocial/activerecord/backend/expression/statements/filter_clause.py b/src/rhosocial/activerecord/backend/expression/statements/filter_clause.py index 7242f3935..57849232c 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/filter_clause.py +++ b/src/rhosocial/activerecord/backend/expression/statements/filter_clause.py @@ -2,7 +2,7 @@ from ..core import BaseExpression -class FilterClauseExpression(BaseExpression): +class FilterClause(BaseExpression): """FILTER (WHERE ...) clause wrapping a condition expression.""" def __init__(self, dialect, condition: BaseExpression): diff --git a/src/rhosocial/activerecord/backend/expression/statements/fulltext_match.py b/src/rhosocial/activerecord/backend/expression/statements/fulltext_match.py index 36b845ab1..7a0fb63b8 100644 --- a/src/rhosocial/activerecord/backend/expression/statements/fulltext_match.py +++ b/src/rhosocial/activerecord/backend/expression/statements/fulltext_match.py @@ -3,7 +3,7 @@ from ..core import BaseExpression -class FulltextMatchExpression(BaseExpression): +class FulltextMatch(BaseExpression): """MATCH ... AGAINST full-text search expression.""" def __init__(self, dialect, columns: List[str], search_term: str, diff --git a/src/rhosocial/activerecord/backend/expression/transaction.py b/src/rhosocial/activerecord/backend/expression/transaction.py index 54856de23..a1e7876db 100644 --- a/src/rhosocial/activerecord/backend/expression/transaction.py +++ b/src/rhosocial/activerecord/backend/expression/transaction.py @@ -22,11 +22,11 @@ from ..dialect import SQLDialectBase -class TransactionExpression(BaseExpression): - """Base class for transaction control expressions. +class TransactionStatement(BaseExpression): + """Base class for transaction statements. - All transaction expressions inherit from this class and provide - fluent API for setting parameters. Transaction expressions hold + All transaction statements inherit from this class and provide + fluent API for setting parameters. Transaction statements hold a dialect reference and delegate SQL generation to the corresponding dialect via the to_sql() method. """ @@ -35,15 +35,15 @@ def __init__(self, dialect: "SQLDialectBase"): super().__init__(dialect) -class BeginTransactionExpression(TransactionExpression): - """Expression for BEGIN TRANSACTION statement. +class BeginTransactionStatement(TransactionStatement): + """BEGIN TRANSACTION statement. Collects parameters for starting a new transaction with optional isolation level and access mode settings. Usage: - expr = BeginTransactionExpression(dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) expr.read_only() sql, params = expr.to_sql() """ @@ -61,25 +61,23 @@ def __init__( begin_type: Optional[str] = None, ): super().__init__(dialect) - if deferrable and not_deferrable: - raise ValueError("deferrable and not_deferrable are mutually exclusive options") - if wait and no_wait: - raise ValueError("wait and no_wait are mutually exclusive options") - self._isolation_level: Optional[IsolationLevel] = isolation_level - self._mode: Optional[TransactionMode] = mode + self.require_exclusive_options(deferrable, not_deferrable, "deferrable", "not_deferrable") + self.require_exclusive_options(wait, no_wait, "wait", "no_wait") + self.isolation_level: Optional[IsolationLevel] = isolation_level + self.mode: Optional[TransactionMode] = mode # PostgreSQL-specific: deferrable mode for SERIALIZABLE transactions. # [NOT] DEFERRABLE is an independent transaction mode in the grammar; # it is rendered whenever requested, not only alongside SERIALIZABLE. - self._deferrable: bool = deferrable - self._not_deferrable: bool = not_deferrable + self.deferrable: bool = deferrable + self.not_deferrable: bool = not_deferrable # Firebird-specific: the lock-wait clause. WAIT and NO WAIT are two # spellings with one parameter each; neither set renders nothing. - self._wait: bool = wait - self._no_wait: bool = no_wait + self.wait: bool = wait + self.no_wait: bool = no_wait # SQLite-specific: BEGIN transaction type (DEFERRED|IMMEDIATE|EXCLUSIVE) - self._begin_type: Optional[str] = begin_type + self.begin_type: Optional[str] = begin_type - def isolation_level(self, level: IsolationLevel) -> "BeginTransactionExpression": + def with_isolation_level(self, level: IsolationLevel) -> "BeginTransactionStatement": """Set the transaction isolation level. Args: @@ -89,10 +87,10 @@ def isolation_level(self, level: IsolationLevel) -> "BeginTransactionExpression" Returns: Self for method chaining. """ - self._isolation_level = level + self.isolation_level = level return self - def read_only(self) -> "BeginTransactionExpression": + def read_only(self) -> "BeginTransactionStatement": """Set transaction to read-only mode. In read-only mode, the transaction cannot modify data. @@ -101,10 +99,10 @@ def read_only(self) -> "BeginTransactionExpression": Returns: Self for method chaining. """ - self._mode = TransactionMode.READ_ONLY + self.mode = TransactionMode.READ_ONLY return self - def read_write(self) -> "BeginTransactionExpression": + def read_write(self) -> "BeginTransactionStatement": """Set transaction to read-write mode (default). In read-write mode, the transaction can read and modify data. @@ -112,10 +110,10 @@ def read_write(self) -> "BeginTransactionExpression": Returns: Self for method chaining. """ - self._mode = TransactionMode.READ_WRITE + self.mode = TransactionMode.READ_WRITE return self - def deferrable(self) -> "BeginTransactionExpression": + def with_deferrable(self) -> "BeginTransactionStatement": """Set deferrable mode (PostgreSQL-specific). Deferrable mode is only *effective* for SERIALIZABLE transactions, but @@ -125,41 +123,41 @@ def deferrable(self) -> "BeginTransactionExpression": Returns: Self for method chaining. """ - self._deferrable = True - self._not_deferrable = False + self.deferrable = True + self.not_deferrable = False return self - def not_deferrable(self) -> "BeginTransactionExpression": + def with_not_deferrable(self) -> "BeginTransactionStatement": """Set NOT DEFERRABLE mode (PostgreSQL-specific). Returns: Self for method chaining. """ - self._not_deferrable = True - self._deferrable = False + self.not_deferrable = True + self.deferrable = False return self - def wait(self) -> "BeginTransactionExpression": + def with_wait(self) -> "BeginTransactionStatement": """Set the lock-wait clause to WAIT (Firebird-specific). Returns: Self for method chaining. """ - self._wait = True - self._no_wait = False + self.wait = True + self.no_wait = False return self - def no_wait(self) -> "BeginTransactionExpression": + def with_no_wait(self) -> "BeginTransactionStatement": """Set the lock-wait clause to NO WAIT (Firebird-specific). Returns: Self for method chaining. """ - self._no_wait = True - self._wait = False + self.no_wait = True + self.wait = False return self - def begin_type(self, begin_type: str) -> "BeginTransactionExpression": + def with_begin_type(self, begin_type: str) -> "BeginTransactionStatement": """Set the BEGIN transaction type (SQLite-specific). SQLite supports three transaction types: @@ -173,32 +171,32 @@ def begin_type(self, begin_type: str) -> "BeginTransactionExpression": Returns: Self for method chaining. """ - self._begin_type = begin_type + self.begin_type = begin_type return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_begin_transaction" + return "format_begin_transaction_statement" -class CommitTransactionExpression(TransactionExpression): +class CommitStatement(TransactionStatement): """Expression for COMMIT statement. Commits the current transaction, making all changes permanent. Usage: - expr = CommitTransactionExpression(dialect) + expr = CommitStatement(dialect) sql, params = expr.to_sql() """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_commit_transaction" + return "format_commit_statement" -class RollbackTransactionExpression(TransactionExpression): +class RollbackStatement(TransactionStatement): """Expression for ROLLBACK statement. Rolls back the current transaction, discarding all changes. @@ -206,20 +204,20 @@ class RollbackTransactionExpression(TransactionExpression): Usage: # Full rollback - expr = RollbackTransactionExpression(dialect) + expr = RollbackStatement(dialect) sql, params = expr.to_sql() # Rollback to savepoint - expr = RollbackTransactionExpression(dialect) + expr = RollbackStatement(dialect) expr.to_savepoint("my_savepoint") sql, params = expr.to_sql() """ def __init__(self, dialect: "SQLDialectBase", *, savepoint: Optional[str] = None): super().__init__(dialect) - self._savepoint: Optional[str] = savepoint + self.savepoint: Optional[str] = savepoint - def to_savepoint(self, name: str) -> "RollbackTransactionExpression": + def to_savepoint(self, name: str) -> "RollbackStatement": """Rollback to a specific savepoint. Args: @@ -228,23 +226,23 @@ def to_savepoint(self, name: str) -> "RollbackTransactionExpression": Returns: Self for method chaining. """ - self._savepoint = name + self.savepoint = name return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_rollback_transaction" + return "format_rollback_statement" -class SavepointExpression(TransactionExpression): +class SavepointStatement(TransactionStatement): """Expression for SAVEPOINT statement. Creates a savepoint within the current transaction. Savepoints allow partial rollback of transactions. Usage: - expr = SavepointExpression(dialect, "my_savepoint") + expr = SavepointStatement(dialect, "my_savepoint") sql, params = expr.to_sql() """ @@ -260,17 +258,17 @@ def name(self) -> str: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_savepoint" + return "format_savepoint_statement" -class ReleaseSavepointExpression(TransactionExpression): +class ReleaseSavepointStatement(TransactionStatement): """Expression for RELEASE SAVEPOINT statement. Releases a savepoint, keeping the changes made after it. The savepoint can no longer be used for rollback. Usage: - expr = ReleaseSavepointExpression(dialect, "my_savepoint") + expr = ReleaseSavepointStatement(dialect, "my_savepoint") sql, params = expr.to_sql() """ @@ -286,19 +284,19 @@ def name(self) -> str: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_release_savepoint" + return "format_release_savepoint_statement" -class SetTransactionExpression(TransactionExpression): - """Expression for SET TRANSACTION statement. +class SetTransactionStatement(TransactionStatement): + """SET TRANSACTION statement. Sets transaction characteristics for the next transaction. Used primarily in MySQL where isolation level must be set before starting the transaction. Usage: - expr = SetTransactionExpression(dialect) - expr.isolation_level(IsolationLevel.READ_COMMITTED) + expr = SetTransactionStatement(dialect) + expr.with_isolation_level(IsolationLevel.READ_COMMITTED) expr.read_only() sql, params = expr.to_sql() """ @@ -316,19 +314,17 @@ def __init__( no_wait: bool = False, ): super().__init__(dialect) - if deferrable and not_deferrable: - raise ValueError("deferrable and not_deferrable are mutually exclusive options") - if wait and no_wait: - raise ValueError("wait and no_wait are mutually exclusive options") - self._isolation_level: Optional[IsolationLevel] = isolation_level - self._mode: Optional[TransactionMode] = mode - self._session: bool = session - self._deferrable: bool = deferrable - self._not_deferrable: bool = not_deferrable - self._wait: bool = wait - self._no_wait: bool = no_wait - - def isolation_level(self, level: IsolationLevel) -> "SetTransactionExpression": + self.require_exclusive_options(deferrable, not_deferrable, "deferrable", "not_deferrable") + self.require_exclusive_options(wait, no_wait, "wait", "no_wait") + self.isolation_level: Optional[IsolationLevel] = isolation_level + self.mode: Optional[TransactionMode] = mode + self.session: bool = session + self.deferrable: bool = deferrable + self.not_deferrable: bool = not_deferrable + self.wait: bool = wait + self.no_wait: bool = no_wait + + def with_isolation_level(self, level: IsolationLevel) -> "SetTransactionStatement": """Set the transaction isolation level. Args: @@ -337,28 +333,28 @@ def isolation_level(self, level: IsolationLevel) -> "SetTransactionExpression": Returns: Self for method chaining. """ - self._isolation_level = level + self.isolation_level = level return self - def read_only(self) -> "SetTransactionExpression": + def read_only(self) -> "SetTransactionStatement": """Set transaction to read-only mode. Returns: Self for method chaining. """ - self._mode = TransactionMode.READ_ONLY + self.mode = TransactionMode.READ_ONLY return self - def read_write(self) -> "SetTransactionExpression": + def read_write(self) -> "SetTransactionStatement": """Set transaction to read-write mode. Returns: Self for method chaining. """ - self._mode = TransactionMode.READ_WRITE + self.mode = TransactionMode.READ_WRITE return self - def session(self, value: bool = True) -> "SetTransactionExpression": + def with_session(self, value: bool = True) -> "SetTransactionStatement": """Set whether to use SESSION CHARACTERISTICS (PostgreSQL specific). When True, sets transaction characteristics for all subsequent @@ -370,50 +366,50 @@ def session(self, value: bool = True) -> "SetTransactionExpression": Returns: Self for method chaining. """ - self._session = value + self.session = value return self - def deferrable(self) -> "SetTransactionExpression": + def with_deferrable(self) -> "SetTransactionStatement": """Set DEFERRABLE mode for SERIALIZABLE transactions (PostgreSQL specific). Returns: Self for method chaining. """ - self._deferrable = True - self._not_deferrable = False + self.deferrable = True + self.not_deferrable = False return self - def not_deferrable(self) -> "SetTransactionExpression": + def with_not_deferrable(self) -> "SetTransactionStatement": """Set NOT DEFERRABLE mode for transactions (PostgreSQL specific). Returns: Self for method chaining. """ - self._not_deferrable = True - self._deferrable = False + self.not_deferrable = True + self.deferrable = False return self - def wait(self) -> "SetTransactionExpression": + def with_wait(self) -> "SetTransactionStatement": """Set the lock-wait clause to WAIT (Firebird-specific). Returns: Self for method chaining. """ - self._wait = True - self._no_wait = False + self.wait = True + self.no_wait = False return self - def no_wait(self) -> "SetTransactionExpression": + def with_no_wait(self) -> "SetTransactionStatement": """Set the lock-wait clause to NO WAIT (Firebird-specific). Returns: Self for method chaining. """ - self._no_wait = True - self._wait = False + self.no_wait = True + self.wait = False return self @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_transaction" + return "format_set_transaction_statement" diff --git a/src/rhosocial/activerecord/backend/expression/type_name.py b/src/rhosocial/activerecord/backend/expression/type_name.py index 4b0c98e66..99e7a5a62 100644 --- a/src/rhosocial/activerecord/backend/expression/type_name.py +++ b/src/rhosocial/activerecord/backend/expression/type_name.py @@ -23,7 +23,6 @@ # src/rhosocial/activerecord/backend/expression/type_name.py import re -from typing import Optional #: Multi-word built-in type names. The only reason a type name is not a bare @@ -34,6 +33,7 @@ "double precision", "national character", "national character varying", "time with time zone", "time without time zone", "timestamp with time zone", "timestamp without time zone", + "timestamp with local time zone", "unsigned big int", "unsigned int", "unsigned small int", "unsigned tiny int", "character large object", "binary large object", }) diff --git a/src/rhosocial/activerecord/backend/expression/types/_base.py b/src/rhosocial/activerecord/backend/expression/types/_base.py index a1289c1d6..82cf65414 100644 --- a/src/rhosocial/activerecord/backend/expression/types/_base.py +++ b/src/rhosocial/activerecord/backend/expression/types/_base.py @@ -6,7 +6,7 @@ import inspect import re from abc import ABC -from typing import TYPE_CHECKING, Optional, Tuple +from typing import Any, TYPE_CHECKING, Optional, Tuple from ..bases import BaseExpression @@ -232,12 +232,53 @@ def parse_data_type_str(dialect: "SQLDialectBase", raw: str) -> "DataType": Delegates to ``dialect.parse_type(raw)`` when the dialect implements ``DataTypeSupport``. Falls back to ``CustomType(raw)``. + + This is the **read** direction: the server reported a spelling, and + the framework needs the concept behind it. Nothing in the write + direction comes through here -- see :meth:`as_type`. """ from ...dialect.protocols import DataTypeSupport if isinstance(dialect, DataTypeSupport): return dialect.parse_type(raw) from .custom import CustomType - return CustomType(dialect, raw) + return CustomType(dialect, raw=raw) + + @staticmethod + def as_type(dialect: "SQLDialectBase", value: Any) -> "DataType": + """The type node for *value*, which may be a node or a type name. + + The write direction's counterpart to :meth:`parse_data_type_str`. A + name is not parsed here -- it is *claimed*: the type is settled in + advance, and a name the framework does not model becomes a + :class:`CustomType`, whose name the grammar validates. A type + position cannot take a bound parameter, so what lands there is code, + not data, and the grammar is what keeps it a name. + + A node is used as it is, so a type the framework *does* model is + rendered by the dialect in its own spelling: ``TextType`` is ``TEXT`` + on PostgreSQL and ``CLOB`` on Oracle. + + Args: + dialect: The dialect the type belongs to. + value: A type node, or a type name. + + Returns: + The type node. + + Raises: + TypeError: If *value* is neither a node nor a name. + InvalidTypeNameError: If *value* is a name that is not one. + """ + if isinstance(value, DataType): + return value + if isinstance(value, str): + from .custom import CustomType + + return CustomType(dialect, raw=value) + raise TypeError( + "a type must be a type name or a DataType, got " + f"{type(value).__name__}" + ) def __repr__(self) -> str: params = self.identity() diff --git a/src/rhosocial/activerecord/backend/expression/types/array.py b/src/rhosocial/activerecord/backend/expression/types/array.py index 1e9cb1aa5..dc46eaab0 100644 --- a/src/rhosocial/activerecord/backend/expression/types/array.py +++ b/src/rhosocial/activerecord/backend/expression/types/array.py @@ -54,8 +54,8 @@ class ArrayType(DataType): name = "array" - def __init__(self, dialect: Optional["SQLDialectBase"] = None, - element_type: Optional[DataType] = None, dimensions: int = 1, + def __init__(self, dialect: Optional["SQLDialectBase"] = None, *, + element_type: DataType, dimensions: int = 1, ): super().__init__(dialect) if element_type is None: diff --git a/src/rhosocial/activerecord/backend/expression/types/custom.py b/src/rhosocial/activerecord/backend/expression/types/custom.py index ceedbc5d2..2cba7aa1b 100644 --- a/src/rhosocial/activerecord/backend/expression/types/custom.py +++ b/src/rhosocial/activerecord/backend/expression/types/custom.py @@ -25,7 +25,7 @@ class CustomType(DataType): raw: str - def __init__(self, dialect=None, raw: str = "", + def __init__(self, dialect=None, *, raw: str, ): super().__init__(dialect) # Validated here rather than in each dialect's formatter: six of the diff --git a/src/rhosocial/activerecord/backend/expression/types/enum_.py b/src/rhosocial/activerecord/backend/expression/types/enum_.py index 193d78f98..d15864836 100644 --- a/src/rhosocial/activerecord/backend/expression/types/enum_.py +++ b/src/rhosocial/activerecord/backend/expression/types/enum_.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import List, Tuple from ._base import DataType @@ -15,7 +15,7 @@ class EnumType(DataType): values: Tuple[str, ...] = () - def __init__(self, dialect=None, values: Optional[List[str]] = None, + def __init__(self, dialect=None, *, values: List[str], ): super().__init__(dialect) if values is None: diff --git a/src/rhosocial/activerecord/backend/expression/types/integer.py b/src/rhosocial/activerecord/backend/expression/types/integer.py index b38e71bd2..d07f8ad70 100644 --- a/src/rhosocial/activerecord/backend/expression/types/integer.py +++ b/src/rhosocial/activerecord/backend/expression/types/integer.py @@ -29,11 +29,6 @@ class TinyIntType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False, spelling: str = "tinyint"): super().__init__(dialect) - 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." - ) self.unsigned = unsigned self.spelling = spelling @@ -51,11 +46,6 @@ class SmallIntType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False, spelling: str = "smallint"): super().__init__(dialect) - 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." - ) self.unsigned = unsigned self.spelling = spelling @@ -86,11 +76,6 @@ class IntegerType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False, spelling: str = "integer"): super().__init__(dialect) - 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." - ) self.unsigned = unsigned self.spelling = spelling @@ -108,11 +93,6 @@ class BigIntType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False, spelling: str = "bigint"): super().__init__(dialect) - 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." - ) self.unsigned = unsigned self.spelling = spelling diff --git a/src/rhosocial/activerecord/backend/expression/types/numeric.py b/src/rhosocial/activerecord/backend/expression/types/numeric.py index 74adea767..414766741 100644 --- a/src/rhosocial/activerecord/backend/expression/types/numeric.py +++ b/src/rhosocial/activerecord/backend/expression/types/numeric.py @@ -40,11 +40,6 @@ def __init__(self, dialect=None, precision: Optional[int] = None, *, unsigned: bool = False): super().__init__(dialect) self._precision = precision - 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." - ) self.unsigned = unsigned @property @@ -126,11 +121,6 @@ class RealType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False): super().__init__(dialect) - 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." - ) self.unsigned = unsigned PARAMETERS = ("unsigned",) @@ -152,11 +142,6 @@ class DoubleType(DataType): def __init__(self, dialect=None, *, unsigned: bool = False, spelling: str = "double"): super().__init__(dialect) - 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." - ) self.unsigned = unsigned self.spelling = spelling @@ -182,11 +167,6 @@ def __init__(self, dialect=None, precision: Optional[int] = None, super().__init__(dialect) self._precision = precision self._scale = scale - 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." - ) self.unsigned = unsigned self.spelling = spelling diff --git a/src/rhosocial/activerecord/backend/expression/uuid.py b/src/rhosocial/activerecord/backend/expression/uuid.py index 5a0531d31..75864aedc 100644 --- a/src/rhosocial/activerecord/backend/expression/uuid.py +++ b/src/rhosocial/activerecord/backend/expression/uuid.py @@ -22,10 +22,11 @@ back to generating one in Python, which is what ``UUIDMixin`` does today. """ +from enum import Enum from typing import Any, Optional, TYPE_CHECKING from ..dialect.exceptions import UnsupportedFeatureError -from .bases import SQLValueExpression +from .bases import SQLValue from .mixins import AliasableMixin, ComparisonMixin, TypeCastingMixin if TYPE_CHECKING: # pragma: no cover @@ -58,11 +59,28 @@ def _require(dialect, probe: str, feature: str, alternative: str) -> None: ) +class UuidConstantKind(str, Enum): + """The two UUID constants that can be written as a literal. + + Nothing else is accepted, so this is the whole legal set: a typo must not + silently render a constant that was not asked for. + """ + + NIL = "nil" + MAX = "max" + + #: A member *is* the word it carries: str() and f-string interpolation must + #: yield the value, not ``ClassName.MEMBER``, or the rendered SQL and the + #: artifact would carry the Python spelling. ``(str, Enum)`` alone does not + #: give that -- ``Enum.__str__`` wins -- so it is stated here once. + __str__ = str.__str__ + + class UUIDGenerationExpression( AliasableMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """A fresh UUID, rendered as a native zero-argument function call. @@ -96,14 +114,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uuid_generation" + return "format_uuid_generation_expression" class UUIDConstantExpression( AliasableMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """The ``nil`` or ``max`` UUID constant. @@ -112,12 +130,10 @@ class UUIDConstantExpression( is accepted — a typo must not silently render a literal. """ - _KINDS = ("nil", "max") - def __init__( self, dialect: "SQLDialectBase", - which: str, + which: UuidConstantKind, *, alias: Optional[str] = None, ): @@ -125,11 +141,17 @@ def __init__( # Argument validation first: a typo is a bug in the calling code, and # reporting "this dialect cannot render constants" instead would hide # it behind an environment limit the caller cannot change. - normalised = str(which).lower() - if normalised not in self._KINDS: - raise ValueError( - f"which must be one of {self._KINDS}, got {which!r}" + try: + normalised = ( + which + if isinstance(which, UuidConstantKind) + else UuidConstantKind(str(which).lower()) ) + except ValueError: + raise ValueError( + f"which must be one of " + f"{', '.join(k.value for k in UuidConstantKind)}, got {which!r}" + ) from None _require( dialect, "supports_uuid_constant", @@ -143,14 +165,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uuid_constant" + return "format_uuid_constant_expression" -class UUIDCastExpression( +class UUIDCastFunction( AliasableMixin, ComparisonMixin, TypeCastingMixin, - SQLValueExpression, + SQLValue, ): """Convert a textual value to a UUID, rejecting malformed input. @@ -186,5 +208,5 @@ def format_method(self) -> str: __all__ = [ "UUIDGenerationExpression", "UUIDConstantExpression", - "UUIDCastExpression", + "UUIDCastFunction", ] diff --git a/src/rhosocial/activerecord/backend/expression/value_types.py b/src/rhosocial/activerecord/backend/expression/value_types.py new file mode 100644 index 000000000..2f1e24938 --- /dev/null +++ b/src/rhosocial/activerecord/backend/expression/value_types.py @@ -0,0 +1,297 @@ +# src/rhosocial/activerecord/backend/expression/value_types.py +"""A *value type*: what a node yields, and what can be done to it. + +A ``*Value`` class states what an operation's result *is*. Text can be searched +and sliced, a number can be added to and has a square root, a date can have days +taken from it -- and the class that says so is named for the type, not for the +node, because the type is a property of the result rather than of whatever +produced it. + +Fetching a column is an operation too, so a column reference is one of these +plus the column's name: :class:`~...expression.column_types.NumericColumn` and +:class:`NumericValue` declare the same thing about the same kind of thing, and +the only difference between them is that one knows where to read from. That is +why there are not two families. It is also why these classes were once called +*surfaces* -- a name for "the operations, with the value part removed" -- which +described what had been taken away rather than what was left. + +What is deliberately **not** here: + +* no ``__init__`` -- a value type declares what operations exist, not how a node + is built. A node binds its dialect through :class:`BaseExpression` directly. +* no ``to_sql`` -- a value type cannot render, because it is not a node. A node + renders itself through its own ``format_method``; see + :class:`~...expression.advanced_functions.DeclaredValueType` for how a node + that also names a value type keeps its own rendering. +* no forwarding -- the value types do not walk MROs to find each other. That + existed for a wrapper holding a call; a node holding nothing does not need it. +* no value -- the value at a position belongs to + :class:`~...expression.core.Literal`, and a value type never carries one. +""" + +from abc import ABC, abstractmethod + +from .bases import SQLValue +from .mixins import ( + AliasableMixin, + ArithmeticMixin, + ArrayMixin, + BooleanLogicMixin, + ComparisonMixin, + DateTimeMixin, + JSONAccessorMixin, + NotANumberMixin, + NotComparableMixin, + NullTestMixin, + NumericValueMixin, + StringPatternPredicateMixin, + StringToIntegerMixin, + StringValueMixin, + TemporalArithmeticMixin, + TranscendentalMixin, + TypeCastingMixin, +) + + +class TextValue( + AliasableMixin, + ComparisonMixin, + StringToIntegerMixin, + StringPatternPredicateMixin, + StringValueMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, + ABC, +): + """Text: comparable, searchable, sliceable, convertible to a number. + + ``StringToIntegerMixin`` is here because ``length()`` and ``bit_length()`` + read a string and answer with a number -- they are operations *on* text whose + result is not text, which is not the same as not offering them. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class NumericValue( + AliasableMixin, + ArithmeticMixin, + ComparisonMixin, + NumericValueMixin, + TranscendentalMixin, + TypeCastingMixin, + SQLValue, + ABC, +): + """A number: arithmetic, comparison, and the algebraic and transcendental roots. + + The same surface serves whole numbers and fractions, because it is the + operations that are the same; what differs is what a node *claims* to be, + and that is said by the node's own kind rather than by a second surface. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class TemporalValue( + AliasableMixin, + ComparisonMixin, + DateTimeMixin, + TemporalArithmeticMixin, + TypeCastingMixin, + SQLValue, + ABC, +): + """A date, a time, a date-time or a span of one. + + No ``StringPatternPredicateMixin``: a date is not text, so ``like`` is not an + operation any of them offers. No ``ArithmeticMixin`` either -- a temporal node + is combined by ``date_add``/``date_sub``, not by ``+``, because what the + ``+`` means depends on which side is the interval. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class BooleanValue( + AliasableMixin, + ComparisonMixin, + BooleanLogicMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, + ABC, +): + """A truth value: three-valued logic over ``AND``/``OR``/``NOT``.""" + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class ArrayValue( + AliasableMixin, + ComparisonMixin, + ArrayMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, + ABC, +): + """A sequence: its elements, its length, its positions.""" + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class JSONValue( + AliasableMixin, + ComparisonMixin, + JSONAccessorMixin, + TypeCastingMixin, + SQLValue, + ABC, +): + """A JSON document: path access, extraction, the array and object forms. + + No arithmetic: a JSON document is not a number however it is written, and + offering ``+`` on one would be a question no backend answers. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class OpaqueValue( + AliasableMixin, + ComparisonMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, + ABC, +): + """Something whose operations are only comparison and conversion. + + One surface for UUIDs and for binary blocks, because their operations are the + same set -- equal, ordered, convertible -- and differ only in what the value + *is*, which is the node's kind rather than the surface's business. Splitting + them would imply a difference in what can be done to them, and there is none. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +class XMLValue( + AliasableMixin, + NotComparableMixin, + NullTestMixin, + TypeCastingMixin, + NotANumberMixin, + SQLValue, + ABC, +): + """An XML document: the null tests and conversion, but not comparison. + + ``<`` between two XML documents is not a question any backend answers the + same way, so the surface declines it rather than offering something whose + answer would vary by engine. + """ + + @property + @abstractmethod + def format_method(self) -> str: + """Never implemented: a surface is not a node and cannot render. + + Declared abstract in every surface for two reasons. The registrar skips + a class with unimplemented abstract methods, so a surface never enters + the kind whitelist as a name nothing can produce. And a node that forgets + its own ``format_method`` fails when its class is built rather than when + it is rendered -- a mistake at import rather than at query time. + """ + raise NotImplementedError + + +__all__ = [ + "TextValue", + "NumericValue", + "TemporalValue", + "BooleanValue", + "ArrayValue", + "JSONValue", + "OpaqueValue", + "XMLValue", +] diff --git a/src/rhosocial/activerecord/backend/expression/values.py b/src/rhosocial/activerecord/backend/expression/values.py new file mode 100644 index 000000000..db55c19b7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/expression/values.py @@ -0,0 +1,368 @@ +# src/rhosocial/activerecord/backend/expression/values.py +"""The closed vocabulary of value kinds (catalog §3.3, wire §5). + +A value that reaches SQL is carried by :class:`~.core.Literal`, which answers +one question -- whether that value binds or is written as text -- and nothing +else. What kind of value it is is a separate question, and this module is its +answer: a closed set, because the set of things a bind parameter can hold is +closed. + +=========================== ========================== ======================= +stage form who converts +=========================== ========================== ======================= +:class:`Literal` ``value`` host value the caller +the artifact canonical spelling :func:`LiteralKind.pack` +the parameter sequence host value nobody -- it is what + the driver binds +=========================== ========================== ======================= + +So an artifact carries the kind as ``{"kind": "date_value", "params": +{"value": "2026-10-17"}}`` and a Python ``date`` is its host form. Why the +spelling rather than the host value: spec 4.8 requires the artifact to be +language-neutral, so a Python ``date`` and a Go ``time.Time`` must serialize +identically. That constraint lands here, on the pack/unpack pair, which is +where it belongs. + +There is no class per value type and there is no node per value type. One node +carries every value, so the twelve classes this used to have would each have +restated the same two things -- the host type, and the dialect method that +spells it -- on a node that had nothing else to do. + +Why the spelling is not a free choice at the call site: the artifact must be +readable by a reader that has a different set of host types, so the conversion +is declared once per kind rather than left to each caller. +""" + +import base64 +import datetime +import decimal +import enum +import math +import re +import uuid +from typing import Any, Callable, Optional + +#: ISO 8601 duration, reduced to what ``timedelta`` can hold: days, hours, +#: minutes, seconds and an optional fraction. Years and months are refused -- +#: their length is not a constant, so a timedelta cannot carry them honestly. +_DURATION_RE = re.compile( + r"^-?P" + r"(?:(?P\d+)D)?" + r"(?:T" + r"(?:(?P\d+)H)?" + r"(?:(?P\d+)M)?" + r"(?:(?P\d+(?:\.\d+)?)S)?" + r")?$" +) + + +def _parse_duration(text: str) -> datetime.timedelta: + """``"P1DT2H3M4.5S"`` -> timedelta, or a refusal naming what it cannot hold.""" + match = _DURATION_RE.match(text) + if match is None: + raise ValueError( + f"{text!r} is not an ISO 8601 duration this implementation can " + f"carry. Years and months have no fixed length, so they cannot " + f"become a timedelta; use a day/hour/minute/second form." + ) + parts = match.groupdict() + delta = datetime.timedelta( + days=int(parts["days"] or 0), + hours=int(parts["hours"] or 0), + minutes=int(parts["minutes"] or 0), + seconds=float(parts["seconds"] or 0), + ) + return -delta if text.startswith("-") else delta + + +def _pack_duration(value: datetime.timedelta) -> str: + """``timedelta`` -> ISO 8601, empty parts omitted.""" + total = value.total_seconds() + sign = "-" if total < 0 else "" + days, rest = divmod(abs(total), 86400) + hours, rest = divmod(rest, 3600) + minutes, seconds = divmod(rest, 60) + out = [sign, "P"] + if days: + out.append(f"{int(days)}D") + if hours or minutes or seconds or len(out) == 2: + out.append("T") + if hours: + out.append(f"{int(hours)}H") + if minutes: + out.append(f"{int(minutes)}M") + if seconds or (not hours and not minutes): + text = f"{seconds:.6f}".rstrip("0").rstrip(".") + out.append(f"{text or '0'}S") + return "".join(out) + + +def _identity(value: Any) -> Any: + """A host type JSON already carries: ``int``, ``bool``, ``str``, ``None``.""" + return value + + +def _unpack_float(payload: Any) -> Any: + """A JSON number read back as a float. + + Non-finite is refused, and not because the value is somehow invalid -- it is + an ordinary member of the float type. It is refused because ``float_value`` + is the kind for a number *JSON* can carry, and these three are exactly what + JSON's number grammar leaves out (RFC 8259). They have kinds of their own + (:attr:`LiteralKind.NAN`, :attr:`LiteralKind.INFINITY`), so one arriving here + is a payload that contradicts its own kind. + + A non-number is refused for that reason plus one: it would bind as a string + and surface as a database error somewhere else entirely, naming neither the + artifact nor the position. + """ + if isinstance(payload, bool) or not isinstance(payload, (int, float)): + raise ValueError(f"float_value must carry a JSON number, got {payload!r}") + if math.isnan(payload) or math.isinf(payload): + raise ValueError( + f"float_value cannot carry {payload!r}: JSON has no literal for a " + "non-finite number (RFC 8259). It has a kind of its own -- " + "nan_value or infinity_value -- which travels as text." + ) + return float(payload) + + +def _pack_infinity(value: Any) -> Any: + """An infinity, as the text naming which way it points. + + The sign is the whole content of the value, so it is spelled rather than + left to be inferred: ``"+inf"`` is not a JSON number either, and writing a + bare ``Infinity`` would put the same problem back one level up. + """ + return "+inf" if value > 0 else "-inf" + + +def _unpack_infinity(payload: Any) -> Any: + """``"+inf"`` / ``"-inf"`` back to the float they name.""" + if payload == "+inf": + return float("inf") + if payload == "-inf": + return float("-inf") + raise ValueError(f"infinity_value must be '+inf' or '-inf', got {payload!r}") + + +class LiteralKind(enum.Enum): + """One value type: its wire kind, its host type, and the two conversions. + + A member states four things and no more, and each of them is forced by the + others. ``wire`` is the kind string that goes into an artifact (spec 4.11.1 + -- part of the wire contract, so it cannot drift). ``host`` is the type a + bind parameter of this kind carries, and it is what :meth:`of` matches + against. ``pack`` turns the host value into what JSON can carry, and + ``unpack`` turns it back; the default pair is the identity, because a + value whose host type *is* a JSON scalar needs no conversion. + + The order of the members is the order :meth:`of` tries them, and it is not + cosmetic -- two host types are subclasses of another, so the narrower one + has to be tried first or the broader one claims it. ``bool`` is an ``int`` + subclass, so ``BOOLEAN`` precedes ``INTEGER`` and every ``True`` reads back + as itself rather than as ``1``. ``datetime`` is a ``date`` subclass, so + ``TIMESTAMP`` precedes ``DATE``. With one class per kind, as this module + used to have, no such order existed to get wrong; an enum makes the + matching positional and so has to say which way round it goes. + + ``LIST`` and ``TUPLE`` are here because a literal really does carry them: + ``IN`` takes a value list and renders one placeholder per member, so the + list is the value at that position rather than a container around one. The + two are separate kinds because JSON draws no distinction between an array + and a tuple and the artifact must (§4.8) -- they are told apart on the way + back by the kind, which is the whole reason a kind is worth having. + """ + + NULL = ("null_value", type(None), None, None, _identity, _identity) + BOOLEAN = ("boolean_value", bool, None, None, _identity, _identity) + INTEGER = ("integer_value", int, None, None, _identity, _identity) + FLOAT = ("float_value", float, None, None, _identity, _unpack_float) + # The two members of the float type JSON's number grammar leaves out. They + # are not errors and not edge cases -- IEEE 754 defines them and a Decimal + # has them -- so they are carried, each in its own kind, as text. Putting + # them in float_value would mean a payload that is not the number its kind + # promises; dropping them would mean losing part of the type. + NAN = ("nan_value", float, None, None, lambda _value: "nan", + lambda _text: float("nan")) + INFINITY = ("infinity_value", float, None, None, _pack_infinity, _unpack_infinity) + DECIMAL = ( + "decimal_value", + decimal.Decimal, + None, + None, + # A Decimal travels as a string: binary floating point cannot carry its + # precision, and an artifact must not round a money value. + str, + lambda text: decimal.Decimal(text), + ) + STRING = ("string_value", str, None, None, _identity, _identity) + BINARY = ( + "binary_value", + (bytes, bytearray), + None, + None, + # Base64 is text, so the wire form is a JSON string. It used to be + # ``{"__b64__": "..."}`` -- a wrapper around that same string, carrying + # nothing the kind was not already saying, and something every language + # would have had to reimplement in order to read it back. + lambda value: base64.b64encode(bytes(value)).decode("ascii"), + lambda text: base64.b64decode(text), + ) + TIMESTAMP = ( + "timestamp_value", + datetime.datetime, + None, + None, + lambda value: value.isoformat(sep=" "), + # Naive and aware are both ``datetime``; the distinction lives in + # ``tzinfo`` and the spelling keeps the offset. This never adds ``Z`` + # and never reinterprets a naive value in some other zone. + lambda text: datetime.datetime.fromisoformat(text), + ) + DATE = ( + "date_value", + datetime.date, + None, + None, + lambda value: value.isoformat(), + lambda text: datetime.date.fromisoformat(text), + ) + TIME = ( + "time_value", + datetime.time, + None, + None, + lambda value: value.isoformat(), + lambda text: datetime.time.fromisoformat(text), + ) + INTERVAL = ( + "interval_value", + datetime.timedelta, + None, + None, + # ISO 8601 is text, so the wire form is a JSON string. The entry stays + # wide and a bare number still reads as seconds -- the only unit a plain + # scalar can name -- while what gets *written* is always the string, so + # the artifact holds one normalised type (§10: wide in, normalised out). + _pack_duration, + lambda payload: _parse_duration(payload) + if isinstance(payload, str) + else datetime.timedelta(seconds=float(payload)), + ) + LIST = ("list_value", list, None, None, list, list) + TUPLE = ("tuple_value", tuple, None, None, list, tuple) + UUID = ( + "uuid_value", + uuid.UUID, + None, + None, + str, + # Every spelling names one value: hyphenated, plain hex, any case, the + # ``0x`` form. Widening here is a compatibility question -- external + # sources write all four -- not a semantic one. + lambda text: uuid.UUID(text), + ) + + def __init__( + self, + wire: str, + host: Any, + _formatter: Optional[str], + _unused: Optional[str], + pack: Callable[[Any], Any], + unpack: Callable[[Any], Any], + ): + self._value_ = wire + self.host = host + self._pack = pack + self._unpack = unpack + + @property + def wire(self) -> str: + """The kind string this value type writes into an artifact.""" + return self._value_ + + @classmethod + def of(cls, value: Any) -> "LiteralKind": + """The kind *value* belongs to, by its host type. + + This is the one place a bare host value names its own kind, and it is + total over the closed set above. A value outside the set is refused by + name rather than guessed at: a bind parameter carrying something this + package does not know how to write has no artifact, and inventing one + would put an unreadable value on the wire. + """ + return cls._refine(cls._by_host(value), value) + + @classmethod + def _by_host(cls, value: Any) -> "LiteralKind": + for member in cls: + if isinstance(value, member.host): + return member + raise TypeError( + f"{type(value).__name__} is not one of the value kinds this " + f"package can write: " + + ", ".join(m.wire for m in cls) + + ". Implement SQLLiteral on the type for inline rendering, and " + "give it its own artifact shape if it must travel." + ) + + @classmethod + def match(cls, value: Any) -> Optional["LiteralKind"]: + """The kind *value* belongs to, or ``None`` if it belongs to none. + + The difference from :meth:`of` is who gets to be told "no". A literal + that carries a type the dialect constructs itself -- a ``Point``, an + ``inet``, anything implementing :class:`~...bases.SQLLiteral` -- still + renders, and refusing to build it would refuse SQL the backend can + write. So building asks :meth:`match`, and refusing is left to + serialization, which is the layer that actually cannot proceed. + """ + for member in cls: + if isinstance(value, member.host): + return cls._refine(member, value) + return None + + @classmethod + def _refine(cls, member: "LiteralKind", value: Any) -> "LiteralKind": + """Separate the non-finite floats from the rest, which share one type. + + ``isinstance`` cannot do this and no amount of reordering will: NaN and + both infinities *are* floats, so all four arrive as the same host type. + The kind table matches on type everywhere else, so this is the one place + that also looks at the value -- and it is worth the exception, because + the alternative is a kind whose payload contradicts its own name. + + The membership is decided once, here, and read back through the same + function on the way in, so a value written as ``nan_value`` comes back + as a NaN and not as some other float. + """ + if member is cls.FLOAT: + if math.isnan(value): + return cls.NAN + if math.isinf(value): + return cls.INFINITY + return member + + @classmethod + def by_wire(cls, wire: str) -> "LiteralKind": + """The kind an artifact's ``kind`` names.""" + for member in cls: + if member.wire == wire: + return member + raise ValueError(f"{wire!r} is not a known value kind") + + def pack(self, value: Any) -> Any: + """*value* as JSON carries it, given it is this kind.""" + return self._pack(value) + + def unpack(self, payload: Any) -> Any: + """A packed payload as the host value this kind carries.""" + return self._unpack(payload) + + def __str__(self) -> str: + return self.wire + + +__all__ = ["LiteralKind", "_parse_duration", "_pack_duration"] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/expression/xml.py b/src/rhosocial/activerecord/backend/expression/xml.py index b74911d88..fa9e0b745 100644 --- a/src/rhosocial/activerecord/backend/expression/xml.py +++ b/src/rhosocial/activerecord/backend/expression/xml.py @@ -3,20 +3,28 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional, Sequence, TYPE_CHECKING +from typing import Optional, Sequence, TYPE_CHECKING, Union from .advanced_functions import DeclaredValueType -from .bases import BaseExpression, SQLPredicate, SQLQueryAndParams, SQLValueExpression -from .core import StringValueExpression, XMLValueExpression +from .bases import BaseExpression, SQLPredicate, SQLValue if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase from .query_parts import OrderByClause + from .types import DataType +from .value_types import ( + TextValue, + XMLValue, +) + class XMLParseDocumentType(str, Enum): """SQL/XML XMLPARSE document type keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + DOCUMENT = "DOCUMENT" CONTENT = "CONTENT" @@ -24,6 +32,9 @@ class XMLParseDocumentType(str, Enum): class XMLWhitespaceOption(str, Enum): """SQL/XML whitespace handling keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + PRESERVE = "PRESERVE WHITESPACE" STRIP = "STRIP WHITESPACE" @@ -31,6 +42,9 @@ class XMLWhitespaceOption(str, Enum): class XMLSerializeDocumentType(str, Enum): """SQL/XML XMLSERIALIZE document type keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + DOCUMENT = "DOCUMENT" CONTENT = "CONTENT" @@ -38,6 +52,9 @@ class XMLSerializeDocumentType(str, Enum): class XMLStandaloneOption(str, Enum): """SQL/XML XMLROOT standalone option keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + YES = "YES" NO = "NO" NO_VALUE = "NO VALUE" @@ -46,6 +63,9 @@ class XMLStandaloneOption(str, Enum): class XMLPassingMechanism(str, Enum): """SQL/XML XMLQUERY/XMLEXISTS passing mechanism keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + BY_REF = "BY REF" BY_VALUE = "BY VALUE" @@ -53,6 +73,9 @@ class XMLPassingMechanism(str, Enum): class XMLEmptyHandlingOption(str, Enum): """SQL/XML XMLQUERY empty sequence handling keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NULL_ON_EMPTY = "NULL ON EMPTY" EMPTY_ON_EMPTY = "EMPTY ON EMPTY" @@ -60,15 +83,22 @@ class XMLEmptyHandlingOption(str, Enum): class XMLTableColumnOption(str, Enum): """SQL/XML XMLTABLE column option keywords.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + PATH = "PATH" DEFAULT = "DEFAULT" -class XMLParseExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLParseExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLPARSE expression constructor. The parse of a document *is* an XML value, so it declares - :class:`~...expression.core.XMLValueExpression` (see + :class:`~...expression.value_types.XMLValue` (see :class:`~...expression.advanced_functions.DeclaredValueType` for why these nodes inherit the value class rather than being wrapped by one). """ @@ -88,15 +118,19 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlparse_expression" + return "format_xml_parse_expression" -class XMLSerializeExpression(DeclaredValueType, StringValueExpression, SQLValueExpression): +class XMLSerializeExpression( + DeclaredValueType, + TextValue, + SQLValue, +): """Represents a SQL/XML XMLSERIALIZE expression constructor. Serialization leaves the XML family: what comes back is the character value named in ``target_type``, so the node declares - :class:`~...expression.core.StringValueExpression` and carries the string + :class:`~...expression.value_types.TextValue` and carries the string operations rather than the XML surface. """ @@ -104,20 +138,22 @@ def __init__( self, dialect: "SQLDialectBase", content: BaseExpression, - target_type: str, + target_type: "Union[str, DataType]", document_type: XMLSerializeDocumentType = XMLSerializeDocumentType.CONTENT, whitespace_option: Optional[XMLWhitespaceOption] = None, ): + from .types import DataType + BaseExpression.__init__(self, dialect) self.content = content - self.target_type = target_type + self.target_type = DataType.as_type(dialect, target_type) self.document_type = document_type self.whitespace_option = whitespace_option @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlserialize_expression" + return "format_xml_serialize_expression" @dataclass @@ -134,7 +170,7 @@ class XMLAttribute: name: Optional[str] = None -class XMLAttributesExpression(SQLValueExpression): +class XMLAttributesExpression(SQLValue): """Represents a SQL/XML XMLATTRIBUTES clause.""" def __init__( @@ -148,10 +184,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlattributes_expression" + return "format_xml_attributes_expression" -class XMLElementExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLElementExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLELEMENT expression constructor.""" def __init__( @@ -169,7 +209,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlelement_expression" + return "format_xml_element_expression" @dataclass @@ -183,7 +223,11 @@ class XMLForestItem: name: Optional[str] = None -class XMLForestExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLForestExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLFOREST expression constructor.""" def __init__( @@ -197,10 +241,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlforest_expression" + return "format_xml_forest_expression" -class XMLConcatExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLConcatExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLCONCAT expression constructor.""" def __init__(self, dialect: "SQLDialectBase", parts: Sequence[BaseExpression]): @@ -210,10 +258,14 @@ def __init__(self, dialect: "SQLDialectBase", parts: Sequence[BaseExpression]): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlconcat_expression" + return "format_xml_concat_expression" -class XMLCommentExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLCommentExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLCOMMENT expression constructor.""" def __init__(self, dialect: "SQLDialectBase", content: BaseExpression): @@ -223,10 +275,14 @@ def __init__(self, dialect: "SQLDialectBase", content: BaseExpression): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlcomment_expression" + return "format_xml_comment_expression" -class XMLPIExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLPIExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLPI expression constructor.""" def __init__( @@ -245,7 +301,11 @@ def format_method(self) -> str: return "format_xmlpi_expression" -class XMLRootExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLRootExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLROOT expression constructor.""" def __init__( @@ -263,10 +323,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlroot_expression" + return "format_xml_root_expression" -class XMLAggExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLAggExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLAGG aggregate expression.""" def __init__( @@ -282,53 +346,83 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlagg_expression" + return "format_xml_agg_expression" + + +class PassingClause(BaseExpression): + """The SQL/XML ``PASSING`` clause: the values passed into an XML function. + + ``PASSING [BY {VALUE | REF}]expr [, expr]...`` is a named production + the three querying XML expressions (XMLQUERY, XMLEXISTS, XMLTABLE) + all carry, so it is a clause node of its own with the dialect's + public ``format_passing_clause`` as its formatter — instead of the + unpacked pieces every XML formatter used to pass to a private helper, + which gave the shared state no home and gave backends nothing to + override. Carrying no expressions renders as ``("", ())``. + + Renders as, for example: ``PASSING BY VALUE 'x', y``. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_passing_clause" + + def __init__( + self, + dialect: "SQLDialectBase", + expressions: Sequence["BaseExpression"] = (), + mechanism: Optional[XMLPassingMechanism] = None, + ): + super().__init__(dialect) + self.expressions = list(expressions) + self.mechanism = mechanism -class XMLQueryExpression(DeclaredValueType, XMLValueExpression, SQLValueExpression): +class XMLQueryExpression( + DeclaredValueType, + XMLValue, + SQLValue, +): """Represents a SQL/XML XMLQUERY expression.""" def __init__( self, dialect: "SQLDialectBase", query: BaseExpression, - passing: Optional[Sequence[BaseExpression]] = None, - passing_mechanism: Optional[XMLPassingMechanism] = None, + passing_clause: Optional[PassingClause] = None, returning_content: bool = True, empty_handling: Optional[XMLEmptyHandlingOption] = None, ): BaseExpression.__init__(self, dialect) self.query = query - self.passing = list(passing or []) - self.passing_mechanism = passing_mechanism + self.passing_clause = passing_clause self.returning_content = returning_content self.empty_handling = empty_handling @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlquery_expression" + return "format_xml_query_expression" -class XMLExistsExpression(SQLPredicate): +class XMLExistsPredicate(SQLPredicate): """Represents a SQL/XML XMLEXISTS predicate.""" def __init__( self, dialect: "SQLDialectBase", query: BaseExpression, - passing: Optional[Sequence[BaseExpression]] = None, - passing_mechanism: Optional[XMLPassingMechanism] = None, + passing_clause: Optional[PassingClause] = None, ): super().__init__(dialect) self.query = query - self.passing = list(passing or []) - self.passing_mechanism = passing_mechanism + self.passing_clause = passing_clause @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmlexists_expression" + return "format_xmlexists_predicate" @dataclass @@ -342,7 +436,7 @@ class XMLTableColumn: """ name: str - data_type: str + data_type: "Union[str, DataType]" path: Optional[BaseExpression] = None default: Optional[BaseExpression] = None @@ -355,16 +449,14 @@ def __init__( dialect: "SQLDialectBase", row_pattern: BaseExpression, columns: Sequence[XMLTableColumn], - passing: Optional[Sequence[BaseExpression]] = None, - passing_mechanism: Optional[XMLPassingMechanism] = None, + passing_clause: Optional[PassingClause] = None, ): super().__init__(dialect) self.row_pattern = row_pattern self.columns = list(columns) - self.passing = list(passing or []) - self.passing_mechanism = passing_mechanism + self.passing_clause = passing_clause @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_xmltable_expression" + return "format_xml_table_expression" diff --git a/src/rhosocial/activerecord/backend/expression/xml_serialization.py b/src/rhosocial/activerecord/backend/expression/xml_serialization.py index 3c1ad6570..060b6bff1 100644 --- a/src/rhosocial/activerecord/backend/expression/xml_serialization.py +++ b/src/rhosocial/activerecord/backend/expression/xml_serialization.py @@ -1,34 +1,36 @@ # src/rhosocial/activerecord/backend/expression/xml_serialization.py """ -Expression spec <-> XML serialization. +XML rendering of an expression artifact. -The expression instance is first converted to a spec dict (the ``{type, params}`` -structure produced by ``ExpressionSerializer.serialize``, with the reserved keys -``__expr__`` / ``__tuple__`` / ``__value__`` / ``__vdc__``). This module encodes -that spec dict into an XML document and decodes an XML document back into the -identical spec dict, so ``deserialize()`` and the value codecs are reused -unchanged. +The artifact is produced by the JSON codec first and rendered here, then parsed +back into the identical dict, so ``deserialize()`` is reused unchanged and the +two renderings cannot disagree about what a node is. -Mapping (symmetric, unambiguous wrapping): - module.Class + query_statement - … + … -…value… within a is one of: +…a value within a ```` is one of: scalar -> text + optional type attribute (int/float/bool) - null -> - list -> …… - tuple -> …… - plain dict -> …… - {"__expr__": spec} -> …… - {"__value__": [tag, payload]} -> … - -Every distinct Python container/type maps to a distinct element tag, so -decode is lossless and symmetric with encode. All encoding/parsing is handled -by the standard library ``xml.etree.ElementTree``. + null -> ```` + list -> ```` with one child per element + tuple -> ```` with one child per element + plain dict -> ``……`` + a nested node -> also a ````: it *is* ``{kind, params}``, and + the reader recognises it by that shape + +There are no reserved keys and no wrapper elements for values. A value JSON +cannot spell -- bytes, a timedelta, a Decimal -- travels as the text its kind +names, and the same ```` element carries it with the kind recorded in the +node around it. A per-language dispatch would have to be reimplemented here to +survive the round-trip, which is exactly what the format refuses to ask of any +reader. + +Decoding is lossless and symmetric with encoding. All encoding/parsing is +handled by the standard library ``xml.etree.ElementTree``. """ from typing import Any, Dict, Optional @@ -39,11 +41,12 @@ MAP = "map" LIST = "list" TUPLE_NODE = "tuple" -TYPE_EL = "type" +# The node's *kind*, not its class's FQN. Read by kind is why no import path +# ever crosses the wire (§7.2). Distinct from the ``type="int"`` attribute +# that tags a scalar's textual form -- that one is a lexical marker, not an +# identity, and keeps its name. +KIND_EL = "kind" PARAMS_EL = "params" -EXPR_NODE = "expr" -VALUE_NODE = "value" -VDC_NODE = "vdc" NULL = "null" @@ -71,29 +74,12 @@ def _fill_value(parent: ET.Element, value: Any) -> None: ET.SubElement(parent, NULL) return if isinstance(value, dict): - if "__expr__" in value: - spec = value["__expr__"] - node = ET.SubElement(parent, EXPR_NODE) - t = ET.SubElement(node, TYPE_EL) - t.text = spec.get("type", "") - _fill_dict(ET.SubElement(node, PARAMS_EL), spec.get("params", {})) - elif "__tuple__" in value: - node = ET.SubElement(parent, TUPLE_NODE) - for item in value["__tuple__"]: - _fill_value(node, item) - elif "__value__" in value: - payload = value["__value__"] - node = ET.SubElement(parent, VALUE_NODE) - node.set("tag", payload[0]) - _fill_payload(node, payload[1]) - elif "__vdc__" in value: - payload = value["__vdc__"] - node = ET.SubElement(parent, VDC_NODE) - node.set("class", payload[0]) - _fill_dict(ET.SubElement(node, PARAMS_EL), payload[1]) - else: - node = ET.SubElement(parent, MAP) - _fill_dict(node, value) + # A nested node is a plain map here too -- it *is* ``{kind, params}``, + # and the reader recognises it by that shape, so wrapping it would say + # the same thing a second and weaker way. Any other object is a mapping + # parameter. + node = ET.SubElement(parent, MAP) + _fill_dict(node, value) elif isinstance(value, (list, tuple)): node = ET.SubElement(parent, TUPLE_NODE if isinstance(value, tuple) else LIST) for item in value: @@ -107,7 +93,7 @@ def _fill_value(parent: ET.Element, value: Any) -> None: def _fill_payload(node: ET.Element, payload: Any) -> None: - """Encode a __value__ payload directly into the value node (scalar leaf or container).""" + """Encode a reserved wrapper's payload into the value node (scalar or container).""" if isinstance(payload, (list, tuple)): for item in payload: _fill_value(node, item) @@ -130,8 +116,8 @@ def _fill_dict(parent: ET.Element, d: Dict[str, Any]) -> None: def serialize_xml(spec: Dict[str, Any]) -> bytes: """Encode an expression spec dict into an XML document (bytes).""" root = ET.Element(ROOT) - t = ET.SubElement(root, TYPE_EL) - t.text = spec.get("type", "") + t = ET.SubElement(root, KIND_EL) + t.text = spec.get("kind", "") _fill_dict(ET.SubElement(root, PARAMS_EL), spec.get("params", {})) return ET.tostring(root, encoding="utf-8") @@ -160,7 +146,7 @@ def _decode_value(el: ET.Element) -> Any: tag = el.tag if tag == NULL: return None - if tag not in (LIST, TUPLE_NODE, MAP, EXPR_NODE, VALUE_NODE, VDC_NODE, "s"): + if tag not in (LIST, TUPLE_NODE, MAP, "s"): # unknown tag fallback return None if tag == "s": @@ -169,41 +155,7 @@ def _decode_value(el: ET.Element) -> Any: return [_decode_value(c) for c in el] if tag == TUPLE_NODE: return tuple(_decode_value(c) for c in el) - if tag == MAP: - return _decode_dict(el) - if tag == EXPR_NODE: - return {"__expr__": _decode_expr_spec(el)} - if tag == VALUE_NODE: - return {"__value__": [el.get("tag") or "", _decode_payload(el)]} - if tag == VDC_NODE: - params_el = _find_params(el) - return {"__vdc__": [el.get("class") or "", _decode_dict(params_el)]} - return None - - -def _find_params(el: ET.Element) -> ET.Element: - for c in el: - if c.tag == PARAMS_EL: - return c - return el - - -def _decode_payload(el: ET.Element) -> Any: - """Decode the payload of a node (scalar or container).""" - children = list(el) - if not children: - return _scalar_from(_text(el), el.get("type") or "str") - return [_decode_value(c) for c in children] - - -def _decode_expr_spec(el: ET.Element) -> Dict[str, Any]: - spec: Dict[str, Any] = {"type": "", "params": {}} - for c in el: - if c.tag == TYPE_EL: - spec["type"] = _text(c) - elif c.tag == PARAMS_EL: - spec["params"] = _decode_dict(c) - return spec + return _decode_dict(el) def _decode_dict(el: ET.Element) -> Dict[str, Any]: @@ -223,10 +175,10 @@ def _decode_dict(el: ET.Element) -> Dict[str, Any]: def deserialize_xml(payload: bytes) -> Dict[str, Any]: """Decode an XML document into an expression spec dict.""" root = ET.fromstring(payload) - spec: Dict[str, Any] = {"type": "", "params": {}} + spec: Dict[str, Any] = {"kind": "", "params": {}} for c in root: - if c.tag == TYPE_EL: - spec["type"] = _text(c) + if c.tag == KIND_EL: + spec["kind"] = _text(c) elif c.tag == PARAMS_EL: spec["params"] = _decode_dict(c) return spec \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/dummy/dialect.py b/src/rhosocial/activerecord/backend/impl/dummy/dialect.py index 0c24b8e2b..d167a7636 100644 --- a/src/rhosocial/activerecord/backend/impl/dummy/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/dummy/dialect.py @@ -44,29 +44,81 @@ """ import re - -from typing import Dict, List, Tuple, Type, TYPE_CHECKING - -from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Literal -from rhosocial.activerecord.backend.expression.types import ( - ArrayType, BigIntType, BinaryType, BlobType, BooleanType, CharType, CustomType, - DataType, DateType, DateTimeType, DecimalType, DoubleType, EnumType, FloatType, - IntegerType, IntervalType, JsonBType, JsonType, - RealType, SmallIntType, TextType, TimeType, TimeTzType, - TimestampType, TimestampTzType, TinyIntType, UUIDType, VarBinaryType, VarCharType, XmlType, -) -from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( - DomainAlterAction, - DomainNullability, -) -from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - TypeAlterAction, - TypeDefinition, +from rhosocial.activerecord.backend.dialect.mixins import ( + RelationSourceMixin, + NamespaceMixin, + TableNameMixin, + ViewNameMixin, + MaterializedViewNameMixin, + ForeignTableNameMixin, + IndexNameMixin, + SequenceNameMixin, + TriggerNameMixin, + FunctionNameMixin, + ProcedureNameMixin, + TypeNameMixin, + DomainNameMixin, + SynonymNameMixin, + SchemaNameMixin, + DatabaseNameMixin, + PropertyGraphNameMixin, + SQLXMLMixin, + SQLXMLParsingMixin, + SQLXMLSerializationMixin, + SQLXMLConstructionMixin, + SQLXMLAggregationMixin, + SQLXMLQueryingMixin, + CollationMixin, + WindowFunctionMixin, + CTEMixin, + UpsertMixin, + LateralJoinMixin, + ArrayMixin, + JSONMixin, + UUIDMixin, + ExplainMixin, + MergeMixin, + TemporalTableMixin, + PivotMixin, + GraphMixin, + GraphTableMixin, + JoinMixin, + SetOperationMixin, + ILIKEMixin, + # DDL Mixins + TableMixin, + PartitionMixin, + ConstraintMixin, + CommentOnMixin, + ViewMixin, + TruncateMixin, + SchemaMixin, + IndexMixin, + SequenceMixin, + TriggerMixin, + FunctionMixin, + GeneratedColumnMixin, + AutoIncrementMixin, + IdentityColumnMixin, + DatabaseMixin, + # Introspection Mixin + IntrospectionMixin, + # New Mixins + PredicateMixin, + TrimMixin, + LpadMixin, + RepeatMixin, + RpadMixin, + ExpressionMixin, + DateTimeMixin, + DQLMixin, + DMLMixin, + DDLColumnMixin, + DataTypeMixin, + UserDefinedTypeMixin, + DomainMixin, + TransactionControlMixin, ) -from .expression import _DummyTypeAlterAction, _DummyTypeDefinition -from .column_type import DummyColumnTypeMixin from rhosocial.activerecord.backend.dialect.protocols import ( # Named-object protocols TableObjectSupport, @@ -164,84 +216,28 @@ DqlOrderSupport, PivotSupport, ) -from rhosocial.activerecord.backend.dialect.mixins import ( - DatabaseNameMixin, - PropertyGraphNameMixin, - RelationSourceMixin, - SchemaNameMixin, - NamespaceMixin, - TableNameMixin, - ViewNameMixin, - MaterializedViewNameMixin, - ForeignTableNameMixin, - IndexNameMixin, - SequenceNameMixin, - TriggerNameMixin, - FunctionNameMixin, - ProcedureNameMixin, - TypeNameMixin, - DomainNameMixin, - SynonymNameMixin, - SchemaNameMixin, - DatabaseNameMixin, - PropertyGraphNameMixin, - SQLXMLMixin, - SQLXMLParsingMixin, - SQLXMLSerializationMixin, - SQLXMLConstructionMixin, - SQLXMLAggregationMixin, - SQLXMLQueryingMixin, - CollationMixin, - WindowFunctionMixin, - CTEMixin, - UpsertMixin, - LateralJoinMixin, - ArrayMixin, - JSONMixin, - UUIDMixin, - ExplainMixin, - MergeMixin, - TemporalTableMixin, - PivotMixin, - GraphMixin, - GraphTableMixin, - JoinMixin, - SetOperationMixin, - ILIKEMixin, - # DDL Mixins - TableMixin, - PartitionMixin, - ConstraintMixin, - CommentOnMixin, - ViewMixin, - TruncateMixin, - SchemaMixin, - IndexMixin, - SequenceMixin, - TriggerMixin, - FunctionMixin, - GeneratedColumnMixin, - AutoIncrementMixin, - IdentityColumnMixin, - DatabaseMixin, - # Introspection Mixin - IntrospectionMixin, - # New Mixins - PredicateMixin, - TrimMixin, - LpadMixin, - RepeatMixin, - RpadMixin, - ExpressionMixin, - DateTimeMixin, - DQLMixin, - DMLMixin, - DDLColumnMixin, - DataTypeMixin, - UserDefinedTypeMixin, - DomainMixin, - TransactionControlMixin, +from .column_type import DummyColumnTypeMixin +from .expression import _DummyTypeAlterAction, _DummyTypeDefinition +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DomainAlterAction, + DomainNullability, +) +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, BigIntType, BinaryType, BlobType, BooleanType, CharType, CustomType, + DataType, DateType, DateTimeType, DecimalType, DoubleType, EnumType, FloatType, + IntegerType, IntervalType, JsonBType, JsonType, + RealType, SmallIntType, TextType, TimeType, TimeTzType, + TimestampType, TimestampTzType, TinyIntType, UUIDType, VarBinaryType, VarCharType, XmlType, ) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from typing import Dict, List, Tuple, Type, TYPE_CHECKING + + _COLLATION_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -267,11 +263,10 @@ #: name — the part that says which values is not the part that says which type, #: and ``CustomType`` keeps it verbatim. _PARSE_CONCEPTS = ( - BigIntType, BinaryType, BlobType, BooleanType, CharType, DateTimeType, - DateType, DecimalType, DoubleType, FloatType, IntegerType, JsonBType, - JsonType, RealType, SmallIntType, TextType, TimeType, TimeTzType, - TimestampType, TimestampTzType, TinyIntType, UUIDType, VarBinaryType, - VarCharType, XmlType, + BigIntType, BinaryType, BlobType, BooleanType, CharType, DateTimeType, DateType, + DecimalType, DoubleType, FloatType, IntegerType, JsonBType, JsonType, RealType, + SmallIntType, TextType, TimeType, TimeTzType, TimestampType, TimestampTzType, + TinyIntType, UUIDType, VarBinaryType, VarCharType, XmlType, ) #: ``{UPPER-CASED type name: concept}``. A concept with several spellings @@ -340,19 +335,27 @@ def _split_precision(raw: str) -> Tuple[str, Tuple[int, ...]]: if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - ReleaseSavepointExpression, - RollbackTransactionExpression, - SavepointExpression, - SetTransactionExpression, + BeginTransactionStatement, + CommitStatement, + ReleaseSavepointStatement, + RollbackStatement, + SavepointStatement, + SetTransactionStatement, ) class DummyDialect( + # 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). SQLDialectBase, RelationSourceMixin, # The object tree: each *NameMixin renders one object kind and inherits @@ -439,6 +442,8 @@ class DummyDialect( DateTimeMixin, DQLMixin, DMLMixin, + # Value literals: the typed value nodes render through this, and the Dummy + # dialect has no backend-specific spelling to add on top of it. DataTypeMixin, DummyColumnTypeMixin, UserDefinedTypeMixin, @@ -568,13 +573,27 @@ def _refuse_unsigned(self, data_type, word: str) -> None: success: the same silent loss as accepting the flag and discarding it, which is what this replaces. + The guard lives here, at the rendering end, rather than in the + expression's constructor: an expression collects ``unsigned`` without + judging it, because only the formatter knows which backend will have to + honour it. A non-boolean therefore reaches this point instead of being + rejected at construction, and is named here -- silently dropping it + would render a signed column for a caller who asked for an unsigned one, + which is the failure this whole refusal exists to prevent. + ``word`` is the concept the caller asked for, so the message can say *which* type was refused. ``UnsupportedFeatureError``, not ``ValueError``: the declaration is not a wrong value, it is a declaration this grammar cannot express at all, and the two exceptions do not share a base class. """ - if not getattr(data_type, "unsigned", False): + 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." + ) + if not unsigned: return raise UnsupportedFeatureError( self.name, @@ -731,7 +750,7 @@ def parse_type(self, raw: str) -> DataType: """ stripped = raw.strip() if not stripped: - return CustomType(self, stripped) + return CustomType(self, raw=stripped) # Array markers are not part of the element's name: ``VARCHAR(30)[]`` is # one-dimensional and ``VARCHAR(30)[][]`` two. @@ -760,7 +779,7 @@ def parse_type(self, raw: str) -> DataType: # Either a name dummy does not render, or numbers the concept # cannot carry (``INT(11)``). Both are reported verbatim rather # than guessed at. - element = CustomType(self, stripped) + element = CustomType(self, raw=stripped) else: kwargs = dict(zip(fields, numbers)) if concept.SPELLINGS: @@ -920,7 +939,7 @@ def supports_xmltable(self) -> bool: def supports_collate_expression(self) -> bool: return True - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate a collation name and return its SQL representation. Dummy is a generic SQL-generation test dialect, so it validates that @@ -1023,6 +1042,9 @@ def supports_qualify_clause(self) -> bool: def supports_for_update_skip_locked(self) -> bool: return True + def supports_for_update_nowait(self) -> bool: + return True + def supports_for_update(self) -> bool: return True @@ -1099,15 +1121,6 @@ def supports_intersect(self) -> bool: def supports_except(self) -> bool: return True - def supports_set_operation_order_by(self) -> bool: - return True - - def supports_set_operation_limit_offset(self) -> bool: - return True - - def supports_set_operation_for_update(self) -> bool: - return True - def supports_ilike(self) -> bool: return True @@ -1147,6 +1160,9 @@ def supports_if_exists_table(self) -> bool: def supports_create_table_as(self) -> bool: return True + def supports_create_table_as_bind_parameters(self) -> bool: + return True + def supports_create_table_like(self) -> bool: return True @@ -1232,6 +1248,22 @@ def supports_exclude_constraint(self) -> bool: """Whether EXCLUDE constraints are supported (PG-proprietary).""" return True + def supports_deferrable_constraint(self) -> bool: + """Dummy renders every construct the core can express. + + The core default is ``False`` (a SQL:1999 modifier); dummy opts in so + the deferral render paths stay exercised without a live server. + """ + return True + + def supports_constraint_enforced(self) -> bool: + """Dummy renders every construct the core can express. + + The core default is ``False`` (a SQL:2016 modifier); dummy opts in so + the enforcement render paths stay exercised without a live server. + """ + return True + def supports_alter_constraint_enforced(self) -> bool: return True @@ -1244,6 +1276,9 @@ def supports_validate_constraint(self) -> bool: def supports_create_view(self) -> bool: return True + def supports_create_view_bind_parameters(self) -> bool: + return True + def supports_drop_view(self) -> bool: return True @@ -1262,6 +1297,9 @@ def supports_temporary_view(self) -> bool: def supports_materialized_view(self) -> bool: return True + def supports_materialized_view_bind_parameters(self) -> bool: + return True + def supports_refresh_materialized_view(self) -> bool: return True @@ -1680,7 +1718,7 @@ def supports_savepoint(self) -> bool: """Dummy backend supports savepoints.""" return True - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format BEGIN TRANSACTION statement for dummy dialect.""" params = expr.get_params() parts = ["BEGIN"] @@ -1718,11 +1756,11 @@ def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[ return " ".join(parts), () - def format_commit_transaction(self, expr: "CommitTransactionExpression") -> Tuple[str, tuple]: + def format_commit_statement(self, expr: "CommitStatement") -> Tuple[str, tuple]: """Format COMMIT TRANSACTION statement for dummy dialect.""" return "COMMIT", () - def format_rollback_transaction(self, expr: "RollbackTransactionExpression") -> Tuple[str, tuple]: + def format_rollback_statement(self, expr: "RollbackStatement") -> Tuple[str, tuple]: """Format ROLLBACK TRANSACTION statement for dummy dialect.""" params = expr.get_params() savepoint = params.get("savepoint") @@ -1730,19 +1768,19 @@ def format_rollback_transaction(self, expr: "RollbackTransactionExpression") -> return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () return "ROLLBACK", () - def format_savepoint(self, expr: "SavepointExpression") -> Tuple[str, tuple]: + def format_savepoint_statement(self, expr: "SavepointStatement") -> Tuple[str, tuple]: """Format SAVEPOINT statement for dummy dialect.""" params = expr.get_params() name = params.get("name", "") return f"SAVEPOINT {self.format_identifier(name)}", () - def format_release_savepoint(self, expr: "ReleaseSavepointExpression") -> Tuple[str, tuple]: + def format_release_savepoint_statement(self, expr: "ReleaseSavepointStatement") -> Tuple[str, tuple]: """Format RELEASE SAVEPOINT statement for dummy dialect.""" params = expr.get_params() name = params.get("name", "") return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION statement for dummy dialect.""" params = expr.get_params() parts = [] @@ -1795,6 +1833,11 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: """Format a column definition including generated columns. + Each constraint rides through :meth:`format_column_constraint` -- the + shared production-level formatter -- so the dummy dialect spells a + constraint the way every other dialect does and does not keep a second + implementation of the same grammar here. + Args: col_def: Column definition object containing name, data type, constraints, and optional generated column expression. @@ -1802,61 +1845,21 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu Returns: Tuple of (SQL string, parameters tuple). """ - from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, - ) - from rhosocial.activerecord.backend.expression import bases - - all_params = [] + all_params: list = [] col_sql = f"{self.format_identifier(col_def.name)} {col_def.data_type.to_sql()[0]}" for constraint in col_def.constraints: - if constraint.constraint_type == ColumnConstraintType.PRIMARY_KEY: - col_sql += " PRIMARY KEY" - elif constraint.constraint_type == ColumnConstraintType.NOT_NULL: - col_sql += " NOT NULL" - elif constraint.constraint_type == ColumnConstraintType.NULL: - col_sql += " NULL" - elif constraint.constraint_type == ColumnConstraintType.UNIQUE: - col_sql += " UNIQUE" - elif constraint.constraint_type == ColumnConstraintType.DEFAULT: - if constraint.default_value is None: - raise ValueError("DEFAULT constraint must have a default value specified.") - if isinstance(constraint.default_value, bases.BaseExpression): - default_sql, default_params = constraint.default_value.to_sql() - if default_params and isinstance(constraint.default_value, Literal): - # DDL accepts no bind parameters: inline the literal. - default_sql = self.inline_sql_literal(constraint.default_value.value) - default_params = () - col_sql += f" DEFAULT {default_sql}" - all_params.extend(default_params) - else: - # DDL clauses accept no bind parameters: render inline. - col_sql += f" DEFAULT {self.format_literal(constraint.default_value)}" - elif constraint.constraint_type == ColumnConstraintType.CHECK: - if constraint.check_condition is None: - raise ValueError("CHECK constraint must have a check condition specified.") - check_sql, check_params = constraint.check_condition.to_sql() - enforcement = self._format_constraint_enforcement(constraint) - suffix = f" {enforcement}" if enforcement else "" - col_sql += f" CHECK ({check_sql}){suffix}" - all_params.extend(check_params) - elif constraint.constraint_type == ColumnConstraintType.FOREIGN_KEY: - if constraint.foreign_key_reference is None: - raise ValueError("FOREIGN KEY constraint must have a foreign key reference specified.") - ref_table, ref_cols = constraint.foreign_key_reference - ref_cols_str = ", ".join(self.format_identifier(col) for col in ref_cols) - enforcement = self._format_constraint_enforcement(constraint) - suffix = f" {enforcement}" if enforcement else "" - col_sql += f" REFERENCES {ref_table.to_sql()[0]}({ref_cols_str}){suffix}" + constraint_sql, constraint_params = self.format_column_constraint(constraint) + col_sql += constraint_sql + all_params.extend(constraint_params) if col_def.generated_expression is not None: gen_sql, gen_params = col_def.generated_expression.to_sql() col_sql += gen_sql all_params.extend(gen_params) - for attr in getattr(col_def, "attributes", None) or (): + for attr in col_def.attributes or (): attr_sql, attr_params = self.format_column_attribute(attr) col_sql += attr_sql all_params.extend(attr_params) diff --git a/src/rhosocial/activerecord/backend/impl/dummy/expression.py b/src/rhosocial/activerecord/backend/impl/dummy/expression.py index 98170bd52..fbbb5c654 100644 --- a/src/rhosocial/activerecord/backend/impl/dummy/expression.py +++ b/src/rhosocial/activerecord/backend/impl/dummy/expression.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.statements.ddl_type import ( TypeAlterAction, TypeDefinition, @@ -44,8 +43,9 @@ def __init__( self.new_name = new_name -ExpressionRegistry.register(_DummyTypeDefinition) -ExpressionRegistry.register(_DummyTypeAlterAction) +# No registration calls: the framework's assembly point walks +# ``impl.dummy.expression`` like every other backend package, so these two +# nodes are registered by the same rule as the rest. __all__ = ["_DummyTypeDefinition", "_DummyTypeAlterAction"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/adapters.py b/src/rhosocial/activerecord/backend/impl/sqlite/adapters.py index 2af6fd70f..bd5dc99db 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/adapters.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/adapters.py @@ -50,7 +50,7 @@ class SQLiteBlobAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(bytes, bytes) + self.register_type(bytes, bytes) def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> bytes: """Converts a Python value (str or bytes) to a SQLite-compatible BLOB.""" @@ -80,8 +80,8 @@ class SQLiteJSONAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) - self._register_type(list, str) + self.register_type(dict, str) + self.register_type(list, str) @staticmethod def _extended_json_serializer(obj: Any) -> Any: @@ -123,8 +123,8 @@ class SQLiteUUIDAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(uuid.UUID, str) - self._register_type(uuid.UUID, bytes) + self.register_type(uuid.UUID, str) + self.register_type(uuid.UUID, bytes) def _do_to_database(self, value: uuid.UUID, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: """Converts a Python UUID to a string or bytes.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/async_transaction.py b/src/rhosocial/activerecord/backend/impl/sqlite/async_transaction.py index cbd3d0234..0030eb40a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/async_transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/async_transaction.py @@ -115,15 +115,15 @@ def begin_type(self, value: Optional[str]) -> None: def _build_begin_sql(self) -> Tuple[str, tuple]: """Build BEGIN TRANSACTION SQL with SQLite-specific options.""" - from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression + from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement - expr = BeginTransactionExpression(self._backend.dialect) + expr = BeginTransactionStatement(self._backend.dialect) if self._begin_type is not None: # Explicit begin_type overrides isolation level mapping - expr.begin_type(self._begin_type) + expr.with_begin_type(self._begin_type) elif self._isolation_level is not None: - expr.isolation_level(self._isolation_level) + expr.with_isolation_level(self._isolation_level) if self._transaction_mode == TransactionMode.READ_ONLY: expr.read_only() @@ -141,7 +141,8 @@ async def _do_begin(self) -> None: # READ_UNCOMMITTED: read_uncommitted = 1 if self._isolation_level is not None: pragma_value = 1 if self._isolation_level == IsolationLevel.READ_UNCOMMITTED else 0 - await self._backend.execute(f"PRAGMA read_uncommitted = {pragma_value}") + statement = self._backend.dialect.pragma_set_statement("read_uncommitted", pragma_value) + await self._backend.execute(statement.to_sql()[0]) # Call parent to execute BEGIN await super()._do_begin() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlite/backend/async_backend.py index 432eca03a..ea10801d9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/backend/async_backend.py @@ -17,6 +17,7 @@ from .common import SQLiteBackendMixin, SQLiteConcurrencyMixin, DEFAULT_PRAGMAS from ..config import SQLiteConnectionConfig from ..dialect import SQLiteDialect +from ..expression.introspection import SQLitePragmaQueryStatement, SQLitePragmaSetStatement from ..async_transaction import AsyncSQLiteTransactionManager from rhosocial.activerecord.backend.base import AsyncStorageBackend from rhosocial.activerecord.backend.config import ConnectionConfig @@ -137,10 +138,10 @@ async def set_pragma(self, pragma_key: str, pragma_value: Any) -> None: self.config.pragmas[pragma_key] = pragma_value_str if self._connection: - pragma_statement = f"PRAGMA {pragma_key} = {pragma_value_str}" - self.log(logging.DEBUG, f"Setting pragma: {pragma_statement}") + sql = SQLitePragmaSetStatement(self.dialect, pragma_key, pragma_value_str).to_sql()[0] + self.log(logging.DEBUG, f"Setting pragma: {sql}") try: - await self._connection.execute(pragma_statement) + await self._connection.execute(sql) except sqlite3.Error as e: error_msg = f"Failed to set pragma {pragma_key}: {str(e)}" self.log(logging.ERROR, error_msg) @@ -149,12 +150,12 @@ async def set_pragma(self, pragma_key: str, pragma_value: Any) -> None: async def _apply_pragmas(self) -> None: """Apply PRAGMA settings.""" for pragma_key, pragma_value in self.config.pragmas.items(): - pragma_statement = f"PRAGMA {pragma_key} = {pragma_value}" - self.log(logging.DEBUG, f"Executing pragma: {pragma_statement}") + sql = SQLitePragmaSetStatement(self.dialect, pragma_key, pragma_value).to_sql()[0] + self.log(logging.DEBUG, f"Executing pragma: {sql}") try: - await self._connection.execute(pragma_statement) + await self._connection.execute(sql) except sqlite3.Error as e: - self.log(logging.WARNING, f"Failed to execute pragma {pragma_statement}: {str(e)}") + self.log(logging.WARNING, f"Failed to execute pragma {sql}: {str(e)}") async def connect(self) -> None: """Establish a connection to the SQLite database asynchronously. @@ -507,7 +508,8 @@ async def get_compile_options(self) -> Dict[str, str]: options: Dict[str, str] = {} try: cursor = await self._connection.cursor() - await cursor.execute("PRAGMA compile_options") + sql = SQLitePragmaQueryStatement(self.dialect, "compile_options").to_sql()[0] + await cursor.execute(sql) rows = await cursor.fetchall() for row in rows: opt_name = row[0] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlite/backend/backend.py index 42725c5a2..5dd8a06fd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/backend/backend.py @@ -17,6 +17,7 @@ from .common import SQLiteBackendMixin, SQLiteConcurrencyMixin, DEFAULT_PRAGMAS from ..config import SQLiteConnectionConfig from ..dialect import SQLiteDialect +from ..expression.introspection import SQLitePragmaQueryStatement, SQLitePragmaSetStatement from ..transaction import SQLiteTransactionManager from rhosocial.activerecord.backend.base import StorageBackend from rhosocial.activerecord.backend.config import ConnectionConfig @@ -157,10 +158,10 @@ def set_pragma(self, pragma_key: str, pragma_value: Any) -> None: self.config.pragmas[pragma_key] = pragma_value_str if self._connection: - pragma_statement = f"PRAGMA {pragma_key} = {pragma_value_str}" - self.log(logging.DEBUG, f"Setting pragma: {pragma_statement}") + sql = SQLitePragmaSetStatement(self.dialect, pragma_key, pragma_value_str).to_sql()[0] + self.log(logging.DEBUG, f"Setting pragma: {sql}") try: - self._connection.execute(pragma_statement) + self._connection.execute(sql) except sqlite3.Error as e: error_msg = f"Failed to set pragma {pragma_key}: {str(e)}" self.log(logging.ERROR, error_msg) @@ -169,12 +170,12 @@ def set_pragma(self, pragma_key: str, pragma_value: Any) -> None: def _apply_pragmas(self) -> None: """Apply all pragma settings to the connection.""" for pragma_key, pragma_value in self.config.pragmas.items(): - pragma_statement = f"PRAGMA {pragma_key} = {pragma_value}" - self.log(logging.DEBUG, f"Executing pragma: {pragma_statement}") + sql = SQLitePragmaSetStatement(self.dialect, pragma_key, pragma_value).to_sql()[0] + self.log(logging.DEBUG, f"Executing pragma: {sql}") try: - self._connection.execute(pragma_statement) + self._connection.execute(sql) except sqlite3.Error as e: - self.log(logging.WARNING, f"Failed to execute pragma {pragma_statement}: {str(e)}") + self.log(logging.WARNING, f"Failed to execute pragma {sql}: {str(e)}") def connect(self) -> None: """Establish a connection to the SQLite database. @@ -538,7 +539,8 @@ def get_compile_options(self) -> Dict[str, str]: options: Dict[str, str] = {} try: cursor = self._connection.cursor() - cursor.execute("PRAGMA compile_options") + sql = SQLitePragmaQueryStatement(self.dialect, "compile_options").to_sql()[0] + cursor.execute(sql) for row in cursor.fetchall(): opt_name = row[0] if "=" in opt_name: diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlite/cli/introspect.py index 9122373a5..13260331e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/cli/introspect.py @@ -11,7 +11,39 @@ OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] -INTROSPECT_TYPES = ["tables", "views", "table", "columns", "indexes", "foreign-keys", "triggers", "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", + "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 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 dropped. +_NAME_RULE = { + "tables": "forbidden", + "views": "optional", + "columns": "required", + "indexes": "required", + "foreign-keys": "required", + "triggers": "optional", + "database": "forbidden", +} def create_parser(subparsers): @@ -23,18 +55,23 @@ def create_parser(subparsers): # List all tables in database %(prog)s introspect tables --db-file mydb.sqlite - # List all views + # List all views, or describe one of them %(prog)s introspect views --db-file mydb.sqlite + %(prog)s introspect views active_users --db-file mydb.sqlite - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s introspect table users --db-file mydb.sqlite - - # Get column details for a table + # Get column details for a table, including generated columns %(prog)s introspect columns users --db-file mydb.sqlite + # The triggers of one table + %(prog)s introspect triggers posts --db-file mydb.sqlite + # Output as JSON %(prog)s introspect tables --db-file mydb.sqlite -o json + # NAME is a table for columns/indexes/foreign-keys, the view itself for + # views, a table for triggers, and is refused for tables/database -- those + # answer for the whole database. + # Using in-memory database %(prog)s introspect tables """, @@ -64,13 +101,16 @@ 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: " + ", ".join(INTROSPECT_TYPES), ) parser.add_argument( "name", nargs="?", default=None, - help="Table/view name (required for some types)", + help=( + "Object name: a table for columns/indexes/foreign-keys and " + "triggers, the view itself for views. Refused for tables/database." + ), ) parser.add_argument( "--include-system", @@ -84,9 +124,17 @@ def create_parser(subparsers): def handle(args): """Handle the introspect subcommand.""" provider = create_provider(args.output, ascii_borders=args.rich_ascii) + + # Both checks run before any real work: a NAME the type cannot use is a + # malformed request, and a family this dialect cannot answer is one this + # build does not offer. ``create_backend`` adapts the dialect, so the + # capability switch below answers for the SQLite in front of us. + _validate_name(args) + backend = create_backend(args) try: + _require_capability(args, backend) introspector = backend.introspector if args.type == "tables": @@ -95,48 +143,29 @@ def handle(args): provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views() - data = [_serialize_for_output(v) for v in 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) - if info: - columns_data = [_serialize_for_output(c) for c in info.columns] - provider.display_results(columns_data, title=f"Columns of {args.name}") - if info.indexes: - indexes_data = [_serialize_for_output(i) for i in info.indexes] - provider.display_results(indexes_data, title=f"Indexes of {args.name}") - if info.foreign_keys: - fks_data = [_serialize_for_output(f) for f in info.foreign_keys] - provider.display_results(fks_data, title=f"Foreign Keys of {args.name}") + if args.name: + view = introspector.get_view_info(args.name) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + data = [_serialize_for_output(view)] + provider.display_results(data, title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = introspector.list_views() + data = [_serialize_for_output(v) for v in 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) data = [_serialize_for_output(c) for c in 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) data = [_serialize_for_output(i) for i in 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) data = [_serialize_for_output(f) for f in fks] provider.display_results(data, title=f"Foreign Keys of {args.name}") @@ -163,6 +192,58 @@ def handle(args): # --------------------------------------------------------------------------- +def _validate_name(args) -> None: + """Refuse a NAME the type cannot use, before anything opens a database. + + 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: + # The same code a missing connection argument gets: the run never + # became a question. 2 is reserved for a request this command refuses + # to serve, below. + print( + f"Error: Table name is required for '{args.type}' introspection", + file=sys.stderr, + ) + sys.exit(1) + 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 the connected dialect says it cannot answer. + + ``create_backend`` adapts the dialect before this runs, so the switch + answers for the SQLite in front of us. 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 _serialize_for_output(obj): """Serialize object for JSON output, handling non-serializable types.""" if obj is None: diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlite/dialect.py index 242c76d0d..7bc9d1a77 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/dialect.py @@ -52,11 +52,7 @@ ColumnTypeSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( - DatabaseNameMixin, - PropertyGraphNameMixin, RelationSourceMixin, - SchemaNameMixin, - # Named objects: each *NameMixin inherits NamespaceMixin, so they precede it. NamespaceMixin, TableNameMixin, ViewNameMixin, @@ -127,16 +123,16 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import bases from rhosocial.activerecord.backend.expression.advanced_functions import ( - ArrayExpression, - JSONDocumentExpression, + ArrayValue, + JSONDocumentValue, OrderedSetAggregation, TrimExpression, ) from rhosocial.activerecord.backend.expression.graph import MatchClause from rhosocial.activerecord.backend.expression.predicates import DistinctFromPredicate from rhosocial.activerecord.backend.expression.query_parts import QualifyClause - from rhosocial.activerecord.backend.expression.statements import ExplainExpression - from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression + from rhosocial.activerecord.backend.expression.statements import ExplainStatement + from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateStatement from .protocols import ( SQLiteExtensionSupport, @@ -159,7 +155,6 @@ SQLiteDateTimeMixin, SQLiteDDLColumnMixin, SQLiteDMLMixin, - SQLiteSetOperationMixin, SQLiteViewMixin, SQLiteTriggerMixin, SQLiteTransactionMixin, @@ -167,7 +162,14 @@ SQLiteFTS5Mixin, SQLiteRTreeMixin, SQLiteGeopolyMixin, + # Object naming: SQLite's schema is an attached-database name, which is not + # the SQL-standard construct, so naming is answered here rather than by the + # DDL probes. Placed ahead of the shared TableNameMixin in the class list. + SQLiteObjectNameMixin, SQLiteTypeSupportMixin, + # Value literals: SQLite's blob spelling, overriding the base's + # format_binary_value / format_uuid_value by MRO position. + LiteralValueMixin, # Column types. SQLite states its own full table: the four sequence entries # take JSONColumn (there is no array type here) and float/Decimal both take # NumericColumn. There is no generic table in core to compose. @@ -186,9 +188,25 @@ class SQLiteDialect( + # 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). + # Ahead of SQLDialectBase, unlike every other mixin here. Those add methods + # the base does not have, so their position is a readability choice; this + # one overrides ``format_literal``, and a mixin placed after the class it + # overrides never wins. It resolves SQLite's two tier-2 spellings -- blob + # and UUID -- and leaves the base's portable forms and the + # literal-construction protocol to it. + LiteralValueMixin, SQLDialectBase, RelationSourceMixin, # Named objects: each *NameMixin inherits NamespaceMixin, so they precede it. + SQLiteObjectNameMixin, TableNameMixin, ViewNameMixin, MaterializedViewNameMixin, @@ -224,6 +242,12 @@ class SQLiteDialect( LateralJoinMixin, JoinMixin, # DDL Mixins (without SQLite overrides) + # SQLite's own view mixin answers ``supports_with_data_clause`` as well as + # the view statements, and it has to precede TableMixin -- the core mixin + # declares the same probe, and a mixin placed after the class it overrides + # never wins, so core's answer was the one in force and SQLite's was dead + # code. + SQLiteViewMixin, TableMixin, ConstraintMixin, CommentOnMixin, @@ -263,8 +287,6 @@ class SQLiteDialect( SQLiteDateTimeMixin, SQLiteDDLColumnMixin, SQLiteDMLMixin, - SQLiteSetOperationMixin, - SQLiteViewMixin, SQLiteTriggerMixin, SQLitePragmaMixin, SQLiteIntrospectionCapabilityMixin, @@ -386,14 +408,19 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the SQLite version this dialect is configured for.""" return self.version - def _effective_version(self) -> Tuple[int, int, int]: - """Resolve the version used for capability checks. + def _declared_version(self) -> Tuple[int, int, int]: + """The sqlite3 library's own version, which needs no adaptation. - Returns the explicitly configured or adapted version, falling back to - the sqlite3 library version, which is always known without adaptation. + SQLite has no server to ask: the engine is the library the driver + links against, so ``sqlite3.sqlite_version_info`` already *is* the + version in force. Capability checks can therefore answer before + ``introspect_and_adapt()`` runs, which is what keeps a predicate such + as ``supports_foreign_key_introspection()`` from raising. + + The strict :attr:`version` still raises until adaptation, because an + unconfigured dialect has not been told which version the caller + targets. """ - if self._version is not None: - return self._version import sqlite3 return tuple(sqlite3.sqlite_version_info[:3]) @@ -443,39 +470,39 @@ def suggested_data_types(self) -> Dict[str, type]: # region Protocol Support Checks based on version def supports_basic_cte(self) -> bool: """Basic CTEs are supported since SQLite 3.8.3.""" - return self._effective_version() >= (3, 8, 3) + return self.version_at_least((3, 8, 3)) def supports_recursive_cte(self) -> bool: """Recursive CTEs are supported since SQLite 3.8.3.""" - return self._effective_version() >= (3, 8, 3) + return self.version_at_least((3, 8, 3)) def supports_materialized_cte(self) -> bool: """MATERIALIZED hint is supported since SQLite 3.35.0.""" - return self._effective_version() >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) def supports_returning_insert(self) -> bool: """RETURNING clause is supported for INSERT since SQLite 3.35.0.""" - return self.version >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) def supports_returning_update(self) -> bool: """RETURNING clause is supported for UPDATE since SQLite 3.35.0.""" - return self.version >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) def supports_returning_delete(self) -> bool: """RETURNING clause is supported for DELETE since SQLite 3.35.0.""" - return self.version >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) def supports_window_functions(self) -> bool: """Window functions are supported since SQLite 3.25.0.""" - return self.version >= (3, 25, 0) + return self.version_at_least((3, 25, 0)) def supports_window_frame_clause(self) -> bool: """Whether window frame clauses (ROWS/RANGE) are supported, since SQLite 3.25.0.""" - return self.version >= (3, 25, 0) + return self.version_at_least((3, 25, 0)) def supports_filter_clause(self) -> bool: """FILTER clause for aggregate functions is supported since SQLite 3.10.0.""" - return self.version >= (3, 10, 0) + return self.version_at_least((3, 10, 0)) def supports_json_type(self) -> bool: """JSON is supported with JSON1 extension. @@ -484,16 +511,16 @@ def supports_json_type(self) -> bool: - SQLite >= 3.38.0: JSON1 is built-in, always available - SQLite < 3.38.0: Check runtime detection result (json1_available) """ - if self.version >= (3, 38, 0): + if self.version_at_least((3, 38, 0)): return True # For older versions, use runtime detection result return self.get_runtime_param("json1_available", False) def supports_json_arrow_operators(self) -> bool: """SQLite supports -> and ->> operators from version 3.38.0+.""" - return self.version >= (3, 38, 0) + return self.version_at_least((3, 38, 0)) - def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, tuple]: + def format_json_function_expression(self, expr: "JSONDocumentValue") -> Tuple[str, tuple]: """Render a JSON path access with SQLite's own JSON1 functions. Without this, ``JSONPathMode.FUNCTION`` fell through to the core @@ -502,7 +529,7 @@ def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tup FUNCTION mode produced SQL the server rejects. Args: - expr: The JSONDocumentExpression node. + expr: The JSONDocumentValue node. Returns: Tuple of (SQL string, parameters tuple). @@ -514,7 +541,7 @@ def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tup 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) # json_extract returns SQL NULL for a missing path and a # JSON-quoted string for text, so it stands in for ->> without a # separate unquote step — SQLite has no JSON_UNQUOTE to call. @@ -572,7 +599,7 @@ def supports_explain_format(self, format_type: str) -> bool: # SQLite has limited support for different EXPLAIN formats return format_type.upper() in ["TEXT", "DOT"] - def format_explain_statement(self, expr: "ExplainExpression") -> Tuple[str, tuple]: + def format_explain_statement(self, expr: "ExplainStatement") -> Tuple[str, tuple]: """Format EXPLAIN / EXPLAIN QUERY PLAN for SQLite. SQLite supports two forms: @@ -599,6 +626,14 @@ def supports_for_update_skip_locked(self) -> bool: """Whether FOR UPDATE SKIP LOCKED is supported.""" return False + def supports_for_update_nowait(self) -> bool: + """Whether FOR UPDATE NOWAIT is supported. + + SQLite has no ``NOWAIT``: the locking clause is ``BEGIN IMMEDIATE`` / + ``BEGIN EXCLUSIVE``, and there is nothing to attach a wait modifier to. + """ + return False + def supports_for_update(self) -> bool: """Whether FOR UPDATE clause is supported in SELECT statements. @@ -623,7 +658,7 @@ def supports_qualify_clause(self) -> bool: def supports_upsert(self) -> bool: """Whether UPSERT (ON CONFLICT) is supported.""" # UPSERT (ON CONFLICT) is supported since SQLite 3.24.0 - return self.version >= (3, 24, 0) + return self.version_at_least((3, 24, 0)) def get_upsert_syntax_type(self) -> str: """ @@ -643,7 +678,7 @@ def supports_multiple_on_conflict_clauses(self) -> bool: Multiple ON CONFLICT clauses are supported since SQLite 3.35.0. """ - return self.version >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) # Probed once per process and shared across instances: the result depends only on the # linked SQLite library build, which is fixed for the lifetime of the process. @@ -675,7 +710,7 @@ def supports_lateral_join(self) -> bool: def supports_distinct_from(self) -> bool: """Whether SQLite accepts ``IS [NOT] DISTINCT FROM`` (3.39.0+).""" - return self.version >= (3, 39, 0) + return self.version_at_least((3, 39, 0)) def format_distinct_from_predicate(self, expr: "DistinctFromPredicate") -> Tuple[str, tuple]: """Format a NULL-safe distinctness check, refusing below SQLite 3.39.0. @@ -742,7 +777,7 @@ def format_values_expression(self, expr: "bases.BaseExpression") -> Tuple[str, T Column names must instead be assigned through an outer CTE or by referencing the implicit ``column1``/``column2`` names. """ - values, alias, column_names = expr.values, expr.alias, expr.column_names + values, alias, _ = expr.values, expr.alias, expr.column_names all_params: List[Any] = [] rows_sql: List[str] = [] for row in values: @@ -764,14 +799,14 @@ def supports_truncate(self) -> bool: """SQLite does not support TRUNCATE TABLE (use DELETE FROM instead).""" return False - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """SQLite does not support TRUNCATE TABLE.""" raise UnsupportedFeatureError(self.name, "TRUNCATE TABLE", "Use DELETE FROM instead.") def supports_generated_columns(self) -> bool: """Whether generated columns are supported.""" # Generated columns (STORED/VIRTUAL) are supported since SQLite 3.31.0 - return self.version >= (3, 31, 0) + return self.version_at_least((3, 31, 0)) def supports_stored_generated_columns(self) -> bool: """Whether STORED generated columns are supported.""" @@ -826,12 +861,12 @@ def supports_rename_table(self) -> bool: def supports_rename_column(self) -> bool: """Whether RENAME COLUMN is supported.""" # RENAME COLUMN is supported since SQLite 3.25.0 - return self.version >= (3, 25, 0) + return self.version_at_least((3, 25, 0)) def supports_drop_column(self) -> bool: """Whether DROP COLUMN is supported.""" # DROP COLUMN is supported since SQLite 3.35.0 - return self.version >= (3, 35, 0) + return self.version_at_least((3, 35, 0)) def supports_table_partitioning(self) -> bool: """Whether table partitioning is supported.""" @@ -851,7 +886,7 @@ def supports_add_constraint(self) -> bool: Returns: True if SQLite version >= 3.53.0, False otherwise. """ - return self.version >= (3, 53, 0) + return self.version_at_least((3, 53, 0)) def supports_drop_constraint(self) -> bool: """Whether ALTER TABLE DROP CONSTRAINT is supported. @@ -861,19 +896,31 @@ def supports_drop_constraint(self) -> bool: Returns: True if SQLite version >= 3.53.0, False otherwise. """ - return self.version >= (3, 53, 0) + return self.version_at_least((3, 53, 0)) def supports_fk_match(self) -> bool: """SQLite does not support MATCH clause in FOREIGN KEY.""" return False def supports_deferrable_constraint(self) -> bool: - """SQLite does not support DEFERRABLE table constraints.""" - return False - - def supports_constraint_enforced(self) -> bool: - """SQLite does not support ENFORCED/NOT ENFORCED constraint control.""" - return False + """SQLite defers a foreign key to COMMIT when asked to. + + Opt-in: the core default is ``False`` (a SQL:1999 modifier, not a + constructor), so this override is a measured claim about SQLite + specifically. + + Measured on 3.50.4, ``foreign_keys = ON``: with + ``DEFERRABLE INITIALLY DEFERRED`` a child row that violates the + constraint **inserts successfully** inside the transaction and the + ``COMMIT`` fails; ``NOT DEFERRABLE`` and ``DEFERRABLE INITIALLY + IMMEDIATE`` both reject at the insert. So the deferral is enforced, not + merely parsed. + + Foreign keys only. SQLite's grammar has no deferrable form on + ``PRIMARY KEY``, ``UNIQUE`` or ``CHECK``, so this switch must not be + read as a claim about those. + """ + return True # CreateIndexSupport protocol implementation def supports_create_index(self) -> bool: @@ -899,7 +946,7 @@ def supports_index_if_not_exists(self) -> bool: def supports_partial_index(self) -> bool: """Whether partial indexes (WHERE clause) are supported.""" # Partial indexes are supported since SQLite 3.8.0 - return self.version >= (3, 8, 0) + return self.version_at_least((3, 8, 0)) def supports_functional_index(self) -> bool: """Whether functional/expression indexes are supported.""" @@ -985,25 +1032,12 @@ def supports_union_all(self) -> bool: def supports_intersect(self) -> bool: """Whether INTERSECT operation is supported.""" # INTERSECT is supported in SQLite since version 3.7.6 (2011-02-25) - return self.version >= (3, 7, 6) + return self.version_at_least((3, 7, 6)) def supports_except(self) -> bool: """Whether EXCEPT operation is supported.""" # EXCEPT is supported in SQLite since version 3.7.6 (2011-02-25) - return self.version >= (3, 7, 6) - - def supports_set_operation_order_by(self) -> bool: - """Whether set operations support ORDER BY clauses.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """Whether set operations support LIMIT and OFFSET clauses.""" - return True - - def supports_set_operation_for_update(self) -> bool: - """Whether set operations support FOR UPDATE clauses.""" - # SQLite doesn't support FOR UPDATE in set operations - return False + return self.version_at_least((3, 7, 6)) def supports_fetch_with_ties(self) -> bool: """SQLite does not support FETCH ... WITH TIES.""" @@ -1032,7 +1066,7 @@ def format_grouping_clause(self, expr: "bases.BaseExpression") -> Tuple[str, tup self.name, f"{operation} grouping operation", f"{operation} is not supported by SQLite." ) - def format_array_expression(self, expr: "ArrayExpression") -> Tuple[str, Tuple]: + def format_array_expression(self, expr: "ArrayValue") -> Tuple[str, Tuple]: """Format array expression.""" # SQLite does not support native array types raise UnsupportedFeatureError(self.name, "Array operations", _SUGGESTION_ARRAY_TYPES) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/conftest.py index ad4d57a01..5082e9016 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/conftest.py @@ -123,7 +123,7 @@ "priority": 10, }, "query/union.py": { - "title": "UNION using SetOperationExpression", + "title": "UNION using SetOperation", "dialect_protocols": [], "priority": 10, }, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/connection/quickstart.py index 3d735ac3f..33ed3219d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/connection/quickstart.py @@ -1,3 +1,28 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, + ValuesSource, + WhereClause, +) """ Quick Start: Connect to SQLite and execute queries. @@ -11,28 +36,7 @@ # ============================================================ # SECTION: Connection Setup # ============================================================ -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, - ValuesSource, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Column, Literal -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraint, - ColumnConstraintType, - ColumnDefinition, -) -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType -from .....expression.objects import Table + config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(connection_config=config) @@ -51,7 +55,7 @@ def execute_expression(expression, options=None): def create_demo_tables(): - users_table = CreateTableExpression( + users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -59,14 +63,14 @@ def create_demo_tables(): "id", IntegerType(), 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(dialect, "name", TextType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition(dialect, "status", TextType()), ], @@ -74,7 +78,7 @@ def create_demo_tables(): ) execute_expression(users_table, ddl_options) - logs_table = CreateTableExpression( + logs_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'logs'), columns=[ @@ -82,14 +86,14 @@ def create_demo_tables(): "id", IntegerType(), 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(dialect, "message", TextType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ], if_not_exists=True, @@ -98,12 +102,11 @@ def create_demo_tables(): def seed_demo_data(): - insert_users = InsertExpression( + insert_users = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name", "status"], source=ValuesSource( - dialect, [ [Literal(dialect, "Alice"), Literal(dialect, "active")], [Literal(dialect, "Bob"), Literal(dialect, "inactive")], @@ -125,7 +128,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Queries # ============================================================ -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -136,7 +139,6 @@ def seed_demo_data(): where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "status"), Literal(dialect, "active"), @@ -160,7 +162,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Parameterized Queries (Recommended) # ============================================================ -filtered_query = QueryExpression( +filtered_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -171,13 +173,11 @@ def seed_demo_data(): where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "id"), Literal(dialect, 1), ) & ComparisonPredicate( - dialect, "=", Column(dialect, "status"), Literal(dialect, "active"), @@ -191,7 +191,7 @@ def seed_demo_data(): # SECTION: Handle Transactions # ============================================================ with backend.transaction(): - insert_log = InsertExpression( + insert_log = InsertStatement( dialect=dialect, into=Table(dialect, 'logs'), columns=["message"], @@ -199,7 +199,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_=NamedRelationRef(dialect, Table(dialect, "logs")), @@ -211,7 +211,7 @@ def seed_demo_data(): # SECTION: Error Handling # ============================================================ try: - invalid_query = QueryExpression( + invalid_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "nonexistent_table")), @@ -223,10 +223,10 @@ def seed_demo_data(): # ============================================================ # SECTION: Disconnect # ============================================================ -drop_logs = DropTableExpression(dialect=dialect, table=Table(dialect, 'logs'), if_exists=True) +drop_logs = DropTableStatement(dialect=dialect, table=Table(dialect, 'logs'), if_exists=True) execute_expression(drop_logs, ddl_options) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) execute_expression(drop_users, ddl_options) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/alter_table.py index 0ff45cd04..c7a36cac3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/alter_table.py @@ -1,3 +1,26 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 + AddColumnAction, + RenameColumnAction, +) +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + AlterTableStatement, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint """ Alter table: add column, rename column, drop column. @@ -7,28 +30,14 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -36,15 +45,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -53,7 +62,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -65,18 +74,9 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - AlterTableExpression, - ColumnDefinition, -) -from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AddColumn, - RenameObject, -) -from rhosocial.activerecord.backend.expression.objects import Table # Add a new column (separate statement for SQLite) -add_col_action = AddColumn( +add_col_action = AddColumnAction( dialect=dialect, column=ColumnDefinition(dialect, name="email", @@ -84,7 +84,7 @@ ), ) -add_col_expr = AlterTableExpression( +add_col_expr = AlterTableStatement( dialect=dialect, table_name="users", actions=[add_col_action], @@ -97,13 +97,13 @@ print("Column added successfully") # Rename a column (separate statement for SQLite) -rename_action = RenameObject( +rename_action = RenameColumnAction( dialect=dialect, old_name="name", new_name="full_name", ) -rename_expr = AlterTableExpression( +rename_expr = AlterTableStatement( dialect=dialect, table_name="users", actions=[rename_action], diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_index.py index 3ef5a04ca..b8679e6f4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_index.py @@ -1,3 +1,12 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression import CreateIndexStatement +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import CreateTableStatement +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Create an index on an existing table. """ @@ -5,21 +14,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -27,15 +28,15 @@ "id", IntegerType(), 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(dialect, "email", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "name", TextType()), @@ -48,9 +49,8 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression # noqa: E402 -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect=dialect, index_name="idx_users_email", table_name="users", diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_table.py index b6a1349d8..77cf935a6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/create_table.py @@ -1,13 +1,22 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( # noqa: E402 + IndexDefinition, +) +from rhosocial.activerecord.backend.expression import CreateTableStatement, ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, TimestampType """ Create a table with primary key, auto-increment, and index. """ # ============================================================ # SECTION: Setup (necessary for execution, reference only) -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType, TimestampType # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) @@ -16,39 +25,27 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( # noqa: E402 - IndexDefinition, -) columns = [ ColumnDefinition(dialect, name="id", data_type=IntegerType(), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ), ColumnDefinition(dialect, name="name", data_type=TextType(), constraints=[ - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, name="email", data_type=TextType(), constraints=[ - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), ColumnDefinition(dialect, @@ -64,7 +61,7 @@ ), ] -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=dialect, table_name="users", columns=columns, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/drop_table.py index 8617e82c8..b1cc87537 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/drop_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/drop_table.py @@ -16,12 +16,12 @@ backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -34,11 +34,11 @@ backend.execute(sql, params) # ============================================================ -# SECTION: DROP TABLE (using DropTableExpression) +# SECTION: DROP TABLE (using DropTableStatement) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import DropTableStatement # noqa: E402 -drop_expr = DropTableExpression( +drop_expr = DropTableStatement( dialect=dialect, table_name="users", ) @@ -48,7 +48,7 @@ backend.execute(sql, params) # Already deleted, use IF EXISTS -drop_expr_exists = DropTableExpression( +drop_expr_exists = DropTableStatement( dialect=dialect, table_name="users", if_exists=True, @@ -63,5 +63,5 @@ backend.disconnect() # Key points: -# 1. Use DropTableExpression to drop tables +# 1. Use DropTableStatement to drop tables # 2. if_exists=True prevents error if missing diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/unique_index.py index ee6fce486..5f29b2624 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/unique_index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/unique_index.py @@ -1,3 +1,20 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression import CreateIndexStatement +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + QueryStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ CREATE UNIQUE INDEX - SQLite. @@ -10,28 +27,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - QueryExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -39,22 +41,22 @@ "id", IntegerType(), 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(dialect, "email", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "username", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -66,11 +68,9 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression # noqa: E402 -from .....expression.objects import Table # 1. CREATE UNIQUE INDEX on a single column -create_email_idx = CreateIndexExpression( +create_email_idx = CreateIndexStatement( dialect=dialect, index_name="idx_users_email", table_name="users", @@ -83,7 +83,7 @@ print(f"Params: {params}") # 2. CREATE UNIQUE INDEX on multiple columns (composite unique) -create_composite_idx = CreateIndexExpression( +create_composite_idx = CreateIndexStatement( dialect=dialect, index_name="idx_users_username_email", table_name="users", @@ -109,12 +109,11 @@ print("Index created: idx_users_username_email (UNIQUE on username, email)") # Insert initial row -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["email", "username"], source=ValuesSource( - dialect, [ [Literal(dialect, "alice@example.com"), Literal(dialect, "alice")], ], @@ -126,12 +125,11 @@ # 3. Verify uniqueness: attempt duplicate email (should fail) try: - duplicate_insert = InsertExpression( + duplicate_insert = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["email", "username"], source=ValuesSource( - dialect, [ [Literal(dialect, "alice@example.com"), Literal(dialect, "alice2")], ], @@ -144,9 +142,9 @@ print(f"Uniqueness constraint enforced: {e}") # Verify data after failed insert -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) sql, params = verify_query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/view.py index c460f23a5..46ac9d743 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/ddl/view.py @@ -21,17 +21,17 @@ 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 ColumnDefinition, ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -44,7 +44,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["id", "name"], @@ -64,20 +64,20 @@ # SECTION: CREATE VIEW # ============================================================ from rhosocial.activerecord.backend.expression import ( - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from .....expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) -view_expr = CreateViewExpression( +view_expr = CreateViewStatement( dialect=dialect, view_name="user_names", query=query, @@ -90,7 +90,7 @@ backend.execute(sql, params, options=options) # Query the view -view_query = QueryExpression( +view_query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "user_names")), @@ -106,7 +106,7 @@ # ============================================================ # SECTION: CREATE OR REPLACE VIEW # ============================================================ -view_expr_replace = CreateViewExpression( +view_expr_replace = CreateViewStatement( dialect=dialect, view_name="user_names", query=query, @@ -119,7 +119,7 @@ # ============================================================ # SECTION: DROP VIEW # ============================================================ -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect=dialect, view_name="user_names", ) @@ -130,7 +130,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() @@ -139,6 +139,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/sqlite/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/delete/basic.py index 792e0fb37..0df9c75c4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/delete/basic.py @@ -1,3 +1,23 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( + DeleteStatement, + ReturningClause, + Column, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Delete records and return affected row count. """ @@ -5,26 +25,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -32,15 +39,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -49,7 +56,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -61,20 +68,11 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - DeleteExpression, - ReturningClause, - Column, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....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"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/fts3_4_basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/fts3_4_basic.py index 522eed30b..7d0dd9f0c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/fts3_4_basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/fts3_4_basic.py @@ -28,23 +28,23 @@ # ============================================================ from rhosocial.activerecord.backend.expression import ( Column, - InsertExpression, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, FunctionCall, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.impl.sqlite.expression import ( SQLiteMatchPredicate, # noqa: E402 - CreateVirtualTableExpression, # noqa: E402 + CreateVirtualTableStatement, # noqa: E402 ) from .....expression.objects import Table # Create FTS4 virtual table using the dialect's generic virtual table # formatting method. For FTS4, module='fts4' and tokenizer options # are passed via the options dict. -create_expr = CreateVirtualTableExpression( +create_expr = CreateVirtualTableStatement( dialect=dialect, module="fts4", table_name="documents", @@ -64,7 +64,7 @@ ] for title, content in documents: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'documents'), columns=["title", "content"], @@ -81,7 +81,7 @@ sql, params = insert_expr.to_sql() backend.execute(sql, params) -# Full-text search using SQLiteMatchPredicate with QueryExpression +# Full-text search using SQLiteMatchPredicate with QueryStatement # SQLiteMatchPredicate delegates to the dialect's format_match_predicate, # which in turn calls the FTS extension's formatting logic. search_pred = SQLiteMatchPredicate(dialect, table="documents", query="programming") @@ -93,7 +93,7 @@ # ... offsets query example ... offsets_pred = SQLiteMatchPredicate(dialect, table="documents", query="database") -offsets_query = QueryExpression( +offsets_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "title", table="documents"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/version_353_features.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/version_353_features.py index f2468b139..716fa7d91 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/version_353_features.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/extensions/version_353_features.py @@ -99,15 +99,15 @@ print("Feature Usage Demonstration") print("=" * 60) -from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexExpression # noqa: E402 +from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexStatement # noqa: E402 from rhosocial.activerecord.backend.impl.sqlite.functions import json_array_insert, jsonb_array_insert # noqa: E402 -from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Literal, QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from .....expression.objects import Table if dialect.supports_reindex_expressions(): print("\n--- REINDEX EXPRESSIONS Example ---") - reindex_expr = SQLiteReindexExpression(dialect, expressions=True) + reindex_expr = SQLiteReindexStatement(dialect, expressions=True) sql, params = reindex_expr.to_sql() print(f" SQL: {sql}") else: @@ -160,7 +160,7 @@ col_data = Column(dialect, "data", table="test_json") json_expr = json_array_insert(dialect, col_data, Literal(dialect, 0), position=0) - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[json_expr.as_("modified")], from_=NamedRelationRef(dialect, Table(dialect, "test_json")), @@ -182,7 +182,7 @@ col_data = Column(dialect, "data", table="test_jsonb") json_expr = jsonb_array_insert(dialect, col_data, Literal(dialect, "new_value"), position=1) - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[json_expr.as_("modified")], from_=NamedRelationRef(dialect, Table(dialect, "test_jsonb")), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/batch.py index a0b2e8ea2..62c87fbbf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/batch.py @@ -1,3 +1,21 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + InsertStatement, + ValuesSource, + QueryStatement, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import CreateTableStatement +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Batch insert with multiple rows. """ @@ -5,23 +23,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -29,15 +37,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "email", TextType()), @@ -51,20 +59,11 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - InsertExpression, - ValuesSource, - QueryExpression, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression # noqa: E402 -from .....expression.objects import Table -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), source=ValuesSource( - dialect, [ [Literal(dialect, "Alice"), Literal(dialect, "alice@example.com")], [Literal(dialect, "Bob"), Literal(dialect, "bob@example.com")], @@ -85,9 +84,9 @@ print(f"Affected rows: {result.affected_rows}") # Verify -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) options = ExecutionOptions(stmt_type=StatementType.DQL) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/single.py index 8ca4ab0cd..59aeb057a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/single.py @@ -1,3 +1,19 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + QueryStatement, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Single Row INSERT - SQLite. @@ -9,27 +25,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -37,8 +39,8 @@ "id", IntegerType(), 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(dialect, "name", TextType()), @@ -50,17 +52,14 @@ backend.execute(sql, params) # ============================================================ -# SECTION: Single INSERT (using InsertExpression) +# SECTION: Single INSERT (using InsertStatement) # ============================================================ -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource # noqa: E402 -from .....expression.objects import Table -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], source=ValuesSource( - dialect, [ [Literal(dialect, "Alice")], ], @@ -71,9 +70,9 @@ print(f"Params: {params}") backend.execute(sql, params) -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) sql, params = verify_query.to_sql() @@ -83,7 +82,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() @@ -92,5 +91,5 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use InsertExpression with ValuesSource for single row +# 1. Use InsertStatement with ValuesSource for single row # 2. Auto-increment uses PRIMARY KEY constraint diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/upsert.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/upsert.py index b636fd737..470d8293b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/upsert.py @@ -1,3 +1,26 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard +from rhosocial.activerecord.backend.impl.sqlite.expression.dml import ( # noqa: E402 + SQLiteInsertStatement, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + ValuesSource, + QueryStatement, + CreateTableStatement, + DropTableStatement, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType """ UPSERT (INSERT OR REPLACE / INSERT OR IGNORE) - SQLite. @@ -8,36 +31,14 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( - ValuesSource, - QueryExpression, - CreateTableExpression, - DropTableExpression, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.impl.sqlite.expression.dml import ( # noqa: E402 - SQLiteInsertExpression, -) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from .....expression.objects import Table -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -45,23 +46,23 @@ "id", IntegerType(), 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(dialect, "username", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), ColumnDefinition(dialect, "email", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "login_count", IntegerType()), @@ -73,12 +74,11 @@ backend.execute(sql, params) # Insert initial data -insert_initial = InsertExpression( +insert_initial = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["username", "email", "login_count"], source=ValuesSource( - dialect, [ [Literal(dialect, "alice"), Literal(dialect, "alice@example.com"), Literal(dialect, 5)], [Literal(dialect, "bob"), Literal(dialect, "bob@example.com"), Literal(dialect, 3)], @@ -89,9 +89,9 @@ backend.execute(sql, params) # Verify initial data -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -108,13 +108,12 @@ # --- INSERT OR REPLACE --- # INSERT OR REPLACE deletes the existing conflicting row and inserts the new one. # Note: This resets any columns NOT provided in the insert to their defaults. -# Use SQLiteInsertExpression(or_replace=True) to enable this SQLite-specific syntax. -insert_or_replace = SQLiteInsertExpression( +# Use SQLiteInsertStatement(or_replace=True) to enable this SQLite-specific syntax. +insert_or_replace = SQLiteInsertStatement( dialect=dialect, into="users", columns=["username", "email", "login_count"], source=ValuesSource( - dialect, [ # 'alice' already exists with id=1; OR REPLACE will delete old row and insert new one [Literal(dialect, "alice"), Literal(dialect, "alice_new@example.com"), Literal(dialect, 0)], @@ -128,13 +127,12 @@ # --- INSERT OR IGNORE --- # INSERT OR IGNORE silently skips rows that would cause constraint violations. -# Use SQLiteInsertExpression(or_ignore=True) to enable this SQLite-specific syntax. -insert_or_ignore = SQLiteInsertExpression( +# Use SQLiteInsertStatement(or_ignore=True) to enable this SQLite-specific syntax. +insert_or_ignore = SQLiteInsertStatement( dialect=dialect, into="users", columns=["username", "email", "login_count"], source=ValuesSource( - dialect, [ # 'bob' already exists; OR IGNORE will skip this row [Literal(dialect, "bob"), Literal(dialect, "bob_new@example.com"), Literal(dialect, 10)], @@ -179,7 +177,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() @@ -188,10 +186,10 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SQLiteInsertExpression(or_replace=True) for INSERT OR REPLACE +# 1. Use SQLiteInsertStatement(or_replace=True) for INSERT OR REPLACE # - Deletes existing row on conflict and inserts new row # - Auto-increment ID and unspecified columns are reset to defaults -# 2. Use SQLiteInsertExpression(or_ignore=True) for INSERT OR IGNORE +# 2. Use SQLiteInsertStatement(or_ignore=True) for INSERT OR IGNORE # - Silently skips rows that would cause UNIQUE/PRIMARY KEY constraint violations # - Other rows in the same batch are still inserted # 3. Cannot combine or_replace/or_ignore with on_conflict (OnConflictClause) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/with_returning.py index 922f08d09..4898656f0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/insert/with_returning.py @@ -1,3 +1,19 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( + InsertStatement, + ValuesSource, + ReturningClause, + Column, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import CreateTableStatement +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Insert a record and return the auto-generated ID using RETURNING clause. """ @@ -5,21 +21,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -27,15 +35,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -47,16 +55,8 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - InsertExpression, - ValuesSource, - ReturningClause, - Column, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from .....expression.objects import Table -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), source=ValuesSource(dialect, [[Literal(dialect, "Alice")]]), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/introspection_cache_benchmark.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/introspection_cache_benchmark.py new file mode 100644 index 000000000..1cdfda336 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/introspection_cache_benchmark.py @@ -0,0 +1,202 @@ +# src/rhosocial/activerecord/backend/impl/sqlite/examples/introspection_cache_benchmark.py +"""What the introspection cache buys, measured on SQLite. + +Run:: + + python -m rhosocial.activerecord.backend.impl.sqlite.examples.introspection_cache_benchmark + +Three passes over the same schema are timed, and the number of questions +each pass actually puts to the database is counted: + +* **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. + +The counts matter as much as the timings: SQLite answers a metadata +question inside this process, so a question costs microseconds and a +whole schema walk costs milliseconds. A server backend answers the same +questions over a socket, where each one costs a round trip. + +The last section makes that difference explicit. It walks the schema +again with an artificial delay injected into the executor -- a stand-in +for a backend whose metadata lives on the other end of a connection -- +and reports what the cache is worth there. +""" + +import time +from typing import Any, Dict, List, Tuple + +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.introspection import InMemoryIntrospectionCache +from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor + +TABLES = 40 +COLUMNS = 8 +INDEXES = 2 + + +# --------------------------------------------------------------------- # +# The schema, and the questions asked about it +# --------------------------------------------------------------------- # + + +def build_schema(backend: SQLiteBackend) -> None: + """Create a schema with enough shape that the timings mean something.""" + # The low-level cursor, not the public execute(): this is setup, and + # routing it through the whole execution path would add logging and + # hooks to a measurement that is about introspection, not about DDL. + cursor = backend._get_cursor() + try: + for t in range(TABLES): + columns = ", ".join( + f"col_{c} {'INTEGER' if c == 0 else 'TEXT'}" for c in range(COLUMNS) + ) + cursor.execute(f"CREATE TABLE table_{t} (id INTEGER PRIMARY KEY, {columns})") + for i in range(INDEXES): + cursor.execute( + f"CREATE INDEX idx_{t}_{i} ON table_{t} (col_{1 + i}, col_{3 + i})" + ) + # The connection runs in autocommit, so each DDL commits itself. + finally: + cursor.close() + + +def fresh_backend(path: str = ":memory:") -> SQLiteBackend: + """A connected backend with a schema in it, ready to be asked about.""" + backend = SQLiteBackend(database=path) + backend.connect() + build_schema(backend) + # Adapt the dialect before asking it anything: the introspector builds + # SQL through the dialect, and an unadapted dialect has no version to + # gate its statements on. + backend.introspect_and_adapt() + return backend + + +def walk_schema(backend: SQLiteBackend, counting: Any) -> int: + """Ask the introspector everything it knows about this schema.""" + introspector = backend.introspector + answers = 0 + for table in introspector.list_tables(): + answers += len(introspector.list_columns(table.name)) + answers += len(introspector.list_indexes(table.name)) + answers += 1 if introspector.get_primary_key(table.name) else 0 + return answers + + +# --------------------------------------------------------------------- # +# Counting and delaying what the executor is asked to do +# --------------------------------------------------------------------- # + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that also reports how much it was asked to do.""" + + 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 LatentExecutor(CountingExecutor): + """A counting executor that also spends time the way a socket would.""" + + def __init__(self, backend: Any, delay: float) -> None: + super().__init__(backend) + self._delay = delay + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + time.sleep(self._delay) + return super().execute(sql, params) + + +def uncached(backend: SQLiteBackend) -> Any: + """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: SQLiteBackend, delay: float = 0.0) -> Any: + """Install the default cache, and an executor that may be slow.""" + introspector = backend.introspector + introspector._cache = InMemoryIntrospectionCache() + executor = LatentExecutor(backend, delay) + introspector._executor = executor + return executor + + +def measure(backend: SQLiteBackend, repeats: int = 3) -> 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, executor) + 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 main() -> None: + backend = fresh_backend() + + # No cache: every question is put to the database 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 it + warm, warm_questions, _ = measure(backend) + + print(f"SQLite introspection over {TABLES} tables, " + f"{TABLES * COLUMNS} columns and {TABLES * INDEXES} indexes") + print(f" {answers} values answered, {questions} questions put to the database\n") + print(f" no cache {_ms(cold)} {questions} questions") + print(f" cached {_ms(warm)} {warm_questions} questions") + print(f"\n cache saves {_ms(cold - warm)} " + f"({(cold - warm) / cold * 100:.0f}% of the uncached pass)") + + # The same walk, with each question paying a round trip. Two floors + # bracketing what a local server backend costs per metadata query. + # The achieved latency is measured rather than assumed, because + # ``time.sleep`` only promises a lower bound and a benchmark that + # reports a number it asked for instead of the number it got is not + # a benchmark. + for requested in (0.0005, 0.002): + executor = cached(backend, requested) + executor.calls = 0 + server_cold, _, _ = measure(backend) + server_warm, warm_calls, _ = measure(backend) + achieved = server_cold / questions if questions else 0.0 + local_cost = cold / questions if questions else 0.0 + print(f"\n asked the executor to spend {requested * 1000:.1f} ms per question; " + f"achieved {achieved * 1000:.2f} ms") + print(f" SQLite's own cost per question was {local_cost * 1e6:.0f} µs, " + f"so this is {achieved / local_cost:.0f}× a round trip") + print(f" no cache {_ms(server_cold)} {questions} questions") + print(f" cached {_ms(server_warm)} {warm_calls} questions") + print(f" cache saves {_ms(server_cold - server_warm)} " + f"({(server_cold - server_warm) / server_cold * 100:.0f}% of cold)") + + backend.disconnect() + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_clauses.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_clauses.py index 6bcce11c9..674874751 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_clauses.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_clauses.py @@ -10,11 +10,11 @@ """ from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ComparisonPredicate, Literal, - FunctionCall, + functions, WhereClause, JoinClause, GroupByHavingClause, @@ -36,9 +36,9 @@ def where_example(dialect, status: str = "active"): status: Status value to filter by. Returns: - QueryExpression with WHERE clause. + QueryStatement with WHERE clause. """ - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -59,9 +59,9 @@ def join_example(dialect, user_id: int = 1): user_id: User identifier. Returns: - QueryExpression with JOIN. + QueryStatement with JOIN. """ - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "o.id"), @@ -92,13 +92,13 @@ def group_by_example(dialect, min_total: float = 100.0): min_total: Minimum total for HAVING filter. Returns: - QueryExpression with GROUP BY / HAVING. + QueryStatement with GROUP BY / HAVING. """ - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "status"), - FunctionCall(dialect, "COUNT", Column(dialect, "id")).as_("cnt"), + functions.count(dialect, Column(dialect, "id")).as_("cnt"), ], from_=NamedRelationRef(dialect, Table(dialect, "orders")), group_by_having=GroupByHavingClause( @@ -118,9 +118,9 @@ def order_by_example(dialect, limit: int = 10): limit: Maximum number of rows. Returns: - QueryExpression with ORDER BY and LIMIT. + QueryStatement with ORDER BY and LIMIT. """ - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "amount")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -144,9 +144,9 @@ def distinct_example(dialect): dialect: SQL dialect instance. Returns: - QueryExpression with DISTINCT modifier. + QueryStatement with DISTINCT modifier. """ - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -165,9 +165,9 @@ def compound_example(dialect, user_id: int = 1, status: str = "pending"): status: Status filter. Returns: - QueryExpression with multiple clauses. + QueryStatement with multiple clauses. """ - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "o.id"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_ddl.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_ddl.py index 7b0325687..7707bf8db 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_ddl.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_ddl.py @@ -1,3 +1,19 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.types import IntegerType, RealType, TextType +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + CreateIndexStatement, + ColumnDefinition, + ColumnConstraint, + AlterTableStatement, + AddColumnAction, +) # src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_ddl.py """ DDL named expression examples — CREATE / ALTER / DROP. @@ -6,18 +22,7 @@ a BaseExpression that implements Executable. """ -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - CreateIndexExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - AlterTableExpression, - AddColumn, -) -from rhosocial.activerecord.backend.expression.types import IntegerType, RealType, TextType -from .....expression.objects import Table + def _pk_column(name: str): @@ -26,7 +31,7 @@ def _pk_column(name: str): name=name, data_type=IntegerType(), constraints=[ - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ) @@ -35,10 +40,10 @@ def _integer_column(name: str, default: int = None, not_null: bool = False): """Integer column definition with optional DEFAULT / NOT NULL.""" constraints = [] if not_null: - constraints.append(ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)) + constraints.append(ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))) if default is not None: constraints.append( - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=default) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, default)) ) return ColumnDefinition(dialect, name=name, data_type=IntegerType(), constraints=constraints) @@ -47,10 +52,10 @@ def _text_column(name: str, default: str = None, not_null: bool = False): """Text column definition with optional DEFAULT / NOT NULL.""" constraints = [] if not_null: - constraints.append(ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)) + constraints.append(ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))) if default is not None: constraints.append( - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=default) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, default)) ) return ColumnDefinition(dialect, name=name, data_type=TextType(), constraints=constraints) @@ -60,7 +65,7 @@ def _real_column(name: str, default: float = None): constraints = [] if default is not None: constraints.append( - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=default) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, default)) ) return ColumnDefinition(dialect, name=name, data_type=RealType(), constraints=constraints) @@ -72,9 +77,9 @@ def create_orders_table(dialect): dialect: SQL dialect instance. Returns: - CreateTableExpression. + CreateTableStatement. """ - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'orders'), columns=[ @@ -95,9 +100,9 @@ def create_inventory_table(dialect): dialect: SQL dialect instance. Returns: - CreateTableExpression. + CreateTableStatement. """ - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'inventory'), columns=[ @@ -116,13 +121,13 @@ def add_amount_column(dialect): dialect: SQL dialect instance. Returns: - AlterTableExpression. + AlterTableStatement. """ - return AlterTableExpression( + return AlterTableStatement( dialect, table_name="orders", actions=[ - AddColumn( + AddColumnAction( dialect, column=_real_column("amount", default=0.0), ), @@ -137,9 +142,9 @@ def add_orders_status_index(dialect): dialect: SQL dialect instance. Returns: - CreateIndexExpression. + CreateIndexStatement. """ - return CreateIndexExpression( + return CreateIndexStatement( dialect, index_name="idx_orders_status", table_name="orders", @@ -155,9 +160,9 @@ def drop_old_orders_table(dialect): dialect: SQL dialect instance. Returns: - DropTableExpression. + DropTableStatement. """ - return DropTableExpression( + return DropTableStatement( dialect, table=Table(dialect, 'temp_orders'), if_exists=True, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_dml.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_dml.py index ed123318d..fe71fd8d8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_dml.py @@ -7,10 +7,10 @@ """ from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, ValuesSource, - UpdateExpression, - DeleteExpression, + UpdateStatement, + DeleteStatement, Column, ComparisonPredicate, Literal, @@ -29,9 +29,9 @@ def add_order(dialect, user_id: int, status: str = "pending"): status: Order status. Returns: - InsertExpression. + InsertStatement. """ - return InsertExpression( + return InsertStatement( dialect, into=Table(dialect, 'orders'), columns=["user_id", "status"], @@ -50,9 +50,9 @@ def add_order_bulk(dialect, user_id: int): user_id: User identifier. Returns: - InsertExpression with multiple value rows. + InsertStatement with multiple value rows. """ - return InsertExpression( + return InsertStatement( dialect, into=Table(dialect, 'orders'), columns=["user_id", "status"], @@ -75,9 +75,9 @@ def update_order_status(dialect, order_id: int, new_status: str): new_status: New status value. Returns: - UpdateExpression. + UpdateStatement. """ - return UpdateExpression( + return UpdateStatement( dialect, table=Table(dialect, 'orders'), assignments={"status": Literal(dialect, new_status)}, @@ -96,9 +96,9 @@ def cancel_order(dialect, order_id: int): order_id: Order identifier. Returns: - DeleteExpression. + DeleteStatement. """ - return DeleteExpression( + return DeleteStatement( dialect, tables=Table(dialect, 'orders'), where=WhereClause( @@ -117,9 +117,9 @@ def add_payment(dialect, order_id: int, status: str = "pending"): status: Payment status. Returns: - InsertExpression with RETURNING clause. + InsertStatement with RETURNING clause. """ - return InsertExpression( + return InsertStatement( dialect, into=Table(dialect, 'payments'), columns=["order_id", "status", "transaction_id"], @@ -148,9 +148,9 @@ def archive_processed_orders(dialect, status: str = "completed"): status: Status to filter by. Returns: - DeleteExpression. + DeleteStatement. """ - return DeleteExpression( + return DeleteStatement( dialect, tables=Table(dialect, 'orders'), where=WhereClause( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_expressions.py index 8cd924145..47a5bd9be 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_expressions.py @@ -1,3 +1,21 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal, QueryStatement +from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 + IntegerType, + TextType, +) +from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression import CreateTableStatement, InsertStatement, ValuesSource, ColumnConstraint +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend # src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_expressions.py """ Order-related named query examples. @@ -10,28 +28,11 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, -) -from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 - IntegerType, - TextType, -) def _column(name: str, type_name: str): @@ -39,7 +40,7 @@ def _column(name: str, type_name: str): data_type = IntegerType() if type_name == "INTEGER" else TextType() constraints = [] if "PRIMARY KEY" in type_name: - constraints.append(ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)) + constraints.append(ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))) return ColumnDefinition(dialect, name=name, data_type=data_type, constraints=constraints) @@ -55,7 +56,7 @@ def _column(name: str, type_name: str): ] for table_name, columns in tables: - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[_column(name, type_name) for name, type_name in columns], @@ -70,7 +71,7 @@ def _column(name: str, type_name: str): ("inventory", [(1, 1, 10)]), ]: for row in data: - insert = InsertExpression( + insert = InsertStatement( dialect=dialect, into=Table(dialect, table), columns=[name for name, _ in tables[[t for t, _ in tables].index(table)][1]], @@ -82,13 +83,11 @@ def _column(name: str, type_name: str): # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal, QueryExpression -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef 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_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -98,7 +97,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_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -108,7 +107,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, "reserved")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -118,7 +117,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_=NamedRelationRef(dialect, Table(dialect, "notifications")), @@ -128,7 +127,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_=NamedRelationRef(dialect, Table(dialect, "payments")), @@ -138,7 +137,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_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -148,7 +147,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_=NamedRelationRef(dialect, Table(dialect, "order_records")), @@ -158,7 +157,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_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -188,9 +187,6 @@ def confirm_inventory(dialect, order_id: int): # ============================================================ # SECTION: Execution (run the expression) # ============================================================ -from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 -from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 -from .....expression.objects import Table if __name__ == "__main__": query = get_order(dialect, order_id=1) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_version_compare.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_version_compare.py index 7389f2cf8..163f199f2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_version_compare.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_expressions/order_version_compare.py @@ -19,7 +19,7 @@ """ # noqa: E501 from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, FunctionCall, Column, Literal, @@ -44,7 +44,7 @@ def demo_json_array_insert(dialect, position: int = 0, value: str = "new_item"): value: Value to insert into the array. Returns: - QueryExpression with ``json_array_insert``. + QueryStatement with ``json_array_insert``. Raises: RuntimeError: If dialect version < 3.53.0. @@ -53,7 +53,7 @@ def demo_json_array_insert(dialect, position: int = 0, value: str = "new_item"): ver = f"{dialect.version[0]}.{dialect.version[1]}.{dialect.version[2]}" raise RuntimeError(f"json_array_insert() requires SQLite 3.53.0+, current dialect version: {ver}") - return QueryExpression( + return QueryStatement( dialect, select=[ FunctionCall( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/demo_incompatible.sh b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/demo_incompatible.sh index 0ff241f06..8e351e134 100755 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/demo_incompatible.sh +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/demo_incompatible.sh @@ -45,7 +45,7 @@ from rhosocial.activerecord.backend.migration import ( MigrationRunner, MigrationDirection, MigrationDialectError, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( PartitionClause, PartitionStrategy, @@ -55,9 +55,9 @@ from rhosocial.activerecord.backend.expression.core import Column as ColExpr dialect = SQLiteBackend(database=":memory:").dialect col_def = ColumnDefinition("id", SQLiteIntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]) + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]) col_expr = ColExpr(dialect, "id") -expr = CreateTableExpression( +expr = CreateTableStatement( dialect, table="partitioned", columns=[col_def], partition=PartitionClause(dialect, method=PartitionStrategy.RANGE, keys=[col_expr]), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/expressions.py index 22ef1ec8b..b250a1d54 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/expressions.py @@ -1,3 +1,17 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( + SQLiteIntegerType, + SQLiteTextType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, + DropTableStatement, +) # src/rhosocial/activerecord/backend/impl/sqlite/examples/named_migrations/expressions.py """ DDL named expression functions for migration examples. @@ -6,30 +20,19 @@ These are the building blocks used by NamedMigration up()/down() methods. """ -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - DropTableExpression, -) -from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( - SQLiteIntegerType, - SQLiteTextType, -) -from .....expression.objects import Table + def create_users_table(dialect): """CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", SQLiteTextType(dialect)), ColumnDefinition(dialect, "email", SQLiteTextType(dialect)), @@ -39,19 +42,19 @@ 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 INTEGER PRIMARY KEY, title TEXT, user_id INTEGER).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'posts'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "title", SQLiteTextType(dialect)), ColumnDefinition(dialect, "user_id", SQLiteIntegerType(dialect)), @@ -61,7 +64,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"): @@ -70,14 +73,14 @@ 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=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "value", SQLiteTextType(dialect)), ], @@ -86,4 +89,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) + return DropTableStatement(dialect, table=Table(dialect, table_name), if_exists=True) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedure_graph/q.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedure_graph/q.py index 39733ad31..9cc11db5a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedure_graph/q.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedure_graph/q.py @@ -7,10 +7,10 @@ """ from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ComparisonPredicate, - FunctionCall, + functions, WhereClause, Literal, ) @@ -26,13 +26,13 @@ def agg_sales(dialect, month: str = ""): month: Month in YYYY-MM format. Returns: - QueryExpression. + QueryStatement. """ - return QueryExpression( + return QueryStatement( dialect, select=[ - FunctionCall(dialect, "SUM", Column(dialect, "amount")).as_("total"), - FunctionCall(dialect, "COUNT", Column(dialect, "id")).as_("count"), + functions.sum_(dialect, Column(dialect, "amount")).as_("total"), + functions.count(dialect, Column(dialect, "id")).as_("count"), ], from_=NamedRelationRef(dialect, Table(dialect, "sales")), where=WhereClause( @@ -50,13 +50,13 @@ def agg_refunds(dialect, month: str = ""): month: Month in YYYY-MM format. Returns: - QueryExpression. + QueryStatement. """ - return QueryExpression( + return QueryStatement( dialect, select=[ - FunctionCall(dialect, "SUM", Column(dialect, "amount")).as_("total"), - FunctionCall(dialect, "COUNT", Column(dialect, "id")).as_("count"), + functions.sum_(dialect, Column(dialect, "amount")).as_("total"), + functions.count(dialect, Column(dialect, "id")).as_("count"), ], from_=NamedRelationRef(dialect, Table(dialect, "refunds")), where=WhereClause( @@ -73,14 +73,14 @@ def join_sales_refunds(dialect): dialect: SQL dialect instance. Returns: - QueryExpression joining sales and refunds. + QueryStatement joining sales and refunds. """ - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "s.month"), - FunctionCall(dialect, "SUM", Column(dialect, "s.amount")).as_("sales_total"), - FunctionCall(dialect, "SUM", Column(dialect, "r.amount")).as_("refunds_total"), + functions.sum_(dialect, Column(dialect, "s.amount")).as_("sales_total"), + functions.sum_(dialect, Column(dialect, "r.amount")).as_("refunds_total"), ], ) @@ -93,9 +93,9 @@ def write_summary(dialect, month: str = ""): month: Month in YYYY-MM format. Returns: - QueryExpression. + QueryStatement. """ - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "month")], ) @@ -109,9 +109,9 @@ def check_threshold(dialect, threshold: int = 1000): threshold: Threshold value. Returns: - QueryExpression. + QueryStatement. """ - return QueryExpression( + return QueryStatement( dialect, select=[Literal(dialect, 1)], where=WhereClause( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/diagram_demo.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/diagram_demo.py index bd7509fae..914addc9f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/diagram_demo.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/diagram_demo.py @@ -25,8 +25,8 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 @@ -42,7 +42,7 @@ ] for table_name, columns in tables: - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ColumnDefinition(dialect, c.split()[0], c.split()[1]) for c in columns], @@ -59,7 +59,7 @@ ] for table, cols, rows in sample_data: for row in rows: - insert = InsertExpression( + insert = InsertStatement( dialect=dialect, into=Table(dialect, table), columns=cols, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/order_workflow.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/order_workflow.py index 05e58a645..d037b728c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/order_workflow.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/order_workflow.py @@ -1,3 +1,24 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.named_expression import ProcedureRunner, TransactionMode +from rhosocial.activerecord.backend.named_expression import ( # noqa: E402 + Procedure, + ProcedureContext, + ParallelStep, +) +from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 + IntegerType, + TextType, +) +from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import CreateTableStatement, InsertStatement, ValuesSource, ColumnConstraint +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend # src/rhosocial/activerecord/backend/impl/sqlite/examples/named_procedures/order_workflow.py """ Order processing workflow example - demonstrates Named Procedure flowchart capabilities. @@ -13,28 +34,11 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, -) -from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 - IntegerType, - TextType, -) def _column(name: str, type_name: str): @@ -42,7 +46,7 @@ def _column(name: str, type_name: str): data_type = IntegerType() if type_name == "INTEGER" else TextType() constraints = [] if "PRIMARY KEY" in type_name: - constraints.append(ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)) + constraints.append(ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))) return ColumnDefinition(dialect, name=name, data_type=data_type, constraints=constraints) @@ -58,7 +62,7 @@ def _column(name: str, type_name: str): ] for table_name, columns in tables: - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[_column(name, type_name) for name, type_name in columns], @@ -74,7 +78,7 @@ def _column(name: str, type_name: str): ] for table, cols, rows in sample_data: for row in rows: - insert = InsertExpression( + insert = InsertStatement( dialect=dialect, into=Table(dialect, table), columns=cols, @@ -86,11 +90,6 @@ def _column(name: str, type_name: str): # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.named_expression import ( # noqa: E402 - Procedure, - ProcedureContext, - ParallelStep, -) class OrderProcessingProcedure(Procedure): @@ -196,8 +195,6 @@ def run(self, ctx: ProcedureContext) -> None: # ============================================================ # SECTION: Execution (run the expression) # ============================================================ -from rhosocial.activerecord.backend.named_expression import ProcedureRunner, TransactionMode # noqa: E402 -from rhosocial.activerecord.backend.expression.objects import Table if __name__ == "__main__": runner = ProcedureRunner( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/aggregate.py index 3911cc88f..d65325b6d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/aggregate.py @@ -1,3 +1,26 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import functions +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + GroupByHavingClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Aggregate query with GROUP BY and HAVING clauses. """ @@ -5,28 +28,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="orders", columns=[ @@ -34,8 +42,8 @@ "id", IntegerType(), 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(dialect, "user_id", IntegerType()), @@ -55,7 +63,7 @@ (3, 300.0, "completed"), ] for row in orders_data: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'orders'), columns=["user_id", "amount", "status"], @@ -67,27 +75,19 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - GroupByHavingClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal # noqa: E402 -from .....expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "user_id"), - FunctionCall(dialect, "SUM", Column(dialect, "amount"), alias="total_amount"), - FunctionCall(dialect, "COUNT", Column(dialect, "id"), alias="order_count"), + functions.sum_(dialect, Column(dialect, "amount"), alias="total_amount"), + functions.count(dialect, Column(dialect, "id"), alias="order_count"), ], from_=NamedRelationRef(dialect, Table(dialect, "orders")), group_by_having=GroupByHavingClause( dialect, group_by=[Column(dialect, "user_id")], - having=FunctionCall(dialect, "SUM", Column(dialect, "amount")) > Literal(dialect, 100), + having=functions.sum_(dialect, Column(dialect, "amount")) > Literal(dialect, 100), ), ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/basic.py index 2c04f361d..34a7dc91c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/basic.py @@ -1,3 +1,28 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + WhereClause, + OrderByClause, + LimitOffsetClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Basic SELECT query with WHERE, ORDER BY, and LIMIT clauses. """ @@ -5,28 +30,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -34,15 +44,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "age", IntegerType()), @@ -59,7 +69,7 @@ ("Charlie", 35, "inactive"), ] for row in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name", "age", "status"], @@ -71,19 +81,8 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - WhereClause, - OrderByClause, - LimitOffsetClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -94,7 +93,6 @@ where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "status"), Literal(dialect, "active"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/cte.py index 5e56d9b10..be6fc891a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/cte.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/cte.py @@ -1,3 +1,32 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression import JoinClause +from rhosocial.activerecord.backend.expression.predicates import IsNullPredicate +from rhosocial.activerecord.backend.expression import SetOperation +from rhosocial.activerecord.backend.expression import functions +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CTEClause, + WithStatement, + GroupByHavingClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + CreateTableStatement, + InsertStatement, + ValuesSource, + ArithmeticOperation, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ CTE (Common Table Expressions): basic and recursive. """ @@ -5,31 +34,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - CreateTableExpression, - InsertExpression, - ValuesSource, - BinaryArithmeticExpression, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="employees", columns=[ @@ -37,15 +48,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "manager_id", IntegerType()), @@ -66,7 +77,7 @@ (3, "Frank", "Sales"), # id=6, reports to Carol ] for manager_id, name, dept in employees_data: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'employees'), columns=["manager_id", "name", "department"], @@ -78,25 +89,19 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CTEExpression, - WithQueryExpression, - GroupByHavingClause, -) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 # --- Example 1: Basic CTE (WITH clause) --- # Compute average salary per department, then filter departments above a threshold. # (We use COUNT here as a stand-in aggregate on the employees table.) -dept_count_cte = CTEExpression( +dept_count_cte = CTEClause( dialect=dialect, name="dept_counts", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[ Column(dialect, "department"), - FunctionCall(dialect, "COUNT", Column(dialect, "id"), alias="headcount"), + functions.count(dialect, Column(dialect, "id"), alias="headcount"), ], from_=NamedRelationRef(dialect, Table(dialect, "employees")), group_by_having=GroupByHavingClause( @@ -106,10 +111,10 @@ ), ) -basic_cte_query = WithQueryExpression( +basic_cte_query = WithStatement( dialect=dialect, ctes=[dept_count_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[ Column(dialect, "department"), @@ -124,12 +129,10 @@ print(f"Params: {params}") # --- Example 2: Recursive CTE for hierarchical data (org chart) --- -from rhosocial.activerecord.backend.expression import SetOperationExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import IsNullPredicate # noqa: E402 # Base case: top-level employees (no manager) # The CTE columns parameter defines column names, so we don't need AS aliases -base_query = QueryExpression( +base_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -144,9 +147,6 @@ # Recursive case: employees whose manager is already in org_chart # Use a JOIN between the CTE result and employees table -from rhosocial.activerecord.backend.expression import JoinClause # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....expression.objects import Table join_expr = JoinClause( dialect=dialect, @@ -156,39 +156,39 @@ condition=ComparisonPredicate(dialect, "=", Column(dialect, "id", "oc"), Column(dialect, "manager_id", "e")), ) -recursive_query = QueryExpression( +recursive_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id", "e"), Column(dialect, "name", "e"), Column(dialect, "manager_id", "e"), Column(dialect, "department", "e"), - BinaryArithmeticExpression(dialect, "+", Column(dialect, "level", "oc"), Literal(dialect, 1)), + ArithmeticOperation(dialect, "add", Column(dialect, "level", "oc"), Literal(dialect, 1)), ], from_=join_expr, ) # Combine base and recursive with UNION ALL -union_all = SetOperationExpression( +union_all = SetOperation( dialect=dialect, left=base_query, right=recursive_query, - operation="UNION ALL", + op="union_all", ) # Wrap in a CTE and a WITH query # The columns parameter assigns names to CTE result columns -org_chart_cte = CTEExpression( +org_chart_cte = CTEClause( dialect=dialect, name="org_chart", query=union_all, columns=["id", "name", "manager_id", "department", "level"], ) -recursive_cte_query = WithQueryExpression( +recursive_cte_query = WithStatement( dialect=dialect, ctes=[org_chart_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/distinct.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/distinct.py index bdef5a82f..0e23801bf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/distinct.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/distinct.py @@ -20,17 +20,17 @@ 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 ColumnDefinition, ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -43,7 +43,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["id", "name"], @@ -64,13 +64,13 @@ # SECTION: SELECT DISTINCT (using SelectModifier) # ============================================================ from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, SelectModifier, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from .....expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -87,7 +87,7 @@ # ============================================================ # SECTION: SELECT DISTINCT with multiple columns # ============================================================ -query_distinct = QueryExpression( +query_distinct = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -101,7 +101,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() @@ -110,5 +110,5 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SelectModifier.DISTINCT in QueryExpression +# 1. Use SelectModifier.DISTINCT in QueryStatement # 2. SELECT DISTINCT removes duplicate rows diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/explain.py index eff624d67..c56cf04f8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/explain.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/explain.py @@ -1,3 +1,27 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements.explain import ExplainType, ExplainOptions +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + InsertStatement, + ValuesSource, + QueryStatement, + ExplainStatement, + CreateIndexStatement, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ EXPLAIN and Query Plan analysis - SQLite. @@ -11,33 +35,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, - ValuesSource, - QueryExpression, - ExplainExpression, - CreateIndexExpression, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, Column -from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.statements.explain import ExplainType, ExplainOptions -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType -from .....expression.objects import Table + config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -45,15 +49,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "email", TextType()), @@ -63,7 +67,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect=dialect, index_name="idx_users_email", table_name="users", @@ -79,7 +83,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"], @@ -93,18 +97,17 @@ # ============================================================ # 1. EXPLAIN QUERY PLAN for table scan (no index on name column) -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), where=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), ), ) -explain_scan = ExplainExpression( +explain_scan = ExplainStatement( dialect=dialect, statement=query1, options=ExplainOptions(type=ExplainType.QUERY_PLAN), @@ -117,18 +120,17 @@ print(f" {row}") # 2. EXPLAIN QUERY PLAN for index search (email has index) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), where=ComparisonPredicate( - dialect, "=", Column(dialect, "email"), Literal(dialect, "alice@example.com"), ), ) -explain_search = ExplainExpression( +explain_search = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(type=ExplainType.QUERY_PLAN), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/fulltext.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/fulltext.py index 3f41d7998..434ff677b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/fulltext.py @@ -5,7 +5,7 @@ This example demonstrates using the SQLite dialect's FTS5 formatting methods (format_fts5_create_virtual_table) and the Expression API -(InsertExpression, QueryExpression with SQLiteMatchPredicate) to avoid +(InsertStatement, QueryStatement with SQLiteMatchPredicate) to avoid writing raw SQL strings directly. SQLiteMatchPredicate is a SQLite-specific expression class for full-text search @@ -19,8 +19,8 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig from rhosocial.activerecord.backend.expression import ( - QueryExpression, - WildcardExpression, + QueryStatement, + AllColumnsWildcard, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteMatchPredicate @@ -48,10 +48,10 @@ def execute_match_query(pred: SQLiteMatchPredicate) -> list: - """Build and execute a FTS5 MATCH query using QueryExpression.""" - query = QueryExpression( + """Build and execute a FTS5 MATCH query using QueryStatement.""" + query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "docs")), where=pred, ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/join.py index 874929cbc..c5bb27307 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/join.py @@ -1,3 +1,27 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + TableForeignKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + JoinClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint, TableConstraint +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ JOIN query with multiple tables. """ @@ -5,30 +29,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -users_table = CreateTableExpression( +users_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -36,15 +43,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -53,7 +60,7 @@ sql, params = users_table.to_sql() backend.execute(sql, params) -orders_table = CreateTableExpression( +orders_table = CreateTableStatement( dialect=dialect, table_name="orders", columns=[ @@ -61,20 +68,15 @@ "id", IntegerType(), 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(dialect, "user_id", IntegerType()), ColumnDefinition(dialect, "amount", FloatType()), ], table_constraints=[ - TableConstraint(dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - columns=["user_id"], - foreign_key_table="users", - foreign_key_columns=["id"], - ), + TableConstraint(dialect, branch=TableForeignKeyConstraint(dialect, ["user_id"], "users", ["id"])), ], if_not_exists=True, ) @@ -83,7 +85,7 @@ users = [("Alice",), ("Bob",)] for user in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -98,7 +100,7 @@ (2, 150.0), ] for row in orders: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'orders'), columns=["user_id", "amount"], @@ -110,14 +112,6 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - JoinClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....expression.objects import Table join_expr = JoinClause( dialect=dialect, @@ -125,14 +119,13 @@ right_table=NamedRelationRef(dialect, Table(dialect, "orders"), alias="o"), join_type="LEFT JOIN", condition=ComparisonPredicate( - dialect, "=", Column(dialect, "id", "u"), Column(dialect, "user_id", "o"), ), ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name", "u"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/pagination.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/pagination.py index abd7947f9..7045f76fa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/pagination.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/pagination.py @@ -1,3 +1,27 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Column, AllColumnsWildcard +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + LimitOffsetClause, + OrderByClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, + DropTableStatement, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Pagination using LIMIT/OFFSET - SQLite. @@ -10,29 +34,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, - DropTableExpression, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'articles'), columns=[ @@ -40,8 +48,8 @@ "id", IntegerType(), 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(dialect, "title", TextType()), @@ -65,7 +73,7 @@ ("Recursive CTEs", "Charlie"), ] for title, author in articles: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'articles'), columns=["title", "author"], @@ -77,19 +85,11 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - LimitOffsetClause, - OrderByClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Column, WildcardExpression # noqa: E402 -from .....expression.objects import Table # 1. Basic LIMIT - get first N rows -query_limit = QueryExpression( +query_limit = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "articles")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), limit_offset=LimitOffsetClause(dialect, limit=3), @@ -107,9 +107,9 @@ page_number = 2 offset = (page_number - 1) * page_size -query_page = QueryExpression( +query_page = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "articles")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), limit_offset=LimitOffsetClause(dialect, limit=page_size, offset=offset), @@ -140,9 +140,9 @@ total_pages = -(-len(articles) // page_size) # ceil division for page in range(1, total_pages + 1): offset = (page - 1) * page_size - query = QueryExpression( + query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "articles")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), limit_offset=LimitOffsetClause(dialect, limit=page_size, offset=offset), @@ -154,7 +154,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'articles'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'articles'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/predicate.py index 07179287c..9d464f223 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/predicate.py @@ -1,33 +1,48 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 + LikeOperator, + LikePredicate, + InPredicate, + BetweenPredicate, + IsNullPredicate, + LogicalPredicate, +) +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + WhereClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType """ Complex predicates: LIKE, IN, BETWEEN, IS NULL. """ # ============================================================ # SECTION: Setup (necessary for execution, reference only) -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="products", columns=[ @@ -35,15 +50,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "price", FloatType()), @@ -63,7 +78,7 @@ ("Broccoli", 1.2, "Vegetable", None), ] for row in products_data: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'products'), columns=["name", "price", "category", "description"], @@ -75,31 +90,20 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 - LikePredicate, - InPredicate, - BetweenPredicate, - IsNullPredicate, - LogicalPredicate, -) -from .....expression.objects import Table # Combine multiple predicates: (LIKE pattern) AND (IN list) AND (BETWEEN range) AND (IS NOT NULL) -like_pred = LikePredicate(dialect, "LIKE", Column(dialect, "name"), Literal(dialect, "%a%")) +# The operator is the *word*, not the SQL keyword: the keyword is a spelling +# the dialect owns and never reaches the artifact. +like_pred = LikePredicate( + dialect, LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "%a%") +) in_pred = InPredicate(dialect, Column(dialect, "category"), Literal(dialect, ("Fruit", "Vegetable"))) between_pred = BetweenPredicate(dialect, Column(dialect, "price"), Literal(dialect, 0.5), Literal(dialect, 2.0)) not_null_pred = IsNullPredicate(dialect, Column(dialect, "description"), is_not=True) combined_pred = LogicalPredicate(dialect, "AND", like_pred, in_pred, between_pred, not_null_pred) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/subquery.py index 0a6b526c7..2ca08875d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/subquery.py @@ -1,3 +1,28 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + TableForeignKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Subquery, Literal +from rhosocial.activerecord.backend.expression import functions +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + WhereClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint, TableConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Subquery in WHERE clause and FROM clause. """ @@ -5,30 +30,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -departments_table = CreateTableExpression( +departments_table = CreateTableStatement( dialect=dialect, table_name="departments", columns=[ @@ -36,15 +44,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -53,7 +61,7 @@ sql, params = departments_table.to_sql() backend.execute(sql, params) -employees_table = CreateTableExpression( +employees_table = CreateTableStatement( dialect=dialect, table_name="employees", columns=[ @@ -61,27 +69,22 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "department_id", IntegerType()), ColumnDefinition(dialect, "salary", FloatType()), ], table_constraints=[ - TableConstraint(dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - columns=["department_id"], - foreign_key_table="departments", - foreign_key_columns=["id"], - ), + TableConstraint(dialect, branch=TableForeignKeyConstraint(dialect, ["department_id"], "departments", ["id"])), ], if_not_exists=True, ) @@ -90,7 +93,7 @@ departments = [("Engineering",), ("Sales",)] for dept in departments: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'departments'), columns=["name"], @@ -105,7 +108,7 @@ ("Charlie", 2, 90000), ] for emp in employees: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'employees'), columns=["name", "department_id", "salary"], @@ -117,24 +120,15 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Subquery, Literal, FunctionCall # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....expression.objects import Table # Subquery in WHERE clause: find employees with salary above average -avg_salary_subquery = QueryExpression( +avg_salary_subquery = QueryStatement( dialect=dialect, - select=[FunctionCall(dialect, "AVG", Column(dialect, "salary"))], + select=[functions.avg(dialect, Column(dialect, "salary"))], from_=NamedRelationRef(dialect, Table(dialect, "employees")), ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name"), @@ -144,7 +138,6 @@ where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, ">", Column(dialect, "salary"), Subquery(dialect, avg_salary_subquery), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/union.py index 498cf7acd..eb1c10db2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/union.py @@ -1,5 +1,5 @@ """ -UNION using SetOperationExpression - SQLite. +UNION using SetOperation - SQLite. This example demonstrates: 1. UNION (distinct) @@ -20,17 +20,17 @@ 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 ColumnDefinition, ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ @@ -43,7 +43,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["id", "name"], @@ -60,32 +60,32 @@ backend.execute(sql, params) # ============================================================ -# SECTION: UNION (using SetOperationExpression) +# SECTION: UNION (using SetOperation) # ============================================================ from rhosocial.activerecord.backend.expression import ( - QueryExpression, - SetOperationExpression, + QueryStatement, + SetOperation, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from .....expression.objects import Table # First query -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Literal(dialect, "Charlie")], ) # Union -union_expr = SetOperationExpression( +union_expr = SetOperation( dialect=dialect, left=query1, - operation="UNION", + op="union", right=query2, ) sql, params = union_expr.to_sql() @@ -99,11 +99,10 @@ # ============================================================ # SECTION: UNION ALL # ============================================================ -union_all = SetOperationExpression( +union_all = SetOperation( dialect=dialect, left=query1, - operation="UNION", - all_=True, + op="union_all", right=query2, ) sql, params = union_all.to_sql() @@ -114,7 +113,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() @@ -123,6 +122,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SetOperationExpression with operation='UNION' -# 2. Use operation='UNION', all_=True for UNION ALL +# 1. Use SetOperation with op='union' +# 2. Use op='union_all' for UNION ALL # 3. UNION removes duplicates, UNION ALL keeps all diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/window.py index 75deea50d..d225a9ab1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/query/window.py @@ -1,33 +1,44 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.advanced_functions import ( # noqa: E402 + WindowSpecification, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, + FunctionCall, + OrderByClause, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType """ Window functions: ROW_NUMBER, LAG, LEAD. """ # ============================================================ # SECTION: Setup (necessary for execution, reference only) -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="sales", columns=[ @@ -35,15 +46,15 @@ "id", IntegerType(), 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(dialect, "salesperson", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "region", TextType()), @@ -63,7 +74,7 @@ ("Charlie", "North", 2000, "2024-01-01"), ] for row in sales_data: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'sales'), columns=["salesperson", "region", "amount", "sale_date"], @@ -75,17 +86,6 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, - FunctionCall, - OrderByClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.advanced_functions import ( # noqa: E402 - WindowSpecification, -) -from .....expression.objects import Table window_spec = WindowSpecification( dialect, @@ -94,19 +94,17 @@ ) window_func = FunctionCall( - dialect, "ROW_NUMBER", window_spec=window_spec, alias="row_num", ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "salesperson"), Column(dialect, "amount"), Column(dialect, "sale_date"), - window_func, ], from_=NamedRelationRef(dialect, Table(dialect, "sales")), order_by=OrderByClause( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_column.py index c86b5e02b..3d0a2bbfe 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_column.py @@ -1,3 +1,26 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 + SQLiteSchemaDiffer, +) +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, +) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, + AlterTableStatement, + AddColumnAction, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Schema diff: detect a column added to an existing table. """ @@ -5,28 +28,18 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, - AlterTableExpression, AddColumn, -) -from rhosocial.activerecord.backend.expression.types import ( - IntegerType, TextType, -) config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) backend.introspect_and_adapt() dialect = backend.dialect -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ] ) sql, params = expr.to_sql() @@ -35,20 +48,13 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.schema import ( # noqa: E402 - SyncSchemaSnapshotBuilder, -) -from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 - SQLiteSchemaDiffer, -) -from rhosocial.activerecord.backend.expression.objects import Table builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build(schema="main") # Add a new column -expr = AlterTableExpression(dialect, "users", [ - AddColumn(dialect, ColumnDefinition(dialect, "email", TextType())) +expr = AlterTableStatement(dialect, "users", [ + AddColumnAction(dialect, ColumnDefinition(dialect, "email", TextType())) ]) sql, params = expr.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_table.py index a04c854c7..943c94c5b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/add_table.py @@ -1,3 +1,25 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 + SQLiteSchemaDiffer, +) +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, +) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Schema diff: detect a newly added table. @@ -8,15 +30,6 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.types import ( - IntegerType, TextType, -) config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) @@ -26,27 +39,20 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.schema import ( # noqa: E402 - SyncSchemaSnapshotBuilder, -) -from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 - SQLiteSchemaDiffer, -) -from .....expression.objects import Table # Capture snapshot of the empty database builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build(schema="main") # Create a new table -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", TextType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ] ) sql, params = expr.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/index_change.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/index_change.py index e884c581b..141c984b3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/index_change.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/index_change.py @@ -1,3 +1,25 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 + SQLiteSchemaDiffer, +) +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, FloatType, +) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + CreateIndexStatement, + DropIndexStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Schema diff: detect removed and added indexes between two snapshots. """ @@ -5,26 +27,16 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, CreateIndexExpression, - DropIndexExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.types import ( - IntegerType, TextType, FloatType, -) config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) backend.introspect_and_adapt() dialect = backend.dialect -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'products'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType()), ColumnDefinition(dialect, "price", FloatType()), ] @@ -32,29 +44,22 @@ sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateIndexExpression(dialect, "idx_name", "products", ["name"]) +expr = CreateIndexStatement(dialect, "idx_name", "products", ["name"]) sql, params = expr.to_sql() backend.execute(sql, params) # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.schema import ( # noqa: E402 - SyncSchemaSnapshotBuilder, -) -from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 - SQLiteSchemaDiffer, -) -from rhosocial.activerecord.backend.expression.objects import Table builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build(schema="main") # Drop one index, add another -expr = DropIndexExpression(dialect, "idx_name", "products") +expr = DropIndexStatement(dialect, "idx_name", "products") sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateIndexExpression(dialect, "idx_price", "products", ["price"]) +expr = CreateIndexStatement(dialect, "idx_price", "products", ["price"]) sql, params = expr.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/serialization_roundtrip.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/serialization_roundtrip.py index 97091a817..7725ce602 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/serialization_roundtrip.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/schema_diff/serialization_roundtrip.py @@ -8,26 +8,40 @@ # SECTION: Setup (necessary for execution, reference only) # ============================================================ import json -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 + SQLiteSchemaDiffer, +) +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SchemaSnapshot, + SyncSchemaSnapshotBuilder, ) from rhosocial.activerecord.backend.expression.types import ( IntegerType, TextType, ) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) backend.introspect_and_adapt() dialect = backend.dialect -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'books'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "title", TextType(), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "author", TextType()), ] ) @@ -37,14 +51,6 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.schema import ( # noqa: E402 - SchemaSnapshot, - SyncSchemaSnapshotBuilder, -) -from rhosocial.activerecord.backend.impl.sqlite.schema.differ import ( # noqa: E402 - SQLiteSchemaDiffer, -) -from .....expression.objects import Table builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build(schema="main") diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/basic.py index 924824a1c..0a0a13d07 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/basic.py @@ -1,3 +1,25 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + InsertStatement, + ValuesSource, + UpdateStatement, + QueryStatement, + WhereClause, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Basic transaction control using transaction manager. """ @@ -5,35 +27,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, - ValuesSource, - UpdateExpression, - QueryExpression, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -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, -) -from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 -from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType -from .....expression.objects import Table -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="accounts", columns=[ @@ -41,14 +41,14 @@ "id", IntegerType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition(dialect, "balance", FloatType()), @@ -59,12 +59,11 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'accounts'), columns=["name", "balance"], source=ValuesSource( - dialect, [ [Literal(dialect, "Alice"), Literal(dialect, 100)], ], @@ -85,14 +84,13 @@ # SECTION: Execution (run the expression) # ============================================================ with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, 'accounts'), assignments={"balance": Literal(dialect, 50)}, where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), @@ -103,14 +101,13 @@ backend.execute(sql, params) # Verify -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/exclusive.py index 1b4228ab1..e3f6003f5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/exclusive.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/exclusive.py @@ -1,9 +1,36 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement +from rhosocial.activerecord.backend.transaction import IsolationLevel +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, + UpdateStatement, + ValuesSource, + WhereClause, +) """ SQLite transaction modes. This example demonstrates: 1. DEFERRED and IMMEDIATE transaction behavior via transaction manager settings -2. EXCLUSIVE transaction mode via BeginTransactionExpression.begin_type() +2. EXCLUSIVE transaction mode via BeginTransactionStatement.with_begin_type() 3. Nested transactions with automatic savepoints 4. WAL mode for better concurrency """ @@ -11,28 +38,6 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, - UpdateExpression, - ValuesSource, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Column, Literal -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraint, - ColumnConstraintType, - ColumnDefinition, -) -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.backend.transaction import IsolationLevel config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) @@ -51,7 +56,7 @@ def execute_expression(expression, options=None): def update_balance(name, amount): - return UpdateExpression( + return UpdateStatement( dialect=dialect, table=Table(dialect, 'accounts'), assignments={ @@ -60,7 +65,6 @@ def update_balance(name, amount): where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, name), @@ -70,7 +74,7 @@ def update_balance(name, amount): def fetch_balances(): - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), @@ -78,19 +82,19 @@ def fetch_balances(): return execute_expression(query, dql_options).data -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'accounts'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", TextType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition(dialect, "balance", FloatType()), ], @@ -98,12 +102,11 @@ def fetch_balances(): ) execute_expression(create_table, ddl_options) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'accounts'), columns=["name", "balance"], source=ValuesSource( - dialect, [ [Literal(dialect, "Alice"), Literal(dialect, 1000)], [Literal(dialect, "Bob"), Literal(dialect, 500)], @@ -132,10 +135,8 @@ def fetch_balances(): # This is different from PRAGMA locking_mode: # - BEGIN EXCLUSIVE: exclusive lock only for this transaction # - PRAGMA locking_mode=EXCLUSIVE: persistent lock across all transactions -from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression # noqa: E402 -from .....expression.objects import Table -exclusive_begin = BeginTransactionExpression(dialect).begin_type("EXCLUSIVE") +exclusive_begin = BeginTransactionStatement(dialect).with_begin_type("EXCLUSIVE") sql, params = exclusive_begin.to_sql() print(f"EXCLUSIVE BEGIN SQL: {sql}") @@ -171,7 +172,7 @@ def fetch_balances(): # ============================================================ # SECTION: Teardown # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, 'accounts'), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, 'accounts'), if_exists=True) execute_expression(drop_table, ddl_options) backend.disconnect() @@ -181,7 +182,7 @@ def fetch_balances(): # Key points: # 1. READ_UNCOMMITTED maps to SQLite's DEFERRED transaction behavior # 2. SERIALIZABLE maps to SQLite's IMMEDIATE transaction behavior -# 3. Use BeginTransactionExpression.begin_type("EXCLUSIVE") for exclusive transactions +# 3. Use BeginTransactionStatement.with_begin_type("EXCLUSIVE") for exclusive transactions # 4. Or set transaction_manager.begin_type = "EXCLUSIVE" for all subsequent transactions # 5. Nested backend.transaction() calls use savepoints automatically # 6. WAL mode improves concurrent read/write behavior diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/for_update.py index f86a03c5d..7bc0c40e2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/transaction/for_update.py @@ -1,3 +1,32 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + CreateTableStatement, + DropTableStatement, + InsertStatement, + ValuesSource, + UpdateStatement, + WhereClause, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ FOR UPDATE row locking with SQLite limitations. @@ -8,32 +37,7 @@ 4. How to check dialect capability before using FOR UPDATE """ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - CreateTableExpression, - DropTableExpression, - InsertExpression, - ValuesSource, - UpdateExpression, - WhereClause, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import Literal, Column -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause -from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType -from .....expression.objects import Table + # ============================================================ # SECTION: Setup (necessary for execution, reference only) @@ -46,19 +50,19 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'accounts'), columns=[ ColumnDefinition(dialect, "id", IntegerType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", TextType(), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition(dialect, "balance", FloatType()), ], @@ -67,12 +71,11 @@ sql, params = create_table.to_sql() backend.execute(sql, params, options=ddl_options) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, 'accounts'), columns=["name", "balance"], source=ValuesSource( - dialect, [ [Literal(dialect, "Alice"), Literal(dialect, 1000)], [Literal(dialect, "Bob"), Literal(dialect, 500)], @@ -97,7 +100,6 @@ # FOR UPDATE with OF columns for_update_of = ForUpdateClause( - dialect, of_columns=[Column(dialect, "id"), "balance"], ) sql, params = for_update_of.to_sql() @@ -114,17 +116,16 @@ print(f"SQLite supports FOR UPDATE: {dialect.supports_for_update()}") print(f"SQLite supports FOR UPDATE SKIP LOCKED: {dialect.supports_for_update_skip_locked()}") -# --- 3. Using ForUpdateClause in QueryExpression raises UnsupportedFeatureError --- -# When you include for_update in a QueryExpression, the dialect check +# --- 3. Using ForUpdateClause in QueryStatement raises UnsupportedFeatureError --- +# When you include for_update in a QueryStatement, the dialect check # will raise UnsupportedFeatureError at SQL generation time. -query_with_for_update = QueryExpression( +query_with_for_update = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), @@ -143,12 +144,12 @@ # transaction start, preventing other writers. # BEGIN IMMEDIATE: acquires RESERVED lock (allows readers, blocks writers) -immediate_begin = BeginTransactionExpression(dialect).begin_type("IMMEDIATE") +immediate_begin = BeginTransactionStatement(dialect).with_begin_type("IMMEDIATE") sql, params = immediate_begin.to_sql() print(f"BEGIN IMMEDIATE SQL: {sql}") # BEGIN EXCLUSIVE: acquires EXCLUSIVE lock (blocks both readers and writers) -exclusive_begin = BeginTransactionExpression(dialect).begin_type("EXCLUSIVE") +exclusive_begin = BeginTransactionStatement(dialect).with_begin_type("EXCLUSIVE") sql, params = exclusive_begin.to_sql() print(f"BEGIN EXCLUSIVE SQL: {sql}") @@ -159,14 +160,13 @@ # Pattern: Check dialect capability before using FOR UPDATE if dialect.supports_for_update(): # This branch runs on MySQL/PostgreSQL but NOT SQLite - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), @@ -182,14 +182,13 @@ backend.transaction_manager.begin_type = "IMMEDIATE" with backend.transaction(): # Read within the transaction (RESERVED lock held) - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), @@ -202,14 +201,13 @@ print(f" Read balance: {result.data[0]['balance']}") # Update within the same transaction - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, 'accounts'), assignments={"balance": Literal(dialect, 950)}, where=WhereClause( dialect, condition=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), @@ -224,7 +222,7 @@ backend.transaction_manager.begin_type = None # Verify the update -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), @@ -237,7 +235,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, options=ddl_options) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/types/json_basic.py index e9586822c..c4086d533 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/types/json_basic.py @@ -1,3 +1,25 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import functions as F +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import ( + QueryStatement, + Column, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ JSON operations using JSON functions. """ @@ -5,28 +27,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="documents", columns=[ @@ -34,8 +41,8 @@ "id", IntegerType(), 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(dialect, "data", TextType()), @@ -52,7 +59,7 @@ {"name": "Bob", "age": 25, "tags": ["c"]}, ] for data in insert_data: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'documents'), columns=["data"], @@ -64,19 +71,12 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - QueryExpression, - Column, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal # noqa: E402 -from .....expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), - FunctionCall(dialect, "json_extract", Column(dialect, "data"), Literal(dialect, "$.name")), + F.json_extract(dialect, Column(dialect, "data"), Literal(dialect, "$.name")), ], from_=NamedRelationRef(dialect, Table(dialect, "documents")), ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/sqlite/examples/update/basic.py index bb1c2e5e2..f14b1be66 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/examples/update/basic.py @@ -1,3 +1,23 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from .....expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression import ( + UpdateStatement, + ReturningClause, + Column, +) +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, ColumnConstraint +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + CreateTableStatement, + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend """ Update records and return the updated IDs using RETURNING clause. """ @@ -5,26 +25,13 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig config = SQLiteConnectionConfig(database=":memory:") backend = SQLiteBackend(config) dialect = backend.dialect -from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, - ValuesSource, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table_name="users", columns=[ @@ -32,15 +39,15 @@ "id", IntegerType(), 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(dialect, "name", TextType(), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -49,7 +56,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -61,21 +68,12 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import ( - UpdateExpression, - ReturningClause, - Column, -) -from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from .....expression.objects import Table -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, 'users'), assignments={"name": Literal(dialect, "Alice Smith")}, where=ComparisonPredicate( - dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/__init__.py index b096872cb..3edff0e3c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/__init__.py @@ -6,7 +6,7 @@ such as column info expressions, table list expressions, and REINDEX statement. Directory structure: -- introspection.py - Column info expression +- introspection.py - Column info expression, PRAGMA statements, table count and DDL reads - table_list.py - Table list expression - reindex.py - REINDEX expression - predicates.py - FTS MATCH predicate expression @@ -15,23 +15,31 @@ - geopoly.py - Geopoly expression classes """ -from .introspection import SQLiteColumnInfoExpression +from .introspection import ( + SQLiteColumnInfoExpression, + SQLiteIndexDdlStatement, + SQLitePragmaQueryStatement, + SQLitePragmaSetStatement, + SQLiteTableCountStatement, + SQLiteTableDdlStatement, +) +from .table import SQLiteTable from .table_list import SQLiteTableListExpression -from .reindex import SQLiteReindexExpression -from .vacuum import SQLiteVacuumExpression, SQLiteAnalyzeExpression -from .attach import SQLiteAttachExpression, SQLiteDetachExpression +from .reindex import SQLiteReindexStatement +from .vacuum import SQLiteVacuumStatement, SQLiteAnalyzeStatement +from .attach import SQLiteAttachStatement, SQLiteDetachStatement from .predicates import SQLiteMatchPredicate -from .dml import SQLiteInsertExpression +from .dml import SQLiteInsertStatement from .fts5 import ( - SQLiteFTS5MatchExpression, + SQLiteFTS5MatchPredicate, SQLiteFTS5CreateVirtualTable, SQLiteFTS5RankExpression, SQLiteFTS5HighlightExpression, SQLiteFTS5SnippetExpression, ) from .virtual_table import ( - CreateVirtualTableExpression, - DropVirtualTableExpression, + CreateVirtualTableStatement, + DropVirtualTableStatement, ) from .rtree import ( SQLiteRTreeCreateVirtualTable, @@ -42,55 +50,77 @@ SQLiteGeopolyContainsExpression, SQLiteGeopolyAreaExpression, ) +from .json import SQLiteJsonExtractFunction + +# ``SQLite`` is the backend identifier spelled as §4.9.4 requires, and it is a +# compound word: the general capital-run rule would derive ``sq_lite_json_extract_function`` +# instead of ``sqlite_json_extract_function``. Since a kind is part of the wire +# contract, the identifier is kept whole — and this is the SQLite package's own +# declaration, made where it assembles its own expressions. The core holds no +# backend names, so another backend declaring its own tokens needs no change here +# or in the core (§5.1: rhosocial's own packages get no privilege). +for _sqlite_expression in ( + SQLiteColumnInfoExpression, + SQLiteIndexDdlStatement, + SQLitePragmaQueryStatement, + SQLitePragmaSetStatement, + SQLiteTableCountStatement, + SQLiteTableDdlStatement, + SQLiteTable, + SQLiteTableListExpression, + SQLiteReindexStatement, + SQLiteVacuumStatement, + SQLiteAnalyzeStatement, + SQLiteAttachStatement, + SQLiteDetachStatement, + SQLiteMatchPredicate, + SQLiteInsertStatement, + SQLiteFTS5MatchPredicate, + SQLiteFTS5CreateVirtualTable, + SQLiteFTS5RankExpression, + SQLiteFTS5HighlightExpression, + SQLiteFTS5SnippetExpression, + SQLiteRTreeCreateVirtualTable, + SQLiteRTreeRangeQuery, + SQLiteGeopolyCreateVirtualTable, + SQLiteGeopolyContainsExpression, + SQLiteGeopolyAreaExpression, + SQLiteJsonExtractFunction, +): + _sqlite_expression.KIND_RESERVED = ("SQLite",) +del _sqlite_expression __all__ = [ "SQLiteColumnInfoExpression", + "SQLiteIndexDdlStatement", + "SQLitePragmaQueryStatement", + "SQLitePragmaSetStatement", + "SQLiteTableCountStatement", + "SQLiteTableDdlStatement", + "SQLiteTable", "SQLiteTableListExpression", - "SQLiteReindexExpression", - "SQLiteVacuumExpression", - "SQLiteAnalyzeExpression", - "SQLiteAttachExpression", - "SQLiteDetachExpression", + "SQLiteReindexStatement", + "SQLiteVacuumStatement", + "SQLiteAnalyzeStatement", + "SQLiteAttachStatement", + "SQLiteDetachStatement", "SQLiteMatchPredicate", - "SQLiteInsertExpression", - "SQLiteFTS5MatchExpression", + "SQLiteInsertStatement", + "SQLiteFTS5MatchPredicate", "SQLiteFTS5CreateVirtualTable", "SQLiteFTS5RankExpression", "SQLiteFTS5HighlightExpression", "SQLiteFTS5SnippetExpression", - "CreateVirtualTableExpression", - "DropVirtualTableExpression", + "CreateVirtualTableStatement", + "DropVirtualTableStatement", "SQLiteRTreeCreateVirtualTable", "SQLiteRTreeRangeQuery", "SQLiteGeopolyCreateVirtualTable", "SQLiteGeopolyContainsExpression", "SQLiteGeopolyAreaExpression", + "SQLiteJsonExtractFunction", ] -# Auto-register all SQLite expression classes when this module is imported -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry - -for _expr_cls in ( - SQLiteColumnInfoExpression, - SQLiteTableListExpression, - SQLiteReindexExpression, - SQLiteVacuumExpression, - SQLiteAnalyzeExpression, - SQLiteAttachExpression, - SQLiteDetachExpression, - SQLiteMatchPredicate, - SQLiteInsertExpression, - SQLiteFTS5MatchExpression, - SQLiteFTS5CreateVirtualTable, - SQLiteFTS5RankExpression, - SQLiteFTS5HighlightExpression, - SQLiteFTS5SnippetExpression, - CreateVirtualTableExpression, - DropVirtualTableExpression, - SQLiteRTreeCreateVirtualTable, - SQLiteRTreeRangeQuery, - SQLiteGeopolyCreateVirtualTable, - SQLiteGeopolyContainsExpression, - SQLiteGeopolyAreaExpression, -): - ExpressionRegistry.register(_expr_cls) +# No registration loop: the framework's assembly point walks this package and +# registers every expression defined in it, so a hand-maintained list here could +# only ever be out of date. diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/attach.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/attach.py index 5b2082fe3..5277f42f7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/attach.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/attach.py @@ -1,22 +1,33 @@ # src/rhosocial/activerecord/backend/impl/sqlite/expression/attach.py """ -SQLite-specific ATTACH DATABASE and DETACH DATABASE expressions. +SQLite-specific ATTACH DATABASE and DETACH DATABASE statements. -This module provides SQLiteAttachExpression and SQLiteDetachExpression. +This module provides SQLiteAttachStatement and SQLiteDetachStatement. """ from typing import TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression if TYPE_CHECKING: from ....dialect import SQLDialectBase -__all__ = ["SQLiteAttachExpression", "SQLiteDetachExpression"] +__all__ = ["SQLiteAttachStatement", "SQLiteDetachStatement"] -class SQLiteAttachExpression(BaseExpression): - """SQLite ATTACH DATABASE statement expression. +class SQLiteAttachStatement(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression. + + Declared rather than reached through a ``to_sql`` override: the node + names its formatter and the dialect supplies it. A node that calls the + dialect itself keeps the lookup inside the node, which is the direction + this architecture removes. + """ + return "format_attach_statement" + """SQLite ATTACH DATABASE statement. ATTACH DATABASE attaches another database file to the current connection under the given schema name, enabling cross-database @@ -27,7 +38,7 @@ class SQLiteAttachExpression(BaseExpression): Examples: # Attach a database file as the 'aux' schema - attach = SQLiteAttachExpression(dialect, database="aux.db", schema="aux") + attach = SQLiteAttachStatement(dialect, database="aux.db", schema="aux") """ def __init__( @@ -36,7 +47,7 @@ def __init__( database: str, schema: str, ): - """Initialize an ATTACH DATABASE expression. + """Initialize an ATTACH DATABASE statement. Args: dialect: The SQL dialect instance. @@ -53,18 +64,19 @@ def __init__( super().__init__(dialect) self.database = database self.schema = schema +class SQLiteDetachStatement(BaseExpression): - def to_sql(self) -> SQLQueryAndParams: - """Generate SQL for the ATTACH DATABASE statement. + @property + def format_method(self) -> str: + """The dialect method that renders this expression. - Returns: - Tuple of (SQL string, empty parameters tuple). + Declared rather than reached through a ``to_sql`` override: the node + names its formatter and the dialect supplies it. A node that calls the + dialect itself keeps the lookup inside the node, which is the direction + this architecture removes. """ - return self._dialect.format_attach_statement(self) - - -class SQLiteDetachExpression(BaseExpression): - """SQLite DETACH DATABASE statement expression. + return "format_detach_statement" + """SQLite DETACH DATABASE statement. DETACH DATABASE detaches a previously attached database schema from the current connection. It is a SQLite-specific statement. @@ -74,7 +86,7 @@ class SQLiteDetachExpression(BaseExpression): Examples: # Detach the 'aux' schema - detach = SQLiteDetachExpression(dialect, schema="aux") + detach = SQLiteDetachStatement(dialect, schema="aux") """ def __init__( @@ -82,7 +94,7 @@ def __init__( dialect: "SQLDialectBase", schema: str, ): - """Initialize a DETACH DATABASE expression. + """Initialize a DETACH DATABASE statement. Args: dialect: The SQL dialect instance. @@ -95,11 +107,3 @@ def __init__( raise ValueError("DETACH DATABASE requires a schema name") super().__init__(dialect) self.schema = schema - - def to_sql(self) -> SQLQueryAndParams: - """Generate SQL for the DETACH DATABASE statement. - - Returns: - Tuple of (SQL string, empty parameters tuple). - """ - return self._dialect.format_detach_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/dml.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/dml.py index 8a4675982..77794ea17 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/dml.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional -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 @@ -11,7 +11,7 @@ from rhosocial.activerecord.backend.expression.statements.dml import InsertDataSource -class SQLiteInsertExpression(InsertExpression): +class SQLiteInsertStatement(InsertStatement): """A SQLite INSERT statement extending the generic one. SQLite supports the ``INSERT OR REPLACE`` / ``INSERT OR IGNORE`` conflict @@ -44,5 +44,5 @@ def __init__( __all__ = [ - "SQLiteInsertExpression", + "SQLiteInsertStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/fts5.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/fts5.py index 0b2731282..95d9039df 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/fts5.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/fts5.py @@ -10,21 +10,26 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLPredicate, SQLValueExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression, SQLPredicate, SQLValue from rhosocial.activerecord.backend.expression.objects import Table if TYPE_CHECKING: from ....dialect import SQLDialectBase -class SQLiteFTS5MatchExpression(SQLPredicate): +class SQLiteFTS5MatchPredicate(SQLPredicate): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_fts5_match_expression" """SQLite FTS5 MATCH predicate expression. Wraps the parameters for an FTS5 MATCH expression so the format method receives a single expression object instead of individual arguments. Example: - >>> expr = SQLiteFTS5MatchExpression(dialect, table='docs', query='Python') + >>> expr = SQLiteFTS5MatchPredicate(dialect, table='docs', query='Python') >>> expr.to_sql() ('"docs" MATCH ?', ('Python',)) """ @@ -42,12 +47,12 @@ def __init__( self.query = query self.columns = columns self.negate = negate - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_fts5_match_expression(self) - - class SQLiteFTS5CreateVirtualTable(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_fts5_create_virtual_table" """FTS5 CREATE VIRTUAL TABLE statement expression.""" def __init__( @@ -76,12 +81,12 @@ def __init__( self.content = content self.content_rowid = content_rowid self.tokenize = tokenize +class SQLiteFTS5RankExpression(SQLValue): - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_fts5_create_virtual_table(self) - - -class SQLiteFTS5RankExpression(SQLValueExpression): + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_fts5_rank_expression" """FTS5 BM25 ranking expression for relevance scoring in ORDER BY clauses.""" def __init__( @@ -100,12 +105,12 @@ def __init__( self.table = table self.weights = weights self.bm25_params = bm25_params +class SQLiteFTS5HighlightExpression(SQLValue): - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_fts5_rank_expression(self) - - -class SQLiteFTS5HighlightExpression(SQLValueExpression): + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_fts5_highlight_expression" """FTS5 highlight() function expression for marking matched terms.""" def __init__( @@ -126,12 +131,12 @@ def __init__( self.column = column self.prefix_marker = prefix_marker self.suffix_marker = suffix_marker +class SQLiteFTS5SnippetExpression(SQLValue): - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_fts5_highlight_expression(self) - - -class SQLiteFTS5SnippetExpression(SQLValueExpression): + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_fts5_snippet_expression" """FTS5 snippet() function expression for showing context around matched terms.""" def __init__( @@ -156,6 +161,3 @@ def __init__( self.suffix_marker = suffix_marker self.context_tokens = context_tokens self.ellipsis = ellipsis - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_fts5_snippet_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/geopoly.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/geopoly.py index fb00f46bc..01e6dbbbe 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/geopoly.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/geopoly.py @@ -8,7 +8,7 @@ from typing import List, Optional, TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table if TYPE_CHECKING: @@ -16,13 +16,22 @@ class SQLiteGeopolyCreateVirtualTable(BaseExpression): - """Geopoly CREATE VIRTUAL TABLE statement expression.""" + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_geopoly_create_virtual_table" + """Geopoly CREATE VIRTUAL TABLE statement expression. + + Geopoly has no external content table: the polygons are the rows of the + table itself, and ``extra_columns`` hold the caller's own auxiliary + data. So there is no content table to name, and no parameter for one. + """ def __init__( self, dialect: "SQLDialectBase", table: "Table", - content_table: Optional["Table"] = None, extra_columns: Optional[List[str]] = None, ): super().__init__(dialect) @@ -32,19 +41,13 @@ def __init__( f"got {type(table).__name__}" ) self.table = table - if content_table is not None and not isinstance(content_table, Table): - raise TypeError( - f"content_table must be a Table, " - f"got {type(content_table).__name__}" - ) - self.content_table = content_table self.extra_columns = extra_columns or [] - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_geopoly_create_virtual_table(self) - - class SQLiteGeopolyContainsExpression(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_geopoly_contains_expression" """Geopoly point-in-polygon query expression.""" def __init__( @@ -63,12 +66,12 @@ def __init__( self.table = table self.longitude = longitude self.latitude = latitude - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_geopoly_contains_query(self) - - class SQLiteGeopolyAreaExpression(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_geopoly_area_expression" """Geopoly area calculation expression.""" def __init__( @@ -83,6 +86,3 @@ def __init__( f"got {type(table).__name__}" ) self.table = table - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_geopoly_area_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/introspection.py index dcaf7ca6a..2c28a1278 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/introspection.py @@ -1,23 +1,34 @@ # src/rhosocial/activerecord/backend/impl/sqlite/expression/introspection.py """ -SQLite-specific column info expression. +SQLite-specific introspection expressions. -This module provides SQLiteColumnInfoExpression which extends the base -ColumnInfoExpression with SQLite-specific parameters. +This module carries the introspection questions SQLite answers in its own +vocabulary: + +- ``PRAGMA``: a statement of its own, expressed by no portable grammar node, + so it has two statement classes and two formatters on the SQLite dialect. +- column information with hidden columns, which PRAGMA ``table_xinfo`` reports + and PRAGMA ``table_info`` does not. +- the table count and the stored DDL of a table or index, which live in + ``sqlite_master``: SQLite keeps the statement text that created each object, + and no portable catalogue holds it. + +They live here rather than in core: core carries the families every engine +shares, and these are not among them. """ -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING -from ....expression.introspection import ColumnInfoExpression +from ....expression.introspection import ColumnInfoQueryStatement, IntrospectionExpression if TYPE_CHECKING: from ....dialect import SQLDialectBase -class SQLiteColumnInfoExpression(ColumnInfoExpression): +class SQLiteColumnInfoExpression(ColumnInfoQueryStatement): """SQLite column information expression. - Extends ColumnInfoExpression with SQLite-specific parameters. + Extends ColumnInfoQueryStatement with SQLite-specific parameters. Args: dialect: The SQL dialect to use for SQL generation. @@ -38,3 +49,172 @@ def __init__( ): super().__init__(dialect, table_name, schema, include_hidden) self.use_xinfo_pragma = use_xinfo_pragma + + +class SQLitePragmaQueryStatement(IntrospectionExpression): + """A ``PRAGMA`` question: what does this pragma currently answer? + + PRAGMA accepts no bind parameters, so everything the engine needs is in the + statement text. The ``argument`` is therefore carried in the spelling the + engine expects (a bare name, a quoted identifier) and placed verbatim by + the formatter; it is never re-quoted here. + + Args: + dialect: The SQL dialect to use for SQL generation. + name: PRAGMA name, such as ``"journal_mode"``. + argument: Optional argument the pragma takes, already spelled for the + engine (for example ``"'users'"`` for ``table_info``). + schema: Optional attached-database name, such as ``"temp"``. + + Example: + >>> expr = SQLitePragmaQueryStatement(dialect, "table_info", "'users'") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + argument: Optional[Any] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.name: str = name + self.argument: Optional[Any] = argument + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pragma_query_statement" + + +class SQLitePragmaSetStatement(IntrospectionExpression): + """A ``PRAGMA`` assignment: give this pragma a value. + + Like :class:`SQLitePragmaQueryStatement`, this carries no bind parameters: + the formatter decides how the value is written, because PRAGMA takes its + value as statement text. + + Args: + dialect: The SQL dialect to use for SQL generation. + name: PRAGMA name, such as ``"journal_mode"``. + value: The value to assign. + argument: Optional argument the pragma takes, already spelled for the + engine. + schema: Optional attached-database name, such as ``"main"``. + + Example: + >>> expr = SQLitePragmaSetStatement(dialect, "journal_mode", "WAL") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + value: Any, + argument: Optional[Any] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.name: str = name + self.value: Any = value + self.argument: Optional[Any] = argument + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pragma_set_statement" + + +class SQLiteTableCountStatement(IntrospectionExpression): + """How many tables one database declares, system tables excluded. + + The count is a question about ``sqlite_master``, whose name is this + backend's own vocabulary; the dialect formats it as a ``SELECT COUNT(*)`` + over that relation rather than as a PRAGMA, because SQLite offers no + PRAGMA that counts tables without listing them first. + + Args: + dialect: The SQL dialect to use for SQL generation. + schema: The database to count, such as ``"main"`` or ``"temp"``. + Defaults to the primary database. + + Example: + >>> expr = SQLiteTableCountStatement(dialect, "main") + >>> sql, params = expr.to_sql() + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_count_statement" + + +class SQLiteTableDdlStatement(IntrospectionExpression): + """The DDL a table was declared with, as ``sqlite_master`` stores it. + + SQLite keeps the statement text that created each object, so the DDL is + read back rather than reassembled from column and constraint facts. The + table is matched by both ``name`` and ``tbl_name``: an index row carries + the indexed table in ``tbl_name``, and a table row carries its own name in + both, so requiring both selects the table row alone. + + Args: + dialect: The SQL dialect to use for SQL generation. + table_name: The table whose DDL is read. + schema: The database holding the table, such as ``"main"``. + Defaults to the primary database. + + Example: + >>> expr = SQLiteTableDdlStatement(dialect, "users") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_ddl_statement" + + +class SQLiteIndexDdlStatement(IntrospectionExpression): + """The DDL an index was declared with, as ``sqlite_master`` stores it. + + A partial index's filter condition exists nowhere else: PRAGMA + ``index_list`` reports only that the index is partial, and ``index_xinfo`` + reports its columns, so the ``WHERE`` clause has to be read back from the + statement text. + + Args: + dialect: The SQL dialect to use for SQL generation. + index_name: The index whose DDL is read. + schema: The database holding the index, such as ``"main"``. + Defaults to the primary database. + + Example: + >>> expr = SQLiteIndexDdlStatement(dialect, "idx_users_email") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.index_name: str = index_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_index_ddl_statement" diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/json.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/json.py new file mode 100644 index 000000000..56f2dd8cf --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/json.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/backend/impl/sqlite/expression/json.py +"""SQLite-specific JSON expression nodes.""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValue +from rhosocial.activerecord.backend.expression.mixins import ( + AliasableMixin, + ComparisonMixin, + TypeCastingMixin, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SQLiteJsonExtractFunction( + AliasableMixin, + ComparisonMixin, + TypeCastingMixin, + SQLValue, +): + """``json_extract(expression, path)`` — SQLite's single-path extraction. + + This is a **backend-specific node** and lives in the SQLite implementation + package, not the core expression layer (catalog §3.4): the same concept is + spelled ``->`` on PostgreSQL and ``JSON_EXTRACT`` with a path grammar of + its own on MySQL, so the core must not reserve a slot for any one backend's + spelling. + + It exists because ``json_extract`` is a real function with its own + rendering, and building it through :class:`FunctionCall` would leave the + function's identity inside a name string. The path is an expression + (usually a string literal), so it binds like any other operand and never + reaches the SQL as hand-concatenated text. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + expression: "BaseExpression", + path: "BaseExpression", + ): + super().__init__(dialect) + self.expression = expression + self.path = path + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_sqlite_json_extract_function" diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/predicates.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/predicates.py index 6cda5580b..6a6b88907 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/predicates.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/predicates.py @@ -12,10 +12,14 @@ if TYPE_CHECKING: from ......dialect.base import SQLDialectBase - from ....expression.bases import SQLQueryAndParams class SQLiteMatchPredicate(SQLPredicate): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_match_predicate" """SQLite FTS MATCH predicate expression. This predicate is used for full-text search operations in SQLite's @@ -59,11 +63,3 @@ def __init__( self.query = query self.columns = columns self.negate = negate - - def to_sql(self) -> "SQLQueryAndParams": - """Format the MATCH predicate using SQLite dialect. - - Returns: - Tuple of (SQL string, parameters tuple). - """ - return self.dialect.format_match_predicate(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/reindex.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/reindex.py index 26640dd26..d6b7f18ae 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/reindex.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/reindex.py @@ -2,19 +2,30 @@ """ SQLite-specific REINDEX expression. -This module provides SQLiteReindexExpression for rebuilding indexes. +This module provides SQLiteReindexStatement for rebuilding indexes. """ from typing import Optional, TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression from ....expression.objects import Index, Table if TYPE_CHECKING: from ....dialect import SQLDialectBase -class SQLiteReindexExpression(BaseExpression): +class SQLiteReindexStatement(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression. + + Declared rather than reached through a ``to_sql`` override: the node + names its formatter and the dialect supplies it. A node that calls the + dialect itself keeps the lookup inside the node, which is the direction + this architecture removes. + """ + return "format_reindex_statement" """SQLite REINDEX statement expression. REINDEX is a SQLite-specific statement for rebuilding indexes. @@ -25,16 +36,16 @@ class SQLiteReindexExpression(BaseExpression): Examples: # Rebuild all indexes on a table - reindex = SQLiteReindexExpression(dialect, table=Table(dialect, "users")) + reindex = SQLiteReindexStatement(dialect, table=Table(dialect, "users")) # Rebuild a specific index - reindex = SQLiteReindexExpression(dialect, index=Index(dialect, "idx_users_email")) + reindex = SQLiteReindexStatement(dialect, index=Index(dialect, "idx_users_email")) # Rebuild all expression indexes (SQLite 3.53.0+) - reindex = SQLiteReindexExpression(dialect, expressions=True) + reindex = SQLiteReindexStatement(dialect, expressions=True) # Rebuild all indexes in the database - reindex = SQLiteReindexExpression(dialect) + reindex = SQLiteReindexStatement(dialect) """ def __init__( @@ -76,11 +87,3 @@ def __init__( ) self.table = table self.expressions = expressions - - def to_sql(self) -> SQLQueryAndParams: - """Generate SQL for REINDEX statement. - - Returns: - Tuple of (SQL string, empty parameters tuple). - """ - return self._dialect.format_reindex_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/rtree.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/rtree.py index a7d59ee35..02e31afed 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/rtree.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/rtree.py @@ -8,7 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table if TYPE_CHECKING: @@ -16,6 +16,11 @@ class SQLiteRTreeCreateVirtualTable(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_rtree_create_virtual_table" """R-Tree CREATE VIRTUAL TABLE statement expression.""" def __init__( @@ -41,12 +46,12 @@ def __init__( ) self.content_table = content_table self.content_rowid = content_rowid - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_rtree_create_virtual_table(self) - - class SQLiteRTreeRangeQuery(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression.""" + return "format_rtree_range_query" """R-Tree range query expression for spatial filtering in WHERE clauses.""" def __init__( @@ -65,6 +70,3 @@ def __init__( self.table = table self.ranges = ranges self.column_names = column_names - - def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_rtree_range_query(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/table.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/table.py new file mode 100644 index 000000000..b52784399 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/table.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/sqlite/expression/table.py +"""SQLite's table, which carries a schema of its own kind. + +SQLite has no schema in the SQL-standard sense -- it has no ``CREATE SCHEMA``, +no persistent schema objects, and nothing between catalogue and table except the +name of an *attached database*, which is session state rather than a catalogue +entry. The core :class:`~...expression.objects.Table` therefore refuses a +``schema_name`` on this backend, and that refusal is correct: it is about the +standard construct. + +This subclass exists so the standard construct and SQLite's construct are not +forced to share one slot's acceptance rule. It carries ``schema_name`` because +the *slot* is the right slot -- a level between catalogue and table is what +SQLite has -- while the *rule* for what may occupy it is this dialect's to write. + +So the distinction is by node identity rather than by a capability flag: + +* a **core** ``Table`` with ``schema_name`` is refused, because SQLite has no + schema of that kind; +* a **SQLiteTable** with ``schema_name`` renders, because it stands for an + attached-database name, which SQLite does have. + +Conflating them would lose the fact rather than record it. A single +accommodating flag would make SQLite look as though it supports schemas, and the +next reader would have no way to tell which construct a ``schema_name`` meant. +""" + +from ....expression.objects.relation import Table + +__all__ = ["SQLiteTable"] + + +class SQLiteTable(Table): + """A table inside a SQLite schema -- the attached-database name. + + Adds no slots: a schema is the one namespace level SQLite has, and the + inherited ``schema_name`` already carries it. The class exists for its + *identity*, which is what tells the dialect's object-name formatter that + this ``schema_name`` is an attached database rather than a standard schema. + + Example: + >>> from rhosocial.activerecord.backend.impl.sqlite.dialect import ( + ... SQLiteDialect, + ... ) + >>> d = SQLiteDialect() + >>> SQLiteTable(d, "sqlite_master", schema_name="main").to_sql() + ('main."sqlite_master"', ()) + """ + + __slots__ = () diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/table_list.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/table_list.py index 2d20aef85..7c10dd32e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/table_list.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/table_list.py @@ -3,21 +3,21 @@ SQLite-specific table list expression. This module provides SQLiteTableListExpression which extends the base -TableListExpression with SQLite-specific parameters. +TableListQueryStatement with SQLite-specific parameters. """ from typing import Optional, TYPE_CHECKING -from ....expression.introspection import TableListExpression +from ....expression.introspection import TableListQueryStatement if TYPE_CHECKING: from ....dialect import SQLDialectBase -class SQLiteTableListExpression(TableListExpression): +class SQLiteTableListExpression(TableListQueryStatement): """SQLite table list expression. - Extends TableListExpression with SQLite-specific parameters. + Extends TableListQueryStatement with SQLite-specific parameters. Args: dialect: The SQL dialect to use for SQL generation. diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/types.py index cea5a9d51..73c68cfdf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/types.py @@ -46,6 +46,8 @@ class SQLiteIntegerType(IntegerType): reuse. """ + KIND_RESERVED = ("SQLite",) + name = "sqlite_integer" @@ -58,6 +60,8 @@ class SQLiteTextType(TextType): introspection round-trips. """ + KIND_RESERVED = ("SQLite",) + name = "sqlite_text" length: int | None = None @@ -75,6 +79,8 @@ class SQLiteRealType(RealType): type affinity mapping. """ + KIND_RESERVED = ("SQLite",) + name = "sqlite_real" precision: int | None = None @@ -97,6 +103,8 @@ class SQLiteNumericType(DataType): single core type. """ + KIND_RESERVED = ("SQLite",) + name = "sqlite_numeric" precision: int | None = None @@ -118,4 +126,6 @@ class SQLiteBlobType(BlobType): this affinity. """ + KIND_RESERVED = ("SQLite",) + name = "sqlite_blob" diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/vacuum.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/vacuum.py index 1e21519d4..ac77fcc01 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/vacuum.py @@ -2,20 +2,31 @@ """ SQLite-specific VACUUM and ANALYZE expressions. -This module provides SQLiteVacuumExpression and SQLiteAnalyzeExpression. +This module provides SQLiteVacuumStatement and SQLiteAnalyzeStatement. """ from typing import Optional, TYPE_CHECKING -from ....expression.bases import BaseExpression, SQLQueryAndParams +from ....expression.bases import BaseExpression if TYPE_CHECKING: from ....dialect import SQLDialectBase -__all__ = ["SQLiteVacuumExpression", "SQLiteAnalyzeExpression"] +__all__ = ["SQLiteVacuumStatement", "SQLiteAnalyzeStatement"] -class SQLiteVacuumExpression(BaseExpression): +class SQLiteVacuumStatement(BaseExpression): + + @property + def format_method(self) -> str: + """The dialect method that renders this expression. + + Declared rather than reached through a ``to_sql`` override: the node + names its formatter and the dialect supplies it. A node that calls the + dialect itself keeps the lookup inside the node, which is the direction + this architecture removes. + """ + return "format_vacuum_statement" """SQLite VACUUM statement expression. VACUUM reclaims storage by rebuilding the database file. It is a @@ -29,13 +40,13 @@ class SQLiteVacuumExpression(BaseExpression): Examples: # Vacuum the whole database - vacuum = SQLiteVacuumExpression(dialect) + vacuum = SQLiteVacuumStatement(dialect) # Vacuum a specific attached schema - vacuum = SQLiteVacuumExpression(dialect, schema="aux") + vacuum = SQLiteVacuumStatement(dialect, schema="aux") # Write the vacuumed database to a new file (SQLite 3.27.0+) - vacuum = SQLiteVacuumExpression(dialect, into="backup.db") + vacuum = SQLiteVacuumStatement(dialect, into="backup.db") """ def __init__( @@ -59,17 +70,18 @@ def __init__( super().__init__(dialect) self.schema = schema self.into = into +class SQLiteAnalyzeStatement(BaseExpression): - def to_sql(self) -> SQLQueryAndParams: - """Generate SQL for the VACUUM statement. + @property + def format_method(self) -> str: + """The dialect method that renders this expression. - Returns: - Tuple of (SQL string, empty parameters tuple). + Declared rather than reached through a ``to_sql`` override: the node + names its formatter and the dialect supplies it. A node that calls the + dialect itself keeps the lookup inside the node, which is the direction + this architecture removes. """ - return self._dialect.format_vacuum_statement(self) - - -class SQLiteAnalyzeExpression(BaseExpression): + return "format_analyze_statement" """SQLite ANALYZE statement expression. ANALYZE collects statistics used by the query planner to generate @@ -87,11 +99,3 @@ def __init__( dialect: "SQLDialectBase", ): super().__init__(dialect) - - def to_sql(self) -> SQLQueryAndParams: - """Format the ANALYZE statement. - - Returns: - Tuple of ('ANALYZE', empty parameters tuple). - """ - return self._dialect.format_analyze_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/expression/virtual_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/expression/virtual_table.py index e4b63de24..d6c4ed2b0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/expression/virtual_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/expression/virtual_table.py @@ -14,7 +14,7 @@ from ....dialect import SQLDialectBase -class CreateVirtualTableExpression(BaseExpression): +class CreateVirtualTableStatement(BaseExpression): """Expression for CREATE VIRTUAL TABLE statement.""" def __init__( @@ -38,13 +38,13 @@ def __init__( @property def format_method(self) -> str: - return "format_create_virtual_table" + return "format_create_virtual_table_statement" def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_create_virtual_table(self) + return self.dialect.format_create_virtual_table_statement(self) -class DropVirtualTableExpression(BaseExpression): +class DropVirtualTableStatement(BaseExpression): """Expression for DROP TABLE statement targeting a virtual table.""" def __init__( @@ -64,7 +64,7 @@ def __init__( @property def format_method(self) -> str: - return "format_drop_virtual_table" + return "format_drop_virtual_table_statement" def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_drop_virtual_table(self) + return self.dialect.format_drop_virtual_table_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/functions/json.py b/src/rhosocial/activerecord/backend/impl/sqlite/functions/json.py index 7ee8d12d7..7130c3dfd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/functions/json.py @@ -15,6 +15,7 @@ from typing import Union, Optional, Any, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from ..expression.json import SQLiteJsonExtractFunction if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -132,6 +133,13 @@ def json_extract( """ doc_expr = json_doc path_expr = path + if not paths: + # Single-path extraction is modelled by the SQLite-specific node, so + # the function's identity travels on the kind, not on a name string. + return SQLiteJsonExtractFunction(dialect, doc_expr, path_expr) + # The variadic form (several paths -> one JSON array) is not modelled by + # the single-path node; it stays on the generic call node, the escape hatch + # for shapes the node layer does not yet carry (catalog §3.4). args = [doc_expr, path_expr] for p in paths: args.append(p) @@ -511,7 +519,7 @@ def json_object_retrieve( """ val_expr = json_val path_expr = path - return core.FunctionCall(dialect, "JSON_EXTRACT", val_expr, path_expr) + return SQLiteJsonExtractFunction(dialect, val_expr, path_expr) def json_object_length( diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/introspector.py index f531eb55a..017f3d056 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/introspector.py @@ -12,7 +12,7 @@ Key behaviours: - Version-aware: uses ``PRAGMA table_list`` (3.37+) or ``sqlite_master`` - Extended column info: uses ``PRAGMA table_xinfo`` (3.26+) - - ``_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. - SyncSQLiteIntrospector: for synchronous backends @@ -29,23 +29,24 @@ SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, 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 .pragma_introspector import ( @@ -56,6 +57,11 @@ SyncSQLiteStatusIntrospector, AsyncSQLiteStatusIntrospector, ) +from ..expression.introspection import ( + SQLiteIndexDdlStatement, + SQLiteTableCountStatement, + SQLiteTableDdlStatement, +) class SQLiteIntrospectorMixin(IntrospectorMixin): @@ -66,11 +72,12 @@ class SQLiteIntrospectorMixin(IntrospectorMixin): - Default schema handling - SQLite version detection - SQL generation overrides - - _parse_* implementations + - parse_* implementations """ - def _get_default_schema(self) -> str: - """Return the SQLite schema name for the primary database.""" + @property + def default_schema(self) -> str: + """The SQLite schema name for the primary database.""" return "main" def _get_sqlite_version(self) -> tuple: @@ -84,8 +91,8 @@ def _get_sqlite_version(self) -> tuple: # commands and sqlite_master queries. # ------------------------------------------------------------------ # - # Note: SQLite does NOT override _build_*_sql() methods because: - # 1. Base class IntrospectorMixin._build_*_sql() delegates to Expression.to_sql() + # Note: SQLite does NOT override the *_statement() factories because: + # 1. The base factory returns the family's statement object # 2. Expression.to_sql() calls Dialect.format_*_query() # 3. SQLiteDialect.format_*_query() generates correct PRAGMA/sqlite_master SQL # @@ -95,7 +102,16 @@ def _get_sqlite_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]], + pragma_values: Optional[Dict[str, Any]] = None) -> IntrospectionDatabaseInfo: + """Turn the version row into a database record. + + *pragma_values* carries what the pragmas answered -- encoding, page + counts -- because those are not columns of any row. They arrive as a + dict rather than being read here so that this method stays pure: + ``get_database_info`` does the I/O, and the sync and async paths hand + the same dict to the same parser. + """ row = rows[0] if rows else {} version_str = row.get("version") or sqlite3.sqlite_version parts = version_str.split(".") @@ -103,29 +119,100 @@ def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: # SQLite's database name is the schema name (e.g., "main") # For file-based databases, we can also show the filename db_path = self._backend.config.database or ":memory:" - return DatabaseInfo( + values = pragma_values or {} + + # size_bytes is page_count * page_size, which is the file's size on + # disk rather than the sum of its rows. Either pragma missing makes the + # answer None rather than a wrong number. + page_count = values.get("page_count") + page_size = values.get("page_size") + size_bytes = None + if page_count is not None and page_size is not None: + try: + size_bytes = int(page_count) * int(page_size) + except (TypeError, ValueError): + size_bytes = None + + return IntrospectionDatabaseInfo( name="main", # SQLite's default schema name version=version_str, version_tuple=version_tuple, vendor="SQLite", + encoding=values.get("encoding"), + # No database-level collation: SQLite attaches a collation to a + # column or an expression, not to the file, so this stays None + # rather than repeating the encoding under a second name. + size_bytes=size_bytes, + table_count=values.get("table_count"), extra={"path": db_path}, ) - def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[TableInfo]: + #: The pragmas a database record reads, and why each is here. + _DATABASE_PRAGMAS = ("encoding", "page_count", "page_size") + + @staticmethod + def _pragma_value(row: Any, name: str) -> Any: + """The scalar a PRAGMA answered with, or ``None``. + + ``PRAGMA encoding`` returns one row with one column named after the + pragma; ``pragma.get`` hands back that whole row, so the value has to be + picked out of it. An engine that names the column differently yields a + single-key row, and that key's value is used; a row with several columns + is not a scalar answer and is returned as-is rather than guessed at. + """ + if row is None: + return None + if not isinstance(row, dict): + return row + if name in row: + return row[name] + if len(row) == 1: + return next(iter(row.values())) + return row + + def _collect_database_pragmas(self) -> Dict[str, Any]: + """Read the pragmas that answer what the version row cannot. + + ``encoding`` is the database's text encoding (``PRAGMA encoding``), + ``page_count`` and ``page_size`` the file's extent. ``table_count`` is + not a pragma -- it is a count over ``sqlite_master`` -- and is read by + the caller because it needs the schema. + """ + values: Dict[str, Any] = {} + for name in self._DATABASE_PRAGMAS: + values[name] = self._pragma_value(self.pragma.get(name), name) + return values + + def parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: + # PRAGMA table_list reports four kinds, and the two beyond table/view + # are the ones a caller cannot otherwise tell apart: + # + # table an ordinary storage table + # view a stored SELECT + # shadow the storage tables a virtual-table module creates and + # maintains (fts_data, fts_idx, ...); nobody outside the + # module may write them, which is what SYSTEM_TABLE says + # virtual the module-backed table the user created (an FTS5 or + # R-Tree index); its contents are maintained by the module, + # not by the table itself, which is what EXTERNAL says + # + # The sqlite_master fallback only ever produces the first two, so the + # extra branches cost nothing on older SQLite versions. + table_type_by_kind = { + "table": TableType.BASE_TABLE, + "view": TableType.VIEW, + "shadow": TableType.SYSTEM_TABLE, + "virtual": TableType.EXTERNAL, + } tables = [] for row in rows: type_str = row.get("type", "table").lower() - if type_str == "table": - table_type = TableType.BASE_TABLE - elif type_str == "view": - table_type = TableType.VIEW - else: - table_type = TableType.EXTERNAL + table_type = table_type_by_kind.get(type_str, TableType.EXTERNAL) tables.append( - TableInfo( + IntrospectionTableInfo( name=row["name"], - schema=schema or self._get_default_schema(), + schema=schema or self.default_schema, table_type=table_type, row_count=None, create_time=None, @@ -161,14 +248,28 @@ def _extract_type_params(raw_type: str) -> dict: result["character_maximum_length"] = value return result - def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str, - ddl: Optional[str] = None) -> List[ColumnInfo]: + def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str, + ddl: Optional[str] = None, + unique_columns: Optional[set] = None) -> List[IntrospectionColumnInfo]: + """Turn ``PRAGMA table_xinfo`` rows into column records. + + *ddl* carries the table's own text because ``AUTOINCREMENT`` appears + only there. *unique_columns* carries the names a unique index covers, + because SQLite records uniqueness on the index and not on the column: + ``PRAGMA table_info`` says a column is unique no more than it says a + table has a foreign key. Reporting ``False`` for a column the engine + has a unique index on is the wrong answer, not a conservative one -- + a caller building a model from it would drop a constraint that exists. + """ columns = [] has_auto_increment = ddl is not None and "AUTOINCREMENT" in ddl.upper() for row in rows: hidden = row.get("hidden", 0) if hidden == 1: - continue # Skip rowid alias columns only + # A hidden column of a virtual table (FTS shadow columns and + # the like): part of the module's storage, not of the table a + # caller reads. Generated columns are hidden==2/3 and are kept. + continue nullable = ColumnNullable.NULLABLE if row["notnull"] == 0 else ColumnNullable.NOT_NULL raw_type = row.get("type") or "TEXT" # Extract base type (e.g., "VARCHAR" from "VARCHAR(255)") @@ -181,13 +282,17 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st parsed = DataType.parse_data_type_str(self._backend.dialect, raw_type) # Auto-increment: only applies to INTEGER PRIMARY KEY with AUTOINCREMENT keyword is_auto_inc = is_pk and has_auto_increment - # Generated columns (hidden=2 => virtual, hidden=3 => stored) + # Generated columns (hidden=2 => virtual, hidden=3 => stored). + # SQLite reports *that* a column is generated, not what it is made + # of: the expression lives in the table's DDL, which `table_xinfo` + # does not return. Reading it would mean parsing that DDL, so the + # field stays None here rather than carrying a guess. is_generated = hidden in (2, 3) - gen_expr = row.get("generated_expression") if is_generated else None + gen_expr = None # Extract precision/length from raw type params = self._extract_type_params(raw_type) columns.append( - ColumnInfo( + IntrospectionColumnInfo( name=row["name"], table_name=table_name, schema=schema, @@ -198,6 +303,17 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st nullable=nullable, default_value=row.get("dflt_value"), is_primary_key=is_pk, + # SQLite records uniqueness on the index, not on the column: + # PRAGMA table_info says a column is unique no more than it + # says a table has a foreign key. Reporting False for a + # column the engine has a unique index on is the wrong + # answer rather than a conservative one -- a caller building + # a model from it would drop a constraint that exists. + is_unique=( + is_pk + or (unique_columns is not None + and row["name"] in unique_columns) + ), is_auto_increment=is_auto_inc, is_generated=is_generated, generated_expression=gen_expr, @@ -210,7 +326,69 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st ) return columns - def _parse_indexes(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IndexInfo]: + def _collect_unique_columns(self, table_name: str, schema: Optional[str]) -> set: + """Read the index pragmas and return the unique column names. + + The I/O half of ``is_unique``: ``PRAGMA index_list`` says which indexes + are unique and whether one is partial, ``PRAGMA index_xinfo`` says what + each covers. Both are cheap and both are cached with the column list, so + the cost is paid once per table rather than once per column. + """ + index_rows = self.pragma.index_list(table_name, schema) + columns_by_index: Dict[str, List[str]] = {} + for idx in index_rows: + name = idx.get("name") + if not name: + continue + columns_by_index[name] = [ + str(c.get("name")) + for c in self.pragma.index_xinfo(name, schema) + if c.get("name") and not c.get("hidden") + ] + return self._unique_columns(index_rows, columns_by_index) + + async def _collect_unique_columns_async(self, table_name: str, + schema: Optional[str]) -> set: + """The async twin of :meth:`_collect_unique_columns`. + + Same two pragmas, awaited; the reduction is the shared + :meth:`_unique_columns`, so the two directions cannot disagree about + which columns count. + """ + index_rows = await self.pragma.index_list(table_name, schema) + columns_by_index: Dict[str, List[str]] = {} + for idx in index_rows: + name = idx.get("name") + if not name: + continue + xinfo = await self.pragma.index_xinfo(name, schema) + columns_by_index[name] = [ + str(c.get("name")) + for c in xinfo + if c.get("name") and not c.get("hidden") + ] + return self._unique_columns(index_rows, columns_by_index) + + @staticmethod + def _unique_columns(index_rows: List[Dict[str, Any]], + columns_by_index: Dict[str, List[str]]) -> set: + """Column names a unique index covers, for ``is_unique``. + + Takes the PRAGMA answers rather than the parsed ``IndexInfo`` list, + because the index's *partial* flag and its *origin* are what decide + whether the uniqueness is a promise about every row, and ``IndexInfo`` + carries neither. A partial unique index is skipped: it constrains only + the rows that pass its ``WHERE``, which is not what ``is_unique`` + claims. + """ + unique: set = set() + for idx in index_rows: + if not idx.get("unique") or idx.get("partial"): + continue + unique.update(columns_by_index.get(idx.get("name") or "", [])) + return unique + + def parse_indexes(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IndexInfo]: if not rows: return [] idx_map: Dict[str, IndexInfo] = {} @@ -240,10 +418,10 @@ def _parse_indexes(self, rows: List[Dict[str, Any]], table_name: str, schema: st ) return list(idx_map.values()) - def _parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[ForeignKeyInfo]: + def parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IntrospectionForeignKeyInfo]: if not rows: return [] - fk_map: Dict[int, ForeignKeyInfo] = {} + fk_map: Dict[int, IntrospectionForeignKeyInfo] = {} action_map = { "NO ACTION": ReferentialAction.NO_ACTION, "RESTRICT": ReferentialAction.RESTRICT, @@ -262,7 +440,7 @@ def _parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schem row.get("on_delete", "NO ACTION").upper(), ReferentialAction.NO_ACTION, ) - fk_map[fk_id] = ForeignKeyInfo( + fk_map[fk_id] = IntrospectionForeignKeyInfo( name=f"fk_{table_name}_{fk_id}", table_name=table_name, schema=schema, @@ -276,25 +454,100 @@ def _parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schem fk_map[fk_id].referenced_columns.append(row["to"]) return list(fk_map.values()) - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo]: - return [ViewInfo(name=row["name"], schema=schema, definition=row.get("sql")) for row in rows] + def parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: + return [IntrospectionViewInfo(name=row["name"], schema=schema, definition=row.get("sql")) for row in rows] - def _parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: str) -> Optional[ViewInfo]: + def parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: str) -> Optional[IntrospectionViewInfo]: if not rows: return None row = rows[0] - return ViewInfo(name=row["name"], schema=schema, definition=row.get("sql")) - - def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: - return [ - TriggerInfo( - name=row["name"], - table_name=row["tbl_name"], - schema=schema, - definition=row.get("sql"), + return IntrospectionViewInfo(name=row["name"], schema=schema, definition=row.get("sql")) + + #: The words that say *when* a trigger fires. ``INSTEAD OF`` is a timing + #: too, and the only one that makes a view writable, so it is not folded + #: into the event list. + _TRIGGER_TIMING = ("BEFORE", "AFTER", "INSTEAD OF") + #: The events a trigger can fire on. ``UPDATE OF col`` names columns, which + #: are not part of the event. + _TRIGGER_EVENTS = ("DELETE", "INSERT", "UPDATE") + + @classmethod + def parse_trigger_header(cls, ddl: Optional[str]): + """(timing, events) from a trigger's ``CREATE TRIGGER`` text. + + SQLite keeps a trigger's header only in its DDL -- there is no pragma + and no column for *when* it fires or *what* it fires on -- so leaving + these fields empty discards information the caller can already see in + the definition and cannot be expected to re-parse. + + Returns ``("", [])`` when the text is missing or unparseable rather + than guessing: an empty timing reads as "not stated", a wrong one + reads as a fact about the schema. + """ + if not ddl: + return "", [] + header = ddl.split("ON", 1)[0].upper() + timing = "" + for candidate in cls._TRIGGER_TIMING: + if candidate in header: + timing = candidate + break + events = [e for e in cls._TRIGGER_EVENTS if e in header] + return timing, events + + def parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: + triggers = [] + for row in rows: + timing, events = self.parse_trigger_header(row.get("sql")) + triggers.append( + IntrospectionTriggerInfo( + name=row["name"], + table_name=row["tbl_name"], + schema=schema, + timing=timing, + events=events, + definition=row.get("sql"), + ) ) - for row in rows - ] + return triggers + + + def get_database_info(self) -> IntrospectionDatabaseInfo: + """Answer the version, the encoding, the file extent and the table count. + + Core's version asks one question and parses its row. SQLite's answer + also lives in three pragmas and a count, so this gathers them and hands + the same dict to the same parser the async twin uses -- which is what + keeps the two directions from disagreeing about the values. + """ + key = self._cache.key(IntrospectionScope.DATABASE) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.database_info_statement().to_sql() + rows = self._executor.execute(sql, params) + values = self._collect_database_pragmas() + values["table_count"] = self._count_tables() + result = self.parse_database_info(rows, values) + self._cache.set(key, result) + return result + + def _count_tables(self) -> Optional[int]: + """How many tables this database declares, system ones excluded.""" + sql, params = SQLiteTableCountStatement( + self.dialect, self.default_schema + ).to_sql() + return self.parse_table_count(self._executor.execute(sql, params)) + + def parse_table_count(self, rows: List[Dict[str, Any]]) -> Optional[int]: + """Read the count from the single row ``COUNT(*)`` returns.""" + if not rows: + return None + try: + return int(rows[0]["table_count"]) + except (KeyError, TypeError, ValueError): + return None class SyncSQLiteIntrospector(SQLiteIntrospectorMixin, SyncAbstractIntrospector): @@ -312,8 +565,13 @@ class SyncSQLiteIntrospector(SQLiteIntrospectorMixin, SyncAbstractIntrospector): errors = backend.introspector.pragma.integrity_check() """ - 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._pragma_instance: Optional[SyncPragmaIntrospector] = None self._status_instance: Optional[SyncSQLiteStatusIntrospector] = None @@ -331,33 +589,39 @@ def status(self) -> SyncSQLiteStatusIntrospector: self._status_instance = SyncSQLiteStatusIntrospector(self._backend) return self._status_instance + + # ------------------------------------------------------------------ # # Override list_columns for auto-increment detection # ------------------------------------------------------------------ # - def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[ColumnInfo]: + def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionColumnInfo]: """List columns, with auto-increment detection from DDL.""" - target_db = schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.COLUMN, table_name, schema=schema) - cached = self._get_cached(key) + target_db = schema or self.default_schema + key = self._cache.key(IntrospectionScope.COLUMN, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_column_info_sql(table_name, schema) + sql, params = self.column_info_statement( + table_name=table_name, schema=schema, include_hidden=True + ).to_sql() rows = self._executor.execute(sql, params) - # Query the DDL for AUTOINCREMENT detection + # Query the DDL for AUTOINCREMENT detection, and the index pragmas for + # uniqueness: neither fact is on the column row. ddl = None - ddl_result = self._executor.execute( - "SELECT sql FROM sqlite_master WHERE type='table' " - f"AND name={self.dialect.p()} AND tbl_name={self.dialect.p()}", - (table_name, table_name), - ) + ddl_sql, ddl_params = SQLiteTableDdlStatement(self.dialect, table_name).to_sql() + ddl_result = self._executor.execute(ddl_sql, ddl_params) if ddl_result and ddl_result[0].get("sql"): ddl = ddl_result[0]["sql"] - result = self._parse_columns(rows, table_name, target_db, ddl=ddl) - self._set_cached(key, result) + unique_columns = self._collect_unique_columns(table_name, target_db) + + result = self.parse_columns( + rows, table_name, target_db, ddl=ddl, unique_columns=unique_columns + ) + self._cache.set(key, result) return result # ------------------------------------------------------------------ # @@ -371,9 +635,9 @@ def list_indexes(self, table_name: str, schema: Optional[str] = None) -> List[In 1. PRAGMA index_list(table) - get index list 2. PRAGMA index_xinfo(index) - get column info for each index """ - target_db = schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.INDEX, table_name, schema=schema) - cached = self._get_cached(key) + target_db = schema or self.default_schema + key = self._cache.key(IntrospectionScope.INDEX, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -419,10 +683,8 @@ def list_indexes(self, table_name: str, schema: Optional[str] = None) -> List[In # For partial indexes, extract filter_condition from DDL filter_condition: Optional[str] = None if is_partial: - ddl_result = self._executor.execute( - f"SELECT sql FROM sqlite_master WHERE type='index' AND name={self.dialect.p()}", - (idx_name,), - ) + index_ddl, index_params = SQLiteIndexDdlStatement(self.dialect, idx_name).to_sql() + ddl_result = self._executor.execute(index_ddl, index_params) if ddl_result and ddl_result[0].get("sql"): ddl_sql = ddl_result[0]["sql"] where_match = re.search( @@ -445,18 +707,17 @@ def list_indexes(self, table_name: str, schema: Optional[str] = None) -> List[In ) ) - self._set_cached(key, indexes) + self._cache.set(key, indexes) return indexes # ------------------------------------------------------------------ # - # get_table_info override — uses direct _parse, avoids redundant - # list_tables() call and populates columns/indexes/FKs in one sweep. + # get_table_info override — parses rows directly instead of calling + # list_tables() again, and populates columns/indexes/FKs in one sweep. # ------------------------------------------------------------------ # - def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: - schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -470,10 +731,11 @@ 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 + class AsyncSQLiteIntrospector(SQLiteIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for SQLite backends. @@ -489,8 +751,13 @@ class AsyncSQLiteIntrospector(SQLiteIntrospectorMixin, AsyncAbstractIntrospector errors = await backend.introspector.pragma.integrity_check() """ - 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._pragma_instance: Optional[AsyncPragmaIntrospector] = None self._status_instance: Optional[AsyncSQLiteStatusIntrospector] = None @@ -508,33 +775,64 @@ def status(self) -> AsyncSQLiteStatusIntrospector: self._status_instance = AsyncSQLiteStatusIntrospector(self._backend) return self._status_instance + async def get_database_info(self) -> IntrospectionDatabaseInfo: + """The async twin of :meth:`SyncSQLiteIntrospector.get_database_info`. + + Same questions, awaited, and the same :meth:`parse_database_info` + reduction -- so the two directions cannot report different encodings or + different sizes for one database. + """ + key = self._cache.key(IntrospectionScope.DATABASE) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.database_info_statement().to_sql() + rows = await self._executor.execute(sql, params) + values: Dict[str, Any] = {} + for name in self._DATABASE_PRAGMAS: + values[name] = self._pragma_value(await self.pragma.get(name), name) + values["table_count"] = await self._count_tables_async() + result = self.parse_database_info(rows, values) + self._cache.set(key, result) + return result + + async def _count_tables_async(self) -> Optional[int]: + sql, params = SQLiteTableCountStatement( + self.dialect, self.default_schema + ).to_sql() + return self.parse_table_count(await self._executor.execute(sql, params)) + # ------------------------------------------------------------------ # # Override list_columns for auto-increment detection # ------------------------------------------------------------------ # - async def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[ColumnInfo]: + async def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionColumnInfo]: """List columns, with auto-increment detection from DDL.""" - target_db = schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.COLUMN, table_name, schema=schema) - cached = self._get_cached(key) + target_db = schema or self.default_schema + key = self._cache.key(IntrospectionScope.COLUMN, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_column_info_sql(table_name, schema) + sql, params = self.column_info_statement( + table_name=table_name, schema=schema, include_hidden=True + ).to_sql() rows = await self._executor.execute(sql, params) # Query the DDL for AUTOINCREMENT detection ddl = None - ddl_result = await self._executor.execute( - "SELECT sql FROM sqlite_master WHERE type='table' " - f"AND name={self.dialect.p()} AND tbl_name={self.dialect.p()}", - (table_name, table_name), - ) + ddl_sql, ddl_params = SQLiteTableDdlStatement(self.dialect, table_name).to_sql() + ddl_result = await self._executor.execute(ddl_sql, ddl_params) if ddl_result and ddl_result[0].get("sql"): ddl = ddl_result[0]["sql"] - result = self._parse_columns(rows, table_name, target_db, ddl=ddl) - self._set_cached(key, result) + unique_columns = await self._collect_unique_columns_async(table_name, target_db) + + result = self.parse_columns( + rows, table_name, target_db, ddl=ddl, unique_columns=unique_columns + ) + self._cache.set(key, result) return result # ------------------------------------------------------------------ # @@ -548,9 +846,9 @@ async def list_indexes(self, table_name: str, schema: Optional[str] = None) -> L 1. PRAGMA index_list(table) - get index list 2. PRAGMA index_xinfo(index) - get column info for each index """ - target_db = schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.INDEX, table_name, schema=schema) - cached = self._get_cached(key) + target_db = schema or self.default_schema + key = self._cache.key(IntrospectionScope.INDEX, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -596,10 +894,8 @@ async def list_indexes(self, table_name: str, schema: Optional[str] = None) -> L # For partial indexes, extract filter_condition from DDL filter_condition: Optional[str] = None if is_partial: - ddl_result = await self._executor.execute( - f"SELECT sql FROM sqlite_master WHERE type='index' AND name={self.dialect.p()}", - (idx_name,), - ) + index_ddl, index_params = SQLiteIndexDdlStatement(self.dialect, idx_name).to_sql() + ddl_result = await self._executor.execute(index_ddl, index_params) if ddl_result and ddl_result[0].get("sql"): ddl_sql = ddl_result[0]["sql"] where_match = re.search( @@ -622,18 +918,17 @@ async def list_indexes(self, table_name: str, schema: Optional[str] = None) -> L ) ) - self._set_cached(key, indexes) + self._cache.set(key, indexes) return indexes # ------------------------------------------------------------------ # - # get_table_info override — uses direct _parse, avoids redundant - # list_tables() call and populates columns/indexes/FKs in one sweep. + # get_table_info override — parses rows directly instead of calling + # list_tables() again, and populates columns/indexes/FKs in one sweep. # ------------------------------------------------------------------ # - async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: - schema or self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -647,5 +942,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/src/rhosocial/activerecord/backend/impl/sqlite/introspection/pragma_introspector.py b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/pragma_introspector.py index 6943b288d..8310e0962 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/introspection/pragma_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/pragma_introspector.py @@ -25,87 +25,15 @@ cols = await backend.introspector.pragma.table_info("users") """ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, AsyncIntrospectorExecutor, ) -from rhosocial.activerecord.backend.impl.sqlite.pragma import get_pragma_info -class PragmaMixin: - """Mixin providing shared PRAGMA SQL generation helpers. - - Both SyncPragmaIntrospector and AsyncPragmaIntrospector inherit - from this mixin to share SQL generation logic. - """ - - @staticmethod - def _pragma_sql( - name: str, - argument: Optional[Any] = None, - schema: Optional[str] = None, - ) -> Tuple[str, tuple]: - """Build a PRAGMA statement. - - Args: - name: PRAGMA name (e.g. ``"journal_mode"``). - argument: Optional argument (e.g. a table name or a new value - for a read/write PRAGMA). Passed as a literal, not - as a bound parameter, because SQLite does not support - parameterised PRAGMA values. - schema: Optional attached-database name (e.g. ``"temp"``). - - Returns: - ``(sql, params)`` tuple compatible with executor.execute(). - - Raises: - ValueError: If the PRAGMA name is not in the known whitelist. - """ - info = get_pragma_info(name) - if info is None: - raise ValueError(f"Unknown PRAGMA: {name}") - prefix = f"{schema}." if schema else "" - if argument is not None: - return (f"PRAGMA {prefix}{info.name}({argument})", ()) - return (f"PRAGMA {prefix}{info.name}", ()) - - @staticmethod - def _set_pragma_sql(name: str, value: Any, schema: Optional[str] = None) -> Tuple[str, tuple]: - """Build a ``PRAGMA name = value`` assignment statement. - - Validates the PRAGMA name against the known whitelist, rejects - read-only PRAGMAs, and enforces ``allowed_values`` before building - the SQL, mirroring ``SQLitePragmaMixin.set_pragma_sql``. - - Args: - name: PRAGMA name (e.g. ``"journal_mode"``). - value: Value to set. - schema: Optional attached-database name (e.g. ``"temp"``). - - Returns: - ``(sql, params)`` tuple compatible with executor.execute(). - - Raises: - ValueError: If the PRAGMA is unknown, read-only, or the value - is not in its allowed values. - """ - info = get_pragma_info(name) - if info is None: - raise ValueError(f"Unknown PRAGMA: {name}") - - if info.read_only: - raise ValueError(f"PRAGMA {name} is read-only and cannot be set") - - if info.allowed_values and value not in info.allowed_values: - raise ValueError(f"Invalid value '{value}' for PRAGMA {name}. Allowed values: {info.allowed_values}") - - prefix = f"{schema}." if schema else "" - return (f"PRAGMA {prefix}{info.name} = {value}", ()) - - -class SyncPragmaIntrospector(PragmaMixin): +class SyncPragmaIntrospector: """Synchronous PRAGMA introspector for SQLite backends. All methods are synchronous. Method names do NOT have an _async suffix. @@ -130,7 +58,7 @@ def get( Returns the first row returned by the PRAGMA, or ``None`` if the result is empty (e.g. write-only or action PRAGMAs). """ - sql, params = self._pragma_sql(name, argument, schema) + sql, params = self._backend.dialect.pragma_query_statement(name, argument, schema).to_sql() rows = self._executor.execute(sql, params) return rows[0] if rows else None @@ -141,7 +69,7 @@ def set( schema: Optional[str] = None, ) -> None: """Write a PRAGMA value (for read/write PRAGMAs).""" - sql, params = self._set_pragma_sql(name, value, schema) + sql, params = self._backend.dialect.pragma_set_statement(name, value, schema=schema).to_sql() self._executor.execute(sql, params) def execute( @@ -155,7 +83,7 @@ def execute( Use this for PRAGMAs that return multiple rows (e.g. ``table_info``, ``index_list``, ``foreign_key_list``). """ - sql, params = self._pragma_sql(name, argument, schema) + sql, params = self._backend.dialect.pragma_query_statement(name, argument, schema).to_sql() return self._executor.execute(sql, params) # ------------------------------------------------------------------ # @@ -238,7 +166,7 @@ def wal_checkpoint( return self.get("wal_checkpoint", mode, schema) -class AsyncPragmaIntrospector(PragmaMixin): +class AsyncPragmaIntrospector: """Asynchronous PRAGMA introspector for SQLite backends. All methods are async. Method names do NOT have an _async suffix, @@ -264,7 +192,7 @@ async def get( Returns the first row returned by the PRAGMA, or ``None`` if the result is empty (e.g. write-only or action PRAGMAs). """ - sql, params = self._pragma_sql(name, argument, schema) + sql, params = self._backend.dialect.pragma_query_statement(name, argument, schema).to_sql() rows = await self._executor.execute(sql, params) return rows[0] if rows else None @@ -275,7 +203,7 @@ async def set( schema: Optional[str] = None, ) -> None: """Write a PRAGMA value (for read/write PRAGMAs).""" - sql, params = self._set_pragma_sql(name, value, schema) + sql, params = self._backend.dialect.pragma_set_statement(name, value, schema=schema).to_sql() await self._executor.execute(sql, params) async def execute( @@ -289,7 +217,7 @@ async def execute( Use this for PRAGMAs that return multiple rows (e.g. ``table_info``, ``index_list``, ``foreign_key_list``). """ - sql, params = self._pragma_sql(name, argument, schema) + sql, params = self._backend.dialect.pragma_query_statement(name, argument, schema).to_sql() return await self._executor.execute(sql, params) # ------------------------------------------------------------------ # diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/status_introspector.py index 12290c717..f8523e2fd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/introspection/status_introspector.py @@ -71,7 +71,7 @@ def _get_vendor_name(self) -> str: """Get SQLite vendor name.""" return "SQLite" - def _parse_pragma_value(self, value: Any) -> Any: + def parse_pragma_value(self, value: Any) -> Any: """Parse PRAGMA value to appropriate Python type. Args: @@ -114,7 +114,7 @@ def _create_status_item( """ return StatusItem( name=name, - value=self._parse_pragma_value(value), + value=self.parse_pragma_value(value), category=category, description=description, unit=unit, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/__init__.py index 00e505b35..a3bca8426 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/__init__.py @@ -15,7 +15,6 @@ from .datetime import SQLiteDateTimeMixin from .ddl_column import SQLiteDDLColumnMixin from .dml import SQLiteDMLMixin -from .set_operation import SQLiteSetOperationMixin from .ddl_view import SQLiteViewMixin from .trigger import SQLiteTriggerMixin from .transaction import SQLiteTransactionMixin @@ -24,7 +23,9 @@ from .rtree import SQLiteRTreeMixin from .geopoly import SQLiteGeopolyMixin from .types import SQLiteTypeSupportMixin +from .literal import LiteralValueMixin from .column_type import SQLiteColumnTypeMixin +from .object_name import SQLiteObjectNameMixin __all__ = [ 'SQLiteExtensionMixin', @@ -36,7 +37,6 @@ 'SQLiteDateTimeMixin', 'SQLiteDDLColumnMixin', 'SQLiteDMLMixin', - 'SQLiteSetOperationMixin', 'SQLiteViewMixin', 'SQLiteTriggerMixin', 'SQLiteTransactionMixin', @@ -45,5 +45,7 @@ 'SQLiteRTreeMixin', 'SQLiteGeopolyMixin', 'SQLiteTypeSupportMixin', + 'SQLiteObjectNameMixin', + 'LiteralValueMixin', 'SQLiteColumnTypeMixin', ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/datetime.py index 308e8579d..43fdc090f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/datetime.py @@ -8,10 +8,11 @@ import re from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.mixins import AliasableMixin if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction _COLLATION_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -39,7 +40,7 @@ class SQLiteDateTimeMixin: def apply_alias(self, sql: str, params: tuple, expr) -> Tuple[str, tuple]: """Append ``AS alias`` when the expression carries an alias.""" - if getattr(expr, "alias", None): + if isinstance(expr, AliasableMixin) and expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params @@ -120,13 +121,10 @@ def supports_collate_expression(self) -> bool: """SQLite supports expression-level COLLATE.""" return True - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate SQLite collation names and return their SQL representation.""" - if "schema" in expr.collation_options: + if expr.schema is not None: raise UnsupportedFeatureError(self.name, "schema-qualified COLLATE") - if expr.collation_options: - unsupported = ", ".join(sorted(expr.collation_options)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") if not _COLLATION_NAME_RE.fullmatch(expr.collation_name): raise ValueError(f"Unsupported SQLite collation: {expr.collation_name!r}") return expr.collation_name diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_column.py index ca7bd99e7..c793666c9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_column.py @@ -1,3 +1,8 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from typing import Tuple, TYPE_CHECKING # src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_column.py """ SQLite-specific Ddl Column implementation. @@ -5,123 +10,44 @@ This module provides the SQLiteDDLColumnMixin class. """ -from typing import Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, - AddIndex, - DropColumn, - DropIndex, - DropTableConstraint, - ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraint, + ColumnPrimaryKeyConstraint, ColumnDefinition, DefaultValueClause, ) class SQLiteDDLColumnMixin: - """SQLite-specific column constraint and column definition formatting.""" - - def supports_add_column_if_not_exists(self) -> bool: - return False - - def supports_drop_column_if_exists(self) -> bool: - return False - - def supports_drop_constraint_if_exists(self) -> bool: - return False - - def format_add_index_action(self, action: "AddIndex") -> Tuple[str, tuple]: - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE ADD INDEX", - "SQLite does not support ALTER TABLE ADD INDEX. Use CREATE INDEX directly.", - ) - - def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, tuple]: - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE DROP INDEX", - "SQLite does not support ALTER TABLE DROP INDEX. Use DROP INDEX directly.", - ) - - def format_add_column_action(self, action: "AddColumn") -> Tuple[str, tuple]: - """Format ALTER TABLE ADD COLUMN for SQLite. - - SQLite supports ``ADD COLUMN`` but **not** the vendor extension - ``ADD COLUMN IF NOT EXISTS`` (and never jumps to a single-line - idempotent form). When ``if_not_exists=True`` is requested the - qualifier cannot be expressed; the caller should pre-check - ``PRAGMA table_info``. - """ - if getattr(action, "if_not_exists", None): - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE ADD COLUMN IF NOT EXISTS", - "SQLite does not support IF NOT EXISTS on ADD COLUMN. " - "Pre-check PRAGMA table_info before adding the column.", - ) - return super().format_add_column_action(action) - - def format_drop_column_action(self, action: "DropColumn") -> Tuple[str, tuple]: - """ALTER TABLE DROP COLUMN for SQLite. - - SQLite (>= 3.35.0) supports plain ``DROP COLUMN`` but **not** the - vendor extension ``DROP COLUMN IF EXISTS``. Guard against emitting - the unsupported qualifier. + """SQLite-specific column constraint and column definition formatting. + + What is left here is what SQLite genuinely spells differently: the + ``AUTOINCREMENT`` tail on its primary-key grammar, booleans inlined as + ``1``/``0``, and the placement rules only the column definition can + check. Everything else the core already renders — its IF [NOT] EXISTS + probes default to the answers SQLite gives, so the shared formatters + refuse those qualifiers without a per-dialect copy. + """ + + def format_column_primary_key_constraint( + self, constraint: "ColumnPrimaryKeyConstraint" + ) -> Tuple[str, tuple]: + """SQLite's PRIMARY KEY, which is where ``AUTOINCREMENT`` lives. + + ``AUTOINCREMENT`` is part of SQLite's primary-key grammar -- it follows + ``PRIMARY KEY`` and nowhere else -- so it is appended here rather than + in the shared renderer, which knows nothing about it. + + Placement is checked in :meth:`format_column_definition`, because + whether the column is an ``INTEGER`` is a fact only the column + definition holds. """ - if getattr(action, "if_exists", None): - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE DROP COLUMN IF EXISTS", - "SQLite does not support IF EXISTS on DROP COLUMN. " - "Pre-check PRAGMA table_info before dropping the column.", - ) - return super().format_drop_column_action(action) - - def format_drop_table_constraint_action(self, action: "DropTableConstraint") -> Tuple[str, tuple]: - """ALTER TABLE DROP CONSTRAINT for SQLite. - - SQLite (>= 3.53.0) supports ``DROP CONSTRAINT`` for NOT NULL and - CHECK constraints but **not** the ``IF EXISTS`` qualifier. - """ - if getattr(action, "if_exists", None): - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE DROP CONSTRAINT IF EXISTS", - "SQLite does not support IF EXISTS on DROP CONSTRAINT. " - "Pre-check sqlite_master / the constraint catalog before dropping.", - ) - return super().format_drop_table_constraint_action(action) - - def format_primary_key_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format PRIMARY KEY constraint, optionally with AUTOINCREMENT.""" - result = " PRIMARY KEY" + sql, params = super().format_column_primary_key_constraint(constraint) if constraint.is_auto_increment: - result += " AUTOINCREMENT" - return result, () - - def format_not_null_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format NOT NULL constraint.""" - return " NOT NULL", () - - def format_null_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format NULL constraint.""" - return " NULL", () - - def format_column_unique_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format a COLUMN-level UNIQUE constraint. - - NOTE: deliberately named ``..._column_...`` so it cannot shadow the - TABLE-level ``SQLDialectBase.format_unique_constraint(t_const)`` - defined in ``backend.dialect.mixins.ddl_column`` — the two handle - column-level vs table-level constraints respectively. - """ - return " UNIQUE", () + sql += " AUTOINCREMENT" + return sql, params def format_default_value_clause(self, expr: "DefaultValueClause") -> Tuple[str, tuple]: """Format the value clause of a DEFAULT constraint (SQLite). @@ -130,90 +56,67 @@ def format_default_value_clause(self, expr: "DefaultValueClause") -> Tuple[str, values are inlined directly. Booleans render as ``1``/``0`` (SQLite has no native boolean literal); everything else delegates to the generic implementation. + + The value arrives as a ``Literal``, because the clause normalises a + bare scalar at construction, so the bool is read out of the literal + rather than off the clause. """ - from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.core import Literal value = expr.value - if isinstance(value, bool) and not isinstance(value, bases.BaseExpression): + if isinstance(value, Literal): + value = value.value + if isinstance(value, bool): return f"{'1' if value else '0'}", () return super().format_default_value_clause(expr) - def format_check_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format CHECK constraint.""" - if constraint.check_condition is None: - return "", () - check_sql, check_params = constraint.check_condition.to_sql() - enforcement = self._format_constraint_enforcement(constraint) - suffix = f" {enforcement}" if enforcement else "" - return f" CHECK ({check_sql}){suffix}", check_params - - def format_column_fk_constraint(self, constraint: "ColumnConstraint") -> Tuple[str, tuple]: - """Format a column-level FOREIGN KEY reference for SQLite.""" - from rhosocial.activerecord.backend.expression.statements import ReferentialAction - - if constraint.foreign_key_reference is None: - raise ValueError("Foreign key constraint must have a foreign_key_reference specified.") - referenced_table, referenced_columns = constraint.foreign_key_reference - ref_cols_str = ", ".join(self.format_identifier(col) for col in referenced_columns) - result = f" REFERENCES {referenced_table.to_sql()[0]}({ref_cols_str})" - - if constraint.on_delete is not None and constraint.on_delete != ReferentialAction.NO_ACTION: - result += f" ON DELETE {constraint.on_delete.value}" - if constraint.on_update is not None and constraint.on_update != ReferentialAction.NO_ACTION: - result += f" ON UPDATE {constraint.on_update.value}" - enforcement = self._format_constraint_enforcement(constraint) - if enforcement: - result += f" {enforcement}" - - return result, () - def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: - """Format a column definition for SQLite, including generated columns support.""" - from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType - - if getattr(col_def, "comment", None): - # SQLite has no comment mechanism (neither an inline COMMENT - # clause nor COMMENT ON); a comment on a column definition is - # never silently dropped. - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + """Format a column definition for SQLite. + + SQLite needs no column-definition grammar of its own. What it does need + is decided before delegating: an inline comment is refused (SQLite has + no column-comment mechanism at all), and ``AUTOINCREMENT`` is refused + anywhere but on an ``INTEGER`` primary key. Both are checked here rather + than in the renderer because both are facts about the column, and this + override is the only place that sees the column type. + """ + if col_def.comment: raise UnsupportedFeatureError( self.name, "COLUMN COMMENT", "SQLite has no column comment mechanism; comments are not supported.", ) + self._check_auto_increment_placement(col_def) - constraint_handlers = { - ColumnConstraintType.PRIMARY_KEY: self.format_primary_key_constraint, - ColumnConstraintType.NOT_NULL: self.format_not_null_constraint, - ColumnConstraintType.NULL: self.format_null_constraint, - ColumnConstraintType.UNIQUE: - self.format_column_unique_constraint, - ColumnConstraintType.DEFAULT: self.format_default_constraint, - ColumnConstraintType.CHECK: self.format_check_constraint, - ColumnConstraintType.FOREIGN_KEY: self.format_column_fk_constraint, - } - - all_params = [] - type_sql, _ = col_def.data_type.to_sql() - col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" - - for attr in getattr(col_def, "attributes", None) or (): - attr_sql, attr_params = self.format_column_attribute(attr) - col_sql += attr_sql - all_params.extend(attr_params) - - for constraint in col_def.constraints: - handler = constraint_handlers.get(constraint.constraint_type) - if handler: - sql_part, params = handler(constraint) - col_sql += sql_part - all_params.extend(params) - - if col_def.generated_expression is not None: - gen_sql, gen_params = col_def.generated_expression.to_sql() - col_sql += gen_sql - all_params.extend(gen_params) - - return col_sql, tuple(all_params) + sql, params = super().format_column_definition(col_def) + return sql, params + + def _check_auto_increment_placement(self, col_def: "ColumnDefinition") -> None: + """Refuse ``AUTOINCREMENT`` anywhere SQLite cannot spell it. + + SQLite accepts ``INTEGER PRIMARY KEY AUTOINCREMENT`` and nothing else. + A flag on any other constraint used to be dropped without a word, which + left a table whose key does not autoincrement and no trace of why. + """ + from rhosocial.activerecord.backend.expression.types.integer import ( + IntegerType, + ) + + flagged = [ + constraint + for constraint in col_def.constraints + if isinstance(constraint.branch, ColumnPrimaryKeyConstraint) + and constraint.branch.is_auto_increment + ] + if not flagged: + return + if not isinstance(col_def.data_type, IntegerType): + raise UnsupportedFeatureError( + self.name, + "AUTOINCREMENT on a non-INTEGER column", + f"SQLite spells AUTOINCREMENT only as part of INTEGER PRIMARY " + f"KEY AUTOINCREMENT, and this column is " + f"{type(col_def.data_type).__name__}.", + ) # ============================================================================= diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_view.py index b0a6d361c..a51f6f228 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/ddl_view.py @@ -10,11 +10,11 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) _SUGGESTION_MATERIALIZED_VIEW = "SQLite does not support materialized views." @@ -31,6 +31,15 @@ def supports_create_view(self) -> bool: """SQLite supports CREATE VIEW.""" return True + def supports_create_view_bind_parameters(self) -> bool: + """SQLite stores a view's text, so a bind in it has no meaning later. + + The placeholder would be stored in the view's SQL and left unbound + when the view is read, so the body is refused rather than stored + broken. + """ + return False + def supports_drop_view(self) -> bool: """SQLite supports DROP VIEW.""" return True @@ -84,7 +93,7 @@ def supports_with_data_clause(self) -> bool: ``CREATE TABLE ... AS`` is always populated).""" 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 SQLite.""" parts = ["CREATE"] if expr.temporary: @@ -116,7 +125,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 SQLite. SQLite does not accept CASCADE or RESTRICT on DROP VIEW; asking for @@ -138,16 +147,16 @@ def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, t parts.append(expr.view.to_sql()[0]) return " ".join(parts), () - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: + def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewStatement") -> Tuple[str, tuple]: """Format CREATE MATERIALIZED VIEW statement - not supported by SQLite.""" raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW_ALT) - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: + def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewStatement") -> Tuple[str, tuple]: """Format DROP MATERIALIZED VIEW statement - not supported by SQLite.""" raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW) def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" + self, expr: "RefreshMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format REFRESH MATERIALIZED VIEW statement - not supported by SQLite.""" raise UnsupportedFeatureError(self.name, "REFRESH MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/dml.py index 0e637fc43..2fce75d2c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/dml.py @@ -9,26 +9,18 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from ..expression.fts5 import SQLiteFTS5MatchExpression - from rhosocial.activerecord.backend.expression.statements import InsertExpression, ReturningClause + from ..expression.fts5 import SQLiteFTS5MatchPredicate + from rhosocial.activerecord.backend.expression.statements import InsertStatement, ReturningClause class SQLiteDMLMixin: """SQLite-specific INSERT and RETURNING formatting.""" - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: - """Format INSERT statement with SQLite-specific OR REPLACE / OR IGNORE support.""" - or_replace = getattr(expr, "or_replace", False) - or_ignore = getattr(expr, "or_ignore", False) - - if or_replace and or_ignore: - raise ValueError("Cannot specify both 'or_replace' and 'or_ignore'.") - if (or_replace or or_ignore) and expr.on_conflict: - raise ValueError( - "Cannot use 'or_replace'/'or_ignore' together with 'on_conflict'. " - "Use either the SQLite-specific OR REPLACE/IGNORE syntax or the " - "standard ON CONFLICT clause, but not both." - ) + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: + """Format INSERT statement with SQLite-specific OR REPLACE / OR IGNORE support. + The mutual-exclusion rules are enforced when the node is built, so the + render path only spells what the node was given. + """ if self.strict_validation: expr.validate(strict=True) @@ -50,24 +42,19 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] if isinstance(expr.source, DefaultValuesSource): source_sql = "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) - source_sql = "VALUES " + ", ".join(all_rows_sql) + # Delegate to the base formatter so the cell-vs-bare-value + # distinction is decided once, in format_insert_values_source, + # rather than re-implemented here. + source_sql, values_params = self.format_insert_values_source(expr.source) + all_params.extend(values_params) elif isinstance(expr.source, SelectSource): s_sql, s_params = expr.source.select_query.to_sql() source_sql = s_sql all_params.extend(s_params) - if or_replace: + if expr.or_replace: sql = f"INSERT OR REPLACE INTO {table_sql} {columns_sql} {source_sql}".strip() - elif or_ignore: + elif expr.or_ignore: sql = f"INSERT OR IGNORE INTO {table_sql} {columns_sql} {source_sql}".strip() else: sql = f"INSERT INTO {table_sql} {columns_sql} {source_sql}".strip() @@ -108,7 +95,7 @@ def format_returning_clause(self, clause: "ReturningClause") -> Tuple[str, tuple def format_match_predicate( self, - expr: "SQLiteFTS5MatchExpression", + expr: "SQLiteFTS5MatchPredicate", ) -> Tuple[str, tuple]: """Format full-text search MATCH predicate for FTS5.""" return self.format_fts5_match_expression(expr) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/extension.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/extension.py index 1a704db51..21c27ea0b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/extension.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/extension.py @@ -180,10 +180,12 @@ def detect_extensions(self) -> Dict[str, SQLiteExtensionInfo]: """ result = {} for name, meta in _EXTENSION_METADATA.items(): - available = self._is_extension_enabled_in_build(name) and self.version >= meta["min_version"] + available = self._is_extension_enabled_in_build(name) and self.version_at_least( + meta["min_version"] + ) features = [ fname for fname, fver in meta.get("features", {}).items() - if self.version >= fver + if self.version_at_least(fver) ] result[name] = SQLiteExtensionInfo( name=name, @@ -211,7 +213,7 @@ def is_extension_available(self, name: str) -> bool: return False if not self._is_extension_enabled_in_build(name): return False - return self.version >= meta["min_version"] + return self.version_at_least(meta["min_version"]) def get_extension_info(self, name: str) -> Optional[SQLiteExtensionInfo]: """Get information about a specific extension. @@ -225,10 +227,12 @@ def get_extension_info(self, name: str) -> Optional[SQLiteExtensionInfo]: meta = _EXTENSION_METADATA.get(name) if not meta: return None - available = self._is_extension_enabled_in_build(name) and self.version >= meta["min_version"] + available = self._is_extension_enabled_in_build(name) and self.version_at_least( + meta["min_version"] + ) features = [ fname for fname, fver in meta.get("features", {}).items() - if self.version >= fver + if self.version_at_least(fver) ] return SQLiteExtensionInfo( name=name, @@ -259,7 +263,7 @@ def check_extension_feature(self, ext_name: str, feature_name: str) -> bool: feat_version = meta.get("features", {}).get(feature_name) if feat_version is None: return False - return self.version >= feat_version + return self.version_at_least(feat_version) def get_supported_extension_features(self, ext_name: str) -> List[str]: """Get list of supported features for an extension. @@ -275,5 +279,5 @@ def get_supported_extension_features(self, ext_name: str) -> List[str]: return [] return [ fname for fname, fver in meta.get("features", {}).items() - if self._is_extension_enabled_in_build(ext_name) and self.version >= fver + if self._is_extension_enabled_in_build(ext_name) and self.version_at_least(fver) ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/fts5.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/fts5.py index d8e63f2ae..5282f929d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/fts5.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/fts5.py @@ -17,7 +17,7 @@ from ..expression.fts5 import ( SQLiteFTS5CreateVirtualTable, SQLiteFTS5HighlightExpression, - SQLiteFTS5MatchExpression, + SQLiteFTS5MatchPredicate, SQLiteFTS5RankExpression, SQLiteFTS5SnippetExpression, ) @@ -38,7 +38,7 @@ def supports_fts5(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_FTS5" in compile_options - return self.version >= (3, 9, 0) + return self.version_at_least((3, 9, 0)) def supports_fts5_bm25(self) -> bool: """Whether BM25 ranking function is supported (available since FTS5 was introduced).""" @@ -55,19 +55,19 @@ def supports_fts5_snippet(self) -> bool: def get_supported_fts5_tokenizers(self) -> List[str]: """Get list of supported FTS5 tokenizers.""" tokenizers = ["unicode61", "ascii", "porter"] - if self.supports_fts5() and self.version >= (3, 34, 0): + if self.supports_fts5() and self.version_at_least((3, 34, 0)): tokenizers.append("trigram") return tokenizers # ========== SQL Formatting ========== def format_fts5_match_expression( - self, expr: "SQLiteFTS5MatchExpression" + self, expr: "SQLiteFTS5MatchPredicate" ) -> Tuple[str, tuple]: """Format FTS5 MATCH expression. Args: - expr: SQLiteFTS5MatchExpression instance + expr: SQLiteFTS5MatchPredicate instance Returns: Tuple of (SQL string, parameters tuple) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/function.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/function.py index b4bdc8f34..1bf345a74 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/function.py @@ -10,9 +10,9 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression import bases from rhosocial.activerecord.backend.expression.advanced_functions import ( - LpadExpression, - RepeatExpression, - RpadExpression, + LpadFunction, + RepeatFunction, + RpadFunction, ) @@ -174,7 +174,7 @@ def _pad_copies(self, count, pad): pad, ) - def format_repeat_expression(self, expr: "RepeatExpression") -> Tuple[str, tuple]: + def format_repeat_expression(self, expr: "RepeatFunction") -> Tuple[str, tuple]: """Emulate ``REPEAT``, which SQLite does not have. Each ``00`` pair in ``HEX(ZEROBLOB(count))`` is one copy slot, so @@ -195,7 +195,7 @@ def format_repeat_expression(self, expr: "RepeatExpression") -> Tuple[str, tuple ) return call.to_sql() - def format_lpad_expression(self, expr: "LpadExpression") -> Tuple[str, tuple]: + def format_lpad_expression(self, expr: "LpadFunction") -> Tuple[str, tuple]: """Emulate ``LPAD``, which SQLite does not have. Two branches, because SQLite alone answers "already long enough" and @@ -233,7 +233,7 @@ def format_lpad_expression(self, expr: "LpadExpression") -> Tuple[str, tuple]: ) return case.to_sql() - def format_rpad_expression(self, expr: "RpadExpression") -> Tuple[str, tuple]: + def format_rpad_expression(self, expr: "RpadFunction") -> Tuple[str, tuple]: """Emulate ``RPAD``, which SQLite does not have. One branch is enough: the padding is appended on the right and the @@ -329,3 +329,164 @@ def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple return FunctionCallMixin.format_function_call(self, translated) return FunctionCallMixin.format_function_call(self, expr) + # The spellings above used to be applied by rewriting ``func_name`` inside + # ``format_function_call``. The nodes that carry them now have their own + # formatter, so the differences are stated here per node instead -- which is + # both shorter and narrower: each override names the one spelling it + # changes, and a node SQLite agrees with needs nothing at all. + + def format_truncate_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite's ``TRUNC`` takes one argument, so a precision is arithmetic. + + The value is scaled by a power of ten, truncated toward zero and scaled + back, which is exact for negatives too -- truncation is symmetric there, + unlike rounding. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal + from rhosocial.activerecord.backend.expression.operators import BinaryExpression + + if len(expr.arguments) < 2: + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"TRUNC({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + precision = expr.arguments[1] + scale = FunctionCall(self, "POW", Literal(self, 10), precision) + scaled = BinaryExpression(self, "*", expr.arguments[0], scale) + return BinaryExpression( + self, "/", FunctionCall(self, "TRUNC", scaled), scale + ).to_sql() + + def format_logarithm_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite takes the **base first**: ``LOG(base, x)``. + + The node holds the value first, as the standard writes it, so the two + are swapped here. That is a spelling difference and not a semantic one -- + the meaning is fixed by the node. + """ + if len(expr.arguments) < 2: + parts_sql, parts_params = [], [] + for operand in expr.arguments: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"LOG({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + parts_sql, parts_params = [], [] + for operand in [expr.arguments[1], expr.arguments[0]]: + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"LOG({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_ascii_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite spells ``ASCII`` as ``UNICODE``.""" + parts_sql, parts_params = [], [] + for operand in (expr.target,): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"UNICODE({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_position_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite's native spelling is ``INSTR(haystack, needle)``. + + ``INSTR`` takes the haystack first, which is the order this node already + holds, so the standard's reversed ``POSITION(needle IN haystack)`` needs + no rearranging here -- :attr:`spelling` only matters to a backend that + has to write the standard's form. + """ + parts_sql, parts_params = [], [] + for operand in (expr.haystack, expr.needle): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"INSTR({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_left_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite has no ``LEFT``; ``SUBSTR(x, 1, n)`` is the same thing.""" + from rhosocial.activerecord.backend.expression.core import Literal + + parts_sql, parts_params = [], [] + for operand in (expr.target, Literal(self, 1), expr.count): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"SUBSTR({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_right_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """SQLite has no ``RIGHT``; read from a computed start instead. + + ``SUBSTR(x, LENGTH(x) - n + 1, n)`` reads from the end when *n* exceeds + the length -- it returns the tail instead of the whole string -- so the + start is clamped with ``MAX(..., 1)``. Verified against MySQL, MariaDB, + ClickHouse and BigQuery for n = 0, 1, len-1, len, len+1. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal + from rhosocial.activerecord.backend.expression.operators import BinaryExpression + + length_call = FunctionCall(self, "LENGTH", expr.target) + start = BinaryExpression( + self, + "+", + BinaryExpression(self, "-", length_call, expr.count), + Literal(self, 1), + ) + parts_sql, parts_params = [], [] + for operand in (expr.target, FunctionCall(self, "MAX", start, Literal(self, 1)), expr.count): + operand_sql, operand_params = operand.to_sql() + parts_sql.append(operand_sql) + parts_params.extend(operand_params) + joined_sql = ", ".join(parts_sql) + sql = f"SUBSTR({joined_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(parts_params) + + + def format_sqlite_json_extract_function(self, expr) -> Tuple[str, tuple]: + """Render ``json_extract(expression, path)``. + + SQLite specific: the same concept is ``->`` / ``->>`` on PostgreSQL and + ``JSON_EXTRACT`` with its own path grammar on MySQL, which is why this + node lives in the SQLite package. The path is an expression, so it + binds as a parameter rather than being spliced into the SQL text. + """ + expr_sql, expr_params = expr.expression.to_sql() + path_sql, path_params = expr.path.to_sql() + sql = f"JSON_EXTRACT({expr_sql}, {path_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(expr_params) + tuple(path_params) + diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/geopoly.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/geopoly.py index 0c6c2ef32..1c064b973 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/geopoly.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/geopoly.py @@ -36,7 +36,7 @@ def supports_geopoly(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_GEOPOLY" in compile_options - return self.version >= (3, 26, 0) + return self.version_at_least((3, 26, 0)) # ========== SQL Formatting ========== @@ -55,19 +55,14 @@ def format_geopoly_create_virtual_table(self, expr: "SQLiteGeopolyCreateVirtualT ) table = expr.table.to_sql()[0] - cols = ", ".join(self.format_identifier(c) for c in expr.extra_columns) if expr.extra_columns else "" - if expr.content_table is not None: - self._validate_safe_identifier(expr.content_table.name) - sql = f"CREATE VIRTUAL TABLE {table} USING geopoly({cols}, content='{self._escape_sql_string(expr.content_table.name)}')" - return sql, () - elif not cols: + cols = ", ".join(self.format_identifier(c) for c in expr.extra_columns) + if not cols: sql = f"CREATE VIRTUAL TABLE {table} USING geopoly()" - return sql, () else: sql = f"CREATE VIRTUAL TABLE {table} USING geopoly({cols})" - return sql, () + return sql, () - def format_geopoly_contains_query(self, expr: "SQLiteGeopolyContainsExpression") -> Tuple[str, tuple]: + def format_geopoly_contains_expression(self, expr: "SQLiteGeopolyContainsExpression") -> Tuple[str, tuple]: """Format point-in-polygon query. Args: diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/introspection.py index d225d0efa..0d2ce9d89 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/introspection.py @@ -5,23 +5,39 @@ This module provides the SQLiteIntrospectionCapabilityMixin class. """ -from typing import List, Tuple, TYPE_CHECKING +import re +from typing import List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, + ) + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteIndexDdlStatement, + SQLitePragmaQueryStatement, + SQLitePragmaSetStatement, + SQLiteTableCountStatement, + SQLiteTableDdlStatement, ) from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + +#: A PRAGMA value the engine reads in its own vocabulary rather than as a SQL +#: literal: keywords (``WAL``, ``ON``) and numbers (``1000``). Anything else is +#: written as an escaped string literal, which is the form that cannot carry +#: statement text into the assignment. +_PRAGMA_VALUE_TOKEN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_+\-.]*") + + class SQLiteIntrospectionCapabilityMixin: """SQLite introspection capability declaration. @@ -74,8 +90,7 @@ def supports_foreign_key_introspection(self) -> bool: Note: Foreign key constraints are disabled by default and must be enabled via 'PRAGMA foreign_keys = ON'. """ - version = self.version - return version >= (3, 6, 19) + return self.version_at_least((3, 6, 19)) def supports_view_introspection(self) -> bool: """SQLite supports view introspection.""" @@ -136,7 +151,7 @@ def supports_table_list_pragma(self) -> bool: True if PRAGMA table_list is available. If False, fallback to sqlite_master query. """ - return self.version >= (3, 37, 0) + return self.version_at_least((3, 37, 0)) def supports_table_xinfo_pragma(self) -> bool: """PRAGMA table_xinfo (includes hidden columns) requires SQLite 3.26.0+. @@ -145,7 +160,7 @@ def supports_table_xinfo_pragma(self) -> bool: True if PRAGMA table_xinfo is available. If False, use PRAGMA table_info instead. """ - return self.version >= (3, 26, 0) + return self.version_at_least((3, 26, 0)) def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: """Get list of supported introspection scopes.""" @@ -166,7 +181,72 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== - def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[str, tuple]: + # ------------------------------------------------------------------ + # The sqlite_master READ, expressed once + # ------------------------------------------------------------------ + def sqlite_master_query( + self, + schema: "str", + projections: "list", + conditions: "list", + order_by: "Optional[list]" = None, + ) -> Tuple[str, tuple]: + """Build a SELECT over ``sqlite_master`` as an expression tree. + + Every introspection query that reads ``sqlite_master`` is this shape -- + a projection, a conjunction of conditions, and an optional ordering -- + so the shape lives here and each formatter supplies only what varies. + What a query *asks* is then a structure the rest of the framework can + inspect, instead of a string reassembled one clause at a time. + + The relation is qualified by *schema*, which on this backend is the name + of the database being read -- ``main`` by default, or an + ``ATTACH``-ed database. Dropping the qualifier is not neutral: it would + silently read the primary database when a caller asked for another one, + returning rows from the wrong database instead of either working or + failing. :class:`~...expression.table.SQLiteTable` carries the name, and + this dialect names it because SQLite's schema is that database name + rather than the SQL-standard construct. + + Args: + schema: The database whose ``sqlite_master`` is read. + projections: The select list, in order. + conditions: Conditions to AND together. + order_by: The ordering expressions, or ``None`` for no ORDER BY. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + LogicalPredicate, + OrderByClause, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.sources.relation import ( + NamedRelationRef, + ) + from ..expression import SQLiteTable + + kwargs: "dict" = { + "select": projections, + "from_": NamedRelationRef( + self, + SQLiteTable( + self, + "sqlite_master", + schema_name=schema, + schema_need_quote=False, + name_need_quote=False, + ), + ), + "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_statement(self, expr: "DatabaseInfoQueryStatement") -> Tuple[str, tuple]: """Format database information query. SQLite doesn't have a specific query for database info, so we use @@ -178,10 +258,152 @@ def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[st Returns: Tuple of (SQL string, empty parameters tuple). """ - sql = "SELECT sqlite_version() AS version" - return (sql, ()) + from rhosocial.activerecord.backend.expression import FunctionCall, QueryStatement + + # No FROM: this query reads no table, it asks the engine a question. + return QueryStatement( + self, + select=[FunctionCall(self, "sqlite_version", alias="version")], + ).to_sql() + + def format_pragma_query_statement(self, expr: "SQLitePragmaQueryStatement") -> Tuple[str, tuple]: + """Format a PRAGMA read. + + PRAGMA takes no bind parameters, so the parameters tuple is empty and + the argument, when the pragma has one, is placed as it was given. The + statement class does not quote it: the caller that supplies a table or + index name spells it the way the rest of the dialect spells + identifiers, and a second quoting pass would change it. + + Args: + expr: The PRAGMA question. + + Returns: + Tuple of (SQL string, empty parameters tuple). + """ + name = f"{expr.schema}.{expr.name}" if expr.schema else expr.name + if expr.argument is None: + return (f"PRAGMA {name}", ()) + return (f"PRAGMA {name}({expr.argument})", ()) + + def format_pragma_set_statement(self, expr: "SQLitePragmaSetStatement") -> Tuple[str, tuple]: + """Format a PRAGMA assignment. + + The value is statement text, not a bind parameter. A bare keyword or + number keeps its own spelling (``WAL``, ``1000``); everything else is + written as an escaped string literal, so a value can never close the + statement it appears in. + + Args: + expr: The PRAGMA assignment. + + Returns: + Tuple of (SQL string, empty parameters tuple). + """ + name = f"{expr.schema}.{expr.name}" if expr.schema else expr.name + if expr.argument is not None: + name = f"{name}({expr.argument})" + + if isinstance(expr.value, str) and _PRAGMA_VALUE_TOKEN.fullmatch(expr.value): + value = expr.value + else: + value = self.format_literal(expr.value) + + return (f"PRAGMA {name} = {value}", ()) + + def format_table_count_statement(self, expr: "SQLiteTableCountStatement") -> Tuple[str, tuple]: + """Count the tables of one database, system tables excluded. + + ``sqlite_master`` names its kind in ``type`` and hides the engine's own + tables behind the ``sqlite_`` prefix, so both facts are conditions on + the same read rather than a filter applied to a listing afterwards. + + Args: + expr: The count question. - def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tuple]: + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + LikeOperator, + LikePredicate, + Literal, + count, + ) + + return self.sqlite_master_query( + expr.schema or "main", + [count(self, "*", alias="table_count")], + [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "table", inline=True) + ), + LikePredicate( + self, + LikeOperator.NOT_LIKE, + Column(self, "name"), + Literal(self, "sqlite_%", inline=True), + ), + ], + ) + + def format_table_ddl_statement(self, expr: "SQLiteTableDdlStatement") -> Tuple[str, tuple]: + """Read back the statement a table was declared with. + + ``name`` and ``tbl_name`` are both matched: an index row carries the + indexed table in ``tbl_name``, a table row carries its own name in + both, so requiring both selects the table row alone. + + Args: + expr: The DDL question. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + + return self.sqlite_master_query( + expr.schema or "main", + [Column(self, "sql")], + [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "table", inline=True) + ), + ComparisonPredicate(self, "eq", Column(self, "name"), Literal(self, expr.table_name)), + ComparisonPredicate( + self, "eq", Column(self, "tbl_name"), Literal(self, expr.table_name) + ), + ], + ) + + def format_index_ddl_statement(self, expr: "SQLiteIndexDdlStatement") -> Tuple[str, tuple]: + """Read back the statement an index was declared with. + + Only the index's own row carries the ``WHERE`` clause of a partial + index, and this is the only place that clause can be read from. + + Args: + expr: The DDL question. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + + return self.sqlite_master_query( + expr.schema or "main", + [Column(self, "sql")], + [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "index", inline=True) + ), + ComparisonPredicate(self, "eq", Column(self, "name"), Literal(self, expr.index_name)), + ], + ) + + def format_table_list_query_statement(self, expr: "TableListQueryStatement") -> Tuple[str, tuple]: """Format table list query. For SQLite 3.37.0+, use PRAGMA table_list. @@ -203,31 +425,65 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup # since PRAGMA cannot filter them out at the SQL level. # For user-only queries, sqlite_master provides native filtering. if self.supports_table_list_pragma() and include_system: - sql = f"PRAGMA {schema}.table_list" - return (sql, ()) + from ..expression.introspection import SQLitePragmaQueryStatement + + return SQLitePragmaQueryStatement(self, "table_list", schema=schema).to_sql() # Fallback to sqlite_master query (also used for include_system=False - # because sqlite_master supports NOT LIKE filtering natively) - sql = "SELECT name, type FROM sqlite_master WHERE type IN ('table'" - if include_views: - sql += ", 'view'" - sql += ")" + # because sqlite_master supports NOT LIKE filtering natively). + # + # The intent is carried by a tree rather than concatenated, so what this + # query asks is a structure the rest of the framework can inspect, and + # the dialect owns only the spelling. The projection is name before + # type, the IN list is literal-inline so the parameters stay empty + # exactly as the concatenated form left them, and the system-table + # exclusion is a NOT LIKE predicate rather than appended text. + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + LikeOperator, + LikePredicate, + Literal, + LogicalPredicate, + OrderByClause, + QueryStatement, + WhereClause, + ) + + values = ("table", "view") if include_views else ("table",) + conditions = [InPredicate(self, Column(self, "type"), Literal(self, values, inline=True))] # Exclude system tables unless requested if not include_system: - sql += " AND name NOT LIKE 'sqlite_%'" + conditions.append( + LikePredicate( + self, + LikeOperator.NOT_LIKE, + Column(self, "name"), + Literal(self, "sqlite_%", inline=True), + ) + ) # Filter by table type if specified if table_type: - if table_type == "BASE TABLE": - sql += " AND type = 'table'" - elif table_type == "VIEW": - sql += " AND type = 'view'" - - sql += " ORDER BY name" - return (sql, ()) - - def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tuple]: + spelled = "table" if table_type == "BASE TABLE" else table_type.lower() + conditions.append( + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, spelled, inline=True) + ) + ) + + query = QueryStatement( + self, + select=[Column(self, "name"), Column(self, "type")], + from_="sqlite_master", + where=WhereClause(self, LogicalPredicate(self, "AND", *conditions)), + order_by=OrderByClause(self, [Column(self, "name")]), + ) + return query.to_sql() + + def format_table_info_query_statement(self, expr: "TableInfoQueryStatement") -> Tuple[str, tuple]: """Format single table information query. Query sqlite_master for table details. @@ -241,11 +497,15 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema") or "main" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal - sql = f"SELECT name, type, sql FROM {schema}.sqlite_master WHERE name = {self.p()}" - return (sql, (table_name,)) + return self.sqlite_master_query( + schema, + [Column(self, "name"), Column(self, "type"), Column(self, "sql")], + [ComparisonPredicate(self, "eq", Column(self, "name"), Literal(self, table_name))], + ) - 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. For SQLite 3.26.0+, can use PRAGMA table_xinfo to include hidden columns. @@ -262,15 +522,15 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t include_hidden = params.get("include_hidden", False) schema = params.get("schema") or "main" - # Use table_xinfo for hidden columns if supported and requested - if include_hidden and self.supports_table_xinfo_pragma(): - sql = f"PRAGMA {schema}.table_xinfo({self.format_identifier(table_name)})" - else: - sql = f"PRAGMA {schema}.table_info({self.format_identifier(table_name)})" + from ..expression.introspection import SQLitePragmaQueryStatement - return (sql, ()) + # Use table_xinfo for hidden columns if supported and requested + pragma = "table_xinfo" if include_hidden and self.supports_table_xinfo_pragma() else "table_info" + return SQLitePragmaQueryStatement( + self, pragma, self.format_identifier(table_name), schema=schema + ).to_sql() - 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. Use PRAGMA index_list to get indexes for a table. @@ -285,10 +545,13 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup table_name = params.get("table_name", "") schema = params.get("schema") or "main" - sql = f"PRAGMA {schema}.index_list({self.format_identifier(table_name)})" - return (sql, ()) + from ..expression.introspection import SQLitePragmaQueryStatement - def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, tuple]: + return SQLitePragmaQueryStatement( + self, "index_list", self.format_identifier(table_name), schema=schema + ).to_sql() + + def format_foreign_key_query_statement(self, expr: "ForeignKeyQueryStatement") -> Tuple[str, tuple]: """Format foreign key information query. Use PRAGMA foreign_key_list to get foreign keys for a table. @@ -303,10 +566,13 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t table_name = params.get("table_name", "") schema = params.get("schema") or "main" - sql = f"PRAGMA {schema}.foreign_key_list({self.format_identifier(table_name)})" - return (sql, ()) + from ..expression.introspection import SQLitePragmaQueryStatement + + return SQLitePragmaQueryStatement( + self, "foreign_key_list", self.format_identifier(table_name), schema=schema + ).to_sql() - def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple]: # noqa: F821 + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: # noqa: F821 """Format view list query. Query sqlite_master for views. @@ -321,16 +587,42 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple schema = params.get("schema") or "main" include_system = params.get("include_system", False) - sql = f"SELECT name FROM {schema}.sqlite_master WHERE type = 'view'" - + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + LikeOperator, + LikePredicate, + Literal, + ) + + conditions = [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "view", inline=True) + ) + ] # Exclude system views unless requested if not include_system: - sql += " AND name NOT LIKE 'sqlite_%'" - - sql += " ORDER BY name" - return (sql, ()) - - def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple]: # noqa: F821 + conditions.append( + LikePredicate( + self, + LikeOperator.NOT_LIKE, + Column(self, "name"), + Literal(self, "sqlite_%", inline=True), + ) + ) + + # ``sql`` is the view's own text and it must be projected: the parser + # reads it for ``IntrospectionViewInfo.definition``, and a projection + # without it silently reports every view as having no definition -- + # which is not the same answer as "this view has none". + return self.sqlite_master_query( + schema, + [Column(self, "name"), Column(self, "sql")], + conditions, + [Column(self, "name")], + ) + + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: # noqa: F821 """Format single view information query. Query sqlite_master for view details. @@ -345,10 +637,24 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple view_name = params.get("view_name", "") schema = params.get("schema") or "main" - sql = f"SELECT name, sql FROM {schema}.sqlite_master WHERE type = 'view' AND name = {self.p()}" - return (sql, (view_name,)) + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + conditions = [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "view", inline=True) + ), + # The name is bound, exactly as the concatenated form bound it. + ComparisonPredicate(self, "eq", Column(self, "name"), Literal(self, view_name)), + ] + return self.sqlite_master_query( + schema, [Column(self, "name"), Column(self, "sql")], conditions + ) - def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: # noqa: F821 + def format_trigger_list_query_statement(self, expr: "TriggerListQueryStatement") -> Tuple[str, tuple]: # noqa: F821 """Format trigger list query. Query sqlite_master for triggers, optionally filtered by table. @@ -363,15 +669,37 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, table_name = params.get("table_name") schema = params.get("schema") or "main" - sql = f"SELECT name, tbl_name, sql FROM {schema}.sqlite_master WHERE type = 'trigger'" - if table_name: - sql += f" AND tbl_name = {self.p()}" - return (sql, (table_name,)) - - sql += " ORDER BY name" - return (sql, ()) + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) - def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, tuple]: # noqa: F821 + conditions = [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "trigger", inline=True) + ) + ] + # Filtering by table drops the ordering -- an asymmetry worth keeping + # visible in the tree rather than hiding in a string branch. + if table_name: + conditions.append( + ComparisonPredicate(self, "eq", Column(self, "tbl_name"), Literal(self, table_name)) + ) + return self.sqlite_master_query( + schema, + [Column(self, "name"), Column(self, "tbl_name"), Column(self, "sql")], + conditions, + ) + + return self.sqlite_master_query( + schema, + [Column(self, "name"), Column(self, "tbl_name"), Column(self, "sql")], + conditions, + [Column(self, "name")], + ) + + def format_trigger_info_query_statement(self, expr: "TriggerInfoQueryStatement") -> Tuple[str, tuple]: # noqa: F821 """Format single trigger information query. Query sqlite_master for trigger details. @@ -386,6 +714,21 @@ def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, trigger_name = params.get("trigger_name", "") schema = params.get("schema") or "main" - sql = f"SELECT name, tbl_name, sql FROM {schema}.sqlite_master WHERE type = 'trigger' AND name = {self.p()}" - return (sql, (trigger_name,)) + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + conditions = [ + ComparisonPredicate( + self, "eq", Column(self, "type"), Literal(self, "trigger", inline=True) + ), + ComparisonPredicate(self, "eq", Column(self, "name"), Literal(self, trigger_name)), + ] + return self.sqlite_master_query( + schema, + [Column(self, "name"), Column(self, "tbl_name"), Column(self, "sql")], + conditions, + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/literal.py new file mode 100644 index 000000000..356cdfd70 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/literal.py @@ -0,0 +1,43 @@ +# src/rhosocial/activerecord/backend/impl/sqlite/mixins/literal.py +"""SQLite'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 itself, and the literal-construction protocol. This +module is SQLite's second tier. SQLite has two such types and no more: + +* ``bytes`` becomes a blob literal, ``X'0102FF'`` with uppercase hex. It is + rendered from the bytes rather than from the base64 spelling an artifact + carries, which is why a node holds ``bytes`` in the first place. +* ``UUID`` becomes quoted text, because all SQLite has is a ``TEXT`` column. + +Everything else falls through to the base implementation, which is where the +portable forms and the protocol live. Nothing here is SQLite-specific except +the two spellings above. +""" + +import uuid +from typing import Any + + +class LiteralValueMixin: + """SQLite's tier-2 branches of :meth:`format_literal`.""" + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, SQLite'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 SQLite has no + literal form for, and this is the same refusal. + """ + 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) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/maintenance.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/maintenance.py index 13f2ace6f..8f6529f8e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/maintenance.py @@ -11,8 +11,8 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from ..expression.attach import SQLiteAttachExpression, SQLiteDetachExpression - from ..expression.vacuum import SQLiteAnalyzeExpression, SQLiteVacuumExpression + from ..expression.attach import SQLiteAttachStatement, SQLiteDetachStatement + from ..expression.vacuum import SQLiteAnalyzeStatement, SQLiteVacuumStatement class SQLiteMaintenanceMixin: @@ -28,7 +28,7 @@ def supports_vacuum(self) -> bool: def supports_vacuum_into(self) -> bool: """VACUUM INTO 'filename' is supported since SQLite 3.27.0.""" - return self.version >= (3, 27, 0) + return self.version_at_least((3, 27, 0)) def supports_analyze(self) -> bool: """ANALYZE is supported since SQLite 3.0.""" @@ -42,7 +42,7 @@ def supports_detach(self) -> bool: """DETACH DATABASE is supported since SQLite 3.0.""" return True - def format_vacuum_statement(self, expr: "SQLiteVacuumExpression") -> Tuple[str, tuple]: + def format_vacuum_statement(self, expr: "SQLiteVacuumStatement") -> Tuple[str, tuple]: """Format the VACUUM statement for SQLite. SQLite VACUUM syntax: @@ -51,7 +51,7 @@ def format_vacuum_statement(self, expr: "SQLiteVacuumExpression") -> Tuple[str, - VACUUM INTO 'filename' -- Write vacuumed db to a new file (3.27.0+) Args: - expr: SQLiteVacuumExpression instance. + expr: SQLiteVacuumStatement instance. Returns: Tuple of (SQL string, empty parameters tuple). @@ -65,7 +65,7 @@ def format_vacuum_statement(self, expr: "SQLiteVacuumExpression") -> Tuple[str, raise UnsupportedFeatureError( self.name, "VACUUM INTO", "VACUUM INTO requires SQLite 3.27.0 or later." ) - escaped = self._escape_sql_string(expr.into) + escaped = self.escape_sql_string(expr.into) return f"VACUUM INTO '{escaped}'", () if expr.schema is not None: @@ -73,40 +73,40 @@ def format_vacuum_statement(self, expr: "SQLiteVacuumExpression") -> Tuple[str, return "VACUUM", () - def format_analyze_statement(self, expr: "SQLiteAnalyzeExpression") -> Tuple[str, tuple]: + def format_analyze_statement(self, expr: "SQLiteAnalyzeStatement") -> Tuple[str, tuple]: """Format the ANALYZE statement for SQLite. SQLite ANALYZE syntax: ``ANALYZE``. Args: - expr: SQLiteAnalyzeExpression instance. + expr: SQLiteAnalyzeStatement instance. Returns: Tuple of (SQL string, empty parameters tuple). """ return "ANALYZE", () - def format_attach_statement(self, expr: "SQLiteAttachExpression") -> Tuple[str, tuple]: + def format_attach_statement(self, expr: "SQLiteAttachStatement") -> Tuple[str, tuple]: """Format the ATTACH DATABASE statement for SQLite. SQLite ATTACH syntax: ``ATTACH DATABASE 'filename' AS schema_name``. Args: - expr: SQLiteAttachExpression instance. + expr: SQLiteAttachStatement instance. Returns: Tuple of (SQL string, empty parameters tuple). """ - escaped = self._escape_sql_string(expr.database) + escaped = self.escape_sql_string(expr.database) return f"ATTACH DATABASE '{escaped}' AS {self.format_identifier(expr.schema)}", () - def format_detach_statement(self, expr: "SQLiteDetachExpression") -> Tuple[str, tuple]: + def format_detach_statement(self, expr: "SQLiteDetachStatement") -> Tuple[str, tuple]: """Format the DETACH DATABASE statement for SQLite. SQLite DETACH syntax: ``DETACH DATABASE schema_name``. Args: - expr: SQLiteDetachExpression instance. + expr: SQLiteDetachStatement instance. Returns: Tuple of (SQL string, empty parameters tuple). diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/object_name.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/object_name.py new file mode 100644 index 000000000..2f7b26c55 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/object_name.py @@ -0,0 +1,78 @@ +# src/rhosocial/activerecord/backend/impl/sqlite/mixins/object_name.py +"""Naming objects SQLite can name, including the ones core namespaces refuse. + +:class:`.NamespaceMixin` answers whether a namespace level may appear on a name +by asking the dialect's capability probes. Those probes answer a *DDL* question +-- whether the engine has schemas and catalogues it can create -- and SQLite +answers them ``False`` truthfully and keeps answering that way. + +Object *naming* is a different question, and SQLite's answer differs from its +DDL answer: the engine will resolve ``main.sqlite_master`` and +``attached_name.table`` without complaint. So this mixin overrides naming without +touching the probes, and the difference is read off the node's identity: + +* a core object carrying ``schema_name`` is refused, unchanged, because SQLite + has no schema of that kind; +* a :class:`~...expression.table.SQLiteTable` carrying ``schema_name`` renders, + because it stands for an attached database, which SQLite does have; +* ``catalog_name`` stays refused either way -- SQLite has no level above the + attached-database name. + +This is why the two questions are kept apart rather than answered by one flag. +Answering naming with the DDL flag would make ``main.sqlite_master`` +inexpressible; answering DDL with a naming flag would make SQLite look as though +it had schemas it could create. +""" + +from typing import Tuple + +from rhosocial.activerecord.backend.dialect.mixins.schema_namespace import ( + NamespaceMixin, +) +from rhosocial.activerecord.backend.expression.objects import RelationObject +from rhosocial.activerecord.backend.impl.sqlite.expression.table import SQLiteTable + +__all__ = ["SQLiteObjectNameMixin"] + + +class SQLiteObjectNameMixin(NamespaceMixin): + """Renders a table name, accepting SQLite's own schema level.""" + + def format_table_object(self, expr: "RelationObject") -> Tuple[str, tuple]: + """Render *expr* as a table name. + + Args: + expr: The table being named. + + Returns: + A ``(sql, params)`` tuple; ``params`` is empty, because an + identifier is never a bind parameter. + + Raises: + UnsupportedFeatureError: A namespace level the dialect cannot + express -- for a core object, exactly as before; for a + ``SQLiteTable``, the catalogue level, which SQLite still lacks. + """ + if type(expr) is SQLiteTable and expr.schema_name and not expr.catalog_name: + parts = [ + self.format_identifier(expr.schema_name, expr.schema_need_quote), + self.format_identifier(expr.name, expr.name_need_quote), + ] + return self.separator.join(parts), () + + if type(expr) is SQLiteTable and expr.catalog_name: + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "catalog-qualified names", + suggestion=( + f"SQLiteTable carries catalog_name={expr.catalog_name!r}, and " + f"SQLite has no level above the attached-database name; the " + f"database belongs in schema_name" + ), + ) + + return super().format_table_object(expr) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/pragma_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/pragma_mixin.py index 6c4f09393..b87550480 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/pragma_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/pragma_mixin.py @@ -5,9 +5,13 @@ This module provides the SQLitePragmaMixin class. """ -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional + from ..pragma import PragmaCategory, PragmaInfo, get_pragma_info, get_all_pragma_infos, get_pragmas_by_category +if TYPE_CHECKING: + from ..expression.introspection import SQLitePragmaQueryStatement, SQLitePragmaSetStatement + class SQLitePragmaMixin: """Mixin for SQLite PRAGMA support. @@ -22,46 +26,88 @@ def get_pragma_info(self, name: str) -> Optional[PragmaInfo]: name: PRAGMA name Returns: - PragmaInfo, or None if not found + PragmaInfo, or None if not found in the registry or not available + in this SQLite version. + + Note: + A dialect that has not been adapted has no version to compare + against, so the registry answer stands. Adaptation + (``backend.introspect_and_adapt()``) is what makes the version + gate bite, and an un-adapted dialect keeps its old permissiveness. """ info = get_pragma_info(name) if info is None: return None - version = self.version + from rhosocial.activerecord.backend.dialect.exceptions import DialectNotAdaptedException + + try: + version = self.version + except DialectNotAdaptedException: + return info + if version < info.min_version: return None return info - def get_pragma_sql(self, name: str, argument: Any = None) -> str: - """Get SQL for reading a PRAGMA. + def pragma_query_statement( + self, + name: str, + argument: Any = None, + schema: Optional[str] = None, + ) -> "SQLitePragmaQueryStatement": + """The statement that reads a PRAGMA. + + This is where the registry is consulted. A name SQLite does not have, + or that this version does not have, is rejected before any SQL exists, + so an unknown name never reaches the engine as statement text. Args: name: PRAGMA name - argument: Optional argument + argument: Optional argument, already spelled for the engine + schema: Optional attached-database name Returns: - SQL string + The PRAGMA question, ready for ``to_sql()``. + + Raises: + ValueError: If the PRAGMA is not in the known whitelist or is not + available in this SQLite version. """ info = self.get_pragma_info(name) if info is None: raise ValueError(f"Unknown PRAGMA: {name}") - if info.requires_argument and argument is not None: - return f"PRAGMA {info.name}({argument})" - return f"PRAGMA {info.name}" + from ..expression.introspection import SQLitePragmaQueryStatement - def set_pragma_sql(self, name: str, value: Any, argument: Any = None) -> str: - """Get SQL for setting a PRAGMA. + return SQLitePragmaQueryStatement(self, info.name, argument, schema) + + def pragma_set_statement( + self, + name: str, + value: Any, + argument: Any = None, + schema: Optional[str] = None, + ) -> "SQLitePragmaSetStatement": + """The statement that assigns a PRAGMA. + + The registry is consulted here for the same reason as in + :meth:`pragma_query_statement`, and additionally decides whether the + pragma may be written at all and which values it accepts. Args: name: PRAGMA name value: Value to set - argument: Optional argument + argument: Optional argument, already spelled for the engine + schema: Optional attached-database name Returns: - SQL string + The PRAGMA assignment, ready for ``to_sql()``. + + Raises: + ValueError: If the PRAGMA is unknown, read-only, or the value is + not among its allowed values. """ info = self.get_pragma_info(name) if info is None: @@ -73,9 +119,34 @@ def set_pragma_sql(self, name: str, value: Any, argument: Any = None) -> str: if info.allowed_values and value not in info.allowed_values: raise ValueError(f"Invalid value '{value}' for PRAGMA {name}. Allowed values: {info.allowed_values}") - if info.requires_argument and argument is not None: - return f"PRAGMA {info.name}({argument}) = {value}" - return f"PRAGMA {info.name} = {value}" + from ..expression.introspection import SQLitePragmaSetStatement + + return SQLitePragmaSetStatement(self, info.name, value, argument, schema) + + def get_pragma_sql(self, name: str, argument: Any = None) -> str: + """Get SQL for reading a PRAGMA. + + Args: + name: PRAGMA name + argument: Optional argument + + Returns: + SQL string + """ + return self.pragma_query_statement(name, argument).to_sql()[0] + + def set_pragma_sql(self, name: str, value: Any, argument: Any = None) -> str: + """Get SQL for setting a PRAGMA. + + Args: + name: PRAGMA name + value: Value to set + argument: Optional argument + + Returns: + SQL string + """ + return self.pragma_set_statement(name, value, argument).to_sql()[0] def is_pragma_available(self, name: str) -> bool: """Check if a PRAGMA is available. diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/reindex.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/reindex.py index 5e1111020..5168afc4c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/reindex.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/reindex.py @@ -9,7 +9,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from ..expression.reindex import SQLiteReindexExpression + from ..expression.reindex import SQLiteReindexStatement class SQLiteReindexMixin: @@ -25,9 +25,9 @@ def supports_reindex(self) -> bool: def supports_reindex_expressions(self) -> bool: """SQLite 3.53.0+ supports REINDEX EXPRESSIONS.""" - return self.version >= (3, 53, 0) + return self.version_at_least((3, 53, 0)) - def format_reindex_statement(self, expr: "SQLiteReindexExpression") -> Tuple[str, tuple]: + def format_reindex_statement(self, expr: "SQLiteReindexStatement") -> Tuple[str, tuple]: """Format REINDEX statement for SQLite. SQLite REINDEX syntax: @@ -37,7 +37,7 @@ def format_reindex_statement(self, expr: "SQLiteReindexExpression") -> Tuple[str - REINDEX EXPRESSIONS -- Rebuild all expression indexes (3.53.0+) Args: - expr: SQLiteReindexExpression object + expr: SQLiteReindexStatement object Returns: Tuple of (SQL string, empty parameters tuple) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/rtree.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/rtree.py index c9bf6108f..79c7eff93 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/rtree.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/rtree.py @@ -32,7 +32,7 @@ def supports_rtree(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_RTREE" in compile_options - return self.version >= (3, 6, 0) + return self.version_at_least((3, 6, 0)) # ========== SQL Formatting ========== @@ -67,11 +67,17 @@ def format_rtree_create_virtual_table(self, expr: "SQLiteRTreeCreateVirtualTable if expr.content_table is not None: self._validate_safe_identifier(expr.content_table.name) parts.append( - f"content='{self._escape_sql_string(expr.content_table.name)}'" + f"content='{self.escape_sql_string(expr.content_table.name)}'" ) - if expr.content_rowid: - self._validate_safe_identifier(expr.content_rowid) - parts.append(f"content_rowid='{self._escape_sql_string(expr.content_rowid)}'") + # R-Tree counts each of content= and content_rowid= as a column + # of its own, and the total has to stay odd -- so the two go + # together, and one without the other is a statement SQLite + # refuses. The rowid name defaults to "rowid", which is what + # SQLite itself assumes, so naming it changes nothing but the + # column count. + rowid = expr.content_rowid or "rowid" + self._validate_safe_identifier(rowid) + parts.append(f"content_rowid='{self.escape_sql_string(rowid)}'") if parts: full = f"{cols_str}, {', '.join(parts)}" diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/set_operation.py index 80b8bd054..f982c9ccb 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/set_operation.py @@ -1,73 +1,15 @@ # src/rhosocial/activerecord/backend/impl/sqlite/mixins/set_operation.py """ -SQLite-specific Set Operation implementation. +SQLite-specific set-operation module. -This module provides the SQLiteSetOperationMixin class. -""" - -from typing import Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression import bases - -_SUGGESTION_FOR_UPDATE_SET_OP = "SQLite does not support FOR UPDATE clause in set operations (UNION, INTERSECT, EXCEPT)" - - -class SQLiteSetOperationMixin: - """SQLite-specific set operation (UNION, INTERSECT, EXCEPT) formatting.""" - - def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, Tuple]: - """Format set operation expression (UNION, INTERSECT, EXCEPT).""" - left = expr.left - right = expr.right - operation = expr.operation - alias = expr.alias - all_ = expr.all_ - distinct = expr.distinct - order_by_clause = expr.order_by_clause - limit_offset_clause = expr.limit_offset_clause - for_update_clause = expr.for_update_clause - left_sql, left_params = left.to_sql() - right_sql, right_params = right.to_sql() - if all_: - all_str = " ALL" - elif distinct: - all_str = " DISTINCT" - else: - all_str = "" - - base_sql = f"{left_sql} {operation}{all_str} {right_sql}" - - all_params = list(left_params + right_params) - sql_parts = [base_sql] - - if alias: - sql_parts.append(f"AS {self.format_identifier(alias)}") - - if order_by_clause: - order_by_sql, order_by_params = order_by_clause.to_sql() - sql_parts.append(order_by_sql) - all_params.extend(order_by_params) - - if limit_offset_clause: - limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() - sql_parts.append(limit_offset_sql) - all_params.extend(limit_offset_params) - - if for_update_clause: - if self.supports_set_operation_for_update(): - for_update_sql, for_update_params = for_update_clause.to_sql() - sql_parts.append(for_update_sql) - all_params.extend(for_update_params) - else: - raise UnsupportedFeatureError(self.name, "FOR UPDATE in set operations", _SUGGESTION_FOR_UPDATE_SET_OP) - - sql = " ".join(sql_parts) - return sql, tuple(all_params) +Set-operation formatting is provided by the core +``SetOperationMixin.format_set_operation`` (the SQLite-specific override that +used to live here rendered trailing clauses the node no longer carries, so +the core implementation is authoritative now). +This module keeps the view DDL support that shares the file. +""" # ============================================================================= # SQLiteViewMixin — View DDL support # ============================================================================= - diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/transaction.py index 22938d540..e0518347c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/transaction.py @@ -8,7 +8,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression + from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement class SQLiteTransactionMixin: """SQLite transaction control formatting.""" @@ -37,7 +37,7 @@ def supports_savepoint(self) -> bool: """SQLite supports savepoints.""" return True - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format BEGIN TRANSACTION statement for SQLite. SQLite has no DEFERRABLE transaction mode; a requested spelling is diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/trigger.py index 6bfcf3f77..8a7763b49 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/trigger.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) @@ -50,7 +50,7 @@ def supports_trigger_if_not_exists(self) -> bool: return True def format_create_trigger_statement( - self, expr: "CreateTriggerExpression" + self, expr: "CreateTriggerStatement" ) -> "Tuple[str, tuple]": """Format CREATE TRIGGER statement for SQLite.""" from rhosocial.activerecord.backend.expression.statements import TriggerLevel @@ -88,7 +88,7 @@ def format_create_trigger_statement( return " ".join(parts), tuple(all_params) - 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 for SQLite.""" parts = ["DROP TRIGGER"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/types.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/types.py index 64cd7aa4a..096209970 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/types.py @@ -83,7 +83,13 @@ def _refuse_unsigned_integer(self, data_type, word: str) -> None: stays: it is SQLite's documented storage model, and it says nothing about range. Signedness is not a width, so it cannot collapse that way. """ - if not getattr(data_type, "unsigned", False): + if not isinstance(data_type.unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got " + f"{type(data_type.unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not data_type.unsigned: return raise UnsupportedFeatureError( self.name, @@ -141,7 +147,13 @@ def _refuse_unsigned_numeric(self, data_type, word: str) -> None: the grammar cannot express at all rather than a wrong value, and the two exceptions do not share a base class. """ - if not getattr(data_type, "unsigned", False): + if not isinstance(data_type.unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got " + f"{type(data_type.unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not data_type.unsigned: return raise UnsupportedFeatureError( self.name, @@ -169,7 +181,7 @@ def format_data_type_sqlite_integer(self, data_type: SQLiteIntegerType) -> Tuple return "INTEGER", () def format_data_type_sqlite_text(self, data_type: SQLiteTextType) -> Tuple[str, tuple]: - length = getattr(data_type, "length", None) + length = data_type.length return (f"TEXT({length})" if length is not None else "TEXT"), () def format_data_type_sqlite_real(self, data_type: SQLiteRealType) -> Tuple[str, tuple]: @@ -447,4 +459,4 @@ def parse_type(self, raw: str) -> DataType: if self._BLOB_TYPES.match(upper): return SQLiteBlobType(self) - return CustomType(self, stripped) + return CustomType(self, raw=stripped) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/virtual_table.py b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/virtual_table.py index 63c2230b2..8deb7d274 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/mixins/virtual_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/mixins/virtual_table.py @@ -10,8 +10,8 @@ from .extension import SQLiteExtensionMixin if TYPE_CHECKING: - from ..expression.fts5 import SQLiteFTS5MatchExpression - from ..expression.virtual_table import CreateVirtualTableExpression, DropVirtualTableExpression + from ..expression.fts5 import SQLiteFTS5MatchPredicate + from ..expression.virtual_table import CreateVirtualTableStatement, DropVirtualTableStatement _KNOWN_VTABLE_MODULES: Set[str] = { "rtree", "fts5", "fts4", "fts3", "geopoly", @@ -37,8 +37,7 @@ class SQLiteVirtualTableMixin(SQLiteExtensionMixin): def supports_virtual_table(self) -> bool: """Whether virtual tables are supported.""" - version = self.version - return version >= (3, 8, 8) + return self.version_at_least((3, 8, 8)) def supports_rtree(self) -> bool: """Whether R-Tree virtual table is supported. @@ -49,8 +48,7 @@ def supports_rtree(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_RTREE" in compile_options - version = self.version - return version >= (3, 6, 0) + return self.version_at_least((3, 6, 0)) def supports_fts5(self) -> bool: """Whether FTS5 virtual table is supported. @@ -61,8 +59,7 @@ def supports_fts5(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_FTS5" in compile_options - version = self.version - return version >= (3, 9, 0) + return self.version_at_least((3, 9, 0)) def supports_geopoly(self) -> bool: """Whether Geopoly virtual table is supported. @@ -73,8 +70,7 @@ def supports_geopoly(self) -> bool: compile_options = self.get_runtime_param("compile_options", {}) if compile_options: return "ENABLE_GEOPOLY" in compile_options - version = self.version - return version >= (3, 26, 0) + return self.version_at_least((3, 26, 0)) def supports_math_functions(self) -> bool: """Whether built-in math functions are supported. @@ -85,8 +81,7 @@ def supports_math_functions(self) -> bool: Returns: True if math functions are supported """ - version = self.version - if version >= (3, 35, 0): + if self.version_at_least((3, 35, 0)): return self.get_runtime_param("math_functions_available", True) return False @@ -101,19 +96,18 @@ def supports_json1_extension(self) -> bool: Returns: True if json1 extension is available """ - version = self.version - if version >= (3, 38, 0): + if self.version_at_least((3, 38, 0)): return True return self.get_runtime_param("json1_available", False) - def format_create_virtual_table( + def format_create_virtual_table_statement( self, - expr: "CreateVirtualTableExpression", + expr: "CreateVirtualTableStatement", ) -> Tuple[str, tuple]: """Format CREATE VIRTUAL TABLE statement. Args: - expr: CreateVirtualTableExpression instance + expr: CreateVirtualTableStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -128,14 +122,14 @@ def format_create_virtual_table( sql = f"CREATE VIRTUAL TABLE {name} USING {expr.module}({cols})" return sql, () - def format_drop_virtual_table( + def format_drop_virtual_table_statement( self, - expr: "DropVirtualTableExpression", + expr: "DropVirtualTableStatement", ) -> Tuple[str, tuple]: """Format DROP TABLE statement for a virtual table. Args: - expr: DropVirtualTableExpression instance + expr: DropVirtualTableStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -147,7 +141,7 @@ def format_drop_virtual_table( def format_match_predicate( self, - expr: "SQLiteFTS5MatchExpression", + expr: "SQLiteFTS5MatchPredicate", ) -> Tuple[str, tuple]: """Format full-text search MATCH predicate (delegates to FTS5Mixin).""" return self.format_fts5_match_expression(expr) diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/pragma/base.py b/src/rhosocial/activerecord/backend/impl/sqlite/pragma/base.py index 0824ad11a..1682cecd2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/pragma/base.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/pragma/base.py @@ -73,32 +73,6 @@ class PragmaInfo: argument_type: Optional[type] = None documentation_url: Optional[str] = None - def get_sql(self) -> str: - """Get PRAGMA SQL statement for reading. - - Returns: - SQL string for reading the PRAGMA value - """ - return f"PRAGMA {self.name}" - - def get_set_sql(self, value: Any = None, argument: Any = None) -> str: - """Get PRAGMA SQL statement for setting. - - Args: - value: Value to set (for configuration pragmas) - argument: Argument (for pragmas that require an argument) - - Returns: - SQL string for setting the PRAGMA value - """ - if self.requires_argument and argument is not None: - if value is not None: - return f"PRAGMA {self.name}({argument}) = {value}" - return f"PRAGMA {self.name}({argument})" - elif value is not None: - return f"PRAGMA {self.name} = {value}" - return f"PRAGMA {self.name}" - @runtime_checkable class PragmaProtocol(Protocol): @@ -133,29 +107,6 @@ def get_info(self) -> PragmaInfo: """ ... - def get_value_sql(self, argument: Any = None) -> str: - """Get SQL for reading PRAGMA value. - - Args: - argument: Optional argument for parameterized pragmas - - Returns: - SQL string - """ - ... - - def set_value_sql(self, value: Any, argument: Any = None) -> str: - """Get SQL for setting PRAGMA value. - - Args: - value: Value to set - argument: Optional argument for parameterized pragmas - - Returns: - SQL string - """ - ... - class PragmaBase(ABC): """Abstract base class for SQLite PRAGMA implementations. @@ -255,42 +206,6 @@ def get_info(self) -> PragmaInfo: documentation_url=self._documentation_url, ) - def get_value_sql(self, argument: Any = None) -> str: - """Get SQL for reading PRAGMA value. - - Args: - argument: Optional argument for parameterized pragmas - - Returns: - SQL string - """ - if self._requires_argument and argument is not None: - return f"PRAGMA {self._name}({argument})" - return f"PRAGMA {self._name}" - - def set_value_sql(self, value: Any, argument: Any = None) -> str: - """Get SQL for setting PRAGMA value. - - Args: - value: Value to set - argument: Optional argument for parameterized pragmas - - Returns: - SQL string - - Raises: - ValueError: If PRAGMA is read-only - """ - if self._read_only: - raise ValueError(f"PRAGMA {self._name} is read-only and cannot be set") - - if self._allowed_values and value not in self._allowed_values: - raise ValueError(f"Invalid value '{value}' for PRAGMA {self._name}. Allowed values: {self._allowed_values}") - - if self._requires_argument and argument is not None: - return f"PRAGMA {self._name}({argument}) = {value}" - return f"PRAGMA {self._name} = {value}" - def validate_value(self, value: Any) -> bool: """Validate a value for this PRAGMA. diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/pragma/config.py b/src/rhosocial/activerecord/backend/impl/sqlite/pragma/config.py index 78f281910..1977ae9a9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/pragma/config.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/pragma/config.py @@ -59,6 +59,17 @@ default_value="NORMAL", documentation_url="https://www.sqlite.org/pragma.html#pragma_locking_mode", ), + "read_uncommitted": PragmaInfo( + name="read_uncommitted", + category=PragmaCategory.CONFIGURATION, + description="Read changes made by other connections that have not committed", + read_only=False, + min_version=(3, 0, 0), + value_type=int, + allowed_values=[0, 1], + default_value=0, + documentation_url="https://www.sqlite.org/pragma.html#pragma_read_uncommitted", + ), "temp_store": PragmaInfo( name="temp_store", category=PragmaCategory.CONFIGURATION, diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/protocols.py b/src/rhosocial/activerecord/backend/impl/sqlite/protocols.py index 4b4b0ad94..9813d396c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/protocols.py @@ -9,11 +9,11 @@ from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: - from .expression.attach import SQLiteAttachExpression, SQLiteDetachExpression + from .expression.attach import SQLiteAttachStatement, SQLiteDetachStatement from .expression.fts5 import ( SQLiteFTS5CreateVirtualTable, SQLiteFTS5HighlightExpression, - SQLiteFTS5MatchExpression, + SQLiteFTS5MatchPredicate, SQLiteFTS5RankExpression, SQLiteFTS5SnippetExpression, ) @@ -22,10 +22,10 @@ SQLiteGeopolyContainsExpression, SQLiteGeopolyCreateVirtualTable, ) - from .expression.reindex import SQLiteReindexExpression + from .expression.reindex import SQLiteReindexStatement from .expression.rtree import SQLiteRTreeCreateVirtualTable, SQLiteRTreeRangeQuery - from .expression.vacuum import SQLiteAnalyzeExpression, SQLiteVacuumExpression - from .expression.virtual_table import CreateVirtualTableExpression, DropVirtualTableExpression + from .expression.vacuum import SQLiteAnalyzeStatement, SQLiteVacuumStatement + from .expression.virtual_table import CreateVirtualTableStatement, DropVirtualTableStatement @runtime_checkable @@ -222,28 +222,28 @@ def supports_json1_extension(self) -> bool: # ========== Virtual Table SQL Formatting ========== - def format_create_virtual_table( + def format_create_virtual_table_statement( self, - expr: "CreateVirtualTableExpression", + expr: "CreateVirtualTableStatement", ) -> Tuple[str, tuple]: """Format CREATE VIRTUAL TABLE statement. Args: - expr: CreateVirtualTableExpression instance + expr: CreateVirtualTableStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_virtual_table( + def format_drop_virtual_table_statement( self, - expr: "DropVirtualTableExpression", + expr: "DropVirtualTableStatement", ) -> Tuple[str, tuple]: """Format DROP TABLE statement for virtual table. Args: - expr: DropVirtualTableExpression instance + expr: DropVirtualTableStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -254,7 +254,7 @@ def format_drop_virtual_table( def format_match_predicate( self, - expr: "SQLiteFTS5MatchExpression", + expr: "SQLiteFTS5MatchPredicate", ) -> Tuple[str, tuple]: """Format full-text search MATCH predicate. @@ -298,11 +298,11 @@ def supports_reindex_expressions(self) -> bool: """Whether REINDEX EXPRESSIONS is supported (SQLite 3.53.0+).""" ... - def format_reindex_statement(self, expr: "SQLiteReindexExpression") -> Tuple[str, tuple]: + def format_reindex_statement(self, expr: "SQLiteReindexStatement") -> Tuple[str, tuple]: """Format REINDEX statement. Args: - expr: SQLiteReindexExpression instance + expr: SQLiteReindexStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -341,44 +341,44 @@ def supports_detach(self) -> bool: """Whether DETACH DATABASE is supported.""" ... - def format_vacuum_statement(self, expr: "SQLiteVacuumExpression") -> Tuple[str, tuple]: + def format_vacuum_statement(self, expr: "SQLiteVacuumStatement") -> Tuple[str, tuple]: """Format a VACUUM statement. Args: - expr: SQLiteVacuumExpression instance. + expr: SQLiteVacuumStatement instance. Returns: Tuple of (SQL string, parameters tuple). """ ... - def format_analyze_statement(self, expr: "SQLiteAnalyzeExpression") -> Tuple[str, tuple]: + def format_analyze_statement(self, expr: "SQLiteAnalyzeStatement") -> Tuple[str, tuple]: """Format an ANALYZE statement. Args: - expr: SQLiteAnalyzeExpression instance. + expr: SQLiteAnalyzeStatement instance. Returns: Tuple of (SQL string, parameters tuple). """ ... - def format_attach_statement(self, expr: "SQLiteAttachExpression") -> Tuple[str, tuple]: + def format_attach_statement(self, expr: "SQLiteAttachStatement") -> Tuple[str, tuple]: """Format an ATTACH DATABASE statement. Args: - expr: SQLiteAttachExpression instance. + expr: SQLiteAttachStatement instance. Returns: Tuple of (SQL string, parameters tuple). """ ... - def format_detach_statement(self, expr: "SQLiteDetachExpression") -> Tuple[str, tuple]: + def format_detach_statement(self, expr: "SQLiteDetachStatement") -> Tuple[str, tuple]: """Format a DETACH DATABASE statement. Args: - expr: SQLiteDetachExpression instance. + expr: SQLiteDetachStatement instance. Returns: Tuple of (SQL string, parameters tuple). @@ -411,7 +411,7 @@ def get_supported_fts5_tokenizers(self) -> List[str]: ... def format_fts5_match_expression( - self, expr: "SQLiteFTS5MatchExpression" + self, expr: "SQLiteFTS5MatchPredicate" ) -> Tuple[str, tuple]: """Format FTS5 MATCH expression.""" ... @@ -462,7 +462,7 @@ def format_geopoly_create_virtual_table(self, expr: "SQLiteGeopolyCreateVirtualT """Format CREATE VIRTUAL TABLE for Geopoly.""" ... - def format_geopoly_contains_query(self, expr: "SQLiteGeopolyContainsExpression") -> Tuple[str, tuple]: + def format_geopoly_contains_expression(self, expr: "SQLiteGeopolyContainsExpression") -> Tuple[str, tuple]: """Format Geopoly point-in-polygon query.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlite/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlite/transaction.py index 0fb1f8d30..aa7c70a18 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlite/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlite/transaction.py @@ -90,7 +90,8 @@ def isolation_level(self, level: Optional[IsolationLevel]) -> None: # READ_UNCOMMITTED: read_uncommitted = 1 if level is not None: pragma_value = 1 if level == IsolationLevel.READ_UNCOMMITTED else 0 - self._backend.execute(f"PRAGMA read_uncommitted = {pragma_value}") + statement = self._backend.dialect.pragma_set_statement("read_uncommitted", pragma_value) + self._backend.execute(statement.to_sql()[0]) self._isolation_level = level self.log(logging.INFO, f"Isolation level set to {level}") @@ -127,15 +128,15 @@ def begin_type(self, value: Optional[str]) -> None: def _build_begin_sql(self) -> Tuple[str, tuple]: """Build BEGIN TRANSACTION SQL with SQLite-specific options.""" - from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression + from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement - expr = BeginTransactionExpression(self._backend.dialect) + expr = BeginTransactionStatement(self._backend.dialect) if self._begin_type is not None: # Explicit begin_type overrides isolation level mapping - expr.begin_type(self._begin_type) + expr.with_begin_type(self._begin_type) elif self._isolation_level is not None: - expr.isolation_level(self._isolation_level) + expr.with_isolation_level(self._isolation_level) if self._transaction_mode == TransactionMode.READ_ONLY: expr.read_only() diff --git a/src/rhosocial/activerecord/backend/introspection/__init__.py b/src/rhosocial/activerecord/backend/introspection/__init__.py index a92eaf6f5..11ff7eb1e 100644 --- a/src/rhosocial/activerecord/backend/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/introspection/__init__.py @@ -10,8 +10,8 @@ IntrospectorBackendMixin, SyncIntrospectorExecutor, AsyncIntrospectorExecutor, - DatabaseInfo, TableInfo, ColumnInfo, IndexInfo, - ForeignKeyInfo, ViewInfo, TriggerInfo, + IntrospectionDatabaseInfo, IntrospectionTableInfo, IntrospectionColumnInfo, IndexInfo, + IntrospectionForeignKeyInfo, IntrospectionViewInfo, IntrospectionTriggerInfo, ) """ @@ -21,14 +21,14 @@ ColumnNullable, IndexType, ReferentialAction, - DatabaseInfo, - TableInfo, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, IndexInfo, IndexColumnInfo, - ForeignKeyInfo, - ViewInfo, - TriggerInfo, + IntrospectionForeignKeyInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, ) from .errors import ( IntrospectionError, @@ -41,8 +41,10 @@ SyncIntrospectorExecutor, AsyncIntrospectorExecutor, ) +from .cache import InMemoryIntrospectionCache, IntrospectionCache from .base import ( IntrospectorMixin, + IntrospectorBase, SyncAbstractIntrospector, AsyncAbstractIntrospector, ) @@ -56,14 +58,14 @@ "IndexType", "ReferentialAction", # Data structures - "DatabaseInfo", - "TableInfo", - "ColumnInfo", + "IntrospectionDatabaseInfo", + "IntrospectionTableInfo", + "IntrospectionColumnInfo", "IndexInfo", "IndexColumnInfo", - "ForeignKeyInfo", - "ViewInfo", - "TriggerInfo", + "IntrospectionForeignKeyInfo", + "IntrospectionViewInfo", + "IntrospectionTriggerInfo", # Exceptions "IntrospectionError", "IntrospectionNotSupportedError", @@ -73,8 +75,12 @@ # Executor "SyncIntrospectorExecutor", "AsyncIntrospectorExecutor", + # Cache + "IntrospectionCache", + "InMemoryIntrospectionCache", # Core "IntrospectorMixin", + "IntrospectorBase", "SyncAbstractIntrospector", "AsyncAbstractIntrospector", "IntrospectorBackendMixin", diff --git a/src/rhosocial/activerecord/backend/introspection/base.py b/src/rhosocial/activerecord/backend/introspection/base.py index 565ac91c9..839c15d8c 100644 --- a/src/rhosocial/activerecord/backend/introspection/base.py +++ b/src/rhosocial/activerecord/backend/introspection/base.py @@ -5,8 +5,9 @@ This module provides the abstract base classes for database introspection, following the project's sync/async parity principle: -1. IntrospectorMixin: Shared non-I/O logic (cache management, SQL generation, - _parse_* abstract method declarations) +1. IntrospectorMixin: the shared base every introspector inherits, which + knows the default schema. It holds no state, does no I/O and never sees + a result row. 2. SyncAbstractIntrospector: Synchronous introspector with all I/O methods being synchronous. @@ -18,51 +19,61 @@ - Sync and Async are separate and cannot coexist in the same class. - I/O methods have identical signatures between sync and async versions, differing only in async/await keywords. - - Non-I/O methods (cache, SQL generation, parsing) are shared via mixin. + - The cache is a component of its own (see cache.py): the manager owns an + IntrospectionCache and delegates invalidation to it, because results + outlive a single call. The in-memory implementation is the default; a + backend may inject its own. + - Asking for a statement, running it and parsing the rows are three + separate jobs: this module keeps the first pure (the ``*_statement`` + factories on the manager), and puts the other two on the manager. + - No SQL is built here. Each factory picks a statement object; the + dialect's ``format_*_query_statement`` decides the SQL that object + renders, so a backend overrides the factory rather than rewriting SQL. """ -import threading -import time from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from .cache import InMemoryIntrospectionCache, IntrospectionCache from .types import ( - DatabaseInfo, - TableInfo, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, IndexInfo, - ForeignKeyInfo, - ViewInfo, - TriggerInfo, + IntrospectionForeignKeyInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) if TYPE_CHECKING: # pragma: no cover from ..dialect.base import SQLDialectBase + from ..expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, + ) from .executor import SyncIntrospectorExecutor, AsyncIntrospectorExecutor class IntrospectorMixin: """Mixin providing shared non-I/O functionality for introspectors. - This mixin contains: - - Cache management (thread-safe with TTL support) - - SQL generation methods (delegating to Expression + Dialect) - - Abstract _parse_* method declarations (must be implemented by subclasses) - - Helper methods for schema handling + This mixin knows which schema a query means by default and holds + nothing else: no state, no I/O, and it never sees a result row, which + is what makes it safe to share between the sync and the async manager + and between every backend. - Both SyncAbstractIntrospector and AsyncAbstractIntrospector inherit - from this mixin to share all non-I/O logic. + Caching and parsing belong to the manager that inherits this class -- + the cache because results outlive a call, and the parsing contract + because it is the manager that has a database to answer. """ - DEFAULT_CACHE_TTL: int = 300 # seconds - - def _init_introspector_state(self) -> None: - """Initialize introspector state. Call from __init__.""" - self._cache: Dict[str, Tuple[Any, float]] = {} - self._cache_lock = threading.RLock() - self._cache_ttl: int = self.DEFAULT_CACHE_TTL - # ------------------------------------------------------------------ # # Dialect shortcut # ------------------------------------------------------------------ # @@ -72,228 +83,287 @@ def dialect(self) -> "SQLDialectBase": """Get the SQL dialect from the backend.""" return self._backend.dialect - def _get_default_schema(self) -> str: - """Return the default schema name for this database. + @property + def server_version(self) -> Optional[Tuple[int, int, int]]: + """The server version to report, or ``None`` when nothing knows it. + + Three sources, cheapest and most authoritative first: the adapted + dialect, the version the dialect names without a handshake (SQLite's + engine *is* its driver's library), then what the backend cached from + its last connection. Reading it never raises and never performs I/O. + + Parsers fill ``IntrospectionDatabaseInfo.version`` from this rather + than from ``dialect.version``: the strict property raises before + adaptation, and a parser has no business refusing to report a + database just because the version is not known yet. + """ + version = self.dialect.version_or_none + if version is not None: + return version + return self._backend.get_cached_server_version() + + @property + def default_schema(self) -> str: + """The schema name this database calls its own by default. - Subclasses should override to return the database-specific default - (e.g. ``"main"`` for SQLite, ``"public"`` for PostgreSQL). - Returns an empty string by default so generic callers can still work. + Subclasses override to name the database-specific default (``"main"`` + for SQLite, ``"public"`` for PostgreSQL). Empty by default so generic + callers still work against a backend that has no such name. """ return "" + +class IntrospectorBase(IntrospectorMixin, ABC): + """What every introspection manager is: owns the cache, provides the + statement factories, and contracts the parsing of database rows. + + The sync and the async manager differ only in how they run the SQL the + statements render -- everything below is common to both, so it is + declared once here rather than twice. That includes the factories: a + backend overrides ``table_list_statement`` and both managers see it. + """ + + def __init__(self, backend: Any, executor: Any, cache: Optional[IntrospectionCache] = None) -> None: + self._backend = backend + self._executor = executor + #: The only mutable state in this path. A component with a lifetime + #: of its own rather than borrowed fields, and injectable, so a + #: backend can put its own implementation here. + self._cache: IntrospectionCache = ( + cache if cache is not None else InMemoryIntrospectionCache() + ) + # ------------------------------------------------------------------ # - # Cache management (thread-safe, no I/O) + # Statement factories + # + # One factory per introspection family. Each returns a *statement + # object* and renders nothing: the dialect's + # ``format_*_query_statement`` decides the SQL. A backend that needs a + # different statement (an extra column, another catalog view) overrides + # the factory and nothing else -- which is why these live on the shared + # base and not on one side of the sync/async split. # ------------------------------------------------------------------ # - def _get_cached(self, key: str) -> Optional[Any]: - """Get cached value if not expired.""" - with self._cache_lock: - entry = self._cache.get(key) - if entry is None: - return None - data, ts = entry - if time.time() - ts > self._cache_ttl: - del self._cache[key] - return None - return data - - def _set_cached(self, key: str, data: Any) -> None: - """Store value in cache with current timestamp.""" - with self._cache_lock: - self._cache[key] = (data, time.time()) - - def _make_cache_key( + def database_info_statement(self) -> "DatabaseInfoQueryStatement": + """Return the statement that asks for database information.""" + from ..expression.introspection import DatabaseInfoQueryStatement + + # Databases that identify themselves by schema (MySQL's database + # name, for one) need the default schema on the statement. Every + # parameter is collected at construction: a statement is built once, + # with everything it asks, and never edited afterwards. + return DatabaseInfoQueryStatement( + self.dialect, schema=self.default_schema or None + ) + + def table_list_statement( self, - scope: IntrospectionScope, - *args: str, - schema: Optional[str] = None, - extra: Optional[str] = None, - ) -> str: - """Build a cache key from scope and optional parameters.""" - parts = [scope.value] - if schema: - parts.append(f"schema:{schema}") - parts.extend(str(a) for a in args) - if extra: - parts.append(extra) - return ":".join(parts) + *, + schema: Optional[str], + include_system: bool, + include_views: bool = True, + table_type: Optional[str] = None, + ) -> "TableListQueryStatement": + """Return the statement that lists tables, views and system tables.""" + from ..expression.introspection import TableListQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return TableListQueryStatement( + self.dialect, + schema=target_schema, + include_views=include_views, + include_system=include_system, + table_type=table_type, + ) + + def column_info_statement( + self, + *, + table_name: str, + schema: Optional[str], + include_hidden: bool = False, + ) -> "ColumnInfoQueryStatement": + """Return the statement that asks for a table's columns. + + *include_hidden* asks the engine for the columns a plain listing leaves + out. What that means is the backend's business: SQLite keeps generated + columns behind ``PRAGMA table_xinfo`` and PostgreSQL hides system + columns unless asked. The default is ``False`` because most callers + want the table as it reads, but a backend whose own column question + cannot be answered without them says so at its call site rather than + changing the default for everyone. + """ + from ..expression.introspection import ColumnInfoQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return ColumnInfoQueryStatement( + self.dialect, + table_name=table_name, + schema=target_schema, + include_hidden=include_hidden, + ) + + def index_info_statement( + self, + *, + table_name: str, + schema: Optional[str], + ) -> "IndexInfoQueryStatement": + """Return the statement that asks for a table's indexes.""" + from ..expression.introspection import IndexInfoQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return IndexInfoQueryStatement( + self.dialect, table_name=table_name, schema=target_schema + ) + + def foreign_key_statement( + self, + *, + table_name: str, + schema: Optional[str], + ) -> "ForeignKeyQueryStatement": + """Return the statement that asks for a table's foreign keys.""" + from ..expression.introspection import ForeignKeyQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return ForeignKeyQueryStatement( + self.dialect, table_name=table_name, schema=target_schema + ) + + def view_list_statement( + self, + *, + schema: Optional[str], + include_system: bool, + ) -> "ViewListQueryStatement": + """Return the statement that lists views.""" + from ..expression.introspection import ViewListQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return ViewListQueryStatement( + self.dialect, schema=target_schema, include_system=include_system + ) + + def view_info_statement( + self, + *, + view_name: str, + schema: Optional[str], + ) -> "ViewInfoQueryStatement": + """Return the statement that asks for one view's information.""" + from ..expression.introspection import ViewInfoQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return ViewInfoQueryStatement( + self.dialect, view_name=view_name, schema=target_schema + ) + + def trigger_list_statement( + self, + *, + schema: Optional[str], + table_name: Optional[str] = None, + ) -> "TriggerListQueryStatement": + """Return the statement that lists triggers, optionally one table's.""" + from ..expression.introspection import TriggerListQueryStatement + + target_schema = schema if schema is not None else self.default_schema + return TriggerListQueryStatement( + self.dialect, schema=target_schema, table_name=table_name + ) + + # ------------------------------------------------------------------ # + # Cache — the manager's own service contract + # ------------------------------------------------------------------ # + + @property + def cache(self) -> IntrospectionCache: + """The result cache this manager owns. + + Exposed so a caller can read or replace what the manager caches + with -- an implementation that expires differently, or does not + cache at all, is a legitimate choice and this is where it goes. + Replacing it drops nothing: the new cache simply starts empty. + """ + return self._cache + + @cache.setter + def cache(self, value: IntrospectionCache) -> None: + self._cache = value def invalidate_cache( self, scope: Optional[IntrospectionScope] = None, name: Optional[str] = None, ) -> None: - """Invalidate cached introspection results. + """Drop cached results, from everything down to one object. - Args: - scope: Scope to invalidate; None invalidates everything. - name: Object name within the scope to invalidate; None - invalidates all entries for the scope. + Kept on the manager rather than pushed onto the caller because + invalidation is part of what the service promises after a + schema change, not a detail of how the cache stores things. """ - with self._cache_lock: - if scope is None: - self._cache.clear() - elif name is None: - sv = scope.value - prefix = f"{sv}:" - for k in [k for k in self._cache if k == sv or k.startswith(prefix)]: - del self._cache[k] - else: - sv = scope.value - prefix = f"{sv}:" - for k in [k for k in self._cache if (k == sv or k.startswith(prefix)) and name in k]: - del self._cache[k] + self._cache.invalidate(scope=scope, name=name) def clear_cache(self) -> None: - """Clear all cached introspection results.""" - with self._cache_lock: - self._cache.clear() + """Drop every cached result, whatever its scope.""" + self._cache.clear() # ------------------------------------------------------------------ # - # SQL generation — delegates to Expression + Dialect (no I/O) - # Subclasses may override to use database-specific statements. + # Parsing — turns the rows a database returned into results # ------------------------------------------------------------------ # - def _build_database_info_sql(self) -> Tuple[str, tuple]: - """Build SQL for database information query.""" - from ..expression.introspection import DatabaseInfoExpression - - expr = DatabaseInfoExpression(self.dialect) - # Set default schema for databases that need it (e.g., MySQL needs database name) - default_schema = self._get_default_schema() - if default_schema: - expr = expr.schema(default_schema) - return expr.to_sql() - - def _build_table_list_sql( - self, - schema: Optional[str], - include_system: bool, - include_views: bool = True, - table_type: Optional[str] = None, - ) -> Tuple[str, tuple]: - """Build SQL for table list query.""" - from ..expression.introspection import TableListExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = ( - TableListExpression(self.dialect) - .schema(target_schema) - .include_system(include_system) - .include_views(include_views) - ) - if table_type: - expr = expr.table_type(table_type) - return expr.to_sql() - - def _build_column_info_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """Build SQL for column information query.""" - from ..expression.introspection import ColumnInfoExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = ColumnInfoExpression(self.dialect, table_name).schema(target_schema) - return expr.to_sql() - - def _build_index_info_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """Build SQL for index information query.""" - from ..expression.introspection import IndexInfoExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = IndexInfoExpression(self.dialect, table_name).schema(target_schema) - return expr.to_sql() - - def _build_foreign_key_sql(self, table_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """Build SQL for foreign key query.""" - from ..expression.introspection import ForeignKeyExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = ForeignKeyExpression(self.dialect, table_name).schema(target_schema) - return expr.to_sql() - - def _build_view_list_sql(self, schema: Optional[str], include_system: bool) -> Tuple[str, tuple]: - """Build SQL for view list query.""" - from ..expression.introspection import ViewListExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = ViewListExpression(self.dialect).schema(target_schema).include_system(include_system) - return expr.to_sql() - - def _build_view_info_sql(self, view_name: str, schema: Optional[str]) -> Tuple[str, tuple]: - """Build SQL for view information query.""" - from ..expression.introspection import ViewInfoExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = ViewInfoExpression(self.dialect, view_name).schema(target_schema) - return expr.to_sql() - - def _build_trigger_list_sql(self, table_name: Optional[str], schema: Optional[str]) -> Tuple[str, tuple]: - """Build SQL for trigger list query.""" - from ..expression.introspection import TriggerListExpression - - # Use provided schema or default - target_schema = schema if schema is not None else self._get_default_schema() - expr = TriggerListExpression(self.dialect).schema(target_schema) - if table_name: - expr = expr.for_table(table_name) - return expr.to_sql() - # ------------------------------------------------------------------ # # Parse methods — pure Python, no I/O, shared by sync and async paths # Subclasses MUST implement these. # ------------------------------------------------------------------ # @abstractmethod - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: """Parse database info from query result rows.""" ... @abstractmethod - 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]: """Parse table list from query result rows.""" ... @abstractmethod - def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[ColumnInfo]: + def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IntrospectionColumnInfo]: """Parse column list from query result rows.""" ... @abstractmethod - def _parse_indexes(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IndexInfo]: + def parse_indexes(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IndexInfo]: """Parse index list from query result rows.""" ... @abstractmethod - def _parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[ForeignKeyInfo]: + def parse_foreign_keys(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IntrospectionForeignKeyInfo]: """Parse foreign key list from query result rows.""" ... @abstractmethod - 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]: """Parse view list from query result rows.""" ... @abstractmethod - def _parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: str) -> Optional[ViewInfo]: + def parse_view_info(self, rows: List[Dict[str, Any]], view_name: str, schema: str) -> Optional[IntrospectionViewInfo]: """Parse view info from query result rows.""" ... @abstractmethod - 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]: """Parse trigger list from query result rows.""" ... -class SyncAbstractIntrospector(IntrospectorMixin, ABC): +class SyncAbstractIntrospector(IntrospectorBase, ABC): """Abstract base class for synchronous database introspectors. This class provides all synchronous I/O methods for database introspection. - Subclasses must implement all _parse_* methods. + Subclasses must implement all parse_* methods. Public API: get_database_info(), list_tables(), get_table_info(), table_exists(), @@ -305,24 +375,27 @@ class SyncAbstractIntrospector(IntrospectorMixin, ABC): Cache: invalidate_cache(), clear_cache() """ - def __init__(self, backend: Any, executor: "SyncIntrospectorExecutor") -> None: - self._backend = backend - self._executor = executor - self._init_introspector_state() + def __init__( + self, + backend: Any, + executor: "SyncIntrospectorExecutor", + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) # ------------------------------------------------------------------ # # Public synchronous API # ------------------------------------------------------------------ # - def get_database_info(self) -> DatabaseInfo: + def get_database_info(self) -> IntrospectionDatabaseInfo: """Return basic information about the connected database.""" - key = self._make_cache_key(IntrospectionScope.DATABASE) - cached = self._get_cached(key) + key = self._cache.key(IntrospectionScope.DATABASE) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_database_info_sql() - result = self._parse_database_info(self._executor.execute(sql, params)) - self._set_cached(key, result) + sql, params = self.database_info_statement().to_sql() + result = self.parse_database_info(self._executor.execute(sql, params)) + self._cache.set(key, result) return result def list_tables( @@ -330,26 +403,31 @@ def list_tables( schema: Optional[str] = None, include_system: bool = False, table_type: Optional[str] = None, - ) -> List[TableInfo]: + ) -> List[IntrospectionTableInfo]: """List tables (and optionally views/system tables) in the database.""" - key = self._make_cache_key( + key = self._cache.key( IntrospectionScope.TABLE, schema=schema, extra=f"{include_system}:{table_type}", ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_table_list_sql(schema, include_system, True, table_type) - result = self._parse_tables(self._executor.execute(sql, params), schema) - self._set_cached(key, result) + sql, params = self.table_list_statement( + schema=schema, + include_system=include_system, + include_views=True, + table_type=table_type, + ).to_sql() + result = self.parse_tables(self._executor.execute(sql, params), schema) + self._cache.set(key, result) return result - 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]: """Return detailed information for a specific table, including columns, indexes, and foreign keys.""" - 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 table = next((t for t in self.list_tables(schema) if t.name == table_name), None) @@ -358,26 +436,26 @@ 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 def table_exists(self, table_name: str, schema: Optional[str] = None) -> bool: """Return True if the named table exists.""" return self.get_table_info(table_name, schema) is not None - def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[ColumnInfo]: + def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionColumnInfo]: """List all columns of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.COLUMN, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.COLUMN, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_column_info_sql(table_name, schema) - result = self._parse_columns(self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.column_info_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_columns(self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result - def get_column_info(self, table_name: str, column_name: str, schema: Optional[str] = None) -> Optional[ColumnInfo]: + def get_column_info(self, table_name: str, column_name: str, schema: Optional[str] = None) -> Optional[IntrospectionColumnInfo]: """Return information for a specific column, or None if not found.""" return next( (c for c in self.list_columns(table_name, schema) if c.name == column_name), @@ -390,14 +468,14 @@ def column_exists(self, table_name: str, column_name: str, schema: Optional[str] def list_indexes(self, table_name: str, schema: Optional[str] = None) -> List[IndexInfo]: """List all indexes of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.INDEX, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.INDEX, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_index_info_sql(table_name, schema) - result = self._parse_indexes(self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.index_info_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_indexes(self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result def get_index_info(self, table_name: str, index_name: str, schema: Optional[str] = None) -> Optional[IndexInfo]: @@ -414,21 +492,21 @@ def get_primary_key(self, table_name: str, schema: Optional[str] = None) -> Opti None, ) - def list_foreign_keys(self, table_name: str, schema: Optional[str] = None) -> List[ForeignKeyInfo]: + def list_foreign_keys(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionForeignKeyInfo]: """List all foreign keys of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.FOREIGN_KEY, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.FOREIGN_KEY, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_foreign_key_sql(table_name, schema) - result = self._parse_foreign_keys(self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.foreign_key_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_foreign_keys(self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result def get_foreign_key_info( self, table_name: str, fk_name: str, schema: Optional[str] = None - ) -> Optional[ForeignKeyInfo]: + ) -> Optional[IntrospectionForeignKeyInfo]: """Return information for a specific foreign key, or None if not found.""" return next( (fk for fk in self.list_foreign_keys(table_name, schema) if fk.name == fk_name), @@ -439,29 +517,29 @@ def list_views( self, schema: Optional[str] = None, include_system: bool = False, - ) -> List[ViewInfo]: + ) -> List[IntrospectionViewInfo]: """List all views in the database.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.VIEW, schema=schema, extra=str(include_system)) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.VIEW, schema=schema, extra=str(include_system)) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_view_list_sql(schema, include_system) - result = self._parse_views(self._executor.execute(sql, params), target) - self._set_cached(key, result) + sql, params = self.view_list_statement(schema=schema, include_system=include_system).to_sql() + result = self.parse_views(self._executor.execute(sql, params), target) + self._cache.set(key, result) return result - def get_view_info(self, view_name: str, schema: Optional[str] = None) -> Optional[ViewInfo]: + def get_view_info(self, view_name: str, schema: Optional[str] = None) -> Optional[IntrospectionViewInfo]: """Return detailed information for a specific view, or None if not found.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.VIEW, view_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.VIEW, view_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_view_info_sql(view_name, schema) - result = self._parse_view_info(self._executor.execute(sql, params), view_name, target) + sql, params = self.view_info_statement(view_name=view_name, schema=schema).to_sql() + result = self.parse_view_info(self._executor.execute(sql, params), view_name, target) if result is not None: - self._set_cached(key, result) + self._cache.set(key, result) return result def view_exists(self, view_name: str, schema: Optional[str] = None) -> bool: @@ -472,19 +550,19 @@ def list_triggers( self, table_name: Optional[str] = None, schema: Optional[str] = None, - ) -> List[TriggerInfo]: + ) -> List[IntrospectionTriggerInfo]: """List all triggers, optionally filtered by table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.TRIGGER, table_name or "*", schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TRIGGER, table_name or "*", schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_trigger_list_sql(table_name, schema) - result = self._parse_triggers(self._executor.execute(sql, params), target) - self._set_cached(key, result) + sql, params = self.trigger_list_statement(schema=schema, table_name=table_name).to_sql() + result = self.parse_triggers(self._executor.execute(sql, params), target) + self._cache.set(key, result) return result - def get_trigger_info(self, trigger_name: str, schema: Optional[str] = None) -> Optional[TriggerInfo]: + def get_trigger_info(self, trigger_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTriggerInfo]: """Return information for a specific trigger, or None if not found.""" return next( (t for t in self.list_triggers(schema=schema) if t.name == trigger_name), @@ -492,11 +570,11 @@ def get_trigger_info(self, trigger_name: str, schema: Optional[str] = None) -> O ) -class AsyncAbstractIntrospector(IntrospectorMixin, ABC): +class AsyncAbstractIntrospector(IntrospectorBase, ABC): """Abstract base class for asynchronous database introspectors. This class provides all asynchronous I/O methods for database introspection. - Subclasses must implement all _parse_* methods. + Subclasses must implement all parse_* methods. Public API (all methods are async, names match sync version): get_database_info(), list_tables(), get_table_info(), table_exists(), @@ -508,25 +586,28 @@ class AsyncAbstractIntrospector(IntrospectorMixin, ABC): Cache (synchronous): invalidate_cache(), clear_cache() """ - def __init__(self, backend: Any, executor: "AsyncIntrospectorExecutor") -> None: - self._backend = backend - self._executor = executor - self._init_introspector_state() + def __init__( + self, + backend: Any, + executor: "AsyncIntrospectorExecutor", + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) # ------------------------------------------------------------------ # # Public asynchronous API # Method names match the sync version (no _async suffix). # ------------------------------------------------------------------ # - async def get_database_info(self) -> DatabaseInfo: + async def get_database_info(self) -> IntrospectionDatabaseInfo: """Return basic information about the connected database.""" - key = self._make_cache_key(IntrospectionScope.DATABASE) - cached = self._get_cached(key) + key = self._cache.key(IntrospectionScope.DATABASE) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_database_info_sql() - result = self._parse_database_info(await self._executor.execute(sql, params)) - self._set_cached(key, result) + sql, params = self.database_info_statement().to_sql() + result = self.parse_database_info(await self._executor.execute(sql, params)) + self._cache.set(key, result) return result async def list_tables( @@ -534,26 +615,31 @@ async def list_tables( schema: Optional[str] = None, include_system: bool = False, table_type: Optional[str] = None, - ) -> List[TableInfo]: + ) -> List[IntrospectionTableInfo]: """List tables (and optionally views/system tables) in the database.""" - key = self._make_cache_key( + key = self._cache.key( IntrospectionScope.TABLE, schema=schema, extra=f"{include_system}:{table_type}", ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_table_list_sql(schema, include_system, True, table_type) - result = self._parse_tables(await self._executor.execute(sql, params), schema) - self._set_cached(key, result) + sql, params = self.table_list_statement( + schema=schema, + include_system=include_system, + include_views=True, + table_type=table_type, + ).to_sql() + result = self.parse_tables(await self._executor.execute(sql, params), schema) + self._cache.set(key, result) return result - 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]: """Return detailed information for a specific table, including columns, indexes, and foreign keys.""" - 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 table = next( @@ -565,28 +651,28 @@ 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 async def table_exists(self, table_name: str, schema: Optional[str] = None) -> bool: """Return True if the named table exists.""" return await self.get_table_info(table_name, schema) is not None - async def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[ColumnInfo]: + async def list_columns(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionColumnInfo]: """List all columns of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.COLUMN, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.COLUMN, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_column_info_sql(table_name, schema) - result = self._parse_columns(await self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.column_info_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_columns(await self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result async def get_column_info( self, table_name: str, column_name: str, schema: Optional[str] = None - ) -> Optional[ColumnInfo]: + ) -> Optional[IntrospectionColumnInfo]: """Return information for a specific column, or None if not found.""" return next( (c for c in await self.list_columns(table_name, schema) if c.name == column_name), @@ -599,14 +685,14 @@ async def column_exists(self, table_name: str, column_name: str, schema: Optiona async def list_indexes(self, table_name: str, schema: Optional[str] = None) -> List[IndexInfo]: """List all indexes of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.INDEX, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.INDEX, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_index_info_sql(table_name, schema) - result = self._parse_indexes(await self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.index_info_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_indexes(await self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result async def get_index_info( @@ -625,21 +711,21 @@ async def get_primary_key(self, table_name: str, schema: Optional[str] = None) - None, ) - async def list_foreign_keys(self, table_name: str, schema: Optional[str] = None) -> List[ForeignKeyInfo]: + async def list_foreign_keys(self, table_name: str, schema: Optional[str] = None) -> List[IntrospectionForeignKeyInfo]: """List all foreign keys of the given table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.FOREIGN_KEY, table_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.FOREIGN_KEY, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_foreign_key_sql(table_name, schema) - result = self._parse_foreign_keys(await self._executor.execute(sql, params), table_name, target) - self._set_cached(key, result) + sql, params = self.foreign_key_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_foreign_keys(await self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) return result async def get_foreign_key_info( self, table_name: str, fk_name: str, schema: Optional[str] = None - ) -> Optional[ForeignKeyInfo]: + ) -> Optional[IntrospectionForeignKeyInfo]: """Return information for a specific foreign key, or None if not found.""" return next( (fk for fk in await self.list_foreign_keys(table_name, schema) if fk.name == fk_name), @@ -650,29 +736,29 @@ async def list_views( self, schema: Optional[str] = None, include_system: bool = False, - ) -> List[ViewInfo]: + ) -> List[IntrospectionViewInfo]: """List all views in the database.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.VIEW, schema=schema, extra=str(include_system)) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.VIEW, schema=schema, extra=str(include_system)) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_view_list_sql(schema, include_system) - result = self._parse_views(await self._executor.execute(sql, params), target) - self._set_cached(key, result) + sql, params = self.view_list_statement(schema=schema, include_system=include_system).to_sql() + result = self.parse_views(await self._executor.execute(sql, params), target) + self._cache.set(key, result) return result - async def get_view_info(self, view_name: str, schema: Optional[str] = None) -> Optional[ViewInfo]: + async def get_view_info(self, view_name: str, schema: Optional[str] = None) -> Optional[IntrospectionViewInfo]: """Return detailed information for a specific view, or None if not found.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.VIEW, view_name, schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.VIEW, view_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_view_info_sql(view_name, schema) - result = self._parse_view_info(await self._executor.execute(sql, params), view_name, target) + sql, params = self.view_info_statement(view_name=view_name, schema=schema).to_sql() + result = self.parse_view_info(await self._executor.execute(sql, params), view_name, target) if result is not None: - self._set_cached(key, result) + self._cache.set(key, result) return result async def view_exists(self, view_name: str, schema: Optional[str] = None) -> bool: @@ -683,19 +769,19 @@ async def list_triggers( self, table_name: Optional[str] = None, schema: Optional[str] = None, - ) -> List[TriggerInfo]: + ) -> List[IntrospectionTriggerInfo]: """List all triggers, optionally filtered by table.""" - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key(IntrospectionScope.TRIGGER, table_name or "*", schema=schema) - cached = self._get_cached(key) + target = schema if schema is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TRIGGER, table_name or "*", schema=schema) + cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_trigger_list_sql(table_name, schema) - result = self._parse_triggers(await self._executor.execute(sql, params), target) - self._set_cached(key, result) + sql, params = self.trigger_list_statement(schema=schema, table_name=table_name).to_sql() + result = self.parse_triggers(await self._executor.execute(sql, params), target) + self._cache.set(key, result) return result - async def get_trigger_info(self, trigger_name: str, schema: Optional[str] = None) -> Optional[TriggerInfo]: + async def get_trigger_info(self, trigger_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTriggerInfo]: """Return information for a specific trigger, or None if not found.""" return next( (t for t in await self.list_triggers(schema=schema) if t.name == trigger_name), diff --git a/src/rhosocial/activerecord/backend/introspection/cache.py b/src/rhosocial/activerecord/backend/introspection/cache.py new file mode 100644 index 000000000..134661e18 --- /dev/null +++ b/src/rhosocial/activerecord/backend/introspection/cache.py @@ -0,0 +1,226 @@ +# src/rhosocial/activerecord/backend/introspection/cache.py +"""The introspection result cache: the only mutable state in this path. + +Introspection asks the database for the same metadata over and over -- a +table's columns are wanted by the column list, by the schema diff and by +the migration planner, all within one logical operation. The results are +therefore cached, and the cache is a component with a lifetime of its own +rather than borrowed fields on a mixin. + +The contract is :class:`IntrospectionCache`. What ships here is +:class:`InMemoryIntrospectionCache`, which is only a local in-memory +implementation with an expiry -- a backend that wants something else +supplies its own. + +Three responsibilities, and nothing else: + +* **key policy** -- :meth:`IntrospectionCache.key`. The key *is* the + correctness contract: two callers asking about the same thing must agree + on a key, and two callers asking about different things must not + collide. The method is on the interface so that policy has exactly one + home; a caller that invented its own string format would be free to + drift. +* **storage** -- :meth:`set` stores, :meth:`get` returns. +* **invalidation** -- :meth:`invalidate` drops by scope, by scope plus a + name, or everything. Schema-changing operations invalidate; the cache + does not know what those operations are. + +Thread safety, where an implementation needs it, is the cache's own +problem: the lock is taken and released inside each method, so a caller +never composes it with its own lock. +""" + +import threading +import time +from typing import Any, Dict, Optional, Protocol, Tuple, runtime_checkable + +from .types import IntrospectionScope + + +@runtime_checkable +class IntrospectionCache(Protocol): + """What an introspection manager needs from a result cache. + + An interface, so a backend can put its own implementation behind the + manager instead of inheriting this package's expiry policy. What a + cache does about expiry, about sharing, or about reaching beyond this + process is that implementation's business. + + :meth:`key` is the part that is not optional: it is how two callers + asking the same question reach the same entry, and how a caller + asking a different question does not. + """ + + #: Seconds an entry stays fresh. An implementation that never expires + #: reports 0. + ttl: int + + def key( + self, + scope: IntrospectionScope, + *args: str, + schema: Optional[str] = None, + extra: Optional[str] = None, + ) -> str: + """Build the cache key for one introspection result. + + Args: + scope: What is being asked about. Two scopes never share a key. + *args: Further discriminators, usually an object name. + schema: The schema, when the backend has more than one. + extra: A namespace for results that share a scope but are not + the same thing. Materialized views share + ``IntrospectionScope.VIEW`` with ordinary views, so a + caller asking about them passes an ``extra`` to keep the + two families from answering each other's questions. + + Returns: + The key, as a string. + """ + ... + + def get(self, key: str) -> Optional[Any]: + """Return the cached value for *key*, or ``None``.""" + ... + + def set(self, key: str, data: Any) -> None: + """Store *data* under *key*.""" + ... + + def invalidate( + self, + scope: Optional[IntrospectionScope] = None, + name: Optional[str] = None, + ) -> None: + """Drop entries, from everything down to one object in one scope. + + Args: + scope: Scope to invalidate; ``None`` invalidates everything. + name: Object name within the scope; ``None`` invalidates every + entry of the scope. + """ + ... + + def clear(self) -> None: + """Drop every entry, whatever its scope.""" + ... + + def __len__(self) -> int: + """How many entries are held. For diagnostics and for tests.""" + ... + + +class InMemoryIntrospectionCache(IntrospectionCache): + """Cache results in this process's memory, expiring them after a TTL. + + The default because it is the least surprising: metadata changes + rarely, an answer beats a round trip, and an answer that has outlived + its usefulness is dropped the moment somebody looks at it rather than + by a background sweeper. + + The lock is an ``RLock`` so that a subclass may invalidate from inside + a :meth:`get` it has overridden without deadlocking itself. + """ + + #: Seconds an entry stays fresh unless the owner says otherwise. + DEFAULT_TTL: int = 300 + + def __init__(self, ttl: int = DEFAULT_TTL) -> None: + self._entries: Dict[str, Tuple[Any, float]] = {} + self._lock = threading.RLock() + self._ttl = ttl + + # ------------------------------------------------------------------ # + # Key policy + # ------------------------------------------------------------------ # + + def key( + self, + scope: IntrospectionScope, + *args: str, + schema: Optional[str] = None, + extra: Optional[str] = None, + ) -> str: + parts = [scope.value] + if schema: + parts.append(f"schema:{schema}") + parts.extend(str(a) for a in args) + if extra: + parts.append(extra) + return ":".join(parts) + + # ------------------------------------------------------------------ # + # Storage + # ------------------------------------------------------------------ # + + def get(self, key: str) -> Optional[Any]: + """Return the cached value for *key*, or ``None``. + + An entry past its TTL is dropped rather than returned: the expiry + is enforced on read, so nothing needs a background sweeper. + """ + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + data, stored_at = entry + if time.time() - stored_at > self._ttl: + del self._entries[key] + return None + return data + + def set(self, key: str, data: Any) -> None: + """Store *data* under *key*, stamped with the current time.""" + with self._lock: + self._entries[key] = (data, time.time()) + + # ------------------------------------------------------------------ # + # Invalidation + # ------------------------------------------------------------------ # + + def invalidate( + self, + scope: Optional[IntrospectionScope] = None, + name: Optional[str] = None, + ) -> None: + """Drop entries, from everything down to one object in one scope. + + When *name* is given, an entry is dropped if the name appears + anywhere in its key, which is what makes a table's column list and + its index list both go when the table is named. + """ + with self._lock: + if scope is None: + self._entries.clear() + return + scope_value = scope.value + prefix = f"{scope_value}:" + for key in list(self._entries): + in_scope = key == scope_value or key.startswith(prefix) + if not in_scope: + continue + if name is None or name in key: + del self._entries[key] + + def clear(self) -> None: + """Drop every entry, whatever its scope.""" + with self._lock: + self._entries.clear() + + def __len__(self) -> int: + """How many entries are held. For diagnostics and for tests.""" + with self._lock: + return len(self._entries) + + # ------------------------------------------------------------------ # + # TTL + # ------------------------------------------------------------------ # + + @property + def ttl(self) -> int: + """Seconds an entry stays fresh.""" + return self._ttl + + @ttl.setter + def ttl(self, value: int) -> None: + self._ttl = value diff --git a/src/rhosocial/activerecord/backend/introspection/status/types.py b/src/rhosocial/activerecord/backend/introspection/status/types.py index 48c34435f..99831a57d 100644 --- a/src/rhosocial/activerecord/backend/introspection/status/types.py +++ b/src/rhosocial/activerecord/backend/introspection/status/types.py @@ -54,7 +54,7 @@ class StatusItem: class DatabaseBriefInfo: """Brief database/schema information for status overview. - A simplified version of DatabaseInfo focused on status display. + A simplified version of IntrospectionDatabaseInfo focused on status display. """ name: str diff --git a/src/rhosocial/activerecord/backend/introspection/types.py b/src/rhosocial/activerecord/backend/introspection/types.py index bcc516b84..6842da08e 100644 --- a/src/rhosocial/activerecord/backend/introspection/types.py +++ b/src/rhosocial/activerecord/backend/introspection/types.py @@ -32,10 +32,25 @@ class IntrospectionScope(Enum): class TableType(Enum): - """Table type enumeration.""" + """Table type enumeration. + + One member per kind a catalog reports, including kinds that behave like an + ordinary table. A materialized view is refreshed rather than written to, + and a partitioned table's rows live in its partitions; collapsing either + into ``BASE_TABLE`` loses the distinction at the only layer that saw it, + because nothing downstream can recover it. + + Catalog spellings that are vendor-specific rather than standard -- + BigQuery's ``CLONE`` / ``SNAPSHOT``, ClickHouse's engines, Snowflake's + ``MANAGED`` / ``EVENT`` tables -- stay in ``extra`` beside the member they + map to, not in this enum. + """ BASE_TABLE = "BASE_TABLE" VIEW = "VIEW" + MATERIALIZED_VIEW = "MATERIALIZED_VIEW" + FOREIGN_TABLE = "FOREIGN_TABLE" + PARTITIONED_TABLE = "PARTITIONED_TABLE" SYSTEM_TABLE = "SYSTEM_TABLE" TEMPORARY = "TEMPORARY" EXTERNAL = "EXTERNAL" @@ -72,7 +87,7 @@ class ReferentialAction(Enum): @dataclass -class DatabaseInfo: +class IntrospectionDatabaseInfo: """Database information.""" name: str @@ -98,7 +113,7 @@ class IndexColumnInfo: @dataclass -class ColumnInfo: +class IntrospectionColumnInfo: """Column information. ``parsed_data_type`` is the column's type as a @@ -137,6 +152,11 @@ class ColumnInfo: is_unique: bool = False is_auto_increment: bool = False is_generated: bool = False + #: The expression a generated column is computed from, when the backend's + #: catalogue carries it. None means "not read", never "not generated" -- + #: ``is_generated`` answers that. No backend fills this yet: SQLite's + #: ``PRAGMA table_xinfo`` reports only *that* a column is generated, and the + #: expression lives in the table's DDL. generated_expression: Optional[str] = None comment: Optional[str] = None character_maximum_length: Optional[int] = None @@ -166,7 +186,7 @@ class IndexInfo: @dataclass -class ForeignKeyInfo: +class IntrospectionForeignKeyInfo: """Foreign key information.""" name: str @@ -184,7 +204,7 @@ class ForeignKeyInfo: @dataclass -class ViewInfo: +class IntrospectionViewInfo: """View information.""" name: str @@ -194,12 +214,12 @@ class ViewInfo: is_insertable: Optional[bool] = None check_option: Optional[str] = None comment: Optional[str] = None - columns: List[ColumnInfo] = field(default_factory=list) + columns: List[IntrospectionColumnInfo] = field(default_factory=list) extra: Dict[str, Any] = field(default_factory=dict) @dataclass -class TriggerInfo: +class IntrospectionTriggerInfo: """Trigger information.""" name: str @@ -215,7 +235,7 @@ class TriggerInfo: @dataclass -class TableInfo: +class IntrospectionTableInfo: """Table information.""" name: str @@ -227,7 +247,7 @@ class TableInfo: auto_increment: Optional[int] = None create_time: Optional[str] = None update_time: Optional[str] = None - columns: List[ColumnInfo] = field(default_factory=list) + columns: List[IntrospectionColumnInfo] = field(default_factory=list) indexes: List[IndexInfo] = field(default_factory=list) - foreign_keys: List[ForeignKeyInfo] = field(default_factory=list) + foreign_keys: List[IntrospectionForeignKeyInfo] = field(default_factory=list) extra: Dict[str, Any] = field(default_factory=dict) diff --git a/src/rhosocial/activerecord/backend/named_expression/__init__.py b/src/rhosocial/activerecord/backend/named_expression/__init__.py index a0a3f88ae..0cfedbfe1 100644 --- a/src/rhosocial/activerecord/backend/named_expression/__init__.py +++ b/src/rhosocial/activerecord/backend/named_expression/__init__.py @@ -14,13 +14,13 @@ Example function: >>> def active_users(dialect, limit: int = 100): ... '''Get active users with optional limit.''' - ... return QueryExpression(...) + ... return QueryStatement(...) Example class: >>> class UserQueries: ... def __call__(self, dialect, status: str = 'active'): ... '''Get users by status.''' - ... return QueryExpression(...) + ... return QueryStatement(...) Important Notes: This is a BACKEND FEATURE, independent of ActiveRecord or ActiveQuery. diff --git a/src/rhosocial/activerecord/backend/named_expression/procedure_graph.py b/src/rhosocial/activerecord/backend/named_expression/procedure_graph.py index fcda197dc..b6a80203f 100644 --- a/src/rhosocial/activerecord/backend/named_expression/procedure_graph.py +++ b/src/rhosocial/activerecord/backend/named_expression/procedure_graph.py @@ -142,7 +142,7 @@ class StepNode: Example: >>> StepNode.query("fetch_users", "myapp.q.active_users", params={"limit": 100}) - >>> StepNode.expr("create_tbl", CreateTableExpression(...)) + >>> StepNode.expr("create_tbl", CreateTableStatement(...)) """ __slots__ = ( @@ -302,7 +302,7 @@ class ProcedureGraph: Attributes: transaction_mode: Transaction mode for execution. - strict: Whether to reject RawSQLExpression. + strict: Whether to reject RawSQL. description: Human-readable description. Example: @@ -392,11 +392,11 @@ def validate(self) -> List[str]: if self.strict and node.kind == StepKind.EXPRESSION: from rhosocial.activerecord.backend.expression.operators import ( - RawSQLExpression, + RawSQL, ) - if isinstance(node.expression, RawSQLExpression): - errors.append(f"Step {node.name!r} uses RawSQLExpression (strict=True)") + if isinstance(node.expression, RawSQL): + errors.append(f"Step {node.name!r} uses RawSQL (strict=True)") return errors diff --git a/src/rhosocial/activerecord/backend/named_expression/resolver.py b/src/rhosocial/activerecord/backend/named_expression/resolver.py index 6ccf4f24f..05a42dab5 100644 --- a/src/rhosocial/activerecord/backend/named_expression/resolver.py +++ b/src/rhosocial/activerecord/backend/named_expression/resolver.py @@ -17,13 +17,13 @@ Example function: >>> def active_users(dialect, limit: int = 100): ... '''Get active users.''' - ... return QueryExpression(...) + ... return QueryStatement(...) Example class: >>> class UserQueries: ... def __call__(self, dialect, status: str = 'active'): ... '''Get users by status.''' - ... return QueryExpression(...) + ... return QueryStatement(...) Usage: Resolving a named expression: diff --git a/src/rhosocial/activerecord/backend/schema/differ.py b/src/rhosocial/activerecord/backend/schema/differ.py index 9e1842e18..4f06dbe3b 100644 --- a/src/rhosocial/activerecord/backend/schema/differ.py +++ b/src/rhosocial/activerecord/backend/schema/differ.py @@ -8,7 +8,7 @@ from typing import Dict, List, Optional, TYPE_CHECKING if TYPE_CHECKING: - from ..introspection.types import TableInfo, ColumnInfo, IndexInfo, ForeignKeyInfo + from ..introspection.types import IntrospectionTableInfo, IntrospectionColumnInfo, IndexInfo, IntrospectionForeignKeyInfo # --------------------------------------------------------------------------- @@ -20,8 +20,8 @@ class ColumnDiff: """Difference for a single column.""" column_name: str - old: Optional["ColumnInfo"] # None = column added - new: Optional["ColumnInfo"] # None = column removed + old: Optional["IntrospectionColumnInfo"] # None = column added + new: Optional["IntrospectionColumnInfo"] # None = column removed @property def is_added(self) -> bool: @@ -40,13 +40,13 @@ def is_modified(self) -> bool: class TableDiff: """Difference for a single table.""" table_name: str - old: Optional["TableInfo"] # None = table added - new: Optional["TableInfo"] # None = table removed + old: Optional["IntrospectionTableInfo"] # None = table added + new: Optional["IntrospectionTableInfo"] # None = table removed column_diffs: List[ColumnDiff] = field(default_factory=list) added_indexes: List["IndexInfo"] = field(default_factory=list) removed_indexes: List["IndexInfo"] = field(default_factory=list) - added_foreign_keys: List["ForeignKeyInfo"] = field(default_factory=list) - removed_foreign_keys: List["ForeignKeyInfo"] = field(default_factory=list) + added_foreign_keys: List["IntrospectionForeignKeyInfo"] = field(default_factory=list) + removed_foreign_keys: List["IntrospectionForeignKeyInfo"] = field(default_factory=list) @property def is_added(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/schema/snapshot.py b/src/rhosocial/activerecord/backend/schema/snapshot.py index e088ac866..4f284c9b7 100644 --- a/src/rhosocial/activerecord/backend/schema/snapshot.py +++ b/src/rhosocial/activerecord/backend/schema/snapshot.py @@ -13,12 +13,12 @@ from ..introspection.base import SyncAbstractIntrospector, AsyncAbstractIntrospector from ..introspection.types import ( - DatabaseInfo, - TableInfo, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, IndexInfo, IndexColumnInfo, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, TableType, ColumnNullable, IndexType, @@ -102,12 +102,12 @@ def _data_type_from_dict(data: dict) -> "DataType": "TableType": TableType, "IndexType": IndexType, "ReferentialAction": ReferentialAction, - "DatabaseInfo": DatabaseInfo, - "TableInfo": TableInfo, - "ColumnInfo": ColumnInfo, + "IntrospectionDatabaseInfo": IntrospectionDatabaseInfo, + "IntrospectionTableInfo": IntrospectionTableInfo, + "IntrospectionColumnInfo": IntrospectionColumnInfo, "IndexInfo": IndexInfo, "IndexColumnInfo": IndexColumnInfo, - "ForeignKeyInfo": ForeignKeyInfo, + "IntrospectionForeignKeyInfo": IntrospectionForeignKeyInfo, "DataType": None, # marker — handled by content-based detection } @@ -117,7 +117,7 @@ def _resolve_forward_ref(t: type) -> type: Some Python versions (notably 3.8 with ``from __future__ import annotations``) fail to resolve particularly quoted forward references (e.g. - ``"'DatabaseInfo'"``) inside a ``ForwardRef``, returning the ``ForwardRef`` + ``"'IntrospectionDatabaseInfo'"``) inside a ``ForwardRef``, returning the ``ForwardRef`` instance itself instead of the target class. We work around this by manually stripping surrounding quotes and looking up the name. """ @@ -282,18 +282,18 @@ class SchemaSnapshot: serialised, and compared without holding a runtime reference. captured_at : datetime UTC timestamp when the snapshot was taken. - database_info : DatabaseInfo + database_info : IntrospectionDatabaseInfo Vendor / version metadata from ``introspector.get_database_info()``. - tables : Dict[str, TableInfo] - Mapping of table name → ``TableInfo`` (columns, indexes, FKs). + tables : Dict[str, IntrospectionTableInfo] + Mapping of table name → ``IntrospectionTableInfo`` (columns, indexes, FKs). schema_name : Optional[str] The schema / database name scoped during capture. """ dialect_class: str captured_at: datetime - database_info: "DatabaseInfo" - tables: Dict[str, "TableInfo"] + database_info: "IntrospectionDatabaseInfo" + tables: Dict[str, "IntrospectionTableInfo"] schema_name: Optional[str] = None extra: Dict = field(default_factory=dict) @@ -349,7 +349,7 @@ def build( self._introspector.invalidate_cache() db_info = self._introspector.get_database_info() table_list = self._introspector.list_tables(schema=schema, include_system=include_system) - tables: Dict[str, "TableInfo"] = {} + tables: Dict[str, "IntrospectionTableInfo"] = {} for tbl in table_list: full = self._introspector.get_table_info(tbl.name, schema=schema) if full is not None: @@ -384,7 +384,7 @@ async def build( self._introspector.invalidate_cache() db_info = await self._introspector.get_database_info() table_list = await self._introspector.list_tables(schema=schema, include_system=include_system) - tables: Dict[str, "TableInfo"] = {} + tables: Dict[str, "IntrospectionTableInfo"] = {} for tbl in table_list: full = await self._introspector.get_table_info(tbl.name, schema=schema) if full is not None: diff --git a/src/rhosocial/activerecord/backend/transaction.py b/src/rhosocial/activerecord/backend/transaction.py index 6ef075e4c..b8da5e0ed 100644 --- a/src/rhosocial/activerecord/backend/transaction.py +++ b/src/rhosocial/activerecord/backend/transaction.py @@ -21,8 +21,9 @@ written only once and shared, reducing duplication and potential for bugs. """ +import asyncio import logging -import warnings +import threading from abc import ABC from contextlib import contextmanager, asynccontextmanager from enum import Enum, auto @@ -305,18 +306,18 @@ def _get_savepoint_name(self, level: int) -> str: def _build_begin_sql(self) -> Tuple[str, tuple]: """Build BEGIN TRANSACTION SQL using expression system. - This method creates a BeginTransactionExpression and delegates + This method creates a BeginTransactionStatement and delegates SQL generation to the backend's dialect. Returns: Tuple of (SQL string, parameters tuple). """ - from .expression.transaction import BeginTransactionExpression + from .expression.transaction import BeginTransactionStatement - expr = BeginTransactionExpression(self._backend.dialect) + expr = BeginTransactionStatement(self._backend.dialect) if self._isolation_level is not None: - expr.isolation_level(self._isolation_level) + expr.with_isolation_level(self._isolation_level) if self._transaction_mode == TransactionMode.READ_ONLY: expr.read_only() @@ -329,9 +330,9 @@ def _build_commit_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, parameters tuple). """ - from .expression.transaction import CommitTransactionExpression + from .expression.transaction import CommitStatement - expr = CommitTransactionExpression(self._backend.dialect) + expr = CommitStatement(self._backend.dialect) return expr.to_sql() def _build_rollback_sql(self, savepoint: Optional[str] = None) -> Tuple[str, tuple]: @@ -343,9 +344,9 @@ def _build_rollback_sql(self, savepoint: Optional[str] = None) -> Tuple[str, tup Returns: Tuple of (SQL string, parameters tuple). """ - from .expression.transaction import RollbackTransactionExpression + from .expression.transaction import RollbackStatement - expr = RollbackTransactionExpression(self._backend.dialect) + expr = RollbackStatement(self._backend.dialect) if savepoint: expr.to_savepoint(savepoint) return expr.to_sql() @@ -359,9 +360,9 @@ def _build_savepoint_sql(self, name: str) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, parameters tuple). """ - from .expression.transaction import SavepointExpression + from .expression.transaction import SavepointStatement - expr = SavepointExpression(self._backend.dialect, name) + expr = SavepointStatement(self._backend.dialect, name) return expr.to_sql() def _build_release_savepoint_sql(self, name: str) -> Tuple[str, tuple]: @@ -373,9 +374,9 @@ def _build_release_savepoint_sql(self, name: str) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, parameters tuple). """ - from .expression.transaction import ReleaseSavepointExpression + from .expression.transaction import ReleaseSavepointStatement - expr = ReleaseSavepointExpression(self._backend.dialect, name) + expr = ReleaseSavepointStatement(self._backend.dialect, name) return expr.to_sql() @@ -392,6 +393,7 @@ class TransactionManager(TransactionManagerBase): def __init__(self, backend: "StorageBackend", logger=None): super().__init__(backend, logger) + self._owner_thread = None # Which thread owns the transaction def _do_begin(self) -> None: """Begin a new transaction via backend.execute().""" @@ -448,9 +450,30 @@ def begin(self) -> None: For nested calls, creates a savepoint instead of starting a new transaction. + A transaction belongs to the thread that opened it, and only that thread + may nest it. A second thread driving the same connection would interleave + ``BEGIN``/``COMMIT`` and make the level bookkeeping meaningless; the + damage only shows up later as ``No active transaction to commit``, far + from the cause. + Raises: - TransactionError: If begin operation fails + TransactionError: If begin operation fails, or another thread owns + this connection's transaction. """ + owner = threading.get_ident() + if ( + self._transaction_level > 0 + and self._owner_thread is not None + and self._owner_thread != owner + ): + raise TransactionError( + "this connection's transaction is owned by another thread; one " + "connection cannot serve two concurrent transactions. Give each " + "thread its own backend." + ) + if self._transaction_level == 0: + self._owner_thread = owner + self.log(logging.DEBUG, f"Beginning transaction (level {self._transaction_level})") try: @@ -537,6 +560,9 @@ def commit(self) -> None: # Update state if no more active transactions if self._transaction_level == 0: self._state = TransactionState.INACTIVE + # The outermost transaction ended: the connection is free for + # another thread to use. + self._owner_thread = None self.log(logging.DEBUG, f"Transaction committed, new level: {self._transaction_level}") except Exception as e: @@ -604,6 +630,7 @@ def rollback(self) -> None: # Update state if no more active transactions if self._transaction_level == 0: self._state = TransactionState.INACTIVE + self._owner_thread = None self.log(logging.DEBUG, f"Transaction rolled back, new level: {self._transaction_level}") except Exception as e: @@ -752,7 +779,9 @@ def transaction( yield self.commit() except BaseException: - if self.is_active: + # Only the owner may roll back: a refusal raised by ``begin()`` in + # another thread must not destroy the owner's transaction. + if self.is_active and self._owner_thread == threading.get_ident(): self.rollback() raise finally: @@ -858,7 +887,29 @@ async def supports_savepoint(self) -> bool: return self._backend.dialect.supports_savepoint() async def begin(self) -> None: - """Begin a transaction or create a savepoint""" + """Begin a transaction or create a savepoint. + + A transaction belongs to the task that opened it, and only that task may + nest it (nesting becomes a savepoint). A second task driving the same + connection would interleave ``BEGIN``/``COMMIT`` and make the level + bookkeeping meaningless; the damage only shows up later as + ``No active transaction to commit``, far from the cause. + """ + owner = asyncio.current_task() + if ( + self._transaction_level > 0 + and self._owner_task is not None + and self._owner_task is not owner + ): + raise TransactionError( + "this connection's transaction is owned by another task; one " + "connection cannot serve two concurrent transactions. Give each " + "task its own connection (AsyncBackendPool.connection() or " + "AsyncBackendPool.transaction()) or its own backend." + ) + if self._transaction_level == 0: + self._owner_task = owner + self.log(logging.DEBUG, f"Beginning transaction (level {self._transaction_level})") try: @@ -926,6 +977,9 @@ async def commit(self) -> None: if self._transaction_level == 0: self._state = TransactionState.INACTIVE + # The outermost transaction ended: the connection is free for + # another task to use. + self._owner_task = None self.log(logging.DEBUG, f"Transaction committed, new level: {self._transaction_level}") except Exception as e: @@ -1079,21 +1133,12 @@ async def transaction( async with manager.transaction(mode=TransactionMode.READ_ONLY): # Read-only transaction """ - import asyncio - - current_task = asyncio.current_task() - if self._owner_task is not None and self._owner_task is not current_task: - warnings.warn( - "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.", - UserWarning, - stacklevel=2, - ) - if self._owner_task is None: - self._owner_task = current_task + + # Ownership is enforced by ``begin()``: a second task entering this + # manager raises there, with a message that names the cause. The old + # warning here was only emitted by this method, so a caller that used + # ``pool.transaction()`` (which calls ``begin_transaction()`` directly) + # never saw it. # Save current settings original_isolation_level = self._isolation_level @@ -1110,7 +1155,9 @@ async def transaction( yield await self.commit() except BaseException: - if self.is_active: + # Only the owner may roll back: a refusal raised by ``begin()`` in + # another task must not destroy the owner's transaction. + if self.is_active and self._owner_task is asyncio.current_task(): await self.rollback() raise finally: diff --git a/src/rhosocial/activerecord/backend/type_adapter.py b/src/rhosocial/activerecord/backend/type_adapter.py index 430a17c93..991e5317b 100644 --- a/src/rhosocial/activerecord/backend/type_adapter.py +++ b/src/rhosocial/activerecord/backend/type_adapter.py @@ -104,7 +104,7 @@ def __init__(self): def supported_types(self) -> Dict[Type, Set[Type]]: return self._supported_types - def _register_type(self, py_type: Type, db_type: Type) -> None: + def register_type(self, py_type: Type, db_type: Type) -> None: """Registers a supported (Python type, DB type) pair.""" if py_type not in self._supported_types: self._supported_types[py_type] = set() @@ -160,12 +160,12 @@ class DateTimeAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(datetime, str) - self._register_type(datetime, int) - self._register_type(datetime, float) - self._register_type(date, str) - self._register_type(date, int) - self._register_type(time, str) + self.register_type(datetime, str) + self.register_type(datetime, int) + self.register_type(datetime, float) + self.register_type(date, str) + self.register_type(date, int) + self.register_type(time, str) def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if isinstance(value, datetime): @@ -215,8 +215,8 @@ class JSONAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) - self._register_type(list, str) + self.register_type(dict, str) + self.register_type(list, str) def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if target_type is str: @@ -243,7 +243,7 @@ class UUIDAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(UUID, str) + self.register_type(UUID, str) def _do_to_database(self, value: UUID, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if target_type is str: @@ -302,8 +302,8 @@ class BooleanAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(bool, int) - self._register_type(bool, str) + self.register_type(bool, int) + self.register_type(bool, str) def _do_to_database(self, value: bool, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if target_type is int: @@ -343,8 +343,8 @@ class DecimalAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(Decimal, str) - self._register_type(Decimal, float) + self.register_type(Decimal, str) + self.register_type(Decimal, float) def _do_to_database(self, value: Decimal, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if target_type is str: @@ -385,7 +385,7 @@ class ArrayAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() # Removed default registration to avoid conflict with JSONAdapter - # self._register_type(list, str) + # self.register_type(list, str) def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]]) -> Any: if target_type is str: diff --git a/src/rhosocial/activerecord/base/base.py b/src/rhosocial/activerecord/base/base.py index bbab42dc8..f4ccee01a 100644 --- a/src/rhosocial/activerecord/base/base.py +++ b/src/rhosocial/activerecord/base/base.py @@ -17,7 +17,7 @@ Literal, SQLPredicate, ) -from ..backend.expression.bases import is_sql_query_and_params +from ..backend.expression.bases import QueryWithParams from ..backend.options import DeleteOptions, UpdateOptions from ..backend.options import InsertOptions from ..backend.type_adapter import SQLTypeAdapter @@ -457,7 +457,7 @@ def find_one( raise ValueError(f"Expected {len(cols)} pk values, got {len(condition)}") pk_dict = dict(zip(cols, condition)) query = query.where(cls._build_pk_where_predicate(pk_dict)) - elif is_sql_query_and_params(condition): + elif QueryWithParams.holds(condition): sql, params = condition query = query.where(sql, params) elif not cls.is_composite_pk(): @@ -537,7 +537,7 @@ def find_all( query = query.where( InPredicate(dialect, Column(dialect, pk_field_name), Literal(dialect, tuple(condition))) ) - elif is_sql_query_and_params(condition): + elif QueryWithParams.holds(condition): sql, params = condition query = query.where(sql, params) else: # Assumes list of primary keys (backward compat) @@ -1158,7 +1158,7 @@ async def find_one( raise ValueError(f"Expected {len(cols)} pk values, got {len(condition)}") pk_dict = dict(zip(cols, condition)) query = query.where(cls._build_pk_where_predicate(pk_dict)) - elif is_sql_query_and_params(condition): + elif QueryWithParams.holds(condition): sql, params = condition query = query.where(sql, params) elif not cls.is_composite_pk(): @@ -1238,7 +1238,7 @@ async def find_all( query = query.where( InPredicate(dialect, Column(dialect, pk_field_name), Literal(dialect, tuple(condition))) ) - elif is_sql_query_and_params(condition): + elif QueryWithParams.holds(condition): sql, params = condition query = query.where(sql, params) else: # Assumes list of primary keys (backward compat) diff --git a/src/rhosocial/activerecord/base/ddl.py b/src/rhosocial/activerecord/base/ddl.py index 5fe0e530e..4bc270483 100644 --- a/src/rhosocial/activerecord/base/ddl.py +++ b/src/rhosocial/activerecord/base/ddl.py @@ -24,25 +24,12 @@ TYPE_CHECKING, ) -try: - from typing import Annotated -except ImportError: # Python 3.8 - from typing_extensions import Annotated - -from rhosocial.activerecord.backend.expression.statements.ddl_index import CreateIndexExpression, DropIndexExpression -from rhosocial.activerecord.backend.expression.statements.ddl_partition import PartitionClause -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraint, - ColumnConstraintType, - ColumnDefinition, - CreateTableExpression, - CreateTableOptions, - DropTableExpression, - GeneratedColumnExpression, - IndexDefinition, - StorageOptionsExpression, - TableConstraint, - TableConstraintType, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + TablePrimaryKeyConstraint, ) from .fields import ( DDLAnnotation, @@ -54,6 +41,24 @@ UseIndex, UseSqlType, ) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ColumnDefinition, + CreateTableStatement, + CreateTableOptions, + DropTableStatement, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import PartitionClause +from rhosocial.activerecord.backend.expression.statements.ddl_index import CreateIndexStatement, DropIndexStatement + +try: + from typing import Annotated +except ImportError: # Python 3.8 + from typing_extensions import Annotated + if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -64,8 +69,7 @@ DDLColumnDataType = Union["UseSqlType", "DataType", Sequence["DataType"]] DDLColumnType = Union["UseColumnType", Type["ColumnBase"], Sequence[Type["ColumnBase"]]] DDLGeneratedColumn = Union[ - GeneratedColumnExpression, - Callable[["SQLDialectBase"], GeneratedColumnExpression], + Callable[["SQLDialectBase"], ColumnGeneratedConstraint], ] PEP604_UNION_TYPE = getattr(types, "UnionType", None) @@ -136,7 +140,6 @@ class ColumnOptions: """Dialect-free declaration for backend-specific column options.""" def column_definition_class(self) -> Type["ColumnDefinition"]: - from rhosocial.activerecord.backend.expression.statements import ColumnDefinition return ColumnDefinition @@ -386,17 +389,17 @@ def column_options( ... def table_options( - self, + cls, ) -> Optional[Union[CreateTableOptions, Sequence[CreateTableOptions]]]: ... def table_storage_options( - self, + cls, ) -> Optional[Union[StorageOptionsExpression, Sequence[StorageOptionsExpression]]]: ... def table_partition( - self, + cls, ) -> Optional[Union[PartitionClause, Sequence[PartitionClause]]]: ... @@ -405,7 +408,7 @@ def table_indexes(self) -> Sequence[IndexDefinition]: def create_table_statement_classes( self, - ) -> Optional[Union[Type[CreateTableExpression], Sequence[Type[CreateTableExpression]]]]: + ) -> Optional[Union[Type[CreateTableStatement], Sequence[Type[CreateTableStatement]]]]: ... def table_constraints(self) -> Sequence[TableConstraint]: @@ -471,13 +474,13 @@ def table_constraints(cls) -> Sequence[TableConstraint]: explicit_primary_keys = [ constraint for constraint in declarations - if constraint.constraint_type == TableConstraintType.PRIMARY_KEY + if isinstance(constraint.branch, TablePrimaryKeyConstraint) ] if explicit_primary_keys: matching_primary_keys = [ constraint for constraint in explicit_primary_keys - if list(constraint.columns or []) == primary_key_columns + if list(constraint.branch.columns or []) == primary_key_columns ] if not matching_primary_keys: raise ValueError( @@ -486,12 +489,10 @@ def table_constraints(cls) -> Sequence[TableConstraint]: ) else: declarations.append( - TableConstraint( +TableConstraint( cast(Any, None), - TableConstraintType.PRIMARY_KEY, - columns=primary_key_columns, - ) - ) + branch=TablePrimaryKeyConstraint(cast(Any, None), primary_key_columns), + ) ) return declarations @classmethod @@ -521,25 +522,25 @@ def index_definition(cls, marker: UseIndex, column_name: str) -> IndexDefinition @classmethod def create_table_statement_classes( cls, - ) -> Optional[Union[Type[CreateTableExpression], Sequence[Type[CreateTableExpression]]]]: + ) -> Optional[Union[Type[CreateTableStatement], Sequence[Type[CreateTableStatement]]]]: return None @classmethod def drop_table_statement_classes( cls, - ) -> Optional[Union[Type[DropTableExpression], Sequence[Type[DropTableExpression]]]]: + ) -> Optional[Union[Type[DropTableStatement], Sequence[Type[DropTableStatement]]]]: return None @classmethod def create_index_statement_classes( cls, - ) -> Optional[Union[Type[CreateIndexExpression], Sequence[Type[CreateIndexExpression]]]]: + ) -> Optional[Union[Type[CreateIndexStatement], Sequence[Type[CreateIndexStatement]]]]: return None @classmethod def drop_index_statement_classes( cls, - ) -> Optional[Union[Type[DropIndexExpression], Sequence[Type[DropIndexExpression]]]]: + ) -> Optional[Union[Type[DropIndexStatement], Sequence[Type[DropIndexStatement]]]]: return None @classmethod @@ -602,29 +603,29 @@ def column_constraints(cls, field: str) -> Sequence[ColumnConstraint]: if metadata is None: return [] constraints = [marker.constraint for marker in metadata.constraints] - declared_types = {constraint.constraint_type for constraint in constraints} - explicit_not_null = ColumnConstraintType.NOT_NULL in declared_types - explicit_nullable = ColumnConstraintType.NULL in declared_types + declared_types = {type(constraint.branch) for constraint in constraints} + explicit_not_null = ColumnNotNullConstraint in declared_types + explicit_nullable = ColumnNullableConstraint in declared_types source = cast(Any, cls) pk_member = cls.column_name(field) in source.primary_key_columns() if pk_member and not source.is_composite_pk(): constraints.append( - ColumnConstraint(cast(Any, None), ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(cast(Any, None), branch=ColumnPrimaryKeyConstraint(cast(Any, None))) ) if pk_member: if explicit_nullable: constraints = [ constraint for constraint in constraints - if constraint.constraint_type != ColumnConstraintType.NULL + if not isinstance(constraint.branch, ColumnNullableConstraint) ] if not explicit_not_null: constraints.append( - ColumnConstraint(cast(Any, None), ColumnConstraintType.NOT_NULL) + ColumnConstraint(cast(Any, None), branch=ColumnNotNullConstraint(cast(Any, None))) ) elif not explicit_not_null and not explicit_nullable and not metadata.is_optional: constraints.append( - ColumnConstraint(cast(Any, None), ColumnConstraintType.NOT_NULL) + ColumnConstraint(cast(Any, None), branch=ColumnNotNullConstraint(cast(Any, None))) ) return constraints diff --git a/src/rhosocial/activerecord/base/fields.py b/src/rhosocial/activerecord/base/fields.py index 3512eea9b..0b33f0f71 100644 --- a/src/rhosocial/activerecord/base/fields.py +++ b/src/rhosocial/activerecord/base/fields.py @@ -7,12 +7,28 @@ from ..backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, - GeneratedColumnExpression, + ColumnConstraintBranch, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + ColumnPrimaryKeyConstraint, IndexDefinition, ) from ..backend.type_adapter import SQLTypeAdapter +if TYPE_CHECKING: + from ..backend.expression.bases import BaseExpression, SQLDialectBase, SQLPredicate + from ..backend.expression.column_types import ColumnBase + from ..backend.expression.statements.ddl_table import ( + ReferentialAction, + ) + from ..backend.expression.types import DataType + from .ddl import ColumnAttribute + + + + + + if TYPE_CHECKING: from ..backend.expression.bases import BaseExpression, SQLDialectBase, SQLPredicate from ..backend.expression.column_types import ColumnBase @@ -86,7 +102,6 @@ class UseAdapter: class User(ActiveRecord): # This field will use MyCustomAdapter to convert datetime to str custom_field: Annotated[ - datetime, UseAdapter(MyCustomAdapter(), str) ] """ @@ -225,6 +240,11 @@ class UseColumnType: """ def __init__(self, *column_classes: Type["ColumnBase"]): + # Resolved here rather than at module level: the module keeps backend + # imports lazy, and this is the only runtime use of the name. The + # annotation above is a string, so it needed nothing at runtime -- the + # issubclass check below did, and it raised NameError the moment a + # marker was declared (see 0245b299). from ..backend.expression.column_types import ColumnBase if not column_classes: @@ -347,24 +367,25 @@ def __repr__(self) -> str: class UseConstraint(DDLAnnotation): - """Marker for ``Annotated[T, UseConstraint(constraint_type, ...)]``. + """Marker for ``Annotated[T, UseConstraint(branch, ...)]``. Declares a constraint applied directly to the annotated column in the generated CREATE TABLE statement. - Rejected constraint types (declaration raises immediately): + The first argument is the constraint's **branch** -- the node class that + says which kind this is (``ColumnNotNullConstraint``, + ``ColumnCheckConstraint``, ``ColumnForeignKeyConstraint``, …) -- and the + keyword arguments are that branch's own fields. Which fields those are is + therefore readable from the class, and a field that belongs to another + branch cannot be passed here at all. - - ``PRIMARY_KEY`` — the **only** constraint the marker refuses on - semantic grounds: the primary key has a single source, the - ``__primary_key__`` constant (accessed through ``primary_key()``); - a single-column PK lands on the column, a composite PK becomes a - table-level constraint. - - ``IDENTITY`` / ``COLLATE`` — migrated to the column-attribute channel: - declare ``UseColumnAttributes(IdentityAttribute(...))`` or - ``UseColumnAttributes(CollationAttribute(...))`` instead. + Rejected branches (declaration raises immediately): - Accepted constraint types: NOT NULL / UNIQUE / CHECK / FOREIGN KEY / - DEFAULT. + - ``ColumnPrimaryKeyConstraint`` — the **only** branch the marker refuses + on semantic grounds: the primary key has a single source, the + ``__primary_key__`` constant (accessed through ``primary_key()``); a + single-column PK lands on the column, a composite PK becomes a + table-level constraint. For table-level constraints (CHECK spanning multiple columns, composite UNIQUE, composite FOREIGN KEY), declare ``__table_constraints__`` on the @@ -380,8 +401,7 @@ class UseConstraint(DDLAnnotation): ) status: Annotated[str, UseConstraint( - ColumnConstraintType.CHECK, - check_condition=lambda d: InPredicate( + condition=lambda d: InPredicate( d, Column(d, "status"), Literal(d, ("open", "paid")) ), )] @@ -389,41 +409,60 @@ class UseConstraint(DDLAnnotation): def __init__( self, - constraint_type: "ColumnConstraintType", + branch: type, *, name: Optional[str] = None, - check_condition: Optional[Union["SQLPredicate", "Callable"]] = None, - foreign_key_reference: Optional[tuple] = None, - default_value: Any = None, - is_auto_increment: bool = False, + condition: Optional[Union["SQLPredicate", "Callable"]] = None, + references: Optional[tuple] = None, + value: Any = None, on_delete: Optional["ReferentialAction"] = None, on_update: Optional["ReferentialAction"] = None, + match_type: Optional[Any] = None, deferrable: Optional[bool] = None, initially_deferred: Optional[bool] = None, enforced: Optional[bool] = None, ): - # check_condition may be a ready SQLPredicate or a lazy + # ``condition`` may be a ready SQLPredicate or a lazy # ``(dialect) -> SQLPredicate`` factory; the generator resolves it. # The marker is constructed at model-declaration time (no dialect - # yet) — the constraint node defers binding and the DDL generator + # yet) -- the constraint node defers binding and the DDL generator # binds it through the dialect setter. - if constraint_type == ColumnConstraintType.PRIMARY_KEY: + + if not (isinstance(branch, type) and issubclass(branch, ColumnConstraintBranch)): + raise TypeError( + f"UseConstraint takes a ColumnConstraintBranch class, got {branch!r}" + ) + if branch is ColumnPrimaryKeyConstraint: raise ValueError( - "UseConstraint does not accept PRIMARY_KEY: the primary key " - "has a single source, the __primary_key__ constant (or a " - "primary_key() override). A single-column PK lands on the " + "UseConstraint does not accept ColumnPrimaryKeyConstraint: the " + "primary key has a single source, the __primary_key__ constant " + "(or a primary_key() override). A single-column PK lands on the " "column automatically; a composite PK becomes a table-level " "constraint." ) - self.constraint = ColumnConstraint(None, - constraint_type=constraint_type, + branch_fields = { + "condition": condition, + "references": references, + "value": value, + "on_delete": on_delete, + "on_update": on_update, + "match_type": match_type, + } + if branch is ColumnForeignKeyConstraint and not references: + raise ValueError( + "A ColumnForeignKeyConstraint needs references: the tuple of " + "(Table, [column names]) it points at." + ) + # Only the fields this branch declares are passed; the rest would be + # a field the branch has no use for, which is exactly the kind of + # request the branch nodes exist to make impossible. + import inspect as _inspect + + accepted = set(_inspect.signature(branch.__init__).parameters) - {"self", "dialect"} + self.constraint = ColumnConstraint( + None, + branch=branch(None, **{k: v for k, v in branch_fields.items() if k in accepted and v is not None}), name=name, - check_condition=check_condition, - foreign_key_reference=foreign_key_reference, - default_value=default_value, - is_auto_increment=is_auto_increment, - on_delete=on_delete, - on_update=on_update, # The AR-layer marker keeps its tri-state until the AR layer's # exposure of the split is decided (out of scope for this round); # translate it to the expression layer's two parameters. @@ -436,7 +475,7 @@ def __init__( ) def __repr__(self) -> str: - return f"UseConstraint({self.constraint.constraint_type.name})" + return f"UseConstraint({type(self.constraint.branch).__name__})" class UseColumnAttributes(DDLAnnotation): @@ -454,6 +493,7 @@ class UseColumnAttributes(DDLAnnotation): """ def __init__(self, *attributes: "ColumnAttribute"): + from .ddl import ColumnAttribute if not attributes: @@ -506,18 +546,18 @@ class UseGeneratedColumn(DDLAnnotation): from *expr*. Equivalent to overriding :meth:`generated_column`, but declarative. - *expr* is either a ready :class:`GeneratedColumnExpression` or a lazy - ``(dialect) -> GeneratedColumnExpression`` factory. A factory is the + *expr* is either a ready :class:`ColumnGeneratedConstraint` or a lazy + ``(dialect) -> ColumnGeneratedConstraint`` factory. A factory is the usual form: the annotation is evaluated at class-definition time, before any dialect exists, so a generated column that references other columns must build its expression once the deriver supplies the dialect. """ def __init__(self, expression: Any): - if not callable(expression) and not isinstance(expression, GeneratedColumnExpression): + if not callable(expression) and not isinstance(expression, ColumnGeneratedConstraint): raise TypeError( - "UseGeneratedColumn expects a GeneratedColumnExpression or a " - "(dialect) -> GeneratedColumnExpression factory, got " + "UseGeneratedColumn expects a ColumnGeneratedConstraint or a " + "(dialect) -> ColumnGeneratedConstraint factory, got " f"{type(expression).__name__}." ) self.expression = expression diff --git a/src/rhosocial/activerecord/connection/pool/async_pool.py b/src/rhosocial/activerecord/connection/pool/async_pool.py index a382c625c..b99d74da3 100644 --- a/src/rhosocial/activerecord/connection/pool/async_pool.py +++ b/src/rhosocial/activerecord/connection/pool/async_pool.py @@ -394,79 +394,99 @@ async def acquire(self, timeout: Optional[float] = None) -> Any: f"in_use={self._stats.current_in_use}" ) - while True: - async with self._lock: - if self._closed: - self._semaphore.release() - raise RuntimeError("Pool is closed") - - if self._available: - pooled = self._available.popleft() - self._stats.current_available -= 1 - is_new_backend = False - else: - pooled = None - is_new_backend = True + # The permit is owned from here on. Every exit returns it except the + # success path, where ownership moves to the caller until ``release()``. + # ``CancelledError`` is a ``BaseException``, so the old ``except + # Exception`` clauses let a cancelled acquire keep its permit forever: + # a readiness probe that gave up after two seconds permanently shrank + # the pool, and after enough timeouts ``acquire`` reported + # ``available=0, in_use=0`` while every request waited 30s and failed. + pooled = None + try: + while True: + async with self._lock: + if self._closed: + raise RuntimeError("Pool is closed") + + if self._available: + pooled = self._available.popleft() + self._stats.current_available -= 1 + is_new_backend = False + else: + pooled = None + is_new_backend = True - if pooled is None: - try: + if pooled is None: should_connect = self._is_persistent or ( not self._is_persistent and self.config.auto_connect_on_acquire ) pooled = await self._create_backend(connect=should_connect) - except Exception: - self._semaphore.release() - raise + if pooled is None: + raise RuntimeError("Failed to acquire connection") - if pooled is None: - self._semaphore.release() - raise RuntimeError("Failed to acquire connection") - - try: - if self._is_persistent and self.config.validate_on_borrow: - if not await self._validate_backend(pooled): - if not await self._reconnect_backend(pooled): - await self._destroy_backend(pooled) - if is_new_backend: - self._semaphore.release() - raise RuntimeError("Failed to validate new connection") - continue - - if not self._is_persistent and not is_new_backend: - if self.config.auto_connect_on_acquire: - await self._async_connect(pooled.backend) - if self.config.validate_on_borrow: + try: + if self._is_persistent and self.config.validate_on_borrow: + if not await self._validate_backend(pooled): + if not await self._reconnect_backend(pooled): + await self._destroy_backend(pooled) + pooled = None + if is_new_backend: + raise RuntimeError("Failed to validate new connection") + continue + + if not self._is_persistent and not is_new_backend: + if self.config.auto_connect_on_acquire: + await self._async_connect(pooled.backend) + if self.config.validate_on_borrow: + if not await self._validate_backend(pooled): + await self._destroy_backend(pooled) + pooled = None + continue + + if ( + not self._is_persistent + and is_new_backend + and self.config.validate_on_borrow + ): if not await self._validate_backend(pooled): await self._destroy_backend(pooled) - continue - - if not self._is_persistent and is_new_backend and self.config.validate_on_borrow: - if not await self._validate_backend(pooled): + pooled = None + raise RuntimeError("Failed to validate new connection") + except Exception as e: + if not isinstance(e, RuntimeError): + pooled.mark_unhealthy() await self._destroy_backend(pooled) - self._semaphore.release() - raise RuntimeError("Failed to validate new connection") - except Exception as e: - if not isinstance(e, RuntimeError): - pooled.mark_unhealthy() - await self._destroy_backend(pooled) - logger.error(f"Failed to prepare acquired backend: {e}") - if is_new_backend: - self._semaphore.release() - raise - continue - - async with self._lock: - if self._closed: + pooled = None + logger.error(f"Failed to prepare acquired backend: {e}") + if is_new_backend: + raise + continue + + async with self._lock: + if self._closed: + await self._destroy_backend(pooled) + pooled = None + raise RuntimeError("Pool is closed") + + pooled.mark_used() + self._in_use[id(pooled.backend)] = pooled + self._stats.current_in_use += 1 + self._stats.total_acquired += 1 + self._stats.last_acquired_at = datetime.now() + backend = pooled.backend + pooled = None + return backend + except BaseException: + # Nothing was handed out, so nothing may stay checked out: a backend + # that was mid-connect (or mid-validation) is not trustworthy, and + # the permit must go back or the pool dies a little on each cancel. + if pooled is not None: + try: await self._destroy_backend(pooled) - self._semaphore.release() - raise RuntimeError("Pool is closed") - - pooled.mark_used() - self._in_use[id(pooled.backend)] = pooled - self._stats.current_in_use += 1 - self._stats.total_acquired += 1 - self._stats.last_acquired_at = datetime.now() - return pooled.backend + except BaseException: # noqa: BLE001 - we are already unwinding + pass + self._semaphore.release() + raise async def release(self, backend: Any) -> None: """Release Backend instance. @@ -613,8 +633,8 @@ async def transaction(self, timeout: Optional[float] = None) -> AsyncGenerator[A await existing_conn.begin_transaction() yield existing_conn await existing_conn.commit_transaction() - except Exception: - await existing_conn.rollback_transaction() + except Exception as original: + await self._rollback_after_failure(existing_conn, original) raise finally: ctx._reset_async_transaction_backend(tx_token) @@ -628,14 +648,35 @@ async def transaction(self, timeout: Optional[float] = None) -> AsyncGenerator[A await backend.begin_transaction() yield backend await backend.commit_transaction() - except Exception: - await backend.rollback_transaction() + except Exception as original: + await self._rollback_after_failure(backend, original) raise finally: ctx._reset_async_connection_backend(conn_token) ctx._reset_async_transaction_backend(tx_token) await self.release(backend) + async def _rollback_after_failure(self, backend: Any, original: BaseException) -> None: + """Roll back after the body failed, without hiding what failed. + + A rollback can legitimately fail for reasons that say nothing about the + original error: the transaction was already ended (a statement error + aborts it), the connection is gone, or another coroutine finished it. + Letting that error replace the body's exception is how + ``TransactionError: No active transaction to rollback`` came to be + reported for what was actually a duplicate-key ``IntegrityError``: the + caller lost the only fact it could act on. The rollback failure is + logged, and the original exception is what propagates. + """ + try: + await backend.rollback_transaction() + except Exception as rollback_error: # noqa: BLE001 - the original wins + logger.warning( + "Rollback after %s failed; keeping the original error: %s", + type(original).__name__, + rollback_error, + ) + async def close(self, timeout: Optional[float] = None, force: bool = False) -> None: """Close connection pool gracefully. diff --git a/src/rhosocial/activerecord/connection/pool/sync_pool.py b/src/rhosocial/activerecord/connection/pool/sync_pool.py index 993e0e662..726139c6c 100644 --- a/src/rhosocial/activerecord/connection/pool/sync_pool.py +++ b/src/rhosocial/activerecord/connection/pool/sync_pool.py @@ -574,8 +574,8 @@ def transaction(self, timeout: Optional[float] = None) -> Generator[Any, None, N existing_conn.begin_transaction() yield existing_conn existing_conn.commit_transaction() - except Exception: - existing_conn.rollback_transaction() + except Exception as original: + self._rollback_after_failure(existing_conn, original) raise finally: ctx._reset_transaction_backend(tx_token) @@ -589,14 +589,28 @@ def transaction(self, timeout: Optional[float] = None) -> Generator[Any, None, N backend.begin_transaction() yield backend backend.commit_transaction() - except Exception: - backend.rollback_transaction() + except Exception as original: + self._rollback_after_failure(backend, original) raise finally: ctx._reset_connection_backend(conn_token) ctx._reset_transaction_backend(tx_token) self.release(backend) + def _rollback_after_failure(self, backend: Any, original: BaseException) -> None: + """Roll back after the body failed, without hiding what failed. + + See :meth:`AsyncBackendPool._rollback_after_failure`; the sync twin. + """ + try: + backend.rollback_transaction() + except Exception as rollback_error: # noqa: BLE001 - the original wins + logger.warning( + "Rollback after %s failed; keeping the original error: %s", + type(original).__name__, + rollback_error, + ) + def close(self, timeout: Optional[float] = None, force: bool = False) -> None: """Close connection pool gracefully. diff --git a/src/rhosocial/activerecord/field/version.py b/src/rhosocial/activerecord/field/version.py index 38933a981..f1b106a5e 100644 --- a/src/rhosocial/activerecord/field/version.py +++ b/src/rhosocial/activerecord/field/version.py @@ -31,24 +31,25 @@ class Article(OptimisticLockMixin, ActiveRecord): __version_increment_by__ = 2 # optional row_version: Annotated[ - int, UseColumn("row_ver"), UseConstraint(ColumnConstraintType.NOT_NULL) + int, UseColumn("row_ver"), UseConstraint(ColumnNotNullConstraint) ] = Field(default=1, ge=1) """ import sys -from typing import Any, ClassVar, Dict, List, Union - +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, +) +from .field_config import FieldConfigValidationHandler +from ..interface.model import IActiveRecord, IAsyncActiveRecord +from ..interface.update import IUpdateBehavior +from ..interface import ModelEvent +from ..backend.result import QueryResult +from ..backend.expression import SQLPredicate, SQLValue +from ..backend.errors import DatabaseError +from ..base.fields import UseConstraint from pydantic import Field +from typing import Any, ClassVar, Dict, List, Union -from ..base.fields import UseConstraint -from ..backend.errors import DatabaseError -from ..backend.expression import SQLPredicate, SQLValueExpression -from ..backend.expression.statements.ddl_table import ColumnConstraintType -from ..backend.result import QueryResult -from ..interface import ModelEvent -from ..interface.update import IUpdateBehavior -from ..interface.model import IActiveRecord, IAsyncActiveRecord -from .field_config import FieldConfigValidationHandler if sys.version_info >= (3, 9): from typing import Annotated @@ -163,7 +164,7 @@ def get_update_conditions(self) -> List[SQLPredicate]: # predicate this is. A silently-always-false check would drop the lock. return [ComparisonPredicate(dialect, "=", column, Literal(dialect, self._version_snapshot))] - def get_update_expressions(self) -> Dict[str, SQLValueExpression]: + def get_update_expressions(self) -> Dict[str, SQLValue]: """Add the version increment to the UPDATE SET clause. The key is the *field name*; the update pipeline maps it to the @@ -265,6 +266,6 @@ class DefaultOptimisticLockMixin(OptimisticLockMixin): field should carry a different Python name or column. """ - version: Annotated[int, UseConstraint(ColumnConstraintType.NOT_NULL)] = Field( + version: Annotated[int, UseConstraint(ColumnNotNullConstraint)] = Field( default=1, ge=1 ) diff --git a/src/rhosocial/activerecord/interface/update.py b/src/rhosocial/activerecord/interface/update.py index f98a9174c..7cf98fbd2 100644 --- a/src/rhosocial/activerecord/interface/update.py +++ b/src/rhosocial/activerecord/interface/update.py @@ -5,7 +5,7 @@ from abc import ABC from typing import Any, Dict, List -from ..backend.expression import SQLValueExpression, SQLPredicate +from ..backend.expression import SQLValue, SQLPredicate class IUpdateBehavior(ABC): @@ -70,7 +70,7 @@ def get_update_conditions(self): """ return None # Return None by default if not overridden - def get_update_expressions(self) -> Dict[str, SQLValueExpression]: + def get_update_expressions(self) -> Dict[str, SQLValue]: """ Get additional field expressions to include in UPDATE SET clause. @@ -80,20 +80,20 @@ def get_update_expressions(self) -> Dict[str, SQLValueExpression]: or setting audit information. Returns: - Dict[str, SQLValueExpression]: Mapping of field names to SQL expression objects. + Dict[str, SQLValue]: Mapping of field names to SQL expression objects. If no additional expressions are needed, return an empty dict or None, which will be automatically skipped during updates. Example: def get_update_expressions(self): from ..backend.expression import Column, Literal, FunctionCall - from ..backend.expression.operators import BinaryArithmeticExpression + from ..backend.expression.operators import ArithmeticOperation # Method 1: Using explicit expression objects return { # Increment version in database (for optimistic locking) - 'version': BinaryArithmeticExpression( + 'version': ArithmeticOperation( self.backend().dialect, - '+', + 'add', Column(self.backend().dialect, 'version'), Literal(self.backend().dialect, 1) ), @@ -115,7 +115,7 @@ def get_update_expressions(self): The field proxy is more commonly used in conditions (get_update_conditions) For expressions, you can still use field proxy for the column reference: def get_update_expressions(self): - # This generates SQLValueExpression objects automatically + # This generates SQLValue objects automatically return { 'version': (self.__class__.c.version + 1), # Uses field proxy for arithmetic 'updated_at': 'CURRENT_TIMESTAMP', # SQL function string diff --git a/src/rhosocial/activerecord/query/active_query.py b/src/rhosocial/activerecord/query/active_query.py index 73c066636..4cbae9b19 100644 --- a/src/rhosocial/activerecord/query/active_query.py +++ b/src/rhosocial/activerecord/query/active_query.py @@ -13,7 +13,7 @@ from .async_relational import AsyncRelationalQueryMixin from .set_operation import SetOperationQuery from ..backend.base import StorageBackend, AsyncStorageBackend -from ..backend.expression import WildcardExpression, statements, LimitOffsetClause, bases +from ..backend.expression import AllColumnsWildcard, statements, LimitOffsetClause, bases from ..backend.expression.objects import Table from ..backend.expression.sources import NamedRelationRef from ..interface.model import IActiveRecord, IAsyncActiveRecord @@ -54,7 +54,7 @@ class ActiveQuery( - Supports relationship queries with model instantiation and association management When constructing queries that include wildcards (SELECT *), the system - automatically uses WildcardExpression instead of Literal("*") to avoid + automatically uses AllColumnsWildcard instead of Literal("*") to avoid treating the wildcard as a parameter value. This ensures correct SQL generation. """ @@ -71,7 +71,7 @@ def __init__(self, model_class: Type[IActiveRecord]): self.where_clause = None self.order_by_clause = None self.join_clauses = [] - self.select_columns = [WildcardExpression(self.backend().dialect)] + self.select_columns = [AllColumnsWildcard(self.backend().dialect)] self.limit_offset_clause = None self.group_by_having_clause = None self._adapt_params = True @@ -178,7 +178,7 @@ def one(self) -> Optional[IActiveRecord]: backend = self.backend() dialect = backend.dialect - # Create a temporary QueryExpression with LIMIT 1 + # Create a temporary QueryStatement with LIMIT 1 from_clause = NamedRelationRef( dialect, Table( @@ -192,7 +192,7 @@ def one(self) -> Optional[IActiveRecord]: temp_limit_offset = LimitOffsetClause(dialect, limit=1) # Use the temporary limit_offset instead of the original one - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=from_clause, @@ -203,7 +203,7 @@ def one(self) -> Optional[IActiveRecord]: for_update=self._for_update_clause, # Pass FOR UPDATE clause for row locking ) - # Generate SQL using the temporary QueryExpression + # Generate SQL using the temporary QueryStatement sql, params = query_expr.to_sql() self._log(logging.INFO, f"Executing query: {sql}, parameters: {params}") @@ -228,8 +228,8 @@ def one(self) -> Optional[IActiveRecord]: return record - def to_query_expression(self) -> "statements.QueryExpression": - """Build the underlying QueryExpression for this query. + def to_query_expression(self) -> "statements.QueryStatement": + """Build the underlying QueryStatement for this query. Public so predicates (e.g. ``IN``) can embed this query as a subquery without reaching into private state. @@ -247,8 +247,8 @@ def to_query_expression(self) -> "statements.QueryExpression": ), ) - # Create QueryExpression with all components - return statements.QueryExpression( + # Create QueryStatement with all components + return statements.QueryStatement( dialect, select=self.select_columns, from_=self.join_clause if self.join_clause else from_clause, @@ -268,7 +268,7 @@ def to_sql(self) -> "bases.SQLQueryAndParams": Returns: Tuple of (SQL string, parameters tuple) """ - # Generate SQL using the QueryExpression + # Generate SQL using the QueryStatement return self.to_query_expression().to_sql() def union(self, other: "IQuery") -> "SetOperationQuery": @@ -426,7 +426,7 @@ class AsyncActiveQuery( - Supports relationship queries with model instantiation and association management When constructing queries that include wildcards (SELECT *), the system - automatically uses WildcardExpression instead of Literal("*") to avoid + automatically uses AllColumnsWildcard instead of Literal("*") to avoid treating the wildcard as a parameter value. This ensures correct SQL generation. """ @@ -443,7 +443,7 @@ def __init__(self, model_class: Type[IAsyncActiveRecord]): self.where_clause = None self.order_by_clause = None self.join_clauses = [] - self.select_columns = [WildcardExpression(self.backend().dialect)] + self.select_columns = [AllColumnsWildcard(self.backend().dialect)] self.limit_offset_clause = None self.group_by_having_clause = None self._adapt_params = True @@ -550,7 +550,7 @@ async def one(self) -> Optional[IActiveRecord]: backend = self.backend() dialect = backend.dialect - # Create a temporary QueryExpression with LIMIT 1 + # Create a temporary QueryStatement with LIMIT 1 from_clause = NamedRelationRef( dialect, Table( @@ -564,7 +564,7 @@ async def one(self) -> Optional[IActiveRecord]: temp_limit_offset = LimitOffsetClause(dialect, limit=1) # Use the temporary limit_offset instead of the original one - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=from_clause, @@ -575,7 +575,7 @@ async def one(self) -> Optional[IActiveRecord]: for_update=self._for_update_clause, # Pass FOR UPDATE clause for row locking ) - # Generate SQL using the temporary QueryExpression + # Generate SQL using the temporary QueryStatement sql, params = query_expr.to_sql() self._log(logging.INFO, f"Executing async query: {sql}, parameters: {params}") @@ -622,8 +622,8 @@ def to_sql(self) -> "bases.SQLQueryAndParams": ), ) - # Create QueryExpression with all components - query_expr = statements.QueryExpression( + # Create QueryStatement with all components + query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=self.join_clause if self.join_clause else from_clause, @@ -634,7 +634,7 @@ def to_sql(self) -> "bases.SQLQueryAndParams": for_update=self._for_update_clause, ) - # Generate SQL using the QueryExpression + # Generate SQL using the QueryStatement return query_expr.to_sql() def union(self, other: "IAsyncQuery") -> "IAsyncSetOperationQuery": diff --git a/src/rhosocial/activerecord/query/aggregate.py b/src/rhosocial/activerecord/query/aggregate.py index c9b5c60d1..ed48da8dc 100644 --- a/src/rhosocial/activerecord/query/aggregate.py +++ b/src/rhosocial/activerecord/query/aggregate.py @@ -8,7 +8,7 @@ functions, statements, BaseExpression, - WildcardExpression, + RowCountWildcard, Column, ) from ..backend.expression.objects import Table @@ -67,7 +67,7 @@ def count( dialect = backend.dialect if isinstance(column, str) and column == "*": - column_arg = WildcardExpression(dialect) + column_arg = RowCountWildcard(dialect) else: column_arg = column if isinstance(column, BaseExpression) else Column(dialect, str(column)) @@ -343,7 +343,7 @@ def aggregate(self) -> List[Dict[str, Any]]: ), ) - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=self.join_clause if self.join_clause else from_clause, @@ -353,9 +353,9 @@ def aggregate(self) -> List[Dict[str, Any]]: limit_offset=self.limit_offset_clause, ) - # Create ExplainExpression with the query and options + # Create ExplainStatement with the query and options explain_options = statements.ExplainOptions(**self._explain_options) - explain_expr = statements.ExplainExpression(dialect, query_expr, explain_options) + explain_expr = statements.ExplainStatement(dialect, query_expr, explain_options) # Generate SQL for the EXPLAIN statement explain_sql, explain_params = explain_expr.to_sql() @@ -458,7 +458,7 @@ async def count( backend = self.backend() dialect = backend.dialect if isinstance(column, str) and column == "*": - column_arg = WildcardExpression(dialect) + column_arg = RowCountWildcard(dialect) else: column_arg = column if isinstance(column, BaseExpression) else Column(dialect, str(column)) @@ -734,7 +734,7 @@ async def aggregate(self) -> List[Dict[str, Any]]: ), ) - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=self.join_clause if self.join_clause else from_clause, @@ -744,9 +744,9 @@ async def aggregate(self) -> List[Dict[str, Any]]: limit_offset=self.limit_offset_clause, ) - # Create ExplainExpression with the query and options + # Create ExplainStatement with the query and options explain_options = statements.ExplainOptions(**self._explain_options) - explain_expr = statements.ExplainExpression(dialect, query_expr, explain_options) + explain_expr = statements.ExplainStatement(dialect, query_expr, explain_options) # Generate SQL for the EXPLAIN statement explain_sql, explain_params = explain_expr.to_sql() diff --git a/src/rhosocial/activerecord/query/base.py b/src/rhosocial/activerecord/query/base.py index c5dce2402..3d72569e0 100644 --- a/src/rhosocial/activerecord/query/base.py +++ b/src/rhosocial/activerecord/query/base.py @@ -15,6 +15,7 @@ LimitOffsetClause, ForUpdateClause, ) +from ..backend.expression.query_parts import SortDirection from ..interface import IQueryBuilding from ..logging.defaults import _LOGGER_QUERY from ..logging.mixin import BackendLoggingMixin @@ -236,9 +237,9 @@ def select(self, *columns: Union[str, BaseExpression], append: bool = False) -> if converted_columns: self.select_columns = converted_columns else: - from ..backend.expression import WildcardExpression + from ..backend.expression import AllColumnsWildcard - self.select_columns = [WildcardExpression(dialect)] + self.select_columns = [AllColumnsWildcard(dialect)] return self @@ -304,15 +305,16 @@ def order_by( else: raise TypeError(f"Expression must be str or BaseExpression, got {type(expr)}") - # Validate direction - direction = direction.upper() - if direction not in ("ASC", "DESC"): - raise ValueError(f"Order direction must be 'ASC' or 'DESC', got '{direction}'") + # Validate direction. Through the enum rather than a literal + # pair, so the accepted set is declared once -- the same reason + # the artifact writes a word: a list of spellings checked here + # is a list that can drift from the type that defines them. + direction = SortDirection.parse(direction) order_expressions.append((expr_obj, direction)) elif isinstance(clause, BaseExpression): # Expression object with default direction - order_expressions.append((clause, "ASC")) + order_expressions.append((clause, SortDirection.ASC)) else: raise TypeError( f"Order clause must be str, BaseExpression, or (expression, direction) tuple, got {type(clause)}" diff --git a/src/rhosocial/activerecord/query/cte_query.py b/src/rhosocial/activerecord/query/cte_query.py index b39b7b0bd..86fe4c4bf 100644 --- a/src/rhosocial/activerecord/query/cte_query.py +++ b/src/rhosocial/activerecord/query/cte_query.py @@ -12,10 +12,10 @@ from .utils import convert_qmark_placeholder from ..backend.base import StorageBackend, AsyncStorageBackend from ..backend.dialect.exceptions import UnsupportedFeatureError -from ..backend.expression import statements, WildcardExpression, query_sources, bases +from ..backend.expression import statements, AllColumnsWildcard, query_sources, bases from ..backend.expression.objects import Table from ..backend.expression.sources import NamedRelationRef -from ..backend.expression.query_sources import CTEExpression +from ..backend.expression.query_sources import CTEClause from ..interface import ICTEQuery, IAsyncCTEQuery, ISetOperationQuery, IAsyncSetOperationQuery, IQuery, IAsyncQuery if TYPE_CHECKING: # pragma: no cover @@ -48,12 +48,12 @@ class CTEQuery( - Results are always dictionaries, no model instantiation occurs When constructing queries that include wildcards (SELECT *), the system - automatically uses WildcardExpression instead of Literal("*") to avoid + automatically uses AllColumnsWildcard instead of Literal("*") to avoid treating the wildcard as a parameter value. This ensures correct SQL generation. """ # region Instance Attributes - _ctes: List[CTEExpression] + _ctes: List[CTEClause] _main_cte_name: Optional[str] _recursive: bool # endregion @@ -88,7 +88,7 @@ def __init__(self, backend: StorageBackend): self.where_clause = None self.order_by_clause = None self.join_clauses = [] - self.select_columns = [WildcardExpression(self.backend().dialect)] + self.select_columns = [AllColumnsWildcard(self.backend().dialect)] self.limit_offset_clause = None self.group_by_having_clause = None self._adapt_params = True @@ -116,7 +116,7 @@ def backend(self): def with_cte( self, name: str, - query: Union[str, "bases.SQLQueryAndParams", "IQuery", "statements.QueryExpression"], + query: Union[str, "bases.SQLQueryAndParams", "IQuery", "statements.QueryStatement"], columns: Optional[List[str]] = None, materialized: Optional[bool] = None, ): @@ -125,7 +125,7 @@ def with_cte( Args: name: Name of the CTE query: The query that defines the CTE. Best practice is to use IQuery instances - (such as ActiveQuery) or QueryExpression objects. Also supports raw SQL + (such as ActiveQuery) or QueryStatement objects. Also supports raw SQL string or SQLQueryAndParams (sql_string, params_tuple) for direct SQL input. columns: Optional list of column names for the CTE materialized: Whether the CTE should be materialized (for databases that support it) @@ -138,8 +138,8 @@ def with_cte( active_query = MyModel.query().select('col1', 'col2').where('col1 > ?', (10,)) cte_query.with_cte('my_cte', active_query) - # Recommended: Using QueryExpression - query_expr = statements.QueryExpression(dialect, ...) + # Recommended: Using QueryStatement + query_expr = statements.QueryStatement(dialect, ...) cte_query.with_cte('my_cte', query_expr) # Supported but for demonstration: raw SQL with parameters @@ -151,18 +151,18 @@ def with_cte( # Convert the query to an appropriate expression if isinstance(query, str): # If query is a string, we'll need to handle it differently - # For now, we'll create a RawSQLExpression - from ..backend.expression.operators import RawSQLExpression + # For now, we'll create a RawSQL + from ..backend.expression.operators import RawSQL - query_expr = RawSQLExpression(dialect, convert_qmark_placeholder(dialect, query)) - elif bases.is_sql_query_and_params(query): - # If query is a SQLQueryAndParams (str, tuple), create a RawSQLExpression with parameters - from ..backend.expression.operators import RawSQLExpression + query_expr = RawSQL(dialect, convert_qmark_placeholder(dialect, query)) + elif bases.QueryWithParams.holds(query): + # If query is a SQLQueryAndParams (str, tuple), create a RawSQL with parameters + from ..backend.expression.operators import RawSQL sql_string, params = query # If params is None, use an empty tuple params = params if params is not None else () - query_expr = RawSQLExpression(dialect, convert_qmark_placeholder(dialect, sql_string), params) + query_expr = RawSQL(dialect, convert_qmark_placeholder(dialect, sql_string), params) elif isinstance(query, IAsyncQuery): # Reject async queries to prevent sync/async mixing. # Although to_sql() is itself a sync/async-agnostic pure computation @@ -176,27 +176,27 @@ def with_cte( elif isinstance(query, IQuery): # Accept sync IQuery. to_sql() is a sync, sync/async-agnostic pure # computation that returns SQL and params, so it is safe to inline. - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL sql, params = query.to_sql() - query_expr = RawSQLExpression(dialect, sql, params) - elif isinstance(query, statements.QueryExpression): - # If query is a QueryExpression, convert it to a RawSQLExpression to avoid extra parentheses - from ..backend.expression.operators import RawSQLExpression + query_expr = RawSQL(dialect, sql, params) + elif isinstance(query, statements.QueryStatement): + # If query is a QueryStatement, convert it to a RawSQL to avoid extra parentheses + from ..backend.expression.operators import RawSQL sql, params = query.to_sql() - query_expr = RawSQLExpression(dialect, sql, params) + query_expr = RawSQL(dialect, sql, params) else: # For other types, raise an error as they are not supported raise TypeError( f"Query type {type(query)} is not supported in CTE. " - "Only str, SQLQueryAndParams, IQuery, and QueryExpression are supported." + "Only str, SQLQueryAndParams, IQuery, and QueryStatement are supported." ) - # Create a CTEExpression. The query-layer ``materialized`` tri-state is + # Create a CTEClause. The query-layer ``materialized`` tri-state is # translated to the expression layer's two parameters; the AR-layer # exposure of the split is decided later. - cte_expr = query_sources.CTEExpression( + cte_expr = query_sources.CTEClause( dialect, name=name, query=query_expr, columns=columns, materialized=materialized is True, not_materialized=materialized is False, @@ -232,7 +232,7 @@ def recursive(self, enabled: bool = True): return self def to_sql(self) -> "bases.SQLQueryAndParams": - """Generate SQL for this CTE query using WithQueryExpression. + """Generate SQL for this CTE query using WithStatement. Note: Unlike BaseQueryMixin.to_sql(), this method constructs a complete WITH query using CTE expressions. The SQL generation follows the pattern: @@ -261,7 +261,7 @@ def to_sql(self) -> "bases.SQLQueryAndParams": raise ValueError(f"CTE '{main_cte_name}' not found in defined CTEs: {cte_names}") # Build the main query using collected conditions from mixins - main_query_expr = statements.QueryExpression( + main_query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=NamedRelationRef( @@ -274,9 +274,9 @@ def to_sql(self) -> "bases.SQLQueryAndParams": limit_offset=self.limit_offset_clause, ) - # Create WithQueryExpression with the CTEs and main query - with_query_expr = query_sources.WithQueryExpression( - dialect, ctes=self._ctes, main_query=main_query_expr, recursive=self._recursive + # Create WithStatement with the CTEs and main query + with_query_expr = query_sources.WithStatement( + dialect, ctes=self._ctes, query=main_query_expr, recursive=self._recursive ) return with_query_expr.to_sql() @@ -288,12 +288,12 @@ def aggregate(self) -> List[Dict[str, Any]]: """ if self._explain_enabled: dialect = self.backend().dialect - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL - query_expr = RawSQLExpression(dialect, *self.to_sql()) + query_expr = RawSQL(dialect, *self.to_sql()) explain_options = statements.ExplainOptions(**self._explain_options) - explain_expr = statements.ExplainExpression(dialect, query_expr, explain_options) + explain_expr = statements.ExplainStatement(dialect, query_expr, explain_options) explain_sql, explain_params = explain_expr.to_sql() self._log( @@ -369,12 +369,12 @@ class AsyncCTEQuery( - Results are always dictionaries, no model instantiation occurs When constructing queries that include wildcards (SELECT *), the system - automatically uses WildcardExpression instead of Literal("*") to avoid + automatically uses AllColumnsWildcard instead of Literal("*") to avoid treating the wildcard as a parameter value. This ensures correct SQL generation. """ # region Instance Attributes - _ctes: List[CTEExpression] + _ctes: List[CTEClause] _main_cte_name: Optional[str] _recursive: bool # endregion @@ -409,7 +409,7 @@ def __init__(self, backend: AsyncStorageBackend): self.where_clause = None self.order_by_clause = None self.join_clauses = [] - self.select_columns = [WildcardExpression(self.backend().dialect)] + self.select_columns = [AllColumnsWildcard(self.backend().dialect)] self.limit_offset_clause = None self.group_by_having_clause = None self._adapt_params = True @@ -437,7 +437,7 @@ def backend(self): def with_cte( self, name: str, - query: Union[str, "bases.SQLQueryAndParams", "IQuery", "statements.QueryExpression"], + query: Union[str, "bases.SQLQueryAndParams", "IQuery", "statements.QueryStatement"], columns: Optional[List[str]] = None, materialized: Optional[bool] = None, ): @@ -446,7 +446,7 @@ def with_cte( Args: name: Name of the CTE query: The query that defines the CTE. Best practice is to use IQuery instances - (such as ActiveQuery) or QueryExpression objects. Also supports raw SQL + (such as ActiveQuery) or QueryStatement objects. Also supports raw SQL string or SQLQueryAndParams (sql_string, params_tuple) for direct SQL input. columns: Optional list of column names for the CTE materialized: Whether the CTE should be materialized (for databases that support it) @@ -459,8 +459,8 @@ def with_cte( active_query = MyModel.query().select('col1', 'col2').where('col1 > ?', (10,)) cte_query.with_cte('my_cte', active_query) - # Recommended: Using QueryExpression - query_expr = statements.QueryExpression(dialect, ...) + # Recommended: Using QueryStatement + query_expr = statements.QueryStatement(dialect, ...) cte_query.with_cte('my_cte', query_expr) # Supported but for demonstration: raw SQL with parameters @@ -472,27 +472,27 @@ def with_cte( # Convert the query to an appropriate expression if isinstance(query, str): # If query is a string, we'll need to handle it differently - # For now, we'll create a RawSQLExpression - from ..backend.expression.operators import RawSQLExpression + # For now, we'll create a RawSQL + from ..backend.expression.operators import RawSQL - query_expr = RawSQLExpression(dialect, convert_qmark_placeholder(dialect, query)) - elif bases.is_sql_query_and_params(query): - # If query is a SQLQueryAndParams (str, tuple), create a RawSQLExpression with parameters - from ..backend.expression.operators import RawSQLExpression + query_expr = RawSQL(dialect, convert_qmark_placeholder(dialect, query)) + elif bases.QueryWithParams.holds(query): + # If query is a SQLQueryAndParams (str, tuple), create a RawSQL with parameters + from ..backend.expression.operators import RawSQL sql_string, params = query # If params is None, use an empty tuple params = params if params is not None else () - query_expr = RawSQLExpression(dialect, convert_qmark_placeholder(dialect, sql_string), params) + query_expr = RawSQL(dialect, convert_qmark_placeholder(dialect, sql_string), params) elif isinstance(query, IAsyncQuery): # Accept async IAsyncQuery (including AsyncActiveQuery and # AsyncSetOperationQuery). to_sql() is a sync, sync/async-agnostic # pure computation that returns SQL and params, so it is safe to # inline here regardless of the query's async execution surface. - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL sql, params = query.to_sql() - query_expr = RawSQLExpression(dialect, sql, params) + query_expr = RawSQL(dialect, sql, params) elif isinstance(query, IQuery): # Reject sync queries to prevent sync/async mixing. The async query # types are dispatched above via IAsyncQuery; anything reaching here @@ -501,23 +501,23 @@ def with_cte( f"AsyncCTEQuery (async) cannot accept sync query of type {type(query).__name__}. " "Use CTEQuery for sync queries." ) - elif isinstance(query, statements.QueryExpression): - # If query is a QueryExpression, convert it to a RawSQLExpression to avoid extra parentheses - from ..backend.expression.operators import RawSQLExpression + elif isinstance(query, statements.QueryStatement): + # If query is a QueryStatement, convert it to a RawSQL to avoid extra parentheses + from ..backend.expression.operators import RawSQL sql, params = query.to_sql() - query_expr = RawSQLExpression(dialect, sql, params) + query_expr = RawSQL(dialect, sql, params) else: # For other types, raise an error as they are not supported raise TypeError( f"Query type {type(query)} is not supported in CTE. " - "Only str, SQLQueryAndParams, IQuery, and QueryExpression are supported." + "Only str, SQLQueryAndParams, IQuery, and QueryStatement are supported." ) - # Create a CTEExpression. The query-layer ``materialized`` tri-state is + # Create a CTEClause. The query-layer ``materialized`` tri-state is # translated to the expression layer's two parameters; the AR-layer # exposure of the split is decided later. - cte_expr = query_sources.CTEExpression( + cte_expr = query_sources.CTEClause( dialect, name=name, query=query_expr, columns=columns, materialized=materialized is True, not_materialized=materialized is False, @@ -553,7 +553,7 @@ def recursive(self, enabled: bool = True): return self def to_sql(self) -> "bases.SQLQueryAndParams": - """Generate SQL for this CTE query using WithQueryExpression. + """Generate SQL for this CTE query using WithStatement. Note: Unlike BaseQueryMixin.to_sql(), this method constructs a complete WITH query using CTE expressions. The SQL generation follows the pattern: @@ -582,7 +582,7 @@ def to_sql(self) -> "bases.SQLQueryAndParams": raise ValueError(f"CTE '{main_cte_name}' not found in defined CTEs: {cte_names}") # Build the main query using collected conditions from mixins - main_query_expr = statements.QueryExpression( + main_query_expr = statements.QueryStatement( dialect, select=self.select_columns, from_=NamedRelationRef( @@ -595,9 +595,9 @@ def to_sql(self) -> "bases.SQLQueryAndParams": limit_offset=self.limit_offset_clause, ) - # Create WithQueryExpression with the CTEs and main query - with_query_expr = query_sources.WithQueryExpression( - dialect, ctes=self._ctes, main_query=main_query_expr, recursive=self._recursive + # Create WithStatement with the CTEs and main query + with_query_expr = query_sources.WithStatement( + dialect, ctes=self._ctes, query=main_query_expr, recursive=self._recursive ) return with_query_expr.to_sql() @@ -609,12 +609,12 @@ async def aggregate(self) -> List[Dict[str, Any]]: """ if self._explain_enabled: dialect = self.backend().dialect - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL - query_expr = RawSQLExpression(dialect, *self.to_sql()) + query_expr = RawSQL(dialect, *self.to_sql()) explain_options = statements.ExplainOptions(**self._explain_options) - explain_expr = statements.ExplainExpression(dialect, query_expr, explain_options) + explain_expr = statements.ExplainStatement(dialect, query_expr, explain_options) explain_sql, explain_params = explain_expr.to_sql() self._log( diff --git a/src/rhosocial/activerecord/query/range.py b/src/rhosocial/activerecord/query/range.py index 88c705e4c..fd98e8c0d 100644 --- a/src/rhosocial/activerecord/query/range.py +++ b/src/rhosocial/activerecord/query/range.py @@ -10,6 +10,7 @@ ComparisonPredicate, InPredicate, IsNullPredicate, + LikeOperator, LikePredicate, Literal, LogicalPredicate, @@ -246,7 +247,9 @@ def like(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery": """ col_expr = self._get_col_expr(column) dialect = self.backend().dialect - predicate = LikePredicate(dialect, "LIKE", col_expr, Literal(dialect, pattern)) + predicate = LikePredicate( + dialect, LikeOperator.LIKE, col_expr, Literal(dialect, pattern) + ) return self.where(predicate) def not_like(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery": @@ -269,7 +272,7 @@ def not_like(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery" """ col_expr = self._get_col_expr(column) dialect = self.backend().dialect - predicate = ~LikePredicate(dialect, "LIKE", col_expr, Literal(dialect, pattern)) + predicate = ~LikePredicate(dialect, LikeOperator.LIKE, col_expr, Literal(dialect, pattern)) return self.where(predicate) def ilike(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery": @@ -293,7 +296,9 @@ def ilike(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery": """ col_expr = self._get_col_expr(column) dialect = self.backend().dialect - predicate = LikePredicate(dialect, "ILIKE", col_expr, Literal(dialect, pattern)) + predicate = LikePredicate( + dialect, LikeOperator.ILIKE, col_expr, Literal(dialect, pattern) + ) return self.where(predicate) def not_ilike(self, column: Union[str, BaseExpression], pattern: str) -> "IQuery": diff --git a/src/rhosocial/activerecord/query/set_operation.py b/src/rhosocial/activerecord/query/set_operation.py index 04f026869..a984ef323 100644 --- a/src/rhosocial/activerecord/query/set_operation.py +++ b/src/rhosocial/activerecord/query/set_operation.py @@ -4,11 +4,36 @@ from typing import Union, List, Dict, Any, Optional from ..backend.base import StorageBackend, AsyncStorageBackend -from ..backend.expression import SetOperationExpression, bases +from ..backend.expression import SetOperation, bases from ..interface import IQuery, IAsyncQuery, ISetOperationQuery, IAsyncSetOperationQuery from ..logging.defaults import _LOGGER_QUERY from ..logging.mixin import BackendLoggingMixin +# The set operation the expression node carries is a **word form** +# (``union`` / ``union_all`` / ``intersect`` / ``except``) — the wire enum. +# This query layer has always spoken SQL symbols at its public surface, so +# both spellings are accepted here and translated once, at the boundary. +_OPERATION_TO_OP = { + "union": "union", + "union_all": "union_all", + "intersect": "intersect", + "except": "except", + "UNION": "union", + "UNION ALL": "union_all", + "INTERSECT": "intersect", + "EXCEPT": "except", +} + + +def _to_op_word(operation: str) -> str: + try: + return _OPERATION_TO_OP[operation] + except KeyError: + raise ValueError( + f"operation must be one of {sorted(set(_OPERATION_TO_OP.values()))} " + f"(or its SQL symbol spelling), got {operation!r}" + ) from None + class SetOperationQuery(ISetOperationQuery): """SetOperationQuery implementation for UNION, INTERSECT, and EXCEPT queries. @@ -79,12 +104,12 @@ def __init__( f"Different dialect types for left ({type(left_dialect)}) and right ({type(right_dialect)}) operands" ) - # Use SetOperationExpression to represent the set operation - self._set_op_expr = SetOperationExpression( + # Use SetOperation to represent the set operation + self._set_op_expr = SetOperation( left_dialect, # Use left's dialect as the main dialect + op=_to_op_word(operation), left=self._convert_to_base_expression(left), right=self._convert_to_base_expression(right), - operation=operation, ) # Initialize explain-related attributes @@ -97,20 +122,20 @@ def __init__( def _convert_to_base_expression(self, query: IQuery) -> bases.BaseExpression: """Convert a query object to a BaseExpression suitable for expression system. - Since SetOperationExpression belongs to the expression system, it should only accept + Since SetOperation belongs to the expression system, it should only accept BaseExpression objects, not high-level IQuery objects. """ # If the query is a SetOperationQuery (which inherits from IQuery), get its underlying expression if isinstance(query, SetOperationQuery): return query._set_op_expr # If the query implements IQuery (including ActiveQuery), convert it to a - # RawSQLExpression to avoid extra parentheses + # RawSQL to avoid extra parentheses elif isinstance(query, IQuery): - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL - # Convert the IQuery to SQL first, then create a RawSQLExpression + # Convert the IQuery to SQL first, then create a RawSQL sql, params = query.to_sql() - return RawSQLExpression(query.backend().dialect, sql, params) + return RawSQL(query.backend().dialect, sql, params) else: # Fallback: This case might indicate a design issue or missing functionality # in how different query types are handled @@ -118,7 +143,7 @@ def _convert_to_base_expression(self, query: IQuery) -> bases.BaseExpression: def to_sql(self) -> "bases.SQLQueryAndParams": """Convert the set operation query to SQL and parameters.""" - # Use the SetOperationExpression's to_sql method + # Use the SetOperation's to_sql method return self._set_op_expr.to_sql() def union(self, other: "IQuery") -> "SetOperationQuery": @@ -189,11 +214,11 @@ def aggregate(self) -> List[Dict[str, Any]]: dialect = backend.dialect # Create the underlying set operation expression - from ..backend.expression.statements import ExplainExpression, ExplainOptions + from ..backend.expression.statements import ExplainStatement, ExplainOptions - # Create ExplainExpression with the set operation expression and options + # Create ExplainStatement with the set operation expression and options explain_options = ExplainOptions(**self._explain_options) - explain_expr = ExplainExpression(dialect, self._set_op_expr, explain_options) + explain_expr = ExplainStatement(dialect, self._set_op_expr, explain_options) # Generate SQL for the EXPLAIN statement explain_sql, explain_params = explain_expr.to_sql() @@ -309,12 +334,12 @@ def __init__( f"Different dialect types for left ({type(left_dialect)}) and right ({type(right_dialect)}) operands" ) - # Use SetOperationExpression to represent the set operation - self._set_op_expr = SetOperationExpression( + # Use SetOperation to represent the set operation + self._set_op_expr = SetOperation( left_dialect, # Use left's dialect as the main dialect + op=_to_op_word(operation), left=self._convert_to_base_expression(left), right=self._convert_to_base_expression(right), - operation=operation, ) # Initialize explain-related attributes @@ -327,20 +352,20 @@ def __init__( def _convert_to_base_expression(self, query: IAsyncQuery) -> bases.BaseExpression: """Convert an async query object to a BaseExpression suitable for expression system. - Since SetOperationExpression belongs to the expression system, it should only accept + Since SetOperation belongs to the expression system, it should only accept BaseExpression objects, not high-level IAsyncQuery objects. """ # If the query is an AsyncSetOperationQuery (which inherits from IAsyncQuery), get its underlying expression if isinstance(query, AsyncSetOperationQuery): return query._set_op_expr # If the query implements IAsyncQuery (including AsyncActiveQuery), convert it to a - # RawSQLExpression to avoid extra parentheses + # RawSQL to avoid extra parentheses elif isinstance(query, IAsyncQuery): - from ..backend.expression.operators import RawSQLExpression + from ..backend.expression.operators import RawSQL - # Convert the IAsyncQuery to SQL first, then create a RawSQLExpression + # Convert the IAsyncQuery to SQL first, then create a RawSQL sql, params = query.to_sql() - return RawSQLExpression(query.backend().dialect, sql, params) + return RawSQL(query.backend().dialect, sql, params) else: # Fallback: This case might indicate a design issue or missing functionality # in how different query types are handled @@ -348,7 +373,7 @@ def _convert_to_base_expression(self, query: IAsyncQuery) -> bases.BaseExpressio def to_sql(self) -> "bases.SQLQueryAndParams": """Convert the async set operation query to SQL and parameters.""" - # Use the SetOperationExpression's to_sql method + # Use the SetOperation's to_sql method return self._set_op_expr.to_sql() def union(self, other: "IAsyncQuery") -> "AsyncSetOperationQuery": @@ -419,11 +444,11 @@ async def aggregate(self) -> List[Dict[str, Any]]: dialect = backend.dialect # Create the underlying set operation expression - from ..backend.expression.statements import ExplainExpression, ExplainOptions + from ..backend.expression.statements import ExplainStatement, ExplainOptions - # Create ExplainExpression with the set operation expression and options + # Create ExplainStatement with the set operation expression and options explain_options = ExplainOptions(**self._explain_options) - explain_expr = ExplainExpression(dialect, self._set_op_expr, explain_options) + explain_expr = ExplainStatement(dialect, self._set_op_expr, explain_options) # Generate SQL for the EXPLAIN statement explain_sql, explain_params = explain_expr.to_sql() diff --git a/tests/providers/basic.py b/tests/providers/basic.py index b03373baa..4526e281c 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -17,7 +17,7 @@ import logging from typing import Type, List, Tuple -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter from rhosocial.activerecord.model import ActiveRecord @@ -423,7 +423,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -700,7 +700,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -780,7 +780,7 @@ async def _initialize_async_model_schema( from rhosocial.activerecord.backend.schema import StatementType options = ExecutionOptions(stmt_type=StatementType.DDL) - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, diff --git a/tests/providers/events.py b/tests/providers/events.py index e2c6721b8..eaafacb9a 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -17,7 +17,7 @@ import logging from typing import Type, List -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.model import ActiveRecord # Setup logging for fixture selection debugging @@ -207,7 +207,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -329,7 +329,7 @@ async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_nam from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 2c3cd00f4..9e67cc4eb 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -1,3 +1,19 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + TablePrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + ColumnDefinition, + ColumnConstraint, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.types import BlobType, BooleanType, DateTimeType, DecimalType, FloatType, IntegerType, TextType, VarCharType +from typing import Callable, Dict # tests/providers/fixtures/basic.py """ DDL expressions for the feature/basic table group. @@ -10,43 +26,31 @@ 2. Eventually: replace .sql files entirely """ -from typing import Callable, Dict -from rhosocial.activerecord.backend.expression.types import BlobType, BooleanType, DateTimeType, DecimalType, FloatType, IntegerType, TextType, VarCharType -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -from rhosocial.activerecord.backend.expression.objects import Table -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, 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", 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, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0), + 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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -54,14 +58,14 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress ) -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, columns=[ ColumnDefinition(dialect, "id", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", TextType(dialect)), ColumnDefinition(dialect, "email", TextType(dialect)), ColumnDefinition(dialect, "tiny_int", IntegerType(dialect)), @@ -84,14 +88,14 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa ) -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, columns=[ ColumnDefinition(dialect, "id", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "string_field", TextType(dialect)), ColumnDefinition(dialect, "int_field", IntegerType(dialect)), ColumnDefinition(dialect, "float_field", FloatType(dialect)), @@ -104,54 +108,54 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa ) -def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), 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", 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, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect)), ], ) -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, 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", 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, "age", IntegerType(dialect)), ], ) -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, 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", TextType(dialect)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -165,97 +169,97 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ ) -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, 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, "age", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "email", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], ) -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=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, "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(dialect)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, False))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect)), ], ) -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=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, "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(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, False))]), ], ) -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, 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, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], ) -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, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType(dialect), - 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)), @@ -264,16 +268,16 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota ) -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, 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, "optional_name", TextType(dialect)), ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), ColumnDefinition(dialect, "last_login", TextType(dialect)), @@ -285,93 +289,87 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes ) -def drop_table(dialect, table_name: str) -> DropTableExpression: +def drop_table(dialect, table_name: str) -> DropTableStatement: """通用 DROP TABLE 表达式,供 Provider 调用。""" - return DropTableExpression( + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, ) -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, 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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - 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"])), ], ) -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, 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), + 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"])), ], ) -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, 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(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], ) -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, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "price", FloatType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "price", FloatType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 34a1b0e27..1094659a0 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -1,42 +1,46 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from typing import Callable, Dict # tests/providers/fixtures/events.py """ DDL expressions for the feature/events table group. -Each function returns a CreateTableExpression matching the .sql schema file. +Each function returns a CreateTableStatement matching the .sql schema file. """ -from typing import Callable, Dict -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.objects import Table -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, 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, "status", TextType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft"), + 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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", TextType(dialect)), @@ -45,19 +49,19 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create ) -def create_event_test_models_table(dialect, table_name: str = "event_test_models") -> CreateTableExpression: - return CreateTableExpression( +def create_event_test_models_table(dialect, table_name: str = "event_test_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), 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", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "description", TextType(dialect)), ColumnDefinition(dialect, "status", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))]), ColumnDefinition(dialect, "event_log", TextType(dialect)), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -65,27 +69,27 @@ def create_event_test_models_table(dialect, table_name: str = "event_test_models ) -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, 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", TextType(dialect), - 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.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "last_viewed_at", TextType(dialect)), ], ) -def drop_table(dialect, table_name: str) -> DropTableExpression: - return DropTableExpression( +def drop_table(dialect, table_name: str) -> DropTableStatement: + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index f8a2c8943..e4fde4d7e 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -1,118 +1,122 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType +from typing import Callable, Dict # tests/providers/fixtures/mixins.py """ DDL expressions for the feature/mixins table group. """ -from typing import Callable, Dict -from rhosocial.activerecord.backend.expression.types import FloatType, IntegerType, TextType -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.objects import Table -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=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, "title", TextType(dialect), - 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", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) -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=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), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0), + 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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ], ) -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=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, "title", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_completed", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0)), ]), ColumnDefinition(dialect, "deleted_at", TextType(dialect)), ], ) -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=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, "title", TextType(dialect), - 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", TextType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft"), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft")), ]), ColumnDefinition(dialect, "version", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "created_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "deleted_at", TextType(dialect)), ], ) -def drop_table(dialect, table_name: str) -> DropTableExpression: - return DropTableExpression( +def drop_table(dialect, table_name: str) -> DropTableStatement: + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index 68757b2c8..066289237 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -1,47 +1,51 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.types import DecimalType, FloatType, IntegerType, TextType, TimestampType, VarCharType +from rhosocial.activerecord.backend.expression.statements import ReferentialAction +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + DropTableStatement, + ColumnDefinition, + ColumnConstraint, + ForeignKeyConstraint, + RawSQL, +) +from typing import Callable, Dict +from decimal import Decimal # tests/providers/fixtures/query.py """ DDL expressions for the feature/query table group. """ -from decimal import Decimal -from typing import Callable, Dict -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ForeignKeyConstraint, - RawSQLExpression, -) -from rhosocial.activerecord.backend.expression.statements import ReferentialAction -from rhosocial.activerecord.backend.expression.types import DecimalType, FloatType, IntegerType, TextType, TimestampType, VarCharType -from rhosocial.activerecord.backend.expression.objects import Table -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, 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", 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, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=Decimal("0.00")), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Decimal("0.00"))), ]), ColumnDefinition(dialect, "is_active", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -49,24 +53,24 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress ) -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, 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", TextType(dialect), - 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", TextType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published"), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "published")), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -81,24 +85,24 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ) -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, 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), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_hidden", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0)), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -110,16 +114,16 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE ) -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, 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", TextType(dialect)), ColumnDefinition(dialect, "created_at", TextType(dialect)), @@ -131,27 +135,27 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE ) -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, 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", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=Decimal("0.00")), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Decimal("0.00"))), ]), ColumnDefinition(dialect, "status", TextType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending"), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending")), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -162,29 +166,29 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre ) -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, 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", TextType(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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "subtotal", DecimalType(dialect, precision=10, scale=2), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=Decimal("0.00")), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Decimal("0.00"))), ]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), @@ -195,42 +199,42 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create ) -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, 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", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=Decimal("0.00")), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Decimal("0.00"))), ]), ColumnDefinition(dialect, "status", TextType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending"), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending")), ]), ColumnDefinition(dialect, "priority", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "medium"))]), ColumnDefinition(dialect, "region", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "default"))]), ColumnDefinition(dialect, "category", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "product", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "department", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "year", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "quarter", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -240,29 +244,29 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - ) -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, 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", TextType(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), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "category", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "region", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -274,18 +278,18 @@ def create_extended_order_items_table(dialect, table_name: str = "extended_order ) -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, 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", 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, "age", IntegerType(dialect)), ColumnDefinition(dialect, "settings", TextType(dialect)), ColumnDefinition(dialect, "tags", TextType(dialect)), @@ -300,38 +304,38 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa ) -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, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType(dialect)), ColumnDefinition(dialect, "tags", TextType(dialect)), ], ) -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=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, 100), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "parent_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=RawSQLExpression(dialect, "NULL"))]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, RawSQL(dialect, "NULL")))]), ColumnDefinition(dialect, "value", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=Decimal("0.00"))]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Decimal("0.00")))]), ColumnDefinition(dialect, "created_at", TimestampType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=RawSQLExpression(dialect, "CURRENT_TIMESTAMP"))]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, RawSQL(dialect, "CURRENT_TIMESTAMP")))]), ColumnDefinition(dialect, "updated_at", TimestampType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=RawSQLExpression(dialect, "CURRENT_TIMESTAMP"))]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, RawSQL(dialect, "CURRENT_TIMESTAMP")))]), ], table_constraints=[ ForeignKeyConstraint(dialect, @@ -344,8 +348,8 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress ) -def drop_table(dialect, table_name: str) -> DropTableExpression: - return DropTableExpression( +def drop_table(dialect, table_name: str) -> DropTableStatement: + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/relation.py b/tests/providers/fixtures/relation.py index bc715e64b..a75d23b4b 100644 --- a/tests/providers/fixtures/relation.py +++ b/tests/providers/fixtures/relation.py @@ -1,163 +1,167 @@ -# tests/providers/fixtures/relation.py -"""DDL expressions for the feature/relation table group.""" - -from typing import Callable, Dict - -from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ForeignKeyConstraint, ) -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from typing import Callable, Dict +# tests/providers/fixtures/relation.py +"""DDL expressions for the feature/relation table group.""" + + -def create_employees_table(dialect, table_name: str = "employees") -> CreateTableExpression: - return CreateTableExpression( +def create_employees_table(dialect, table_name: str = "employees") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "username", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "department_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "username", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "department_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["department_id"], foreign_key_table=Table(dialect, "departments"), foreign_key_columns=["id"])], ) -def create_departments_table(dialect, table_name: str = "departments") -> CreateTableExpression: - return CreateTableExpression( +def create_departments_table(dialect, table_name: str = "departments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "description", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "description", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], ) -def create_authors_table(dialect, table_name: str = "authors") -> CreateTableExpression: - return CreateTableExpression( +def create_authors_table(dialect, table_name: str = "authors") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) -def create_books_table(dialect, table_name: str = "books") -> CreateTableExpression: - return CreateTableExpression( +def create_books_table(dialect, table_name: str = "books") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "author_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "author_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["author_id"], foreign_key_table=Table(dialect, "authors"), foreign_key_columns=["id"])], ) -def create_chapters_table(dialect, table_name: str = "chapters") -> CreateTableExpression: - return CreateTableExpression( +def create_chapters_table(dialect, table_name: str = "chapters") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "book_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "book_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["book_id"], foreign_key_table=Table(dialect, "books"), foreign_key_columns=["id"])], ) -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=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "bio", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "author_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "bio", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "author_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["author_id"], foreign_key_table=Table(dialect, "authors"), foreign_key_columns=["id"])], ) -def create_rl_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_rl_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", TextType(dialect)), ColumnDefinition(dialect, "settings", TextType(dialect)), ], ) -def create_rl_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_rl_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "body", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "view_count", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "body", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "view_count", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "metadata", TextType(dialect)), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table=Table(dialect, "users"), foreign_key_columns=["id"])], ) -def create_rl_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_rl_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "body", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "post_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "body", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "post_id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "meta", TextType(dialect)), ], table_constraints=[ForeignKeyConstraint(dialect, columns=["post_id"], foreign_key_table=Table(dialect, "posts"), foreign_key_columns=["id"])], ) -def create_relation_boundary_owners_table(dialect, table_name: str = "relation_boundary_owners") -> CreateTableExpression: - return CreateTableExpression( +def create_relation_boundary_owners_table(dialect, table_name: str = "relation_boundary_owners") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) -def create_relation_boundary_profiles_table(dialect, table_name: str = "relation_boundary_profiles") -> CreateTableExpression: - return CreateTableExpression( +def create_relation_boundary_profiles_table(dialect, table_name: str = "relation_boundary_profiles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "bio", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "bio", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "owner_id", IntegerType(dialect)), ], ) -def create_relation_boundary_posts_table(dialect, table_name: str = "relation_boundary_posts") -> CreateTableExpression: - return CreateTableExpression( +def create_relation_boundary_posts_table(dialect, table_name: str = "relation_boundary_posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), + ColumnDefinition(dialect, "title", TextType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "owner_id", IntegerType(dialect)), ], ) -def drop_table(dialect, table_name: str) -> DropTableExpression: - return DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True) +def drop_table(dialect, table_name: str) -> DropTableStatement: + return DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True) TABLE_EXPRESSIONS: Dict[str, Callable] = { diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 9f64be34a..bc1c5e8fd 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -17,7 +17,7 @@ import logging from typing import Type, List -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.model import ActiveRecord # Setup logging for fixture selection debugging @@ -187,7 +187,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -317,7 +317,7 @@ async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_nam from rhosocial.activerecord.backend.schema import StatementType try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, diff --git a/tests/providers/query.py b/tests/providers/query.py index e7a76bc0a..923d842b8 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -22,7 +22,7 @@ import logging from typing import Type, List, Tuple -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord # Setup logging for fixture selection debugging @@ -273,7 +273,7 @@ def delayed_save(self, *args, **kwargs): from rhosocial.activerecord.backend.schema import StatementType options = ExecutionOptions(stmt_type=StatementType.DDL) - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -391,7 +391,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.schema import StatementType options = ExecutionOptions(stmt_type=StatementType.DDL) try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=OrderItemBase.__backend__.dialect, table=Table(OrderItemBase.__backend__.dialect, 'order_items'), if_exists=True, @@ -571,7 +571,7 @@ async def delayed_save(self, *args, **kwargs): from rhosocial.activerecord.backend.schema import StatementType options = ExecutionOptions(stmt_type=StatementType.DDL) - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=model_class.__backend__.dialect, table=Table(model_class.__backend__.dialect, table_name), if_exists=True, @@ -745,7 +745,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe from rhosocial.activerecord.backend.schema import StatementType options = ExecutionOptions(stmt_type=StatementType.DDL) try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=AsyncOrderItemBase.__backend__.dialect, table=Table(AsyncOrderItemBase.__backend__.dialect, 'order_items'), if_exists=True, diff --git a/tests/rhosocial/activerecord_test/feature/backend/base/test_transaction_ownership.py b/tests/rhosocial/activerecord_test/feature/backend/base/test_transaction_ownership.py new file mode 100644 index 000000000..7488a829a --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/base/test_transaction_ownership.py @@ -0,0 +1,145 @@ +# tests/rhosocial/activerecord_test/feature/backend/base/test_transaction_ownership.py +"""A connection's transaction belongs to the caller that opened it. + +Two callers driving one connection's transaction interleave ``BEGIN``/``COMMIT`` +and drive the level counter to zero in the middle of someone else's work. The +symptom appears much later and somewhere else: + + TransactionError: No active transaction to commit + +That message names nothing useful, so the check belongs where the mistake is +made -- the second ``begin()`` -- with an error that says what to do instead. + +The check was missing from ``begin()`` entirely. Only the ``transaction()`` +context manager warned, so code that used ``pool.transaction()`` (which calls +``begin_transaction()`` directly) got no warning at all. +""" + +from __future__ import annotations + +import asyncio +import threading +from unittest.mock import MagicMock + +import pytest + +from rhosocial.activerecord.backend.errors import TransactionError +from rhosocial.activerecord.backend.transaction import ( + AsyncTransactionManager, + TransactionManager, +) + + +class _SyncManager(TransactionManager): + def _do_begin(self) -> None: ... + def _do_commit(self) -> None: ... + def _do_rollback(self) -> None: ... + def _do_create_savepoint(self, name: str) -> None: ... + def _do_release_savepoint(self, name: str) -> None: ... + def _do_rollback_savepoint(self, name: str) -> None: ... + def supports_savepoint(self) -> bool: + return True + + +class _AsyncManager(AsyncTransactionManager): + async def _do_begin(self) -> None: ... + async def _do_commit(self) -> None: ... + async def _do_rollback(self) -> None: ... + async def _do_create_savepoint(self, name: str) -> None: ... + async def _do_release_savepoint(self, name: str) -> None: ... + async def _do_rollback_savepoint(self, name: str) -> None: ... + async def supports_savepoint(self) -> bool: + return True + + +class TestAsyncOwnership: + def test_a_second_task_cannot_begin_the_same_transaction(self) -> None: + manager = _AsyncManager(MagicMock()) + + async def scenario() -> None: + await manager.begin() + try: + with pytest.raises(TransactionError, match="owned by another task"): + await asyncio.create_task(manager.begin()) + # The owner's transaction is untouched by the refusal. + assert manager.transaction_level == 1 + await manager.commit() + finally: + if manager.is_active: + await manager.rollback() + + asyncio.run(scenario()) + + def test_the_owner_may_nest(self) -> None: + manager = _AsyncManager(MagicMock()) + + async def scenario() -> None: + await manager.begin() + await manager.begin() + assert manager.transaction_level == 2 + await manager.commit() + await manager.commit() + assert manager.transaction_level == 0 + + asyncio.run(scenario()) + + def test_another_task_may_use_the_connection_after_the_commit(self) -> None: + manager = _AsyncManager(MagicMock()) + + async def scenario() -> None: + await manager.begin() + await manager.commit() + + async def later() -> None: + await manager.begin() + await manager.commit() + + await asyncio.create_task(later()) + + asyncio.run(scenario()) + + +class TestSyncOwnership: + def test_another_thread_cannot_begin_the_same_transaction(self) -> None: + manager = _SyncManager(MagicMock()) + captured: list[BaseException] = [] + + manager.begin() + try: + + def other_thread() -> None: + try: + manager.begin() + except BaseException as exc: # noqa: BLE001 - the point of the test + captured.append(exc) + + thread = threading.Thread(target=other_thread) + thread.start() + thread.join(timeout=10) + + assert captured, "the second thread must be refused" + assert isinstance(captured[0], TransactionError) + assert "owned by another thread" in str(captured[0]) + assert manager.transaction_level == 1 + finally: + manager.commit() + + def test_another_thread_may_use_the_connection_after_the_commit(self) -> None: + manager = _SyncManager(MagicMock()) + captured: list[BaseException] = [] + + manager.begin() + manager.commit() + + def other_thread() -> None: + try: + manager.begin() + manager.commit() + except BaseException as exc: # noqa: BLE001 - the point of the test + captured.append(exc) + + thread = threading.Thread(target=other_thread) + thread.start() + thread.join(timeout=10) + + assert not captured, f"the connection was free, yet: {captured!r}" diff --git a/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/migrations.py b/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/migrations.py index cb2f81109..181043f55 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/migrations.py +++ b/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/migrations.py @@ -1,3 +1,23 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.migration import ( + NamedMigration, + MigrationContext, + AsyncNamedMigration, + AsyncMigrationContext, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( + SQLiteIntegerType, + SQLiteTextType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, + DropTableStatement, +) # tests/rhosocial/activerecord_test/feature/backend/cli/named_series/migrations.py """Named migration fixtures (named-migration layer). @@ -6,24 +26,6 @@ tables that the queries/procedures/graphs depend on. """ -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - DropTableExpression, -) -from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( - SQLiteIntegerType, - SQLiteTextType, -) -from rhosocial.activerecord.backend.migration import ( - NamedMigration, - MigrationContext, - AsyncNamedMigration, - AsyncMigrationContext, -) -from rhosocial.activerecord.backend.expression.objects import Table # --- DDL named expressions ------------------------------------------------ @@ -31,13 +33,13 @@ def create_users_table(dialect): """CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, "email", SQLiteTextType(dialect=dialect)), @@ -47,18 +49,18 @@ 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 INTEGER PRIMARY KEY, title TEXT, user_id INTEGER).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'posts'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "title", SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, "user_id", SQLiteIntegerType(dialect)), @@ -68,7 +70,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) # --- NamedMigration classes ------------------------------------------------- diff --git a/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/queries.py b/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/queries.py index 2ab7bf328..9ac261ead 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/queries.py +++ b/tests/rhosocial/activerecord_test/feature/backend/cli/named_series/queries.py @@ -10,8 +10,9 @@ Column, Literal, FunctionCall, - QueryExpression, - InsertExpression, + NumericColumn, + QueryStatement, + InsertStatement, ValuesSource, OrderByClause, ) @@ -21,7 +22,7 @@ def list_users(dialect): """List all users (id, name, email).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -31,17 +32,17 @@ def list_users(dialect): def user_by_id(dialect, user_id: int): """Fetch a single user by id.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=NamedRelationRef(dialect, Table(dialect, "users")), - where=Column(dialect, "id") == Literal(dialect, user_id), + where=NumericColumn(dialect, "id") == Literal(dialect, user_id), ) def list_posts(dialect): """List all posts (id, title, user_id).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "title"), Column(dialect, "user_id")], from_=NamedRelationRef(dialect, Table(dialect, "posts")), @@ -51,17 +52,17 @@ def list_posts(dialect): def posts_by_user(dialect, user_id: int): """List posts for a specific user.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "title")], from_=NamedRelationRef(dialect, Table(dialect, "posts")), - where=Column(dialect, "user_id") == Literal(dialect, user_id), + where=NumericColumn(dialect, "user_id") == Literal(dialect, user_id), ) def count_posts(dialect): """Count posts, aliased as total.""" - return QueryExpression( + return QueryStatement( dialect, select=[FunctionCall(dialect, "COUNT", Column(dialect, "id")).as_("total")], from_=NamedRelationRef(dialect, Table(dialect, "posts")), @@ -70,7 +71,7 @@ def count_posts(dialect): def insert_user(dialect, name: str, email: str): """Insert a user row; returns id/name for verification.""" - return InsertExpression( + return InsertStatement( dialect, into=Table(dialect, 'users'), columns=["name", "email"], @@ -80,7 +81,7 @@ def insert_user(dialect, name: str, email: str): def insert_post(dialect, title: str, user_id: int): """Insert a post row; returns id/title for verification.""" - return InsertExpression( + return InsertStatement( dialect, into=Table(dialect, 'posts'), columns=["title", "user_id"], diff --git a/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py b/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py index 700f59a01..73849206f 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py +++ b/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py @@ -56,6 +56,23 @@ def run_cli(argv): class TestCommandSurface: """C1: command surface.""" + def test_console_script_is_declared(self): + """The documented command is really installed, not only documented. + + `docs/{en_US,zh_CN}/backend/sqlite/cli/README.md` promises + `rhosocial-activerecord-sqlite`; without this entry point the promise + only held for `python -m ...`. + """ + from pathlib import Path + + pyproject = Path(__file__).resolve().parents[6] / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + assert "[project.scripts]" in text + assert ( + 'rhosocial-activerecord-sqlite = "rhosocial.activerecord.backend.impl.sqlite.__main__:main"' + in text + ) + def test_help_lists_all_commands(self): out, _, _ = run_cli(["--help"]) for cmd in COMMANDS: diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_clauses.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_clauses.py new file mode 100644 index 000000000..aa46822f7 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_clauses.py @@ -0,0 +1,237 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_clauses.py +"""Column-definition clauses are expression nodes, not AR-layer objects. + +``COLLATE`` and ``CHARACTER SET`` used to be reachable only by putting an +AR-layer ``ColumnAttribute`` into ``ColumnDefinition.attributes``, which put a +reverse dependency in the dialect: the formatter's subject was not an +expression. These tests pin the new shape (an expression node is the subject), +the capability gates, and the transitional AR route that still has to work +until a deriver exists. +""" +import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, +) +from rhosocial.activerecord.base.ddl import ( + CharacterSetAttribute, + CollationAttribute, + IdentityAttribute, +) +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.statements import ( + CharacterSetClause, + CollationClause, + ColumnConstraint, + ColumnDefinition, + IdentityClause, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def probed(**answers): + """Return a dialect class whose capability probes answer as ``answers``.""" + namespace = { + name: (lambda self, answer=answer: answer) + for name, answer in answers.items() + } + return type("ProbedDialect", (SQLiteDialect,), namespace) + + +def column_with(dialect, *clauses): + return ColumnDefinition(dialect, "c", TextType(dialect), attributes=list(clauses)) + + +class TestClauseNodes: + """The nodes exist, name their formatter, and validate their one field.""" + + def test_each_names_its_formatter(self): + dialect = SQLiteDialect() + + assert CollationClause(dialect, "NOCASE").format_method == "format_collation_clause" + assert ( + CharacterSetClause(dialect, "utf8mb4").format_method + == "format_character_set_clause" + ) + + @pytest.mark.parametrize( + "factory", (CollationClause, CharacterSetClause) + ) + @pytest.mark.parametrize("bad", ("", " ", None, 7)) + def test_a_name_is_required(self, factory, bad): + dialect = SQLiteDialect() + + with pytest.raises((ValueError, TypeError)): + factory(dialect, bad) + + def test_a_clause_with_no_formatter_says_so(self): + """The base class refuses to pretend a formatter exists.""" + from rhosocial.activerecord.backend.expression.statements import ColumnClause + + dialect = SQLiteDialect() + clause = ColumnClause(dialect) + + with pytest.raises(NotImplementedError, match="ColumnClause"): + _ = clause.format_method + + +class TestCollationClause: + """COLLATE renders as a clause, and only where the dialect has one.""" + + def test_renders(self): + dialect = SQLiteDialect() + + assert dialect.format_column_attribute( + CollationClause(dialect, "NOCASE") + ) == (" COLLATE NOCASE", ()) + + def test_inside_a_column_definition(self): + dialect = SQLiteDialect() + + sql, params = column_with(dialect, CollationClause(dialect, "NOCASE")).to_sql() + + assert params == () + assert sql.endswith("COLLATE NOCASE") + + def test_refused_where_unsupported(self): + dialect = probed(supports_column_collation=False)() + + with pytest.raises(UnsupportedFeatureError, match="COLLATE"): + dialect.format_column_attribute(CollationClause(dialect, "NOCASE")) + + def test_a_dialect_may_quote_the_specification_instead(self): + """Snowflake and BigQuery spell COLLATE as a quoted string literal.""" + dialect = probed()() + + def quoted(clause): + return f" COLLATE '{clause.name}'", () + + dialect.format_collation_clause = quoted + + assert dialect.format_column_attribute( + CollationClause(dialect, "und:ci") + ) == (" COLLATE 'und:ci'", ()) + + +class TestCharacterSetClause: + """CHARACTER SET renders as a clause, gated because it is a MySQL-family + construct rather than part of the standard column grammar.""" + + def test_renders_where_supported(self): + dialect = probed(supports_column_character_set=True)() + + assert dialect.format_column_attribute( + CharacterSetClause(dialect, "utf8mb4") + ) == (" CHARACTER SET utf8mb4", ()) + + def test_refused_where_unsupported(self): + dialect = SQLiteDialect() # declares character sets unsupported + + with pytest.raises(UnsupportedFeatureError, match="CHARACTER SET"): + dialect.format_column_attribute(CharacterSetClause(dialect, "utf8mb4")) + + +class TestArLayerRouteStillWorks: + """The AR layer has no deriver yet, so its attributes still have to render. + + These are the tests that say when the transitional route may be deleted: + once a deriver turns ``UseColumnAttributes`` into clauses, this whole class + goes with it. + """ + + def test_collation_attribute_still_renders(self): + dialect = SQLiteDialect() + + sql, _ = column_with(dialect, CollationAttribute(name="NOCASE")).to_sql() + + assert sql.endswith("COLLATE NOCASE") + + def test_character_set_attribute_still_renders_where_supported(self): + dialect = probed(supports_column_character_set=True)() + + sql, _ = column_with(dialect, CharacterSetAttribute(name="utf8")).to_sql() + + assert sql.endswith("CHARACTER SET utf8") + + def test_identity_attribute_still_renders(self): + dialect = probed( + supports_identity_column=True, + supports_identity_generation_always=True, + supports_identity_start=True, + supports_identity_increment=True, + )() + + sql, _ = column_with( + dialect, IdentityAttribute(generation="ALWAYS", start=1, increment=2) + ).to_sql() + + assert "GENERATED ALWAYS AS IDENTITY" in sql + + def test_an_unknown_clause_is_refused_by_name(self): + dialect = SQLiteDialect() + + with pytest.raises(UnsupportedFeatureError, match="object"): + dialect.format_column_attribute(object()) + + +class TestClauseOrderIsTheCallersOrder: + """The sequence is spelled in the order it was built.""" + + def test_two_clauses_keep_their_order(self): + dialect = probed( + supports_column_collation=True, supports_column_character_set=True + )() + clauses = [ + CollationClause(dialect, "NOCASE"), + CharacterSetClause(dialect, "utf8mb4"), + ] + + sql, _ = column_with(dialect, *clauses).to_sql() + + assert sql.index("COLLATE") < sql.index("CHARACTER SET") + + def test_reversing_the_sequence_reverses_the_sql(self): + dialect = probed( + supports_column_collation=True, supports_column_character_set=True + )() + + forward, _ = column_with( + dialect, + CollationClause(dialect, "NOCASE"), + CharacterSetClause(dialect, "utf8mb4"), + ).to_sql() + backward, _ = column_with( + dialect, + CharacterSetClause(dialect, "utf8mb4"), + CollationClause(dialect, "NOCASE"), + ).to_sql() + + assert forward != backward + + +class TestUnrelatedClausesUnaffected: + """A column definition's other channels still render as before.""" + + def test_constraints_and_clauses_coexist(self): + dialect = probed(supports_column_collation=True)() + definition = ColumnDefinition( + dialect, + "c", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + attributes=[CollationClause(dialect, "NOCASE")], + ) + + sql, params = definition.to_sql() + + assert params == () + assert "NOT NULL" in sql and "COLLATE NOCASE" in sql + + def test_identity_clause_is_still_dispatched_by_format_method(self): + dialect = probed(supports_identity_column=True)() + clause = IdentityClause(dialect, "BY DEFAULT") + + assert dialect.format_column_attribute(clause) == ( + dialect.format_identity_clause(clause) + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_constraint_gates.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_constraint_gates.py new file mode 100644 index 000000000..a5e7db656 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_constraint_gates.py @@ -0,0 +1,360 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_column_constraint_gates.py +"""Capability gates on column-constraint rendering. + +A column constraint clause is rendered only when the dialect declares it can +express it; otherwise the spelling is refused by name instead of being dropped. +These tests pin the gates that were declared but never consulted, plus the two +paths that render the same constraint -- ``ColumnDefinition`` and +``ColumnConstraint.to_sql()`` -- which must agree. +""" +import pytest +from rhosocial.activerecord.backend.expression import NumericColumn +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.base.ddl import CharacterSetAttribute, CollationAttribute +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefinition, + ReferentialAction, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def probed(**answers): + """Return a dialect class whose capability probes answer as ``answers``.""" + namespace = { + name: (lambda self, answer=answer: answer) + for name, answer in answers.items() + } + return type("ProbedDialect", (SQLiteDialect,), namespace) + + +def check_constraint(dialect): + condition = NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + return ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, condition)) + + +def fk_constraint(dialect, **kwargs): + # The referential actions belong to the branch; the deferral traits are + # the production's, which is where they went when the two levels split. + branch_traits = ("on_delete", "on_update", "match_type") + clause_traits = ("deferrable", "initially_deferred") + branch = ColumnForeignKeyConstraint( + dialect, + (Table(dialect, "parent"), ["id"]), + **{k: v for k, v in kwargs.items() if k in branch_traits}, + ) + return ColumnConstraint( + dialect, + branch=branch, + **{k: v for k, v in kwargs.items() if k in clause_traits}, + ) + + +class TestSimpleConstraintGates: + """PRIMARY KEY / NOT NULL / NULL / UNIQUE consult their own probes.""" + + def test_supported_spellings_render(self): + dialect = SQLiteDialect() + for branch_cls, expected in ( + (ColumnPrimaryKeyConstraint, " PRIMARY KEY"), + (ColumnNotNullConstraint, " NOT NULL"), + (ColumnNullableConstraint, " NULL"), + (ColumnUniqueConstraint, " UNIQUE"), + ): + constraint = ColumnConstraint(dialect, branch=branch_cls(dialect)) + assert constraint.to_sql() == (expected, ()) + + @pytest.mark.parametrize( + "probe, branch_cls, spelling", + ( + ("supports_primary_key_constraint", ColumnPrimaryKeyConstraint, "PRIMARY KEY"), + ("supports_not_null_constraint", ColumnNotNullConstraint, "NOT NULL"), + ("supports_unique_constraint", ColumnUniqueConstraint, "UNIQUE"), + ), + ) + def test_unsupported_spelling_is_refused_by_name(self, probe, branch_cls, spelling): + dialect = probed(**{probe: False})() + constraint = ColumnConstraint(dialect, branch=branch_cls(dialect)) + + with pytest.raises(UnsupportedFeatureError) as excinfo: + constraint.to_sql() + + assert spelling in str(excinfo.value) + + def test_null_has_no_gate_to_close(self): + """NULL stays renderable: an explicitly nullable column is universal.""" + dialect = probed( + supports_primary_key_constraint=False, + supports_not_null_constraint=False, + supports_unique_constraint=False, + )() + constraint = ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect)) + + assert constraint.to_sql() == (" NULL", ()) + + +class TestForeignKeyGate: + """A column-level REFERENCES clause consults the foreign key probe.""" + + def test_unsupported_reference_is_refused_by_name(self): + dialect = probed(supports_foreign_key_constraint=False)() + + with pytest.raises(UnsupportedFeatureError, match="REFERENCES"): + fk_constraint(dialect).to_sql() + + def test_reference_renders_where_supported(self): + dialect = SQLiteDialect() + + assert fk_constraint(dialect).to_sql() == ( + ' REFERENCES "parent"("id")', + (), + ) + + +class TestCheckConstraintGate: + """CHECK consults its probe and never carries bind parameters into DDL.""" + + def test_predicate_is_inlined(self): + dialect = SQLiteDialect() + + sql, params = check_constraint(dialect).to_sql() + + assert params == () + # The literal is spelled out rather than bound, and the operands stay + # in the order the caller wrote them. They used to come out reversed as + # ``0 < "age"``, and this test recorded that as "the dialect normalises + # a > b to b < a" -- it does not. Bare ``Column`` carries no comparison + # operator, so ``Column("age") > Literal(0)`` fell through to Python's + # reflected-operator fallback and built the predicate from the other + # end. Naming the column's type is what puts the order back. + assert sql == ' CHECK ("age" > 0)' + + def test_unsupported_check_is_refused_by_name(self): + dialect = probed(supports_check_constraint=False)() + + with pytest.raises(UnsupportedFeatureError, match="CHECK"): + check_constraint(dialect).to_sql() + + def test_missing_condition_is_a_declaration_error(self): + """Refused at construction: the branch that states the condition is how + a CHECK is expressed, so a CHECK without one cannot be written.""" + + with pytest.raises(ValueError, match="needs a condition"): + ColumnCheckConstraint(SQLiteDialect(), None) + + +class TestDeferralGates: + """DEFERRABLE and INITIALLY are gated, not rendered unconditionally.""" + + def test_deferrable_is_refused_where_unsupported(self): + dialect = probed(supports_deferrable_constraint=False)() + + with pytest.raises(UnsupportedFeatureError, match="DEFERRABLE"): + fk_constraint(dialect, deferrable=True).to_sql() + + def test_initially_deferred_is_refused_where_unsupported(self): + dialect = probed(supports_deferrable_constraint=False)() + + with pytest.raises(UnsupportedFeatureError, match="DEFERRABLE"): + fk_constraint(dialect, initially_deferred=True).to_sql() + + def test_deferrable_renders_where_supported(self): + dialect = probed(supports_deferrable_constraint=True)() + + assert fk_constraint( + dialect, deferrable=True, initially_deferred=True + ).to_sql() == ( + ' REFERENCES "parent"("id") DEFERRABLE INITIALLY DEFERRED', + (), + ) + + +class TestColumnAttributeGates: + """COLLATE and CHARACTER SET attributes consult their probes.""" + + def test_collation_is_refused_where_unsupported(self): + dialect = probed(supports_column_collation=False)() + + with pytest.raises(UnsupportedFeatureError, match="COLLATE"): + dialect.format_column_attribute(CollationAttribute(name="NOCASE")) + + def test_collation_renders_where_supported(self): + dialect = SQLiteDialect() + + assert dialect.format_column_attribute( + CollationAttribute(name="NOCASE") + ) == (" COLLATE NOCASE", ()) + + def test_character_set_is_refused_where_unsupported(self): + dialect = SQLiteDialect() # declares character sets unsupported + + with pytest.raises(UnsupportedFeatureError, match="CHARACTER SET"): + dialect.format_column_attribute(CharacterSetAttribute(name="utf8")) + + def test_character_set_renders_where_supported(self): + dialect = probed(supports_column_character_set=True)() + + assert dialect.format_column_attribute( + CharacterSetAttribute(name="utf8") + ) == (" CHARACTER SET utf8", ()) + + +class TestBothRenderPathsAgree: + """``ColumnDefinition`` and ``ColumnConstraint.to_sql()`` must agree. + + A constraint rendered one way inside ``CREATE TABLE`` and another way on its + own is how a column silently lost a ``CHECK`` or a foreign key in the first + place, so the two paths are compared directly. + """ + + @pytest.mark.parametrize( + "build", + ( + lambda d: ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d)), + lambda d: ColumnConstraint(d, branch=ColumnNotNullConstraint(d)), + lambda d: ColumnConstraint(d, branch=ColumnNullableConstraint(d)), + lambda d: ColumnConstraint(d, branch=ColumnUniqueConstraint(d)), + lambda d: ColumnConstraint(d, branch=ColumnDefaultConstraint(d, 0)), + check_constraint, + lambda d: fk_constraint( + d, + on_delete=ReferentialAction.CASCADE, + on_update=ReferentialAction.SET_NULL, + ), + ), + ) + def test_column_definition_ends_with_the_constraint_sql(self, build): + dialect = SQLiteDialect() + constraint = build(dialect) + + definition_sql, _ = ColumnDefinition( + dialect, "c", IntegerType(dialect), constraints=[constraint] + ).to_sql() + constraint_sql, _ = constraint.to_sql() + + assert definition_sql == f'"c" INTEGER{constraint_sql}' + + def test_check_inside_a_definition_carries_no_bind_parameters(self): + dialect = SQLiteDialect() + constraint = check_constraint(dialect) + + sql, params = ColumnDefinition( + dialect, "age", IntegerType(dialect), constraints=[constraint] + ).to_sql() + + assert params == () + # Compared against the constraint's own SQL rather than a literal, so + # the assertion is about the two paths agreeing, not about how a + # comparison happens to be spelled. + assert sql == f'"age" INTEGER{constraint.to_sql()[0]}' + + +class TestConstraintNamingGates: + """The ``CONSTRAINT `` prefix is gated, not silently dropped.""" + + def test_name_renders_on_the_column_path(self): + dialect = SQLiteDialect() + + sql, _ = ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), name="uq_e").to_sql() + + assert sql == ' CONSTRAINT "uq_e" UNIQUE' + + def test_name_is_refused_where_unsupported(self): + dialect = probed(supports_constraint_naming=False)() + + with pytest.raises(UnsupportedFeatureError, match="CONSTRAINT name"): + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), name="uq_e").to_sql() + + def test_table_path_gates_the_name_too(self): + dialect = probed(supports_constraint_naming=False)() + constraint = TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["e"]), name="uq_e") + + with pytest.raises(UnsupportedFeatureError, match="CONSTRAINT name"): + constraint.to_sql() + + def test_naming_defaults_true_on_the_shared_mixin(self): + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + ConstraintMixin, + ) + + class Bare(ConstraintMixin): + pass + + assert Bare().supports_constraint_naming() is True + + +class TestEnforcementGates: + """``[NOT] ENFORCED`` renders on the ENFORCABLE kinds and refuses the rest. + + The SQL:2016 kinds are PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK; a + dialect narrows the set in its own formatter and may not widen it. + """ + + def test_enforced_renders_on_unique(self): + dialect = probed(supports_constraint_enforced=True)() + + sql, _ = ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), enforced=True).to_sql() + + assert sql == " UNIQUE ENFORCED" + + def test_not_enforced_renders_on_primary_key(self): + dialect = probed(supports_constraint_enforced=True)() + + sql, _ = ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect), not_enforced=True).to_sql() + + assert sql == " PRIMARY KEY NOT ENFORCED" + + def test_enforced_is_refused_where_unsupported(self): + dialect = probed(supports_constraint_enforced=False)() + + with pytest.raises(UnsupportedFeatureError, match="ENFORCED"): + ColumnConstraint( + dialect, + branch=ColumnCheckConstraint( + dialect, NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + ), + enforced=True, + ).to_sql() + + def test_not_null_cannot_carry_the_trait(self): + dialect = probed(supports_constraint_enforced=True)() + + with pytest.raises(ValueError, match="cannot ride a ColumnNotNullConstraint"): + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect), enforced=True) + + def test_default_cannot_carry_the_trait(self): + dialect = probed(supports_constraint_enforced=True)() + + with pytest.raises(ValueError, match="cannot ride a ColumnDefaultConstraint"): + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0), enforced=True) + + def test_both_spellings_at_once_are_refused_by_the_formatter(self): + """The constructor guard does not cover attribute injection. + + A pair injected after construction bypasses ``__init__``; the + formatter re-checks the exclusion as the last line of defence. + """ + dialect = probed(supports_constraint_enforced=True)() + condition = NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + constraint = ColumnConstraint( + dialect, branch=ColumnCheckConstraint(dialect, condition), enforced=True + ) + constraint.not_enforced = True + + with pytest.raises(ValueError, match="mutually exclusive"): + constraint.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_ddl_bind_parameters.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_ddl_bind_parameters.py new file mode 100644 index 000000000..73cfa0d62 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_ddl_bind_parameters.py @@ -0,0 +1,123 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_ddl_bind_parameters.py +"""A DDL statement's body carries no bind parameters unless the dialect says so. + +Three statements have a *query* as their body -- ``CREATE TABLE ... AS``, +``CREATE VIEW`` and ``CREATE MATERIALIZED VIEW`` -- and a query may carry +bind parameters. Whether the engine accepts them is the dialect's question, +and the answer differs by engine and by statement: + +* the body of a CTAS is executed once, so a bind is meaningful where the + engine takes one; +* the body of a view or a materialized view is *stored and run again later*, + where a bind has no meaning -- PostgreSQL refuses both in its own words; +* Oracle's DDL statements never take bind variables at all, and the driver + rejects the statement before the server sees it; +* MySQL's, MariaDB's and ClickHouse's "parameters" are interpolated + client-side, so the value is baked into the stored text. + +So the question is asked per dialect, and the answer is a probe: +``supports_create_table_as_bind_parameters``, +``supports_create_view_bind_parameters`` and +``supports_materialized_view_bind_parameters``. The default is ``False`` -- +a DDL statement carries no bind parameters unless the dialect says it can, +which is the same rule the DEFAULT and ALTER COLUMN paths already enforce. + +The rule these cases pin is that the probe is **load-bearing**: flip it and +the outcome flips with it. A probe that is only declared, and never +consulted, would let every dialect emit a statement its engine refuses. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Column, + CreateMaterializedViewStatement, + CreateTableAsStatement, + CreateViewStatement, + Literal, + QueryStatement, +) +from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table, View +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + +#: The three statements whose body is a query, and the probe that answers +#: for each. The builders are called with a dialect and a query. +STATEMENTS = { + "create_table_as": ( + "supports_create_table_as_bind_parameters", + lambda d, q: CreateTableAsStatement(d, Table(d, "t"), q), + ), + "create_view": ( + "supports_create_view_bind_parameters", + lambda d, q: CreateViewStatement(d, View(d, "v"), q), + ), + "create_materialized_view": ( + "supports_materialized_view_bind_parameters", + lambda d, q: CreateMaterializedViewStatement(d, MaterializedView(d, "mv"), q), + ), +} + + +def _dialect(): + return DummyDialect() + + +def _query(dialect, inline): + return QueryStatement( + dialect=dialect, + select=[Literal(dialect, 1, inline=inline)], + from_=Table(dialect, "src"), + ) + + +def _flipped(probe_name): + """A dialect with exactly one probe flipped.""" + stock = getattr(_dialect(), probe_name)() + return type( + f"Flipped_{probe_name}", + (DummyDialect,), + {probe_name: lambda self: not stock}, + )() + + +@pytest.mark.parametrize("label", sorted(STATEMENTS)) +class TestTheProbeIsLoadBearing: + """Flipping the probe must change the statement's fate.""" + + def test_the_stock_dialect_carries_the_bind(self, label): + probe_name, build = STATEMENTS[label] + dialect = _dialect() + assert getattr(dialect, probe_name)() is True + sql, params = build(dialect, _query(dialect, inline=False)).to_sql() + assert params == (1,) + assert "?" in sql or "%s" in sql, sql + + def test_a_flipped_probe_refuses_by_name(self, label): + probe_name, build = STATEMENTS[label] + dialect = _flipped(probe_name) + with pytest.raises(UnsupportedFeatureError) as excinfo: + build(dialect, _query(dialect, inline=False)).to_sql() + message = str(excinfo.value) + assert "bound parameters" in message, message + assert "inlined literal" in message, message + + def test_an_inlined_body_renders_under_either_answer(self, label): + """The way out is the same under both answers: inline the value.""" + probe_name, build = STATEMENTS[label] + for dialect in (_dialect(), _flipped(probe_name)): + sql, params = build(dialect, _query(dialect, inline=True)).to_sql() + assert params == () + assert "1" in sql, sql + + def test_a_query_without_a_literal_is_never_refused(self, label): + """The check is about bind parameters, not about queries.""" + _probe_name, build = STATEMENTS[label] + dialect = _flipped(STATEMENTS[label][0]) + query = QueryStatement( + dialect=dialect, + select=[Column(dialect, "id")], + from_=Table(dialect, "src"), + ) + sql, params = build(dialect, query).to_sql() + assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_foreign_key_match.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_foreign_key_match.py new file mode 100644 index 000000000..f160d2bca --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_foreign_key_match.py @@ -0,0 +1,166 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_foreign_key_match.py +"""MATCH is a typed field on every constraint that can carry it. + +It used to be three different shapes: a bare ``str`` on ``ReferencesClause``, +a bare ``str`` reachable only through the ``ForeignKeyConstraint`` subclass, +and nothing at all on ``ColumnConstraint``. So a column-level +``REFERENCES ... MATCH FULL`` could not be written, and the value was +re-validated by hand inside the formatter on every render. +""" +import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ForeignKeyConstraint, + ForeignKeyMatchType, + ReferentialAction, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + normalize_foreign_key_match_type, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def probed(**answers): + namespace = { + name: (lambda self, answer=answer: answer) + for name, answer in answers.items() + } + return type("ProbedDialect", (SQLiteDialect,), namespace) + + +def column_fk(dialect, **kwargs): + return ColumnConstraint( + dialect, + branch=ColumnForeignKeyConstraint( + dialect, (Table(dialect, "parent"), ["id"]), **kwargs + ), + ) + + +class TestNormalizer: + """The normalizer accepts a member, its value, or its name, in any case.""" + + @pytest.mark.parametrize( + "given, expected", + ( + (ForeignKeyMatchType.FULL, ForeignKeyMatchType.FULL), + ("FULL", ForeignKeyMatchType.FULL), + ("full", ForeignKeyMatchType.FULL), + (" Partial ", ForeignKeyMatchType.PARTIAL), + ("SIMPLE", ForeignKeyMatchType.SIMPLE), + ), + ) + def test_accepted_spellings(self, given, expected): + assert normalize_foreign_key_match_type(given) is expected + + def test_none_means_not_specified(self): + """None is not SIMPLE: a dialect may treat the default differently.""" + assert normalize_foreign_key_match_type(None) is None + + @pytest.mark.parametrize("bad", ("MATCH", "FULL PARTIAL", "", 3)) + def test_a_bad_value_is_refused_by_name(self, bad): + with pytest.raises(ValueError, match="MATCH type"): + normalize_foreign_key_match_type(bad) + + +class TestEnumRendersItsSqlSpelling: + """A dialect may interpolate the member directly.""" + + def test_str_is_the_sql_word(self): + assert str(ForeignKeyMatchType.PARTIAL) == "PARTIAL" + assert f"{ForeignKeyMatchType.FULL}" == "FULL" + + +class TestColumnLevelMatch: + """The point of the change: a column-level MATCH is expressible.""" + + def test_renders(self): + dialect = probed(supports_fk_match=True)() + + assert column_fk(dialect, match_type="FULL").to_sql() == ( + ' REFERENCES "parent"("id") MATCH FULL', + (), + ) + + def test_accepts_the_member_too(self): + dialect = probed(supports_fk_match=True)() + constraint = column_fk(dialect, match_type=ForeignKeyMatchType.PARTIAL) + + assert "MATCH PARTIAL" in constraint.to_sql()[0] + + def test_refused_where_unsupported(self): + dialect = probed(supports_fk_match=False)() + + with pytest.raises(UnsupportedFeatureError, match="MATCH"): + column_fk(dialect, match_type="FULL").to_sql() + + def test_no_match_type_renders_nothing_extra(self): + dialect = probed(supports_fk_match=True)() + + assert "MATCH" not in column_fk(dialect).to_sql()[0] + + def test_a_bad_value_is_refused(self): + dialect = probed(supports_fk_match=True)() + + with pytest.raises(ValueError, match="MATCH type"): + column_fk(dialect, match_type="HALF").to_sql() + + +class TestTableLevelMatchNeedsNoSubclass: + """The fields are on the base, so no formatter has to ask which class it got.""" + + def test_a_plain_table_constraint_carries_the_actions(self): + dialect = probed(supports_fk_match=True)() + constraint = TableConstraint(dialect, branch=TableForeignKeyConstraint(dialect, ["a"], (Table(dialect, "parent"), ["id"]), match_type="FULL")) + + assert constraint.branch.match_type == "FULL" + assert "MATCH FULL" in constraint.to_sql()[0] + + def test_the_subclass_still_works_and_still_defaults_the_actions(self): + dialect = probed(supports_fk_match=True)() + constraint = ForeignKeyConstraint( + dialect, + columns=["a"], + foreign_key_table=Table(dialect, "parent"), + foreign_key_columns=["id"], + match_type="FULL", + ) + + assert isinstance(constraint, TableConstraint) + assert constraint.on_delete is ReferentialAction.NO_ACTION + assert "MATCH FULL" in constraint.to_sql()[0] + + def test_the_base_leaves_the_actions_unspecified(self): + dialect = probed(supports_fk_match=True)() + constraint = TableConstraint(dialect, branch=TableForeignKeyConstraint(dialect, ["a"], (Table(dialect, "parent"), ["id"]))) + + assert constraint.branch.on_delete is None + assert "ON DELETE" not in constraint.to_sql()[0] + + +class TestActionsOnANonForeignKey: + """The fields are not on a PRIMARY KEY branch at all, so they cannot be set + on one: what used to be kept and ignored is now unexpressible.""" + + def test_a_primary_key_branch_has_no_match_field(self): + dialect = probed(supports_fk_match=True)() + + with pytest.raises(TypeError, match="match_type"): + TablePrimaryKeyConstraint(dialect, ["a"], match_type="FULL") + + def test_and_the_key_still_renders_as_the_key_alone(self): + dialect = probed(supports_fk_match=True)() + constraint = TableConstraint( + dialect, branch=TablePrimaryKeyConstraint(dialect, ["a"]) + ) + + assert constraint.to_sql()[0] == 'PRIMARY KEY ("a")' diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_auto_increment.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_auto_increment.py new file mode 100644 index 000000000..057f6818f --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_auto_increment.py @@ -0,0 +1,140 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_auto_increment.py +"""SQLite spells AUTOINCREMENT as part of INTEGER PRIMARY KEY, and nowhere else. + +It used to be a flag on any column constraint, read only by a private SQLite +handler, and dropped without a word anywhere else. Twenty-three examples in +the tree were relying on that silence: they put the flag on a NOT NULL +constraint next to a PRIMARY KEY and got a table whose key did not +autoincrement. +""" +import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefinition, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def definition(dialect, data_type, *constraints): + return ColumnDefinition(dialect, "id", data_type, constraints=list(constraints)) + + +def primary_key(dialect, **kwargs): + return ColumnConstraint( + dialect, branch=ColumnPrimaryKeyConstraint(dialect, **kwargs) + ) + + +class TestAutoIncrementRenders: + def test_integer_primary_key(self): + dialect = SQLiteDialect() + column = definition(dialect, IntegerType(dialect), primary_key(dialect, is_auto_increment=True)) + + assert column.to_sql()[0] == '"id" INTEGER PRIMARY KEY AUTOINCREMENT' + + def test_the_sqlite_integer_spelling_too(self): + dialect = SQLiteDialect() + column = definition( + dialect, + SQLiteIntegerType(dialect), + primary_key(dialect, is_auto_increment=True), + ) + + assert "AUTOINCREMENT" in column.to_sql()[0] + + def test_without_the_flag_there_is_no_marker(self): + dialect = SQLiteDialect() + column = definition(dialect, IntegerType(dialect), primary_key(dialect)) + + assert column.to_sql()[0] == '"id" INTEGER PRIMARY KEY' + + +class TestAutoIncrementRefusedElsewhere: + """SQLite would reject the SQL, so the request is refused by name.""" + + def test_the_flag_belongs_to_the_primary_key_branch_alone(self): + """The refusal is structural now: no other branch has the field. + + What SQLite used to refuse at render time -- a flag on a constraint + kind that has nowhere to spell it -- is no longer expressible: the flag + is a field of the primary-key branch, and the other branches do not + declare it, so ``is_auto_increment=True`` cannot reach them at all. + """ + + assert "is_auto_increment" in ColumnPrimaryKeyConstraint.__init__.__code__.co_varnames + for branch_cls in ( + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnUniqueConstraint, + ): + assert "is_auto_increment" not in branch_cls.__init__.__code__.co_varnames + + def test_on_a_non_integer_column(self): + dialect = SQLiteDialect() + column = definition( + dialect, TextType(dialect), primary_key(dialect, is_auto_increment=True) + ) + + with pytest.raises(UnsupportedFeatureError, match="non-INTEGER"): + column.to_sql() + + +class TestPrivateHandlersAreGone: + """The SQLite-specific copies of the shared renderers are deleted. + + Every column constraint now goes through one public entry point, so a + constraint cannot render one way inside CREATE TABLE and another way on its + own. + """ + + @pytest.mark.parametrize( + "branch_cls, expected", + ( + (ColumnPrimaryKeyConstraint, " PRIMARY KEY"), + (ColumnNotNullConstraint, " NOT NULL"), + (ColumnNullableConstraint, " NULL"), + (ColumnUniqueConstraint, " UNIQUE"), + ), + ) + def test_each_kind_renders_through_the_one_entry_point(self, branch_cls, expected): + dialect = SQLiteDialect() + constraint = ColumnConstraint(dialect, branch=branch_cls(dialect)) + + assert constraint.to_sql() == (expected, ()) + + @pytest.mark.parametrize( + "deleted", + ( + "format_not_null_constraint", + "format_null_constraint", + "format_unique_constraint", + "format_check_constraint", + ), + ) + def test_the_duplicates_no_longer_exist(self, deleted): + """Names only SQLite ever defined; core's own methods are untouched.""" + assert not hasattr(SQLiteDialect, deleted) + + def test_two_paths_agree_for_every_kind(self): + dialect = SQLiteDialect() + for branch in ( + ColumnNotNullConstraint(dialect), + ColumnNullableConstraint(dialect), + ColumnUniqueConstraint(dialect), + ColumnDefaultConstraint(dialect, 0), + ): + constraint = ColumnConstraint(dialect, branch=branch) + column_sql, _ = definition(dialect, IntegerType(dialect), constraint).to_sql() + constraint_sql, _ = constraint.to_sql() + assert column_sql == f'"id" INTEGER{constraint_sql}' diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_column_constraints.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_column_constraints.py new file mode 100644 index 000000000..af5a36cbc --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_column_constraints.py @@ -0,0 +1,301 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_sqlite_column_constraints.py +"""What SQLite's column-constraint grammar actually accepts. + +Everything here was measured against a live SQLite rather than read off the +syntax diagrams, because the two disagree in both directions: the grammar shows +``AUTOINCREMENT`` on a table constraint that the parser rejects, and shows +``MATCH`` on a foreign key that is parsed and then ignored. +""" +import sqlite3 + +import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefinition, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def probed(**answers): + namespace = { + name: (lambda self, answer=answer: answer) + for name, answer in answers.items() + } + return type("ProbedDialect", (SQLiteDialect,), namespace) + + +def accepts(ddl: str) -> bool: + """Whether this SQLite build accepts the statement.""" + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE p (id INTEGER PRIMARY KEY)") + conn.execute(ddl) + return True + except sqlite3.Error: + return False + finally: + conn.close() + + +class TestColumnConstraintNames: + """``CONSTRAINT name`` is a column constraint in SQLite, not only a table one.""" + + def test_sqlite_build_accepts_a_named_column_constraint(self): + assert accepts( + "CREATE TABLE t (id INTEGER PRIMARY KEY, " + "e TEXT CONSTRAINT uq_e UNIQUE)" + ) + + def test_the_renderer_spells_the_name(self): + """The column path renders ``CONSTRAINT `` like the table path. + + The renderer used to read ``name`` on the table path only, which + silently unnamed every named column constraint. The SQL-92 grammar + opens both constraint productions with ``[CONSTRAINT ]``, so + both render it, gated by ``supports_constraint_naming`` (default + True; SQLite measured accepting it on 3.50.4). + """ + dialect = SQLiteDialect() + constraint = ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect), name="uq_e") + + assert constraint.name == "uq_e" + assert constraint.to_sql()[0] == ' CONSTRAINT "uq_e" UNIQUE' + + def test_the_table_path_already_renders_names(self): + dialect = SQLiteDialect() + constraint = TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["e"]), name="uq_e") + + assert constraint.to_sql()[0].startswith('CONSTRAINT "uq_e"') + + +class TestDeferralIsEnforced: + """``DEFERRABLE INITIALLY DEFERRED`` defers to COMMIT; it is not a no-op.""" + + @staticmethod + def _probe(ddl: str): + conn = sqlite3.connect(":memory:") + try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("CREATE TABLE p (id INTEGER PRIMARY KEY)") + conn.execute(ddl) + conn.execute("BEGIN") + try: + conn.execute("INSERT INTO t (pid) VALUES (999)") + inserted = True + except sqlite3.IntegrityError: + inserted = False + try: + conn.execute("COMMIT") + committed = True + except sqlite3.IntegrityError: + committed = False + return inserted, committed + finally: + conn.close() + + def test_deferred_rejects_at_commit_not_at_insert(self): + inserted, committed = self._probe( + "CREATE TABLE t (id INTEGER PRIMARY KEY, " + "pid INTEGER REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED)" + ) + + assert inserted is True + assert committed is False + + def test_not_deferrable_rejects_at_insert(self): + inserted, _ = self._probe( + "CREATE TABLE t (id INTEGER PRIMARY KEY, " + "pid INTEGER REFERENCES p(id) NOT DEFERRABLE)" + ) + + assert inserted is False + + def test_sqlite_declares_the_capability(self): + assert SQLiteDialect().supports_deferrable_constraint() is True + + def test_a_deferred_column_foreign_key_renders(self): + dialect = SQLiteDialect() + constraint = ColumnConstraint(dialect, branch=ColumnForeignKeyConstraint(dialect, (Table(dialect, "p"), ["id"])), deferrable=True, initially_deferred=True) + + sql = constraint.to_sql()[0] + + assert "DEFERRABLE" in sql and "INITIALLY DEFERRED" in sql + assert accepts(f"CREATE TABLE t ({sql.strip().join(['id INTEGER ', ', pid INTEGER '])})") + + +class TestMatchIsParsedAndIgnored: + """SQLite parses ``MATCH`` and never enforces it, so it is refused.""" + + def test_the_syntax_is_accepted(self): + assert accepts( + "CREATE TABLE t (id INTEGER PRIMARY KEY, " + "pid INTEGER REFERENCES p(id) MATCH FULL)" + ) + + def test_the_dialect_refuses_it_by_name(self): + dialect = SQLiteDialect() + constraint = ColumnConstraint(dialect, branch=ColumnForeignKeyConstraint(dialect, (Table(dialect, "p"), ["id"]), match_type="FULL")) + + with pytest.raises(UnsupportedFeatureError, match="MATCH"): + constraint.to_sql() + + +class TestAutoIncrementHasTwoForms: + """AUTOINCREMENT belongs to the primary key, not to a column definition. + + It has two spellings and both are valid:: + + id INTEGER PRIMARY KEY AUTOINCREMENT -- column level + PRIMARY KEY (id AUTOINCREMENT) -- table level + + The keyword sits *inside* the parentheses in the second form. Reading the + syntax diagram as ``PRIMARY KEY (id) AUTOINCREMENT`` -- outside -- yields a + syntax error, which is what an earlier reading of that diagram concluded. + """ + + def test_column_level_form_is_accepted(self): + assert accepts("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT)") + + def test_table_level_form_is_accepted(self): + assert accepts("CREATE TABLE t (id INTEGER, PRIMARY KEY (id AUTOINCREMENT))") + + def test_the_two_forms_are_not_interchangeable(self): + """Outside the parentheses it is a syntax error, not a spelling variant.""" + assert not accepts( + "CREATE TABLE t (id INTEGER, PRIMARY KEY (id) AUTOINCREMENT)" + ) + + def test_the_table_level_form_autoincrements(self): + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE TABLE t (id INTEGER, name TEXT, PRIMARY KEY (id AUTOINCREMENT))" + ) + conn.execute("INSERT INTO t (name) VALUES ('a')") + conn.execute("INSERT INTO t (name) VALUES ('b')") + conn.execute("DELETE FROM t WHERE id = 2") + conn.execute("INSERT INTO t (name) VALUES ('c')") + + ids = [r[0] for r in conn.execute("SELECT id FROM t ORDER BY id")] + # The last insert must not reuse the deleted ROWID. + assert ids == [1, 3] + assert conn.execute( + "SELECT seq FROM sqlite_sequence WHERE name = 't'" + ).fetchone() == (3,) + finally: + conn.close() + + @pytest.mark.parametrize( + "ddl", + ( + "CREATE TABLE t (id TEXT, PRIMARY KEY (id AUTOINCREMENT))", + "CREATE TABLE t (id INTEGER, j INTEGER, PRIMARY KEY (id, j AUTOINCREMENT))", + ), + ) + def test_both_levels_require_a_single_integer_column(self, ddl): + """Same rule at both levels: a lone INTEGER primary key.""" + assert not accepts(ddl) + + def test_direction_cannot_combine_with_autoincrement(self): + assert not accepts( + "CREATE TABLE t (id INTEGER, PRIMARY KEY (id AUTOINCREMENT DESC))" + ) + + def test_direction_alone_is_fine_at_both_levels(self): + assert accepts("CREATE TABLE t (id INTEGER PRIMARY KEY DESC)") + assert accepts("CREATE TABLE t (id INTEGER, PRIMARY KEY (id DESC))") + + def test_the_table_level_form_cannot_be_requested(self): + """The gap: ``TableConstraint`` has no field for it. + + SQLite accepts the form and we cannot express it. The assertion records + the missing field rather than pretending the form does not exist. + """ + constraint = TableConstraint(SQLiteDialect(), branch=TablePrimaryKeyConstraint(SQLiteDialect(), ["id"])) + + assert not hasattr(constraint, "is_auto_increment") + assert "AUTOINCREMENT" not in constraint.to_sql()[0] + + +class TestConflictClauseIsMissingFromTheExpressionLayer: + """SQLite accepts a conflict clause on three column constraints. + + There is no expression for it anywhere in the tree, so this cannot be + written at all. Recorded here so the gap has a failing test rather than a + note in a plan. + """ + + @pytest.mark.parametrize( + "ddl", + ( + "CREATE TABLE t (id INTEGER PRIMARY KEY, e TEXT UNIQUE ON CONFLICT IGNORE)", + "CREATE TABLE t (id INTEGER PRIMARY KEY, e TEXT NOT NULL ON CONFLICT FAIL)", + ), + ) + def test_sqlite_build_accepts_them(self, ddl): + assert accepts(ddl) + + @pytest.mark.parametrize( + "branch_cls", (ColumnUniqueConstraint, ColumnNotNullConstraint) + ) + def test_but_the_expression_cannot_carry_one(self, branch_cls): + dialect = SQLiteDialect() + constraint = ColumnConstraint(dialect, branch=branch_cls(dialect)) + + assert not hasattr(constraint, "on_conflict") + assert "ON CONFLICT" not in constraint.to_sql()[0] + + +class TestPrimaryKeyDirection: + """SQLite lets the primary key carry a sort direction; nothing models it.""" + + def test_sqlite_build_accepts_desc(self): + assert accepts("CREATE TABLE t (id INTEGER PRIMARY KEY DESC)") + + def test_but_the_expression_has_no_field_for_it(self): + constraint = ColumnConstraint(SQLiteDialect(), branch=ColumnPrimaryKeyConstraint(SQLiteDialect())) + + assert not hasattr(constraint, "direction") + assert "DESC" not in constraint.to_sql()[0] + + +class TestColumnDefinitionPathMatchesTheGrammar: + """Sanity: what we render for the common case is what SQLite accepts.""" + + def test_integer_primary_key(self): + dialect = SQLiteDialect() + column = ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], + ) + + assert accepts(f"CREATE TABLE t ({column.to_sql()[0]})") + + def test_not_null_and_unique(self): + dialect = SQLiteDialect() + column = ColumnDefinition( + dialect, + "e", + TextType(dialect), + constraints=[ + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), + ], + ) + + assert accepts(f"CREATE TABLE t ({column.to_sql()[0]})") diff --git a/tests/rhosocial/activerecord_test/feature/backend/ddl/test_type_name_positions.py b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_type_name_positions.py new file mode 100644 index 000000000..ba8d59d45 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/ddl/test_type_name_positions.py @@ -0,0 +1,137 @@ +# tests/rhosocial/activerecord_test/feature/backend/ddl/test_type_name_positions.py +"""A type position holds a type, and a name is a name. + +Three positions take a type: XMLSERIALIZE's target type, an XMLTABLE +column's type, and ALTER COLUMN ... SET DATA TYPE's type. Each used to carry +a raw string, guarded only by a character class that neither matched what the +engines accept nor kept the value inside its syntactic position -- +``TEXT AS (SELECT 1`` passed it. + +The rule these cases pin: + +* a position holds a type node, and the dialect renders it in its own + spelling; +* a name the framework does not model becomes the framework's name node, whose + name the grammar validates -- a type position cannot take a bound + parameter, so what lands there is code, not data; +* a value that is not a type name is **refused**, not rendered. + +Whether the type *exists* is the server's answer, and the server gives it: +``mytype`` is rendered as it was written, and whether PostgreSQL has that +type is PostgreSQL's business. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Literal +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterColumnAction, +) +from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, +) +from rhosocial.activerecord.backend.expression.types import ( + CustomType, + IntegerType, + TextType, + VarCharType, +) +from rhosocial.activerecord.backend.expression.xml import ( + XMLSerializeExpression, + XMLTableColumn, + XMLTableExpression, +) +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + +#: Values that are not a type name. Each of these passed the old character +#: class, and each is a way of ending the type and starting something else. +NOT_A_TYPE_NAME = ( + "TEXT AS (SELECT 1", + "TEXT) , (SELECT password FROM users", + "TEXT, (SELECT 1)", + "TEXT UNION SELECT 1", + "(SELECT 1)", + "TEXT; DROP TABLE t", + "TEXT'", + 'TEXT"', + "TEXT/*comment*/", +) + + +@pytest.fixture +def dialect(): + return DummyDialect() + + +def _serialize(dialect, target_type): + return dialect.format_xml_serialize_expression( + XMLSerializeExpression(dialect, Literal(dialect, "x"), target_type) + )[0] + + +def _xmltable(dialect, data_type): + return dialect.format_xml_table_expression( + XMLTableExpression( + dialect, + row_pattern=Literal(dialect, "x"), + columns=[XMLTableColumn(name="c", data_type=data_type)], + ) + )[0] + + +def _set_data_type(dialect, new_value): + return dialect.format_alter_column_action( + AlterColumnAction(dialect, "c", "SET DATA TYPE", new_value=new_value) + )[0] + + +class TestThePositionsTakeAType: + """A name or a node, and a type comes out.""" + + @pytest.mark.parametrize("name", ["TEXT", "VARCHAR(100)", "INTEGER"]) + def test_a_name_renders_as_a_type(self, dialect, name): + assert name in _serialize(dialect, name) + + def test_a_node_renders_as_a_type(self, dialect): + assert "TEXT" in _serialize(dialect, TextType(dialect)) + assert "INTEGER" in _serialize(dialect, IntegerType(dialect)) + assert "VARCHAR(100)" in _serialize(dialect, VarCharType(dialect, 100)) + + def test_a_name_the_framework_does_not_model_is_kept_verbatim(self, dialect): + """An extension type is the server's to accept, not the framework's.""" + assert "mytype" in _serialize(dialect, "mytype") + assert "probe.mytype" in _serialize(dialect, "probe.mytype") + + def test_a_custom_type_node_renders_its_name(self, dialect): + assert "mytype" in _serialize(dialect, CustomType(dialect, raw="mytype")) + + def test_the_other_two_positions_take_a_type_too(self, dialect): + assert "INTEGER" in _xmltable(dialect, "INTEGER") + assert "INTEGER" in _set_data_type(dialect, "INTEGER") + assert "INTEGER" in _xmltable(dialect, IntegerType(dialect)) + assert "INTEGER" in _set_data_type(dialect, IntegerType(dialect)) + + +class TestThePositionsRefuseAValueThatIsNotAType: + """The old character class let these through.""" + + @pytest.mark.parametrize("value", NOT_A_TYPE_NAME) + def test_xmlserialize_refuses_it(self, dialect, value): + with pytest.raises(InvalidTypeNameError): + _serialize(dialect, value) + + @pytest.mark.parametrize("value", NOT_A_TYPE_NAME) + def test_xmltable_refuses_it(self, dialect, value): + with pytest.raises(InvalidTypeNameError): + _xmltable(dialect, value) + + @pytest.mark.parametrize("value", NOT_A_TYPE_NAME) + def test_alter_column_refuses_it(self, dialect, value): + with pytest.raises(InvalidTypeNameError): + _set_data_type(dialect, value) + + def test_a_value_that_is_neither_a_name_nor_a_node_is_refused(self, dialect): + with pytest.raises(TypeError): + _serialize(dialect, 42) + with pytest.raises(TypeError): + _set_data_type(dialect, object()) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_array_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_array_protocol.py index 89996e9f1..65555d5d3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_array_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_array_protocol.py @@ -36,7 +36,7 @@ def test_no_array_dialect_does_not_support_features(): def test_format_array_expression_behavior(): """Test that format_array_expression behaves appropriately in no-array dialect.""" from unittest.mock import MagicMock - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayOperation dialect = NoArrayDialect() @@ -45,8 +45,8 @@ def test_format_array_expression_behavior(): assert dialect.supports_array_access() is False # The method should still execute without throwing an error for basic operations - # Create a mock ArrayExpression to pass to format_array_expression - mock_expr = MagicMock(spec=ArrayExpression) + # Create a mock ArrayOperation to pass to format_array_expression + mock_expr = MagicMock(spec=ArrayOperation) mock_expr.operation = "CONSTRUCTOR" mock_expr.elements = None mock_expr.base_expr = None diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_cte_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_cte_protocol.py index 5dca40974..a60330b07 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_cte_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_cte_protocol.py @@ -7,10 +7,16 @@ """ from rhosocial.activerecord.backend.dialect import SQLDialectBase, CTEMixin, CTESupport -from rhosocial.activerecord.backend.expression import Column, QueryExpression +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + QueryStatement, +) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression, WithQueryExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause, WithStatement from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect class NoCTEDialect(SQLDialectBase, CTEMixin, CTESupport): @@ -47,39 +53,171 @@ def test_format_cte_reports_no_support(): assert not dialect.supports_materialized_cte() -def test_cte_expression_integration_reports_no_support(): - """Test CTEExpression integration with no-CTE dialect.""" +def test_cte_clause_integration_reports_no_support(): + """Test CTEClause integration with no-CTE dialect.""" dialect = NoCTEDialect() # Create a simple query to use in CTE - query = QueryExpression( - dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")) + query = QueryStatement( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) # Create CTE expression - CTEExpression(dialect, name="user_cte", query=query, columns=["id", "name"]) + CTEClause(dialect, name="user_cte", query=query, columns=["id", "name"]) # The dialect should report no CTE support assert not dialect.supports_basic_cte() -def test_with_query_expression_integration_reports_no_support(): - """Test WithQueryExpression integration with no-CTE dialect.""" +def test_with_statement_integration_reports_no_support(): + """Test WithStatement integration with no-CTE dialect.""" dialect = NoCTEDialect() # Create a simple query to use in CTE - query = QueryExpression( - dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")) + query = QueryStatement( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), ) # Create CTE - cte = CTEExpression(dialect, name="user_cte", query=query, columns=["id", "name"]) + cte = CTEClause(dialect, name="user_cte", query=query, columns=["id", "name"]) # Create main query that uses the CTE - main_query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "user_cte"))) + main_query = QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "user_cte")), + ) - # Create WithQueryExpression - WithQueryExpression(dialect, ctes=[cte], main_query=main_query) + # Create WithStatement + WithStatement(dialect, ctes=[cte], query=main_query) # The dialect should report no CTE support assert not dialect.supports_basic_cte() + + +def _simple_cte(dialect): + query = QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), + ) + return CTEClause(dialect, name="user_cte", query=query, columns=["id"]) + + +def _probed(**answers): + """A SQLiteDialect subclass whose CTE probes answer as ``answers``.""" + namespace = { + name: (lambda self, answer=answer: answer) + for name, answer in answers.items() + } + return type("ProbedDialect", (SQLiteDialect,), namespace) + + +def _simple_main_query(dialect): + return QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "user_cte")), + ) + + +def test_cte_clause_is_the_node_for_one_definition(): + """A single CTE definition renders through its own formatter.""" + dialect = SQLiteDialect() + cte = _simple_cte(dialect) + + assert cte.format_method == "format_cte_clause" + sql, params = cte.to_sql() + assert sql == '"user_cte" ("id") AS (SELECT "id" FROM "users")' + assert params == () + + +def test_with_statement_renders_through_its_own_formatter(): + """The statement is a node: its spelling is ``format_with_statement``.""" + dialect = SQLiteDialect() + stmt = WithStatement(dialect, ctes=[_simple_cte(dialect)], query=_simple_main_query(dialect)) + + assert stmt.format_method == "format_with_statement" + sql, params = stmt.to_sql() + assert sql == ( + 'WITH "user_cte" ("id") AS (SELECT "id" FROM "users") ' + 'SELECT "id" FROM "user_cte"' + ) + assert params == () + + +def test_with_statement_spells_recursive(): + dialect = SQLiteDialect() + stmt = WithStatement( + dialect, ctes=[_simple_cte(dialect)], query=_simple_main_query(dialect), recursive=True + ) + + assert stmt.to_sql()[0] == ( + 'WITH RECURSIVE "user_cte" ("id") AS (SELECT "id" FROM "users") ' + 'SELECT "id" FROM "user_cte"' + ) + + +def test_with_statement_without_ctes_renders_the_bare_query(): + """No CTE definitions: the statement is the query, so a plain query needs no wrapper.""" + dialect = SQLiteDialect() + stmt = WithStatement(dialect, query=_simple_main_query(dialect)) + + assert stmt.to_sql()[0] == 'SELECT "id" FROM "user_cte"' + + +def test_with_statement_refuses_a_dialect_without_basic_ctes(): + """The basic-CTE probe is consulted, not decorative.""" + import pytest + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + dialect = _probed(supports_basic_cte=False)() + stmt = WithStatement(dialect, ctes=[_simple_cte(dialect)], query=_simple_main_query(dialect)) + + with pytest.raises(UnsupportedFeatureError, match="basic CTE"): + stmt.to_sql() + + +def test_with_statement_refuses_recursive_without_support(): + """A RECURSIVE request on a dialect without recursive CTEs is refused by name.""" + import pytest + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + dialect = _probed(supports_recursive_cte=False)() + stmt = WithStatement( + dialect, ctes=[_simple_cte(dialect)], query=_simple_main_query(dialect), recursive=True + ) + + with pytest.raises(UnsupportedFeatureError, match="WITH RECURSIVE"): + stmt.to_sql() + + +def test_with_statement_places_cte_params_before_query_params(): + """Parameter order follows the rendered text order.""" + dialect = SQLiteDialect() + cte = CTEClause( + dialect, + name="user_cte", + query=QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "users")), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 7)), + ), + ) + main = QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_=NamedRelationRef(dialect, Table(dialect, "user_cte")), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 9)), + ) + + assert WithStatement(dialect, ctes=[cte], query=main).to_sql()[1] == (7, 9) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_dql_capabilities.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_dql_capabilities.py index 95737b9b3..a9d9e10f8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_dql_capabilities.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_dql_capabilities.py @@ -2,7 +2,6 @@ """Tests for DQL capability switches, silent-skip fixes and expression gaps. Covers: -- Set-operation FOR UPDATE fail-fast (Q02) - FETCH FIRST ... WITH TIES (G01) - ORDER BY ... NULLS FIRST/LAST (G02) - typed ForUpdateClause LockStrength (G03-G06) @@ -17,20 +16,13 @@ LimitOffsetClause, LockStrength, OrderByClause, - OrderByExpression, + OrderByItem, ) -from rhosocial.activerecord.backend.expression.statements import QueryExpression -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression from rhosocial.activerecord.backend.impl.dummy.backend import DummyDialect pytestmark = [pytest.mark.feature, pytest.mark.backend] -class NoSetOpForUpdate(DummyDialect): - def supports_set_operation_for_update(self) -> bool: - return False - - class NoFetchWithTies(DummyDialect): def supports_fetch_with_ties(self) -> bool: return False @@ -46,32 +38,6 @@ def supports_for_share(self) -> bool: return False -def _set_op(dialect, **kwargs): - return SetOperationExpression( - dialect, - left=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t1"), - right=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t2"), - operation="UNION", - **kwargs, - ) - - -class TestSetOperationForUpdate: - """Q02: set-operation FOR UPDATE is gated, not silently rendered/dropped.""" - - def test_for_update_raises_when_unsupported(self): - dialect = NoSetOpForUpdate() - expr = _set_op(dialect, for_update_clause=ForUpdateClause(dialect)) - with pytest.raises(UnsupportedFeatureError, match="FOR UPDATE in set operations"): - expr.to_sql() - - def test_for_update_renders_when_supported(self): - dialect = DummyDialect() - expr = _set_op(dialect, for_update_clause=ForUpdateClause(dialect)) - sql, _ = expr.to_sql() - assert sql.endswith("FOR UPDATE") - - class TestFetchWithTies: """G01: FETCH FIRST ... WITH TIES.""" @@ -108,7 +74,7 @@ def test_nulls_first_renders(self): dialect = DummyDialect() clause = OrderByClause( dialect, - expressions=[OrderByExpression(dialect, Column(dialect, "name"), nulls_first=True)], + expressions=[OrderByItem(dialect, Column(dialect, "name"), nulls="first")], ) sql, _ = dialect.format_order_by_clause(clause) assert sql == 'ORDER BY "name" NULLS FIRST' @@ -117,7 +83,7 @@ def test_direction_and_nulls_last_render(self): dialect = DummyDialect() clause = OrderByClause( dialect, - expressions=[OrderByExpression(dialect, Column(dialect, "name"), "DESC", nulls_last=True)], + expressions=[OrderByItem(dialect, Column(dialect, "name"), "DESC", nulls="last")], ) sql, _ = dialect.format_order_by_clause(clause) assert sql == 'ORDER BY "name" DESC NULLS LAST' @@ -126,20 +92,20 @@ def test_raises_when_unsupported(self): dialect = NoNullsFirstLast() clause = OrderByClause( dialect, - expressions=[OrderByExpression(dialect, Column(dialect, "name"), nulls_first=True)], + expressions=[OrderByItem(dialect, Column(dialect, "name"), nulls="first")], ) with pytest.raises(UnsupportedFeatureError, match="NULLS FIRST/LAST"): dialect.format_order_by_clause(clause) - def test_mutually_exclusive_nulls(self): + def test_invalid_nulls_word(self): dialect = DummyDialect() with pytest.raises(ValueError): - OrderByExpression(dialect, Column(dialect, "x"), nulls_first=True, nulls_last=True) + OrderByItem(dialect, Column(dialect, "x"), nulls="sideways") def test_invalid_direction(self): dialect = DummyDialect() with pytest.raises(ValueError): - OrderByExpression(dialect, Column(dialect, "x"), "SIDEWAYS") + OrderByItem(dialect, Column(dialect, "x"), "SIDEWAYS") class TestForUpdateStrength: diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_drop_table_cascade.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_drop_table_cascade.py index bf7e6666d..3f65f3a51 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_drop_table_cascade.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_drop_table_cascade.py @@ -11,13 +11,13 @@ dialects that reject the keywords. - Real SQLite dialect (False/False) confirms gating does not depend on subclass of DummyDialect. -- Serialization round-trip of a DropTableExpression with cascade=True. +- Serialization round-trip of a DropTableStatement with cascade=True. """ import pytest from rhosocial.activerecord.backend.dialect import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.serialization import serialize, deserialize from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect @@ -50,7 +50,7 @@ def test_table_mixin_default_switches(self): def test_dummy_dialect_renders_cascade(self): """DummyDialect renders the standard CASCADE token when supported.""" dialect = DummyDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), cascade=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), cascade=True) sql, params = expr.to_sql() assert sql.endswith(" CASCADE") assert "RESTRICT" not in sql @@ -59,7 +59,7 @@ def test_dummy_dialect_renders_cascade(self): def test_dummy_dialect_renders_restrict(self): """DummyDialect renders the standard RESTRICT token when supported.""" dialect = DummyDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), restrict=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), restrict=True) sql, params = expr.to_sql() assert sql.endswith(" RESTRICT") assert "CASCADE" not in sql @@ -68,21 +68,21 @@ def test_dummy_dialect_renders_restrict(self): def test_no_cascade_dialect_rejects_cascade(self): """cascade=True raises UnsupportedFeatureError when unsupported.""" dialect = NoCascadeDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), cascade=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), cascade=True) with pytest.raises(UnsupportedFeatureError, match="DROP TABLE ... CASCADE"): expr.to_sql() def test_no_cascade_dialect_rejects_restrict(self): """restrict=True raises UnsupportedFeatureError when unsupported.""" dialect = NoCascadeDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), restrict=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), restrict=True) with pytest.raises(UnsupportedFeatureError, match="DROP TABLE ... RESTRICT"): expr.to_sql() def test_unset_behavior_always_allowed(self): """Neither spelling set is always allowed, even on rejecting dialects.""" dialect = NoCascadeDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users')) + expr = DropTableStatement(dialect, table=Table(dialect, 'users')) sql, params = expr.to_sql() assert "CASCADE" not in sql assert "RESTRICT" not in sql @@ -93,7 +93,7 @@ def test_both_spellings_are_refused(self): with pytest.raises( ValueError, match="cascade and restrict are mutually exclusive" ): - DropTableExpression( + DropTableStatement( dialect, table=Table(dialect, 'users'), cascade=True, restrict=True ) @@ -106,25 +106,25 @@ def test_real_sqlite_dialect_rejects_cascade(self): dialect = SQLiteDialect() assert dialect.supports_drop_table_cascade() is False assert dialect.supports_drop_table_restrict() is False - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), cascade=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), cascade=True) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_real_sqlite_dialect_plain_drop_still_works(self): """A plain DROP TABLE on SQLite (neither spelling set) renders normally.""" dialect = SQLiteDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), if_exists=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), if_exists=True) sql, params = expr.to_sql() assert sql.startswith("DROP TABLE IF EXISTS") assert params == () class TestDropTableCascadeSerialization: - """Serialization round-trip for a DropTableExpression with cascade set.""" + """Serialization round-trip for a DropTableStatement with cascade set.""" def test_cascade_true_serialization_roundtrip(self): dialect = DummyDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), cascade=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), cascade=True) expected_sql, expected_params = expr.to_sql() @@ -139,7 +139,7 @@ def test_cascade_true_serialization_roundtrip(self): def test_restrict_serialization_roundtrip(self): dialect = DummyDialect() - expr = DropTableExpression(dialect, table=Table(dialect, 'users'), restrict=True) + expr = DropTableStatement(dialect, table=Table(dialect, 'users'), restrict=True) expected_sql, expected_params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_identifier_quoting.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_identifier_quoting.py index 85fe672fb..14cb2e7af 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_identifier_quoting.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_identifier_quoting.py @@ -8,7 +8,7 @@ - SQLDialectBase.is_reserved_word (case-insensitive) - Reserved word warning emission - Per-role need_quote properties on Column, NamedRelationRef, - QualifiedIdentifierExpression, WildcardExpression, Identifier + QualifiedIdentifier, Identifier - Format methods (format_column, format_table, format_wildcard, format_identifier_expression, format_qualified_identifier) """ @@ -20,8 +20,7 @@ from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning from rhosocial.activerecord.backend.expression import ( Column, - QualifiedIdentifierExpression, - WildcardExpression, + QualifiedIdentifier, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.literals import Identifier @@ -226,18 +225,18 @@ def test_alias_need_quote_independent(self): assert source.alias_need_quote is False -class TestQualifiedIdentifierExpressionIdentifierQuoting: - """Test QualifiedIdentifierExpression per-role need_quote properties.""" +class TestQualifiedIdentifierIdentifierQuoting: + """Test QualifiedIdentifier per-role need_quote properties.""" def test_defaults_all_true(self): d = SQLiteDialect() - qi = QualifiedIdentifierExpression(d, schema="public", name="users") + qi = QualifiedIdentifier(d, schema="public", name="users") assert qi.name_need_quote is True assert qi.schema_need_quote is True def test_all_false(self): d = SQLiteDialect() - qi = QualifiedIdentifierExpression(d, schema="public", name="users", + qi = QualifiedIdentifier(d, schema="public", name="users", name_need_quote=False, schema_need_quote=False) assert qi.name_need_quote is False @@ -245,7 +244,7 @@ def test_all_false(self): def test_schema_need_quote_independent(self): d = SQLiteDialect() - qi = QualifiedIdentifierExpression(d, schema="public", name="users", + qi = QualifiedIdentifier(d, schema="public", name="users", name_need_quote=True, schema_need_quote=False) assert qi.schema_need_quote is False @@ -253,41 +252,11 @@ def test_schema_need_quote_independent(self): def test_no_schema(self): d = SQLiteDialect() - qi = QualifiedIdentifierExpression(d, name="users", + qi = QualifiedIdentifier(d, name="users", name_need_quote=False) assert qi.name_need_quote is False -class TestWildcardExpressionIdentifierQuoting: - """Test WildcardExpression per-role need_quote properties.""" - - def test_defaults_all_true(self): - d = SQLiteDialect() - w = WildcardExpression(d, table="users") - assert w.table_need_quote is True - assert w.schema_need_quote is True - - def test_all_false(self): - d = SQLiteDialect() - w = WildcardExpression(d, table="users", - table_need_quote=False, schema_need_quote=False) - assert w.table_need_quote is False - assert w.schema_need_quote is False - - def test_table_need_quote_independent(self): - d = SQLiteDialect() - w = WildcardExpression(d, table="users", schema_name="public", - table_need_quote=False, schema_need_quote=True) - assert w.table_need_quote is False - assert w.schema_need_quote is True - - def test_schema_need_quote_independent(self): - d = SQLiteDialect() - w = WildcardExpression(d, table="users", schema_name="public", - table_need_quote=True, schema_need_quote=False) - assert w.schema_need_quote is False - assert w.table_need_quote is True - class TestIdentifierIdentifierQuoting: """Test Identifier per-role need_quote properties.""" @@ -428,37 +397,10 @@ def test_source_alias_independent_quoting(self): sql, params = source.to_sql() assert sql == '"users" AS u' - def test_format_wildcard_unquoted(self): - d = DummyDialect() - w = WildcardExpression(d, table="users", table_need_quote=False) - sql, params = d.format_wildcard(w) - assert sql == "users.*" - def test_format_wildcard_quoted(self): - d = DummyDialect() - w = WildcardExpression(d, table="users", table_need_quote=True) - sql, params = d.format_wildcard(w) - assert sql == '"users".*' - def test_format_wildcard_no_table(self): - d = DummyDialect() - w = WildcardExpression(d) - sql, params = d.format_wildcard(w) - assert sql == "*" - def test_format_wildcard_schema_and_table(self): - d = DummyDialect() - w = WildcardExpression(d, table="users", schema_name="public", - table_need_quote=False, schema_need_quote=False) - sql, params = d.format_wildcard(w) - assert sql == "public.users.*" - def test_format_wildcard_schema_and_table_quoted(self): - d = DummyDialect() - w = WildcardExpression(d, table="users", schema_name="public", - table_need_quote=True, schema_need_quote=True) - sql, params = d.format_wildcard(w) - assert sql == '"public"."users".*' def test_format_identifier_expression_unquoted(self): d = DummyDialect() @@ -497,7 +439,7 @@ def test_format_identifier_expression_reserved_word_warning(self): def test_format_qualified_identifier_unquoted(self): d = DummyDialect() - qi = QualifiedIdentifierExpression(d, schema="public", name="users", + qi = QualifiedIdentifier(d, schema="public", name="users", name_need_quote=False, schema_need_quote=False) sql, params = d.format_qualified_identifier(qi) @@ -505,7 +447,7 @@ def test_format_qualified_identifier_unquoted(self): def test_format_qualified_identifier_quoted(self): d = DummyDialect() - qi = QualifiedIdentifierExpression(d, schema="public", name="users", + qi = QualifiedIdentifier(d, schema="public", name="users", name_need_quote=True, schema_need_quote=True) sql, params = d.format_qualified_identifier(qi) @@ -513,7 +455,7 @@ def test_format_qualified_identifier_quoted(self): def test_format_qualified_identifier_no_schema(self): d = DummyDialect() - qi = QualifiedIdentifierExpression(d, name="users", + qi = QualifiedIdentifier(d, name="users", name_need_quote=False) sql, params = d.format_qualified_identifier(qi) assert sql == "users" @@ -541,9 +483,3 @@ def test_source_mixed_quoting(self): sql, params = source.to_sql() assert sql == 'main."users"' - def test_format_wildcard_table_need_quote_independent(self): - d = DummyDialect() - w = WildcardExpression(d, table="users", schema_name="public", - table_need_quote=False, schema_need_quote=True) - sql, params = d.format_wildcard(w) - assert sql == '"public".users.*' diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_insert_on_conflict_capabilities.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_insert_on_conflict_capabilities.py index 3f342ae09..df474c370 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_insert_on_conflict_capabilities.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_insert_on_conflict_capabilities.py @@ -6,14 +6,14 @@ - Default UpsertMixin capability switch values. - Gating: multiple ON CONFLICT clauses rejected when unsupported. - Gating: any ON CONFLICT clause rejected when the clause form is unsupported. -- Serialization round-trip of an InsertExpression with multiple clauses. +- Serialization round-trip of an InsertStatement with multiple clauses. """ import pytest from rhosocial.activerecord.backend.dialect import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, Literal, OnConflictClause, ValuesSource, @@ -58,7 +58,7 @@ def test_single_conflict_dialect_rejects_multiple_clauses(self): source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause1 = OnConflictClause(dialect, conflict_target=["a"], do_nothing=True) clause2 = OnConflictClause(dialect, conflict_target=["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() @@ -68,7 +68,7 @@ def test_single_conflict_dialect_accepts_single_clause(self): dialect = SingleConflictDialect() source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause = OnConflictClause(dialect, conflict_target=["a"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, 't'), source=source, on_conflict=clause) + expr = InsertStatement(dialect, into=Table(dialect, 't'), source=source, on_conflict=clause) sql, params = expr.to_sql() assert "ON CONFLICT" in sql @@ -79,7 +79,7 @@ def test_no_on_conflict_clause_dialect_rejects_any_clause(self): dialect = NoOnConflictClauseDialect() source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause = OnConflictClause(dialect, conflict_target=["a"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, 't'), source=source, on_conflict=clause) + expr = InsertStatement(dialect, into=Table(dialect, 't'), source=source, on_conflict=clause) with pytest.raises(UnsupportedFeatureError, match="does not support ON CONFLICT"): expr.to_sql() @@ -89,7 +89,7 @@ class TestOnConflictSerialization: """Serialization round-trip for multiple ON CONFLICT clauses.""" def test_multiple_on_conflict_serialization_roundtrip(self): - """An InsertExpression with multiple clauses survives serialize/deserialize.""" + """An InsertStatement with multiple clauses survives serialize/deserialize.""" dialect = DummyDialect() source = ValuesSource(dialect, values_list=[[Literal(dialect, 1), Literal(dialect, "x")]]) clause1 = OnConflictClause(dialect, conflict_target=["id"], do_nothing=True) @@ -98,7 +98,7 @@ def test_multiple_on_conflict_serialization_roundtrip(self): conflict_target=["name"], update_assignments={"name": Literal(dialect, "y")}, ) - expr = InsertExpression( + expr = InsertStatement( dialect, into=Table(dialect, 'users'), columns=["id", "name"], diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_json_path_mode.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_json_path_mode.py index 34a036947..caf446367 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_json_path_mode.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_json_path_mode.py @@ -6,7 +6,7 @@ - JSONMixin.format_json_expression() dispatch decisions - format_json_arrow_expression() error path on unsupported dialects - format_json_function_expression() equivalence on supported dialects -- JSONDocumentExpression stores mode correctly +- JSONDocumentValue stores mode correctly """ from typing import TYPE_CHECKING, Any, Tuple @@ -20,7 +20,7 @@ ) from rhosocial.activerecord.backend.dialect.mixins import ExpressionMixin from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, + JSONDocumentValue, JSONPathMode, ) @@ -69,7 +69,7 @@ def __init__(self, function_marker: str = "FN") -> None: super().__init__() self.function_marker = function_marker - def format_json_function_expression(self, expr: "JSONDocumentExpression") -> Tuple[str, tuple]: + def format_json_function_expression(self, expr: "JSONDocumentValue") -> Tuple[str, tuple]: return f"{self.function_marker}({expr.column}, {expr.path})", () @@ -117,12 +117,12 @@ def test_invalid_type_raises(self) -> None: # ---------------------------------------------------------------------------- -# JSONDocumentExpression stores mode as JSONPathMode enum +# JSONDocumentValue stores mode as JSONPathMode enum # ---------------------------------------------------------------------------- class TestJSONExpressionMode: - """Verify JSONDocumentExpression coerces the mode argument.""" + """Verify JSONDocumentValue coerces the mode argument.""" @pytest.mark.parametrize( "value, expected", @@ -135,13 +135,13 @@ class TestJSONExpressionMode: ) def test_mode_is_coerced_to_enum(self, value: Any, expected: "JSONPathMode") -> None: d = ArrowSupportedDialect() - expr = JSONDocumentExpression(d, "data", "$.a", operation="->", mode=value) + expr = JSONDocumentValue(d, "data", "$.a", operation="->", mode=value) assert expr.mode is expected def test_default_mode_is_auto(self) -> None: """Default behavior unchanged: mode=None → JSONPathMode.AUTO.""" d = ArrowSupportedDialect() - expr = JSONDocumentExpression(d, "data", "$.a") + expr = JSONDocumentValue(d, "data", "$.a") assert expr.mode is JSONPathMode.AUTO @@ -155,7 +155,7 @@ class TestDispatchBehavior: def test_auto_on_arrow_supported_dialect_uses_arrow(self) -> None: d = ArrowSupportedDialect() - expr = JSONDocumentExpression(d, "data", "$.a", operation="->>") + expr = JSONDocumentValue(d, "data", "$.a", operation="->>") sql, params = d.format_json_expression(expr) # Arrow formatting emits col->>'...' assert "->>" in sql @@ -163,7 +163,7 @@ def test_auto_on_arrow_supported_dialect_uses_arrow(self) -> None: def test_auto_on_arrow_unsupported_dialect_uses_function(self) -> None: d = ArrowUnsupportedDialect() - expr = JSONDocumentExpression(d, "data", "$.a", operation="->>") + expr = JSONDocumentValue(d, "data", "$.a", operation="->>") sql, params = d.format_json_expression(expr) # Default function-based: JSON_UNQUOTE(JSON_EXTRACT(...)) assert "JSON_UNQUOTE" in sql @@ -171,7 +171,7 @@ def test_auto_on_arrow_unsupported_dialect_uses_function(self) -> None: def test_arrow_mode_on_unsupported_raises(self) -> None: d = ArrowUnsupportedDialect() - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode=JSONPathMode.ARROW ) with pytest.raises(UnsupportedFeatureError): @@ -179,7 +179,7 @@ def test_arrow_mode_on_unsupported_raises(self) -> None: def test_arrow_mode_on_supported_uses_arrow(self) -> None: d = ArrowSupportedDialect() - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode=JSONPathMode.ARROW ) sql, params = d.format_json_expression(expr) @@ -189,7 +189,7 @@ def test_arrow_mode_on_supported_uses_arrow(self) -> None: def test_function_mode_on_supported_dialect_uses_function(self) -> None: """FUNCTION mode must ignore arrow capability and dispatch to function path.""" d = FunctionOverrideDialect(function_marker="FN_MARKER") - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode=JSONPathMode.FUNCTION ) sql, params = d.format_json_expression(expr) @@ -199,7 +199,7 @@ def test_function_mode_on_supported_dialect_uses_function(self) -> None: def test_function_mode_on_unsupported_dialect_uses_function(self) -> None: """FUNCTION mode must work even when arrow is not supported.""" d = ArrowUnsupportedDialect() - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->", mode=JSONPathMode.FUNCTION ) sql, params = d.format_json_expression(expr) @@ -210,7 +210,7 @@ def test_function_mode_on_unsupported_dialect_uses_function(self) -> None: def test_string_mode_coerced_and_dispatched(self) -> None: """String mode values gets coerced to enum and dispatched correctly.""" d = FunctionOverrideDialect(function_marker="STRING_MODE") - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode="function" ) sql, _ = d.format_json_expression(expr) @@ -227,7 +227,7 @@ class TestDirectFormatMethods: def test_arrow_direct_on_unsupported_raises(self) -> None: d = ArrowUnsupportedDialect() - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode=JSONPathMode.ARROW ) with pytest.raises(UnsupportedFeatureError): @@ -236,7 +236,7 @@ def test_arrow_direct_on_unsupported_raises(self) -> None: def test_function_direct_always_succeeds_on_supported(self) -> None: """Even on arrow-capable backends, function path is available.""" d = ArrowSupportedDialect() - expr = JSONDocumentExpression( + expr = JSONDocumentValue( d, "data", "$.a", operation="->>", mode=JSONPathMode.FUNCTION ) sql, params = d.format_json_function_expression(expr) @@ -247,7 +247,7 @@ def test_function_direct_always_succeeds_on_supported(self) -> None: def test_arrow_format_keeps_path_in_string_literal(self) -> None: """Arrow formatter escapes via SQL string literal, not parameter.""" d = ArrowSupportedDialect() - expr = JSONDocumentExpression(d, "users", "$.name", operation="->>") + expr = JSONDocumentValue(d, "users", "$.name", operation="->>") sql, params = d.format_json_arrow_expression(expr) # No parameter; path is embedded as SQL literal assert params == () @@ -257,7 +257,7 @@ def test_arrow_format_keeps_path_in_string_literal(self) -> None: def test_function_format_keeps_path_in_string_literal(self) -> None: """Function default uses SQL string literal but no parameter.""" d = ArrowSupportedDialect() - expr = JSONDocumentExpression(d, "users", "$.name", operation="->") + expr = JSONDocumentValue(d, "users", "$.name", operation="->") sql, params = d.format_json_function_expression(expr) assert params == () # Default function path embeds the path as SQL literal @@ -280,7 +280,7 @@ def test_arrow_escapes_single_quote_in_path(self, op: str) -> None: # Path containing a single quote — if not escaped, this would break the # surrounding SQL string literal. path = "$.o'Brien" - expr = JSONDocumentExpression(d, "data", path, operation=op) + expr = JSONDocumentValue(d, "data", path, operation=op) sql, _ = d.format_json_arrow_expression(expr) # The single quote in the path must appear doubled-up (escaped) so it # cannot terminate the SQL literal early. @@ -292,7 +292,7 @@ def test_arrow_escapes_single_quote_in_path(self, op: str) -> None: def test_function_escapes_single_quote_in_path(self, op: str) -> None: d = ArrowSupportedDialect() path = "$.o'Brien" - expr = JSONDocumentExpression(d, "data", path, operation=op) + expr = JSONDocumentValue(d, "data", path, operation=op) sql, _ = d.format_json_function_expression(expr) assert "''" in sql assert "'o'Brien'" not in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_lateral_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_lateral_protocol.py index 90343dac1..8bbbb5ddb 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_lateral_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_lateral_protocol.py @@ -11,7 +11,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase, LateralJoinMixin, LateralJoinSupport from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import DQLMixin, ExpressionMixin, DDLColumnMixin, TableMixin -from rhosocial.activerecord.backend.expression import Column, QueryExpression, Subquery +from rhosocial.activerecord.backend.expression import Column, QueryStatement, Subquery from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.query_sources import LateralExpression, TableFunctionExpression from rhosocial.activerecord.backend.expression.objects import Table @@ -69,7 +69,7 @@ def test_lateral_expression_integration_reports_no_support(): # Create a subquery to use in lateral expression subquery = Subquery( - dialect, QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "other_table"))) + dialect, QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "other_table"))) ) # Create LateralExpression diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_mixins_import_order.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_mixins_import_order.py index 21e60e843..f6047d25d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_mixins_import_order.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_mixins_import_order.py @@ -47,11 +47,11 @@ def test_module_imports_without_cycle(target: str): def test_format_function_call_filter_path_in_fresh_interpreter(): - """Exercise the lazy ``FilterClauseExpression`` import path in isolation. + """Exercise the lazy ``FilterClause`` import path in isolation. Importing the ``function`` mixin module first, then formatting a filtered function call on a fully composed dialect, must succeed. A top-level import - of ``FilterClauseExpression`` would make this fail with an ImportError or + of ``FilterClause`` would make this fail with an ImportError or AttributeError. """ body = ( diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_pivot_capabilities.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_pivot_capabilities.py index 8b1779b5b..a1ab547d5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_pivot_capabilities.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_pivot_capabilities.py @@ -4,7 +4,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import PivotExpression, UnpivotExpression +from rhosocial.activerecord.backend.expression import Pivot, Unpivot from rhosocial.activerecord.backend.impl.dummy.backend import DummyDialect pytestmark = [pytest.mark.feature, pytest.mark.backend] @@ -25,19 +25,19 @@ def test_switch_defaults(self): def test_renders(self): dialect = DummyDialect() - expr = PivotExpression(dialect, "SUM", "amount", "month", ["Jan", "Feb"], alias="p") + expr = Pivot(dialect, "SUM", "amount", "month", ["Jan", "Feb"], alias="p") sql, _ = expr.to_sql() assert sql == "PIVOT (SUM(\"amount\") FOR \"month\" IN ('Jan', 'Feb')) \"p\"" def test_default_renders(self): dialect = DummyDialect() - expr = PivotExpression(dialect, "SUM", "amount", "month", ["Jan"], default=0) + expr = Pivot(dialect, "SUM", "amount", "month", ["Jan"], default=0) sql, _ = expr.to_sql() assert sql.endswith("DEFAULT 0)") def test_raises_when_unsupported(self): dialect = NoPivotDialect() - expr = PivotExpression(dialect, "SUM", "amount", "month", ["Jan"]) + expr = Pivot(dialect, "SUM", "amount", "month", ["Jan"]) with pytest.raises(UnsupportedFeatureError, match="PIVOT"): expr.to_sql() @@ -46,19 +46,19 @@ class TestUnpivot: def test_renders_unspecified_without_nulls_clause(self): """Neither spelling set: no INCLUDE/EXCLUDE NULLS clause at all.""" dialect = DummyDialect() - expr = UnpivotExpression(dialect, "val", "col", ["a", "b"]) + expr = Unpivot(dialect, "val", "col", ["a", "b"]) sql, _ = expr.to_sql() assert sql == 'UNPIVOT ("val" FOR "col" IN ("a", "b"))' def test_renders_explicit_exclude_nulls(self): dialect = DummyDialect() - expr = UnpivotExpression(dialect, "val", "col", ["a", "b"], exclude_nulls=True) + expr = Unpivot(dialect, "val", "col", ["a", "b"], exclude_nulls=True) sql, _ = expr.to_sql() assert sql == 'UNPIVOT EXCLUDE NULLS ("val" FOR "col" IN ("a", "b"))' def test_renders_include_nulls_with_alias(self): dialect = DummyDialect() - expr = UnpivotExpression(dialect, "val", "col", ["a", "b"], include_nulls=True, alias="u") + expr = Unpivot(dialect, "val", "col", ["a", "b"], include_nulls=True, alias="u") sql, _ = expr.to_sql() assert sql == 'UNPIVOT INCLUDE NULLS ("val" FOR "col" IN ("a", "b")) "u"' @@ -67,12 +67,12 @@ def test_both_null_spellings_are_refused(self): with pytest.raises( ValueError, match="include_nulls and exclude_nulls are mutually exclusive" ): - UnpivotExpression( + Unpivot( dialect, "val", "col", ["a"], include_nulls=True, exclude_nulls=True ) def test_raises_when_unsupported(self): dialect = NoPivotDialect() - expr = UnpivotExpression(dialect, "val", "col", ["a"]) + expr = Unpivot(dialect, "val", "col", ["a"]) with pytest.raises(UnsupportedFeatureError, match="UNPIVOT"): expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_returning_capabilities.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_returning_capabilities.py index b743b7d0a..6fa4239a6 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_returning_capabilities.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_returning_capabilities.py @@ -16,7 +16,7 @@ from rhosocial.activerecord.backend.expression.core import ( Column, FunctionCall, - WildcardExpression, + AllColumnsWildcard, ) from rhosocial.activerecord.backend.expression.statements import ReturningClause from rhosocial.activerecord.backend.impl.dummy.backend import DummyDialect @@ -106,7 +106,7 @@ def test_column_renders(self): def test_wildcard_renders(self): dialect = DummyDialect() - clause = ReturningClause(dialect, expressions=[WildcardExpression(dialect)]) + clause = ReturningClause(dialect, expressions=[AllColumnsWildcard(dialect)]) sql, params = dialect.format_returning_clause(clause) assert sql == "RETURNING *" @@ -161,7 +161,7 @@ def test_alias_raises_when_unsupported(self): def test_wildcard_raises_when_unsupported(self): dialect = NoWildcardDialect() - clause = ReturningClause(dialect, expressions=[WildcardExpression(dialect)]) + clause = ReturningClause(dialect, expressions=[AllColumnsWildcard(dialect)]) with pytest.raises(UnsupportedFeatureError, match="wildcard in RETURNING"): dialect.format_returning_clause(clause) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_version_predicates.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_version_predicates.py new file mode 100644 index 000000000..74a6c06f9 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_version_predicates.py @@ -0,0 +1,125 @@ +# tests/rhosocial/activerecord_test/feature/backend/dialect/test_version_predicates.py +"""The version a capability check reads, and the answer it gets before adaptation. + +A capability predicate decides whether SQL may use a feature, so it has to +answer ``no`` rather than raise when the version is unknown: the caller is +asking because it cannot assume the feature is there. These tests pin that +contract down at the two layers that shape it -- the dialect, which resolves +the version, and an introspector, which reports it without connecting. +""" + +import sqlite3 + +import pytest + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.exceptions import DialectNotAdaptedException +from rhosocial.activerecord.backend.dialect.mixins.dql import DQLMixin +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + +pytestmark = [pytest.mark.feature, pytest.mark.backend] + +SQLITE_LIBRARY_VERSION = tuple(sqlite3.sqlite_version_info[:3]) + + +class BareDialect(DQLMixin, SQLDialectBase): + """A dialect that cannot name a version and knows nothing without a server.""" + + +class TestVersionWithoutAdaptation: + """The strict property keeps its contract; the predicates stay answerable.""" + + def test_strict_version_still_raises(self): + with pytest.raises(DialectNotAdaptedException): + _ = BareDialect().version + + def test_version_or_none_reports_nothing(self): + assert BareDialect().version_or_none is None + + def test_version_at_least_answers_no(self): + assert BareDialect().version_at_least((0, 0, 1)) is False + + def test_capability_predicate_answers_instead_of_raising(self): + # The DQL default mirrors PostgreSQL's 13+ gate; a bare dialect has no + # version, so the honest answer is "not confirmed" rather than an + # exception the caller would have to catch to learn the same thing. + assert BareDialect().supports_fetch_with_ties() is False + + def test_adapted_version_wins(self): + dialect = BareDialect() + dialect.version = (15, 2, 0) + assert dialect.version_or_none == (15, 2, 0) + assert dialect.version_at_least((13, 0, 0)) is True + assert dialect.supports_fetch_with_ties() is True + + +class TestSQLiteNamesItsVersionWithoutAdaptation: + """SQLite's engine is the driver's library, so the version is already known.""" + + def test_declared_version_is_the_library(self): + assert SQLiteDialect().version_or_none == SQLITE_LIBRARY_VERSION + + def test_strict_version_still_waits_for_adaptation(self): + # Capability checks may use the library version; the strict property + # must still refuse, because an unconfigured dialect has not been told + # which version the caller targets. + with pytest.raises(DialectNotAdaptedException): + _ = SQLiteDialect().version + + def test_foreign_key_predicate_answers_before_adaptation(self): + assert SQLiteDialect().supports_foreign_key_introspection() is True + + def test_declared_version_feeds_every_version_gated_predicate(self): + dialect = SQLiteDialect() + assert dialect.supports_basic_cte() is True + assert dialect.supports_window_functions() is True + assert dialect.supports_table_list_pragma() is True + + def test_adapted_version_overrides_the_library(self): + dialect = SQLiteDialect() + dialect.version = (3, 20, 0) + assert dialect.version_or_none == (3, 20, 0) + assert dialect.supports_returning_insert() is False + assert dialect.supports_window_functions() is False + + def test_every_sqlite_predicate_answers_before_adaptation(self): + # None of these may raise: callers ask them in order to decide whether + # a feature may be used, and "unknown version" must read as "no". + dialect = SQLiteDialect() + predicates = ( + "supports_virtual_table", + "supports_rtree", + "supports_fts5", + "supports_geopoly", + "supports_math_functions", + "supports_json1_extension", + "supports_foreign_key_introspection", + "supports_table_list_pragma", + "supports_table_xinfo_pragma", + ) + answers = {name: getattr(dialect, name)() for name in predicates} + assert all(isinstance(value, bool) for value in answers.values()) + + +class TestIntrospectorReportsVersionWithoutIO: + """`server_version` is the parser-facing answer: no raise, no connection.""" + + def test_backend_caches_nothing_before_it_probes(self): + backend = SQLiteBackend(database=":memory:") + assert backend.get_cached_server_version() is None + + def test_introspector_reads_the_dialects_declared_version(self): + backend = SQLiteBackend(database=":memory:") + assert backend.introspector.server_version == SQLITE_LIBRARY_VERSION + + def test_introspector_falls_back_to_the_backends_cache(self): + backend = SQLiteBackend(database=":memory:") + backend._dialect = BareDialect() + backend._server_version_cache = (8, 4, 1) + assert backend.introspector.server_version == (8, 4, 1) + + def test_introspector_reports_nothing_rather_than_raising(self): + backend = SQLiteBackend(database=":memory:") + backend._dialect = BareDialect() + assert backend.introspector.server_version is None diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_protocol.py index 377cf4947..f9e19e685 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_protocol.py @@ -1,13 +1,21 @@ # tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_protocol.py """ -Test for WildcardSupport protocol implementation. +Test for the WildcardSupport protocol in its two grammar positions. -This test creates a dialect that only supports wildcard expressions and verifies that -the format_wildcard method works correctly while other features remain unsupported. +The asterisk is one lexical token serving two contracts (spec 4.10): the +select-list/RETURNING expansion and the aggregate row count. A dialect that +only supports wildcards implements both formatter entries; each names its +own formatter, so overriding one spelling never disturbs the other. """ from rhosocial.activerecord.backend.dialect import SQLDialectBase, WildcardSupport from rhosocial.activerecord.backend.dialect.mixins import ExpressionMixin +from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + RowCountWildcard, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef class WildcardOnlyDialect(SQLDialectBase, ExpressionMixin, WildcardSupport): @@ -24,21 +32,64 @@ def test_wildcard_only_dialect_supports_features(): assert isinstance(dialect, WildcardSupport) -def test_format_wildcard_works(): - """Test that format_wildcard method works in wildcard-only dialect.""" - dialect = WildcardOnlyDialect() +class TestAllColumnsWildcardFormatting: + """The select-list entry: bare ``*`` and the relation-qualified ``rel.*``.""" + + def test_bare_wildcard(self): + dialect = WildcardOnlyDialect() + result = dialect.format_all_columns_wildcard(AllColumnsWildcard(dialect)) + assert isinstance(result, tuple) + assert len(result) == 2 + assert result[0] == "*" + assert result[1] == () + + def test_qualified_wildcard(self): + """The qualified form needs a dialect that renders relation objects.""" + from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + dialect = DummyDialect() + node = AllColumnsWildcard( + dialect, relation=NamedRelationRef(dialect, Table(dialect, "users")) + ) + result = dialect.format_all_columns_wildcard(node) + assert result[0] == '"users".*' + assert result[1] == () + + def test_alias_is_the_qualifier(self): + """A aliased reference is known by its alias in the query scope.""" + from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + dialect = DummyDialect() + node = AllColumnsWildcard( + dialect, relation=NamedRelationRef(dialect, Table(dialect, "users"), alias="u") + ) + result = dialect.format_all_columns_wildcard(node) + assert result[0] == '"u".*' + + def test_format_method_declares_the_entry(self): + dialect = WildcardOnlyDialect() + assert AllColumnsWildcard(dialect).format_method == "format_all_columns_wildcard" + + +class TestRowCountWildcardFormatting: + """The aggregate-parameter entry: the bare asterisk and nothing else.""" + + def test_bare_star(self): + dialect = WildcardOnlyDialect() + result = dialect.format_row_count_wildcard(RowCountWildcard(dialect)) + assert isinstance(result, tuple) + assert len(result) == 2 + assert result[0] == "*" + assert result[1] == () - from rhosocial.activerecord.backend.expression.core import WildcardExpression + def test_format_method_declares_the_entry(self): + dialect = WildcardOnlyDialect() + assert RowCountWildcard(dialect).format_method == "format_row_count_wildcard" - # Test with no table - result = dialect.format_wildcard(WildcardExpression(dialect)) - assert isinstance(result, tuple) - assert len(result) == 2 - assert result[0] == "*" + def test_no_relation_slot(self): + """The row-count wildcard carries no relation by contract (4.10.2).""" + import pytest - # Test with table - result = dialect.format_wildcard(WildcardExpression(dialect, table="users")) - assert isinstance(result, tuple) - assert len(result) == 2 - assert "users" in result[0] - assert "*" in result[0] + dialect = WildcardOnlyDialect() + with pytest.raises(TypeError): + RowCountWildcard(dialect, relation=None) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_quoting.py b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_quoting.py new file mode 100644 index 000000000..14df32281 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dialect/test_wildcard_quoting.py @@ -0,0 +1,79 @@ +"""The wildcard quotes through its relation, not its own flags. + +The old node carried per-role quoting fields; those are gone. Quoting now +belongs to the relation object the wildcard qualifies. The schema-qualified +forms need a dialect whose namespace probes answer yes -- neither core +dialect models a schema layer, and the render must refuse there rather than +emit a name the engine cannot resolve. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import AllColumnsWildcard +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + +def schema_dialect(): + """A dummy whose namespace probes admit schema-qualified names.""" + return type( + "SchemaQualifyingDialect", + (DummyDialect,), + {"supports_schema_qualification": lambda self: True}, + )() + + +class TestWildcardIdentifierQuoting: + """Quoting decisions ride on the qualified relation's own identity.""" + + def test_bare_wildcard_has_no_qualifier(self): + d = DummyDialect() + w = AllColumnsWildcard(d) + assert w.relation is None + sql, params = d.format_all_columns_wildcard(w) + assert sql == "*" + assert params == () + + def test_relation_quoted_by_default(self): + d = DummyDialect() + w = AllColumnsWildcard(d, relation=NamedRelationRef(d, Table(d, "users"))) + sql, params = d.format_all_columns_wildcard(w) + assert sql == '"users".*' + assert params == () + + def test_name_quoting_independent(self): + d = schema_dialect() + w = AllColumnsWildcard( + d, + relation=NamedRelationRef( + d, + Table(d, "users", schema_name="public", name_need_quote=False, schema_need_quote=True), + ), + ) + sql, params = d.format_all_columns_wildcard(w) + assert sql == '"public".users.*' + assert params == () + + def test_schema_quoting_independent(self): + d = schema_dialect() + w = AllColumnsWildcard( + d, + relation=NamedRelationRef( + d, + Table(d, "users", schema_name="public", name_need_quote=True, schema_need_quote=False), + ), + ) + sql, params = d.format_all_columns_wildcard(w) + assert sql == 'public."users".*' + assert params == () + + def test_schema_refused_where_unmodeled(self): + """A schema qualifier on a dialect without one is an explicit failure.""" + d = DummyDialect() + w = AllColumnsWildcard( + d, relation=NamedRelationRef(d, Table(d, "users", schema_name="public")) + ) + with pytest.raises(UnsupportedFeatureError, match="schema-qualified"): + d.format_all_columns_wildcard(w) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_member_completeness.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_member_completeness.py index 72b6d91b0..f35cac678 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_member_completeness.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_member_completeness.py @@ -171,3 +171,42 @@ def test_positive_and_negative_lists_partition_all_protocols(self): f"Either implement them or add to the negative list." ) + +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(DummyDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.dummy" + 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}" + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_support.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_support.py index 7bcecfa7f..10cafd870 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_support.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_dummy_protocol_support.py @@ -184,9 +184,6 @@ def test_set_operation_support_methods(self, dialect): assert dialect.supports_union_all() is True assert dialect.supports_intersect() is True assert dialect.supports_except() is True - assert dialect.supports_set_operation_order_by() is True - assert dialect.supports_set_operation_limit_offset() is True - assert dialect.supports_set_operation_for_update() is True # endregion diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_function_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_function_mixin.py index 13d5a3613..3850b95e3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_function_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_function_mixin.py @@ -10,9 +10,9 @@ class TestFunctionMixinFormatMethods: def test_format_create_function_basic(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement basic case.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "calculate_total")) + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "calculate_total")) sql, params = dummy_dialect.format_create_function_statement(create_func) assert "CREATE FUNCTION" in sql @@ -22,9 +22,9 @@ def test_format_create_function_basic(self, dummy_dialect: DummyDialect): def test_format_create_function_with_parameters(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with parameters.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "add_numbers"), parameters=[{"name": "a", "type": "INTEGER"}, {"name": "b", "type": "INTEGER"}], @@ -40,9 +40,9 @@ def test_format_create_function_with_parameters(self, dummy_dialect: DummyDialec def test_format_create_function_with_returns(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with RETURNS clause.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "get_count"), returns="INTEGER") + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "get_count"), returns="INTEGER") sql, params = dummy_dialect.format_create_function_statement(create_func) assert "RETURNS INTEGER" in sql @@ -50,9 +50,9 @@ def test_format_create_function_with_returns(self, dummy_dialect: DummyDialect): def test_format_create_function_with_language(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with LANGUAGE clause.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "plpgsql_func"), language="plpgsql") + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "plpgsql_func"), language="plpgsql") sql, params = dummy_dialect.format_create_function_statement(create_func) assert "LANGUAGE plpgsql" in sql @@ -60,9 +60,9 @@ def test_format_create_function_with_language(self, dummy_dialect: DummyDialect) def test_format_create_function_with_body(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with function body.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "multiply"), parameters=[{"name": "x", "type": "INTEGER"}, {"name": "y", "type": "INTEGER"}], @@ -77,9 +77,9 @@ def test_format_create_function_with_body(self, dummy_dialect: DummyDialect): def test_format_create_function_or_replace(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with OR REPLACE.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "existing_func"), or_replace=True) + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "existing_func"), or_replace=True) sql, params = dummy_dialect.format_create_function_statement(create_func) assert "CREATE FUNCTION" in sql @@ -89,9 +89,9 @@ def test_format_create_function_or_replace(self, dummy_dialect: DummyDialect): def test_format_create_function_full(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with all options.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "complex_func"), parameters=[{"name": "input", "type": "TEXT"}, {"name": "multiplier", "type": "INTEGER"}], @@ -115,9 +115,9 @@ def test_format_create_function_full(self, dummy_dialect: DummyDialect): def test_format_create_function_parameter_type_only(self, dummy_dialect: DummyDialect): """Tests format_create_function_statement with type-only parameters.""" - from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression + from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "type_only_func"), parameters=[{"type": "INTEGER"}, {"type": "TEXT"}] ) sql, params = dummy_dialect.format_create_function_statement(create_func) @@ -128,9 +128,9 @@ def test_format_create_function_parameter_type_only(self, dummy_dialect: DummyDi def test_format_drop_function_basic(self, dummy_dialect: DummyDialect): """Tests format_drop_function_statement basic case.""" - from rhosocial.activerecord.backend.expression.statements import DropFunctionExpression + from rhosocial.activerecord.backend.expression.statements import DropFunctionStatement - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "old_function")) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "old_function")) sql, params = dummy_dialect.format_drop_function_statement(drop_func) assert "DROP FUNCTION" in sql @@ -139,9 +139,9 @@ def test_format_drop_function_basic(self, dummy_dialect: DummyDialect): def test_format_drop_function_if_exists(self, dummy_dialect: DummyDialect): """Tests format_drop_function_statement with IF EXISTS.""" - from rhosocial.activerecord.backend.expression.statements import DropFunctionExpression + from rhosocial.activerecord.backend.expression.statements import DropFunctionStatement - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "maybe_function"), if_exists=True) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "maybe_function"), if_exists=True) sql, params = dummy_dialect.format_drop_function_statement(drop_func) assert "DROP FUNCTION IF EXISTS" in sql @@ -150,9 +150,9 @@ def test_format_drop_function_if_exists(self, dummy_dialect: DummyDialect): def test_format_drop_function_with_parameters(self, dummy_dialect: DummyDialect): """Tests format_drop_function_statement with parameter types.""" - from rhosocial.activerecord.backend.expression.statements import DropFunctionExpression + from rhosocial.activerecord.backend.expression.statements import DropFunctionStatement - drop_func = DropFunctionExpression( + drop_func = DropFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "overloaded_func"), parameters=["INTEGER", "TEXT"] ) sql, params = dummy_dialect.format_drop_function_statement(drop_func) @@ -164,9 +164,9 @@ def test_format_drop_function_with_parameters(self, dummy_dialect: DummyDialect) def test_format_drop_function_with_cascade(self, dummy_dialect: DummyDialect): """Tests format_drop_function_statement with CASCADE.""" - from rhosocial.activerecord.backend.expression.statements import DropFunctionExpression + from rhosocial.activerecord.backend.expression.statements import DropFunctionStatement - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "dependent_func"), cascade=True) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "dependent_func"), cascade=True) sql, params = dummy_dialect.format_drop_function_statement(drop_func) assert "DROP FUNCTION" in sql @@ -175,9 +175,9 @@ def test_format_drop_function_with_cascade(self, dummy_dialect: DummyDialect): def test_format_drop_function_all_options(self, dummy_dialect: DummyDialect): """Tests format_drop_function_statement with all options.""" - from rhosocial.activerecord.backend.expression.statements import DropFunctionExpression + from rhosocial.activerecord.backend.expression.statements import DropFunctionStatement - drop_func = DropFunctionExpression( + drop_func = DropFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "complex_func"), if_exists=True, parameters=["INTEGER", "INTEGER"], cascade=True ) sql, params = dummy_dialect.format_drop_function_statement(drop_func) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_ilike_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_ilike_mixin.py index 71dcc0836..330161c83 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_ilike_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_ilike_mixin.py @@ -2,11 +2,11 @@ """Tests for ILIKEMixin format methods.""" from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression import Column, ILIKEExpression +from rhosocial.activerecord.backend.expression import Column, ILikePredicate class TestILIKEMixinFormatMethods: - """Tests for ILIKEMixin format_ilike_expression method. + """Tests for ILIKEMixin format_ilike_predicate method. Note: The default implementation uses LOWER() function for case-insensitive comparison since the DummyDialect follows the mixin's default behavior. @@ -14,7 +14,7 @@ class TestILIKEMixinFormatMethods: def test_format_ilike_basic(self, dummy_dialect: DummyDialect): """Tests basic ILIKE expression formatting (uses LOWER()).""" - expr = ILIKEExpression(dummy_dialect, Column(dummy_dialect, "name"), "John%") + expr = ILikePredicate(dummy_dialect, Column(dummy_dialect, "name"), "John%") sql, params = expr.to_sql() assert 'LOWER("name")' in sql @@ -24,7 +24,7 @@ def test_format_ilike_basic(self, dummy_dialect: DummyDialect): def test_format_ilike_with_negate(self, dummy_dialect: DummyDialect): """Tests ILIKE expression with NOT (negate=True).""" - expr = ILIKEExpression(dummy_dialect, Column(dummy_dialect, "name"), "John%", negate=True) + expr = ILikePredicate(dummy_dialect, Column(dummy_dialect, "name"), "John%", negate=True) sql, params = expr.to_sql() assert 'LOWER("name")' in sql @@ -33,7 +33,7 @@ def test_format_ilike_with_negate(self, dummy_dialect: DummyDialect): def test_format_ilike_with_column_expression(self, dummy_dialect: DummyDialect): """Tests ILIKE expression with Column expression.""" - expr = ILIKEExpression(dummy_dialect, Column(dummy_dialect, "email"), "%@example.com") + expr = ILikePredicate(dummy_dialect, Column(dummy_dialect, "email"), "%@example.com") sql, params = expr.to_sql() assert '"email"' in sql @@ -42,7 +42,7 @@ def test_format_ilike_with_column_expression(self, dummy_dialect: DummyDialect): def test_format_ilike_with_negate_and_column(self, dummy_dialect: DummyDialect): """Tests NOT ILIKE expression with Column expression.""" - expr = ILIKEExpression( + expr = ILikePredicate( dummy_dialect, Column(dummy_dialect, "status"), "active%", negate=True ) sql, params = expr.to_sql() @@ -53,7 +53,7 @@ def test_format_ilike_with_negate_and_column(self, dummy_dialect: DummyDialect): def test_format_ilike_case_insensitive_matching(self, dummy_dialect: DummyDialect): """Tests that ILIKE pattern matching converts to lowercase.""" - expr = ILIKEExpression(dummy_dialect, Column(dummy_dialect, "name"), "John%") + expr = ILikePredicate(dummy_dialect, Column(dummy_dialect, "name"), "John%") sql, params = expr.to_sql() assert "LIKE" in sql @@ -61,7 +61,7 @@ def test_format_ilike_case_insensitive_matching(self, dummy_dialect: DummyDialec def test_format_ilike_string_column(self, dummy_dialect: DummyDialect): """Tests ILIKE with a bare string column name held by the expression.""" - expr = ILIKEExpression(dummy_dialect, "username", "admin%") + expr = ILikePredicate(dummy_dialect, "username", "admin%") sql, params = expr.to_sql() assert '"username"' in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_index_mixin_fulltext.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_index_mixin_fulltext.py index b39b56cd5..0b0a31f35 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_index_mixin_fulltext.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_index_mixin_fulltext.py @@ -2,7 +2,7 @@ """Tests for IndexMixin fulltext format methods.""" from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatchExpression +from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatch class TestIndexMixinFulltextFormatMethods: @@ -10,7 +10,7 @@ class TestIndexMixinFulltextFormatMethods: def test_format_fulltext_match_natural_language(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH ... AGAINST in natural language mode.""" - expr = FulltextMatchExpression(dummy_dialect, columns=["title", "content"], search_term="database") + expr = FulltextMatch(dummy_dialect, columns=["title", "content"], search_term="database") sql, params = dummy_dialect.format_fulltext_match(expr) assert 'MATCH("title", "content")' in sql @@ -20,7 +20,7 @@ def test_format_fulltext_match_natural_language(self, dummy_dialect: DummyDialec def test_format_fulltext_match_boolean_mode(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH ... AGAINST in boolean mode.""" - expr = FulltextMatchExpression( + expr = FulltextMatch( dummy_dialect, columns=["content"], search_term="+python -java", mode="BOOLEAN" ) sql, params = dummy_dialect.format_fulltext_match(expr) @@ -32,7 +32,7 @@ def test_format_fulltext_match_boolean_mode(self, dummy_dialect: DummyDialect): def test_format_fulltext_match_query_expansion(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH ... AGAINST with query expansion.""" - expr = FulltextMatchExpression( + expr = FulltextMatch( dummy_dialect, columns=["title"], search_term="database", mode="QUERY EXPANSION" ) sql, params = dummy_dialect.format_fulltext_match(expr) @@ -44,7 +44,7 @@ def test_format_fulltext_match_query_expansion(self, dummy_dialect: DummyDialect def test_format_fulltext_match_with_query_expansion(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH ... AGAINST with 'WITH QUERY EXPANSION' mode string.""" - expr = FulltextMatchExpression( + expr = FulltextMatch( dummy_dialect, columns=["content"], search_term="search", mode="WITH QUERY EXPANSION" ) sql, params = dummy_dialect.format_fulltext_match(expr) @@ -55,7 +55,7 @@ def test_format_fulltext_match_with_query_expansion(self, dummy_dialect: DummyDi def test_format_fulltext_match_multiple_columns(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH with multiple columns.""" - expr = FulltextMatchExpression( + expr = FulltextMatch( dummy_dialect, columns=["title", "description", "tags"], search_term="python programming" ) sql, params = dummy_dialect.format_fulltext_match(expr) @@ -68,7 +68,7 @@ def test_format_fulltext_match_multiple_columns(self, dummy_dialect: DummyDialec def test_format_fulltext_match_single_column(self, dummy_dialect: DummyDialect): """Tests FULLTEXT MATCH with single column.""" - expr = FulltextMatchExpression(dummy_dialect, columns=["content"], search_term="test") + expr = FulltextMatch(dummy_dialect, columns=["content"], search_term="test") sql, params = dummy_dialect.format_fulltext_match(expr) assert 'MATCH("content")' in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_sqlxml_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_sqlxml_mixin.py index fb3c4d72c..e57ef311b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_sqlxml_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_sqlxml_mixin.py @@ -6,17 +6,17 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import SQLXMLMixin +from rhosocial.activerecord.backend.expression.value_types import TextValue, XMLValue from rhosocial.activerecord.backend.expression import ( Column, Literal, OrderByClause, - StringValueExpression, + PassingClause, XMLParseDocumentType, XMLParseExpression, XMLPassingMechanism, XMLStandaloneOption, XMLTableColumn, - XMLValueExpression, xmlagg, xmlattributes, xmlcomment, @@ -96,9 +96,9 @@ def test_xmlparse_expression_can_be_constructed_directly(self, dummy_dialect: Du def test_xml_document_factories_return_xml_values(self, dummy_dialect: DummyDialect): """The document constructors declare the value class they produce. - Each one builds an XML document, so the result is an - ``XMLValueExpression`` -- the class is the statement, not a tag, and - the SQL assertions elsewhere in this file are unchanged by it. + Each one builds an XML document, so the result carries the XML surface + -- the class is the statement, not a tag, and the SQL assertions + elsewhere in this file are unchanged by it. """ expressions = [ xmlparse(dummy_dialect, ""), @@ -112,14 +112,14 @@ def test_xml_document_factories_return_xml_values(self, dummy_dialect: DummyDial xmlquery(dummy_dialect, "/root"), ] for expr in expressions: - assert isinstance(expr, XMLValueExpression), type(expr).__name__ + assert isinstance(expr, XMLValue), type(expr).__name__ def test_xmlserialize_factory_returns_the_text_value(self, dummy_dialect: DummyDialect): """Serialization yields the character value it names, not XML again.""" expr = xmlserialize(dummy_dialect, "", "TEXT") - assert isinstance(expr, StringValueExpression) - assert not isinstance(expr, XMLValueExpression) + assert isinstance(expr, TextValue) + assert not isinstance(expr, XMLValue) def test_xmlserialize_generates_standard_sql(self, dummy_dialect: DummyDialect): expr = xmlserialize(dummy_dialect, Column(dummy_dialect, "payload"), "VARCHAR(100)") @@ -240,6 +240,31 @@ def test_xmltable_generates_standard_sql(self, dummy_dialect: DummyDialect): ) assert params == ("/root/item", "@id", "name", "unknown") + def test_passing_clause_is_a_node_with_its_own_formatter(self, dummy_dialect: DummyDialect): + """The clause renders through the dialect's public formatter.""" + clause = PassingClause( + dummy_dialect, + [Column(dummy_dialect, "payload")], + XMLPassingMechanism.BY_REF, + ) + + assert clause.format_method == "format_passing_clause" + assert clause.to_sql() == ('PASSING BY REF "payload"', ()) + + def test_empty_passing_clause_renders_nothing(self, dummy_dialect: DummyDialect): + assert PassingClause(dummy_dialect).to_sql() == ("", ()) + + def test_passing_clause_formatter_is_overridable(self, dummy_dialect: DummyDialect): + """A backend re-spells PASSING by overriding one formatter.""" + + class RefSpelling(DummyDialect): + def format_passing_clause(self, clause: PassingClause): + parts = [expression.to_sql()[0] for expression in clause.expressions] + return "PASSING BY REF " + " ".join(parts), () + + clause = PassingClause(RefSpelling(), [Column(RefSpelling(), "a"), Column(RefSpelling(), "b")]) + assert clause.to_sql()[0] == 'PASSING BY REF "a" "b"' + @pytest.mark.parametrize( ("support_method", "expr", "feature"), [ diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_trigger_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_trigger_mixin.py index 73a6c41b7..e61801ae1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy/test_trigger_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy/test_trigger_mixin.py @@ -13,12 +13,12 @@ class TestTriggerMixinFormatMethods: def test_format_create_trigger_basic(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement basic case.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "audit_trigger"), table=Table(dummy_dialect, "users"), @@ -42,7 +42,7 @@ def test_format_create_trigger_basic(self, dummy_dialect: DummyDialect): def test_format_create_trigger_with_condition(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement with WHEN condition.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, ) @@ -53,7 +53,7 @@ def test_format_create_trigger_with_condition(self, dummy_dialect: DummyDialect) Column(dummy_dialect, "status"), Literal(dummy_dialect, "active") ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "status_trigger"), table=Table(dummy_dialect, "orders"), @@ -71,13 +71,13 @@ def test_format_create_trigger_with_condition(self, dummy_dialect: DummyDialect) def test_format_create_trigger_statement_level(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement with FOR EACH STATEMENT.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, TriggerLevel, ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "bulk_trigger"), table=Table(dummy_dialect, "logs"), @@ -94,12 +94,12 @@ def test_format_create_trigger_statement_level(self, dummy_dialect: DummyDialect def test_format_create_trigger_with_referencing(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement with REFERENCING clause.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "ref_trigger"), table=Table(dummy_dialect, "audit_log"), @@ -117,12 +117,12 @@ def test_format_create_trigger_with_referencing(self, dummy_dialect: DummyDialec def test_format_create_trigger_if_not_exists(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement with IF NOT EXISTS.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "safe_trigger"), table=Table(dummy_dialect, "data"), @@ -139,12 +139,12 @@ def test_format_create_trigger_if_not_exists(self, dummy_dialect: DummyDialect): def test_format_create_trigger_update_of_columns(self, dummy_dialect: DummyDialect): """Tests format_create_trigger_statement with UPDATE OF columns.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, ) - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "col_trigger"), table=Table(dummy_dialect, "products"), @@ -162,9 +162,9 @@ def test_format_create_trigger_update_of_columns(self, dummy_dialect: DummyDiale def test_format_drop_trigger_basic(self, dummy_dialect: DummyDialect): """Tests format_drop_trigger_statement basic case.""" - from rhosocial.activerecord.backend.expression.statements import DropTriggerExpression + from rhosocial.activerecord.backend.expression.statements import DropTriggerStatement - drop_trigger = DropTriggerExpression(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger"), table=Table(dummy_dialect, "users")) + drop_trigger = DropTriggerStatement(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger"), table=Table(dummy_dialect, "users")) sql, params = dummy_dialect.format_drop_trigger_statement(drop_trigger) assert "DROP TRIGGER" in sql @@ -175,9 +175,9 @@ def test_format_drop_trigger_basic(self, dummy_dialect: DummyDialect): def test_format_drop_trigger_if_exists(self, dummy_dialect: DummyDialect): """Tests format_drop_trigger_statement with IF EXISTS.""" - from rhosocial.activerecord.backend.expression.statements import DropTriggerExpression + from rhosocial.activerecord.backend.expression.statements import DropTriggerStatement - drop_trigger = DropTriggerExpression( + drop_trigger = DropTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "maybe_trigger"), table=Table(dummy_dialect, "temp"), if_exists=True ) sql, params = dummy_dialect.format_drop_trigger_statement(drop_trigger) @@ -188,9 +188,9 @@ def test_format_drop_trigger_if_exists(self, dummy_dialect: DummyDialect): def test_format_drop_trigger_without_table(self, dummy_dialect: DummyDialect): """Tests format_drop_trigger_statement without table name.""" - from rhosocial.activerecord.backend.expression.statements import DropTriggerExpression + from rhosocial.activerecord.backend.expression.statements import DropTriggerStatement - drop_trigger = DropTriggerExpression(dummy_dialect, trigger=Trigger(dummy_dialect, "standalone_trigger")) + drop_trigger = DropTriggerStatement(dummy_dialect, trigger=Trigger(dummy_dialect, "standalone_trigger")) sql, params = dummy_dialect.format_drop_trigger_statement(drop_trigger) assert "DROP TRIGGER" in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_function_window.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_function_window.py index 501ecaa3d..45214fbb7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_function_window.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_function_window.py @@ -1,4 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_function_window.py +from rhosocial.activerecord.backend.expression.functions import cast import pytest from rhosocial.activerecord.backend.expression.types import CustomType @@ -6,12 +7,12 @@ Column, Literal, FunctionCall, - QueryExpression, + QueryStatement, CaseExpression, ComparisonPredicate, - ExistsExpression, - AnyExpression, - AllExpression, + ExistsPredicate, + AnyPredicate, + AllPredicate, SelectModifier, ForUpdateClause, WindowFrameSpecification, @@ -87,7 +88,7 @@ def test_cast_method(self, dummy_dialect: DummyDialect, expr_data, target_type, else: return - cast_expr = expr.cast(CustomType(dummy_dialect, raw=target_type)) + cast_expr = cast(dummy_dialect, expr, CustomType(dummy_dialect, raw=target_type)) sql, params = cast_expr.to_sql() assert sql == expected_sql assert params == expected_params @@ -103,7 +104,7 @@ def test_cast_method_on_arithmetic_result(self, dummy_dialect: DummyDialect): # --- EXISTS and related expressions --- def test_exists_expression(self, dummy_dialect: DummyDialect): """Tests EXISTS predicate.""" - subquery = QueryExpression( + subquery = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "orders")), @@ -114,14 +115,14 @@ def test_exists_expression(self, dummy_dialect: DummyDialect): ), ), ) - exists_expr = ExistsExpression(dummy_dialect, subquery) + exists_expr = ExistsPredicate(dummy_dialect, subquery) sql, params = exists_expr.to_sql() expected = 'EXISTS (SELECT "id" FROM "orders" WHERE "user_id" = "u"."id")' assert sql == expected assert params == () def test_exists_expression_invalid_subquery_type_raises_error(self, dummy_dialect: DummyDialect): - """Tests that ExistsExpression raises TypeError for invalid subquery parameter type.""" + """Tests that ExistsPredicate raises TypeError for invalid subquery parameter type.""" # Create an object that is neither Subquery nor BaseExpression (doesn't have to_sql method) class InvalidObject: @@ -133,12 +134,12 @@ class InvalidObject: with pytest.raises( TypeError, match=r"subquery must be Subquery or BaseExpression, got " ): - ExistsExpression(dummy_dialect, invalid_obj) + ExistsPredicate(dummy_dialect, invalid_obj) def test_any_all_expressions(self, dummy_dialect: DummyDialect): """Tests ANY and ALL expressions.""" # ANY expression - any_expr = AnyExpression( + any_expr = AnyPredicate( dummy_dialect, Column(dummy_dialect, "value"), ">", Literal(dummy_dialect, [10, 20, 30]) ) sql, params = any_expr.to_sql() @@ -147,7 +148,7 @@ def test_any_all_expressions(self, dummy_dialect: DummyDialect): assert params == ((10, 20, 30),) # ALL expression - all_expr = AllExpression( + all_expr = AllPredicate( dummy_dialect, Column(dummy_dialect, "score"), ">=", Literal(dummy_dialect, [85, 90, 95]) ) sql, params = all_expr.to_sql() @@ -155,7 +156,7 @@ def test_any_all_expressions(self, dummy_dialect: DummyDialect): assert sql == expected assert params == ((85, 90, 95),) - # --- QueryExpression with DISTINCT/ALL modifiers --- + # --- QueryStatement with DISTINCT/ALL modifiers --- @pytest.mark.parametrize( "modifier, expected_prefix", [ @@ -165,8 +166,8 @@ def test_any_all_expressions(self, dummy_dialect: DummyDialect): ], ) def test_query_expression_select_modifier(self, dummy_dialect: DummyDialect, modifier, expected_prefix): - """Tests QueryExpression with DISTINCT/ALL modifiers.""" - query = QueryExpression( + """Tests QueryStatement with DISTINCT/ALL modifiers.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name"), Column(dummy_dialect, "category")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")), @@ -179,11 +180,11 @@ def test_query_expression_select_modifier(self, dummy_dialect: DummyDialect, mod # --- FOR UPDATE clause --- def test_query_expression_with_for_update_clause(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with FOR UPDATE clause.""" + """Tests QueryStatement with FOR UPDATE clause.""" for_update_clause = ForUpdateClause( dummy_dialect, of_columns=[Column(dummy_dialect, "id"), "name"], nowait=False, skip_locked=True ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -194,11 +195,11 @@ def test_query_expression_with_for_update_clause(self, dummy_dialect: DummyDiale assert params == () def test_query_expression_with_for_update_nowait(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with FOR UPDATE NOWAIT.""" + """Tests QueryStatement with FOR UPDATE NOWAIT.""" for_update_clause = ForUpdateClause( dummy_dialect, of_columns=[Column(dummy_dialect, "id")], nowait=True, skip_locked=False ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "locks")), @@ -311,7 +312,7 @@ def test_query_with_window_clause(self, dummy_dialect: DummyDialect): window_clause = WindowClause(dummy_dialect, definitions=[window_def1, window_def2]) # Create a query that would reference these named windows - QueryExpression( + QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "employee_name"), @@ -319,7 +320,7 @@ def test_query_with_window_clause(self, dummy_dialect: DummyDialect): FunctionCall(dummy_dialect, "ROW_NUMBER", window_spec="dept_ranking"), ], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "employees")), - # Note: The WindowClause would need to be integrated into QueryExpression to be fully functional + # Note: The WindowClause would need to be integrated into QueryStatement to be fully functional ) # Since we are testing the window clause itself, let's test it independently diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_functions.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_functions.py index c0d07e1c4..d07f61ca2 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_advanced_functions.py @@ -3,14 +3,15 @@ Tests for the advanced SQL function expression components in advanced_functions.py """ -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.functions import cast +from rhosocial.activerecord.backend.expression import Column, Literal, NumericColumn, StringColumn from rhosocial.activerecord.backend.expression.advanced_functions import ( CaseExpression, - ExistsExpression, - AnyExpression, - AllExpression, - JSONDocumentExpression, - ArrayExpression, + ExistsPredicate, + AnyPredicate, + AllPredicate, + JSONDocumentValue, + ArrayOperation, OrderedSetAggregation, ) from rhosocial.activerecord.backend.expression.core import FunctionCall, Subquery @@ -24,7 +25,7 @@ class TestCaseExpression: def test_case_expression_basic(self, dummy_dialect: DummyDialect): """Test basic CASE expression functionality.""" # Create a basic CASE expression with one condition-result pair - condition = Column(dummy_dialect, "age") > Literal(dummy_dialect, 18) + condition = NumericColumn(dummy_dialect, "age") > Literal(dummy_dialect, 18) result = Literal(dummy_dialect, "adult") case_expr = CaseExpression(dummy_dialect, cases=[(condition, result)]) sql, params = case_expr.to_sql() @@ -36,7 +37,7 @@ def test_case_expression_with_value(self, dummy_dialect: DummyDialect): """Test CASE expression with value.""" value_expr = Column(dummy_dialect, "status") # Create a basic CASE expression with one condition-result pair - condition = Column(dummy_dialect, "status") == Literal(dummy_dialect, "active") + condition = StringColumn(dummy_dialect, "status") == Literal(dummy_dialect, "active") result = Literal(dummy_dialect, "ACTIVE_USER") case_expr = CaseExpression(dummy_dialect, value=value_expr, cases=[(condition, result)]) sql, params = case_expr.to_sql() @@ -46,7 +47,7 @@ def test_case_expression_with_value(self, dummy_dialect: DummyDialect): def test_case_expression_with_cases(self, dummy_dialect: DummyDialect): """Test CASE expression with cases.""" - condition = Column(dummy_dialect, "age") > Literal(dummy_dialect, 18) + condition = NumericColumn(dummy_dialect, "age") > Literal(dummy_dialect, 18) result = Literal(dummy_dialect, "adult") case_expr = CaseExpression(dummy_dialect, cases=[(condition, result)]) sql, params = case_expr.to_sql() @@ -56,7 +57,7 @@ def test_case_expression_with_cases(self, dummy_dialect: DummyDialect): def test_case_expression_with_else_result(self, dummy_dialect: DummyDialect): """Test CASE expression with else result.""" - condition = Column(dummy_dialect, "age") > Literal(dummy_dialect, 18) + condition = NumericColumn(dummy_dialect, "age") > Literal(dummy_dialect, 18) result = Literal(dummy_dialect, "adult") else_result = Literal(dummy_dialect, "minor") case_expr = CaseExpression(dummy_dialect, cases=[(condition, result)], else_result=else_result) @@ -66,7 +67,7 @@ def test_case_expression_with_else_result(self, dummy_dialect: DummyDialect): def test_case_expression_with_alias(self, dummy_dialect: DummyDialect): """Test CASE expression with alias.""" - condition = Column(dummy_dialect, "status") == Literal(dummy_dialect, "active") + condition = StringColumn(dummy_dialect, "status") == Literal(dummy_dialect, "active") result = Literal(dummy_dialect, 1) else_result = Literal(dummy_dialect, 0) case_expr = CaseExpression( @@ -91,7 +92,7 @@ def test_cast_expression_basic(self, dummy_dialect: DummyDialect): def test_cast_expression_with_literal(self, dummy_dialect: DummyDialect): """Test CAST expression with literal value.""" literal = Literal(dummy_dialect, "123") - expr = literal.cast(IntegerType(dummy_dialect)) + expr = cast(dummy_dialect, literal, IntegerType(dummy_dialect)) sql, params = expr.to_sql() assert "CAST(" in sql assert "AS INTEGER" in sql @@ -99,12 +100,12 @@ def test_cast_expression_with_literal(self, dummy_dialect: DummyDialect): class TestExistsExpression: - """Tests for ExistsExpression class.""" + """Tests for ExistsPredicate class.""" def test_exists_expression_basic(self, dummy_dialect: DummyDialect): """Test basic EXISTS expression functionality.""" subquery = Subquery(dummy_dialect, "SELECT 1 FROM users WHERE id = ?", (1,)) - exists_expr = ExistsExpression(dummy_dialect, subquery) + exists_expr = ExistsPredicate(dummy_dialect, subquery) sql, params = exists_expr.to_sql() assert "EXISTS" in sql assert params == (1,) @@ -112,20 +113,20 @@ def test_exists_expression_basic(self, dummy_dialect: DummyDialect): def test_not_exists_expression(self, dummy_dialect: DummyDialect): """Test NOT EXISTS expression functionality.""" subquery = Subquery(dummy_dialect, "SELECT 1 FROM users WHERE id = ?", (1,)) - exists_expr = ExistsExpression(dummy_dialect, subquery, is_not=True) + exists_expr = ExistsPredicate(dummy_dialect, subquery, is_not=True) sql, params = exists_expr.to_sql() assert "NOT EXISTS" in sql assert params == (1,) class TestAnyExpression: - """Tests for AnyExpression class.""" + """Tests for AnyPredicate class.""" def test_any_expression_basic(self, dummy_dialect: DummyDialect): """Test basic ANY expression functionality.""" expr = Column(dummy_dialect, "value") array_expr = Column(dummy_dialect, "array_col") - any_expr = AnyExpression(dummy_dialect, expr, "=", array_expr) + any_expr = AnyPredicate(dummy_dialect, expr, "=", array_expr) sql, params = any_expr.to_sql() assert "ANY" in sql assert "=" in sql @@ -134,7 +135,7 @@ def test_any_expression_with_literal_list(self, dummy_dialect: DummyDialect): """Test ANY expression with literal list.""" expr = Column(dummy_dialect, "value") array_expr = Literal(dummy_dialect, [1, 2, 3]) - any_expr = AnyExpression(dummy_dialect, expr, ">", array_expr) + any_expr = AnyPredicate(dummy_dialect, expr, ">", array_expr) sql, params = any_expr.to_sql() assert "ANY" in sql assert ">" in sql @@ -142,13 +143,13 @@ def test_any_expression_with_literal_list(self, dummy_dialect: DummyDialect): class TestAllExpression: - """Tests for AllExpression class.""" + """Tests for AllPredicate class.""" def test_all_expression_basic(self, dummy_dialect: DummyDialect): """Test basic ALL expression functionality.""" expr = Column(dummy_dialect, "value") array_expr = Column(dummy_dialect, "array_col") - all_expr = AllExpression(dummy_dialect, expr, ">", array_expr) + all_expr = AllPredicate(dummy_dialect, expr, ">", array_expr) sql, params = all_expr.to_sql() assert "ALL" in sql assert ">" in sql @@ -157,19 +158,19 @@ def test_all_expression_with_literal_list(self, dummy_dialect: DummyDialect): """Test ALL expression with literal list.""" expr = Column(dummy_dialect, "value") array_expr = Literal(dummy_dialect, [1, 2, 3]) - all_expr = AllExpression(dummy_dialect, expr, "=", array_expr) + all_expr = AllPredicate(dummy_dialect, expr, "=", array_expr) sql, params = all_expr.to_sql() assert "ALL" in sql assert "=" in sql class TestJSONExpression: - """Tests for JSONDocumentExpression class.""" + """Tests for JSONDocumentValue class.""" def test_json_extract_expression(self, dummy_dialect: DummyDialect): """Test JSON extract falls back to function-based formatting (-> operation).""" col = Column(dummy_dialect, "json_col") - json_expr = JSONDocumentExpression(dummy_dialect, col, "$.name", operation="->") + json_expr = JSONDocumentValue(dummy_dialect, col, "$.name", operation="->") sql, params = json_expr.to_sql() assert "JSON_EXTRACT" in sql assert params == () @@ -177,7 +178,7 @@ def test_json_extract_expression(self, dummy_dialect: DummyDialect): def test_json_extract_text_expression(self, dummy_dialect: DummyDialect): """Test JSON extract text falls back to function-based formatting (->> operation).""" col = Column(dummy_dialect, "json_col") - json_expr = JSONDocumentExpression(dummy_dialect, col, "$.name", operation="->>") + json_expr = JSONDocumentValue(dummy_dialect, col, "$.name", operation="->>") sql, params = json_expr.to_sql() assert "JSON_UNQUOTE" in sql assert "JSON_EXTRACT" in sql @@ -185,19 +186,19 @@ def test_json_extract_text_expression(self, dummy_dialect: DummyDialect): def test_json_extract_with_string_column(self, dummy_dialect: DummyDialect): """Test JSON extract with string column name.""" - json_expr = JSONDocumentExpression(dummy_dialect, "json_col", "$.name", operation="->") + json_expr = JSONDocumentValue(dummy_dialect, "json_col", "$.name", operation="->") sql, params = json_expr.to_sql() assert "JSON_EXTRACT" in sql assert params == () -class TestArrayExpression: - """Tests for ArrayExpression class.""" +class TestArrayValue: + """Tests for ArrayValue class.""" def test_array_constructor(self, dummy_dialect: DummyDialect): """Test ARRAY constructor operation.""" elements = [Literal(dummy_dialect, 1), Literal(dummy_dialect, 2), Literal(dummy_dialect, 3)] - array_expr = ArrayExpression(dummy_dialect, "CONSTRUCTOR", elements=elements) + array_expr = ArrayOperation(dummy_dialect, "CONSTRUCTOR", elements=elements) sql, params = array_expr.to_sql() assert "ARRAY[" in sql assert params == (1, 2, 3) @@ -206,14 +207,14 @@ def test_array_access(self, dummy_dialect: DummyDialect): """Test ARRAY access operation.""" base_expr = Column(dummy_dialect, "array_col") index_expr = Literal(dummy_dialect, 1) - array_expr = ArrayExpression(dummy_dialect, "ACCESS", base_expr=base_expr, index_expr=index_expr) + array_expr = ArrayOperation(dummy_dialect, "ACCESS", base_expr=base_expr, index_expr=index_expr) sql, params = array_expr.to_sql() assert "[" in sql and "]" in sql assert params == (1,) def test_array_default_operation(self, dummy_dialect: DummyDialect): """Test ARRAY with default operation.""" - array_expr = ArrayExpression(dummy_dialect, "OTHER") + array_expr = ArrayOperation(dummy_dialect, "OTHER") sql, params = array_expr.to_sql() assert sql == "ARRAY[]" assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_aliasable_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_aliasable_mixin.py index 80fa289a6..0af9c71b8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_aliasable_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_aliasable_mixin.py @@ -8,18 +8,17 @@ from rhosocial.activerecord.backend.expression import Literal, Column, FunctionCall, Subquery, ComparisonPredicate from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, - ArrayExpression, + JSONDocumentValue, + ArrayOperation, OrderedSetAggregation, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( CustomType, - DecimalType, TextType, ) @@ -112,32 +111,32 @@ def test_cast_expression_alias_with_as_method(self, dummy_dialect: DummyDialect) assert params == () def test_json_expression_alias_initialization(self, dummy_dialect: DummyDialect): - """Test JSONDocumentExpression with alias specified during initialization.""" - json_expr = JSONDocumentExpression(dummy_dialect, Column(dummy_dialect, "data"), "$.name", alias="name_field") + """Test JSONDocumentValue with alias specified during initialization.""" + json_expr = JSONDocumentValue(dummy_dialect, Column(dummy_dialect, "data"), "$.name", alias="name_field") assert json_expr.alias == "name_field" sql, params = json_expr.to_sql() assert sql == 'JSON_EXTRACT("data", \'$.name\') AS "name_field"' assert params == () def test_json_expression_alias_with_as_method(self, dummy_dialect: DummyDialect): - """Test JSONDocumentExpression with alias specified using as_() method.""" - json_expr = JSONDocumentExpression(dummy_dialect, Column(dummy_dialect, "data"), "$.name").as_("name_field") + """Test JSONDocumentValue with alias specified using as_() method.""" + json_expr = JSONDocumentValue(dummy_dialect, Column(dummy_dialect, "data"), "$.name").as_("name_field") assert json_expr.alias == "name_field" sql, params = json_expr.to_sql() assert sql == 'JSON_EXTRACT("data", \'$.name\') AS "name_field"' assert params == () def test_array_expression_alias_initialization(self, dummy_dialect: DummyDialect): - """Test ArrayExpression with alias specified during initialization.""" - arr = ArrayExpression(dummy_dialect, "CONSTRUCTOR", elements=[Column(dummy_dialect, "a")], alias="arr_alias") + """Test ArrayValue with alias specified during initialization.""" + arr = ArrayOperation(dummy_dialect, "CONSTRUCTOR", elements=[Column(dummy_dialect, "a")], alias="arr_alias") assert arr.alias == "arr_alias" sql, params = arr.to_sql() assert sql == 'ARRAY["a"] AS "arr_alias"' assert params == () def test_array_expression_alias_with_as_method(self, dummy_dialect: DummyDialect): - """Test ArrayExpression with alias specified using as_() method.""" - arr = ArrayExpression(dummy_dialect, "CONSTRUCTOR", elements=[Column(dummy_dialect, "a")]).as_("arr_alias") + """Test ArrayValue with alias specified using as_() method.""" + arr = ArrayOperation(dummy_dialect, "CONSTRUCTOR", elements=[Column(dummy_dialect, "a")]).as_("arr_alias") assert arr.alias == "arr_alias" sql, params = arr.to_sql() assert sql == 'ARRAY["a"] AS "arr_alias"' @@ -199,8 +198,8 @@ def test_original_untouched_after_as(self, dummy_dialect: DummyDialect): def test_reuse_in_two_contexts_no_leak(self, dummy_dialect: DummyDialect): # Same expression used in SELECT (aliased) and ORDER BY (unaliased). - dist = BinaryArithmeticExpression( - dummy_dialect, "<=>", Column(dummy_dialect, "embedding"), + dist = ArithmeticOperation( + dummy_dialect, "sub", Column(dummy_dialect, "embedding"), Literal(dummy_dialect, "[1, 2, 3]"), ) select_expr = dist.as_("cosine_distance") @@ -213,8 +212,8 @@ def test_nested_parent_no_contamination(self, dummy_dialect: DummyDialect): # still references the unaliased original. child = Column(dummy_dialect, "embedding") child_aliased = child.as_("emb") - parent = BinaryArithmeticExpression( - dummy_dialect, "-", Literal(dummy_dialect, 1), child + parent = ArithmeticOperation( + dummy_dialect, "sub", Literal(dummy_dialect, 1), child ) parent_sql = parent.to_sql()[0] # The parent uses the unaliased original -> no AS inside parentheses. @@ -225,11 +224,11 @@ def test_multi_level_nested_alias(self, dummy_dialect: DummyDialect): # Aliasing the top of a multi-level tree renders AS at the top only; # reusing the aliased copy nested does not leak into the base. leaf = Column(dummy_dialect, "embedding") - inner = BinaryArithmeticExpression( - dummy_dialect, "<=>", leaf, Literal(dummy_dialect, "[1, 2, 3]") + inner = ArithmeticOperation( + dummy_dialect, "sub", leaf, Literal(dummy_dialect, "[1, 2, 3]") ) - outer = BinaryArithmeticExpression( - dummy_dialect, "-", Literal(dummy_dialect, 1), inner + outer = ArithmeticOperation( + dummy_dialect, "sub", Literal(dummy_dialect, 1), inner ) outer_aliased = outer.as_("cosine_similarity") # Aliased top renders with AS at the outermost position only. @@ -256,7 +255,6 @@ def test_chained_as_then_cast_keeps_alias(self, dummy_dialect: DummyDialect): @pytest.mark.parametrize( "factory", [ - lambda d: Literal(d, 1), lambda d: Column(d, "name"), lambda d: FunctionCall(d, "UPPER", Column(d, "name")), lambda d: Subquery(d, "SELECT 1"), @@ -269,7 +267,7 @@ def test_chained_as_then_cast_keeps_alias(self, dummy_dialect: DummyDialect): condition=ComparisonPredicate(d, "=", Column(d, "id", "u"), Column(d, "user_id", "i")), ), ], - ids=["Literal", "Column", "FunctionCall", "Subquery", "NamedRelationRef", "JoinClause"], + ids=["Column", "FunctionCall", "Subquery", "NamedRelationRef", "JoinClause"], ) def test_as_returns_copy_for_each_expression_type( self, dummy_dialect: DummyDialect, factory diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_batch_validation.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_batch_validation.py index 6ee538a79..af6c6abae 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_batch_validation.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_batch_validation.py @@ -13,9 +13,9 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - InsertExpression, - UpdateExpression, - DeleteExpression, + InsertStatement, + UpdateStatement, + DeleteStatement, ValuesSource, ComparisonPredicate, ReturningClause, @@ -32,14 +32,14 @@ def _make_insert(dialect, table="users", name_val="Alice", email_val="a@test.com"): - """Construct a minimal single-row InsertExpression without RETURNING.""" + """Construct a minimal single-row InsertStatement without RETURNING.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, name_val), Literal(dialect, email_val)]]) - return InsertExpression(dialect, into=Table(dialect, table), columns=["name", "email"], source=source) + return InsertStatement(dialect, into=Table(dialect, table), columns=["name", "email"], source=source) def _make_update(dialect, table="users", set_col="name", set_val="Bob", pk_val=1): - """Construct a minimal UpdateExpression without RETURNING.""" - return UpdateExpression( + """Construct a minimal UpdateStatement without RETURNING.""" + return UpdateStatement( dialect, table=Table(dialect, table), assignments={set_col: Literal(dialect, set_val)}, @@ -48,8 +48,8 @@ def _make_update(dialect, table="users", set_col="name", set_val="Bob", pk_val=1 def _make_delete(dialect, table="users", pk_val=1): - """Construct a minimal DeleteExpression without RETURNING.""" - return DeleteExpression( + """Construct a minimal DeleteStatement without RETURNING.""" + return DeleteStatement( dialect, tables=Table(dialect, table), where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, pk_val)), @@ -70,26 +70,26 @@ def test_all_insert_passes(self, dummy_dialect: DummyDialect): [e.to_sql() for e in exprs] types = {type(e) for e in exprs} assert len(types) == 1 - assert types.pop() is InsertExpression + assert types.pop() is InsertStatement def test_all_update_passes(self, dummy_dialect: DummyDialect): exprs = [_make_update(dummy_dialect, set_val=f"name{i}", pk_val=i) for i in range(3)] types = {type(e) for e in exprs} assert len(types) == 1 - assert types.pop() is UpdateExpression + assert types.pop() is UpdateStatement def test_all_delete_passes(self, dummy_dialect: DummyDialect): exprs = [_make_delete(dummy_dialect, pk_val=i) for i in range(3)] types = {type(e) for e in exprs} assert len(types) == 1 - assert types.pop() is DeleteExpression + assert types.pop() is DeleteStatement @pytest.mark.parametrize( "maker_a, maker_b, label_a, label_b", [ - pytest.param(_make_insert, _make_update, "InsertExpression", "UpdateExpression", id="insert_update_mixed"), - pytest.param(_make_insert, _make_delete, "InsertExpression", "DeleteExpression", id="insert_delete_mixed"), - pytest.param(_make_update, _make_delete, "UpdateExpression", "DeleteExpression", id="update_delete_mixed"), + pytest.param(_make_insert, _make_update, "InsertStatement", "UpdateStatement", id="insert_update_mixed"), + pytest.param(_make_insert, _make_delete, "InsertStatement", "DeleteStatement", id="insert_delete_mixed"), + pytest.param(_make_update, _make_delete, "UpdateStatement", "DeleteStatement", id="update_delete_mixed"), ], ) def test_mixed_types_detected(self, dummy_dialect, maker_a, maker_b, label_a, label_b): @@ -103,7 +103,7 @@ def test_mixed_types_detected(self, dummy_dialect, maker_a, maker_b, label_a, la def test_single_expression(self, dummy_dialect: DummyDialect): exprs = [_make_insert(dummy_dialect)] assert len(exprs) == 1 - assert type(exprs[0]) is InsertExpression + assert type(exprs[0]) is InsertStatement def test_empty_list(self, dummy_dialect: DummyDialect): exprs = [] @@ -123,7 +123,7 @@ def test_insert_with_returning_detected(self, dummy_dialect: DummyDialect): dummy_dialect, values_list=[[Literal(dummy_dialect, "Alice"), Literal(dummy_dialect, "a@t.com")]] ) clause = ReturningClause(dummy_dialect, expressions=[Column(dummy_dialect, "id")]) - expr = InsertExpression( + expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], @@ -134,7 +134,7 @@ def test_insert_with_returning_detected(self, dummy_dialect: DummyDialect): def test_update_with_returning_detected(self, dummy_dialect: DummyDialect): clause = ReturningClause(dummy_dialect, expressions=[Column(dummy_dialect, "id")]) - expr = UpdateExpression( + expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "Bob")}, @@ -145,7 +145,7 @@ def test_update_with_returning_detected(self, dummy_dialect: DummyDialect): def test_delete_with_returning_detected(self, dummy_dialect: DummyDialect): clause = ReturningClause(dummy_dialect, expressions=[Column(dummy_dialect, "id")]) - expr = DeleteExpression( + expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)), @@ -164,7 +164,7 @@ def test_mixed_some_with_returning(self, dummy_dialect: DummyDialect): source = ValuesSource( dummy_dialect, values_list=[[Literal(dummy_dialect, "B"), Literal(dummy_dialect, "b@t.com")]] ) - expr_dirty = InsertExpression( + expr_dirty = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], @@ -191,10 +191,10 @@ def test_same_table_same_columns_consistent(self, dummy_dialect: DummyDialect): def test_different_columns_inconsistent(self, dummy_dialect: DummyDialect): source_a = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "Alice")]]) - expr_a = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name"], source=source_a) + expr_a = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name"], source=source_a) source_b = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "a@t.com")]]) - expr_b = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["email"], source=source_b) + expr_b = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["email"], source=source_b) sql_a, _ = expr_a.to_sql() sql_b, _ = expr_b.to_sql() @@ -210,7 +210,7 @@ def test_different_tables_inconsistent(self, dummy_dialect: DummyDialect): def test_different_where_structure_inconsistent(self, dummy_dialect: DummyDialect): expr_a = _make_update(dummy_dialect, set_col="name", set_val="A", pk_val=1) # Different WHERE column - expr_b = UpdateExpression( + expr_b = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "B")}, @@ -374,7 +374,7 @@ def test_all_triple_row_consistent(self, dummy_dialect: DummyDialect): def make_3row(i): rows = [[Literal(dummy_dialect, f"u{i}_{j}"), Literal(dummy_dialect, f"u{i}_{j}@t.com")] for j in range(3)] source = ValuesSource(dummy_dialect, values_list=rows) - return InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], source=source) + return InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], source=source) exprs = [make_3row(i) for i in range(3)] templates = {e.to_sql()[0] for e in exprs} @@ -387,7 +387,7 @@ def test_mixed_row_counts_inconsistent(self, dummy_dialect: DummyDialect): # Triple row rows = [[Literal(dummy_dialect, f"u{j}"), Literal(dummy_dialect, f"u{j}@t.com")] for j in range(3)] source_3 = ValuesSource(dummy_dialect, values_list=rows) - expr_3 = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], source=source_3) + expr_3 = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name", "email"], source=source_3) sql_1, _ = expr_1.to_sql() sql_3, _ = expr_3.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_protocol.py index f859ac323..87a571022 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_protocol.py @@ -2,10 +2,17 @@ """Tests for ConstraintSupport protocol and ConstraintMixin.""" import pytest -from rhosocial.activerecord.backend.dialect.protocols import ConstraintSupport +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + TableCheckConstraint, + TableExcludeConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.dialect.mixins import ConstraintMixin +from rhosocial.activerecord.backend.dialect.protocols import ConstraintSupport from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.objects import Table class TestConstraintProtocol: @@ -96,12 +103,15 @@ class TestDialect(ConstraintMixin): assert dialect.supports_fk_on_delete() is True assert dialect.supports_fk_on_update() is True - def test_advanced_sql_standard_features_default_true(self): - """Advanced SQL standard features default to True. + def test_modifiers_default_false_and_constructors_default_true(self): + """A modifier defaults to False; a constructor to True. - All ConstraintMixin methods default to True to enable DummyDialect - to validate the full constraint implementation. Backends that don't - support these features must explicitly override them. + Constraint kinds (PRIMARY KEY, UNIQUE, ...) are things every grammar + has, so they default True and a dialect overrides to refuse. The + SQL:1999/SQL:2016 modifiers (DEFERRABLE, ENFORCED) are optional + grammar, so they default False and the dialects that have them opt + in. Constraint naming is part of the SQL-92 production itself, so it + defaults True. """ class TestDialect(ConstraintMixin): @@ -109,8 +119,12 @@ class TestDialect(ConstraintMixin): dialect = TestDialect() assert dialect.supports_fk_match() is True - assert dialect.supports_deferrable_constraint() is True - assert dialect.supports_constraint_enforced() is True + assert dialect.supports_primary_key_constraint() is True + assert dialect.supports_unique_constraint() is True + assert dialect.supports_constraint_naming() is True + assert dialect.supports_deferrable_constraint() is False + assert dialect.supports_constraint_enforced() is False + assert dialect.supports_alter_constraint_enforced() is False def test_alter_table_constraint_operations_default_true(self): """ALTER TABLE constraint operations default to True.""" @@ -150,18 +164,14 @@ def dialect(self): def test_format_add_primary_key_constraint(self, dialect): """Test ADD PRIMARY KEY constraint SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraintAction from rhosocial.activerecord.backend.expression.statements.ddl_table import ( TableConstraint, - TableConstraintType, ) - action = AddTableConstraint( + action = AddTableConstraintAction( dialect, - constraint=TableConstraint(dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["id"], - ), + constraint=TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"])), ) sql, params = dialect.format_add_table_constraint_action(action) assert "PRIMARY KEY" in sql @@ -169,18 +179,14 @@ def test_format_add_primary_key_constraint(self, dialect): def test_format_add_unique_constraint(self, dialect): """Test ADD UNIQUE constraint SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraintAction from rhosocial.activerecord.backend.expression.statements.ddl_table import ( TableConstraint, - TableConstraintType, ) - action = AddTableConstraint( + action = AddTableConstraintAction( dialect, - constraint=TableConstraint(dialect, - constraint_type=TableConstraintType.UNIQUE, - columns=["email"], - ), + constraint=TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["email"])), ) sql, params = dialect.format_add_table_constraint_action(action) assert "UNIQUE" in sql @@ -188,20 +194,16 @@ def test_format_add_unique_constraint(self, dialect): def test_format_add_check_constraint(self, dialect): """Test ADD CHECK constraint SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraintAction from rhosocial.activerecord.backend.expression.statements.ddl_table import ( TableConstraint, - TableConstraintType, ) - from rhosocial.activerecord.backend.expression import Column as ExprColumn, Literal + from rhosocial.activerecord.backend.expression import NumericColumn, Literal - check_condition = ExprColumn(dialect, "age") > Literal(dialect, 0) - action = AddTableConstraint( + check_condition = NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + action = AddTableConstraintAction( dialect, - constraint=TableConstraint(dialect, - constraint_type=TableConstraintType.CHECK, - check_condition=check_condition, - ), + constraint=TableConstraint(dialect, branch=TableCheckConstraint(dialect, check_condition)), ) sql, params = dialect.format_add_table_constraint_action(action) assert "CHECK" in sql @@ -209,10 +211,10 @@ def test_format_add_check_constraint(self, dialect): def test_format_add_foreign_key_constraint(self, dialect): """Test ADD FOREIGN KEY constraint SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraintAction from rhosocial.activerecord.backend.expression.statements.ddl_table import ForeignKeyConstraint - action = AddTableConstraint( + action = AddTableConstraintAction( dialect, constraint=ForeignKeyConstraint(dialect, columns=["user_id"], @@ -227,19 +229,15 @@ def test_format_add_foreign_key_constraint(self, dialect): def test_format_add_named_constraint(self, dialect): """Test ADD CONSTRAINT with name SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddTableConstraintAction from rhosocial.activerecord.backend.expression.statements.ddl_table import ( TableConstraint, - TableConstraintType, + TableUniqueConstraint, ) - action = AddTableConstraint( + action = AddTableConstraintAction( dialect, - constraint=TableConstraint(dialect, - constraint_type=TableConstraintType.UNIQUE, - name="uk_email", - columns=["email"], - ), + constraint=TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["email"]), name="uk_email"), ) sql, params = dialect.format_add_table_constraint_action(action) assert "CONSTRAINT" in sql @@ -248,9 +246,9 @@ def test_format_add_named_constraint(self, dialect): def test_format_drop_constraint(self, dialect): """Test DROP CONSTRAINT SQL generation.""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import DropTableConstraint + from rhosocial.activerecord.backend.expression.statements.ddl_alter import DropTableConstraintAction - action = DropTableConstraint( + action = DropTableConstraintAction( dialect, constraint_name="uk_email", ) @@ -264,33 +262,27 @@ def test_table_and_column_enforcement(self): from rhosocial.activerecord.backend.expression import ( Column, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, ComparisonPredicate, - CreateTableExpression, + CreateTableStatement, Literal, - TableConstraint, - TableConstraintType, - ) + TableConstraint,) from rhosocial.activerecord.backend.expression.types import IntegerType - from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() - condition = ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline_literals=True)) + condition = ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline=True)) table_constraint = TableConstraint( dialect, - TableConstraintType.CHECK, + branch=TableCheckConstraint(dialect, condition), name="age_check", - check_condition=condition, not_enforced=True, ) column_constraint = ColumnConstraint( dialect, - ColumnConstraintType.CHECK, - check_condition=condition, + branch=ColumnCheckConstraint(dialect, condition), not_enforced=True, ) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, 'people'), [ColumnDefinition(dialect, "age", IntegerType(dialect), [column_constraint])], @@ -306,114 +298,144 @@ def test_table_and_column_enforcement(self): assert params == () def test_add_constraint_validation_is_type_specific(self): - from rhosocial.activerecord.backend.expression import AddTableConstraint, TableConstraint, TableConstraintType + from rhosocial.activerecord.backend.expression import ( + AddTableConstraintAction, + TableConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) from rhosocial.activerecord.backend.expression.statements import ConstraintValidation - from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() - for constraint_type in (TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE): + for branch_cls in (TablePrimaryKeyConstraint, TableUniqueConstraint): constraint = TableConstraint( dialect, - constraint_type, - columns=["id"], + branch=branch_cls(dialect, ["id"]), validation=ConstraintValidation.NOVALIDATE, ) with pytest.raises(ValueError, match="NOT VALID"): - AddTableConstraint(dialect, constraint).to_sql() + AddTableConstraintAction(dialect, constraint).to_sql() def test_enforcement_capability_is_gated(self): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from rhosocial.activerecord.backend.expression import Column, ColumnConstraint, ColumnConstraintType, Literal + from rhosocial.activerecord.backend.expression import ( + ColumnCheckConstraint, + ColumnConstraint, + Literal, + NumericColumn, + ) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect dialect = SQLiteDialect() - condition = Column(dialect, "age") > Literal(dialect, 0, inline_literals=True) - constraint = ColumnConstraint( - dialect, - ColumnConstraintType.CHECK, - check_condition=condition, - not_enforced=True, - ) + condition = NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + constraint = ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, condition), not_enforced=True) with pytest.raises(UnsupportedFeatureError): constraint.to_sql() def test_alter_constraint_actions(self): from rhosocial.activerecord.backend.expression import ( - AlterConstraint, - ColumnConstraintType, - TableConstraintType, - ValidateConstraint, + AlterConstraintAction, + ValidateConstraintAction, + TableCheckConstraint, ) - from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() - action = AlterConstraint( + action = AlterConstraintAction( dialect, "age_check", not_enforced=True, - constraint_type=ColumnConstraintType.CHECK, + constraint_kind=TableCheckConstraint, ) - assert action.constraint_type is TableConstraintType.CHECK + assert action.constraint_branch is TableCheckConstraint assert action.to_sql()[0] == 'ALTER CONSTRAINT "age_check" NOT ENFORCED' - assert ValidateConstraint(dialect, "age_check").to_sql()[0] == ( + assert ValidateConstraintAction(dialect, "age_check").to_sql()[0] == ( 'VALIDATE CONSTRAINT "age_check"' ) with pytest.raises(TypeError): - AlterConstraint(dialect, "age_check", False) + AlterConstraintAction(dialect, "age_check", False) - def test_constraint_type_and_validation_strings_are_normalized(self): + def test_branch_and_validation_types_are_refused(self): + """The branch names the kind and ``validation`` names its spelling; + neither accepts a string in place of the declared type. + + The kind used to be a runtime-normalised enum member or its name, and + ``validation`` was normalised at render time — a fallback that let a + mistyped value reach the dialect before failing. The kind is now a + class, which cannot be spelled at all, and ``validation`` is refused + where it is read. + """ from rhosocial.activerecord.backend.expression import ( - AddTableConstraint, - Column, + AddTableConstraintAction, + ConstraintValidation, Literal, + NumericColumn, TableConstraint, + TableCheckConstraint, ) - from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() - constraint = TableConstraint( + condition = NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + typed = TableConstraint( dialect, - " check ", - check_condition=Column(dialect, "age") > Literal(dialect, 0), - validation=" not valid ", + branch=TableCheckConstraint(dialect, condition), + validation=ConstraintValidation.NOVALIDATE, ) - assert AddTableConstraint(dialect, constraint).to_sql()[0].endswith("NOT VALID") - assert "NOT VALID" not in constraint.to_sql()[0] + assert AddTableConstraintAction(dialect, typed).to_sql()[0].endswith("NOT VALID") + assert "NOT VALID" not in typed.to_sql()[0] - def test_check_predicate_inlines_literals_and_rejects_raw_parameters(self): + with pytest.raises(TypeError, match="TableConstraintBranch"): + TableConstraint(dialect, branch=" check ") + with pytest.raises(TypeError, match="ConstraintValidation"): + AddTableConstraintAction( + dialect, + TableConstraint( + dialect, + branch=TableCheckConstraint( + dialect, NumericColumn(dialect, "age") > Literal(dialect, 0, inline=True) + ), + validation=" not valid ", + ), + ).to_sql() + + def test_check_predicate_inlines_marked_literals_and_carries_the_rest(self): from rhosocial.activerecord.backend.expression import ( Column, ComparisonPredicate, Literal, RawSQLPredicate, TableConstraint, + TableCheckConstraint, ) - from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() + condition = ComparisonPredicate( + dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline=True) + ) trusted = TableConstraint( - dialect, - "CHECK", - check_condition=ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 0)), + dialect, branch=TableCheckConstraint(dialect, condition) ) assert trusted.to_sql() == ('CHECK ("age" > 0)', ()) + # A value the caller left as a bind value stays one: the statement + # carries it in params, and the engine refuses it if it cannot take it. + raw = RawSQLPredicate(dialect, "age > %s", (0,)) unsafe = TableConstraint( - dialect, - "CHECK", - check_condition=RawSQLPredicate(dialect, "age > %s", (0,)), + dialect, branch=TableCheckConstraint(dialect, raw) ) - with pytest.raises(ValueError, match="must not contain bind parameters"): - unsafe.to_sql() + sql, params = unsafe.to_sql() + assert params == (0,) def test_exclude_is_rejected_on_unsupported_dialect(self): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from rhosocial.activerecord.backend.expression import TableConstraint, TableConstraintType + from rhosocial.activerecord.backend.expression import ( + TableConstraint, + ) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - constraint = TableConstraint(SQLiteDialect(), TableConstraintType.EXCLUDE) + dialect = SQLiteDialect() + constraint = TableConstraint(dialect, branch=TableExcludeConstraint(dialect, ["ts"])) with pytest.raises(UnsupportedFeatureError, match="EXCLUDE"): constraint.to_sql() @@ -421,25 +443,28 @@ def test_exclude_is_rejected_on_unsupported_dialect(self): class TestInPredicateInDDLCheckConstraints: """``IN`` must be usable inside a CHECK constraint. - PostgreSQL and MySQL both reject bind parameters in DDL, so the dialect - preparation pass sets ``inline_literals`` on every literal inside a CHECK - predicate. ``InPredicate`` used to discard that flag -- it unpacked the - collection and rebuilt placeholders unconditionally -- which made ``IN`` the - one predicate shape that could not appear in a CHECK. Since the flag was - dropped, the shape that silently produced a bind parameter was the ordinary - ``column.in_([...])`` call rather than anything unusual. + PostgreSQL and MySQL both reject bind parameters in DDL, and the CHECK + classes declare ``inline_binding = True``: the dialect preparation pass + marks every literal inside a CHECK predicate ``inline``, the decision + whose strategy is inline. ``InPredicate`` reads that class-level strategy + when rendering the value list, so the ``IN`` shape participates in the + same lift as every other predicate. """ def _check(self, dialect, condition): - from rhosocial.activerecord.backend.expression import TableConstraint - return TableConstraint(dialect, "CHECK", check_condition=condition) + from rhosocial.activerecord.backend.expression import ( + TableConstraint, + TableCheckConstraint, + ) + return TableConstraint(dialect, branch=TableCheckConstraint(dialect, condition)) def test_in_list_inlines_inside_a_check(self, dummy_dialect: DummyDialect): from rhosocial.activerecord.backend.expression import InPredicate, Literal, Column constraint = self._check( dummy_dialect, - InPredicate(dummy_dialect, Column(dummy_dialect, "direction"), Literal(dummy_dialect, ("debit", "credit"))), + InPredicate(dummy_dialect, + Column(dummy_dialect, "direction"), Literal(dummy_dialect, ("debit", "credit"), inline=True)), ) assert constraint.to_sql() == ( 'CHECK ("direction" IN (\'debit\', \'credit\'))', (), @@ -451,7 +476,7 @@ def test_in_tuple_inlines_inside_a_check(self, dummy_dialect: DummyDialect): condition = InPredicate( dummy_dialect, Column(dummy_dialect, "direction"), - Literal(dummy_dialect, ("debit", "credit")), + Literal(dummy_dialect, ("debit", "credit"), inline=True), ) assert self._check(dummy_dialect, condition).to_sql() == ( 'CHECK ("direction" IN (\'debit\', \'credit\'))', (), @@ -465,7 +490,8 @@ def test_not_in_inlines_inside_a_check(self, dummy_dialect: DummyDialect): LogicalPredicate( dummy_dialect, "NOT", - InPredicate(dummy_dialect, Column(dummy_dialect, "state"), Literal(dummy_dialect, ("a", "b"))), + InPredicate(dummy_dialect, + Column(dummy_dialect, "state"), Literal(dummy_dialect, ("a", "b"), inline=True)), ), ) assert constraint.to_sql() == ( @@ -477,7 +503,8 @@ def test_numeric_in_list_inlines_inside_a_check(self, dummy_dialect: DummyDialec constraint = self._check( dummy_dialect, - InPredicate(dummy_dialect, Column(dummy_dialect, "priority"), Literal(dummy_dialect, (1, 2, 3))), + InPredicate(dummy_dialect, + Column(dummy_dialect, "priority"), Literal(dummy_dialect, (1, 2, 3), inline=True)), ) assert constraint.to_sql() == ( 'CHECK ("priority" IN (1, 2, 3))', (), @@ -488,7 +515,7 @@ def test_in_list_escapes_quotes_when_inlined(self, dummy_dialect: DummyDialect): constraint = self._check( dummy_dialect, - InPredicate(dummy_dialect, Column(dummy_dialect, "label"), Literal(dummy_dialect, ("a'b",))), + InPredicate(dummy_dialect, Column(dummy_dialect, "label"), Literal(dummy_dialect, ("a'b",), inline=True)), ) assert constraint.to_sql() == ('CHECK ("label" IN (\'a\'\'b\'))', ()) @@ -497,7 +524,7 @@ def test_in_list_with_null_inlines_inside_a_check(self, dummy_dialect: DummyDial constraint = self._check( dummy_dialect, - InPredicate(dummy_dialect, Column(dummy_dialect, "parent"), Literal(dummy_dialect, (None, 1))), + InPredicate(dummy_dialect, Column(dummy_dialect, "parent"), Literal(dummy_dialect, (None, 1), inline=True)), ) assert constraint.to_sql() == ('CHECK ("parent" IN (NULL, 1))', ()) @@ -549,15 +576,16 @@ def test_literal_wrapping_a_list_outside_ddl_still_binds( ) assert condition.to_sql() == ('"direction" IN (?, ?)', ("debit", "credit")) - def test_explicitly_inlined_literal_list_renders_inline_without_ddl( + def test_ddl_literal_list_renders_inline_without_ddl( self, dummy_dialect: DummyDialect ): - """The inline switch works on its own, not only via DDL preparation.""" + """The DDL-position literal class inlines on its own, not only via + DDL preparation.""" from rhosocial.activerecord.backend.expression import Column, InPredicate, Literal condition = InPredicate( dummy_dialect, Column(dummy_dialect, "direction"), - Literal(dummy_dialect, ("debit", "credit"), inline_literals=True), + Literal(dummy_dialect, ("debit", "credit"), inline=True), ) assert condition.to_sql() == ('"direction" IN (\'debit\', \'credit\')', ()) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_sql_baseline.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_sql_baseline.py new file mode 100644 index 000000000..a1ae02319 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_sql_baseline.py @@ -0,0 +1,256 @@ +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_constraint_sql_baseline.py +"""The SQL every constraint form renders, byte for byte. + +This is the baseline the two-level constraint nodes (S3) must not move, and +it is written against the branch nodes: each form is now spelled the way a +caller must spell it, so the test doubles as evidence that the branch is what +says which kind a constraint is. The refactor changed which node carries which +field and where the constraint-level prefix and the trailing traits are +rendered; it did not change a character of the output. + +Two entries record **behaviour S3 deliberately changed**, and say so where +they live: the deferral traits now render on every branch that can carry them +(a column-level PRIMARY KEY used to drop them silently, because only the +foreign-key formatter read them), and a request for a trait a branch cannot +carry is refused where the node is built instead of being accepted and +dropped. +""" + +import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + + +def _check(dialect): + """``age > 0`` -- DDL takes no bind parameters, so this renders inlined.""" + return ComparisonPredicate( + dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline=True) + ) + + +def _probed(dialect_cls=DummyDialect, **answers): + """A dialect subclass whose capability probes answer as ``answers``.""" + namespace = {name: (lambda self, answer=answer: answer) for name, answer in answers.items()} + return type("ProbedDialect", (dialect_cls,), namespace) + + +class TestColumnConstraintBaseline: + def test_bare_branches(self, dummy_dialect): + cases = ( + (ColumnNotNullConstraint, " NOT NULL"), + (ColumnNullableConstraint, " NULL"), + (ColumnUniqueConstraint, " UNIQUE"), + (ColumnPrimaryKeyConstraint, " PRIMARY KEY"), + ) + for branch_cls, expected in cases: + clause = ColumnConstraint(dummy_dialect, branch=branch_cls(dummy_dialect)) + assert clause.to_sql() == (expected, ()) + + def test_named_prefix(self, dummy_dialect): + clause = ColumnConstraint( + dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect), name="nn_users_age" + ) + assert clause.to_sql() == (' CONSTRAINT "nn_users_age" NOT NULL', ()) + + def test_primary_key_autoincrement_is_sqlite_only(self, dummy_dialect): + """AUTOINCREMENT is part of SQLite's grammar and nowhere else.""" + clause = ColumnConstraint( + dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect, True) + ) + assert clause.to_sql() == (" PRIMARY KEY", ()) + sqlite = SQLiteDialect() + assert ColumnConstraint( + sqlite, branch=ColumnPrimaryKeyConstraint(sqlite, True) + ).to_sql() == (" PRIMARY KEY AUTOINCREMENT", ()) + + def test_check_inlines_its_literal(self, dummy_dialect): + clause = ColumnConstraint( + dummy_dialect, branch=ColumnCheckConstraint(dummy_dialect, _check(dummy_dialect)) + ) + assert clause.to_sql() == (' CHECK ("age" > 0)', ()) + + def test_default_inlines_its_value(self, dummy_dialect): + clause = ColumnConstraint( + dummy_dialect, + branch=ColumnDefaultConstraint(dummy_dialect, Literal(dummy_dialect, 7, inline=True)), + ) + assert clause.to_sql() == (" DEFAULT 7", ()) + + def test_foreign_key_renders_the_references_half_only(self, dummy_dialect): + """Current shape: no ``FOREIGN KEY (col)`` prefix, and no space before + the column list. S3 keeps the bytes; fixing the spelling is a separate + change with its own before/after.""" + clause = ColumnConstraint( + dummy_dialect, + branch=ColumnForeignKeyConstraint( + dummy_dialect, (Table(dummy_dialect, "users"), ["id"]) + ), + ) + assert clause.to_sql() == (' REFERENCES "users"("id")', ()) + + def test_foreign_key_renders_the_deferral_traits(self, dummy_dialect): + dialect = _probed(supports_deferrable_constraint=True)() + clause = ColumnConstraint( + dialect, + branch=ColumnForeignKeyConstraint( + dialect, (Table(dialect, "users"), ["id"]) + ), + deferrable=True, + initially_deferred=True, + ) + assert clause.to_sql() == (' REFERENCES "users"("id") DEFERRABLE INITIALLY DEFERRED', ()) + + def test_foreign_key_refuses_deferral_without_support(self, dummy_dialect): + dialect = _probed(supports_deferrable_constraint=False)() + clause = ColumnConstraint( + dialect, + branch=ColumnForeignKeyConstraint( + dialect, (Table(dialect, "users"), ["id"]) + ), + deferrable=True, + ) + with pytest.raises(UnsupportedFeatureError, match="DEFERRABLE"): + clause.to_sql() + + def test_primary_key_renders_the_deferral_traits_now(self, dummy_dialect): + """Changed by S3: the traits are the production's, so every branch that + can carry them renders them. They used to be read by the foreign-key + formatter alone and dropped on everything else.""" + dialect = _probed(supports_deferrable_constraint=True)() + clause = ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect), + deferrable=True, + initially_deferred=True, + ) + assert clause.to_sql() == (" PRIMARY KEY DEFERRABLE INITIALLY DEFERRED", ()) + + def test_a_trait_the_branch_cannot_carry_is_refused_at_construction(self, dummy_dialect): + """The defect this refactor exists to remove: a request that could not + mean anything used to be accepted and then dropped.""" + with pytest.raises(ValueError, match="cannot ride a ColumnDefaultConstraint"): + ColumnConstraint( + dummy_dialect, + branch=ColumnDefaultConstraint(dummy_dialect, Literal(dummy_dialect, 1, inline=True)), + enforced=True, + ) + with pytest.raises(ValueError, match="cannot ride a ColumnNotNullConstraint"): + ColumnConstraint( + dummy_dialect, + branch=ColumnNotNullConstraint(dummy_dialect), + deferrable=True, + ) + + def test_enforced_trait(self, dummy_dialect): + dialect = _probed(supports_constraint_enforced=True)() + clause = ColumnConstraint( + dialect, + branch=ColumnCheckConstraint(dialect, _check(dialect)), + not_enforced=True, + ) + assert clause.to_sql() == (' CHECK ("age" > 0) NOT ENFORCED', ()) + + def test_enforcement_refused_without_support(self, dummy_dialect): + dialect = _probed(supports_constraint_enforced=False)() + clause = ColumnConstraint( + dialect, + branch=ColumnCheckConstraint(dialect, _check(dialect)), + enforced=True, + ) + with pytest.raises(UnsupportedFeatureError): + clause.to_sql() + + def test_named_prefix_needs_the_capability(self, dummy_dialect): + dialect = _probed(supports_constraint_naming=False)() + clause = ColumnConstraint( + dialect, branch=ColumnNotNullConstraint(dialect), name="nn" + ) + with pytest.raises(UnsupportedFeatureError, match="CONSTRAINT"): + clause.to_sql() + + def test_the_tag_field_is_gone(self, dummy_dialect): + clause = ColumnConstraint( + dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect) + ) + with pytest.raises(AttributeError, match="ColumnNotNullConstraint"): + clause.constraint_type + + def test_a_branch_is_required(self, dummy_dialect): + with pytest.raises(TypeError, match="branch"): + ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint) + + +class TestTableConstraintBaseline: + def test_primary_key_and_unique(self, dummy_dialect): + assert TableConstraint( + dummy_dialect, branch=TablePrimaryKeyConstraint(dummy_dialect, ["id"]) + ).to_sql() == ('PRIMARY KEY ("id")', ()) + assert TableConstraint( + dummy_dialect, branch=TableUniqueConstraint(dummy_dialect, ["email"]) + ).to_sql() == ('UNIQUE ("email")', ()) + + def test_foreign_key(self, dummy_dialect): + + assert TableConstraint( + dummy_dialect, + branch=TableForeignKeyConstraint( + dummy_dialect, ["user_id"], (Table(dummy_dialect, "users"), ["id"]) + ), + ).to_sql() == ('FOREIGN KEY ("user_id") REFERENCES "users"("id")', ()) + + def test_check(self, dummy_dialect): + assert TableConstraint( + dummy_dialect, branch=TableCheckConstraint(dummy_dialect, _check(dummy_dialect)) + ).to_sql() == ('CHECK ("age" > 0)', ()) + + def test_named_prefix_and_enforced(self, dummy_dialect): + dialect = _probed(supports_constraint_enforced=True)() + assert TableConstraint( + dialect, + branch=TableCheckConstraint(dialect, _check(dialect)), + name="ck_users_age", + not_enforced=True, + ).to_sql() == ('CONSTRAINT "ck_users_age" CHECK ("age" > 0) NOT ENFORCED', ()) + + def test_exclude_is_a_branch_that_refuses_by_name(self, dummy_dialect): + """Enumerable, so modelled -- and refused where the constraint is + spelled, since no dialect here can spell EXCLUDE.""" + + clause = TableConstraint( + dummy_dialect, branch=TableExcludeConstraint(dummy_dialect, ["ts"]) + ) + with pytest.raises(UnsupportedFeatureError, match="EXCLUDE"): + clause.to_sql() + + def test_validation_is_a_field_the_node_keeps(self, dummy_dialect): + """``NOT VALID`` is refused by the CREATE TABLE path, not here -- the + constraint alone has no say in where it is being added.""" + clause = TableConstraint( + dummy_dialect, + branch=TableCheckConstraint(dummy_dialect, _check(dummy_dialect)), + validation="NOTVALID", + ) + assert clause.validation == "NOTVALID" diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_core.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_core.py index c532c5b68..bfd4074e0 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_core.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_core.py @@ -24,8 +24,8 @@ def test_literal_basic(self, dummy_dialect: DummyDialect): def test_literal_repr(self, dummy_dialect: DummyDialect): """Test Literal repr method.""" literal = Literal(dummy_dialect, "test_value") - assert repr(literal) == "Literal('test_value', inline_literals=False)" - assert repr(Literal(dummy_dialect, "test_value", inline_literals=True)) == "Literal('test_value', inline_literals=True)" + assert repr(literal) == "Literal('test_value')" + assert repr(Literal(dummy_dialect, "test_value")) == "Literal('test_value')" def test_literal_numeric_values(self, dummy_dialect: DummyDialect): """Test Literal with numeric values.""" @@ -161,11 +161,11 @@ def test_subquery_from_existing_subquery(self, dummy_dialect: DummyDialect): def test_subquery_from_base_expression(self, dummy_dialect: DummyDialect): """Test Subquery initialization with BaseExpression (has to_sql method).""" - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.expression.query_parts import WhereClause # Create a simple query expression to use as base expression - query_expr = QueryExpression( + query_expr = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "orders")), @@ -198,7 +198,7 @@ def __str__(self): def test_subquery_from_sql_query_and_params_tuple(self, dummy_dialect: DummyDialect): """Test Subquery initialization with SQLQueryAndParams tuple (str, tuple) - covers the missing branch.""" - # This tests the elif bases.is_sql_query_and_params(query): branch + # This tests the elif bases.QueryWithParams.holds(query): branch query_tuple = ("SELECT id FROM users WHERE age > ?", (25,)) subquery = Subquery(dummy_dialect, query_tuple) sql, params = subquery.to_sql() @@ -209,28 +209,28 @@ def test_subquery_from_sql_query_and_params_tuple_with_none_params(self, dummy_d """Test Subquery initialization with SQLQueryAndParams tuple where params is None.""" # This simulates a case where the second element of the tuple is None # Though in practice, SQLQueryAndParams expects a tuple, not None - # We'll test the branch by directly checking the is_sql_query_and_params function - from rhosocial.activerecord.backend.expression.bases import is_sql_query_and_params + # We'll test the branch by directly checking the QueryWithParams.holds function + from rhosocial.activerecord.backend.expression.bases import QueryWithParams - # Verify that is_sql_query_and_params correctly identifies the tuple format + # Verify that QueryWithParams.holds correctly identifies the tuple format valid_tuple = ("SELECT * FROM table", (1, 2, 3)) - assert is_sql_query_and_params(valid_tuple) is True + assert QueryWithParams.holds(valid_tuple) is True # Test with a tuple that has None as second element (this should return True now) valid_tuple_with_none = ("SELECT * FROM table", None) - assert is_sql_query_and_params(valid_tuple_with_none) is True + assert QueryWithParams.holds(valid_tuple_with_none) is True # Test with a tuple that has wrong length (this should return False) invalid_tuple_length = ("SELECT * FROM table", (1,), "extra") - assert is_sql_query_and_params(invalid_tuple_length) is False + assert QueryWithParams.holds(invalid_tuple_length) is False # Test with a tuple that has non-string first element (this should return False) invalid_tuple_first = (123, (1, 2, 3)) - assert is_sql_query_and_params(invalid_tuple_first) is False + assert QueryWithParams.holds(invalid_tuple_first) is False # Test with a tuple that has non-tuple second element (this should return False) invalid_tuple_second = ("SELECT * FROM table", [1, 2, 3]) - assert is_sql_query_and_params(invalid_tuple_second) is False + assert QueryWithParams.holds(invalid_tuple_second) is False def test_subquery_from_sql_query_and_params_tuple_with_none_as_params(self, dummy_dialect: DummyDialect): """Test Subquery initialization with SQLQueryAndParams tuple where params is None.""" @@ -265,7 +265,7 @@ def test_table_expression_with_temporal_options(self, dummy_dialect: DummyDialec temporal_opts = {"as_of": "2023-01-01", "for_system_time": "AS OF"} table = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"), alias="u", temporal_options=temporal_opts) sql, params = table.to_sql() - # This should call dialect.format_temporal_options + # This should call dialect.format_temporal_options_expression # The exact output depends on the dialect implementation assert '"users"' in sql @@ -274,24 +274,25 @@ def test_table_expression_empty_temporal_options(self, dummy_dialect: DummyDiale table = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"), temporal_options={}) sql, params = table.to_sql() assert '"users"' in sql - # Should not call format_temporal_options since dict is empty + # Should not call format_temporal_options_expression since dict is empty def test_table_expression_with_temporal_options_that_returns_none(self, dummy_dialect: DummyDialect): - """Test NamedRelationRef with temporal options when dialect returns None from format_temporal_options.""" - # Mock the dialect's format_temporal_options to return None - original_method = dummy_dialect.format_temporal_options + """Test NamedRelationRef with temporal options when the dialect returns + None from format_temporal_options_expression.""" + # Mock the dialect's format_temporal_options_expression to return None + original_method = dummy_dialect.format_temporal_options_expression def mock_format_temporal_options(expr): return None - dummy_dialect.format_temporal_options = mock_format_temporal_options + dummy_dialect.format_temporal_options_expression = mock_format_temporal_options temporal_opts = {"as_of": "2023-01-01"} table = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"), alias="u", temporal_options=temporal_opts) sql, params = table.to_sql() # Restore original method - dummy_dialect.format_temporal_options = original_method + dummy_dialect.format_temporal_options_expression = original_method # Should still work but not include temporal options since dialect returned None assert '"users"' in sql @@ -343,10 +344,10 @@ def test_format_join_clause_with_cross_join_without_condition_succeeds(self, dum assert params == () def test_format_temporal_options_with_empty_options_raises_error(self, dummy_dialect: DummyDialect): - """Tests that format_temporal_options raises ValueError when called with empty options.""" + """Tests that format_temporal_options_expression raises ValueError when called with empty options.""" from rhosocial.activerecord.backend.expression.datetime import TemporalOptionsExpression with pytest.raises( ValueError, - match=r"Temporal options cannot be empty. If no temporal options are needed, don't call format_temporal_options.", # noqa: E501 + match=r"Temporal options cannot be empty. If no temporal options are needed, don't call format_temporal_options_expression.", # noqa: E501 ): - dummy_dialect.format_temporal_options(TemporalOptionsExpression(dummy_dialect, {})) + dummy_dialect.format_temporal_options_expression(TemporalOptionsExpression(dummy_dialect, {})) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_type_enum_contract.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_type_enum_contract.py index 4db3cf0ec..02b2df82d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_type_enum_contract.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_type_enum_contract.py @@ -35,18 +35,22 @@ def dialect(): def test_values_are_required(dialect): """An enum with no values is a text field, and saying so is better than - emitting ``ENUM()``.""" - with pytest.raises(ValueError, match="requires values"): + emitting ``ENUM()``. + + Stated by the signature now: ``values`` is required and keyword-only, so + the call fails before a dialect is even consulted. + """ + with pytest.raises(TypeError, match="values"): EnumType(dialect) def test_an_empty_value_list_is_rejected(dialect): with pytest.raises(ValueError, match="at least one value"): - EnumType(dialect, []) + EnumType(dialect, values=[]) def test_values_survive_as_a_tuple(dialect): - assert EnumType(dialect, ["a", "b"]).values == ("a", "b") + assert EnumType(dialect, values=["a", "b"]).values == ("a", "b") # --------------------------------------------------------------------------- @@ -55,7 +59,7 @@ def test_values_survive_as_a_tuple(dialect): def test_values_are_rendered_in_order(dialect): - assert dialect.format_data_type(EnumType(dialect, ["draft", "live"]))[0] == ( + assert dialect.format_data_type(EnumType(dialect, values=["draft", "live"]))[0] == ( "ENUM('draft','live')" ) @@ -65,7 +69,7 @@ def test_a_value_containing_a_quote_is_escaped(dialect): ``ENUM('o'brien')`` is not valid SQL; doubling the quote is. """ - sql, _ = dialect.format_data_type(EnumType(dialect, ["o'brien"])) + sql, _ = dialect.format_data_type(EnumType(dialect, values=["o'brien"])) assert sql == "ENUM('o''brien')" @@ -119,7 +123,7 @@ def substitute_advice(self, name): def test_an_unrenderable_type_is_a_type_error(dialect): with pytest.raises(TypeError, match="does not support the generic type 'enum'"): - Narrow().format_data_type(EnumType(dialect, ["a"])) + Narrow().format_data_type(EnumType(dialect, values=["a"])) def test_the_refusal_names_the_substitute(dialect): @@ -134,7 +138,7 @@ def test_the_refusal_names_the_substitute(dialect): ``substitute_advice``; the mechanism is exercised below. """ with pytest.raises(TypeError) as excinfo: - Suggester().format_data_type(EnumType(dialect, ["a"])) + Suggester().format_data_type(EnumType(dialect, values=["a"])) message = str(excinfo.value) assert "VarCharType" in message, message assert "what this backend uses" in message, message @@ -145,7 +149,7 @@ def test_the_refusal_names_the_substitute(dialect): def test_a_lossy_substitute_can_say_so(dialect): """The caveat a backend supplies lands in the error the caller reads.""" with pytest.raises(TypeError) as excinfo: - Lossy().format_data_type(EnumType(dialect, ["a"])) + Lossy().format_data_type(EnumType(dialect, values=["a"])) message = str(excinfo.value) assert "VarCharType" in message, message assert "drops the zone" in message, message @@ -154,7 +158,7 @@ def test_a_lossy_substitute_can_say_so(dialect): def test_no_substitute_means_no_advice(dialect): with pytest.raises(TypeError) as excinfo: - Narrow().format_data_type(EnumType(dialect, ["a"])) + Narrow().format_data_type(EnumType(dialect, values=["a"])) message = str(excinfo.value) assert "It suggests" not in message, message assert "Use a type this backend supports" in message, message diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_types.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_types.py index f03994014..7366a180c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_types.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_data_types.py @@ -112,8 +112,8 @@ def test_json_types(self, dialect): assert JsonBType(dialect=dialect).to_sql() == ("JSONB", ()) def test_custom_type(self, dialect): - assert CustomType(dialect, "GEOMETRY").to_sql() == ("GEOMETRY", ()) - assert CustomType(dialect, "VARCHAR(255)").to_sql() == ("VARCHAR(255)", ()) + assert CustomType(dialect, raw="GEOMETRY").to_sql() == ("GEOMETRY", ()) + assert CustomType(dialect, raw="VARCHAR(255)").to_sql() == ("VARCHAR(255)", ()) # --------------------------------------------------------------------------- @@ -129,7 +129,7 @@ def test_equal_types(self): assert VarCharType(None, 255) == VarCharType(None, 255) assert DecimalType(None, 10, 2) == DecimalType(None, 10, 2) assert FloatType() == FloatType() - assert CustomType(None, "UUID") == CustomType(None, "UUID") + assert CustomType(None, raw="UUID") == CustomType(None, raw="UUID") def test_not_equal_different_params(self): assert VarCharType(None, 255) != VarCharType(None, 100) @@ -254,10 +254,35 @@ def test_unsigned_is_a_real_bool_not_a_truthy_test(self, klass): ``UNSIGNED`` for a field the caller wrote as a display width or a count, and nothing about the resulting DDL says the declaration was reinterpreted. + + The refusal happens at **rendering** time, not at construction: an + expression collects ``unsigned`` without judging it, because only the + formatter knows which backend has to honour it. The guard therefore + guards the formatter, and this test drives it the way rendering does. + """ + dialect = _dummy_dialect() + for wrong in (1, 0, "yes", "", None, [], object()): + # construction does not judge; rendering does + declared = klass(dialect=dialect, unsigned=wrong) + with pytest.raises(TypeError, match="unsigned must be a bool"): + declared.to_sql() + + @pytest.mark.parametrize("klass", CONCEPTS, ids=lambda k: k.__name__) + def test_the_constructor_collects_without_judging(self, klass): + """Construction accepts what it is given; rendering is what refuses. + + The counterpart to the test above: a non-boolean is not rejected where + it is collected, because the expression cannot know which dialect will + render it. What is refused is refused by the formatter, naming the + field. This is what keeps "declared" and "rendered" from disagreeing + silently. """ + dialect = _dummy_dialect() for wrong in (1, 0, "yes", "", None, [], object()): + dirty = klass(dialect=dialect, unsigned=wrong) # constructed, not judged + assert dirty.unsigned is wrong # collected verbatim with pytest.raises(TypeError, match="unsigned must be a bool"): - klass(unsigned=wrong) + dirty.to_sql() # refused only at rendering @pytest.mark.parametrize("klass", CONCEPTS, ids=lambda k: k.__name__) def test_the_other_fields_are_untouched(self, klass): @@ -315,9 +340,9 @@ def dialect(self): return _dummy_dialect() def test_custom_type_eq_hash(self, dialect): - ct1 = CustomType(dialect, "SOME_UNKNOWN_TYPE") - ct2 = CustomType(None, "SOME_UNKNOWN_TYPE") - ct3 = CustomType(None, "OTHER_TYPE") + ct1 = CustomType(dialect, raw="SOME_UNKNOWN_TYPE") + ct2 = CustomType(None, raw="SOME_UNKNOWN_TYPE") + ct3 = CustomType(None, raw="OTHER_TYPE") assert ct1 == ct2 assert ct1 != ct3 assert hash(ct1) == hash(ct2) @@ -393,21 +418,26 @@ class TestArrayTypeContract: """``ArrayType`` refuses to exist without something to be an array of.""" def test_element_type_is_required(self): - with pytest.raises(TypeError, match="requires an element_type"): + # The signature says so, not the body. An array without an element type + # is not a type, so `element_type` is required and keyword-only: it + # cannot be omitted at all, and that holds before any dialect is + # involved -- where a body check would have been bypassable by passing + # None explicitly. + with pytest.raises(TypeError, match="element_type"): ArrayType(None) def test_element_type_must_be_a_data_type(self): with pytest.raises(TypeError, match="must be a DataType instance"): - ArrayType(None, "integer") + ArrayType(None, element_type="integer") def test_dimensions_must_be_a_positive_int(self): with pytest.raises(ValueError, match="dimensions must be an int >= 1"): - ArrayType(None, IntegerType(), 0) + ArrayType(None, element_type=IntegerType(), dimensions=0) with pytest.raises(ValueError, match="dimensions must be an int >= 1"): - ArrayType(None, IntegerType(), 1.5) + ArrayType(None, element_type=IntegerType(), dimensions=1.5) def test_dimensions_default_to_one(self): - assert ArrayType(None, IntegerType()).dimensions == 1 + assert ArrayType(None, element_type=IntegerType()).dimensions == 1 # --------------------------------------------------------------------------- diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_ddl_improvements.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_ddl_improvements.py index 348e9124e..925d71cac 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_ddl_improvements.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_ddl_improvements.py @@ -6,27 +6,27 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements import ( ViewCheckOption, ViewOptions, - CreateTableExpression, + CreateTableStatement, ColumnDefinition, IndexDefinition, - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, TriggerTiming, TriggerEvent, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, - DropDatabaseExpression, - AlterDatabaseExpression, + CreateDatabaseStatement, + DropDatabaseStatement, + AlterDatabaseStatement, AlterDatabaseAction, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect @@ -43,12 +43,12 @@ class TestViewParenthesesFix: def test_create_view_no_parentheses_around_query(self, dummy_dialect: DummyDialect): """CREATE VIEW should not wrap query_sql in parentheses.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "v"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "v"), query=query) sql, _ = create_view.to_sql() assert "AS (" not in sql assert "AS SELECT" in sql @@ -59,12 +59,12 @@ class TestViewCapabilityGating: def test_create_or_replace_view_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE OR REPLACE VIEW should raise UnsupportedFeatureError when not supported.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "v"), query=query, replace=True ) with patch.object(type(dummy_dialect), "supports_create_or_replace_view", return_value=False): @@ -73,12 +73,12 @@ def test_create_or_replace_view_raises_when_unsupported(self, dummy_dialect: Dum def test_create_view_if_not_exists_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE VIEW IF NOT EXISTS should raise UnsupportedFeatureError when not supported.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "v"), query=query, if_not_exists=True ) with patch.object(type(dummy_dialect), "supports_if_not_exists_view", return_value=False): @@ -87,7 +87,7 @@ def test_create_view_if_not_exists_raises_when_unsupported(self, dummy_dialect: def test_drop_view_if_exists_raises_when_unsupported(self, dummy_dialect: DummyDialect): """DROP VIEW IF EXISTS should raise UnsupportedFeatureError when not supported.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "v"), if_exists=True ) with patch.object(type(dummy_dialect), "supports_if_exists_view", return_value=False): @@ -96,7 +96,7 @@ def test_drop_view_if_exists_raises_when_unsupported(self, dummy_dialect: DummyD def test_drop_view_cascade_raises_when_unsupported(self, dummy_dialect: DummyDialect): """DROP VIEW CASCADE should raise UnsupportedFeatureError when not supported.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "v"), cascade=True ) with patch.object(type(dummy_dialect), "supports_cascade_view", return_value=False): @@ -105,12 +105,12 @@ def test_drop_view_cascade_raises_when_unsupported(self, dummy_dialect: DummyDia def test_create_view_check_option_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE VIEW WITH CHECK OPTION should raise when unsupported.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "v"), query=query, @@ -125,7 +125,7 @@ class TestTableCapabilityGating: """Tests for CREATE TABLE capability gating.""" def _table(self, dummy_dialect: DummyDialect, **kwargs): - return CreateTableExpression( + return CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 't'), columns=[ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect))], @@ -209,7 +209,7 @@ class TestTriggerCapabilityGating: def test_create_trigger_if_not_exists_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE TRIGGER IF NOT EXISTS should raise UnsupportedFeatureError when not supported.""" - trigger = CreateTriggerExpression( + trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "t"), table=Table(dummy_dialect, "t"), @@ -224,7 +224,7 @@ def test_create_trigger_if_not_exists_raises_when_unsupported(self, dummy_dialec def test_create_trigger_referencing_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE TRIGGER REFERENCING should raise UnsupportedFeatureError when not supported.""" - trigger = CreateTriggerExpression( + trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "t"), table=Table(dummy_dialect, "t"), @@ -240,7 +240,7 @@ def test_create_trigger_referencing_raises_when_unsupported(self, dummy_dialect: def test_create_trigger_when_raises_when_unsupported(self, dummy_dialect: DummyDialect): """CREATE TRIGGER WHEN should raise UnsupportedFeatureError when not supported.""" from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate - trigger = CreateTriggerExpression( + trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "t"), table=Table(dummy_dialect, "t"), @@ -260,7 +260,7 @@ def test_create_trigger_when_raises_when_unsupported(self, dummy_dialect: DummyD def test_drop_trigger_if_exists_raises_when_unsupported(self, dummy_dialect: DummyDialect): """DROP TRIGGER IF EXISTS should raise UnsupportedFeatureError when not supported.""" - trigger = DropTriggerExpression( + trigger = DropTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "t"), if_exists=True ) with patch.object(type(dummy_dialect), "supports_trigger_if_exists", return_value=False): @@ -304,24 +304,24 @@ class TestDatabaseExpressions: """Tests for DATABASE DDL expressions.""" def test_create_database_expression(self, dummy_dialect: DummyDialect): - """Test CreateDatabaseExpression generates correct SQL.""" - expr = CreateDatabaseExpression(dummy_dialect, database=Database(dummy_dialect, "testdb")) + """Test CreateDatabaseStatement generates correct SQL.""" + expr = CreateDatabaseStatement(dummy_dialect, database=Database(dummy_dialect, "testdb")) sql, params = expr.to_sql() assert "CREATE DATABASE" in sql assert '"testdb"' in sql or "testdb" in sql assert params == () def test_drop_database_expression(self, dummy_dialect: DummyDialect): - """Test DropDatabaseExpression generates correct SQL.""" - expr = DropDatabaseExpression(dummy_dialect, database=Database(dummy_dialect, "testdb")) + """Test DropDatabaseStatement generates correct SQL.""" + expr = DropDatabaseStatement(dummy_dialect, database=Database(dummy_dialect, "testdb")) sql, params = expr.to_sql() assert "DROP DATABASE" in sql assert '"testdb"' in sql or "testdb" in sql assert params == () def test_alter_database_expression(self, dummy_dialect: DummyDialect): - """Test AlterDatabaseExpression generates correct SQL.""" - expr = AlterDatabaseExpression( + """Test AlterDatabaseStatement generates correct SQL.""" + expr = AlterDatabaseStatement( dummy_dialect, database=Database(dummy_dialect, "testdb"), action=AlterDatabaseAction.RENAME_TO, diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_declared_field_contract.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_declared_field_contract.py new file mode 100644 index 000000000..f9ec186e8 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_declared_field_contract.py @@ -0,0 +1,99 @@ +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_declared_field_contract.py +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_declared_field_contract.py +"""Fields a formatter reads are declared on the node it formats. + +Every one of these pins a case where the formatter used to ask an object +whether it carried an attribute instead of reading a declared field: the +answer was a silent default whenever the field was missing, and the feature +was unreachable when nothing ever set the attribute. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.mixins import AliasableMixin +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.statements.dml import ( + InsertStatement, + OnConflictClause, + ValuesSource, +) + + +class TestDeclaredAlias: + """``alias`` is a field of every aliased expression, not a later attachment.""" + + def test_mixin_declares_alias_so_formatters_can_read_it_directly(self, dummy_dialect): + assert AliasableMixin.alias is None + assert Column(dummy_dialect, "name").alias is None + + def test_as_sets_the_declared_field(self, dummy_dialect): + assert Column(dummy_dialect, "name").as_("n").alias == "n" + + def test_a_catalogue_entry_still_carries_no_alias(self, dummy_dialect): + """The declaration lives on the aliasing mixin, not on every node.""" + assert not hasattr(Table(dummy_dialect, "users"), "alias") + + +class TestDeclaredInsertModifiers: + """SQLite's OR REPLACE / OR IGNORE are fields on the node, not attributes.""" + + def _values_source(self, dummy_dialect): + return ValuesSource(dummy_dialect, [[Literal(dummy_dialect, 1)]]) + + def test_defaults_are_false(self, dummy_dialect): + stmt = InsertStatement( + dummy_dialect, Table(dummy_dialect, "users"), self._values_source(dummy_dialect) + ) + assert (stmt.or_replace, stmt.or_ignore) == (False, False) + + def test_or_replace_renders(self, dummy_dialect): + sqlite = SQLiteDialect() + stmt = InsertStatement( + sqlite, + Table(sqlite, "users"), + self._values_source(sqlite), + or_replace=True, + ) + assert stmt.to_sql()[0].startswith("INSERT OR REPLACE INTO") + + def test_or_ignore_renders(self, dummy_dialect): + sqlite = SQLiteDialect() + stmt = InsertStatement( + sqlite, + Table(sqlite, "users"), + self._values_source(sqlite), + or_ignore=True, + ) + assert stmt.to_sql()[0].startswith("INSERT OR IGNORE INTO") + + def test_a_dialect_that_spells_neither_ignores_the_modifiers(self, dummy_dialect): + """The core spelling is unaffected: the fields default to False.""" + stmt = InsertStatement( + dummy_dialect, + Table(dummy_dialect, "users"), + self._values_source(dummy_dialect), + or_ignore=True, + ) + assert stmt.to_sql()[0].startswith("INSERT INTO") + + def test_the_two_modifiers_are_mutually_exclusive(self, dummy_dialect): + with pytest.raises(ValueError, match="both"): + InsertStatement( + dummy_dialect, + Table(dummy_dialect, "users"), + self._values_source(dummy_dialect), + or_replace=True, + or_ignore=True, + ) + + def test_the_modifiers_exclude_on_conflict(self, dummy_dialect): + with pytest.raises(ValueError, match="on_conflict"): + InsertStatement( + dummy_dialect, + Table(dummy_dialect, "users"), + self._values_source(dummy_dialect), + or_ignore=True, + on_conflict=OnConflictClause(dummy_dialect, ["id"], do_nothing=True), + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_dialect_binding_semantics.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_dialect_binding_semantics.py index d648a4f52..f5d5cc5ed 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_dialect_binding_semantics.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_dialect_binding_semantics.py @@ -36,20 +36,22 @@ """ import pytest - -from rhosocial.activerecord.backend.expression.types import ( - ArrayType, - IntegerType, - VarCharType, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, ) -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, - CreateTableExpression, + CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + IntegerType, + VarCharType, +) + class TestExplicitPerNodeBinding: @@ -65,7 +67,7 @@ def test_explicit_binding_no_length(self, dummy_dialect: DummyDialect): def test_nested_tree_explicit_per_node(self, dummy_dialect: DummyDialect): # 嵌套树:每一层构造时都显式传方言。 - arr = ArrayType(dummy_dialect, IntegerType(dummy_dialect)) + arr = ArrayType(dummy_dialect, element_type=IntegerType(dummy_dialect)) assert arr._dialect is dummy_dialect assert arr.element_type._dialect is dummy_dialect sql, _ = arr.to_sql() @@ -74,7 +76,7 @@ def test_nested_tree_explicit_per_node(self, dummy_dialect: DummyDialect): def test_ddl_tree_explicit_per_node(self, dummy_dialect: DummyDialect): # 端到端:DDL 全树逐节点显式方言——两种写法渲染结果一致。 def build(varchar: VarCharType) -> str: - expr = CreateTableExpression( + expr = CreateTableStatement( dummy_dialect, Table(dummy_dialect, 't'), [ @@ -83,9 +85,7 @@ def build(varchar: VarCharType) -> str: "id", IntegerType(dummy_dialect), constraints=[ - ColumnConstraint( - dummy_dialect, ColumnConstraintType.PRIMARY_KEY - ) + ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect)) ], ), ColumnDefinition(dummy_dialect, "name", varchar), @@ -108,7 +108,7 @@ class TestPerNodeInjection: def test_injection_walk_binds_every_node(self, dummy_dialect: DummyDialect): # 声明期形态:全部无方言。 - arr = ArrayType(None, IntegerType(None)) + arr = ArrayType(None, element_type=IntegerType(None)) col = ColumnDefinition(None, "name", arr) tree = [col] @@ -149,7 +149,7 @@ def test_deferred_matches_explicit(self, dummy_dialect: DummyDialect): def test_setter_does_not_propagate(self, dummy_dialect: DummyDialect): # setter 只影响本节点——这是 D4 决策的锚点断言。 - arr = ArrayType(None, IntegerType(None)) + arr = ArrayType(None, element_type=IntegerType(None)) arr.dialect = dummy_dialect assert arr._dialect is dummy_dialect assert arr.element_type._dialect is None # 未传播 diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_contract.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_contract.py deleted file mode 100644 index 6222eb456..000000000 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_contract.py +++ /dev/null @@ -1,160 +0,0 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_contract.py -""" -Contract test: every built-in expression class must satisfy the -"__init__ parameter ↔ stored attribute" convention that powers the -introspection-based default implementation of BaseExpression.get_params(). - -For each registered expression class, an instance is constructed with -heuristic placeholder arguments, then get_params() is required to return -a value for every __init__ parameter (excluding self/dialect/**kwargs) -without emitting the "cannot find attribute" warning. - -Classes that cannot be auto-constructed must be listed explicitly in -KNOWN_NON_AUTO_CONSTRUCTIBLE with a reason, so new violations fail loudly. -""" - -import inspect -import warnings - -from rhosocial.activerecord.backend.expression.core import Literal -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry - -from rhosocial.activerecord.testsuite.utils.expression import ( - _try_construct, - special_constructors, -) - - -# Classes that legitimately cannot be auto-constructed with heuristic -# fillers (e.g. require real backend objects). Any new entry must carry -# a justification. -KNOWN_NON_AUTO_CONSTRUCTIBLE = { - # class FQN suffix : reason - "types.enum_.EnumType": "requires non-empty `values` (structural " - "construction check raises ValueError on None/[])", - "statements.ddl_alter.AlterConstraint": "requires a non-empty constraint name", - "statements.ddl_alter.ValidateConstraint": "requires a non-empty constraint name", -"statements.ddl_domain.AddDomainCheckAction": "requires a concrete DomainCheckConstraint", - "statements.ddl_domain.DomainCheckConstraint": "requires a concrete check condition", - # The XML constructors take a sequence of their own item types, and an - # empty one is structurally meaningless -- there is no value to check and - # nothing to render. - "expression.xml.XMLAttributesExpression": "requires a non-empty attribute list", - "expression.xml.XMLForestExpression": "requires a non-empty forest item list", - "expression.xml.XMLConcatExpression": "requires a non-empty part list", - "expression.xml.XMLTableExpression": "requires a row-specifying context item", - "expression.uuid.UUIDConstantExpression": "requires `which` to name a " - "constant kind ('nil' / 'max'); __init__ rejects anything else, and a " - "heuristic filler has no way to guess a valid one", - "types.custom.CustomType": "requires `raw`, the backend's own spelling of " - "the type. It is the escape hatch for a type name this library does " - "not model -- DOUBLE PRECISION, a vendor's domain type -- and there is " - "nothing to guess: the name is the whole point, and it is validated as " - "a grammar rather than accepted blindly", -} - - - - -class TestInitParamAttributeContract: - """For every registered expression class, get_params() must resolve - every __init__ parameter without emitting the fallback warning.""" - - def test_all_builtin_expressions(self, dummy_dialect): - ExpressionRegistry._auto_register_builtins() - specials = special_constructors() - skipped = [] - checked = 0 - for fqn, cls in sorted(ExpressionRegistry._registry.items()): - if inspect.isabstract(cls): - continue - special = next((fn for s, fn in specials.items() if fqn.endswith(s)), None) - if special is not None: - instance = special(dummy_dialect) - else: - instance = _try_construct(cls, dummy_dialect) - if instance is None: - skipped.append(fqn) - continue - checked += 1 - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - params = instance.get_params() - missing_warnings = [ - w for w in caught if "cannot find attribute" in str(w.message) - ] - assert not missing_warnings, ( - f"{fqn}: get_params() could not resolve attributes: " - f"{[str(w.message) for w in missing_warnings]}" - ) - sig = inspect.signature(cls.__init__) - for pname, param in sig.parameters.items(): - if pname in ("self", "dialect"): - continue - if param.kind == inspect.Parameter.VAR_KEYWORD: - continue - if pname not in params: - # Round-trip safe omission: a defaulted parameter whose - # current value equals its default may be omitted from - # get_params() (the deserializer restores the default). - assert param.default is not inspect.Parameter.empty, ( - f"{fqn}: required __init__ parameter '{pname}' missing " - "from get_params() result" - ) - attr = getattr(instance, f"_{pname}", getattr(instance, pname, None)) - assert attr == param.default, ( - f"{fqn}: __init__ parameter '{pname}' omitted from " - f"get_params() but its value {attr!r} != default " - f"{param.default!r}; round-trip would lose state" - ) - - unexpected_skips = [ - fqn for fqn in skipped - if not any(fqn.endswith(suffix) for suffix in KNOWN_NON_AUTO_CONSTRUCTIBLE) - ] - assert checked > 0, "no expression classes were auto-constructed" - assert not unexpected_skips, ( - "Classes could not be auto-constructed (add to " - "KNOWN_NON_AUTO_CONSTRUCTIBLE with a reason, or fix): " - + ", ".join(unexpected_skips) - ) - - def test_no_get_params_override(self): - """The generic get_params() is the single serialization path. - - Every registered expression class must NOT override get_params(). - The only exception is a VAR_POSITIONAL class that performs genuine - raw-vs-normalized round-trip rewriting (e.g. JSONArrayExpression - style) - none exist in the core registry, so the rule is absolute - here. Adding an override reintroduces the special-casing that - caused serialization gaps; instead fold state into __init__. - """ - ExpressionRegistry._auto_register_builtins() - import inspect as _inspect - from rhosocial.activerecord.backend.expression.bases import BaseExpression - offenders = [] - for fqn, cls in ExpressionRegistry._registry.items(): - if inspect.isabstract(cls): - continue - sig = _inspect.signature(cls.__init__) - is_vararg = any( - p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) - for p in sig.parameters.values() - ) - if cls.get_params is not BaseExpression.get_params: - offenders.append((fqn, is_vararg)) - assert not offenders, ( - "Expression classes must not override get_params() (generic path " - "covers __init__-params and same-name kwargs). Fold state into " - "__init__ instead. Offenders: " - ) + ", ".join(f"{f} (vararg={v})" for f, v in offenders) - - def test_var_keyword_resolves_same_name_attr(self, dummy_dialect): - """VAR_KEYWORD extras must serialize as merged top-level params.""" - from rhosocial.activerecord.backend.expression.collation import CollateExpression - expr = CollateExpression(dummy_dialect, Literal(dummy_dialect, "a"), "NOCASE", - binary=True, pad="PAD") - params = expr.get_params() - assert params.get("binary") is True - assert params.get("pad") == "PAD" - assert "collation_options" not in params diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_coverage_gaps.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_coverage_gaps.py index bfa3feb52..787e944a5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_coverage_gaps.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_coverage_gaps.py @@ -4,9 +4,10 @@ Targets: core.py:69,177 / bases.py:16 / executable.py:35 / operators.py:153 """ +from rhosocial.activerecord.backend.expression.functions import cast from rhosocial.activerecord.backend.expression import Column, Literal, Subquery from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( CustomType, @@ -37,13 +38,14 @@ class TestLiteralCastTypes: def test_literal_with_single_cast(self, dummy_dialect): lit = Literal(dummy_dialect, 42) - expr = lit.cast(IntegerType(dummy_dialect)) + expr = cast(dummy_dialect, lit, IntegerType(dummy_dialect)) sql, params = expr.to_sql() assert "CAST" in sql or "INTEGER" in sql def test_literal_with_chained_casts(self, dummy_dialect): lit = Literal(dummy_dialect, "100") - expr = lit.cast(CustomType(dummy_dialect, raw='money')).cast(DecimalType(dummy_dialect)) + expr = cast(dummy_dialect, cast(dummy_dialect, lit, CustomType(dummy_dialect, raw='money')), + DecimalType(dummy_dialect)) sql, params = expr.to_sql() assert sql # Verify it doesn't crash @@ -58,13 +60,13 @@ def test_subquery_with_cast(self, dummy_dialect): assert "CAST" in sql or "TEXT" in sql -class TestBinaryArithmeticCastTypes: +class TestArithmeticOperationCastTypes: """Arithmetic cast goes through the CastExpression AST node.""" def test_arithmetic_with_cast(self, dummy_dialect): left = Column(dummy_dialect, "price") right = Literal(dummy_dialect, 10) - expr = BinaryArithmeticExpression(dummy_dialect, "+", left, right) + expr = ArithmeticOperation(dummy_dialect, "add", left, right) casted = expr.cast(DecimalType(dummy_dialect)) sql, params = casted.to_sql() assert "CAST" in sql or "DECIMAL" in sql @@ -75,11 +77,11 @@ class TestExecutableProtocol: def test_executable_protocol_isinstance_check(self, dummy_dialect): from rhosocial.activerecord.backend.expression.executable import Executable - from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.core import Column - # QueryExpression implements Executable via statement_type - query = QueryExpression( + # QueryStatement implements Executable via statement_type + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) assert isinstance(query, Executable) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_data_types.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_data_types.py index 73ee50d3f..8b1761b82 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_data_types.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_data_types.py @@ -14,8 +14,9 @@ ComparisonPredicate, FunctionCall, JSONPathMode, - ArrayExpression, -) + ArrayOperation, + NumericColumn, + StringColumn,) from rhosocial.activerecord.backend.expression.functions import count, sum_ from rhosocial.activerecord.backend.expression.types import ( ArrayType, @@ -77,12 +78,12 @@ def test_numeric_types_equality_hash(self): assert FloatType(None, 24) == FloatType(None, 24) def test_custom_type_equality_hash(self): - assert CustomType(None, "geometry") == CustomType(None, "geometry") - assert CustomType(None, "geometry") != CustomType(None, "geography") - assert hash(CustomType(None, "geometry")) == hash(CustomType(None, "geometry")) + assert CustomType(None, raw="geometry") == CustomType(None, raw="geometry") + assert CustomType(None, raw="geometry") != CustomType(None, raw="geography") + assert hash(CustomType(None, raw="geometry")) == hash(CustomType(None, raw="geometry")) def test_custom_type_roundtrip_sql(self, dialect): - t = CustomType(dialect, "GEOMETRY") + t = CustomType(dialect, raw="GEOMETRY") assert t.to_sql() == ("GEOMETRY", ()) def test_data_type_without_dialect_raises(self): @@ -100,25 +101,25 @@ class TestArrayType: """Array container type semantics.""" def test_array_equality(self): - assert ArrayType(None, IntegerType()) == ArrayType(None, IntegerType()) - assert ArrayType(None, IntegerType(), dimensions=2) != ArrayType(None, IntegerType()) + assert ArrayType(None, element_type=IntegerType()) == ArrayType(None, element_type=IntegerType()) + assert ArrayType(None, element_type=IntegerType(), dimensions=2) != ArrayType(None, element_type=IntegerType()) def test_array_hash(self): - assert hash(ArrayType(None, IntegerType())) == hash(ArrayType(None, IntegerType())) + assert hash(ArrayType(None, element_type=IntegerType())) == hash(ArrayType(None, element_type=IntegerType())) def test_array_is_equivalent(self): - assert ArrayType(None, IntegerType()) == ArrayType(None, IntegerType()) - assert ArrayType(None, IntegerType(), dimensions=2) != ArrayType(None, IntegerType()) + assert ArrayType(None, element_type=IntegerType()) == ArrayType(None, element_type=IntegerType()) + assert ArrayType(None, element_type=IntegerType(), dimensions=2) != ArrayType(None, element_type=IntegerType()) def test_array_is_element_type_equivalent_with_array(self): """``is_element_type_equivalent`` answers a different question from ``==``: not "is this the same declaration" but "does this array store the same kind of thing", so dimensions are ignored.""" - assert ArrayType(None, IntegerType()).is_element_type_equivalent(ArrayType(None, IntegerType(), dimensions=2)) + assert ArrayType(None, element_type=IntegerType()).is_element_type_equivalent(ArrayType(None, element_type=IntegerType(), dimensions=2)) def test_array_is_element_type_equivalent_with_plain_type(self): - assert ArrayType(None, IntegerType()).is_element_type_equivalent(IntegerType()) - assert not ArrayType(None, IntegerType()).is_element_type_equivalent(CharType(None, 10)) + assert ArrayType(None, element_type=IntegerType()).is_element_type_equivalent(IntegerType()) + assert not ArrayType(None, element_type=IntegerType()).is_element_type_equivalent(CharType(None, 10)) def test_array_declares_its_identity_fields(self): """An array's identity is its element type and its dimensionality. @@ -127,16 +128,16 @@ def test_array_declares_its_identity_fields(self): ``__hash__`` and ``repr`` cannot each answer the question differently. """ assert ArrayType.PARAMETERS == ("element_type", "dimensions") - arr = ArrayType(None, IntegerType(), dimensions=2) + arr = ArrayType(None, element_type=IntegerType(), dimensions=2) assert arr.identity() == (IntegerType(), 2) # and a dimensionality change is a change of declaration - assert arr != ArrayType(None, IntegerType(), dimensions=1) + assert arr != ArrayType(None, element_type=IntegerType(), dimensions=1) def test_array_repr(self): - assert "ArrayType" in repr(ArrayType(None, IntegerType())) + assert "ArrayType" in repr(ArrayType(None, element_type=IntegerType())) def test_array_expression_constructor(self, dialect): - expr = ArrayExpression( + expr = ArrayOperation( dialect, "CONSTRUCTOR", elements=[Literal(dialect, 1), Literal(dialect, 2)], @@ -172,7 +173,7 @@ class TestAggregateGetParamsWithFilter: def test_get_params_with_filter(self, dialect): agg = count(dialect, "*", alias="active_count").filter( - Column(dialect, "status") == Literal(dialect, "active") + StringColumn(dialect, "status") == Literal(dialect, "active") ) params = agg.get_params() assert "filter_predicate" in params @@ -185,12 +186,12 @@ def test_get_params_without_filter(self, dialect): def test_filter_returns_self(self, dialect): agg = FunctionCall(dialect, "COUNT", "*", is_aggregate=True) - predicate = Column(dialect, "status") == Literal(dialect, "active") + predicate = StringColumn(dialect, "status") == Literal(dialect, "active") assert agg.filter(predicate) is agg assert agg.get_params()["filter_predicate"] is predicate def test_chained_filters_combine(self, dialect): agg = FunctionCall(dialect, "COUNT", "*", is_aggregate=True) - agg.filter(Column(dialect, "a") == Literal(dialect, 1)) - agg.filter(Column(dialect, "b") == Literal(dialect, 2)) + agg.filter(NumericColumn(dialect, "a") == Literal(dialect, 1)) + agg.filter(NumericColumn(dialect, "b") == Literal(dialect, 2)) assert "AND" in agg.get_params()["filter_predicate"].to_sql()[0] diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_edge_branches.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_edge_branches.py index d46cb1e4f..b510e3a7e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_edge_branches.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_edge_branches.py @@ -10,16 +10,18 @@ introspection to_sql() and ``date_diff`` must raise. """ -import warnings +from rhosocial.activerecord.backend.expression.functions import cast import pytest +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, - QualifiedIdentifierExpression, - WildcardExpression, + RawSQL, + QualifiedIdentifier, + AllColumnsWildcard, ) from rhosocial.activerecord.backend.expression.types import ( ArrayType, @@ -37,18 +39,18 @@ VarCharType, ) from rhosocial.activerecord.backend.expression.introspection import ( - ColumnInfoExpression, - DatabaseInfoExpression, - ForeignKeyExpression, - IndexInfoExpression, - TableInfoExpression, - TableListExpression, - TriggerInfoExpression, - TriggerListExpression, - ViewInfoExpression, - ViewListExpression, + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableInfoQueryStatement, + TableListQueryStatement, + TriggerInfoQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, ) -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.statements import ( OnConflictClause, PartitionClause, @@ -56,7 +58,6 @@ ) from rhosocial.activerecord.backend.expression.functions.string import trim from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.objects import Table @pytest.fixture @@ -68,38 +69,38 @@ class TestCoreExpressionBranches: """Branch coverage for core/operator expressions.""" def test_literal_cast_and_alias(self, dialect): - expr = Literal(dialect, 42).cast(DecimalType(dialect)).as_("n") + expr = cast(dialect, Literal(dialect, 42), DecimalType(dialect)).as_("n") sql, params = expr.to_sql() assert "CAST" in sql or "DECIMAL" in sql assert "AS" in sql assert params == (42,) def test_qualified_identifier_with_schema(self, dialect): - expr = QualifiedIdentifierExpression(dialect, schema="app", name="users") + expr = QualifiedIdentifier(dialect, schema="app", name="users") sql, params = expr.to_sql() assert sql == '"app"."users"' assert params == () def test_qualified_identifier_without_schema(self, dialect): - expr = QualifiedIdentifierExpression(dialect, name="users") + expr = QualifiedIdentifier(dialect, name="users") sql, params = expr.to_sql() assert sql == '"users"' assert params == () def test_wildcard_expression_basic(self, dialect): - expr = WildcardExpression(dialect) + expr = AllColumnsWildcard(dialect) sql, params = expr.to_sql() assert sql == "*" assert params == () def test_wildcard_expression_qualified(self, dialect): - expr = WildcardExpression(dialect, table="users", schema_name="app") + expr = AllColumnsWildcard(dialect, relation=NamedRelationRef(dialect, Table(dialect, "users"))) sql, params = expr.to_sql() assert "*" in sql assert params == () - def test_binary_arithmetic_with_cast(self, dialect): - expr = BinaryArithmeticExpression(dialect, "+", Column(dialect, "price"), Literal(dialect, 10)) + def test_arithmetic_operation_with_cast(self, dialect): + expr = ArithmeticOperation(dialect, "add", Column(dialect, "price"), Literal(dialect, 10)) casted = expr.cast(DecimalType(dialect)) aliased = casted.as_("total") sql, params = aliased.to_sql() @@ -107,7 +108,7 @@ def test_binary_arithmetic_with_cast(self, dialect): assert "total" in sql def test_raw_sql_expression(self, dialect): - expr = RawSQLExpression(dialect, "NOW()") + expr = RawSQL(dialect, "NOW()") sql, params = expr.to_sql() assert sql == "NOW()" assert params == () @@ -119,12 +120,12 @@ class TestGetParamsFallback: def test_var_positional_params(self, dialect): # An expression subclass whose __init__ uses *args: get_params collects # them as a list. - from rhosocial.activerecord.backend.expression.bases import SQLValueExpression + from rhosocial.activerecord.backend.expression.bases import SQLValue - class ArgsExpr(SQLValueExpression): + class ArgsExpr(SQLValue): def __init__(self, dialect, *values, **kwargs): super().__init__(dialect) - self._values = list(values) + self.values = list(values) self.kwargs = dict(kwargs) self.extra = kwargs.get("extra") @@ -137,12 +138,17 @@ def to_sql(self): assert params["values"] == [1, 2, 3] assert params["extra"] == "x" - def test_get_params_missing_attribute_warns(self, dialect): - # A subclass whose declared __init__ parameter has no matching - # _name/name attribute triggers a warning. - from rhosocial.activerecord.backend.expression.bases import SQLValueExpression + def test_get_params_missing_attribute_raises(self, dialect): + """A parameter the class did not store is a defect, and it says so. + + This used to warn and carry on, which put the omission in the artifact + with nothing to show for it: a serialized node that reconstructs into a + different tree. Reading the parameter's own name has no fallback to + reach for, so the AttributeError names the class and the parameter. + """ + from rhosocial.activerecord.backend.expression.bases import SQLValue - class MissingAttrExpr(SQLValueExpression): + class MissingAttrExpr(SQLValue): def __init__(self, dialect, some_param): super().__init__(dialect) self._x = 1 @@ -151,11 +157,8 @@ def to_sql(self): return "x", () expr = MissingAttrExpr(dialect, 123) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - params = expr.get_params() - assert any("get_params" in str(w.message) for w in caught) - assert "some_param" not in params + with pytest.raises(AttributeError, match="some_param"): + expr.get_params() class TestTrimDirectionValidation: @@ -241,56 +244,56 @@ class TestOnConflictValidation: def test_non_list_on_conflict(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ValuesSource, ) source = ValuesSource(dialect, [[Literal(dialect, 1)]]) with pytest.raises(TypeError): - InsertExpression(dialect, into=Table(dialect, 'users'), source=source, on_conflict="bad") + InsertStatement(dialect, into=Table(dialect, 'users'), source=source, on_conflict="bad") def test_non_onconflict_clause_item(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ValuesSource, ) source = ValuesSource(dialect, [[Literal(dialect, 1)]]) with pytest.raises(TypeError): - InsertExpression(dialect, into=Table(dialect, 'users'), source=source, on_conflict=["bad", "worse"]) + InsertStatement(dialect, into=Table(dialect, 'users'), source=source, on_conflict=["bad", "worse"]) def test_valid_on_conflict(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ValuesSource, ) source = ValuesSource(dialect, [[Literal(dialect, 1)]]) conflict = OnConflictClause(dialect, ["id"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) + expr = InsertStatement(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) assert expr is not None assert expr.on_conflict == [conflict] def test_validate_with_on_conflict(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ValuesSource, ) source = ValuesSource(dialect, [[Literal(dialect, 1)]]) conflict = OnConflictClause(dialect, ["id"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) + expr = InsertStatement(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) expr.validate() def test_validate_on_conflict_bad_item(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ValuesSource, ) source = ValuesSource(dialect, [[Literal(dialect, 1)]]) conflict = OnConflictClause(dialect, ["id"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) + expr = InsertStatement(dialect, into=Table(dialect, 'users'), source=source, on_conflict=[conflict]) expr.on_conflict = [conflict, "bad"] with pytest.raises(TypeError): expr.validate() @@ -300,73 +303,53 @@ class TestIntrospectionExpressionStructure: """Generic introspection expression construction (dialect-independent).""" def test_table_list_include_system(self, dialect): - expr = TableListExpression(dialect).include_system(True) + expr = TableListQueryStatement(dialect, include_system=True) assert expr.get_params()["include_system"] is True def test_table_list_table_type(self, dialect): - expr = TableListExpression(dialect).table_type("VIEW") + expr = TableListQueryStatement(dialect, table_type="VIEW") assert expr.get_params()["table_type"] == "VIEW" def test_table_info_include_indexes(self, dialect): - expr = TableInfoExpression(dialect, "users").include_indexes(True) + expr = TableInfoQueryStatement(dialect, "users", include_indexes=True) assert expr.get_params()["include_indexes"] is True def test_table_info_include_foreign_keys(self, dialect): - expr = TableInfoExpression(dialect, "users").include_foreign_keys(True) + expr = TableInfoQueryStatement(dialect, "users", include_foreign_keys=True) assert expr.get_params()["include_foreign_keys"] is True def test_column_info_include_hidden(self, dialect): - expr = ColumnInfoExpression(dialect, "users").include_hidden(True) + expr = ColumnInfoQueryStatement(dialect, "users", include_hidden=True) assert expr.get_params()["include_hidden"] is True def test_index_info_table_name(self, dialect): - expr = IndexInfoExpression(dialect, "users") + expr = IndexInfoQueryStatement(dialect, "users") assert expr.get_params()["table_name"] == "users" def test_foreign_key_table_name(self, dialect): - expr = ForeignKeyExpression(dialect, "users") + expr = ForeignKeyQueryStatement(dialect, "users") assert expr.get_params()["table_name"] == "users" def test_view_list(self, dialect): - expr = ViewListExpression(dialect) + expr = ViewListQueryStatement(dialect) assert "include_system" in expr.get_params() def test_view_info(self, dialect): - expr = ViewInfoExpression(dialect, "my_view").include_columns(True) + expr = ViewInfoQueryStatement(dialect, "my_view", include_columns=True) params = expr.get_params() assert params["view_name"] == "my_view" assert params["include_columns"] is True def test_trigger_info(self, dialect): - expr = TriggerInfoExpression(dialect, "my_trigger").for_table("users") + expr = TriggerInfoQueryStatement(dialect, "my_trigger", table_name="users") params = expr.get_params() assert params["trigger_name"] == "my_trigger" assert params["table_name"] == "users" def test_trigger_list(self, dialect): - expr = TriggerInfoExpression(dialect, "t").for_table("users") + expr = TriggerInfoQueryStatement(dialect, "t", table_name="users") assert "table_name" in expr.get_params() - def test_table_info_table_name_setter(self, dialect): - expr = TableInfoExpression(dialect, "old").table_name("new") - assert expr.get_params()["table_name"] == "new" - - def test_column_info_table_name_setter(self, dialect): - expr = ColumnInfoExpression(dialect, "old").table_name("new") - assert expr.get_params()["table_name"] == "new" - - def test_index_info_table_name_setter(self, dialect): - expr = IndexInfoExpression(dialect, "old").table_name("new") - assert expr.get_params()["table_name"] == "new" - - def test_foreign_key_table_name_setter(self, dialect): - expr = ForeignKeyExpression(dialect, "old").table_name("new") - assert expr.get_params()["table_name"] == "new" - - def test_view_info_view_name_setter(self, dialect): - expr = ViewInfoExpression(dialect, "old").view_name("new") - assert expr.get_params()["view_name"] == "new" - class TestCrossTypeDataTypes: """Cross-type inequality branches (type(self) is not type(other)).""" @@ -382,7 +365,7 @@ def test_datetime_types_inequality(self): def test_string_types_inequality(self): assert CharType(None, 10) != VarCharType(None, 10) assert VarCharType(None, 10) != CharType(None, 10) - assert CharType(None, 10) != CustomType(None, "CHAR(10)") + assert CharType(None, 10) != CustomType(None, raw="CHAR(10)") def test_numeric_types_inequality(self): assert FloatType(None, 24) != DecimalType(None, 10, 2) @@ -390,10 +373,10 @@ def test_numeric_types_inequality(self): assert FloatType(None, 24) != IntegerType() def test_array_and_custom_inequality(self): - assert ArrayType(None, IntegerType()) != ArrayType(None, CharType(None, 10)) - assert ArrayType(None, IntegerType()) != CharType(None, 10) - assert ArrayType(None, IntegerType()) != CharType(None, 10) - assert CustomType(None, "geometry") != IntegerType() + assert ArrayType(None, element_type=IntegerType()) != ArrayType(None, element_type=CharType(None, 10)) + assert ArrayType(None, element_type=IntegerType()) != CharType(None, 10) + assert ArrayType(None, element_type=IntegerType()) != CharType(None, 10) + assert CustomType(None, raw="geometry") != IntegerType() def test_hash_branches(self): assert hash(CharType(None, 10)) == hash(CharType(None, 10)) @@ -405,12 +388,6 @@ def test_timetz_timestamptz_equality(self): assert TimestampTzType(None, 0) == TimestampTzType(None, 0) assert TimestampTzType(None, 0) != TimestampTzType(None, 1) - def test_trigger_info_trigger_name_setter(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import TriggerInfoExpression - - expr = TriggerInfoExpression(dialect, "old").trigger_name("new") - assert expr.get_params()["trigger_name"] == "new" - class TestXmlFunctionsTupleArgs: """xmlattributes/xmlforest accept (value, name) tuples.""" @@ -470,16 +447,16 @@ class TestIntrospectionToSqlUnsupported: @pytest.mark.parametrize( "factory", [ - lambda d: DatabaseInfoExpression(d), - lambda d: TableListExpression(d), - lambda d: TableInfoExpression(d, "users"), - lambda d: ColumnInfoExpression(d, "users"), - lambda d: IndexInfoExpression(d, "users"), - lambda d: ForeignKeyExpression(d, "users"), - lambda d: ViewListExpression(d), - lambda d: ViewInfoExpression(d, "v"), - lambda d: TriggerListExpression(d), - lambda d: TriggerInfoExpression(d, "t"), + lambda d: DatabaseInfoQueryStatement(d), + lambda d: TableListQueryStatement(d), + lambda d: TableInfoQueryStatement(d, "users"), + lambda d: ColumnInfoQueryStatement(d, "users"), + lambda d: IndexInfoQueryStatement(d, "users"), + lambda d: ForeignKeyQueryStatement(d, "users"), + lambda d: ViewListQueryStatement(d), + lambda d: ViewInfoQueryStatement(d, "v"), + lambda d: TriggerListQueryStatement(d), + lambda d: TriggerInfoQueryStatement(d, "t"), ], ) def test_to_sql_raises(self, dialect, factory): diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_roundtrip_all.py deleted file mode 100644 index 98d2c7f53..000000000 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_roundtrip_all.py +++ /dev/null @@ -1,1041 +0,0 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_roundtrip_all.py -""" -Functional serialization coverage: every registered expression class must -round-trip losslessly through all three encodings (dict / JSON string / XML). - -For each expression class that can be constructed: - 1. dict round-trip : deserialize(serialize(e)).get_params() == e.get_params() - 2. JSON round-trip : deserialize_json(serialize_json(e)).get_params() == ... - 3. XML round-trip : deserialize_xml(serialize_xml(e)).get_params() == ... - 4. SQL consistency : classified, never swallowed -- see below. - -Why ``to_sql()`` is classified rather than caught -================================================= - -This matrix used to wrap the first render in ``try/except Exception: return``, -which made every render failure a green tick: the three SQL comparisons never -ran, so a formatter reading a field that no longer existed was indistinguishable -from a formatter for a feature the dialect does not support. That is the same -vacuity that let a namespace test pass while the formatter ignored the namespace -it claimed to be testing. - -Each outcome is now named and asserted: - -* **renders** -- all three encodings must restore byte-identical SQL *and* - byte-identical bind parameters. -* ``UnsupportedFeatureError`` -- this dialect does not model the feature. - Asserted as exactly that type, so a different error cannot hide behind it. -* a member of :data:`LEGITIMATE_NON_RENDERS` -- a class that cannot render for a - reason belonging to its own tree. Each entry pins the exception type *and* a - message fragment, so a class that started failing for a different reason fails - here instead of staying quietly green. -* **anything else** -- a failure naming the class and the exception. - -And what happens when a class cannot be constructed -================================================== - -``make_instance(...) is None`` becomes a skip, but only for a class named in -:data:`UNCONSTRUCTIBLE`, and :func:`test_unconstructible_list_is_exact` pins that -tuple. A class that starts needing an exemption fails CI instead of turning into -a skip, and a stale entry fails too. -""" - -import inspect -from typing import Dict - -import pytest - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import graph as graph_mod -from rhosocial.activerecord.backend.expression.advanced_functions import ( - CaseExpression, - WindowClause, - WindowDefinition, - WindowSpecification, -) -from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.core import Column, Literal -from rhosocial.activerecord.backend.expression.datetime import ( - TemporalOptionsExpression, -) -from rhosocial.activerecord.backend.expression.objects import ( - Database, - Domain, - EdgeTable as EdgeTableObject, - Function, - Index, - MaterializedView, - NodeTable, - PropertyGraph, - Sequence, - Schema, - Table, - Trigger, - Type, - View, -) -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.query_parts import JoinClause -from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, - deserialize, - deserialize_json, - deserialize_xml, - serialize, - serialize_json, - serialize_xml, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.statements import ( - ddl_alter, - ddl_comment, - ddl_database, - ddl_domain, - ddl_function, - ddl_index, - ddl_schema, - ddl_sequence, - ddl_table, - ddl_trigger, - ddl_truncate, - ddl_type, - ddl_view, - dml, -) -from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseAction, -) -from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( - DomainCheckConstraint, - RenameDomainAction, -) -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraint, - ColumnConstraintType, - ForeignKeyConstraint, - IndexDefinition, - ReferencesClause, - TableConstraint, - TableConstraintType, -) -from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - TriggerEvent, - TriggerTiming, -) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression -from rhosocial.activerecord.backend.expression.statements.dml import ( - MergeAction, - MergeActionType, -) -from rhosocial.activerecord.backend.expression.types import ( - ArrayType, - IntegerType, - VarCharType, -) -from rhosocial.activerecord.backend.expression.xml import ( - XMLAttribute, - XMLAttributesExpression, - XMLConcatExpression, - XMLForestExpression, - XMLForestItem, -) -from rhosocial.activerecord.backend.impl.dummy.expression import ( - _DummyTypeAlterAction, - _DummyTypeDefinition, -) -from rhosocial.activerecord.testsuite.utils.expression import ( - collect_expression_classes, - make_instance, - register_special_constructor, -) - - -def assert_params_equal(a, b, path="params"): - """Deep compare two get_params() dicts, treating nested BaseExpression - instances as structurally equal when their get_params() match.""" - if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): - assert_params_equal(a.get_params(), b.get_params(), path + ".") - 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}" - - -CORE_EXPR_PKG = "rhosocial.activerecord.backend.expression" - - -def _collect_matrix_classes(): - """Every concrete expression class the core package defines. - - Collected by walking the package, not by reading ``ExpressionRegistry``: - the registry is process-global and *grows* as other test modules import - their own backends, so reading it makes this matrix's contents depend on - which files pytest happened to import first. Several sqlite backend - classes appear in the registry once ``impl.sqlite.expression`` is loaded, - and they would then be rendered with the Dummy dialect -- which is not a - meaningful thing to assert. Walking the package is deterministic. - - ``ddl_alter`` exports four aliases of two classes (``AlterConstraint`` and - ``ValidateConstraint`` each have two spellings). They are the same objects, - so the first name wins and the matrix does not test a class four times. - """ - ExpressionRegistry._auto_register_builtins() - collected = collect_expression_classes(CORE_EXPR_PKG) - by_identity: Dict[int, str] = {} - for fqn, cls in sorted(collected.items()): - by_identity.setdefault(id(cls), fqn) - return { - fqn: cls - for cls in collected.values() - if not inspect.isabstract(cls) - for fqn in [by_identity[id(cls)]] - } - - -REGISTERED = _collect_matrix_classes() - - -# --------------------------------------------------------------------------- -# Special constructors: a real value where the introspective guess is a lie -# --------------------------------------------------------------------------- -# -# ``make_instance`` reads each required parameter's annotation and guesses: -# ``"x"`` for a string, ``[]`` for a list, ``IntegerType()`` for a type. That is -# right for a name and wrong for every parameter that wants a catalogue object -- -# a Table, an Index, a PropertyGraph -- because a bare ``"x"`` is not one, and the -# formatter now refuses it by name. It is also wrong for the containers that -# require at least one member (CASE, WINDOW, JOIN, temporal options) and for the -# predicates that must render without bind parameters. -# -# Every registration below replaces a guess that would otherwise have produced an -# instance the dialect cannot render. Suffixes are spelled relative to the -# expression package because ``make_instance`` matches with ``str.endswith`` and -# several modules export identically named classes. - - -def _table_obj(dialect, name="t"): - """A table with a bare name and no namespace.""" - return Table(dialect, name) - - -def _column_predicate(dialect): - """A predicate comparing two columns, so it renders with no bind parameters.""" - return ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")) - - -def _one_column_query(dialect): - """A single-column ``SELECT`` over a table.""" - return QueryExpression( - dialect, select=[Column(dialect, "id")], from_=_table_obj(dialect) - ) - - -def _integer_column(dialect, name="col"): - """A column definition carrying a *dialect-bound* type. - - The binding is load-bearing, not cosmetic: ``to_sql()`` dispatches on the - type through its own dialect, so an unbound ``IntegerType()`` raises - ``ValueError: ... has no dialect bound`` the moment anything renders it. - """ - return ddl_table.ColumnDefinition(dialect, name, IntegerType(dialect)) - - -def register_specials(): - """Replace every introspective guess that would cost a real assertion.""" - # -- the FROM side ------------------------------------------------------ - register_special_constructor( - "sources.relation.NamedRelationRef", - lambda d: NamedRelationRef(d, _table_obj(d)), - ) - register_special_constructor( - "query_parts.JoinClause", - lambda d: JoinClause( - d, - left_table=NamedRelationRef(d, _table_obj(d)), - right_table=NamedRelationRef(d, Table(d, "other")), - condition=_column_predicate(d), - ), - ) - - # -- tables ------------------------------------------------------------- - register_special_constructor( - "statements.ddl_table.CreateTableExpression", - lambda d: ddl_table.CreateTableExpression(d, _table_obj(d), [_integer_column(d)]), - ) - register_special_constructor( - "statements.ddl_table.DropTableExpression", - lambda d: ddl_table.DropTableExpression(d, _table_obj(d)), - ) - register_special_constructor( - "statements.ddl_table.CreateTableLikeExpression", - lambda d: ddl_table.CreateTableLikeExpression( - d, _table_obj(d), Table(d, "other") - ), - ) - register_special_constructor( - "statements.ddl_table.CreateTableCloneExpression", - lambda d: ddl_table.CreateTableCloneExpression( - d, _table_obj(d), Table(d, "other") - ), - ) - register_special_constructor( - "statements.ddl_table.CreateTableAsExpression", - lambda d: ddl_table.CreateTableAsExpression(d, _table_obj(d), _one_column_query(d)), - ) - register_special_constructor( - "statements.ddl_table.CreateTableFromTemplateExpression", - lambda d: ddl_table.CreateTableFromTemplateExpression( - d, _table_obj(d), _one_column_query(d) - ), - ) - register_special_constructor( - "statements.ddl_truncate.TruncateExpression", - lambda d: ddl_truncate.TruncateExpression(d, _table_obj(d)), - ) - # Overrides the shared testsuite factory, which binds no dialect to its type. - register_special_constructor( - "statements.ddl_table.ColumnDefinition", _integer_column - ) - - # -- indexes ------------------------------------------------------------ - register_special_constructor( - "statements.ddl_index.CreateIndexExpression", - lambda d: ddl_index.CreateIndexExpression( - d, index=Index(d, "i"), table=_table_obj(d), columns=["a"] - ), - ) - register_special_constructor( - "statements.ddl_index.DropIndexExpression", - lambda d: ddl_index.DropIndexExpression(d, index=Index(d, "i")), - ) - register_special_constructor( - "statements.ddl_index.CreateFulltextIndexExpression", - lambda d: ddl_index.CreateFulltextIndexExpression( - d, index=Index(d, "i"), table=_table_obj(d), columns=["a"] - ), - ) - register_special_constructor( - "statements.ddl_index.DropFulltextIndexExpression", - lambda d: ddl_index.DropFulltextIndexExpression( - d, index=Index(d, "i"), table=_table_obj(d) - ), - ) - register_special_constructor( - "statements.ddl_alter.DropIndex", - lambda d: ddl_alter.DropIndex(d, Index(d, "i")), - ) - - # -- schemas, sequences, databases, domains, types --------------------- - register_special_constructor( - "statements.ddl_schema.CreateSchemaExpression", - lambda d: ddl_schema.CreateSchemaExpression(d, Schema(d, "s")), - ) - register_special_constructor( - "statements.ddl_schema.DropSchemaExpression", - lambda d: ddl_schema.DropSchemaExpression(d, Schema(d, "s")), - ) - register_special_constructor( - "statements.ddl_sequence.CreateSequenceExpression", - lambda d: ddl_sequence.CreateSequenceExpression(d, Sequence(d, "s")), - ) - register_special_constructor( - "statements.ddl_sequence.AlterSequenceExpression", - lambda d: ddl_sequence.AlterSequenceExpression(d, Sequence(d, "s"), restart=1), - ) - register_special_constructor( - "statements.ddl_sequence.DropSequenceExpression", - lambda d: ddl_sequence.DropSequenceExpression(d, Sequence(d, "s")), - ) - register_special_constructor( - "statements.ddl_database.CreateDatabaseExpression", - lambda d: ddl_database.CreateDatabaseExpression(d, Database(d, "db")), - ) - register_special_constructor( - "statements.ddl_database.DropDatabaseExpression", - lambda d: ddl_database.DropDatabaseExpression(d, Database(d, "db")), - ) - register_special_constructor( - "statements.ddl_database.AlterDatabaseExpression", - lambda d: ddl_database.AlterDatabaseExpression( - d, - Database(d, "db"), - action=AlterDatabaseAction.RENAME_TO, - target="renamed_db", - ), - ) - register_special_constructor( - "statements.ddl_domain.CreateDomainExpression", - lambda d: ddl_domain.CreateDomainExpression(d, Domain(d, "dom"), IntegerType(d)), - ) - register_special_constructor( - "statements.ddl_domain.DropDomainExpression", - lambda d: ddl_domain.DropDomainExpression(d, Domain(d, "dom")), - ) - register_special_constructor( - "statements.ddl_domain.AlterDomainExpression", - lambda d: ddl_domain.AlterDomainExpression( - d, Domain(d, "dom"), [RenameDomainAction(d, "other")] - ), - ) - # A DOMAIN CHECK is DDL: it must render without bind parameters, so its - # condition compares two columns rather than a column and a literal. - register_special_constructor( - "statements.ddl_domain.DomainCheckConstraint", - lambda d: DomainCheckConstraint(d, _column_predicate(d), name="chk"), - ) - # Likewise `_DummyTypeDefinition`: its body is a type, which must be bound. - register_special_constructor( - "dummy.expression._DummyTypeDefinition", - lambda d: _DummyTypeDefinition(d, IntegerType(d)), - ) - register_special_constructor( - "statements.ddl_type.CreateTypeExpression", - lambda d: ddl_type.CreateTypeExpression( - d, type=Type(d, "t"), definition=_DummyTypeDefinition(d, IntegerType(d)) - ), - ) - register_special_constructor( - "statements.ddl_type.AlterTypeExpression", - lambda d: ddl_type.AlterTypeExpression( - d, type=Type(d, "t"), actions=[_DummyTypeAlterAction(d, new_name="u")] - ), - ) - register_special_constructor( - "statements.ddl_type.DropTypeExpression", - lambda d: ddl_type.DropTypeExpression(d, type=Type(d, "t")), - ) - - # -- routines, triggers, comments -------------------------------------- - register_special_constructor( - "statements.ddl_function.CreateFunctionExpression", - lambda d: ddl_function.CreateFunctionExpression( - d, Function(d, "fn"), returns="integer", body="SELECT 1" - ), - ) - register_special_constructor( - "statements.ddl_function.DropFunctionExpression", - lambda d: ddl_function.DropFunctionExpression(d, Function(d, "fn")), - ) - register_special_constructor( - "statements.ddl_trigger.CreateTriggerExpression", - lambda d: ddl_trigger.CreateTriggerExpression( - d, - trigger=Trigger(d, "trg"), - table=_table_obj(d), - timing=TriggerTiming.BEFORE, - events=[TriggerEvent.INSERT], - function=Function(d, "fn"), - ), - ) - register_special_constructor( - "statements.ddl_trigger.DropTriggerExpression", - lambda d: ddl_trigger.DropTriggerExpression(d, trigger=Trigger(d, "trg")), - ) - register_special_constructor( - "statements.ddl_comment.CommentOnExpression", - lambda d: ddl_comment.CommentOnExpression(d, "table", _table_obj(d), comment="c"), - ) - - # -- views -------------------------------------------------------------- - register_special_constructor( - "statements.ddl_view.DropViewExpression", - lambda d: ddl_view.DropViewExpression(d, View(d, "v")), - ) - register_special_constructor( - "statements.ddl_view.CreateMaterializedViewExpression", - lambda d: ddl_view.CreateMaterializedViewExpression( - d, MaterializedView(d, "mv"), _one_column_query(d) - ), - ) - register_special_constructor( - "statements.ddl_view.DropMaterializedViewExpression", - lambda d: ddl_view.DropMaterializedViewExpression(d, MaterializedView(d, "mv")), - ) - register_special_constructor( - "statements.ddl_view.RefreshMaterializedViewExpression", - lambda d: ddl_view.RefreshMaterializedViewExpression( - d, MaterializedView(d, "mv") - ), - ) - - # -- DML ---------------------------------------------------------------- - register_special_constructor( - "statements.dml.InsertExpression", - lambda d: dml.InsertExpression( - d, into=_table_obj(d), source=dml.ValuesSource(d, [[Literal(d, 1)]]) - ), - ) - register_special_constructor( - "statements.dml.DeleteExpression", - lambda d: dml.DeleteExpression(d, _table_obj(d)), - ) - register_special_constructor( - "statements.dml.MergeExpression", - lambda d: dml.MergeExpression( - d, - target_table=_table_obj(d), - source=NamedRelationRef(d, Table(d, "src")), - on_condition=_column_predicate(d), - when_matched=[ - MergeAction( - d, - MergeActionType.UPDATE, - {"a": Literal(d, 1)}, - _column_predicate(d), - "matched", - ) - ], - ), - ) - register_special_constructor( - "statements.dml.MergeAction", - lambda d: MergeAction( - d, - MergeActionType.UPDATE, - {"a": Literal(d, 1)}, - _column_predicate(d), - "matched", - ), - ) - - # -- constraints and column pieces -------------------------------------- - register_special_constructor( - "statements.ddl_table.ColumnConstraint", - lambda d: ColumnConstraint(d, ColumnConstraintType.NOT_NULL, name="c"), - ) - register_special_constructor( - "statements.ddl_table.TableConstraint", - lambda d: TableConstraint( - d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"] - ), - ) - register_special_constructor( - "statements.ddl_table.ForeignKeyConstraint", - lambda d: ForeignKeyConstraint( - d, - columns=["a"], - foreign_key_table=Table(d, "other"), - foreign_key_columns=["b"], - name="fk", - ), - ) - register_special_constructor( - "statements.ddl_table.ReferencesClause", - lambda d: ReferencesClause(d, Table(d, "other"), ["b"]), - ) - register_special_constructor( - "statements.ddl_alter.AddColumn", - lambda d: ddl_alter.AddColumn(d, _integer_column(d)), - ) - register_special_constructor( - "statements.ddl_alter.AddIndex", - lambda d: ddl_alter.AddIndex(d, IndexDefinition(d, "i", ["a"])), - ) - register_special_constructor( - "statements.ddl_alter.AddTableConstraint", - lambda d: ddl_alter.AddTableConstraint( - d, - TableConstraint( - d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"] - ), - ), - ) - - # -- expressions that need at least one member -------------------------- - register_special_constructor( - "advanced_functions.CaseExpression", - lambda d: CaseExpression( - d, - cases=[(_column_predicate(d), Literal(d, 1))], - else_result=Literal(d, 0), - ), - ) - register_special_constructor( - "advanced_functions.WindowSpecification", - lambda d: WindowSpecification(d, partition_by=["a"]), - ) - register_special_constructor( - "advanced_functions.WindowDefinition", - lambda d: WindowDefinition(d, "w", WindowSpecification(d, partition_by=["a"])), - ) - register_special_constructor( - "advanced_functions.WindowClause", - lambda d: WindowClause( - d, [WindowDefinition(d, "w", WindowSpecification(d, partition_by=["a"]))] - ), - ) - # An empty options dict is refused by the formatter, so a time-travel clause - # needs an actual option. - register_special_constructor( - "datetime.TemporalOptionsExpression", - lambda d: TemporalOptionsExpression(d, {"as_of": "2020-01-01"}), - ) - - # -- property graphs ---------------------------------------------------- - def node_table(d): - return NodeTable(d, "people") - - def edge_table(d): - return EdgeTableObject(d, "knows") - - def path_pattern(d): - return graph_mod.PathPattern( - d, graph_mod.GraphVertex(d, "n", node_table(d)) - ) - - register_special_constructor( - "graph.GraphVertex", lambda d: graph_mod.GraphVertex(d, "n", node_table(d)) - ) - register_special_constructor( - "graph.GraphEdge", lambda d: graph_mod.GraphEdge(d, "e", edge_table(d)) - ) - register_special_constructor( - "graph.VertexTable", - lambda d: graph_mod.VertexTable(d, node_table(d), key_columns=["id"]), - ) - register_special_constructor( - "graph.EdgeTable", - lambda d: graph_mod.EdgeTable(d, edge_table(d), ["src"], ["dst"]), - ) - register_special_constructor( - "graph.QuantifiedPath", - lambda d: graph_mod.QuantifiedPath( - d, graph_mod.GraphEdge(d, "e", edge_table(d)), min_repeats=1, max_repeats=3 - ), - ) - register_special_constructor("graph.PathPattern", path_pattern) - register_special_constructor( - "graph.MatchClause", lambda d: graph_mod.MatchClause(d, path_pattern(d)) - ) - register_special_constructor( - "graph.ColumnsClause", - lambda d: graph_mod.ColumnsClause(d, graph_mod.GraphColumn("n", "id")), - ) - register_special_constructor( - "graph.GraphTableExpression", - lambda d: graph_mod.GraphTableExpression( - d, - graph=PropertyGraph(d, "g"), - match=graph_mod.MatchClause(d, path_pattern(d)), - columns=graph_mod.ColumnsClause(d, graph_mod.GraphColumn("n", "id")), - ), - ) - register_special_constructor( - "graph.CreatePropertyGraphExpression", - lambda d: graph_mod.CreatePropertyGraphExpression( - d, graph=PropertyGraph(d, "g"), - vertex_tables=[graph_mod.VertexTable(d, node_table(d))], - ), - ) - # The formatter accepts "add"/"drop" against "vertex tables"/"edge tables"/ - # "tables"; anything else is refused. - register_special_constructor( - "graph.AlterPropertyGraphExpression", - lambda d: graph_mod.AlterPropertyGraphExpression( - d, - graph=PropertyGraph(d, "g"), - action="add", - target="vertex tables", - vertex_tables=[graph_mod.VertexTable(d, node_table(d))], - ), - ) - register_special_constructor( - "graph.DropPropertyGraphExpression", - lambda d: graph_mod.DropPropertyGraphExpression(d, graph=PropertyGraph(d, "g")), - ) - - # -- SQL/XML and types -------------------------------------------------- - register_special_constructor( - "xml.XMLAttributesExpression", - lambda d: XMLAttributesExpression(d, [XMLAttribute(Literal(d, "v"), "a")]), - ) - register_special_constructor( - "xml.XMLForestExpression", - lambda d: XMLForestExpression(d, [XMLForestItem(Literal(d, "v"), "a")]), - ) - register_special_constructor( - "xml.XMLConcatExpression", - lambda d: XMLConcatExpression(d, [Literal(d, "a"), Literal(d, "b")]), - ) - register_special_constructor( - "types.array.ArrayType", lambda d: ArrayType(d, VarCharType(d, 10)) - ) - - -register_specials() - - -# --------------------------------------------------------------------------- -# Lists that cannot grow or shrink silently -# --------------------------------------------------------------------------- - -#: Classes the generic introspective constructor cannot build. -#: -#: Each is a real coverage gap, named here so it is visible rather than lost. -#: ``test_unconstructible_list_is_exact`` pins the tuple, so a class that gains a -#: constructor fails here until this entry is removed, and a class that starts -#: failing to build fails here too -- neither can become a quiet skip. -#: -#: The registered constructors in this module bring this down to four. What -#: remains is either a shape the introspective guess cannot satisfy -- keyword-only -#: parameters hidden behind defaulted positionals -- or a class that needs a -#: renderable member to exist at all. Registering a constructor for any of them -#: is the way to retire an entry. -#: -#: A second cause is a misread annotation, and that one belongs to testsuite rather -#: than here: a parameter typed ``Sequence[X]`` was read as the Sequence *object* -#: because the alias' name carries the word. That retired the XMLTABLE entry once -#: the harness was fixed, which is the better outcome -- no exemption at all. -#: -#: Sorted, because the integrity test compares this against a sorted tuple of what -#: it observes. The reasons below are grouped by cause, not by module. -UNCONSTRUCTIBLE = ( - # ALTER CONSTRAINT. `name` and `constraint_type` sit behind defaulted - # positionals and are keyword-only, so the introspective constructor skips - # them and the class refuses an incomplete action. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraint", - # VALIDATE CONSTRAINT. Same shape as AlterConstraint: the required `name` is - # keyword-only behind a defaulted positional. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.ValidateConstraint", - # ADD DOMAIN CHECK. Widens a SQLPredicate into a DomainCheckConstraint and - # needs one; the guess supplies a bare comparison whose literal would have to - # render as a bind parameter, which DDL cannot carry. - "rhosocial.activerecord.backend.expression.statements.ddl_domain.AddDomainCheckAction", - # ENUM type. Its `values` list is keyword-only behind a defaulted positional, - # so the introspective constructor skips it and the type declares no members. - "rhosocial.activerecord.backend.expression.types.enum_.EnumType", - # CUSTOM type. `raw` names the SQL type string and carries a default, so the - # introspective constructor skips it and builds a type with no spelling. The - # default cannot help: the class declares `dialect` first and `raw` after it, - # so a required `raw` behind that default is not expressible as a signature. - "rhosocial.activerecord.backend.expression.types.custom.CustomType", - # UUID constant. `which` must name a constant kind -- 'nil' or 'max' -- and - # __init__ rejects anything else, so the guess's "x" is refused at - # construction. There is no value the filler could offer that is both a valid - # constant and not a guess about which one the caller wanted. - "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", -) -# XMLTABLE used to be a fifth entry, needing a row source document, a COLUMNS list -# of typed projections and a pass-through clause. Its `columns` and `passing` -# parameters are annotated `Sequence[XMLTableColumn]` / `Sequence[XMLAttribute]`, -# and the introspective constructor read that alias' own name -- "Sequence" -- as -# the catalogue object Sequence, handing a directory object to a parameter asking -# for a list of columns. testsuite now decides a parameterised alias before it -# tests for a relation name, so the constructor gets [] and builds the class. -# Retiring the entry is what the pin demands: it names a gap that no longer exists. - -#: Classes that construct but cannot render, for a reason belonging to their own -#: tree rather than to a defect. Each entry pins the exception type and a message -#: fragment, so a class that starts failing for a *different* reason fails here. -_NO_FORMATTER = "does not declare its dialect formatting method name" - -LEGITIMATE_NON_RENDERS = { - # ---- bases that name an expression category, not a renderable thing ---- - # Each of these is a base class that deliberately declares no `format_method`, - # so `to_sql()` reports that there is nothing to dispatch. They are not - # `inspect.isabstract` -- they are concrete enough to construct -- so the - # registry keeps them, and each is pinned to its exact message so a base that - # started rendering fails here instead of passing quietly. - "rhosocial.activerecord.backend.expression.bases.SQLPredicate": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": ( - NotImplementedError, _NO_FORMATTER - ), - # The roots of the object tree. Each concrete object overrides `format_method` - # with its own `format_*_object`; the base names only what every catalogue - # object has in common. - "rhosocial.activerecord.backend.expression.objects.base.SchemaObject": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.objects.relation.RelationObject": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.objects.routine.RoutineObject": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.objects.type_.TypeObject": ( - NotImplementedError, _NO_FORMATTER - ), - # Expression-category bases whose concrete members each name their own - # formatter: an ALTER TABLE action, an INSERT row source, a transaction - # step, a temporal value, an introspection query. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterTableAction": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.statements.dml.InsertDataSource": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( - NotImplementedError, _NO_FORMATTER - ), - "rhosocial.activerecord.backend.expression.introspection.IntrospectionExpression": ( - NotImplementedError, _NO_FORMATTER - ), - - # ---- roots whose incompleteness is structural ---- - # The root of the row-source tree. No concrete source renders through - # `format_table_source`; each overrides `format_method`. Rendering the base - # would mean inventing SQL for an object that carries nothing but an alias. - # UnsupportedFeatureError rather than AttributeError: a dialect with no such - # formatter is reporting a capability gap, which is what every other probe in - # the tree reports, and to_sql() now says so for the same reason. - "rhosocial.activerecord.backend.expression.sources.base.TableSource": ( - UnsupportedFeatureError, - "does not support the 'format_table_source' statement", - ), - # The root of the type tree. Every concrete type declares its own generic - # `name`, which is what `format_data_type` dispatches on; the root declares - # none, so there is nothing to dispatch. TypeError, not - # UnsupportedFeatureError, because the class is incomplete rather than the - # dialect being unable. - "rhosocial.activerecord.backend.expression.types._base.DataType": ( - TypeError, - "does not declare a valid generic type name", - ), -} - - -# --------------------------------------------------------------------------- -# The local SQL assertion: classify the outcome instead of swallowing it -# --------------------------------------------------------------------------- - -def assert_sql_roundtrip_classified(fqn, instance, dialect): - """Assert an expression's SQL survives the round-trip, or say precisely why not. - - Four outcomes, each asserted: - - * **renders** -- all three encodings must restore byte-identical SQL *and* - byte-identical bind parameters. - * ``UnsupportedFeatureError`` -- the dialect does not model the feature. - Asserted as exactly that type, so a formatter raising it for an unrelated - reason is still visible as that type rather than as a pass. - * a member of :data:`LEGITIMATE_NON_RENDERS` -- unrenderable by design, - asserted as its exact type *and* message fragment. - * **anything else** -- a failure naming the class and the exception. - - Returns a short string naming the branch taken, so a caller can report the - classification distribution if it wants to. - - Raises: - AssertionError: On a round-trip mismatch, on an unexpected exception - type, or when a class's rendering outcome changed. - """ - try: - expected_sql, expected_params = instance.to_sql() - except UnsupportedFeatureError as exc: - assert type(exc) is UnsupportedFeatureError, fqn - return "unsupported" - except Exception as exc: - if fqn not in LEGITIMATE_NON_RENDERS: - raise AssertionError( - f"{fqn}: to_sql() raised {type(exc).__name__}, which is neither a " - f"render nor a classified non-render, and this is a defect.\n" - f" UnsupportedFeatureError means the dialect lacks the feature and " - f"is always allowed.\n" - f" A class that cannot render for a reason belonging to its own " - f"tree belongs in LEGITIMATE_NON_RENDERS.\n" - f" Exception: {exc}" - ) from exc - expected_type, fragment = LEGITIMATE_NON_RENDERS[fqn] - assert type(exc) is expected_type, ( - f"{fqn}: LEGITIMATE_NON_RENDERS pins this class as a legitimate " - f"non-render raising {expected_type.__name__}, but it raised " - f"{type(exc).__name__}: {exc}" - ) - assert fragment in str(exc), ( - f"{fqn}: expected {expected_type.__name__} and was expected to say " - f"{fragment!r}, but it said: {exc}" - ) - return "non-render" - - for channel, decoded in ( - ("dict", deserialize(serialize(instance), dialect)), - ("json", deserialize_json(serialize_json(instance), dialect)), - ("xml", deserialize_xml(serialize_xml(instance), dialect)), - ): - decoded_sql, decoded_params = decoded.to_sql() - assert decoded_sql == expected_sql, ( - f"{fqn}: {channel} round-trip changed the SQL.\n" - f" original: {expected_sql!r}\n" - f" {channel}: {decoded_sql!r}" - ) - assert decoded_params == expected_params, ( - f"{fqn}: {channel} round-trip changed the bind parameters.\n" - f" original: {expected_params!r}\n" - f" {channel}: {decoded_params!r}" - ) - return "rendered" - - -@pytest.fixture(params=[fqn for fqn in sorted(REGISTERED)], ids=sorted(REGISTERED)) -def expr_case(request, dummy_dialect): - fqn = request.param - cls = REGISTERED[fqn] - instance, source = make_instance(cls, dummy_dialect) - if instance is None: - assert fqn in UNCONSTRUCTIBLE, ( - f"{fqn} cannot be built by the generic constructor ({source}) and is " - f"not in UNCONSTRUCTIBLE. Either register a special constructor for " - f"it or add it to the tuple with a reason -- do not let it disappear " - f"into a skip." - ) - pytest.skip(f"{fqn}: pinned in UNCONSTRUCTIBLE, cannot be constructed ({source})") - return fqn, instance - - -class TestExpressionRoundtripAll: - """All constructible expression classes round-trip through all encodings.""" - - def test_get_params_roundtrip_across_encodings(self, expr_case, dummy_dialect): - fqn, instance = expr_case - original = instance.get_params() - - spec_dict = serialize(instance) - restored_d = deserialize(spec_dict, dummy_dialect) - assert_params_equal(restored_d.get_params(), original, fqn) - - json_str = serialize_json(instance) - restored_j = deserialize_json(json_str, dummy_dialect) - assert_params_equal(restored_j.get_params(), original, fqn) - - xml_bytes = serialize_xml(instance) - restored_x = deserialize_xml(xml_bytes, dummy_dialect) - assert_params_equal(restored_x.get_params(), original, fqn) - - def test_to_sql_roundtrip_classified(self, expr_case, dummy_dialect): - """A render must survive the round-trip; a non-render must be classified.""" - fqn, instance = expr_case - assert_sql_roundtrip_classified(fqn, instance, dummy_dialect) - - -class TestMatrixIntegrity: - """Guards on the matrix and its lists, so neither can quietly change.""" - - def test_unconstructible_list_is_exact(self, dummy_dialect): - """Pin the unconstructible tuple against what the constructor really skips. - - Two directions are checked. A class named here that now builds has gained - a constructor and the entry is stale; a class that fails to build without - being named would become a silent skip. Both fail here. - """ - dialect = dummy_dialect - ExpressionRegistry._auto_register_builtins() - actual = tuple( - sorted( - fqn - for fqn in REGISTERED - if make_instance(REGISTERED[fqn], dialect)[0] is None - ) - ) - assert actual == tuple(sorted(UNCONSTRUCTIBLE)), ( - "the set of expression classes the generic constructor cannot build " - "changed.\n" - f" now skipped but not named: " - f"{sorted(set(actual) - set(UNCONSTRUCTIBLE))}\n" - f" named but now built: " - f"{sorted(set(UNCONSTRUCTIBLE) - set(actual))}\n" - "Each new entry needs a reason in the comment above UNCONSTRUCTIBLE." - ) - - def test_unconstructible_entries_are_real_classes(self): - """Every entry names a class that was actually collected. - - A typo in the tuple would otherwise exempt nothing while still reading as - a deliberate decision. - """ - unknown = set(UNCONSTRUCTIBLE) - set(REGISTERED) - assert not unknown, ( - f"UNCONSTRUCTIBLE names classes that were not registered: {sorted(unknown)}" - ) - - def test_legitimate_non_renders_are_real_classes(self): - """Every pinned non-render names a class that was actually collected.""" - unknown = set(LEGITIMATE_NON_RENDERS) - set(REGISTERED) - assert not unknown, ( - f"LEGITIMATE_NON_RENDERS names classes that were not registered: " - f"{sorted(unknown)}" - ) - - def test_pinned_non_render_really_does_not_render(self, dummy_dialect): - """Each pinned entry still raises what it claims, for the stated reason. - - Without this, an entry could sit in the tuple for a class that renders - perfectly well, and the matrix would be asserting nothing about it. - """ - for fqn, (expected_type, fragment) in LEGITIMATE_NON_RENDERS.items(): - instance, source = make_instance(REGISTERED[fqn], dummy_dialect) - assert instance is not None, ( - f"{fqn} is pinned as a non-render but could not be constructed " - f"({source})" - ) - with pytest.raises(expected_type) as exc_info: - instance.to_sql() - assert fragment in str(exc_info.value), ( - f"{fqn}: expected the message to mention {fragment!r}, got: " - f"{exc_info.value}" - ) - - def test_matrix_covers_the_whole_core_package(self): - """The matrix covers every concrete class the core package defines. - - Re-walked here rather than trusting the module-level collection, so a - class that appeared after import is caught. The package walk is used - rather than the registry because the registry also holds whatever - backends other test modules happened to import. - """ - ExpressionRegistry._auto_register_builtins() - expected = set(_collect_matrix_classes()) - stray = [fqn for fqn in REGISTERED if not fqn.startswith(f"{CORE_EXPR_PKG}.")] - assert not stray, f"classes outside the core package are in the matrix: {stray}" - assert expected == set(REGISTERED), ( - "the set of classes the core package defines changed after " - f"collection.\n now defined but not covered: " - f"{sorted(expected - set(REGISTERED))}\n" - f" covered but no longer defined: {sorted(set(REGISTERED) - expected)}" - ) - assert len(REGISTERED) > 200, ( - f"only {len(REGISTERED)} classes collected; the package walk may " - f"have stopped early" - ) - - def test_every_covered_class_is_registered_for_deserialization(self): - """A class in the matrix can be found again when deserializing. - - Deserialization looks the class up by name, so a class the matrix - renders but the registry cannot resolve would round-trip into the - wrong thing or nothing at all. - """ - ExpressionRegistry._auto_register_builtins() - unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) - assert not unresolved, ( - f"the matrix covers classes the registry cannot resolve: {unresolved}" - ) - - def test_coverage_report(self, dummy_dialect): - """Surface what the matrix covers, so coverage stays transparent.""" - ExpressionRegistry._auto_register_builtins() - constructible = [fqn for fqn in REGISTERED - if make_instance(REGISTERED[fqn], dummy_dialect)[0] is not None] - assert constructible - print( - f"\nexpression matrix: {len(constructible)} constructible, " - f"{len(UNCONSTRUCTIBLE)} pinned-unconstructible, " - f"{len(REGISTERED)} registered" - ) - for fqn in UNCONSTRUCTIBLE: - print(f" not constructible: {fqn}") \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_serialization.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_serialization.py deleted file mode 100644 index 0a2c2126f..000000000 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_serialization.py +++ /dev/null @@ -1,1023 +0,0 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_serialization.py -""" -Tests for expression serialization and deserialization. - -These tests verify that all generic expressions (not backend-specific) can be -serialized to a JSON-compatible dict and restored via deserialization. -""" - -import json -import warnings - -import pytest - -from rhosocial.activerecord.backend.expression import ( - Column, - Literal, - WildcardExpression, - FunctionCall, - ComparisonPredicate, - LogicalPredicate, - InPredicate, - LikePredicate, - IsNullPredicate, - IsBooleanPredicate, - BetweenPredicate, -) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.query_parts import ( - WhereClause, - GroupByHavingClause, - OrderByClause, - LimitOffsetClause, - ForUpdateClause, -) -from rhosocial.activerecord.backend.expression.types import IntegerType -from rhosocial.activerecord.backend.expression.serialization import ( - serialize, - deserialize, - ExpressionFactory, - ExpressionRegistry, - ExpressionDeserializationError, -) -from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.objects import View -from rhosocial.activerecord.backend.expression.objects import Index -from rhosocial.activerecord.backend.expression.objects import Schema - - -class TestAtomExpressionRoundtrip: - """T1: Atom expression round-trip tests.""" - - def test_column_roundtrip(self, dummy_dialect): - col = Column(dummy_dialect, "age", table="users", alias="u_age") - restored = deserialize(serialize(col), dummy_dialect) - assert restored.to_sql() == col.to_sql() - - @pytest.mark.parametrize("value", [42, "hello", 3.14, None, True, False, ["a", "b", "c"]]) - def test_literal_roundtrip(self, dummy_dialect, value): - lit = Literal(dummy_dialect, value) - assert deserialize(serialize(lit), dummy_dialect).to_sql() == lit.to_sql() - - def test_wildcard_roundtrip(self, dummy_dialect): - w = WildcardExpression(dummy_dialect, table="users") - assert deserialize(serialize(w), dummy_dialect).to_sql() == w.to_sql() - - def test_function_call_roundtrip(self, dummy_dialect): - expr = FunctionCall(dummy_dialect, "COUNT", WildcardExpression(dummy_dialect), is_distinct=True) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - def test_function_call_with_multiple_args(self, dummy_dialect): - """Test FunctionCall with VAR_POSITIONAL *args parameter.""" - expr = FunctionCall( - dummy_dialect, - "CONCAT", - Column(dummy_dialect, "first_name"), - Column(dummy_dialect, "last_name"), - Literal(dummy_dialect, " "), - ) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - def test_table_expression_roundtrip(self, dummy_dialect): - expr = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"), alias="u") - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - -class TestPredicateRoundtrip: - """T2: Predicate round-trip tests.""" - - def test_comparison_predicate_roundtrip(self, dummy_dialect): - pred = ComparisonPredicate( - dummy_dialect, - ">=", - Column(dummy_dialect, "age"), - Literal(dummy_dialect, 18), - ) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - def test_logical_predicate_roundtrip(self, dummy_dialect): - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "status"), Literal(dummy_dialect, "active")) - p2 = ComparisonPredicate(dummy_dialect, ">", Column(dummy_dialect, "score"), Literal(dummy_dialect, 100)) - combined = LogicalPredicate(dummy_dialect, "AND", p1, p2) - assert deserialize(serialize(combined), dummy_dialect).to_sql() == combined.to_sql() - - def test_logical_predicate_with_multiple_predicates(self, dummy_dialect): - """Test LogicalPredicate with VAR_POSITIONAL *predicates parameter.""" - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - p3 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "c"), Literal(dummy_dialect, 3)) - combined = LogicalPredicate(dummy_dialect, "AND", p1, p2, p3) - assert deserialize(serialize(combined), dummy_dialect).to_sql() == combined.to_sql() - - def test_in_predicate_roundtrip(self, dummy_dialect): - pred = InPredicate(dummy_dialect, Column(dummy_dialect, "cat"), Literal(dummy_dialect, ["A", "B", "C"])) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - def test_like_predicate_roundtrip(self, dummy_dialect): - pred = LikePredicate(dummy_dialect, "LIKE", Column(dummy_dialect, "name"), Literal(dummy_dialect, "A%")) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - def test_is_null_predicate_roundtrip(self, dummy_dialect): - pred = IsNullPredicate(dummy_dialect, Column(dummy_dialect, "deleted_at")) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - def test_is_boolean_predicate_roundtrip(self, dummy_dialect): - pred = IsBooleanPredicate(dummy_dialect, Column(dummy_dialect, "active"), value=True) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - def test_between_predicate_roundtrip(self, dummy_dialect): - pred = BetweenPredicate( - dummy_dialect, Column(dummy_dialect, "age"), Literal(dummy_dialect, 18), Literal(dummy_dialect, 65) - ) - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - -class TestWhereClauseAndSerialization: - """T3: WhereClause.and_() equivalence test.""" - - def test_where_clause_and_serialization(self, dummy_dialect): - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - - where = WhereClause(dummy_dialect, p1) - where.and_(p2) - - spec = serialize(where) - restored = deserialize(spec, dummy_dialect) - - assert restored.to_sql() == where.to_sql() - assert "LogicalPredicate" in spec["params"]["condition"]["__expr__"]["type"] - assert spec["params"]["condition"]["__expr__"]["params"]["op"] == "AND" - - -class TestDeeplyNestedRoundtrip: - """T4: Deeply nested composite expression test.""" - - def test_deeply_nested_roundtrip(self, dummy_dialect): - """WHERE (age >= 18 AND status = 'active') OR score > 100""" - pred = ( - (Column(dummy_dialect, "age") >= Literal(dummy_dialect, 18)) - & (Column(dummy_dialect, "status") == Literal(dummy_dialect, "active")) - ) | (Column(dummy_dialect, "score") > Literal(dummy_dialect, 100)) - - assert deserialize(serialize(pred), dummy_dialect).to_sql() == pred.to_sql() - - -class TestAggregateRoundtrip: - """T5: Aggregate function round-trip test.""" - - def test_aggregate_function_roundtrip(self, dummy_dialect): - expr = FunctionCall( - dummy_dialect, "SUM", Column(dummy_dialect, "amount"), alias="total", is_aggregate=True - ) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - -class TestQueryExpressionRoundtrip: - """T6: QueryExpression complete round-trip test.""" - - def test_query_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression - - query = QueryExpression( - dummy_dialect, - select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], - from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), - where=WhereClause( - dummy_dialect, - ComparisonPredicate(dummy_dialect, ">", Column(dummy_dialect, "age"), Literal(dummy_dialect, 18)), - ), - order_by=OrderByClause(dummy_dialect, expressions=[(Column(dummy_dialect, "name"), "ASC")]), - ) - restored = deserialize(serialize(query), dummy_dialect) - assert query.to_sql() == restored.to_sql() - - -class TestRawSQLExpressionRoundtrip: - """RawSQLExpression and RawSQLPredicate round-trip tests.""" - - def test_raw_sql_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import RawSQLExpression - - expr = RawSQLExpression(dummy_dialect, expression="SELECT 1 FROM users WHERE id = ?", params=(1,)) - spec = serialize(expr) - restored = deserialize(spec, dummy_dialect) - assert expr.to_sql() == restored.to_sql() - - def test_raw_sql_predicate_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate - - pred = RawSQLPredicate(dummy_dialect, expression="id IN (SELECT id FROM admins)", params=()) - spec = serialize(pred) - restored = deserialize(spec, dummy_dialect) - assert pred.to_sql() == restored.to_sql() - - -class TestJsonSerializable: - """T7: JSON serialization compatibility test.""" - - def test_json_serializable(self, dummy_dialect): - """ExpressionSpec must be fully JSON-serializable""" - expr = ComparisonPredicate( - dummy_dialect, "=", Column(dummy_dialect, "status", table="orders"), Literal(dummy_dialect, "paid") - ) - spec = serialize(expr) - dumped = json.dumps(spec) - loaded = json.loads(dumped) - assert loaded == spec - - -class TestCrossDialectFastFail: - """T8: Cross-dialect fast-fail test.""" - - def test_sqlite_specific_expression_to_dummy_dialect(self, dummy_dialect): - """Test that SQLite-specific expressions fail fast on non-SQLite dialects. - - Step 1: deserialization should succeed (SQLiteReindexExpression is a known class) - Step 2: to_sql() should fail fast when dummy_dialect doesn't support the expression - """ - from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import SQLiteReindexExpression - - sqlite_dialect = SQLiteDialect(version=(3, 53, 0)) - expr = SQLiteReindexExpression(sqlite_dialect, table=Table(sqlite_dialect, "users")) - spec = serialize(expr) - - assert "sqlite" in spec["type"] - - restored = deserialize(spec, dummy_dialect) - with pytest.raises((TypeError, ValueError, NotImplementedError, AttributeError)): - restored.to_sql() - - -class TestErrorHandling: - """T10: Error handling tests.""" - - def test_deserialize_unknown_type(self, dummy_dialect): - with pytest.raises(ExpressionDeserializationError, match="NonExistentExpr"): - deserialize({"type": "fake.module.NonExistentExpr", "params": {}}, dummy_dialect) - - def test_deserialize_missing_required_param(self, dummy_dialect): - with pytest.raises(ExpressionDeserializationError, match="Failed to reconstruct"): - deserialize({"type": "rhosocial.activerecord.backend.expression.core.Column", "params": {}}, dummy_dialect) - - def test_deserialize_invalid_spec_missing_type(self, dummy_dialect): - with pytest.raises(ExpressionDeserializationError, match="missing 'type' field"): - deserialize({"params": {}}, dummy_dialect) - - def test_deserialize_invalid_spec_missing_module(self, dummy_dialect): - with pytest.raises(ExpressionDeserializationError, match="must be a fully qualified name"): - deserialize({"type": "SomeType", "params": {}}, dummy_dialect) - - def test_deserialize_invalid_class_not_expression(self, dummy_dialect): - class NotAnExpression: - pass - - ExpressionRegistry._registry["test.module.NotAnExpression"] = NotAnExpression - try: - with pytest.raises(ExpressionDeserializationError, match="not a BaseExpression subclass"): - deserialize({"type": "test.module.NotAnExpression", "params": {}}, dummy_dialect) - finally: - ExpressionRegistry._registry.pop("test.module.NotAnExpression", None) - - def test_reconstruct_by_name_type_error(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import _reconstruct_by_name - - with pytest.raises(ExpressionDeserializationError, match="Failed to reconstruct"): - _reconstruct_by_name("Column", dummy_dialect, {"invalid_param": 123}) - - def test_registry_ambiguous_short_name(self): - class CustomExpression(BaseExpression): - def __init__(self, dialect, name): - self._name = name - - def to_sql(self): - return "custom" - - try: - ExpressionRegistry._registry["module1.CustomExpression"] = CustomExpression - ExpressionRegistry._registry["module2.CustomExpression"] = CustomExpression - - with pytest.raises(ExpressionDeserializationError, match="Ambiguous short name"): - ExpressionRegistry.lookup("CustomExpression") - finally: - ExpressionRegistry._registry.pop("module1.CustomExpression", None) - ExpressionRegistry._registry.pop("module2.CustomExpression", None) - - def test_serialize_deserialize_tuple_in_params(self, dummy_dialect): - col = Column(dummy_dialect, "status") - in_pred = InPredicate(dummy_dialect, col, Literal(dummy_dialect, ("active", "pending", "draft"))) - spec = serialize(in_pred) - restored = deserialize(spec, dummy_dialect) - assert restored.to_sql() == in_pred.to_sql() - - def test_deserialize_exceeds_max_nesting_depth(self, dummy_dialect): - inner_spec = { - "type": "rhosocial.activerecord.backend.expression.predicates.ComparisonPredicate", - "params": { - "op": "=", - "left": {"type": "rhosocial.activerecord.backend.expression.core.Column", "params": {"name": "a"}}, - "right": {"type": "rhosocial.activerecord.backend.expression.core.Literal", "params": {"value": 1}}, - }, - } - - current_spec = inner_spec - for _ in range(65): - current_spec = { - "type": "rhosocial.activerecord.backend.expression.predicates.LogicalPredicate", - "params": {"left": current_spec, "right": inner_spec, "op": "AND"}, - } - - with pytest.raises(ExpressionDeserializationError, match="exceeds maximum"): - deserialize(current_spec, dummy_dialect) - - def test_reconstruct_varargs_predicates(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate - - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - p3 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "c"), Literal(dummy_dialect, 3)) - - expr = LogicalPredicate(dummy_dialect, "AND", p1, p2, p3) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_reconstruct_varargs_sql_operation(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import SQLOperation - - col1 = Column(dummy_dialect, "a") - col2 = Column(dummy_dialect, "b") - expr = SQLOperation(dummy_dialect, "GREATEST", col1, col2) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_expression_serializer_invalid_max_depth(self): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - with pytest.raises(ValueError, match="max_depth must be a positive integer"): - ExpressionSerializer(max_depth=0) - - with pytest.raises(ValueError, match="max_depth must be a positive integer"): - ExpressionSerializer(max_depth=-1) - - def test_expression_serializer_invalid_warn_threshold(self): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - with pytest.raises(ValueError, match="warn_threshold must be between 0 and 1"): - ExpressionSerializer(warn_threshold=0) - - with pytest.raises(ValueError, match="warn_threshold must be between 0 and 1"): - ExpressionSerializer(warn_threshold=1.1) - - with pytest.raises(ValueError, match="warn_threshold must be between 0 and 1"): - ExpressionSerializer(warn_threshold=-0.5) - - def test_expression_serializer_warning_threshold(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer(max_depth=10, warn_threshold=0.5) - - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - p3 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "c"), Literal(dummy_dialect, 3)) - p4 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "d"), Literal(dummy_dialect, 4)) - - expr = LogicalPredicate(dummy_dialect, "AND", p1, p2, p3, p4) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - serializer.serialize(expr) - assert any("exceeds warning threshold" in str(warning.message) for warning in w) - - def test_expression_serializer_tuple_in_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer(max_depth=64) - col = Column(dummy_dialect, "status") - in_pred = InPredicate(dummy_dialect, col, Literal(dummy_dialect, ("active", "pending"))) - - spec = serializer.serialize(in_pred) - restored = serializer.deserialize(spec, dummy_dialect) - assert restored.to_sql() == in_pred.to_sql() - - def test_expression_serializer_deserialize_exceeds_depth(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer(max_depth=5) - - inner_spec = { - "type": "rhosocial.activerecord.backend.expression.core.Column", - "params": {"name": "a"}, - } - - current_spec = inner_spec - for _ in range(6): - current_spec = { - "type": "rhosocial.activerecord.backend.expression.predicates.LogicalPredicate", - "params": {"left": current_spec, "right": inner_spec, "op": "AND"}, - } - - with pytest.raises(ExpressionDeserializationError, match="exceeds maximum"): - serializer.deserialize(current_spec, dummy_dialect) - - def test_expression_serializer_raw_sql_with_tuple_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import RawSQLExpression - - expr = RawSQLExpression(dummy_dialect, "SELECT * FROM t WHERE id IN (?, ?)", (1, 2)) - spec = serialize(expr) - assert "__tuple__" in str(spec["params"]) - - restored = deserialize(spec, dummy_dialect) - assert restored.params == (1, 2) - - def test_expression_serializer_varargs_with_extra_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate - - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - p3 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "c"), Literal(dummy_dialect, 3)) - - expr = LogicalPredicate(dummy_dialect, "AND", p1, p2, p3) - spec = serialize(expr) - - import json - - spec_json = json.dumps(spec) - restored = deserialize(json.loads(spec_json), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_expression_serializer_deserialize_depth_limit(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer(max_depth=3) - - inner_spec = { - "type": "rhosocial.activerecord.backend.expression.core.Column", - "params": {"name": "a"}, - } - - current_spec = inner_spec - for _ in range(4): - current_spec = { - "type": "rhosocial.activerecord.backend.expression.predicates.LogicalPredicate", - "params": {"left": current_spec, "right": inner_spec, "op": "AND"}, - } - - with pytest.raises(ExpressionDeserializationError, match="exceeds maximum"): - serializer.deserialize(current_spec, dummy_dialect) - - def test_deserialize_with_tuple_directly(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer() - result = serializer._deserialize_value((1, 2, 3), dummy_dialect, depth=0) - assert result == (1, 2, 3) - - def test_deserialize_expression_depth_exceeded(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - serializer = ExpressionSerializer(max_depth=2) - spec = {"type": "rhosocial.activerecord.backend.expression.core.Column", "params": {"name": "a"}} - - with pytest.raises(ExpressionDeserializationError, match="exceeds maximum"): - serializer._deserialize_expression(spec, dummy_dialect, depth=3) - - def test_reconstruct_varargs_branch(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import _reconstruct - from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate - - params = {"op": "AND", "predicates": [1, 2, 3]} - result = _reconstruct(LogicalPredicate, dummy_dialect, params) - assert result is not None - - def test_reconstruct_non_varargs_branch(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import _reconstruct - from rhosocial.activerecord.backend.expression.core import Column - - params = {"name": "id", "table": "users"} - result = _reconstruct(Column, dummy_dialect, params) - assert result is not None - - def test_reconstruct_varargs_keyword_only_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import SQLOperation - - col1 = Column(dummy_dialect, "a") - col2 = Column(dummy_dialect, "b") - expr = SQLOperation(dummy_dialect, "GREATEST", col1, col2) - spec = serialize(expr) - - import json - - spec_json = json.dumps(spec) - restored = deserialize(json.loads(spec_json), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_expression_factory_serialize(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionFactory - - factory = ExpressionFactory(dummy_dialect) - col = Column(dummy_dialect, "id") - spec = factory.serialize(col) - assert "type" in spec - assert "params" in spec - - def test_deserialize_tuple_in_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.operators import RawSQLExpression - - expr = RawSQLExpression(dummy_dialect, "SELECT ?", (1, 2, 3)) - spec = serialize(expr) - - assert "params" in spec and "params" in spec["params"] - inner_params = spec["params"]["params"] - assert "__tuple__" in inner_params - - restored = deserialize(spec, dummy_dialect) - assert restored.params == (1, 2, 3) - - def test_reconstruct_with_varargs_and_regular_params(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate - from rhosocial.activerecord.backend.expression import Column, Literal - - p1 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1)) - p2 = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "b"), Literal(dummy_dialect, 2)) - - expr = LogicalPredicate(dummy_dialect, "AND", p1, p2) - spec = serialize(expr) - - restored = deserialize(spec, dummy_dialect) - assert restored.op == "AND" - assert len(restored.predicates) == 2 - - -class TestExpressionFactoryAndRegistry: - """Test ExpressionFactory and ExpressionRegistry.""" - - def test_expression_factory_create_with_kwargs(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - col = factory.create("Column", name="id", table="users") - assert col.to_sql() == Column(dummy_dialect, "id", table="users").to_sql() - - def test_expression_factory_create_with_expression_params(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - col = factory.create("Column", name="id", table="users") - pred = factory.create( - "ComparisonPredicate", - op="=", - left=col, - right=factory.create("Literal", value=1), - ) - assert "id" in pred.to_sql()[0] - - def test_expression_factory_from_spec(self, dummy_dialect): - from rhosocial.activerecord.backend.expression import Column - - col = Column(dummy_dialect, "name", table="users") - spec = serialize(col) - factory = ExpressionFactory(dummy_dialect) - restored = factory._create_from_spec(spec) - assert restored.to_sql() == col.to_sql() - - def test_expression_factory_from_spec_missing_type(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - with pytest.raises(ExpressionDeserializationError, match="missing 'type' field"): - factory._create_from_spec({"params": {}}) - - def test_expression_factory_from_spec_missing_module(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - with pytest.raises(ExpressionDeserializationError, match="must be a fully qualified name"): - factory._create_from_spec({"type": "SomeType", "params": {}}) - - def test_expression_factory_from_spec_invalid_module(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - with pytest.raises(ExpressionDeserializationError, match="not found in registry"): - factory._create_from_spec({"type": "fake.module.that.does.not.exist.NonExistent", "params": {}}) - - -class TestExpressionRegistry: - """Test ExpressionRegistry.""" - - def test_registry_lookup_found(self): - from rhosocial.activerecord.backend.expression import Column - - result = ExpressionRegistry.lookup("Column") - assert result is Column - - def test_registry_lookup_not_found(self): - with pytest.raises(ExpressionDeserializationError, match="not found in registry"): - ExpressionRegistry.lookup("NonExistentClass") - - def test_registry_lookup_registered_class(self): - result = ExpressionRegistry.lookup("Column") - assert result is not None - - def test_reconstruct_by_name_not_found(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import _reconstruct_by_name - - with pytest.raises(ExpressionDeserializationError, match="not found in registry"): - _reconstruct_by_name("NonExistentExpression", dummy_dialect, {}) - - def test_registry_lookup_not_found_2(self): - with pytest.raises(ExpressionDeserializationError, match="not found in registry"): - ExpressionRegistry.lookup("SomeClass") - - def test_registry_lookup_not_found_3(self): - with pytest.raises(ExpressionDeserializationError, match="not found in registry"): - ExpressionRegistry.lookup("NonExistentClass") - - -class TestExpressionFactory: - """T11: ExpressionFactory interface test.""" - - def test_expression_factory_create(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - - col = factory.create("Column", name="user_id", table="orders") - assert isinstance(col, Column) - assert col.get_params()["name"] == "user_id" - - def test_expression_factory_nested(self, dummy_dialect): - factory = ExpressionFactory(dummy_dialect) - - pred = factory.create( - "ComparisonPredicate", - op="=", - left=factory.create("Column", name="status"), - right=factory.create("Literal", value="paid"), - ) - assert isinstance(pred, ComparisonPredicate) - sql, params = pred.to_sql() - assert "status" in sql - - -class TestQueryPartsRoundtrip: - """Query parts round-trip tests.""" - - def test_group_by_having_roundtrip(self, dummy_dialect): - agg = FunctionCall(dummy_dialect, "COUNT", is_aggregate=True) - pred = ComparisonPredicate(dummy_dialect, ">", agg, Literal(dummy_dialect, 10)) - expr = GroupByHavingClause( - dummy_dialect, - [Column(dummy_dialect, "dept")], - pred, - ) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - def test_order_by_clause_roundtrip(self, dummy_dialect): - expr = OrderByClause( - dummy_dialect, [(Column(dummy_dialect, "name"), "ASC"), (Column(dummy_dialect, "age"), "DESC")] - ) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - def test_limit_offset_clause_roundtrip(self, dummy_dialect): - expr = LimitOffsetClause(dummy_dialect, limit=10, offset=20) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - def test_for_update_clause_roundtrip(self, dummy_dialect): - expr = ForUpdateClause(dummy_dialect, nowait=True, skip_locked=True) - assert deserialize(serialize(expr), dummy_dialect).to_sql() == expr.to_sql() - - -class TestIntrospectionExpressionRoundtrip: - """Introspection expression round-trip tests (generic dialect).""" - - def test_table_list_expression_default_params_schema_omitted(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import TableListExpression - - expr = TableListExpression(dummy_dialect) - spec = serialize(expr) - assert spec["params"]["schema"] is None - assert spec["params"]["include_views"] is True - assert spec["params"]["include_system"] is False - - def test_table_list_expression_with_schema_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import TableListExpression - - expr = TableListExpression(dummy_dialect, schema="main", include_views=False) - spec = serialize(expr) - assert spec["params"]["schema"] == "main" - restored = deserialize(spec, dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_table_info_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import TableInfoExpression - - expr = TableInfoExpression(dummy_dialect, "users", schema="public") - assert deserialize(serialize(expr), dummy_dialect).get_params() == expr.get_params() - - def test_column_info_expression_schema_omitted(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import ColumnInfoExpression - - expr = ColumnInfoExpression(dummy_dialect, "users") - spec = serialize(expr) - assert spec["params"]["schema"] is None - - def test_column_info_expression_with_schema_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import ColumnInfoExpression - - expr = ColumnInfoExpression(dummy_dialect, "users", schema="main") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_index_info_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import IndexInfoExpression - - expr = IndexInfoExpression(dummy_dialect, "users", "idx_users_name") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_foreign_key_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import ForeignKeyExpression - - expr = ForeignKeyExpression(dummy_dialect, "users", "profiles") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_view_list_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import ViewListExpression - - expr = ViewListExpression(dummy_dialect, schema="main") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_view_info_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import ViewInfoExpression - - expr = ViewInfoExpression(dummy_dialect, "user_stats", schema="public") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_trigger_list_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import TriggerListExpression - - expr = TriggerListExpression(dummy_dialect, schema="main") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_trigger_info_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.introspection import TriggerInfoExpression - - expr = TriggerInfoExpression(dummy_dialect, "trg_update_user", schema="main") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - -class TestDDLRoundtrip: - """Test DDL expression serialization and deserialization.""" - - def test_create_table_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - - col_def = ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]) - expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=[col_def]) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_create_index_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_index import CreateIndexExpression - - expr = CreateIndexExpression(dummy_dialect, Index(dummy_dialect, "idx_name"), Table(dummy_dialect, "users"), ["name", "age"]) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_drop_index_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_index import DropIndexExpression - - expr = DropIndexExpression(dummy_dialect, Index(dummy_dialect, "idx_name")) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_create_schema_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_schema import CreateSchemaExpression - - expr = CreateSchemaExpression(dummy_dialect, Schema(dummy_dialect, "myschema")) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_drop_schema_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_schema import DropSchemaExpression - - expr = DropSchemaExpression(dummy_dialect, Schema(dummy_dialect, "myschema"), cascade=True) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_create_view_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewExpression - - expr = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "user_view"), query="SELECT * FROM users") - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_drop_view_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_view import DropViewExpression - - expr = DropViewExpression(dummy_dialect, View(dummy_dialect, "user_view")) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_truncate_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression - - expr = TruncateExpression(dummy_dialect, Table(dummy_dialect, "users"), cascade=True) - restored = deserialize(serialize(expr), dummy_dialect) - assert restored.get_params() == expr.get_params() - - def test_explain_expression_roundtrip(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.statements.explain import ExplainExpression - from rhosocial.activerecord.backend.expression import Column - - expr = ExplainExpression(dummy_dialect, statement=Column(dummy_dialect, "users")) - restored = deserialize(serialize(expr), dummy_dialect) - # A bare Column compares by identity, so equality goes through the - # rendered SQL, as in the other roundtrip tests in this file. - assert restored.to_sql() == expr.to_sql() - assert restored.get_params()["options"] == expr.get_params()["options"] - - -class TestAllowedTypesAllowlist: - """ExpressionSerializer.allowed_types restricts deserialization to a - fixed set of expression classes (exact FQNs or module-prefix wildcards).""" - - def test_none_allowlist_allows_anything(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - spec = serialize(col) - restored = ExpressionSerializer(allowed_types=None).deserialize(spec, dummy_dialect) - assert restored.to_sql() == col.to_sql() - - def test_exact_fqn_allowed(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - spec = serialize(col) - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.core.Column"] - ) - restored = serializer.deserialize(spec, dummy_dialect) - assert restored.to_sql() == col.to_sql() - - def test_module_wildcard_allowed(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - lit = Literal(dummy_dialect, 1) - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.core.*"] - ) - assert serializer.deserialize(serialize(col), dummy_dialect).to_sql() == col.to_sql() - assert serializer.deserialize(serialize(lit), dummy_dialect).to_sql() == lit.to_sql() - - def test_disallowed_type_raises(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - spec = serialize(col) - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.predicates.*"] - ) - with pytest.raises(ExpressionDeserializationError, match="not in the allowed types"): - serializer.deserialize(spec, dummy_dialect) - - def test_json_channel_enforces_allowlist(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - json_str = ExpressionSerializer().serialize_json(col) - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.predicates.*"] - ) - with pytest.raises(ExpressionDeserializationError, match="not in the allowed types"): - serializer.deserialize_json(json_str, dummy_dialect) - - def test_xml_channel_enforces_allowlist(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - col = Column(dummy_dialect, "id") - xml_bytes = ExpressionSerializer().serialize_xml(col) - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.predicates.*"] - ) - with pytest.raises(ExpressionDeserializationError, match="not in the allowed types"): - serializer.deserialize_xml(xml_bytes, dummy_dialect) - - def test_wildcard_does_not_leak_across_modules(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate - - pred = ComparisonPredicate( - dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1) - ) - spec = serialize(pred) - # core.* must NOT admit predicates.* classes. - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.core.*"] - ) - with pytest.raises(ExpressionDeserializationError, match="not in the allowed types"): - serializer.deserialize(spec, dummy_dialect) - - def test_recursive_enforcement_rejects_nested_disallowed(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - pred = ComparisonPredicate( - dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1) - ) - spec = serialize(pred) - # predicates.* allowed at top level, but nested Column/Literal from core.* are not. - serializer = ExpressionSerializer( - allowed_types=["rhosocial.activerecord.backend.expression.predicates.*"] - ) - with pytest.raises(ExpressionDeserializationError, match="not in the allowed types"): - serializer.deserialize(spec, dummy_dialect) - - def test_recursive_enforcement_allows_all_nested(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - pred = ComparisonPredicate( - dummy_dialect, "=", Column(dummy_dialect, "a"), Literal(dummy_dialect, 1) - ) - spec = serialize(pred) - # Both top-level predicates.* and nested core.* are allowed. - serializer = ExpressionSerializer( - allowed_types=[ - "rhosocial.activerecord.backend.expression.predicates.*", - "rhosocial.activerecord.backend.expression.core.*", - ] - ) - restored = serializer.deserialize(spec, dummy_dialect) - assert restored.to_sql() == pred.to_sql() - - -class TestCastChainDeserializationError: - """__cast__ on a class that does not support cast() must raise, not crash.""" - - def test_cast_unsupported_expression_raises(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.serialization import ExpressionSerializer - - spec = { - "type": "rhosocial.activerecord.backend.expression.query_parts.WhereClause", - "params": { - "condition": { - "__expr__": { - "type": "rhosocial.activerecord.backend.expression.predicates.IsNullPredicate", - "params": {"expr": {"__expr__": { - "type": "rhosocial.activerecord.backend.expression.core.Column", - "params": {"name": "a"}, - }}}, - }, - }, - "__cast__": ["INTEGER"], - }, - } - # The __cast__ special key was removed: casts are CastExpression AST - # nodes now, so an unknown __cast__ init parameter fails reconstruction. - with pytest.raises(ExpressionDeserializationError): - ExpressionSerializer().deserialize(spec, dummy_dialect) - - -class TestGetParamsWarningPaths: - """Defensive warnings raised by BaseExpression.get_params().""" - - def test_var_keyword_not_stored_as_dict_warns(self, dummy_dialect): - import warnings - - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class BadKwargsExpr(BaseExpression): - def __init__(self, dialect, **kwargs): - super().__init__(dialect) - self.kwargs = ["not", "a", "dict"] - - def to_sql(self): - return "", () - - expr = BadKwargsExpr(dummy_dialect) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - params = expr.get_params() - assert any("VAR_KEYWORD" in str(x.message) and "not stored as a dict" in str(x.message) for x in w) - assert params == {} - - def test_missing_attribute_warns(self, dummy_dialect): - import warnings - - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class NoAttrExpr(BaseExpression): - def __init__(self, dialect, missing_param): - super().__init__(dialect) - - def to_sql(self): - return "", () - - expr = NoAttrExpr(dummy_dialect, 1) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - params = expr.get_params() - assert any("cannot find attribute" in str(x.message) for x in w) - assert params == {} diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_traversal_dummy2.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_traversal_dummy2.py index 3fc2b6356..b10152e0c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_traversal_dummy2.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_traversal_dummy2.py @@ -18,9 +18,9 @@ - Core expressions: Column, Literal, FunctionCall, NamedRelationRef, etc. - Predicates: ComparisonPredicate, LogicalPredicate, LikePredicate, etc. - Query parts: WhereClause, GroupByHavingClause, OrderByClause, etc. - - DML/DDL statements: InsertExpression, DeleteExpression, CreateTableExpression, etc. + - DML/DDL statements: InsertStatement, DeleteStatement, CreateTableStatement, etc. - Graph expressions: GraphVertex, GraphEdge, MatchClause - - Transaction expressions: BeginTransactionExpression, CommitExpression, etc. + - Transaction statements: BeginTransactionStatement, CommitStatement, etc. See also: sqlite/test_expression_traversal.py for dialect_options and backend-specific tests. """ @@ -30,7 +30,8 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - WildcardExpression, + AllColumnsWildcard, + RowCountWildcard, FunctionCall, ComparisonPredicate, LogicalPredicate, @@ -49,14 +50,14 @@ JoinClause, ) from rhosocial.activerecord.backend.expression.query_sources import ( - SetOperationExpression, + SetOperation, ValuesExpression, ) -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, - CommitTransactionExpression, - RollbackTransactionExpression, + BeginTransactionStatement, + CommitStatement, + RollbackStatement, ) from rhosocial.activerecord.backend.expression.graph import ( GraphVertex, @@ -64,12 +65,12 @@ GraphEdgeDirection, ) from rhosocial.activerecord.backend.expression.advanced_functions import ( - AllExpression, - AnyExpression, - ArrayExpression, + AllPredicate, + AnyPredicate, + ArrayOperation, CaseExpression, - ExistsExpression, - JSONDocumentExpression, + ExistsPredicate, + JSONDocumentValue, OrderedSetAggregation, WindowClause, WindowDefinition, @@ -79,7 +80,7 @@ from rhosocial.activerecord.backend.expression.core import Subquery from rhosocial.activerecord.backend.expression.literals import Identifier from rhosocial.activerecord.backend.expression.operators import ( - BinaryArithmeticExpression, + ArithmeticOperation, SQLOperation, ) from rhosocial.activerecord.backend.expression.query_parts import ( @@ -88,38 +89,51 @@ ForUpdateClause, ) from rhosocial.activerecord.backend.expression.query_sources import ( - CTEExpression, + CTEClause, LateralExpression, TableFunctionExpression, - WithQueryExpression, + WithStatement, ) from rhosocial.activerecord.backend.expression.transaction import ( - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, + SavepointStatement, + ReleaseSavepointStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.expression.operators import ( BinaryExpression, UnaryExpression, - RawSQLExpression, + RawSQL, RawSQLPredicate, ) from rhosocial.activerecord.backend.expression import serialization -from rhosocial.activerecord.backend.expression.serialization import _reconstruct from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.objects import EdgeTable, NodeTable +def _codec_registry(): + """The default table plus the two kinds it deliberately leaves out. + + This file is about the codec, not about what the default table admits. A + kind the framework ships is still a kind, and reading one back is the thing + under test -- so `RawSQL` is registered here rather than skipped. + """ + view = serialization.default_registry().copy() + for cls in (RawSQL, RawSQLPredicate): + view.register_node(cls) + return view + + EXPRESSION_TEST_CASES = [ dict(name="Column", cls=Column, params_func=lambda d: dict(dialect=d, name="age", table="users")), dict(name="Literal", cls=Literal, params_func=lambda d: dict(dialect=d, value=42)), dict(name="Literal_string", cls=Literal, params_func=lambda d: dict(dialect=d, value="hello")), dict(name="Literal_none", cls=Literal, params_func=lambda d: dict(dialect=d, value=None)), - dict(name="WildcardExpression", cls=WildcardExpression, params_func=lambda d: dict(dialect=d)), + dict(name="AllColumnsWildcard", cls=AllColumnsWildcard, params_func=lambda d: dict(dialect=d)), + dict(name="RowCountWildcard", cls=RowCountWildcard, params_func=lambda d: dict(dialect=d)), dict( name="FunctionCall", cls=FunctionCall, - params_func=lambda d: dict(dialect=d, func_name="COUNT", args=(WildcardExpression(d),)), + params_func=lambda d: dict(dialect=d, func_name="COUNT", args=(RowCountWildcard(d),)), ), dict( name="FunctionCallDistinct", @@ -142,7 +156,7 @@ params_func=lambda d: dict( dialect=d, op="AND", - predicates=( + operands=( ComparisonPredicate(d, "=", Column(d, "a"), Literal(d, 1)), ComparisonPredicate(d, "=", Column(d, "b"), Literal(d, 2)), ), @@ -151,15 +165,15 @@ dict( name="LikePredicate", cls=LikePredicate, - params_func=lambda d: dict(dialect=d, op="LIKE", expr=Column(d, "name"), pattern=Literal(d, "A%")), + params_func=lambda d: dict(dialect=d, op="LIKE", left=Column(d, "name"), pattern=Literal(d, "A%")), ), dict( name="InPredicate", cls=InPredicate, - params_func=lambda d: dict(dialect=d, expr=Column(d, "category"), values=Literal(d, ["A", "B", "C"])), + params_func=lambda d: dict(dialect=d, operand=Column(d, "category"), values=Literal(d, ["A", "B", "C"])), ), dict( - name="IsNullPredicate", cls=IsNullPredicate, params_func=lambda d: dict(dialect=d, expr=Column(d, "deleted_at")) + name="IsNullPredicate", cls=IsNullPredicate, params_func=lambda d: dict(dialect=d, operand=Column(d, "deleted_at")) ), dict( name="IsBooleanPredicate", @@ -169,7 +183,7 @@ dict( name="BetweenPredicate", cls=BetweenPredicate, - params_func=lambda d: dict(dialect=d, expr=Column(d, "age"), low=Literal(d, 18), high=Literal(d, 65)), + params_func=lambda d: dict(dialect=d, operand=Column(d, "age"), low=Literal(d, 18), high=Literal(d, 65)), ), dict( name="FunctionCallAggregate", @@ -204,13 +218,13 @@ ), ), dict( - name="SetOperationExpression", - cls=SetOperationExpression, + name="SetOperation", + cls=SetOperation, params_func=lambda d: dict( dialect=d, - operation="UNION", - left=QueryExpression(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "users"))), - right=QueryExpression(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "admins"))), + op="union", + left=QueryStatement(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "users"))), + right=QueryStatement(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "admins"))), ), ), dict( @@ -221,17 +235,17 @@ ), ), dict( - name="QueryExpression", - cls=QueryExpression, + name="QueryStatement", + cls=QueryStatement, params_func=lambda d: dict( dialect=d, select=[Column(d, "id"), Column(d, "name")], from_=NamedRelationRef(d, Table(d, "users")) ), ), - dict(name="BeginTransactionExpression", cls=BeginTransactionExpression, params_func=lambda d: dict(dialect=d)), - dict(name="CommitTransactionExpression", cls=CommitTransactionExpression, params_func=lambda d: dict(dialect=d)), + dict(name="BeginTransactionStatement", cls=BeginTransactionStatement, params_func=lambda d: dict(dialect=d)), + dict(name="CommitStatement", cls=CommitStatement, params_func=lambda d: dict(dialect=d)), dict( - name="RollbackTransactionExpression", - cls=RollbackTransactionExpression, + name="RollbackStatement", + cls=RollbackStatement, params_func=lambda d: dict(dialect=d), ), dict( @@ -262,8 +276,8 @@ # GraphVertex(d, variable="v2", table=NodeTable(d, "users")), # )), dict( - name="RawSQLExpression", - cls=RawSQLExpression, + name="RawSQL", + cls=RawSQL, params_func=lambda d: dict(dialect=d, expression="SELECT 1", params=(1,)), ), dict( @@ -283,28 +297,28 @@ ), # advanced_functions dict( - name="AllExpression", - cls=AllExpression, + name="AllPredicate", + cls=AllPredicate, params_func=lambda d: dict( dialect=d, expr=Column(d, "x"), op=">", - array_expr=ArrayExpression(d, operation="ARRAY", elements=[Literal(d, 1)]), + array_expr=ArrayOperation(d, operation="ARRAY", elements=[Literal(d, 1)]), ), ), dict( - name="AnyExpression", - cls=AnyExpression, + name="AnyPredicate", + cls=AnyPredicate, params_func=lambda d: dict( dialect=d, expr=Column(d, "x"), op=">", - array_expr=ArrayExpression(d, operation="ARRAY", elements=[Literal(d, 1)]), + array_expr=ArrayOperation(d, operation="ARRAY", elements=[Literal(d, 1)]), ), ), dict( - name="ArrayExpression", - cls=ArrayExpression, + name="ArrayOperation", + cls=ArrayOperation, params_func=lambda d: dict(dialect=d, operation="ARRAY", elements=[Literal(d, 1), Literal(d, 2)]), ), dict( @@ -315,13 +329,13 @@ ), ), dict( - name="ExistsExpression", - cls=ExistsExpression, + name="ExistsPredicate", + cls=ExistsPredicate, params_func=lambda d: dict(dialect=d, subquery=Subquery(d, "SELECT 1")), ), dict( - name="JSONDocumentExpression", - cls=JSONDocumentExpression, + name="JSONDocumentValue", + cls=JSONDocumentValue, params_func=lambda d: dict(dialect=d, column=Column(d, "data"), path="$.key", operation="->"), ), dict( @@ -377,8 +391,8 @@ # MatchClause uses *path (VAR_POSITIONAL) - import present, skipped (commented above) # query_sources dict( - name="CTEExpression", - cls=CTEExpression, + name="CTEClause", + cls=CTEClause, params_func=lambda d: dict(dialect=d, name="cte1", query=Subquery(d, "SELECT 1")), ), dict( @@ -394,12 +408,12 @@ ), ), dict( - name="WithQueryExpression", - cls=WithQueryExpression, + name="WithStatement", + cls=WithStatement, params_func=lambda d: dict( dialect=d, - ctes=[CTEExpression(d, name="cte1", query=Subquery(d, "SELECT 1"))], - main_query=QueryExpression(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "users"))), + ctes=[CTEClause(d, name="cte1", query=Subquery(d, "SELECT 1"))], + query=QueryStatement(d, select=[Column(d, "id")], from_=NamedRelationRef(d, Table(d, "users"))), ), ), # query_parts @@ -426,9 +440,9 @@ # They are tested in backend-specific test files (e.g., sqlite). # operators dict( - name="BinaryArithmeticExpression", - cls=BinaryArithmeticExpression, - params_func=lambda d: dict(dialect=d, op="+", left=Column(d, "a"), right=Column(d, "b")), + name="ArithmeticOperation", + cls=ArithmeticOperation, + params_func=lambda d: dict(dialect=d, op="add", left=Column(d, "a"), right=Column(d, "b")), ), dict( name="SQLOperation", @@ -440,26 +454,65 @@ # literals dict(name="Identifier", cls=Identifier, params_func=lambda d: dict(dialect=d, name="my_id")), # transaction - dict(name="SavepointExpression", cls=SavepointExpression, params_func=lambda d: dict(dialect=d, name="sp1")), + dict(name="SavepointStatement", cls=SavepointStatement, params_func=lambda d: dict(dialect=d, name="sp1")), dict( - name="ReleaseSavepointExpression", - cls=ReleaseSavepointExpression, + name="ReleaseSavepointStatement", + cls=ReleaseSavepointStatement, params_func=lambda d: dict(dialect=d, name="sp1"), ), - dict(name="SetTransactionExpression", cls=SetTransactionExpression, params_func=lambda d: dict(dialect=d)), + dict(name="SetTransactionStatement", cls=SetTransactionStatement, params_func=lambda d: dict(dialect=d)), ] class TestExpressionRoundtrip: """Test serialization/deserialization roundtrip for all expression types.""" + @staticmethod + def _as_params(params): + """Constructor arguments turned into artifact parameter values. + + Two translations, both of them things the reader would otherwise + rightly refuse: + + * ``dialect`` is dropped. It is *context*, passed separately to + :func:`deserialize`, and §10 keeps it out of the artifact. The old + constructor helper took the dialect twice -- once as its own + argument and once out of this dict -- so the duplicate went unnoticed + for as long as it existed. + * live instances become encoded nodes, because a ``params_func`` + hands back objects while an artifact carries JSON. A node is written + as itself -- nested and top-level nodes have the same shape. + + The public writer does the encoding; there is no "build this class + from these arguments" entry point any more, because the artifact *is* + the node. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + if isinstance(params, BaseExpression): + # A nested node is its own {kind, params}; there is no marker to add. + return serialization.serialize(params) + if isinstance(params, dict): + return { + k: TestExpressionRoundtrip._as_params(v) + for k, v in params.items() + if k != "dialect" + } + if isinstance(params, (list, tuple)): + # A tuple goes out as an array, like a list: the artifact draws no + # distinction, and a marker to draw one would be a dispatch every + # language had to reimplement. + return [TestExpressionRoundtrip._as_params(v) for v in params] + return params + @pytest.mark.parametrize("test_case", EXPRESSION_TEST_CASES, ids=[x["name"] for x in EXPRESSION_TEST_CASES]) def test_expression_roundtrip(self, dummy_dialect, test_case): - """Generic test: serialize → deserialize → compare SQL output.""" + """Generic test: build the artifact → read it → re-write → compare SQL.""" cls = test_case["cls"] params = test_case["params_func"](dummy_dialect) - instance = _reconstruct(cls, dummy_dialect, params) + artifact = {"kind": cls.kind(), "params": self._as_params(params)} + instance = serialization.deserialize(artifact, dummy_dialect, registry=_codec_registry()) spec = serialization.serialize(instance) - restored = serialization.deserialize(spec, dummy_dialect) + restored = serialization.deserialize(spec, dummy_dialect, registry=_codec_registry()) assert restored.to_sql() == instance.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_value_codecs.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_value_codecs.py deleted file mode 100644 index 61f5740c3..000000000 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_value_codecs.py +++ /dev/null @@ -1,203 +0,0 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expression_value_codecs.py -""" -JSON value codec round-trip tests. - -The goal is "expression <-> JSON string" via the standard library ``json``. -Non-JSON-native Python values (datetime/date/time/Decimal/bytes/UUID/set/ -Enum/...) go through the ``register_codec`` codecs using json.dumps(default=) -and json.loads(object_hook=). -""" - -import datetime as dt -import fractions -import json -from decimal import Decimal -from enum import Enum -from uuid import uuid4 - -import pytest - -from rhosocial.activerecord.backend.expression.core import Column, Literal -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.serialization import ( - deserialize, - deserialize_json, - deserialize_xml, - register_codec, - serialize, - serialize_json, - serialize_xml, -) -from rhosocial.activerecord.backend.expression.types import DecimalType - - -class Status(Enum): - A = 1 - B = 2 - - -NON_NATIVE_VALUES = [ - dt.datetime(2026, 1, 1, 10, 30), - dt.date(2026, 1, 1), - dt.time(10, 30, 15), - Decimal("3.5"), - b"\x00\x01\x02", - bytearray(b"\x01\x02"), - uuid4(), - {1, 2, 3}, - frozenset([4, 5]), - Status.B, -] - - -class TestLiteralValueCodecRoundtrip: - """Literal values of non-JSON-native types survive serialize_json """ - - @pytest.mark.parametrize("value", NON_NATIVE_VALUES, ids=lambda v: type(v).__name__) - def test_json_roundtrip(self, dummy_dialect, value): - lit = Literal(dummy_dialect, value) - spec = serialize_json(lit) - json.loads(spec) # must be valid JSON string - restored = deserialize_json(spec, dummy_dialect) - assert type(restored.value) is type(value) - assert restored.value == value - - @pytest.mark.parametrize("value", NON_NATIVE_VALUES, ids=lambda v: type(v).__name__) - def test_dict_roundtrip(self, dummy_dialect, value): - lit = Literal(dummy_dialect, value) - restored = deserialize(serialize(lit), dummy_dialect) - assert type(restored.value) is type(value) - assert restored.value == value - - def test_json_nested_expression(self, dummy_dialect): - expr = ComparisonPredicate( - dummy_dialect, ">", Column(dummy_dialect, "t"), Literal(dummy_dialect, dt.datetime(2026, 1, 1)) - ) - restored = deserialize_json(serialize_json(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_json_combined_with_cast_chain(self, dummy_dialect): - expr = Column(dummy_dialect, "amount").cast(DecimalType(dummy_dialect)) - restored = deserialize_json(serialize_json(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_json_container_values(self, dummy_dialect): - lit = Literal(dummy_dialect, [dt.date(2026, 1, 1), Decimal("1.5"), "x"]) - restored = deserialize_json(serialize_json(lit), dummy_dialect) - assert restored.value == [dt.date(2026, 1, 1), Decimal("1.5"), "x"] - assert isinstance(restored.value[0], dt.date) - - -class TestXmlCodecRoundtrip: - """Expression <-> XML document round-trips for the same spec layer.""" - - @pytest.mark.parametrize("value", NON_NATIVE_VALUES, ids=lambda v: type(v).__name__) - def test_xml_roundtrip(self, dummy_dialect, value): - lit = Literal(dummy_dialect, value) - spec = serialize_xml(lit) - restored = deserialize_xml(spec, dummy_dialect) - assert type(restored.value) is type(value) - assert restored.value == value - - def test_xml_scalars(self, dummy_dialect): - for value in [42, "hello", True, 3.14, None]: - restored = deserialize_xml(serialize_xml(Literal(dummy_dialect, value)), dummy_dialect) - assert restored.value == value - assert type(restored.value) is type(value) - - def test_xml_string_whitespace_preserved(self, dummy_dialect): - """XML round-trip must not strip leading/trailing whitespace in strings.""" - for value in [" .+*#", "hello world ", " x", "a b", "", " "]: - restored = deserialize_xml(serialize_xml(Literal(dummy_dialect, value)), dummy_dialect) - assert restored.value == value, f"{value!r} -> {restored.value!r}" - - def test_xml_tuple_preserved(self, dummy_dialect): - lit = Literal(dummy_dialect, (1, "a", 2.0)) - restored = deserialize_xml(serialize_xml(lit), dummy_dialect) - assert isinstance(restored.value, tuple) - assert restored.value == (1, "a", 2.0) - - def test_xml_nested_expression(self, dummy_dialect): - expr = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "t"), - Literal(dummy_dialect, dt.date(2026, 1, 1))) - restored = deserialize_xml(serialize_xml(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_xml_cast_chain(self, dummy_dialect): - expr = Column(dummy_dialect, "amount").cast(DecimalType(dummy_dialect)) - restored = deserialize_xml(serialize_xml(expr), dummy_dialect) - assert restored.to_sql() == expr.to_sql() - - def test_xml_dict_and_list_values(self, dummy_dialect): - lit = Literal(dummy_dialect, [1, {"k": "v"}]) - restored = deserialize_xml(serialize_xml(lit), dummy_dialect) - assert restored.value == [1, {"k": "v"}] - - -class TestCustomCodecRegistration: - """register_codec extends serialization for developer-defined types.""" - - def test_custom_fraction(self, dummy_dialect): - register_codec( - "fract", - fractions.Fraction, - encode=lambda v: f"{v.numerator}/{v.denominator}", - decode=lambda p: fractions.Fraction(*map(int, p.split("/"))), - ) - lit = Literal(dummy_dialect, fractions.Fraction(3, 4)) - restored = deserialize_json(serialize_json(lit), dummy_dialect) - assert restored.value == fractions.Fraction(3, 4) - - def test_custom_codec_non_str_scalar_payload(self, dummy_dialect): - """A codec whose payload is a non-str scalar (int) round-trips through - dict / JSON / XML; the XML layer tags scalar payloads by type.""" - from rhosocial.activerecord.backend.expression.codec import _CODECS - - class Intish(int): - pass - - register_codec("_intish", Intish, encode=int, decode=Intish) - try: - lit = Literal(dummy_dialect, Intish(21)) - assert deserialize(serialize(lit), dummy_dialect).value == Intish(21) - assert deserialize_json(serialize_json(lit), dummy_dialect).value == Intish(21) - assert deserialize_xml(serialize_xml(lit), dummy_dialect).value == Intish(21) - finally: - _CODECS.pop("_intish", None) - - def test_duplicate_tag_rejected(self): - from rhosocial.activerecord.backend.expression.codec import _CODECS - - register_codec("_dup_check", str, encode=str, decode=str) - try: - with pytest.raises(ValueError, match="already registered"): - register_codec("_dup_check", str, encode=str, decode=str) - finally: - _CODECS.pop("_dup_check", None) - - def test_catch_all_codec_lowest_priority(self, dummy_dialect): - from rhosocial.activerecord.backend.expression.codec import decode_value, encode_value, _CODECS - - # py_type=None registers a catch-all used during decode only. - register_codec( - "_catch_all", - None, - encode=lambda v: {"marked": True}, - decode=lambda p: p["marked"], - ) - try: - # A known type must win over the catch-all during encode. - assert encode_value(dt.datetime(2026, 1, 1))["__value__"][0] == "dt" - # py_type=None table entry is skipped during encode (line 85). - assert encode_value(slice(1, 2)) is None - # During decode the catch-all handles unknown tags. - assert decode_value({"__value__": ["_catch_all", {"marked": True}]}) is True - finally: - _CODECS.pop("_catch_all", None) - - def test_decode_unknown_tag_passthrough(self): - from rhosocial.activerecord.backend.expression.codec import decode_value - - # Unknown __value__ tag must fall through untouched (not crash). - result = decode_value({"__value__": ["no_such_tag", "x"]}) - assert result == {"__value__": ["no_such_tag", "x"]} \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_advanced.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_advanced.py index 5bf132ca6..4c8e08af6 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_advanced.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_advanced.py @@ -1,4 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_advanced.py +from rhosocial.activerecord.backend.expression.functions import cast import pytest from rhosocial.activerecord.backend.expression import ( @@ -8,14 +9,13 @@ Subquery, ComparisonPredicate, CaseExpression, - ExistsExpression, - AnyExpression, - AllExpression, + ExistsPredicate, + AnyPredicate, + AllPredicate, WindowSpecification, WindowFrameSpecification, - ArrayExpression, - JSONDocumentExpression, - JSONTextExpression, + ArrayOperation, + JSONDocumentValue, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.types import ( @@ -74,12 +74,12 @@ def test_cast_column(self, dummy_dialect: DummyDialect): def test_cast_literal(self, dummy_dialect: DummyDialect): """Tests cast() method on Literal.""" lit = Literal(dummy_dialect, "2023-01-01") - cast_expr = lit.cast(DateType(dummy_dialect)) + cast_expr = cast(dummy_dialect, lit, DateType(dummy_dialect)) sql, params = cast_expr.to_sql() assert sql == "CAST(? AS DATE)" assert params == ("2023-01-01",) - # --- ExistsExpression --- + # --- ExistsPredicate --- @pytest.mark.parametrize( "is_not, expected_prefix", [ @@ -90,17 +90,17 @@ def test_cast_literal(self, dummy_dialect: DummyDialect): def test_exists_expression(self, dummy_dialect: DummyDialect, is_not, expected_prefix): """Tests EXISTS and NOT EXISTS expressions with a subquery.""" subquery = Subquery(dummy_dialect, "SELECT 1 FROM users WHERE active = ?", (True,)) - exists_expr = ExistsExpression(dummy_dialect, subquery, is_not=is_not) + exists_expr = ExistsPredicate(dummy_dialect, subquery, is_not=is_not) sql, params = exists_expr.to_sql() assert sql == f"{expected_prefix} (SELECT 1 FROM users WHERE active = ?)" assert params == (True,) - # --- AnyExpression / AllExpression --- + # --- AnyPredicate / AllPredicate --- @pytest.mark.parametrize( "expr_type, operator, operand_list, expected_keyword, expected_sql_pattern, expected_params", [ - (AnyExpression, ">", [10, 20, 30], "ANY", '("age" > ANY?)', ((10, 20, 30),)), - (AllExpression, "<", [5, 10], "ALL", '("age" < ALL?)', ((5, 10),)), + (AnyPredicate, ">", [10, 20, 30], "ANY", '("age" > ANY?)', ((10, 20, 30),)), + (AllPredicate, "<", [5, 10], "ALL", '("age" < ALL?)', ((5, 10),)), ], ) def test_any_all_expressions( @@ -124,7 +124,7 @@ def test_any_all_expressions( def test_any_with_subquery(self, dummy_dialect: DummyDialect): """Tests ANY expression with a subquery.""" subquery = Subquery(dummy_dialect, "SELECT product_id FROM top_sellers WHERE category = ?", ("electronics",)) - any_expr = AnyExpression(dummy_dialect, Column(dummy_dialect, "item_id"), "=", subquery) + any_expr = AnyPredicate(dummy_dialect, Column(dummy_dialect, "item_id"), "=", subquery) sql, params = any_expr.to_sql() assert sql == '("item_id" = ANY(SELECT product_id FROM top_sellers WHERE category = ?))' assert params == ("electronics",) @@ -175,7 +175,7 @@ def test_window_function_with_frame(self, dummy_dialect: DummyDialect): assert sql == expected assert params == () - # --- JSONDocumentExpression --- + # --- JSONDocumentValue --- @pytest.mark.parametrize( "col_name, path, operation, expected_sql, expected_params", [ @@ -188,15 +188,15 @@ def test_json_expression( ): """Tests JSON path extraction operations.""" json_col = Column(dummy_dialect, col_name) - json_expr = JSONDocumentExpression(dummy_dialect, json_col, path, operation=operation) + json_expr = JSONDocumentValue(dummy_dialect, json_col, path, operation=operation) sql, params = json_expr.to_sql() assert sql == expected_sql assert params == expected_params - # --- ArrayExpression --- + # --- ArrayOperation --- def test_array_constructor(self, dummy_dialect: DummyDialect): """Tests array constructor (ARRAY[...]).""" - array_expr = ArrayExpression( + array_expr = ArrayOperation( dummy_dialect, "CONSTRUCTOR", elements=[Literal(dummy_dialect, 1), Literal(dummy_dialect, "apple"), Column(dummy_dialect, "item_count")], @@ -207,7 +207,7 @@ def test_array_constructor(self, dummy_dialect: DummyDialect): def test_array_access(self, dummy_dialect: DummyDialect): """Tests array element access (array[index]).""" - array_access_expr = ArrayExpression( + array_access_expr = ArrayOperation( dummy_dialect, "ACCESS", base_expr=Column(dummy_dialect, "tags"), index_expr=Literal(dummy_dialect, 0) ) sql, params = array_access_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_basic.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_basic.py index 91ecf8abe..312490bc3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_basic.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_basic.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_basic.py import pytest -from rhosocial.activerecord.backend.expression import Literal, Column, Identifier, RawSQLExpression +from rhosocial.activerecord.backend.expression import Literal, Column, Identifier, RawSQL from rhosocial.activerecord.backend.impl.dummy.dialect import ( DummyDialect, ) # Explicit import for clarity, though fixture provides it @@ -92,7 +92,7 @@ def test_raw_sql_expression( self, dummy_dialect: DummyDialect, raw_sql_string, raw_params, expected_sql, expected_params ): """Tests raw SQL expressions, ensuring their content is output directly.""" - raw_expr = RawSQLExpression(dummy_dialect, raw_sql_string, raw_params) + raw_expr = RawSQL(dummy_dialect, raw_sql_string, raw_params) sql, params = raw_expr.to_sql() assert sql == expected_sql assert params == expected_params diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_clauses.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_clauses.py index f1d80e011..da73c69b4 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_clauses.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_clauses.py @@ -4,14 +4,14 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, + RawSQL, Subquery, ComparisonPredicate, JoinClause, - CTEExpression, + CTEClause, GroupingClause, ValuesExpression, - MergeExpression, + MergeStatement, MergeAction, OrderedSetAggregation, MergeActionType, @@ -108,11 +108,11 @@ def test_join_expression_validation_both_condition_and_using(self, dummy_dialect dummy_dialect, left_table, right_table, join_type="INNER", condition=condition, using=["user_id"] ) - # --- CTEExpression --- + # --- CTEClause --- def test_basic_cte_expression(self, dummy_dialect: DummyDialect): """Tests a basic Common Table Expression (CTE).""" cte_query = Subquery(dummy_dialect, "SELECT id, name FROM employees WHERE salary > ?", (50000,)) - cte_expr = CTEExpression(dummy_dialect, name="high_earners", query=cte_query, columns=["id", "name"]) + cte_expr = CTEClause(dummy_dialect, name="high_earners", query=cte_query, columns=["id", "name"]) sql, params = cte_expr.to_sql() assert sql == '"high_earners" ("id", "name") AS ((SELECT id, name FROM employees WHERE salary > ?))' assert params == (50000,) @@ -120,8 +120,8 @@ def test_basic_cte_expression(self, dummy_dialect: DummyDialect): def test_recursive_cte_expression(self, dummy_dialect: DummyDialect): """Tests a recursive CTE.""" cte_query_sql = """SELECT id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id, t.level + 1 FROM employees e INNER JOIN org_tree t ON e.manager_id = t.id""" # noqa: E501 - cte_query = RawSQLExpression(dummy_dialect, cte_query_sql) # Or Subquery - cte_expr = CTEExpression(dummy_dialect, name="org_tree", query=cte_query) + cte_query = RawSQL(dummy_dialect, cte_query_sql) # Or Subquery + cte_expr = CTEClause(dummy_dialect, name="org_tree", query=cte_query) sql, params = cte_expr.to_sql() # The recursive flag is now handled at the WITH clause level, not individual CTE level # So the CTE itself should not have RECURSIVE keyword @@ -131,7 +131,7 @@ def test_recursive_cte_expression(self, dummy_dialect: DummyDialect): def test_materialized_cte_expression(self, dummy_dialect: DummyDialect): """Tests a materialized CTE.""" cte_query = Subquery(dummy_dialect, "SELECT * FROM large_data WHERE condition = ?", (True,)) - cte_expr = CTEExpression(dummy_dialect, name="cached_data", query=cte_query, materialized=True) + cte_expr = CTEClause(dummy_dialect, name="cached_data", query=cte_query, materialized=True) sql, params = cte_expr.to_sql() assert sql == '"cached_data" AS MATERIALIZED ((SELECT * FROM large_data WHERE condition = ?))' assert params == (True,) @@ -181,7 +181,7 @@ def test_values_expression( assert sql == expected_sql assert params == expected_params - # --- MergeExpression --- + # --- MergeStatement --- def test_merge_expression_basic(self, dummy_dialect: DummyDialect): """Tests a basic MERGE statement with WHEN MATCHED UPDATE and WHEN NOT MATCHED INSERT.""" target_table = Table(dummy_dialect, "products") @@ -208,7 +208,7 @@ def test_merge_expression_basic(self, dummy_dialect: DummyDialect): }, ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_collation_datetime.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_collation_datetime.py index 4ebaa9d36..7c61c45c8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_collation_datetime.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_collation_datetime.py @@ -5,7 +5,7 @@ DummyDialect implements the generic mixin formatters, so the SQL generation must work without binding to any concrete database. -The one deliberately unsupported case is ``date_diff`` (DateTimeDiffExpression +The one deliberately unsupported case is ``date_diff`` (DatetimeDiffExpression to_sql), which is inherently backend-specific; it must raise UnsupportedFeatureError on Dummy. """ @@ -17,9 +17,9 @@ from rhosocial.activerecord.backend.expression import ( Literal, Column, - DateTimeAddExpression, - DateTimeDiffExpression, - DateTimeSubtractExpression, + DatetimeAddExpression, + DatetimeDiffExpression, + DatetimeSubtractExpression, DatePartExpression, DateTruncExpression, ExtractExpression, @@ -52,7 +52,7 @@ def dialect() -> DummyDialect: return DummyDialect() -class TestCollateExpression: +class TestCollateFunction: """Collation expression support (standard SQL COLLATE).""" def test_column_collate(self, dialect): @@ -158,21 +158,21 @@ def test_interval_factory(self, dialect): def test_datetime_add(self, dialect): interval_expr = IntervalExpression(dialect, 1, IntervalUnit.DAY) - expr = DateTimeAddExpression(dialect, Column(dialect, "created_at"), interval_expr) + expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"), interval_expr) sql, params = expr.to_sql() assert sql == '"created_at" + INTERVAL \'1\' DAY' assert params == () def test_datetime_subtract(self, dialect): interval_expr = IntervalExpression(dialect, 1, IntervalUnit.DAY) - expr = DateTimeSubtractExpression(dialect, Column(dialect, "created_at"), interval_expr) + expr = DatetimeSubtractExpression(dialect, Column(dialect, "created_at"), interval_expr) sql, params = expr.to_sql() assert sql == '"created_at" - INTERVAL \'1\' DAY' assert params == () def test_date_add_factory_numeric(self, dialect): expr = date_add(dialect, Column(dialect, "created_at"), 1, "day") - assert isinstance(expr, DateTimeAddExpression) + assert isinstance(expr, DatetimeAddExpression) sql, params = expr.to_sql() assert sql == '"created_at" + INTERVAL \'1\' DAY' assert params == () @@ -180,11 +180,11 @@ def test_date_add_factory_numeric(self, dialect): def test_date_add_factory_interval_object(self, dialect): interval_expr = IntervalExpression(dialect, 2, IntervalUnit.HOUR) expr = date_add(dialect, Column(dialect, "created_at"), interval_expr) - assert isinstance(expr, DateTimeAddExpression) + assert isinstance(expr, DatetimeAddExpression) def test_date_sub_factory_numeric(self, dialect): expr = date_sub(dialect, Column(dialect, "created_at"), 1, "day") - assert isinstance(expr, DateTimeSubtractExpression) + assert isinstance(expr, DatetimeSubtractExpression) sql, params = expr.to_sql() assert sql == '"created_at" - INTERVAL \'1\' DAY' assert params == () @@ -192,7 +192,7 @@ def test_date_sub_factory_numeric(self, dialect): def test_date_sub_factory_interval_object(self, dialect): interval_expr = IntervalExpression(dialect, 2, IntervalUnit.HOUR) expr = date_sub(dialect, Column(dialect, "created_at"), interval_expr) - assert isinstance(expr, DateTimeSubtractExpression) + assert isinstance(expr, DatetimeSubtractExpression) def test_ensure_interval_unit_conflict(self, dialect): with pytest.raises(ValueError): @@ -204,10 +204,10 @@ def test_ensure_interval_missing_unit(self, dialect): def test_date_diff_factory_constructs(self, dialect): expr = date_diff(dialect, "day", "created_at", Literal(dialect, "updated_at")) - assert isinstance(expr, DateTimeDiffExpression) + assert isinstance(expr, DatetimeDiffExpression) def test_date_diff_to_sql_unsupported(self, dialect): - expr = DateTimeDiffExpression( + expr = DatetimeDiffExpression( dialect, IntervalUnit.DAY, Column(dialect, "created_at"), Column(dialect, "updated_at") ) with pytest.raises(Exception) as exc_info: diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_predicates.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_predicates.py index 5cc02fc7c..98ac1c2c8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_predicates.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_predicates.py @@ -3,7 +3,7 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, + RawSQL, Subquery, BooleanColumn, ComparisonPredicate, @@ -25,8 +25,12 @@ class TestPredicateExpressions: [ ((Column, ("age",)), "=", (Literal, (18,)), '"age" = ?', (18,)), ((Column, ("price",)), ">", (Literal, (100.0,)), '"price" > ?', (100.0,)), - ((Column, ("status",)), "!=", (Literal, ("inactive",)), '"status" != ?', ("inactive",)), - ((RawSQLExpression, ("NOW()",)), "<", (Column, ("end_date",)), 'NOW() < "end_date"', ()), + # `!=` and `<>` are one operation, so the node accepts either and the formatter + # writes the standard spelling; the operator travels the artifact as + # the word "ne". See ComparisonOperator. + ((Column, ("status",)), "!=", (Literal, ("inactive",)), '"status" <> ?', ("inactive",)), + ((Column, ("status",)), "<>", (Literal, ("inactive",)), '"status" <> ?', ("inactive",)), + ((RawSQL, ("NOW()",)), "<", (Column, ("end_date",)), 'NOW() < "end_date"', ()), ], ) def test_comparison_predicate( @@ -90,7 +94,7 @@ def test_in_predicate_empty_list(self, dummy_dialect: DummyDialect): [ ((Column, ("quantity",)), (Literal, (10,)), (Literal, (20,)), '"quantity" BETWEEN ? AND ?', (10, 20)), ( - (RawSQLExpression, ("order_date",)), + (RawSQL, ("order_date",)), (Literal, ("2023-01-01",)), (Literal, ("2023-12-31",)), "order_date BETWEEN ? AND ?", @@ -180,8 +184,8 @@ def test_complex_logical_predicate(self, dummy_dialect: DummyDialect): ((Column, ("is_active",)), True, True, '"is_active" IS NOT TRUE', ()), ((Column, ("is_deleted",)), False, False, '"is_deleted" IS FALSE', ()), ((Column, ("is_deleted",)), False, True, '"is_deleted" IS NOT FALSE', ()), - ((RawSQLExpression, ("verified",)), True, False, "verified IS TRUE", ()), - ((RawSQLExpression, ("verified",)), False, True, "verified IS NOT FALSE", ()), + ((RawSQL, ("verified",)), True, False, "verified IS TRUE", ()), + ((RawSQL, ("verified",)), False, True, "verified IS NOT FALSE", ()), ], ) def test_is_boolean_predicate( diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_transaction.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_transaction.py index ce7511fb6..208deb6c5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_transaction.py @@ -1,14 +1,14 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_expressions_transaction.py -"""Tests for transaction expression classes.""" +"""Tests for the transaction statement classes.""" import pytest from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, + BeginTransactionStatement, + CommitStatement, + RollbackStatement, + SavepointStatement, + ReleaseSavepointStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect @@ -18,26 +18,26 @@ class TestBeginTransactionExpression: - """Tests for BeginTransactionExpression.""" + """Tests for BeginTransactionStatement.""" def test_basic_begin(self, dummy_dialect: DummyDialect): """Test basic BEGIN statement.""" - expr = BeginTransactionExpression(dummy_dialect) + expr = BeginTransactionStatement(dummy_dialect) sql, params = expr.to_sql() assert sql == "BEGIN" assert params == () def test_begin_with_isolation_level(self, dummy_dialect: DummyDialect): """Test BEGIN with isolation level.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert "ISOLATION LEVEL SERIALIZABLE" in sql assert params == () def test_begin_read_only(self, dummy_dialect: DummyDialect): """Test BEGIN READ ONLY.""" - expr = BeginTransactionExpression(dummy_dialect) + expr = BeginTransactionStatement(dummy_dialect) expr.read_only() sql, params = expr.to_sql() assert "READ ONLY" in sql @@ -45,7 +45,7 @@ def test_begin_read_only(self, dummy_dialect: DummyDialect): def test_begin_read_write(self, dummy_dialect: DummyDialect): """Test BEGIN READ WRITE.""" - expr = BeginTransactionExpression(dummy_dialect) + expr = BeginTransactionStatement(dummy_dialect) expr.read_write() sql, params = expr.to_sql() assert "READ WRITE" in sql @@ -53,8 +53,8 @@ def test_begin_read_write(self, dummy_dialect: DummyDialect): def test_begin_with_isolation_and_mode(self, dummy_dialect: DummyDialect): """Test BEGIN with isolation level and mode.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.REPEATABLE_READ).read_only() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.REPEATABLE_READ).read_only() sql, params = expr.to_sql() assert "ISOLATION LEVEL REPEATABLE READ" in sql assert "READ ONLY" in sql @@ -62,8 +62,8 @@ def test_begin_with_isolation_and_mode(self, dummy_dialect: DummyDialect): def test_begin_deferrable(self, dummy_dialect: DummyDialect): """Test BEGIN with DEFERRABLE.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).deferrable() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_deferrable() sql, params = expr.to_sql() assert "ISOLATION LEVEL SERIALIZABLE" in sql assert "DEFERRABLE" in sql @@ -71,16 +71,16 @@ def test_begin_deferrable(self, dummy_dialect: DummyDialect): def test_begin_not_deferrable(self, dummy_dialect: DummyDialect): """Test BEGIN with NOT DEFERRABLE.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).not_deferrable() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_not_deferrable() sql, params = expr.to_sql() assert "NOT DEFERRABLE" in sql assert params == () def test_begin_wait(self, dummy_dialect: DummyDialect): """Test BEGIN with the WAIT lock-wait spelling.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).wait() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_wait() sql, params = expr.to_sql() assert "ISOLATION LEVEL SERIALIZABLE" in sql assert sql.endswith("WAIT") @@ -89,19 +89,19 @@ def test_begin_wait(self, dummy_dialect: DummyDialect): def test_begin_no_wait(self, dummy_dialect: DummyDialect): """Test BEGIN with the NO WAIT lock-wait spelling.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.no_wait() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_no_wait() sql, params = expr.to_sql() assert sql == "BEGIN NO WAIT" assert params == () def test_begin_wait_constructor_kwarg(self, dummy_dialect: DummyDialect): """The pair is also settable at construction.""" - assert BeginTransactionExpression(dummy_dialect, wait=True).to_sql()[0] == "BEGIN WAIT" + assert BeginTransactionStatement(dummy_dialect, wait=True).to_sql()[0] == "BEGIN WAIT" def test_begin_wait_pair_is_mutually_exclusive(self, dummy_dialect: DummyDialect): with pytest.raises(ValueError, match="wait and no_wait are mutually exclusive"): - BeginTransactionExpression(dummy_dialect, wait=True, no_wait=True) + BeginTransactionStatement(dummy_dialect, wait=True, no_wait=True) def test_begin_deferrable_without_serializable_is_rendered(self, dummy_dialect: DummyDialect): """[NOT] DEFERRABLE is an independent transaction mode, not dropped. @@ -110,26 +110,26 @@ def test_begin_deferrable_without_serializable_is_rendered(self, dummy_dialect: accepts it alone, so the request must be carried through rather than silently ignored. """ - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.READ_COMMITTED).deferrable() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.READ_COMMITTED).with_deferrable() sql, params = expr.to_sql() assert "DEFERRABLE" in sql assert "READ COMMITTED" in sql def test_method_chaining(self, dummy_dialect: DummyDialect): """Test method chaining returns self.""" - expr = BeginTransactionExpression(dummy_dialect) - result = expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(dummy_dialect) + result = expr.with_isolation_level(IsolationLevel.SERIALIZABLE) assert result is expr result = expr.read_only() assert result is expr - result = expr.deferrable() + result = expr.with_deferrable() assert result is expr def test_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns correct dictionary.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).read_only().deferrable() + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).read_only().with_deferrable() params = expr.get_params() assert params["isolation_level"] == IsolationLevel.SERIALIZABLE assert params["mode"] == TransactionMode.READ_ONLY @@ -138,7 +138,7 @@ def test_get_params(self, dummy_dialect: DummyDialect): def test_sqlite_read_only_raises_error(self): """Test SQLite READ ONLY raises UnsupportedTransactionModeError.""" sqlite_dialect = SQLiteDialect() - expr = BeginTransactionExpression(sqlite_dialect) + expr = BeginTransactionStatement(sqlite_dialect) expr.read_only() with pytest.raises(UnsupportedTransactionModeError) as exc_info: expr.to_sql() @@ -147,8 +147,8 @@ def test_sqlite_read_only_raises_error(self): def test_sqlite_begin_serializable(self): """Test SQLite BEGIN with SERIALIZABLE isolation.""" sqlite_dialect = SQLiteDialect() - expr = BeginTransactionExpression(sqlite_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(sqlite_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert sql == "BEGIN IMMEDIATE TRANSACTION" assert params == () @@ -156,43 +156,43 @@ def test_sqlite_begin_serializable(self): def test_sqlite_begin_read_uncommitted(self): """Test SQLite BEGIN with READ UNCOMMITTED isolation.""" sqlite_dialect = SQLiteDialect() - expr = BeginTransactionExpression(sqlite_dialect) - expr.isolation_level(IsolationLevel.READ_UNCOMMITTED) + expr = BeginTransactionStatement(sqlite_dialect) + expr.with_isolation_level(IsolationLevel.READ_UNCOMMITTED) sql, params = expr.to_sql() assert sql == "BEGIN DEFERRED TRANSACTION" assert params == () class TestCommitTransactionExpression: - """Tests for CommitTransactionExpression.""" + """Tests for CommitStatement.""" def test_commit(self, dummy_dialect: DummyDialect): """Test COMMIT statement.""" - expr = CommitTransactionExpression(dummy_dialect) + expr = CommitStatement(dummy_dialect) sql, params = expr.to_sql() assert sql == "COMMIT" assert params == () def test_commit_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns empty dict.""" - expr = CommitTransactionExpression(dummy_dialect) + expr = CommitStatement(dummy_dialect) params = expr.get_params() assert params == {} class TestRollbackTransactionExpression: - """Tests for RollbackTransactionExpression.""" + """Tests for RollbackStatement.""" def test_rollback(self, dummy_dialect: DummyDialect): """Test ROLLBACK statement.""" - expr = RollbackTransactionExpression(dummy_dialect) + expr = RollbackStatement(dummy_dialect) sql, params = expr.to_sql() assert sql == "ROLLBACK" assert params == () def test_rollback_to_savepoint(self, dummy_dialect: DummyDialect): """Test ROLLBACK TO SAVEPOINT statement.""" - expr = RollbackTransactionExpression(dummy_dialect) + expr = RollbackStatement(dummy_dialect) expr.to_savepoint("my_savepoint") sql, params = expr.to_sql() assert "ROLLBACK" in sql @@ -201,30 +201,30 @@ def test_rollback_to_savepoint(self, dummy_dialect: DummyDialect): def test_rollback_get_params_with_savepoint(self, dummy_dialect: DummyDialect): """Test get_params returns savepoint name.""" - expr = RollbackTransactionExpression(dummy_dialect) + expr = RollbackStatement(dummy_dialect) expr.to_savepoint("test_sp") params = expr.get_params() assert params == {"savepoint": "test_sp"} def test_rollback_get_params_empty(self, dummy_dialect: DummyDialect): """Test get_params returns savepoint=None when none set.""" - expr = RollbackTransactionExpression(dummy_dialect) + expr = RollbackStatement(dummy_dialect) params = expr.get_params() assert params == {"savepoint": None} def test_rollback_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns savepoint=None when none set.""" - expr = RollbackTransactionExpression(dummy_dialect) + expr = RollbackStatement(dummy_dialect) params = expr.get_params() assert params == {"savepoint": None} class TestSavepointExpression: - """Tests for SavepointExpression.""" + """Tests for SavepointStatement.""" def test_savepoint(self, dummy_dialect: DummyDialect): """Test SAVEPOINT statement.""" - expr = SavepointExpression(dummy_dialect, "my_savepoint") + expr = SavepointStatement(dummy_dialect, "my_savepoint") sql, params = expr.to_sql() assert "SAVEPOINT" in sql assert "my_savepoint" in sql @@ -232,22 +232,22 @@ def test_savepoint(self, dummy_dialect: DummyDialect): def test_savepoint_name_property(self, dummy_dialect: DummyDialect): """Test name property.""" - expr = SavepointExpression(dummy_dialect, "test_sp") + expr = SavepointStatement(dummy_dialect, "test_sp") assert expr.name == "test_sp" def test_savepoint_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns name.""" - expr = SavepointExpression(dummy_dialect, "my_sp") + expr = SavepointStatement(dummy_dialect, "my_sp") params = expr.get_params() assert params == {"name": "my_sp"} class TestReleaseSavepointExpression: - """Tests for ReleaseSavepointExpression.""" + """Tests for ReleaseSavepointStatement.""" def test_release_savepoint(self, dummy_dialect: DummyDialect): """Test RELEASE SAVEPOINT statement.""" - expr = ReleaseSavepointExpression(dummy_dialect, "my_savepoint") + expr = ReleaseSavepointStatement(dummy_dialect, "my_savepoint") sql, params = expr.to_sql() assert "RELEASE SAVEPOINT" in sql assert "my_savepoint" in sql @@ -255,23 +255,23 @@ def test_release_savepoint(self, dummy_dialect: DummyDialect): def test_release_savepoint_name_property(self, dummy_dialect: DummyDialect): """Test name property.""" - expr = ReleaseSavepointExpression(dummy_dialect, "test_sp") + expr = ReleaseSavepointStatement(dummy_dialect, "test_sp") assert expr.name == "test_sp" def test_release_savepoint_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns name.""" - expr = ReleaseSavepointExpression(dummy_dialect, "my_sp") + expr = ReleaseSavepointStatement(dummy_dialect, "my_sp") params = expr.get_params() assert params == {"name": "my_sp"} class TestSetTransactionExpression: - """Tests for SetTransactionExpression.""" + """Tests for SetTransactionStatement.""" def test_set_transaction_isolation_level(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION with isolation level.""" - expr = SetTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = SetTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert "SET TRANSACTION" in sql assert "ISOLATION LEVEL SERIALIZABLE" in sql @@ -279,7 +279,7 @@ def test_set_transaction_isolation_level(self, dummy_dialect: DummyDialect): def test_set_transaction_read_only(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION READ ONLY.""" - expr = SetTransactionExpression(dummy_dialect) + expr = SetTransactionStatement(dummy_dialect) expr.read_only() sql, params = expr.to_sql() assert "SET TRANSACTION" in sql @@ -288,7 +288,7 @@ def test_set_transaction_read_only(self, dummy_dialect: DummyDialect): def test_set_transaction_read_write(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION READ WRITE.""" - expr = SetTransactionExpression(dummy_dialect) + expr = SetTransactionStatement(dummy_dialect) expr.read_write() sql, params = expr.to_sql() assert "SET TRANSACTION" in sql @@ -297,16 +297,16 @@ def test_set_transaction_read_write(self, dummy_dialect: DummyDialect): def test_set_transaction_session(self, dummy_dialect: DummyDialect): """Test SET SESSION CHARACTERISTICS AS TRANSACTION.""" - expr = SetTransactionExpression(dummy_dialect) - expr.session(True) + expr = SetTransactionStatement(dummy_dialect) + expr.with_session(True) sql, params = expr.to_sql() assert "SET SESSION CHARACTERISTICS AS TRANSACTION" in sql assert params == () def test_set_transaction_deferrable(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION DEFERRABLE.""" - expr = SetTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).deferrable() + expr = SetTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_deferrable() sql, params = expr.to_sql() assert "ISOLATION LEVEL SERIALIZABLE" in sql assert "DEFERRABLE" in sql @@ -314,36 +314,36 @@ def test_set_transaction_deferrable(self, dummy_dialect: DummyDialect): def test_set_transaction_not_deferrable(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION NOT DEFERRABLE.""" - expr = SetTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).not_deferrable() + expr = SetTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_not_deferrable() sql, params = expr.to_sql() assert "NOT DEFERRABLE" in sql assert params == () def test_set_transaction_wait(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION with the WAIT lock-wait spelling.""" - expr = SetTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).wait() + expr = SetTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).with_wait() sql, params = expr.to_sql() assert sql == "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE WAIT" assert params == () def test_set_transaction_no_wait(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION with the NO WAIT lock-wait spelling.""" - expr = SetTransactionExpression(dummy_dialect) - expr.no_wait() + expr = SetTransactionStatement(dummy_dialect) + expr.with_no_wait() sql, params = expr.to_sql() assert sql == "SET TRANSACTION NO WAIT" assert params == () def test_set_transaction_wait_pair_is_mutually_exclusive(self, dummy_dialect: DummyDialect): with pytest.raises(ValueError, match="wait and no_wait are mutually exclusive"): - SetTransactionExpression(dummy_dialect, wait=True, no_wait=True) + SetTransactionStatement(dummy_dialect, wait=True, no_wait=True) def test_set_transaction_all_options(self, dummy_dialect: DummyDialect): """Test SET TRANSACTION with all options.""" - expr = SetTransactionExpression(dummy_dialect) - expr.session(True).isolation_level(IsolationLevel.SERIALIZABLE).read_only().deferrable() + expr = SetTransactionStatement(dummy_dialect) + expr.with_session(True).with_isolation_level(IsolationLevel.SERIALIZABLE).read_only().with_deferrable() sql, params = expr.to_sql() assert "SET SESSION CHARACTERISTICS AS TRANSACTION" in sql assert "ISOLATION LEVEL SERIALIZABLE" in sql @@ -353,20 +353,20 @@ def test_set_transaction_all_options(self, dummy_dialect: DummyDialect): def test_method_chaining(self, dummy_dialect: DummyDialect): """Test method chaining returns self.""" - expr = SetTransactionExpression(dummy_dialect) - result = expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = SetTransactionStatement(dummy_dialect) + result = expr.with_isolation_level(IsolationLevel.SERIALIZABLE) assert result is expr result = expr.read_only() assert result is expr - result = expr.session(True) + result = expr.with_session(True) assert result is expr - result = expr.deferrable() + result = expr.with_deferrable() assert result is expr def test_get_params(self, dummy_dialect: DummyDialect): """Test get_params returns correct dictionary.""" - expr = SetTransactionExpression(dummy_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE).read_only().session(True).deferrable() + expr = SetTransactionStatement(dummy_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE).read_only().with_session(True).with_deferrable() params = expr.get_params() assert params["isolation_level"] == IsolationLevel.SERIALIZABLE assert params["mode"] == TransactionMode.READ_ONLY @@ -375,7 +375,7 @@ def test_get_params(self, dummy_dialect: DummyDialect): def test_get_params_empty(self, dummy_dialect: DummyDialect): """Test get_params returns defaults when nothing set.""" - expr = SetTransactionExpression(dummy_dialect) + expr = SetTransactionStatement(dummy_dialect) params = expr.get_params() assert params == { "isolation_level": None, @@ -402,8 +402,8 @@ class TestAllIsolationLevels: ) def test_isolation_level_names(self, dummy_dialect: DummyDialect, level, expected_name): """Test all isolation level names are correctly formatted.""" - expr = BeginTransactionExpression(dummy_dialect) - expr.isolation_level(level) + expr = BeginTransactionStatement(dummy_dialect) + expr.with_isolation_level(level) sql, params = expr.to_sql() assert expected_name in sql @@ -437,11 +437,11 @@ def test_supports_savepoint(self, dummy_dialect: DummyDialect): class TestReleaseSavepointSQL: - """Tests for ReleaseSavepointExpression SQL output.""" + """Tests for ReleaseSavepointStatement SQL output.""" def test_release_savepoint_sql(self, dummy_dialect: DummyDialect): """Test RELEASE SAVEPOINT SQL output.""" - expr = ReleaseSavepointExpression(dummy_dialect, "my_savepoint") + expr = ReleaseSavepointStatement(dummy_dialect, "my_savepoint") sql, params = expr.to_sql() assert "RELEASE SAVEPOINT" in sql assert "my_savepoint" in sql @@ -449,7 +449,7 @@ def test_release_savepoint_sql(self, dummy_dialect: DummyDialect): def test_release_savepoint_with_special_chars(self, dummy_dialect: DummyDialect): """Test RELEASE SAVEPOINT with special characters in name.""" - expr = ReleaseSavepointExpression(dummy_dialect, "sp_with_underscore") + expr = ReleaseSavepointStatement(dummy_dialect, "sp_with_underscore") sql, params = expr.to_sql() assert "RELEASE SAVEPOINT" in sql assert "sp_with_underscore" in sql @@ -487,9 +487,9 @@ def test_not_supports_transaction_wait(self, sqlite_dialect: SQLiteDialect): def test_begin_refuses_wait(self, sqlite_dialect: SQLiteDialect): """SQLite has no WAIT / NO WAIT clause; the request is refused by name.""" with pytest.raises(UnsupportedFeatureError, match="WAIT"): - BeginTransactionExpression(sqlite_dialect, wait=True).to_sql() + BeginTransactionStatement(sqlite_dialect, wait=True).to_sql() with pytest.raises(UnsupportedFeatureError, match="NO WAIT"): - BeginTransactionExpression(sqlite_dialect, no_wait=True).to_sql() + BeginTransactionStatement(sqlite_dialect, no_wait=True).to_sql() def test_supports_savepoint(self, sqlite_dialect: SQLiteDialect): """Test supports_savepoint returns True for SQLite.""" @@ -497,7 +497,7 @@ def test_supports_savepoint(self, sqlite_dialect: SQLiteDialect): def test_begin_raises_error_for_read_only(self, sqlite_dialect: SQLiteDialect): """Test BEGIN with READ ONLY raises UnsupportedTransactionModeError.""" - expr = BeginTransactionExpression(sqlite_dialect) + expr = BeginTransactionStatement(sqlite_dialect) expr.read_only() with pytest.raises(UnsupportedTransactionModeError) as exc_info: expr.to_sql() @@ -505,23 +505,23 @@ def test_begin_raises_error_for_read_only(self, sqlite_dialect: SQLiteDialect): def test_begin_serializable_uses_immediate(self, sqlite_dialect: SQLiteDialect): """Test BEGIN with SERIALIZABLE uses IMMEDIATE keyword.""" - expr = BeginTransactionExpression(sqlite_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(sqlite_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert sql == "BEGIN IMMEDIATE TRANSACTION" assert params == () def test_begin_read_uncommitted_uses_deferred(self, sqlite_dialect: SQLiteDialect): """Test BEGIN with READ_UNCOMMITTED uses DEFERRED keyword.""" - expr = BeginTransactionExpression(sqlite_dialect) - expr.isolation_level(IsolationLevel.READ_UNCOMMITTED) + expr = BeginTransactionStatement(sqlite_dialect) + expr.with_isolation_level(IsolationLevel.READ_UNCOMMITTED) sql, params = expr.to_sql() assert sql == "BEGIN DEFERRED TRANSACTION" assert params == () def test_begin_default_uses_immediate(self, sqlite_dialect: SQLiteDialect): """Test default BEGIN uses IMMEDIATE for better concurrency.""" - expr = BeginTransactionExpression(sqlite_dialect) + expr = BeginTransactionStatement(sqlite_dialect) sql, params = expr.to_sql() assert sql == "BEGIN IMMEDIATE TRANSACTION" assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_column_protocol.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_column_protocol.py index 9466928a1..bae9daa80 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_column_protocol.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_column_protocol.py @@ -1,18 +1,21 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_columns.py -from rhosocial.activerecord.backend.expression import BinaryExpression, Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.types import DecimalType, IntegerType, VarCharType +from rhosocial.activerecord.backend.dialect.mixins import GeneratedColumnMixin +from rhosocial.activerecord.backend.dialect.protocols import GeneratedColumnSupport +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, GeneratedColumnType, - GeneratedColumnExpression, - CreateTableExpression, + CreateTableStatement, ) -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.dialect.protocols import GeneratedColumnSupport -from rhosocial.activerecord.backend.dialect.mixins import GeneratedColumnMixin -from rhosocial.activerecord.backend.expression.types import DecimalType, IntegerType, VarCharType -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import BinaryExpression, Column, Literal +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_generated_columns.py class TestGeneratedColumnProtocol: @@ -40,12 +43,12 @@ class TestDialect(GeneratedColumnMixin): assert dialect.supports_virtual_generated_columns() is False -class TestGeneratedColumnExpression: - """Tests for GeneratedColumnExpression as standalone expression.""" +class TestColumnGeneratedConstraint: + """Tests for ColumnGeneratedConstraint as standalone expression.""" def test_to_sql_stored(self, dummy_dialect: DummyDialect): - """Test GeneratedColumnExpression.to_sql() for STORED.""" - gen = GeneratedColumnExpression( + """Test ColumnGeneratedConstraint.to_sql() for STORED.""" + gen = ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, @@ -62,8 +65,8 @@ def test_to_sql_stored(self, dummy_dialect: DummyDialect): assert params == () def test_to_sql_virtual(self, dummy_dialect: DummyDialect): - """Test GeneratedColumnExpression.to_sql() for VIRTUAL.""" - gen = GeneratedColumnExpression( + """Test ColumnGeneratedConstraint.to_sql() for VIRTUAL.""" + gen = ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression(dummy_dialect, "+", Column(dummy_dialect, "a"), Column(dummy_dialect, "b")), ) @@ -74,7 +77,7 @@ def test_to_sql_virtual(self, dummy_dialect: DummyDialect): def test_default_storage_is_virtual(self, dummy_dialect: DummyDialect): """Test that default storage type is VIRTUAL.""" - gen = GeneratedColumnExpression( + gen = ColumnGeneratedConstraint( dummy_dialect, expression=Column(dummy_dialect, "x"), ) @@ -87,15 +90,16 @@ class TestGeneratedColumnBasic: def test_virtual_generated_column(self, dummy_dialect: DummyDialect): """Test CREATE TABLE with VIRTUAL generated column.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, - "first_name", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)] + "first_name", VarCharType(dummy_dialect, 50), + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))] ), - ColumnDefinition(dummy_dialect, "last_name", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dummy_dialect, "last_name", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "full_name", VarCharType(dummy_dialect, 101), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=( BinaryExpression( @@ -103,7 +107,7 @@ def test_virtual_generated_column(self, dummy_dialect: DummyDialect): "+", BinaryExpression( dummy_dialect, "+", Column(dummy_dialect, "first_name"), - Literal(dummy_dialect, " ", inline_literals=True), + Literal(dummy_dialect, " ", inline=True), ), Column(dummy_dialect, "last_name"), ) @@ -113,7 +117,7 @@ def test_virtual_generated_column(self, dummy_dialect: DummyDialect): ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) sql, params = create_table.to_sql() assert 'CREATE TABLE "users"' in sql @@ -124,13 +128,13 @@ def test_virtual_generated_column(self, dummy_dialect: DummyDialect): def test_stored_generated_column(self, dummy_dialect: DummyDialect): """Test CREATE TABLE with STORED generated column.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "price", DecimalType(dummy_dialect, precision=10, scale=2)), ColumnDefinition(dummy_dialect, "quantity", IntegerType(dummy_dialect)), ColumnDefinition(dummy_dialect, "total", DecimalType(dummy_dialect, precision=10, scale=2), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, @@ -143,7 +147,7 @@ def test_stored_generated_column(self, dummy_dialect: DummyDialect): ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'order_items'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'order_items'), columns=columns) sql, params = create_table.to_sql() assert 'CREATE TABLE "order_items"' in sql @@ -158,23 +162,23 @@ def test_generated_column_default_virtual(self, dummy_dialect: DummyDialect): dummy_dialect, "id", IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))], ), ColumnDefinition(dummy_dialect, "computed", IntegerType(dummy_dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, "+", Column(dummy_dialect, "id"), - Literal(dummy_dialect, 1), + Literal(dummy_dialect, 1, inline=True), ), ), ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) sql, params = create_table.to_sql() assert '"computed" INTEGER GENERATED ALWAYS AS' in sql @@ -187,31 +191,31 @@ class TestGeneratedColumnWithConstraints: def test_generated_column_with_not_null(self, dummy_dialect: DummyDialect): """Test generated column with NOT NULL constraint.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "value", IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)], - generated_expression=GeneratedColumnExpression( + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))], + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, "*", Column(dummy_dialect, "id"), - Literal(dummy_dialect, 2), + Literal(dummy_dialect, 2, inline=True), ), storage_type=GeneratedColumnType.VIRTUAL, ), ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) sql, params = create_table.to_sql() assert '"value" INTEGER NOT NULL GENERATED ALWAYS AS' in sql -class TestGeneratedColumnExpressions: +class TestColumnGeneratedConstraints: """Tests for various expression types in generated columns.""" def test_arithmetic_expression(self, dummy_dialect: DummyDialect): @@ -222,7 +226,7 @@ def test_arithmetic_expression(self, dummy_dialect: DummyDialect): ColumnDefinition(dummy_dialect, "sum_result", IntegerType(dummy_dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, @@ -235,7 +239,7 @@ def test_arithmetic_expression(self, dummy_dialect: DummyDialect): ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'math_table'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'math_table'), columns=columns) sql, params = create_table.to_sql() assert '"sum_result" INTEGER GENERATED ALWAYS AS' in sql @@ -249,7 +253,7 @@ def test_string_concatenation(self, dummy_dialect: DummyDialect): ColumnDefinition(dummy_dialect, "full", VarCharType(dummy_dialect, 101), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dummy_dialect, expression=BinaryExpression( dummy_dialect, @@ -258,7 +262,7 @@ def test_string_concatenation(self, dummy_dialect: DummyDialect): dummy_dialect, "+", Column(dummy_dialect, "first"), - Literal(dummy_dialect, " "), + Literal(dummy_dialect, " ", inline=True), ), Column(dummy_dialect, "last"), ), @@ -267,7 +271,7 @@ def test_string_concatenation(self, dummy_dialect: DummyDialect): ), ] - create_table = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'names'), columns=columns) + create_table = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'names'), columns=columns) sql, params = create_table.to_sql() assert '"full" VARCHAR(101) GENERATED ALWAYS AS' in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph.py index cfcec4327..d35db1f33 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph.py @@ -9,7 +9,7 @@ GraphVertex, GraphEdge, GraphEdgeDirection, QuantifiedPath, PathPattern, MatchClause, ) -from rhosocial.activerecord.backend.expression.operators import RawSQLExpression +from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import EdgeTable, NodeTable @@ -289,7 +289,7 @@ def test_with_quantified_path(self, dummy_dialect: DummyDialect): def test_rejects_unsupported_path_element(self, dummy_dialect: DummyDialect): vertex = GraphVertex(dummy_dialect, "a", NodeTable(dummy_dialect, "person")) - raw = RawSQLExpression(dummy_dialect, "1); DROP TABLE people; --") + raw = RawSQL(dummy_dialect, "1); DROP TABLE people; --") pattern = PathPattern(dummy_dialect, vertex, raw, GraphVertex(dummy_dialect, "b", NodeTable(dummy_dialect, "person"))) with pytest.raises(TypeError, match="PathPattern elements"): diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph_table.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph_table.py index 439a94bfe..5c54561b5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph_table.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_graph_table.py @@ -6,18 +6,18 @@ requires a database with PGQ support (PostgreSQL 19+ or Oracle 23c+). """ import pytest +from rhosocial.activerecord.backend.expression import NumericColumn, StringColumn from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.graph import ( GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, GraphColumn, ColumnsClause, GraphTableExpression, - TablePropertiesClause, VertexTable, EdgeTable, - CreatePropertyGraphExpression, DropPropertyGraphExpression, - AlterPropertyGraphExpression, + TablePropertiesClause, VertexTable, EdgeTableDefinition, + CreatePropertyGraphStatement, DropPropertyGraphStatement, + AlterPropertyGraphStatement, ) from rhosocial.activerecord.backend.expression.query_parts import WhereClause -from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.objects import ( EdgeTable as EdgeTableObject, NodeTable, @@ -104,7 +104,7 @@ def test_with_pattern(self, dummy_dialect: DummyDialect): def test_with_where(self, dummy_dialect: DummyDialect): where = WhereClause(dummy_dialect, - condition=Column(dummy_dialect, "age") > Literal(dummy_dialect, 18)) + condition=NumericColumn(dummy_dialect, "age") > Literal(dummy_dialect, 18)) v = GraphVertex(dummy_dialect, "p", NodeTable(dummy_dialect, "person"), where=where) cols = ColumnsClause(dummy_dialect, GraphColumn("p", "name")) match = MatchClause(dummy_dialect, v) @@ -157,42 +157,60 @@ def test_with_properties(self, dummy_dialect: DummyDialect): sql, params = vt.to_sql() assert "PROPERTIES" in sql + def test_the_key_clause_precedes_the_label_clause(self, dummy_dialect: DummyDialect): + """The clause order is the grammar's, not a free choice. + + Oracle parses ``KEY (...) LABEL ...`` and rejects the other order with + a parse error -- "missing right parenthesis" -- that takes the whole + statement down, so the order is pinned here. A case that only + asserted "LABEL" in the text would not notice the clause being + moved, and the statement would stop being accepted. + """ + vt = VertexTable( + dummy_dialect, + NodeTable(dummy_dialect, "person"), + labels=["Person"], + key_columns=["id"], + ) + sql, params = vt.to_sql() + assert sql.index("KEY") < sql.index("LABEL"), sql + class TestEdgeTable: """Tests for EdgeTable.""" def test_minimal(self, dummy_dialect: DummyDialect): - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) sql, params = et.to_sql() assert "SOURCE KEY" in sql assert "DESTINATION KEY" in sql def test_with_references(self, dummy_dialect: DummyDialect): - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], references_source=("people", ["id"]), references_destination=("people", ["id"])) sql, params = et.to_sql() assert "REFERENCES" in sql def test_with_labels(self, dummy_dialect: DummyDialect): - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], labels=["Knows"]) + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], labels=["Knows"]) sql, params = et.to_sql() assert "LABEL" in sql def test_with_properties(self, dummy_dialect: DummyDialect): props = TablePropertiesClause(dummy_dialect, columns=["since"]) - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], properties=props) + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], properties=props) sql, params = et.to_sql() assert "PROPERTIES" in sql class TestCreatePropertyGraphExpression: - """Tests for CreatePropertyGraphExpression.""" + """Tests for CreatePropertyGraphStatement.""" def test_basic(self, dummy_dialect: DummyDialect): vt = VertexTable(dummy_dialect, NodeTable(dummy_dialect, "person"), labels=["Person"]) - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], labels=["Knows"]) - expr = CreatePropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt], [et]) + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], labels=["Knows"]) + expr = CreatePropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt], [et]) sql, params = expr.to_sql() assert "CREATE PROPERTY GRAPH" in sql assert "VERTEX TABLES" in sql @@ -200,42 +218,42 @@ def test_basic(self, dummy_dialect: DummyDialect): def test_if_not_exists(self, dummy_dialect: DummyDialect): vt = VertexTable(dummy_dialect, NodeTable(dummy_dialect, "person")) - expr = CreatePropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt], if_not_exists=True) + expr = CreatePropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt], if_not_exists=True) sql, params = expr.to_sql() assert "IF NOT EXISTS" in sql def test_no_edges(self, dummy_dialect: DummyDialect): vt = VertexTable(dummy_dialect, NodeTable(dummy_dialect, "person")) - expr = CreatePropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt]) + expr = CreatePropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), [vt]) sql, params = expr.to_sql() assert "CREATE PROPERTY GRAPH" in sql class TestDropPropertyGraphExpression: - """Tests for DropPropertyGraphExpression.""" + """Tests for DropPropertyGraphStatement.""" def test_basic(self, dummy_dialect: DummyDialect): - expr = DropPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g")) + expr = DropPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g")) sql, params = expr.to_sql() assert sql == 'DROP PROPERTY GRAPH "g"' def test_if_exists(self, dummy_dialect: DummyDialect): - expr = DropPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), if_exists=True) + expr = DropPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), if_exists=True) sql, params = expr.to_sql() assert "IF EXISTS" in sql def test_cascade(self, dummy_dialect: DummyDialect): - expr = DropPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), cascade=True) + expr = DropPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), cascade=True) sql, params = expr.to_sql() assert "CASCADE" in sql class TestAlterPropertyGraphExpression: - """Tests for AlterPropertyGraphExpression.""" + """Tests for AlterPropertyGraphStatement.""" def test_basic(self, dummy_dialect: DummyDialect): vt = VertexTable(dummy_dialect, NodeTable(dummy_dialect, "person")) - expr = AlterPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "ADD", "VERTEX TABLES", + expr = AlterPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "ADD", "VERTEX TABLES", vertex_tables=[vt]) sql, params = expr.to_sql() assert "ALTER PROPERTY GRAPH" in sql @@ -243,8 +261,8 @@ def test_basic(self, dummy_dialect: DummyDialect): assert "VERTEX" in sql.upper() def test_with_edge_tables(self, dummy_dialect: DummyDialect): - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) - expr = AlterPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "DROP", "EDGE TABLES", + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) + expr = AlterPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "DROP", "EDGE TABLES", edge_tables=[et]) sql, params = expr.to_sql() assert 'DROP' in sql @@ -253,8 +271,8 @@ def test_with_edge_tables(self, dummy_dialect: DummyDialect): def test_with_both_tables(self, dummy_dialect: DummyDialect): vt = VertexTable(dummy_dialect, NodeTable(dummy_dialect, "person")) - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) - expr = AlterPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "ADD", "TABLES", + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) + expr = AlterPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), "ADD", "TABLES", vertex_tables=[vt], edge_tables=[et]) sql, params = expr.to_sql() assert '"person"' in sql @@ -268,7 +286,7 @@ def test_with_both_tables(self, dummy_dialect: DummyDialect): ], ) def test_rejects_alter_keyword_injection(self, dummy_dialect, action, target, label): - expression = AlterPropertyGraphExpression(dummy_dialect, PropertyGraph(dummy_dialect, "g"), action, target) + expression = AlterPropertyGraphStatement(dummy_dialect, PropertyGraph(dummy_dialect, "g"), action, target) with pytest.raises(ValueError, match=f"Invalid ALTER PROPERTY GRAPH {label}"): expression.to_sql() @@ -299,7 +317,7 @@ class TestVertexWithWhereClause: def test_vertex_with_where(self, dummy_dialect: DummyDialect): where = WhereClause(dummy_dialect, - condition=Column(dummy_dialect, "status") == Literal(dummy_dialect, "active")) + condition=StringColumn(dummy_dialect, "status") == Literal(dummy_dialect, "active")) v = GraphVertex(dummy_dialect, "p", NodeTable(dummy_dialect, "person"), where=where) sql, params = v.to_sql() assert "WHERE" in sql @@ -344,13 +362,13 @@ def test_format_vertex_table_unsupported(self, unsupported_dialect): vt.to_sql() def test_format_edge_table_unsupported(self, unsupported_dialect): - et = EdgeTable(unsupported_dialect, EdgeTableObject(unsupported_dialect, "knows"), ["pid"], ["fid"]) + et = EdgeTableDefinition(unsupported_dialect, EdgeTableObject(unsupported_dialect, "knows"), ["pid"], ["fid"]) with pytest.raises(UnsupportedFeatureError): et.to_sql() def test_edge_table_no_references(self, dummy_dialect: DummyDialect): """EdgeTable without REFERENCES (SOURCE/DESTINATION KEY only).""" - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"]) sql, params = et.to_sql() assert 'SOURCE KEY ("pid")' in sql assert 'DESTINATION KEY ("fid")' in sql @@ -358,7 +376,7 @@ def test_edge_table_no_references(self, dummy_dialect: DummyDialect): def test_edge_table_with_key_columns(self, dummy_dialect: DummyDialect): """EdgeTable with explicit KEY.""" - et = EdgeTable(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], + et = EdgeTableDefinition(dummy_dialect, EdgeTableObject(dummy_dialect, "knows"), ["pid"], ["fid"], key_columns=["id"], references_source=("people", ["id"]), references_destination=("people", ["id"])) @@ -367,18 +385,18 @@ def test_edge_table_with_key_columns(self, dummy_dialect: DummyDialect): def test_create_property_graph_unsupported(self, unsupported_dialect): vt = VertexTable(unsupported_dialect, NodeTable(unsupported_dialect, "person")) - expr = CreatePropertyGraphExpression(unsupported_dialect, PropertyGraph(unsupported_dialect, "g"), [vt]) + expr = CreatePropertyGraphStatement(unsupported_dialect, PropertyGraph(unsupported_dialect, "g"), [vt]) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_drop_property_graph_unsupported(self, unsupported_dialect): - expr = DropPropertyGraphExpression(unsupported_dialect, PropertyGraph(unsupported_dialect, "g")) + expr = DropPropertyGraphStatement(unsupported_dialect, PropertyGraph(unsupported_dialect, "g")) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_alter_property_graph_unsupported(self, unsupported_dialect): vt = VertexTable(unsupported_dialect, NodeTable(unsupported_dialect, "person")) - expr = AlterPropertyGraphExpression(unsupported_dialect, PropertyGraph(unsupported_dialect, "g"), "ADD", "VERTEX TABLES", + expr = AlterPropertyGraphStatement(unsupported_dialect, PropertyGraph(unsupported_dialect, "g"), "ADD", "VERTEX TABLES", vertex_tables=[vt]) with pytest.raises(UnsupportedFeatureError): expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_inline_literals.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_inline_literals.py index 005ddad04..7918c9cd1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_inline_literals.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_inline_literals.py @@ -1,14 +1,24 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_inline_literals.py -"""Tests for Literal inline-literal rendering (per-node inline_literals switch). +"""Tests for per-value inline-vs-bound rendering (``Literal.inline``). Covers: -- Default off: bind-parameter placeholder + params -- Explicit on: inline escaped text via format_literal + empty params -- Per-node isolation inside a comparison predicate -- Setter toggling on an existing node +- ``inline=False`` (the default, every data position): a bind-parameter + placeholder, and nothing in ``params`` to quote +- ``inline=True`` (DDL syntax positions): escaped SQL text, no params +- The decision is per **position**, so one node holding two values can render + one inline and the other bound +- Escaping still goes through the dialect's controlled literal construction +- DDL conditions built from ordinary data-position nodes render inline, because + the DDL renderer marks the values it owns """ -from rhosocial.activerecord.backend.expression import Literal, ComparisonPredicate + +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, +) def _dummy_dialect(): @@ -16,45 +26,148 @@ def _dummy_dialect(): return DummyDialect() -class TestInlineLiterals: - """Literal rendering honours the per-node inline_literals switch.""" +class TestLiteralInlineDecision: + """A literal renders inline or bound according to its own ``inline``.""" - def test_default_off_uses_placeholder(self): + def test_default_binds(self): lit = Literal(_dummy_dialect(), "abc") - assert lit.inline_literals is False + assert lit.inline is False assert lit.to_sql() == ("?", ("abc",)) - def test_explicit_on_inlines_text(self): - lit = Literal(_dummy_dialect(), "abc", inline_literals=True) - sql, params = lit.to_sql() - assert sql == "'abc'" - assert params == () + def test_inline_writes_sql_text(self): + lit = Literal(_dummy_dialect(), "abc", inline=True) + assert lit.to_sql() == ("'abc'", ()) def test_inline_escapes_quotes(self): - lit = Literal(_dummy_dialect(), "it's", inline_literals=True) + lit = Literal(_dummy_dialect(), "it's", inline=True) sql, params = lit.to_sql() assert sql == "'it''s'" assert params == () def test_inline_numeric(self): - lit = Literal(_dummy_dialect(), 42, inline_literals=True) - assert lit.to_sql() == ("42", ()) + assert Literal(_dummy_dialect(), 42, inline=True).to_sql() == ("42", ()) + + def test_repr_shows_the_decision(self): + assert repr(Literal(_dummy_dialect(), 1)) == "Literal(1)" + assert repr(Literal(_dummy_dialect(), 1, inline=True)) == "Literal(1, inline)" + + def test_the_decision_is_per_instance(self): + """Two literals of the same class, decided differently.""" + one = Literal(_dummy_dialect(), 1) + other = Literal(_dummy_dialect(), 2, inline=True) + assert one.to_sql() == ("?", (1,)) + assert other.to_sql() == ("2", ()) - def test_per_node_isolation_in_predicate(self): + +class TestMixedPositionsInOneNode: + """A node holding several values renders each by its own decision.""" + + def test_one_inline_one_bound(self): dialect = _dummy_dialect() pred = ComparisonPredicate( dialect, "=", - Literal(dialect, "a", inline_literals=True), + Literal(dialect, "a", inline=True), Literal(dialect, "b"), ) + assert pred.to_sql() == ("'a' = ?", ("b",)) + + def test_in_list_honours_each_value(self): + dialect = _dummy_dialect() + pred = InPredicate( + dialect, Column(dialect, "status"), + Literal(dialect, ("debit", "credit"), inline=True), + ) sql, params = pred.to_sql() - assert sql == "'a' = ?" - assert params == ("b",) + assert sql == '"status" IN (\'debit\', \'credit\')' + assert params == () - def test_setter_toggling(self): - lit = Literal(_dummy_dialect(), "abc") - assert lit.to_sql() == ("?", ("abc",)) - lit.inline_literals = True - assert lit.to_sql() == ("'abc'", ()) - lit.inline_literals = False - assert lit.to_sql() == ("?", ("abc",)) + def test_in_list_bound_by_default(self): + dialect = _dummy_dialect() + pred = InPredicate( + dialect, Column(dialect, "status"), Literal(dialect, ("a", "b")) + ) + assert pred.to_sql() == ('"status" IN (?, ?)', ("a", "b")) + + +class TestLiteralClaimsNoType: + """A value at a position says nothing about what the position's type is.""" + + def test_no_comparison_operations(self): + lit = Literal(_dummy_dialect(), 7) + assert not hasattr(lit, "like") + assert not hasattr(lit, "sqrt") + + def test_not_a_sql_value(self): + from rhosocial.activerecord.backend.expression.bases import SQLValue + assert not isinstance(Literal(_dummy_dialect(), 7), SQLValue) + + +class TestDdlPositionsDemandInlineValues: + """A DDL position renders the values it is given, and refuses the rest. + + There is no pass that copies the tree and lifts anything into it any more. + A caller who composes a ``CHECK`` condition owns every literal in it and + says ``inline=True`` on each; one that does not renders bind parameters + into a statement that has nowhere to put them, and the refusal names the + fix rather than guarding against a hypothetical. + """ + + def test_check_condition_inlines_the_callers_literals(self): + """A CHECK built from inline-marked values renders inline.""" + from rhosocial.activerecord.backend.expression import TableCheckConstraint + + dialect = _dummy_dialect() + condition = ComparisonPredicate( + dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline=True) + ) + constraint = TableCheckConstraint(dialect, condition) + assert constraint.to_sql() == ('CHECK ("age" > 0)', ()) + + def test_a_value_the_caller_did_not_mark_stays_a_bind_value(self): + """Whether to inline is the caller's decision, not the framework's. + + A literal the caller left as a bind value stays one: the statement + renders with a placeholder and carries the value in ``params``. An + engine that has no room for a bind parameter in that position refuses + the statement -- that is the engine's answer, and the developer knows + which engine the statement is for. + """ + from rhosocial.activerecord.backend.expression import TableCheckConstraint + + dialect = _dummy_dialect() + condition = ComparisonPredicate( + dialect, ">", Column(dialect, "age"), Literal(dialect, 0) + ) + sql, params = TableCheckConstraint(dialect, condition).to_sql() + assert "?" in sql + assert params == (0,) + + def test_partial_index_where_inlines_the_callers_literals(self): + from rhosocial.activerecord.backend.expression.objects import Index, Table + from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexStatement, + ) + + dialect = _dummy_dialect() + where = ComparisonPredicate( + dialect, "=", Column(dialect, "status"), Literal(dialect, "active", inline=True) + ) + stmt = CreateIndexStatement( + dialect, index=Index(dialect, "idx_active"), table=Table(dialect, "users"), + columns=["email"], where=where, + ) + sql, params = stmt.to_sql() + assert "WHERE \"status\" = 'active'" in sql + assert params == () + + def test_check_with_in_list(self): + from rhosocial.activerecord.backend.expression import TableCheckConstraint + + dialect = _dummy_dialect() + condition = InPredicate( + dialect, Column(dialect, "kind"), + Literal(dialect, ("a", "b"), inline=True), + ) + assert TableCheckConstraint(dialect, condition).to_sql() == ( + """CHECK ("kind" IN ('a', 'b'))""", () + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_introspection_expression_params.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_introspection_expression_params.py index d1436b079..a6afe8dab 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_introspection_expression_params.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_introspection_expression_params.py @@ -9,15 +9,15 @@ from rhosocial.activerecord.backend.expression.introspection import ( IntrospectionExpression, - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, ) @@ -30,19 +30,13 @@ def test_introspection_expression_with_schema(self, dummy_dialect): params = expr.get_params() assert params.get("schema") == "public" - def test_introspection_expression_fluent_schema(self, dummy_dialect): - """Test schema can be set via fluent API.""" - expr = IntrospectionExpression(dummy_dialect).schema("public") - params = expr.get_params() - assert params.get("schema") == "public" - class TestTableListExpressionParams: - """Tests for TableListExpression construction parameters.""" + """Tests for TableListQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test TableListExpression accepts all parameters at construction.""" - expr = TableListExpression( + """Test TableListQueryStatement accepts all parameters at construction.""" + expr = TableListQueryStatement( dialect=dummy_dialect, schema="main", include_views=False, include_system=True, table_type="TABLE" ) params = expr.get_params() @@ -52,27 +46,21 @@ def test_constructor_with_all_params(self, dummy_dialect): assert params.get("table_type") == "TABLE" def test_constructor_defaults(self, dummy_dialect): - """Test TableListExpression default values.""" - expr = TableListExpression(dialect=dummy_dialect) + """Test TableListQueryStatement default values.""" + expr = TableListQueryStatement(dialect=dummy_dialect) params = expr.get_params() assert params.get("schema") is None assert params.get("include_views") is True assert params.get("include_system") is False assert params.get("table_type") is None - def test_fluent_api_override(self, dummy_dialect): - """Test fluent API can override constructor values.""" - expr = TableListExpression(dialect=dummy_dialect, include_views=False).include_views(True) - params = expr.get_params() - assert params.get("include_views") is True - class TestTableInfoExpressionParams: - """Tests for TableInfoExpression construction parameters.""" + """Tests for TableInfoQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test TableInfoExpression accepts all parameters at construction.""" - expr = TableInfoExpression( + """Test TableInfoQueryStatement accepts all parameters at construction.""" + expr = TableInfoQueryStatement( dialect=dummy_dialect, table_name="users", schema="public", @@ -88,8 +76,8 @@ def test_constructor_with_all_params(self, dummy_dialect): assert params.get("include_foreign_keys") is False def test_constructor_defaults(self, dummy_dialect): - """Test TableInfoExpression default values.""" - expr = TableInfoExpression(dialect=dummy_dialect, table_name="users") + """Test TableInfoQueryStatement default values.""" + expr = TableInfoQueryStatement(dialect=dummy_dialect, table_name="users") params = expr.get_params() assert params.get("table_name") == "users" assert params.get("schema") is None @@ -97,32 +85,21 @@ def test_constructor_defaults(self, dummy_dialect): assert params.get("include_indexes") is True assert params.get("include_foreign_keys") is True - def test_fluent_api_override(self, dummy_dialect): - """Test fluent API can override constructor values.""" - expr = ( - TableInfoExpression(dialect=dummy_dialect, table_name="users", include_columns=False) - .include_columns(True) - .table_name("posts") - ) - params = expr.get_params() - assert params.get("table_name") == "posts" - assert params.get("include_columns") is True - class TestColumnInfoExpressionParams: - """Tests for ColumnInfoExpression construction parameters.""" + """Tests for ColumnInfoQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test ColumnInfoExpression accepts all parameters at construction.""" - expr = ColumnInfoExpression(dialect=dummy_dialect, table_name="users", schema="public", include_hidden=True) + """Test ColumnInfoQueryStatement accepts all parameters at construction.""" + expr = ColumnInfoQueryStatement(dialect=dummy_dialect, table_name="users", schema="public", include_hidden=True) params = expr.get_params() assert params.get("table_name") == "users" assert params.get("schema") == "public" assert params.get("include_hidden") is True def test_constructor_defaults(self, dummy_dialect): - """Test ColumnInfoExpression default values.""" - expr = ColumnInfoExpression(dialect=dummy_dialect, table_name="users") + """Test ColumnInfoQueryStatement default values.""" + expr = ColumnInfoQueryStatement(dialect=dummy_dialect, table_name="users") params = expr.get_params() assert params.get("table_name") == "users" assert params.get("schema") is None @@ -130,79 +107,75 @@ def test_constructor_defaults(self, dummy_dialect): class TestIndexInfoExpressionParams: - """Tests for IndexInfoExpression construction parameters.""" + """Tests for IndexInfoQueryStatement construction parameters.""" def test_constructor_with_schema(self, dummy_dialect): - """Test IndexInfoExpression accepts schema parameter.""" - expr = IndexInfoExpression(dialect=dummy_dialect, table_name="users", schema="main") + """Test IndexInfoQueryStatement accepts schema parameter.""" + expr = IndexInfoQueryStatement(dialect=dummy_dialect, table_name="users", schema="main") params = expr.get_params() assert params.get("table_name") == "users" assert params.get("schema") == "main" def test_constructor_defaults(self, dummy_dialect): - """Test IndexInfoExpression default values.""" - expr = IndexInfoExpression(dialect=dummy_dialect, table_name="users") + """Test IndexInfoQueryStatement default values.""" + expr = IndexInfoQueryStatement(dialect=dummy_dialect, table_name="users") params = expr.get_params() assert params.get("table_name") == "users" assert params.get("schema") is None class TestForeignKeyExpressionParams: - """Tests for ForeignKeyExpression construction parameters.""" + """Tests for ForeignKeyQueryStatement construction parameters.""" def test_constructor_with_schema(self, dummy_dialect): - """Test ForeignKeyExpression accepts schema parameter.""" - expr = ForeignKeyExpression(dialect=dummy_dialect, table_name="posts", schema="public") + """Test ForeignKeyQueryStatement accepts schema parameter.""" + expr = ForeignKeyQueryStatement(dialect=dummy_dialect, table_name="posts", schema="public") params = expr.get_params() assert params.get("table_name") == "posts" assert params.get("schema") == "public" def test_constructor_defaults(self, dummy_dialect): - """Test ForeignKeyExpression default values.""" - expr = ForeignKeyExpression(dialect=dummy_dialect, table_name="posts") + """Test ForeignKeyQueryStatement default values.""" + expr = ForeignKeyQueryStatement(dialect=dummy_dialect, table_name="posts") params = expr.get_params() assert params.get("table_name") == "posts" assert params.get("schema") is None class TestViewListExpressionParams: - """Tests for ViewListExpression construction parameters.""" + """Tests for ViewListQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test ViewListExpression accepts all parameters at construction.""" - expr = ViewListExpression(dialect=dummy_dialect, schema="main", include_system=True) + """Test ViewListQueryStatement accepts all parameters at construction.""" + expr = ViewListQueryStatement(dialect=dummy_dialect, schema="main", include_system=True) params = expr.get_params() assert params.get("schema") == "main" assert params.get("include_system") is True def test_constructor_defaults(self, dummy_dialect): - """Test ViewListExpression default values.""" - expr = ViewListExpression(dialect=dummy_dialect) + """Test ViewListQueryStatement default values.""" + expr = ViewListQueryStatement(dialect=dummy_dialect) params = expr.get_params() assert params.get("schema") is None assert params.get("include_system") is False - def test_fluent_api_override(self, dummy_dialect): - """Test fluent API can override constructor values.""" - expr = ViewListExpression(dialect=dummy_dialect, include_system=False).include_system(True) - params = expr.get_params() - assert params.get("include_system") is True - class TestViewInfoExpressionParams: - """Tests for ViewInfoExpression construction parameters.""" + """Tests for ViewInfoQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test ViewInfoExpression accepts all parameters at construction.""" - expr = ViewInfoExpression(dialect=dummy_dialect, view_name="user_view", schema="public", include_columns=False) + """Test ViewInfoQueryStatement accepts all parameters at construction.""" + expr = ViewInfoQueryStatement( + dialect=dummy_dialect, view_name="user_view", schema="public", include_columns=False + ) params = expr.get_params() assert params.get("view_name") == "user_view" assert params.get("schema") == "public" assert params.get("include_columns") is False def test_constructor_defaults(self, dummy_dialect): - """Test ViewInfoExpression default values.""" - expr = ViewInfoExpression(dialect=dummy_dialect, view_name="user_view") + """Test ViewInfoQueryStatement default values.""" + expr = ViewInfoQueryStatement(dialect=dummy_dialect, view_name="user_view") params = expr.get_params() assert params.get("view_name") == "user_view" assert params.get("schema") is None @@ -210,35 +183,29 @@ def test_constructor_defaults(self, dummy_dialect): class TestTriggerListExpressionParams: - """Tests for TriggerListExpression construction parameters.""" + """Tests for TriggerListQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test TriggerListExpression accepts all parameters at construction.""" - expr = TriggerListExpression(dialect=dummy_dialect, schema="main", table_name="users") + """Test TriggerListQueryStatement accepts all parameters at construction.""" + expr = TriggerListQueryStatement(dialect=dummy_dialect, schema="main", table_name="users") params = expr.get_params() assert params.get("schema") == "main" assert params.get("table_name") == "users" def test_constructor_defaults(self, dummy_dialect): - """Test TriggerListExpression default values.""" - expr = TriggerListExpression(dialect=dummy_dialect) + """Test TriggerListQueryStatement default values.""" + expr = TriggerListQueryStatement(dialect=dummy_dialect) params = expr.get_params() assert params.get("schema") is None assert params.get("table_name") is None - def test_fluent_api_for_table(self, dummy_dialect): - """Test for_table fluent API.""" - expr = TriggerListExpression(dialect=dummy_dialect, table_name="users").for_table("posts") - params = expr.get_params() - assert params.get("table_name") == "posts" - class TestTriggerInfoExpressionParams: - """Tests for TriggerInfoExpression construction parameters.""" + """Tests for TriggerInfoQueryStatement construction parameters.""" def test_constructor_with_all_params(self, dummy_dialect): - """Test TriggerInfoExpression accepts all parameters at construction.""" - expr = TriggerInfoExpression( + """Test TriggerInfoQueryStatement accepts all parameters at construction.""" + expr = TriggerInfoQueryStatement( dialect=dummy_dialect, trigger_name="my_trigger", schema="main", table_name="users" ) params = expr.get_params() @@ -247,8 +214,8 @@ def test_constructor_with_all_params(self, dummy_dialect): assert params.get("table_name") == "users" def test_constructor_defaults(self, dummy_dialect): - """Test TriggerInfoExpression default values.""" - expr = TriggerInfoExpression(dialect=dummy_dialect, trigger_name="my_trigger") + """Test TriggerInfoQueryStatement default values.""" + expr = TriggerInfoQueryStatement(dialect=dummy_dialect, trigger_name="my_trigger") params = expr.get_params() assert params.get("trigger_name") == "my_trigger" assert params.get("schema") is None diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_json_probe_honesty.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_json_probe_honesty.py index e7cfc5a19..c84110587 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_json_probe_honesty.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_json_probe_honesty.py @@ -20,7 +20,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import Column -from rhosocial.activerecord.backend.expression.advanced_functions import JSONDocumentExpression +from rhosocial.activerecord.backend.expression.advanced_functions import JSONDocumentValue def _sqlite(): @@ -50,7 +50,7 @@ def no_json_dialect(dialect): def _expr(dialect, operation="->>", mode="function"): - return JSONDocumentExpression(dialect, Column(dialect, "j", table="t"), "$.a", operation, mode=mode) + return JSONDocumentValue(dialect, Column(dialect, "j", table="t"), "$.a", operation, mode=mode) # --------------------------------------------------------------------------- diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_literal_construction.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_literal_construction.py new file mode 100644 index 000000000..5b47e276d --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_literal_construction.py @@ -0,0 +1,135 @@ +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_literal_construction.py +"""Tests for the three-level inline literal construction (spec §4.4). + +Levels: +1. Dialect base class built-ins: NULL / boolean / numeric / string / datetime +2. Backend overrides: bytes / UUID / Interval — the base does NOT guess them + and leaves the failure explicit +3. The literal-construction protocol: a value type renders its own complete + inline literal through the dialect's public escape helpers + +Dispatch order is built-in whitelist → protocol → explicit failure, and there +is no ``str()`` fallback. +""" + +import datetime as dt +from decimal import Decimal + +import pytest + +from rhosocial.activerecord.backend.expression import Literal + + +def _dummy_dialect(): + from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + return DummyDialect() + + +class TestLevel1Builtins: + def test_null(self): + assert _dummy_dialect().format_literal(None) == "NULL" + + def test_boolean_renders_before_numeric(self): + """bool is an int subclass: the keyword dispatch must run first.""" + assert _dummy_dialect().format_literal(True) == "TRUE" + assert _dummy_dialect().format_literal(False) == "FALSE" + + def test_integers_and_decimal(self): + d = _dummy_dialect() + assert d.format_literal(42) == "42" + assert d.format_literal(Decimal("1.50")) == "1.50" + + def test_float(self): + assert _dummy_dialect().format_literal(1.5) == "1.5" + + def test_nan_is_refused(self): + with pytest.raises(ValueError, match="NaN"): + _dummy_dialect().format_literal(float("nan")) + + def test_infinity_is_refused(self): + """float('inf') stringifies as 'inf' and would degrade to an identifier.""" + with pytest.raises(ValueError, match="Inf"): + _dummy_dialect().format_literal(float("inf")) + with pytest.raises(ValueError, match="Inf"): + _dummy_dialect().format_literal(float("-inf")) + + def test_non_finite_decimal_is_refused(self): + with pytest.raises(ValueError, match="non-finite"): + _dummy_dialect().format_literal(Decimal("NaN")) + + def test_string_is_quoted_and_escaped(self): + assert _dummy_dialect().format_literal("it's") == "'it''s'" + + def test_datetime_and_date_are_iso_strings(self): + d = _dummy_dialect() + assert d.format_literal(dt.datetime(2026, 1, 2, 3, 4, 5)) == "'2026-01-02 03:04:05'" + assert d.format_literal(dt.date(2026, 1, 2)) == "'2026-01-02'" + + +class TestLevel2BackendDuty: + def test_bytes_are_not_guessed_by_the_base(self): + """The base class leaves backend-specific forms to the backend.""" + with pytest.raises(TypeError, match="no inline SQL literal form"): + _dummy_dialect().format_literal(b"\x00\x01") + + +class _Point: + """A user-defined value type implementing the construction protocol.""" + + def __init__(self, x: float, y: float): + self.x, self.y = x, y + + def __sql_literal__(self, dialect) -> str: + inner = f"POINT({self.x} {self.y})" + return f"'{dialect.escape_sql_string(inner)}'" + + +class _Broken: + def __sql_literal__(self, dialect) -> int: + return 7 + + +class _Empty: + def __sql_literal__(self, dialect) -> str: + return "" + + +class TestLevel3Protocol: + def test_protocol_value_renders_its_own_text(self): + assert _dummy_dialect().format_literal(_Point(1.0, 2.0)) == "'POINT(1.0 2.0)'" + + def test_protocol_string_components_use_the_public_escape_helper(self): + escaped = _dummy_dialect().escape_sql_string("it's") + assert escaped == "it''s" + point = _Point(1.0, 2.0) + text = point.__sql_literal__(_dummy_dialect()) + assert text.startswith("'") and text.endswith("'") + + def test_protocol_is_dispatched_only_after_the_whitelist(self): + """A str subclass hits the built-in whitelist first.""" + class _MyStr(str): + def __sql_literal__(self, dialect): # pragma: no cover + return "SHOULD_NOT_RENDER" + + assert _dummy_dialect().format_literal(_MyStr("plain")) == "'plain'" + + def test_protocol_returning_a_non_string_is_refused(self): + with pytest.raises(TypeError, match="literal-construction"): + _dummy_dialect().format_literal(_Broken()) + + def test_protocol_returning_an_empty_string_is_refused(self): + with pytest.raises(TypeError, match="literal-construction"): + _dummy_dialect().format_literal(_Empty()) + + def test_no_str_fallback_for_plain_objects(self): + class _Plain: + pass + + with pytest.raises(TypeError, match="no inline SQL literal form"): + _dummy_dialect().format_literal(_Plain()) + + +class TestProtocolThroughInlineRendering: + def test_ddl_literal_renders_a_protocol_value_inline(self): + lit = Literal(_dummy_dialect(), _Point(1.0, 2.0), inline=True) + assert lit.to_sql() == ("'POINT(1.0 2.0)'", ()) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_logical_predicate_precedence.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_logical_predicate_precedence.py new file mode 100644 index 000000000..1e7ccd143 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_logical_predicate_precedence.py @@ -0,0 +1,209 @@ +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_logical_predicate_precedence.py +"""Logical predicates must be grouped when an operand binds looser. + +SQL gives the boolean connectives a precedence: ``NOT`` binds tightest, then +``AND``, then ``OR``. A tree therefore carries *two* facts -- the operands and +the grouping -- and a flat rendering can only keep the second when the +parentheses are written: + + a AND (b OR c) -> a AND b OR c WRONG: the OR escapes the AND + (a AND b) OR c -> a AND b OR c right: AND already binds tighter + +``format_logical_predicate`` joins the operands with the bare operator, so every +nesting renders flat. That is harmless while the nesting follows the +precedence (an AND under an OR) and silently wrong the moment it does not (an +OR under an AND) -- the query still parses, the server still answers, and the +answer is a different question's answer. + +The realistic shape is a default filter OR-ed against the caller's condition: +"everything except the system catalogue, or anything the caller named". Written +as ``X AND (relation IS NULL OR relation NOT LIKE 'RDB$%')`` it means one +thing; rendered flat it means ``(X AND relation IS NULL) OR ...``, which +returns every non-system row regardless of ``X``. + +This module states the rule at the layer that writes the SQL, using +``DummyDialect`` because the rule is the shared dialect base's, not a +backend's. The shapes it asserts are the two the precedence decides. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + IsNullPredicate, + LikePredicate, + Literal, + LogicalPredicate, +) +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + +def eq(dialect: DummyDialect, name: str, value) -> ComparisonPredicate: + """``"name" = ?`` -- the leaf every shape below is built from.""" + return ComparisonPredicate(dialect, "=", Column(dialect, name), Literal(dialect, value)) + + +class TestLogicalPredicatePrecedence: + """Which nestings need parentheses, and what they must render to.""" + + def test_or_under_and_is_parenthesised(self, dummy_dialect: DummyDialect): + """``a AND (b OR c)``: the OR must not escape the AND. + + Without the parentheses the rendered text means ``(a AND b) OR c``, + which is a different predicate: it accepts a row where ``a`` is false + but ``c`` is true. + """ + inner = LogicalPredicate( + dummy_dialect, + "OR", + eq(dummy_dialect, "b", 2), + eq(dummy_dialect, "c", 3), + ) + pred = LogicalPredicate(dummy_dialect, "AND", eq(dummy_dialect, "a", 1), inner) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND ("b" = ? OR "c" = ?)' + assert params == (1, 2, 3) + + def test_and_under_or_is_left_flat(self, dummy_dialect: DummyDialect): + """``(a AND b) OR c``: no parentheses are needed, and none are invented. + + ``AND`` binds tighter than ``OR``, so this shape already means what the + tree says. Writing brackets here would be harmless but would also make + the rule "always bracket a nested group", which is not the rule. + """ + inner = LogicalPredicate( + dummy_dialect, + "AND", + eq(dummy_dialect, "a", 1), + eq(dummy_dialect, "b", 2), + ) + pred = LogicalPredicate(dummy_dialect, "OR", inner, eq(dummy_dialect, "c", 3)) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND "b" = ? OR "c" = ?' + assert params == (1, 2, 3) + + def test_or_chain_under_and_is_one_group(self, dummy_dialect: DummyDialect): + """A chain of ORs under an AND is one group, not one bracket per operand.""" + chain = LogicalPredicate( + dummy_dialect, + "OR", + eq(dummy_dialect, "b", 2), + eq(dummy_dialect, "c", 3), + eq(dummy_dialect, "d", 4), + ) + pred = LogicalPredicate(dummy_dialect, "AND", eq(dummy_dialect, "a", 1), chain) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND ("b" = ? OR "c" = ? OR "d" = ?)' + assert params == (1, 2, 3, 4) + + def test_or_between_two_and_operands(self, dummy_dialect: DummyDialect): + """``a AND (b OR c) AND d``: only the OR group is bracketed.""" + left = eq(dummy_dialect, "a", 1) + middle = LogicalPredicate( + dummy_dialect, + "OR", + eq(dummy_dialect, "b", 2), + eq(dummy_dialect, "c", 3), + ) + right = eq(dummy_dialect, "d", 4) + pred = LogicalPredicate(dummy_dialect, "AND", left, middle, right) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND ("b" = ? OR "c" = ?) AND "d" = ?' + assert params == (1, 2, 3, 4) + + def test_and_inside_or_group_stays_flat(self, dummy_dialect: DummyDialect): + """``a AND (b OR c AND d)``: the inner AND needs no brackets of its own.""" + inner_or = LogicalPredicate( + dummy_dialect, + "OR", + eq(dummy_dialect, "b", 2), + LogicalPredicate( + dummy_dialect, + "AND", + eq(dummy_dialect, "c", 3), + eq(dummy_dialect, "d", 4), + ), + ) + pred = LogicalPredicate(dummy_dialect, "AND", eq(dummy_dialect, "a", 1), inner_or) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND ("b" = ? OR "c" = ? AND "d" = ?)' + assert params == (1, 2, 3, 4) + + def test_not_operand_keeps_its_own_group(self, dummy_dialect: DummyDialect): + """``NOT`` already brackets its operand; nesting it under AND changes nothing.""" + negated = LogicalPredicate(dummy_dialect, "NOT", eq(dummy_dialect, "b", 2)) + pred = LogicalPredicate(dummy_dialect, "AND", eq(dummy_dialect, "a", 1), negated) + + sql, params = pred.to_sql() + + assert sql == '"a" = ? AND NOT ("b" = ?)' + assert params == (1, 2) + + def test_default_filter_shape_from_introspection(self, dummy_dialect: DummyDialect): + """The shape that exposed this: a caller filter AND a system-exclusion OR. + + ``object = ? AND (relation IS NULL OR relation NOT LIKE 'RDB$%')``. + Flattened, the OR swallows the object filter, so the row set stops + depending on the object at all -- the answer is not merely unsorted, it + is a different question's answer. + """ + object_filter = eq(dummy_dialect, "object_name", "PROBE_ORDERS") + system_exclusion = LogicalPredicate( + dummy_dialect, + "OR", + IsNullPredicate(dummy_dialect, Column(dummy_dialect, "relation_name")), + LikePredicate( + dummy_dialect, + "NOT LIKE", + Column(dummy_dialect, "relation_name"), + Literal(dummy_dialect, "RDB$%"), + ), + ) + pred = LogicalPredicate(dummy_dialect, "AND", object_filter, system_exclusion) + + sql, params = pred.to_sql() + + assert sql == ( + '"object_name" = ? AND ' + '("relation_name" IS NULL OR "relation_name" NOT LIKE ?)' + ) + assert params == ("PROBE_ORDERS", "RDB$%") + + @pytest.mark.parametrize( + "op", + ["AND", "OR"], + ) + def test_every_connective_keeps_parameter_order(self, dummy_dialect: DummyDialect, op): + """Grouping must not reorder the parameter tuple it travels with. + + The tuple is positional against the text; a formatter that regrouped by + copying operands around could still render the right SQL and bind the + wrong values. + """ + pred = LogicalPredicate( + dummy_dialect, + op, + eq(dummy_dialect, "a", 1), + LogicalPredicate( + dummy_dialect, + "OR" if op == "AND" else "AND", + eq(dummy_dialect, "b", 2), + eq(dummy_dialect, "c", 3), + ), + eq(dummy_dialect, "d", 4), + ) + + _, params = pred.to_sql() + + assert params == (1, 2, 3, 4) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_new_data_types.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_new_data_types.py index da71d606b..1eba39fd6 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_new_data_types.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_new_data_types.py @@ -30,37 +30,39 @@ class TestEnumType: """ENUM — enumerated string values.""" def test_values_required(self): - with pytest.raises(ValueError): + # Required by the signature, keyword-only -- so the call fails at the + # call rather than depending on a check inside the body. + with pytest.raises(TypeError, match="values"): EnumType(None) def test_values_must_not_be_empty(self): with pytest.raises(ValueError): - EnumType(None, []) + EnumType(None, values=[]) def test_values_stored_as_tuple(self): - assert EnumType(None, ["a", "b"]).values == ("a", "b") + assert EnumType(None, values=["a", "b"]).values == ("a", "b") def test_render_with_values(self): dialect = _dummy_dialect() - assert EnumType(dialect, ["a", "b"]).to_sql() == ("ENUM('a','b')", ()) + assert EnumType(dialect, values=["a", "b"]).to_sql() == ("ENUM('a','b')", ()) def test_render_single_value(self): dialect = _dummy_dialect() - assert EnumType(dialect, ["x"]).to_sql() == ("ENUM('x')", ()) + assert EnumType(dialect, values=["x"]).to_sql() == ("ENUM('x')", ()) def test_equality_ignores_dialect(self): - assert EnumType(None, ["a", "b"]) == EnumType(_dummy_dialect(), ["a", "b"]) + assert EnumType(None, values=["a", "b"]) == EnumType(_dummy_dialect(), values=["a", "b"]) def test_equality_on_values(self): - assert EnumType(None, ["a", "b"]) != EnumType(None, ["a", "c"]) - assert EnumType(None, ["a"]) != EnumType(None, ["a", "b"]) + assert EnumType(None, values=["a", "b"]) != EnumType(None, values=["a", "c"]) + assert EnumType(None, values=["a"]) != EnumType(None, values=["a", "b"]) def test_hash_matches_equality(self): - assert hash(EnumType(None, ["a", "b"])) == hash(EnumType(None, ["a", "b"])) + assert hash(EnumType(None, values=["a", "b"])) == hash(EnumType(None, values=["a", "b"])) def test_constructor_rejects_dialect_options(self): with pytest.raises(TypeError): - EnumType(None, ["a"], dialect_options={"charset": "utf8"}) + EnumType(None, values=["a"], dialect_options={"charset": "utf8"}) # --------------------------------------------------------------------------- diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_operators.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_operators.py index 373966cc3..3d9e2dfbf 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_operators.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_operators.py @@ -4,7 +4,7 @@ SQLOperation, BinaryExpression, UnaryExpression, - RawSQLExpression, + RawSQL, RawSQLPredicate, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect @@ -58,7 +58,7 @@ def test_binary_expression_basic(self, dummy_dialect: DummyDialect): sql, params = bin_expr.to_sql() - # The exact format depends on the dialect's format_binary_operator method + # The exact format depends on the dialect's format_binary_expression method assert ">" in sql assert params == (18,) @@ -101,7 +101,7 @@ def test_unary_expression_before_position(self, dummy_dialect: DummyDialect): sql, params = unary_expr.to_sql() - # The exact format depends on the dialect's format_unary_operator method + # The exact format depends on the dialect's format_unary_expression method assert "NOT" in sql.upper() assert params == () @@ -113,7 +113,7 @@ def test_unary_expression_after_position(self, dummy_dialect: DummyDialect): sql, params = unary_expr.to_sql() - # The exact format depends on the dialect's format_unary_operator method + # The exact format depends on the dialect's format_unary_expression method assert "++" in sql assert params == () @@ -129,12 +129,12 @@ def test_unary_expression_with_literal(self, dummy_dialect: DummyDialect): assert params == (-5,) -class TestRawSQLExpression: - """Tests for RawSQLExpression representing raw SQL strings.""" +class TestRawSQL: + """Tests for RawSQL representing raw SQL strings.""" def test_raw_sql_expression_basic(self, dummy_dialect: DummyDialect): """Test basic raw SQL expression.""" - raw_sql = RawSQLExpression(dummy_dialect, "NOW()") + raw_sql = RawSQL(dummy_dialect, "NOW()") sql, params = raw_sql.to_sql() @@ -144,7 +144,7 @@ def test_raw_sql_expression_basic(self, dummy_dialect: DummyDialect): def test_raw_sql_expression_with_complex_sql(self, dummy_dialect: DummyDialect): """Test raw SQL expression with complex SQL string.""" complex_sql = "SELECT COUNT(*) FROM users WHERE created_at > NOW() - INTERVAL 30 DAY" - raw_sql = RawSQLExpression(dummy_dialect, complex_sql) + raw_sql = RawSQL(dummy_dialect, complex_sql) sql, params = raw_sql.to_sql() @@ -154,7 +154,7 @@ def test_raw_sql_expression_with_complex_sql(self, dummy_dialect: DummyDialect): def test_raw_sql_expression_with_placeholders(self, dummy_dialect: DummyDialect): """Test raw SQL expression with parameter placeholders.""" sql_with_placeholders = "SELECT * FROM table WHERE col = ? AND other_col = ?" - raw_sql = RawSQLExpression(dummy_dialect, sql_with_placeholders) + raw_sql = RawSQL(dummy_dialect, sql_with_placeholders) sql, params = raw_sql.to_sql() @@ -162,10 +162,10 @@ def test_raw_sql_expression_with_placeholders(self, dummy_dialect: DummyDialect) assert params == () -class TestBinaryArithmeticExpression: - """Tests for BinaryArithmeticExpression representing binary arithmetic operations.""" +class TestArithmeticOperation: + """Tests for ArithmeticOperation representing binary arithmetic operations.""" - def test_binary_arithmetic_expression_basic(self, dummy_dialect: DummyDialect): + def test_arithmetic_operation_basic(self, dummy_dialect: DummyDialect): """Test basic binary arithmetic expression.""" left = NumericColumn(dummy_dialect, "price") right = Literal(dummy_dialect, 1.1) @@ -178,7 +178,7 @@ def test_binary_arithmetic_expression_basic(self, dummy_dialect: DummyDialect): assert sql == '"price" * ?' assert params == (1.1,) - def test_binary_arithmetic_expression_with_functions(self, dummy_dialect: DummyDialect): + def test_arithmetic_operation_with_functions(self, dummy_dialect: DummyDialect): """Test binary arithmetic with function calls.""" left = FunctionCall(dummy_dialect, "SUM", Column(dummy_dialect, "amount")) right = Literal(dummy_dialect, 2) @@ -191,7 +191,7 @@ def test_binary_arithmetic_expression_with_functions(self, dummy_dialect: DummyD assert sql == 'SUM("amount") / ?' assert params == (2,) - def test_binary_arithmetic_different_operators(self, dummy_dialect: DummyDialect): + def test_arithmetic_operation_different_operators(self, dummy_dialect: DummyDialect): """Test binary arithmetic with different operators.""" left = NumericColumn(dummy_dialect, "quantity") right = NumericColumn(dummy_dialect, "unit_price") @@ -223,8 +223,8 @@ def test_binary_arithmetic_different_operators(self, dummy_dialect: DummyDialect assert params == () -class TestBinaryArithmeticExpressionPrecedence: - """Tests for BinaryArithmeticExpression operator precedence and parentheses handling.""" +class TestArithmeticOperationPrecedence: + """Tests for ArithmeticOperation operator precedence and parentheses handling.""" def test_multiplication_precedence_over_addition(self, dummy_dialect: DummyDialect): """Test that multiplication has higher precedence than addition.""" diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts.py index 4d7b8698a..ce8b9b0c1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts.py @@ -15,6 +15,8 @@ from rhosocial.activerecord.backend.expression.predicates import InPredicate, BetweenPredicate, IsNullPredicate from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Table @@ -53,12 +55,12 @@ def test_where_clause_complex_condition(self, dummy_dialect: DummyDialect): assert params == (18, "verified") def test_comparison_predicate_with_query_expression(self, dummy_dialect: DummyDialect): - """Test ComparisonPredicate when right operand is QueryExpression (covers the isinstance check).""" - from rhosocial.activerecord.backend.expression import QueryExpression + """Test ComparisonPredicate when right operand is QueryStatement (covers the isinstance check).""" + from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef # Create a subquery as the right operand - subquery = QueryExpression( + subquery = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "other_table")), @@ -359,18 +361,36 @@ def test_for_update_clause_with_columns(self, dummy_dialect: DummyDialect): assert params == () def test_for_update_clause_nowait(self, dummy_dialect: DummyDialect): - """Test FOR UPDATE clause with NOWAIT option.""" + """``NOWAIT`` renders where the dialect says it has it. + + The assertion used to accept ``FOR UPDATE`` as well, which is what let + the modifier be dropped without anything failing. It is gated by + ``supports_for_update_nowait`` now, so a dialect that has the probe + open renders the word and one that does not refuses outright (§6). + """ for_update = ForUpdateClause(dummy_dialect, nowait=True) sql, params = for_update.to_sql() - assert sql in [ - "FOR UPDATE NOWAIT", - "FOR UPDATE", - "SELECT ... FOR UPDATE NOWAIT", - ] # Depends on dialect implementation + assert sql == "FOR UPDATE NOWAIT" assert params == () + def test_for_update_clause_nowait_is_refused_where_unsupported(self): + """A dialect without it says so instead of rendering SQL it will reject. + + SQLite has no ``NOWAIT`` -- its locking clause is ``BEGIN IMMEDIATE`` -- + so the gate must refuse here rather than hand the database a statement + that fails at execution time with the caller's transaction already open. + """ + dialect = SQLiteDialect(version=(3, 53, 0)) + assert dialect.supports_for_update_nowait() is False + + with pytest.raises(UnsupportedFeatureError, match="NOWAIT"): + ForUpdateClause(dialect, nowait=True).to_sql() + + # And the refusal is about the modifier, not about locking itself. + assert ForUpdateClause(dialect).to_sql()[0] == "FOR UPDATE" + def test_for_update_clause_skip_locked(self, dummy_dialect: DummyDialect): """Test FOR UPDATE clause with SKIP LOCKED option.""" for_update = ForUpdateClause(dummy_dialect, skip_locked=True) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts_join.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts_join.py index b0be118e6..6e7b67374 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts_join.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_parts_join.py @@ -7,7 +7,8 @@ Column, Literal, ComparisonPredicate, - QueryExpression, + LogicalPredicate, + QueryStatement, FunctionCall, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -335,7 +336,7 @@ def test_complex_conditions_in_chained_joins(self, dummy_dialect: DummyDialect): left_table=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"), alias="u"), right_table=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "orders"), alias="o"), join_type="INNER JOIN", - condition=ComparisonPredicate( + condition=LogicalPredicate( dummy_dialect, "AND", ComparisonPredicate( @@ -350,7 +351,7 @@ def test_complex_conditions_in_chained_joins(self, dummy_dialect: DummyDialect): # Chain with products with another complex condition chained_join = base_join.left_join( right_table=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products"), alias="p"), - condition=ComparisonPredicate( + condition=LogicalPredicate( dummy_dialect, "AND", ComparisonPredicate( @@ -371,9 +372,9 @@ def test_complex_conditions_in_chained_joins(self, dummy_dialect: DummyDialect): assert params == (100, "active") def test_join_expression_with_query_expression_as_table(self, dummy_dialect: DummyDialect): - """Test JoinClause with QueryExpression as one of the tables.""" + """Test JoinClause with QueryStatement as one of the tables.""" # Create a subquery - subquery = QueryExpression( + subquery = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "inactive_users")), @@ -401,7 +402,7 @@ def test_join_expression_with_query_expression_as_table(self, dummy_dialect: Dum def test_chaining_with_subquery_joins(self, dummy_dialect: DummyDialect): """Test chaining joins where one of the 'tables' is a subquery.""" # Create a subquery - user_summary = QueryExpression( + user_summary = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_cte_edge_cases.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_cte_edge_cases.py index 7f0ab06fb..5d23cfb9d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_cte_edge_cases.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_cte_edge_cases.py @@ -1,15 +1,15 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_cte_edge_cases.py -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -class TestCTEExpressionEdgeCases: - """Tests for edge cases in CTEExpression.to_sql() method.""" +class TestCTEClauseEdgeCases: + """Tests for edge cases in CTEClause.to_sql() method.""" def test_cte_expression_with_string_query(self, dummy_dialect: DummyDialect): - """Test CTEExpression when query is a string (covers the else branch).""" + """Test CTEClause when query is a string (covers the else branch).""" # This tests the else branch: query is neither BaseExpression nor tuple - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="test_cte", query="SELECT id, name FROM users WHERE age > 18", # String instead of BaseExpression @@ -25,13 +25,13 @@ def test_cte_expression_with_string_query(self, dummy_dialect: DummyDialect): assert params == () def test_cte_expression_with_tuple_query(self, dummy_dialect: DummyDialect): - """Test CTEExpression when query is a tuple (covers elif branch).""" + """Test CTEClause when query is a tuple (covers elif branch).""" # This tests the elif branch: query is a tuple - # Note: Although we pass a list [123] as params, CTEExpression will convert it to a tuple (123,) + # Note: Although we pass a list [123] as params, CTEClause will convert it to a tuple (123,) # This is done for compatibility, but users should provide tuples directly for best practice query_tuple = ("SELECT id FROM table WHERE col = ?", [123]) - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="tuple_cte", query=query_tuple, # Tuple (sql, params) @@ -46,13 +46,13 @@ def test_cte_expression_with_tuple_query(self, dummy_dialect: DummyDialect): assert params == (123,) def test_cte_expression_with_base_expression_query(self, dummy_dialect: DummyDialect): - """Test CTEExpression when query is a BaseExpression (covers if branch).""" + """Test CTEClause when query is a BaseExpression (covers if branch).""" # This tests the if branch: query is a BaseExpression from rhosocial.activerecord.backend.expression import Subquery subquery = Subquery(dummy_dialect, "SELECT name FROM users WHERE status = ?", ("active",)) - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="subquery_cte", query=subquery, # BaseExpression diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_json_table.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_json_table.py index 0f2627c5a..d1097c9f2 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_json_table.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_json_table.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_json_table.py -from rhosocial.activerecord.backend.expression import Column, QueryExpression +from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.query_sources import JSONTableExpression, JSONTableColumn from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.types import BooleanType, DateType, DecimalType, FloatType, IntegerType, TextType, VarCharType @@ -80,7 +80,7 @@ def test_json_table_used_in_query(self, dummy_dialect: DummyDialect): dummy_dialect, json_column="json_content", path="$.articles[*]", columns=columns, alias="articles" ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id", table="articles"), Column(dummy_dialect, "title", table="articles")], from_=json_table, @@ -110,7 +110,7 @@ def test_json_table_with_table_reference(self, dummy_dialect: DummyDialect): ) # Use the JSON table directly in a query - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "field", table="key_val_pairs"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_lateral.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_lateral.py index f035b4221..e26586d37 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_lateral.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_lateral.py @@ -4,7 +4,7 @@ Literal, Subquery, FunctionCall, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.query_sources import ( @@ -87,7 +87,7 @@ def test_lateral_used_in_query_from_clause(self, dummy_dialect: DummyDialect): lateral_expr = LateralExpression(dummy_dialect, expression=lateral_query, alias="user_stats") # Create query using just the lateral expression as FROM - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "order_count", table="user_stats")], from_=lateral_expr, # Use lateral expression directly @@ -116,7 +116,7 @@ def test_lateral_with_values_expression(self, dummy_dialect: DummyDialect): def test_lateral_expression_complex_subquery(self, dummy_dialect: DummyDialect): """Test LATERAL with complex subquery involving functions.""" # Complex subquery that might reference outer query - complex_query = QueryExpression( + complex_query = QueryStatement( dummy_dialect, select=[ FunctionCall(dummy_dialect, "SUM", Column(dummy_dialect, "amount")), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_set_operation.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_set_operation.py index a3f0d867b..9ddad4699 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_set_operation.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_set_operation.py @@ -1,22 +1,29 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_set_operation.py -from rhosocial.activerecord.backend.expression import Column, Subquery, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Subquery, QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Table +def _query(dialect, table, *conditions): + return QueryStatement( + dialect, + select=[Column(dialect, "id"), Column(dialect, "name")], + from_=NamedRelationRef(dialect, Table(dialect, table)), + **({"where": conditions[0]} if conditions else {}), + ) + + class TestSetOperationExpression: - """Tests for SetOperationExpression with UNION, INTERSECT, EXCEPT operations.""" + """Tests for SetOperation with UNION, INTERSECT, EXCEPT operations.""" def test_union_operation_basic(self, dummy_dialect: DummyDialect): """Test basic UNION operation between two queries.""" left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="UNION", alias="combined_results" - ) + set_op = SetOperation(dummy_dialect, op="union", left=left_query, right=right_query) sql, params = set_op.to_sql() @@ -24,17 +31,15 @@ def test_union_operation_basic(self, dummy_dialect: DummyDialect): assert params == (18, "active") def test_union_all_operation(self, dummy_dialect: DummyDialect): - """Test UNION ALL operation.""" + """Test UNION ALL operation (the union_all word form).""" left_query = Subquery(dummy_dialect, "SELECT id FROM table1", ()) right_query = Subquery(dummy_dialect, "SELECT id FROM table2", ()) - set_op = SetOperationExpression( + set_op = SetOperation( dummy_dialect, + op="union_all", left=left_query, right=right_query, - operation="UNION", - alias="union_all_results", - all_=True, # This should add ALL to the operation ) sql, params = set_op.to_sql() @@ -47,9 +52,7 @@ def test_intersect_operation(self, dummy_dialect: DummyDialect): left_query = Subquery(dummy_dialect, "SELECT id FROM table1 WHERE col1 = ?", ("value1",)) right_query = Subquery(dummy_dialect, "SELECT id FROM table2 WHERE col2 = ?", ("value2",)) - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="INTERSECT", alias="intersection" - ) + set_op = SetOperation(dummy_dialect, op="intersect", left=left_query, right=right_query) sql, params = set_op.to_sql() @@ -61,9 +64,7 @@ def test_except_operation(self, dummy_dialect: DummyDialect): left_query = Subquery(dummy_dialect, "SELECT id FROM table1 WHERE active = ?", (True,)) right_query = Subquery(dummy_dialect, "SELECT id FROM table2 WHERE archived = ?", (True,)) - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="EXCEPT", alias="except_results" - ) + set_op = SetOperation(dummy_dialect, op="except", left=left_query, right=right_query) sql, params = set_op.to_sql() @@ -77,14 +78,10 @@ def test_nested_set_operations(self, dummy_dialect: DummyDialect): query3 = Subquery(dummy_dialect, "SELECT id FROM table3 WHERE col = ?", ("c",)) # First set operation - inner_op = SetOperationExpression( - dummy_dialect, left=query1, right=query2, operation="UNION", alias="inner_union" - ) + inner_op = SetOperation(dummy_dialect, op="union", left=query1, right=query2) # Outer set operation using the inner one - outer_op = SetOperationExpression( - dummy_dialect, left=inner_op, right=query3, operation="UNION", alias="outer_union" - ) + outer_op = SetOperation(dummy_dialect, op="union", left=inner_op, right=query3) sql, params = outer_op.to_sql() @@ -93,21 +90,10 @@ def test_nested_set_operations(self, dummy_dialect: DummyDialect): def test_set_operation_with_complex_queries(self, dummy_dialect: DummyDialect): """Test set operation with more complex query structures.""" - # Create complex left query - left_table = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) - left_query = QueryExpression( - dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=left_table - ) - - # Create complex right query - right_table = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "customers")) - right_query = QueryExpression( - dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=right_table - ) + left_query = _query(dummy_dialect, "users") + right_query = _query(dummy_dialect, "customers") - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="UNION", alias="complex_union" - ) + set_op = SetOperation(dummy_dialect, op="union", left=left_query, right=right_query) sql, params = set_op.to_sql() @@ -121,9 +107,7 @@ def test_set_operation_parameters_handling(self, dummy_dialect: DummyDialect): left_query = Subquery(dummy_dialect, "SELECT id FROM users WHERE age > ? AND status = ?", (18, "active")) right_query = Subquery(dummy_dialect, "SELECT id FROM customers WHERE region = ?", ("west",)) - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="UNION", alias="param_union" - ) + set_op = SetOperation(dummy_dialect, op="union", left=left_query, right=right_query) sql, params = set_op.to_sql() @@ -131,192 +115,48 @@ def test_set_operation_parameters_handling(self, dummy_dialect: DummyDialect): assert params == (18, "active", "west") assert len(params) == 3 - def test_set_operation_with_alias_formatting(self, dummy_dialect: DummyDialect): - """Test that alias is properly formatted in the SQL.""" - left_query = Subquery(dummy_dialect, "SELECT col FROM table1", ()) - right_query = Subquery(dummy_dialect, "SELECT col FROM table2", ()) - - set_op = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="UNION", alias="custom_alias" - ) - - sql, params = set_op.to_sql() - - # Verify alias is included in the formatted result - assert "custom_alias" in sql - assert params == () - - def test_set_operation_case_insensitive_operation_names(self, dummy_dialect: DummyDialect): - """Test that different case operation names work.""" + def test_set_operation_word_forms_are_case_insensitive(self, dummy_dialect: DummyDialect): + """The word form is accepted case-insensitively and stored lowercase.""" left_query = Subquery(dummy_dialect, "SELECT id FROM t1", ()) right_query = Subquery(dummy_dialect, "SELECT id FROM t2", ()) - # Test with uppercase (normal case) - set_op_upper = SetOperationExpression( - dummy_dialect, left=left_query, right=right_query, operation="UNION", alias="test" - ) - - sql_upper, _ = set_op_upper.to_sql() - assert "UNION" in sql_upper - - def test_set_operation_all_variants(self, dummy_dialect: DummyDialect): - """Test all operation variants with and without ALL.""" - query1 = Subquery(dummy_dialect, "SELECT id FROM t1 WHERE a = ?", (1,)) - query2 = Subquery(dummy_dialect, "SELECT id FROM t2 WHERE b = ?", (2,)) - - operations = ["UNION", "INTERSECT", "EXCEPT"] - - for op in operations: - # Test without ALL - set_op = SetOperationExpression( - dummy_dialect, left=query1, right=query2, operation=op, alias=f"test_{op.lower()}" - ) - - sql, params = set_op.to_sql() - assert op in sql.upper() - assert params == (1, 2) - - # Test with ALL - set_op_all = SetOperationExpression( - dummy_dialect, left=query1, right=query2, operation=op, alias=f"test_{op.lower()}_all", all_=True - ) - - sql_all, params_all = set_op_all.to_sql() - assert f"{op} ALL" in sql_all.upper() - assert params_all == (1, 2) - - def test_set_operation_without_alias(self, dummy_dialect: DummyDialect): - """Test set operation expression without alias.""" - left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression without alias - set_op = SetOperationExpression(dummy_dialect, left=left_query, right=right_query, operation="UNION") - - sql, params = set_op.to_sql() - - # Verify that no alias is present in the SQL - assert "AS" not in sql.upper() or "AS " not in sql # Check that alias is not in SQL - # Verify UNION is still present + set_op = SetOperation(dummy_dialect, op="UNION", left=left_query, right=right_query) + assert set_op.op == "union" + sql, _ = set_op.to_sql() assert "UNION" in sql.upper() - # Verify parameters are still handled - assert params == (18, "active") - - def test_set_operation_with_order_by(self, dummy_dialect: DummyDialect): - """Test set operation with ORDER BY clause.""" - from rhosocial.activerecord.backend.expression.query_parts import OrderByClause - - left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with ORDER BY clause - set_op = SetOperationExpression( - dummy_dialect, - left=left_query, - right=right_query, - operation="UNION", - order_by_clause=OrderByClause(dummy_dialect, [Column(dummy_dialect, "id")]), - ) - sql, params = set_op.to_sql() - - # Verify ORDER BY is present in the SQL - assert "ORDER BY" in sql.upper() - assert '"id"' in sql or "id" in sql - # Verify parameters are still handled - assert params == (18, "active") - - def test_set_operation_with_limit_offset(self, dummy_dialect: DummyDialect): - """Test set operation with LIMIT and OFFSET clauses.""" - from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause - - left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with LIMIT/OFFSET clause - set_op = SetOperationExpression( - dummy_dialect, - left=left_query, - right=right_query, - operation="UNION", - limit_offset_clause=LimitOffsetClause(dummy_dialect, limit=10, offset=5), - ) - - sql, params = set_op.to_sql() - - # Verify LIMIT and OFFSET are present in the SQL - assert "LIMIT" in sql.upper() - assert "OFFSET" in sql.upper() - # Verify parameters are still handled (including limit and offset values) - assert params == (18, "active", 10, 5) - - def test_set_operation_with_for_update(self, dummy_dialect: DummyDialect): - """Test set operation with FOR UPDATE clause.""" - from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause - - left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with FOR UPDATE clause - set_op = SetOperationExpression( - dummy_dialect, - left=left_query, - right=right_query, - operation="UNION", - for_update_clause=ForUpdateClause(dummy_dialect), - ) - - sql, params = set_op.to_sql() - - # Verify FOR UPDATE is present in the SQL - assert "FOR UPDATE" in sql.upper() or "FOR SHARE" in sql.upper() - # Verify parameters are still handled - assert params == (18, "active") - - def test_set_operation_with_multiple_clauses(self, dummy_dialect: DummyDialect): - """Test set operation with multiple clauses (ORDER BY, LIMIT, FOR UPDATE).""" - from rhosocial.activerecord.backend.expression.query_parts import ( - OrderByClause, - LimitOffsetClause, - ForUpdateClause, - ) - - left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) + def test_sql_symbol_spellings_are_refused(self, dummy_dialect: DummyDialect): + """The node carries the word form only; multi-word symbols are refused.""" + left_query = Subquery(dummy_dialect, "SELECT id FROM t1", ()) + right_query = Subquery(dummy_dialect, "SELECT id FROM t2", ()) - # Create SetOperationExpression with multiple clauses - set_op = SetOperationExpression( - dummy_dialect, - left=left_query, - right=right_query, - operation="UNION", - order_by_clause=OrderByClause(dummy_dialect, [Column(dummy_dialect, "name")]), - limit_offset_clause=LimitOffsetClause(dummy_dialect, limit=5), - for_update_clause=ForUpdateClause(dummy_dialect), - ) + for bad in ("UNION ALL", "union all", "OUTER UNION", "x"): + with __import__("pytest").raises(ValueError, match="word forms"): + SetOperation(dummy_dialect, op=bad, left=left_query, right=right_query) - sql, params = set_op.to_sql() + def test_get_params_carries_the_wire_shape(self, dummy_dialect: DummyDialect): + """The node's params are exactly op + left + right.""" + left_query = Subquery(dummy_dialect, "SELECT id FROM t1", ()) + right_query = Subquery(dummy_dialect, "SELECT id FROM t2", ()) - # Verify all clauses are present in the SQL - assert "UNION" in sql.upper() - assert "ORDER BY" in sql.upper() - assert "LIMIT" in sql.upper() - # Verify parameters are still handled (including limit value) - assert params == (18, "active", 5) + set_op = SetOperation(dummy_dialect, op="union_all", left=left_query, right=right_query) + params = set_op.get_params() + assert set(params) == {"op", "left", "right"} + assert params["op"] == "union_all" def test_set_operation_with_explain_basic(self, dummy_dialect: DummyDialect): """Test set operation with EXPLAIN clause.""" - from rhosocial.activerecord.backend.expression.statements import ExplainExpression, ExplainOptions + from rhosocial.activerecord.backend.expression.statements import ExplainStatement, ExplainOptions left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - # Create SetOperationExpression first - set_op = SetOperationExpression(dummy_dialect, left=left_query, right=right_query, operation="UNION") + # Create SetOperation first + set_op = SetOperation(dummy_dialect, op="union", left=left_query, right=right_query) # Wrap the set operation in an EXPLAIN expression explain_options = ExplainOptions() - explain_expr = ExplainExpression(dummy_dialect, set_op, explain_options) + explain_expr = ExplainStatement(dummy_dialect, set_op, explain_options) sql, params = explain_expr.to_sql() @@ -329,17 +169,17 @@ def test_set_operation_with_explain_basic(self, dummy_dialect: DummyDialect): def test_set_operation_with_explain_analyze(self, dummy_dialect: DummyDialect): """Test set operation with EXPLAIN ANALYZE clause.""" - from rhosocial.activerecord.backend.expression.statements import ExplainExpression, ExplainOptions, ExplainType + from rhosocial.activerecord.backend.expression.statements import ExplainStatement, ExplainOptions, ExplainType left_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(dummy_dialect, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - # Create SetOperationExpression first - set_op = SetOperationExpression(dummy_dialect, left=left_query, right=right_query, operation="UNION") + # Create SetOperation first + set_op = SetOperation(dummy_dialect, op="union", left=left_query, right=right_query) # Wrap the set operation in an EXPLAIN ANALYZE expression explain_options = ExplainOptions(type=ExplainType.ANALYZE) - explain_expr = ExplainExpression(dummy_dialect, set_op, explain_options) + explain_expr = ExplainStatement(dummy_dialect, set_op, explain_options) sql, params = explain_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_table_function.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_table_function.py index 43dd01386..f7c70d322 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_table_function.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_table_function.py @@ -76,13 +76,13 @@ def test_table_function_with_subquery_argument(self, dummy_dialect: DummyDialect def test_table_function_in_query_context(self, dummy_dialect: DummyDialect): """Test table function used in a query FROM clause.""" - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement arg = Literal(dummy_dialect, ["item1", "item2", "item3"]) table_func = TableFunctionExpression(dummy_dialect, "UNNEST", arg, alias="items", column_names=["item_value"]) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "item_value", table="items")], from_=table_func ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_values.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_values.py index 122a55af4..c2128efb7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_values.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_values.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_values.py -from rhosocial.activerecord.backend.expression import Column, QueryExpression +from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.query_sources import ValuesExpression from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect @@ -80,7 +80,7 @@ def test_values_expression_used_in_query_from_clause(self, dummy_dialect: DummyD ) # Create a query using the VALUES expression as FROM - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name", table="products"), Column(dummy_dialect, "price", table="products")], from_=values_expr, # Using VALUES as data source diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_with.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_with.py index fcc4d50fc..1b8fc6b47 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_with.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_query_sources_with.py @@ -4,21 +4,21 @@ Literal, Subquery, FunctionCall, - QueryExpression, -) + QueryStatement, + NumericColumn,) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression, WithQueryExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause, WithStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Table -class TestCTEAndWithQueryExpressions: - """Tests for CTEExpression and WithQueryExpression with comprehensive parameter support.""" +class TestCTEAndWithStatements: + """Tests for CTEClause and WithStatement with comprehensive parameter support.""" def test_basic_cte_expression(self, dummy_dialect: DummyDialect): """Test basic CTE expression generation.""" query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (25,)) - cte = CTEExpression(dummy_dialect, name="adult_users", query=query) + cte = CTEClause(dummy_dialect, name="adult_users", query=query) sql, params = cte.to_sql() @@ -29,7 +29,7 @@ def test_basic_cte_expression(self, dummy_dialect: DummyDialect): def test_cte_expression_with_columns(self, dummy_dialect: DummyDialect): """Test CTE expression with explicit column names.""" query = Subquery(dummy_dialect, "SELECT id, name FROM users", ()) - cte = CTEExpression(dummy_dialect, name="user_data", query=query, columns=["user_id", "user_name"]) + cte = CTEClause(dummy_dialect, name="user_data", query=query, columns=["user_id", "user_name"]) sql, params = cte.to_sql() @@ -45,14 +45,14 @@ def test_recursive_cte_expression(self, dummy_dialect: DummyDialect): "SELECT id, parent_id FROM tree WHERE parent_id IS NULL UNION ALL SELECT t.id, t.parent_id FROM tree t JOIN tree_cte c ON t.parent_id = c.id", # noqa: E501 (), ) - cte = CTEExpression(dummy_dialect, name="tree_cte", query=query) + cte = CTEClause(dummy_dialect, name="tree_cte", query=query) - # Create a WithQueryExpression with recursive=True to test recursive functionality - main_query = QueryExpression( + # Create a WithStatement with recursive=True to test recursive functionality + main_query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "tree_cte")) ) - with_query = WithQueryExpression(dummy_dialect, ctes=[cte], main_query=main_query, recursive=True) + with_query = WithStatement(dummy_dialect, ctes=[cte], query=main_query, recursive=True) sql, params = with_query.to_sql() @@ -66,14 +66,14 @@ def test_cte_expression_with_materialized(self, dummy_dialect: DummyDialect): query = Subquery(dummy_dialect, "SELECT * FROM large_table WHERE condition = ?", (True,)) # Test MATERIALIZED - cte_materialized = CTEExpression(dummy_dialect, name="cached_data", query=query, materialized=True) + cte_materialized = CTEClause(dummy_dialect, name="cached_data", query=query, materialized=True) sql_mat, params_mat = cte_materialized.to_sql() assert "MATERIALIZED" in sql_mat.upper() assert params_mat == (True,) # Test NOT MATERIALIZED - cte_not_materialized = CTEExpression(dummy_dialect, name="non_cached_data", query=query, not_materialized=True) + cte_not_materialized = CTEClause(dummy_dialect, name="non_cached_data", query=query, not_materialized=True) sql_not_mat, params_not_mat = cte_not_materialized.to_sql() assert "NOT MATERIALIZED" in sql_not_mat.upper() @@ -82,7 +82,7 @@ def test_cte_expression_with_materialized(self, dummy_dialect: DummyDialect): def test_cte_expression_has_no_dialect_options(self, dummy_dialect: DummyDialect): """The generic CTE expression carries no dialect_options bag.""" query = Subquery(dummy_dialect, "SELECT id FROM simple_table", ()) - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="dialect_specific_cte", query=query, @@ -97,13 +97,13 @@ def test_cte_expression_has_no_dialect_options(self, dummy_dialect: DummyDialect def test_with_query_expression_basic(self, dummy_dialect: DummyDialect): """Test basic WithQuery expression with single CTE.""" cte_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE status = ?", ("active",)) - cte = CTEExpression(dummy_dialect, name="active_users", query=cte_query) + cte = CTEClause(dummy_dialect, name="active_users", query=cte_query) - main_query = QueryExpression( + main_query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "active_users")) ) - with_query = WithQueryExpression(dummy_dialect, ctes=[cte], main_query=main_query) + with_query = WithStatement(dummy_dialect, ctes=[cte], query=main_query) sql, params = with_query.to_sql() @@ -114,12 +114,12 @@ def test_with_query_expression_basic(self, dummy_dialect: DummyDialect): def test_with_query_expression_multiple_ctes(self, dummy_dialect: DummyDialect): """Test WithQuery expression with multiple CTEs.""" cte1_query = Subquery(dummy_dialect, "SELECT id, name FROM users WHERE age > ?", (18,)) - cte1 = CTEExpression(dummy_dialect, name="adults", query=cte1_query) + cte1 = CTEClause(dummy_dialect, name="adults", query=cte1_query) cte2_query = Subquery(dummy_dialect, "SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id", ()) - cte2 = CTEExpression(dummy_dialect, name="user_orders", query=cte2_query) + cte2 = CTEClause(dummy_dialect, name="user_orders", query=cte2_query) - main_query = QueryExpression( + main_query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "name", table="adults"), @@ -128,7 +128,7 @@ def test_with_query_expression_multiple_ctes(self, dummy_dialect: DummyDialect): from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "adults")), ) - with_query = WithQueryExpression(dummy_dialect, ctes=[cte1, cte2], main_query=main_query) + with_query = WithStatement(dummy_dialect, ctes=[cte1, cte2], query=main_query) sql, params = with_query.to_sql() @@ -140,14 +140,14 @@ def test_with_query_expression_multiple_ctes(self, dummy_dialect: DummyDialect): def test_with_query_expression_has_no_dialect_options(self, dummy_dialect: DummyDialect): """The generic with-query expression carries no dialect_options bag.""" cte_query = Subquery(dummy_dialect, "SELECT id FROM simple_table", ()) - cte = CTEExpression(dummy_dialect, name="simple_cte", query=cte_query) + cte = CTEClause(dummy_dialect, name="simple_cte", query=cte_query) - main_query = QueryExpression( + main_query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "simple_cte")) ) - with_query = WithQueryExpression( - dummy_dialect, ctes=[cte], main_query=main_query + with_query = WithStatement( + dummy_dialect, ctes=[cte], query=main_query ) assert not hasattr(with_query, "dialect_options") @@ -162,7 +162,7 @@ def test_cte_with_complex_query(self, dummy_dialect: DummyDialect): # Create a complex query with various clauses from_clause = NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")) - complex_query = QueryExpression( + complex_query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "category"), @@ -183,7 +183,7 @@ def test_cte_with_complex_query(self, dummy_dialect: DummyDialect): ) complex_query.group_by_having = group_by_having # Set directly since it's not in constructor - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="popular_categories", query=complex_query, columns=["category", "count", "avg_price"] ) @@ -197,10 +197,10 @@ def test_with_query_preserves_params_from_ctes_and_main_query(self, dummy_dialec """Test that WithQuery properly combines parameters from CTEs and main query.""" # CTE with parameters cte_query = Subquery(dummy_dialect, "SELECT id FROM users WHERE age > ?", (25,)) - cte = CTEExpression(dummy_dialect, name="adults", query=cte_query) + cte = CTEClause(dummy_dialect, name="adults", query=cte_query) # Main query with parameters - main_query = QueryExpression( + main_query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "adults")), @@ -209,10 +209,13 @@ def test_with_query_preserves_params_from_ctes_and_main_query(self, dummy_dialec from rhosocial.activerecord.backend.expression.query_parts import WhereClause - where_clause = WhereClause(dummy_dialect, condition=Column(dummy_dialect, "id") < Literal(dummy_dialect, 1000)) + where_clause = WhereClause( + dummy_dialect, + condition=NumericColumn(dummy_dialect, "id") < Literal(dummy_dialect, 1000), + ) main_query.where = where_clause # Set directly - with_query = WithQueryExpression(dummy_dialect, ctes=[cte], main_query=main_query) + with_query = WithStatement(dummy_dialect, ctes=[cte], query=main_query) sql, params = with_query.to_sql() @@ -223,9 +226,9 @@ def test_with_query_preserves_params_from_ctes_and_main_query(self, dummy_dialec def test_cte_expression_parameter_validation(self, dummy_dialect: DummyDialect): """Test CTE expression with various parameter combinations.""" - # Test with all parameters set (except recursive, which is now at WithQueryExpression level) + # Test with all parameters set (except recursive, which is now at WithStatement level) query = Subquery(dummy_dialect, "SELECT col1, col2 FROM table WHERE id = ?", (123,)) - cte = CTEExpression( + cte = CTEClause( dummy_dialect, name="test_cte", query=query, diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_returning_clause_to_sql.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_returning_clause_to_sql.py index d3703bce4..73f094259 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_returning_clause_to_sql.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_returning_clause_to_sql.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_returning_clause_to_sql.py -from rhosocial.activerecord.backend.expression import Column, Literal, RawSQLExpression, FunctionCall +from rhosocial.activerecord.backend.expression import Column, Literal, RawSQL, FunctionCall from rhosocial.activerecord.backend.expression.statements import ReturningClause from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect @@ -62,7 +62,7 @@ def test_returning_clause_with_raw_sql_expressions(self, dummy_dialect: DummyDia """Tests ReturningClause with raw SQL expressions.""" returning_clause = ReturningClause( dummy_dialect, - expressions=[RawSQLExpression(dummy_dialect, "CURRENT_TIMESTAMP"), Column(dummy_dialect, "id")], + expressions=[RawSQL(dummy_dialect, "CURRENT_TIMESTAMP"), Column(dummy_dialect, "id")], ) sql, params = returning_clause.to_sql() assert "RETURNING" in sql @@ -98,7 +98,7 @@ def test_returning_clause_with_mixed_expressions(self, dummy_dialect: DummyDiale Column(dummy_dialect, "id"), FunctionCall(dummy_dialect, "UPPER", Column(dummy_dialect, "name")), Literal(dummy_dialect, "status"), - RawSQLExpression(dummy_dialect, "1"), + RawSQL(dummy_dialect, "1"), ], ) sql, params = returning_clause.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statement_coverage_gaps.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statement_coverage_gaps.py index 9d23caf87..74e6f5ca7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statement_coverage_gaps.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statement_coverage_gaps.py @@ -2,12 +2,12 @@ """ Tests to cover missing lines in expression statements and transaction modules. Targets: - - dml.py:118 (MergeExpression.statement_type) - - dml.py:310 (DeleteExpression.statement_type) - - dml.py:439 (UpdateExpression.statement_type) - - dml.py:615 (InsertExpression.statement_type) - - dql.py:303 (QueryExpression.statement_type) - - explain.py:128 (ExplainExpression.statement_type) + - dml.py:118 (MergeStatement.statement_type) + - dml.py:310 (DeleteStatement.statement_type) + - dml.py:439 (UpdateStatement.statement_type) + - dml.py:615 (InsertStatement.statement_type) + - dql.py:303 (QueryStatement.statement_type) + - explain.py:128 (ExplainStatement.statement_type) - transaction.py:135-136,152 (begin_type setter and get_params) - ddl_alter.py:61,63 (MODIFY_COLUMN and CHANGE_COLUMN action types) """ @@ -17,20 +17,20 @@ from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements.dml import ( - InsertExpression, - UpdateExpression, - DeleteExpression, - MergeExpression, + InsertStatement, + UpdateStatement, + DeleteStatement, + MergeStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression -from rhosocial.activerecord.backend.expression.statements.explain import ExplainExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.expression.statements.explain import ExplainStatement from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterTableExpression, - ModifyColumn, - ChangeColumn, + AlterTableStatement, + ModifyColumnAction, + ChangeColumnAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ColumnDefinition -from rhosocial.activerecord.backend.expression.transaction import BeginTransactionExpression +from rhosocial.activerecord.backend.expression.transaction import BeginTransactionStatement from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.expression.objects import Table @@ -39,7 +39,7 @@ class TestDmlStatementTypes: """Cover statement_type properties in dml.py.""" def test_insert_statement_type(self, dummy_dialect): - expr = InsertExpression( + expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=[Literal(dummy_dialect, "Alice")], @@ -48,7 +48,7 @@ def test_insert_statement_type(self, dummy_dialect): assert expr.statement_type == StatementType.INSERT def test_update_statement_type(self, dummy_dialect): - expr = UpdateExpression( + expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "Bob")}, @@ -56,7 +56,7 @@ def test_update_statement_type(self, dummy_dialect): assert expr.statement_type == StatementType.UPDATE def test_delete_statement_type(self, dummy_dialect): - expr = DeleteExpression( + expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), ) @@ -65,7 +65,7 @@ def test_delete_statement_type(self, dummy_dialect): def test_merge_statement_type(self, dummy_dialect): col = Column(dummy_dialect, "id") condition = ComparisonPredicate(dummy_dialect, "=", col, Literal(dummy_dialect, 1)) - expr = MergeExpression( + expr = MergeStatement( dummy_dialect, target_table=Table(dummy_dialect, 'users'), source=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "new_users")), @@ -75,10 +75,10 @@ def test_merge_statement_type(self, dummy_dialect): class TestDqlStatementType: - """Cover dql.py:303 — QueryExpression.statement_type.""" + """Cover dql.py:303 — QueryStatement.statement_type.""" def test_query_statement_type(self, dummy_dialect): - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -87,15 +87,15 @@ def test_query_statement_type(self, dummy_dialect): class TestExplainStatementType: - """Cover explain.py:128 — ExplainExpression.statement_type.""" + """Cover explain.py:128 — ExplainStatement.statement_type.""" def test_explain_statement_type(self, dummy_dialect): - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - explain = ExplainExpression(dummy_dialect, query) + explain = ExplainStatement(dummy_dialect, query) assert explain.statement_type == StatementType.EXPLAIN @@ -103,18 +103,18 @@ class TestTransactionBeginType: """Cover transaction.py:135-136,152 — begin_type setter and get_params.""" def test_begin_type_setter(self, dummy_dialect): - expr = BeginTransactionExpression(dummy_dialect) - result = expr.begin_type("DEFERRED") + expr = BeginTransactionStatement(dummy_dialect) + result = expr.with_begin_type("DEFERRED") assert result is expr # Returns self for chaining def test_get_params_with_begin_type(self, dummy_dialect): - expr = BeginTransactionExpression(dummy_dialect) - expr.begin_type("IMMEDIATE") + expr = BeginTransactionStatement(dummy_dialect) + expr.with_begin_type("IMMEDIATE") params = expr.get_params() assert params["begin_type"] == "IMMEDIATE" def test_get_params_without_begin_type(self, dummy_dialect): - expr = BeginTransactionExpression(dummy_dialect) + expr = BeginTransactionStatement(dummy_dialect) params = expr.get_params() assert params.get("begin_type") is None @@ -130,8 +130,8 @@ def test_modify_column_action(self, dummy_dialect): from unittest.mock import patch col_def = ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 255)) - action = ModifyColumn(dummy_dialect, column=col_def) - expr = AlterTableExpression( + action = ModifyColumnAction(dummy_dialect, column=col_def) + expr = AlterTableStatement( dummy_dialect, table=Table(dummy_dialect, "users"), actions=[action], @@ -144,8 +144,8 @@ def test_change_column_action(self, dummy_dialect): from unittest.mock import patch col_def = ColumnDefinition(dummy_dialect, "new_name", VarCharType(dummy_dialect, 255)) - action = ChangeColumn(dummy_dialect, old_name="old_name", column=col_def) - expr = AlterTableExpression( + action = ChangeColumnAction(dummy_dialect, old_name="old_name", column=col_def) + expr = AlterTableStatement( dummy_dialect, table=Table(dummy_dialect, "users"), actions=[action], diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_alter_table.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_alter_table.py index a6cb0b94d..ce6fe9807 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_alter_table.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_alter_table.py @@ -1,37 +1,47 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_alter_table.py import pytest - +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Index +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.types import DecimalType, IntegerType, TextType, TimestampType, VarCharType +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol +from rhosocial.activerecord.backend.expression.statements import ( + AlterColumnAction, + AddTableConstraintAction, + DropTableConstraintAction, + RenameColumnAction, + AddIndexAction, + DropIndexAction, + TableConstraint, + RenameTableAction, + ColumnAlterOperation, + AlterTableAction, +) from rhosocial.activerecord.backend.expression import ( Column, Literal, FunctionCall, ComparisonPredicate, # Import ALTER TABLE related classes - AlterTableExpression, - AddColumn, - DropColumn, + AlterTableStatement, + AddColumnAction, + DropColumnAction, ColumnDefinition, ColumnCommentClause, IndexDefinition, -) -from rhosocial.activerecord.backend.expression.statements import ( - AlterColumn, - AddTableConstraint, - DropTableConstraint, - RenameObject, - AddIndex, - DropIndex, - TableConstraint, - TableConstraintType, - RenameTable, - ColumnAlterOperation, - AlterTableAction, -) -from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.types import DecimalType, IntegerType, TextType, TimestampType, VarCharType -from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.objects import Index + NumericColumn,) + class TestAlterTableStatements: @@ -40,9 +50,9 @@ class TestAlterTableStatements: def test_add_column_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ADD COLUMN action.""" column_def = ColumnDefinition(dummy_dialect, "email", VarCharType(dummy_dialect, 100), comment=ColumnCommentClause(dummy_dialect, "User's email address")) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -52,9 +62,9 @@ def test_add_column_action(self, dummy_dialect: DummyDialect): def test_drop_column_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP COLUMN action.""" - drop_action = DropColumn(dummy_dialect, column_name="old_column") + drop_action = DropColumnAction(dummy_dialect, column_name="old_column") - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -65,14 +75,14 @@ def test_drop_column_action(self, dummy_dialect: DummyDialect): def test_alter_column_modify_type(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ALTER COLUMN to modify data type.""" - alter_action = AlterColumn( + alter_action = AlterColumnAction( dummy_dialect, column_name="price", operation=ColumnAlterOperation.SET_DATA_TYPE, new_value="DECIMAL(10,2)", ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[alter_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -84,9 +94,14 @@ def test_alter_column_modify_type(self, dummy_dialect: DummyDialect): def test_alter_column_modify_default(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ALTER COLUMN to modify default value.""" - alter_action = AlterColumn(dummy_dialect, column_name="status", operation="SET DEFAULT", new_value="active") + alter_action = AlterColumnAction( + dummy_dialect, + column_name="status", + operation="SET DEFAULT", + new_value="active" + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "users"' in sql @@ -97,15 +112,38 @@ def test_alter_column_modify_default(self, dummy_dialect: DummyDialect): assert "DEFAULT 'active'" in sql assert params == () + def test_alter_column_modify_default_given_a_literal(self, dummy_dialect: DummyDialect): + """A Literal value renders the same as the bare string it wraps. + + The two spell the same value, so the statement they produce must be + the same. A value that arrives already wrapped is still a value the + position has to write out: ``SET DEFAULT`` with nothing after it + would set the column's default to nothing at all. + """ + for new_value in ("active", Literal(dummy_dialect, "active", inline=True)): + alter_action = AlterColumnAction( + dummy_dialect, + column_name="status", + operation="SET DEFAULT", + new_value=new_value, + ) + + alter_expr = AlterTableStatement( + dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action] + ) + sql, params = alter_expr.to_sql() + + assert "SET DEFAULT 'active'" in sql + assert params == () + def test_add_constraint_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ADD CONSTRAINT action.""" - check_condition = Column(dummy_dialect, "age") > Literal(dummy_dialect, 0, inline_literals=True) # Using operator overload - constraint = TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.CHECK, check_condition=check_condition, name="chk_positive_age" - ) - add_constraint_action = AddTableConstraint(dummy_dialect, constraint=constraint) + check_condition = NumericColumn(dummy_dialect, + "age") > Literal(dummy_dialect, 0, inline=True) # Using operator overload + constraint = TableConstraint(dummy_dialect, branch=TableCheckConstraint(dummy_dialect, check_condition), name="chk_positive_age") + add_constraint_action = AddTableConstraintAction(dummy_dialect, constraint=constraint) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "employees"), actions=[add_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "employees"), actions=[add_constraint_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -118,9 +156,13 @@ def test_add_constraint_action(self, dummy_dialect: DummyDialect): def test_drop_constraint_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP CONSTRAINT action.""" - drop_constraint_action = DropTableConstraint(dummy_dialect, constraint_name="old_constraint", cascade=False) + drop_constraint_action = DropTableConstraintAction( + dummy_dialect, + constraint_name="old_constraint", + cascade=False + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -131,9 +173,9 @@ def test_drop_constraint_action(self, dummy_dialect: DummyDialect): def test_rename_column_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with RENAME COLUMN action.""" - rename_action = RenameObject(dummy_dialect, old_name="user_name", new_name="username", object_type="COLUMN") + rename_action = RenameColumnAction(dummy_dialect, old_name="user_name", new_name="username") - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[rename_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[rename_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -143,10 +185,10 @@ def test_rename_column_action(self, dummy_dialect: DummyDialect): def test_multiple_actions(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with multiple actions in a single statement.""" column_def = ColumnDefinition(dummy_dialect, name="age", data_type=IntegerType(dummy_dialect)) - add_action = AddColumn(dummy_dialect, column=column_def) - drop_action = DropColumn(dummy_dialect, column_name="old_field") + add_action = AddColumnAction(dummy_dialect, column=column_def) + drop_action = DropColumnAction(dummy_dialect, column_name="old_field") - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "profiles"), actions=[add_action, drop_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "profiles"), actions=[add_action, drop_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -157,9 +199,9 @@ def test_multiple_actions(self, dummy_dialect: DummyDialect): def test_alter_column_cascade_option(self, dummy_dialect: DummyDialect): """Tests ALTER COLUMN with CASCADE option.""" - alter_action = AlterColumn(dummy_dialect, column_name="category", operation="DROP NOT NULL", cascade=True) + alter_action = AlterColumnAction(dummy_dialect, column_name="category", operation="DROP NOT NULL", cascade=True) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[alter_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "products"' in sql @@ -169,9 +211,9 @@ def test_alter_column_cascade_option(self, dummy_dialect: DummyDialect): def test_add_index_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ADD INDEX action.""" index_def = IndexDefinition(dummy_dialect, name="idx_users_email", columns=["email"], unique=True) - add_index_action = AddIndex(dummy_dialect, index=index_def) + add_index_action = AddIndexAction(dummy_dialect, index=index_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_index_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_index_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "users"' in sql @@ -181,11 +223,11 @@ def test_add_index_action(self, dummy_dialect: DummyDialect): def test_drop_index_action(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP INDEX action.""" - drop_index_action = DropIndex( + drop_index_action = DropIndexAction( dummy_dialect, Index(dummy_dialect, "old_idx"), if_exists=True ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_index_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_index_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "legacy_table"' in sql @@ -193,9 +235,13 @@ def test_drop_index_action(self, dummy_dialect: DummyDialect): def test_drop_constraint_with_cascade(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP CONSTRAINT CASCADE.""" - drop_constraint_action = DropTableConstraint(dummy_dialect, constraint_name="fk_orders_user_id", cascade=True) + drop_constraint_action = DropTableConstraintAction( + dummy_dialect, + constraint_name="fk_orders_user_id", + cascade=True + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "orders"' in sql @@ -208,25 +254,24 @@ def test_alter_table_complex_scenario(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with complex scenario involving multiple action types.""" # Add new column new_col_def = ColumnDefinition(dummy_dialect, name="created_by", data_type=IntegerType(dummy_dialect)) - add_action = AddColumn(dummy_dialect, column=new_col_def) + add_action = AddColumnAction(dummy_dialect, column=new_col_def) # Set default value for existing column - alter_action = AlterColumn(dummy_dialect, column_name="status", operation="SET DEFAULT", new_value="pending") + alter_action = AlterColumnAction( + dummy_dialect, + column_name="status", + operation="SET DEFAULT", + new_value="pending" + ) # Add foreign key constraint ComparisonPredicate( dummy_dialect, "=", Column(dummy_dialect, "created_by"), Column(dummy_dialect, "id", "users") ) - constraint = TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - name="fk_created_by", - columns=["created_by"], - foreign_key_table=Table(dummy_dialect, "users"), - foreign_key_columns=["id"], - ) - add_constraint_action = AddTableConstraint(dummy_dialect, constraint=constraint) + constraint = TableConstraint(dummy_dialect, branch=TableForeignKeyConstraint(dummy_dialect, ["created_by"], (Table(dummy_dialect, "users"), ["id"])), name="fk_created_by") + add_constraint_action = AddTableConstraintAction(dummy_dialect, constraint=constraint) - alter_expr = AlterTableExpression( + alter_expr = AlterTableStatement( dummy_dialect, table=Table(dummy_dialect, "posts"), actions=[add_action, alter_action, add_constraint_action] ) sql, params = alter_expr.to_sql() @@ -239,9 +284,9 @@ def test_alter_table_complex_scenario(self, dummy_dialect: DummyDialect): def test_alter_table_simple_types(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with simple data types.""" column_def = ColumnDefinition(dummy_dialect, name="timestamp", data_type=TimestampType(dummy_dialect)) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "events"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "events"), actions=[add_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "events"' in sql @@ -251,9 +296,9 @@ def test_alter_table_simple_types(self, dummy_dialect: DummyDialect): def test_alter_table_numeric_types(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with numeric data types.""" column_def = ColumnDefinition(dummy_dialect, name="amount", data_type=DecimalType(dummy_dialect, precision=10, scale=2)) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "transactions"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "transactions"), actions=[add_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "transactions"' in sql @@ -262,14 +307,14 @@ def test_alter_table_numeric_types(self, dummy_dialect: DummyDialect): def test_alter_column_with_expression_default(self, dummy_dialect: DummyDialect): """Tests ALTER COLUMN with expression as default value.""" - alter_action = AlterColumn( + alter_action = AlterColumnAction( dummy_dialect, column_name="updated_at", operation="SET DEFAULT", new_value=FunctionCall(dummy_dialect, "NOW"), # Use function call as default ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "entities"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "entities"), actions=[alter_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "entities"' in sql @@ -280,7 +325,7 @@ def test_alter_column_with_expression_default(self, dummy_dialect: DummyDialect) def test_add_column_action_direct(self, dummy_dialect: DummyDialect): """Tests direct ADD COLUMN action creation and formatting.""" column_def = ColumnDefinition(dummy_dialect, "phone", VarCharType(dummy_dialect, 20), comment=ColumnCommentClause(dummy_dialect, "User's phone number")) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) # Action now has dialect bound at construction time sql, params = add_action.to_sql() @@ -291,7 +336,7 @@ def test_add_column_action_direct(self, dummy_dialect: DummyDialect): def test_drop_column_action_direct(self, dummy_dialect: DummyDialect): """Tests direct DROP COLUMN action creation and formatting.""" - drop_action = DropColumn(dummy_dialect, column_name="old_phone") + drop_action = DropColumnAction(dummy_dialect, column_name="old_phone") # Action now has dialect bound at construction time sql, params = drop_action.to_sql() @@ -301,7 +346,7 @@ def test_drop_column_action_direct(self, dummy_dialect: DummyDialect): def test_alter_column_action_direct(self, dummy_dialect: DummyDialect): """Tests direct ALTER COLUMN action creation and formatting.""" - alter_action = AlterColumn( + alter_action = AlterColumnAction( dummy_dialect, column_name="description", operation="SET DEFAULT", new_value="default_value" ) # Action now has dialect bound at construction time @@ -315,11 +360,9 @@ def test_alter_column_action_direct(self, dummy_dialect: DummyDialect): def test_add_constraint_action_direct(self, dummy_dialect: DummyDialect): """Tests direct ADD CONSTRAINT action creation and formatting.""" - check_condition = Column(dummy_dialect, "balance") >= Literal(dummy_dialect, 0, inline_literals=True) - constraint = TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.CHECK, check_condition=check_condition, name="chk_balance_positive" - ) - add_constraint_action = AddTableConstraint(dummy_dialect, constraint=constraint) + check_condition = NumericColumn(dummy_dialect, "balance") >= Literal(dummy_dialect, 0, inline=True) + constraint = TableConstraint(dummy_dialect, branch=TableCheckConstraint(dummy_dialect, check_condition), name="chk_balance_positive") + add_constraint_action = AddTableConstraintAction(dummy_dialect, constraint=constraint) # Action now has dialect bound at construction time sql, params = add_constraint_action.to_sql() @@ -330,7 +373,11 @@ def test_add_constraint_action_direct(self, dummy_dialect: DummyDialect): def test_drop_constraint_action_direct(self, dummy_dialect: DummyDialect): """Tests direct DROP CONSTRAINT action creation and formatting.""" - drop_constraint_action = DropTableConstraint(dummy_dialect, constraint_name="fk_old_constraint", cascade=True) + drop_constraint_action = DropTableConstraintAction( + dummy_dialect, + constraint_name="fk_old_constraint", + cascade=True + ) # Action now has dialect bound at construction time sql, params = drop_constraint_action.to_sql() @@ -341,7 +388,7 @@ def test_drop_constraint_action_direct(self, dummy_dialect: DummyDialect): def test_rename_column_action_direct(self, dummy_dialect: DummyDialect): """Tests direct RENAME COLUMN action creation and formatting.""" - rename_action = RenameObject(dummy_dialect, old_name="old_name", new_name="new_name", object_type="COLUMN") + rename_action = RenameColumnAction(dummy_dialect, old_name="old_name", new_name="new_name") # Action now has dialect bound at construction time sql, params = rename_action.to_sql() @@ -353,7 +400,7 @@ def test_rename_column_action_direct(self, dummy_dialect: DummyDialect): def test_add_index_action_direct(self, dummy_dialect: DummyDialect): """Tests direct ADD INDEX action creation and formatting.""" index_def = IndexDefinition(dummy_dialect, name="idx_new_index", columns=["status"], unique=False) - add_index_action = AddIndex(dummy_dialect, index=index_def) + add_index_action = AddIndexAction(dummy_dialect, index=index_def) # Action now has dialect bound at construction time sql, params = add_index_action.to_sql() @@ -364,7 +411,7 @@ def test_add_index_action_direct(self, dummy_dialect: DummyDialect): def test_drop_index_action_direct(self, dummy_dialect: DummyDialect): """Tests direct DROP INDEX action creation and formatting.""" - drop_index_action = DropIndex( + drop_index_action = DropIndexAction( dummy_dialect, Index(dummy_dialect, "old_index"), if_exists=True ) # Action now has dialect bound at construction time @@ -392,13 +439,16 @@ def __init__(self, dialect, column): def test_action_isinstance_tosql_protocol(self, dummy_dialect: DummyDialect): """Tests that AlterTableAction subclasses are instances of ToSQLProtocol.""" - add_action = AddColumn(dummy_dialect, column=ColumnDefinition(dummy_dialect, "test", TextType(dummy_dialect))) + add_action = AddColumnAction( + dummy_dialect, + column=ColumnDefinition(dummy_dialect, "test", TextType(dummy_dialect)) + ) assert isinstance(add_action, ToSQLProtocol) def test_action_requires_dialect(self): """Tests that action construction requires a dialect parameter.""" with pytest.raises(TypeError): - AddColumn(column=ColumnDefinition(None, "test", TextType(None))) + AddColumnAction(column=ColumnDefinition(None, "test", TextType(None))) def test_alter_table_expression_rejects_non_action(self, dummy_dialect: DummyDialect): """A non-action is refused by the formatter, not by the constructor. @@ -407,7 +457,7 @@ def test_alter_table_expression_rejects_non_action(self, dummy_dialect: DummyDia dialect is the party that would have had nothing to render for an entry of another kind. So the refusal happens at render time. """ - expr = AlterTableExpression( + expr = AlterTableStatement( dummy_dialect, table=Table(dummy_dialect, "test"), actions=["not_an_action"] ) with pytest.raises( @@ -417,9 +467,9 @@ def test_alter_table_expression_rejects_non_action(self, dummy_dialect: DummyDia def test_rename_column_action_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with RENAME COLUMN action per SQL standard.""" - rename_action = RenameObject(dummy_dialect, old_name="user_name", new_name="username") + rename_action = RenameColumnAction(dummy_dialect, old_name="user_name", new_name="username") - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[rename_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[rename_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -429,9 +479,9 @@ def test_rename_column_action_standard(self, dummy_dialect: DummyDialect): def test_rename_table_action_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with RENAME TABLE action per SQL standard.""" - rename_action = RenameTable(dummy_dialect, old_name="old_table_name", new_name="new_table_name") + rename_action = RenameTableAction(dummy_dialect, old_name="old_table_name", new_name="new_table_name") - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "old_table_name"), actions=[rename_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "old_table_name"), actions=[rename_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -441,11 +491,11 @@ def test_rename_table_action_standard(self, dummy_dialect: DummyDialect): def test_alter_column_set_default_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ALTER COLUMN SET DEFAULT action per SQL standard.""" - alter_action = AlterColumn( + alter_action = AlterColumnAction( dummy_dialect, column_name="status", operation=ColumnAlterOperation.SET_DEFAULT, new_value="active" ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "users"' in sql @@ -454,9 +504,13 @@ def test_alter_column_set_default_standard(self, dummy_dialect: DummyDialect): def test_alter_column_drop_default_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ALTER COLUMN DROP DEFAULT action per SQL standard.""" - alter_action = AlterColumn(dummy_dialect, column_name="status", operation=ColumnAlterOperation.DROP_DEFAULT) + alter_action = AlterColumnAction( + dummy_dialect, + column_name="status", + operation=ColumnAlterOperation.DROP_DEFAULT + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[alter_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "users"' in sql @@ -465,13 +519,11 @@ def test_alter_column_drop_default_standard(self, dummy_dialect: DummyDialect): def test_add_table_constraint_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with ADD CONSTRAINT action per SQL standard.""" - check_condition = Column(dummy_dialect, "age") > Literal(dummy_dialect, 0, inline_literals=True) - constraint = TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.CHECK, check_condition=check_condition, name="chk_positive_age" - ) - add_constraint_action = AddTableConstraint(dummy_dialect, constraint=constraint) + check_condition = NumericColumn(dummy_dialect, "age") > Literal(dummy_dialect, 0, inline=True) + constraint = TableConstraint(dummy_dialect, branch=TableCheckConstraint(dummy_dialect, check_condition), name="chk_positive_age") + add_constraint_action = AddTableConstraintAction(dummy_dialect, constraint=constraint) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "employees"), actions=[add_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "employees"), actions=[add_constraint_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -481,9 +533,13 @@ def test_add_table_constraint_standard(self, dummy_dialect: DummyDialect): def test_drop_table_constraint_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP CONSTRAINT action per SQL standard.""" - drop_constraint_action = DropTableConstraint(dummy_dialect, constraint_name="old_constraint", cascade=False) + drop_constraint_action = DropTableConstraintAction( + dummy_dialect, + constraint_name="old_constraint", + cascade=False + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -493,9 +549,13 @@ def test_drop_table_constraint_standard(self, dummy_dialect: DummyDialect): def test_drop_table_constraint_with_cascade_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP CONSTRAINT CASCADE per SQL standard.""" - drop_constraint_action = DropTableConstraint(dummy_dialect, constraint_name="fk_orders_user_id", cascade=True) + drop_constraint_action = DropTableConstraintAction( + dummy_dialect, + constraint_name="fk_orders_user_id", + cascade=True + ) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[drop_constraint_action]) sql, params = alter_expr.to_sql() assert 'ALTER TABLE "orders"' in sql @@ -504,9 +564,9 @@ def test_drop_table_constraint_with_cascade_standard(self, dummy_dialect: DummyD def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): """Tests ALTER TABLE with DROP COLUMN IF EXISTS per SQL standard.""" - drop_action = DropColumn(dummy_dialect, column_name="old_column", if_exists=True) + drop_action = DropColumnAction(dummy_dialect, column_name="old_column", if_exists=True) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "legacy_table"), actions=[drop_action]) sql, params = alter_expr.to_sql() # Verify basic structure @@ -518,19 +578,19 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): ("action_factory", "capability", "feature", "suggestion"), [ ( - lambda dialect: DropColumn(dialect, "value"), + lambda dialect: DropColumnAction(dialect, "value"), "supports_drop_column", "ALTER TABLE DROP COLUMN", "Dummy does not support DROP COLUMN.", ), ( - lambda dialect: DropColumn(dialect, "value", if_exists=True), + lambda dialect: DropColumnAction(dialect, "value", if_exists=True), "supports_drop_column_if_exists", "DROP COLUMN IF EXISTS", "Dummy does not support DROP COLUMN IF EXISTS.", ), ( - lambda dialect: AlterColumn( + lambda dialect: AlterColumnAction( dialect, "value", ColumnAlterOperation.SET_DATA_TYPE, @@ -541,7 +601,7 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "Dummy does not support changing a column data type.", ), ( - lambda dialect: AlterColumn( + lambda dialect: AlterColumnAction( dialect, "value", ColumnAlterOperation.SET_DEFAULT, @@ -553,7 +613,7 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "(SET/DROP DEFAULT, SET/DROP NOT NULL).", ), ( - lambda dialect: AlterColumn( + lambda dialect: AlterColumnAction( dialect, "value", ColumnAlterOperation.DROP_DEFAULT, @@ -564,7 +624,7 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "(SET/DROP DEFAULT, SET/DROP NOT NULL).", ), ( - lambda dialect: AlterColumn( + lambda dialect: AlterColumnAction( dialect, "value", ColumnAlterOperation.SET_NOT_NULL, @@ -575,7 +635,7 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "(SET/DROP DEFAULT, SET/DROP NOT NULL).", ), ( - lambda dialect: AlterColumn( + lambda dialect: AlterColumnAction( dialect, "value", ColumnAlterOperation.DROP_NOT_NULL, @@ -586,7 +646,7 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "(SET/DROP DEFAULT, SET/DROP NOT NULL).", ), ( - lambda dialect: AddIndex( + lambda dialect: AddIndexAction( dialect, IndexDefinition(dialect, "idx_value", ["value"]), ), @@ -595,19 +655,19 @@ def test_drop_column_if_exists_standard(self, dummy_dialect: DummyDialect): "Dummy does not support ALTER TABLE ADD INDEX.", ), ( - lambda dialect: DropIndex(dialect, Index(dialect, "idx_value"), if_exists=True), + lambda dialect: DropIndexAction(dialect, Index(dialect, "idx_value"), if_exists=True), "supports_alter_table_index_actions", "ALTER TABLE DROP INDEX", "Dummy does not support ALTER TABLE DROP INDEX.", ), ( - lambda dialect: RenameObject(dialect, "old_value", "new_value"), + lambda dialect: RenameColumnAction(dialect, "old_value", "new_value"), "supports_rename_column", "ALTER TABLE RENAME COLUMN", "Dummy does not support RENAME COLUMN.", ), ( - lambda dialect: RenameTable(dialect, "records", "renamed_records"), + lambda dialect: RenameTableAction(dialect, "records", "renamed_records"), "supports_rename_table", "ALTER TABLE RENAME TO", "Dummy does not support RENAME TABLE.", @@ -638,7 +698,7 @@ def test_capability_gated_action_rejects_unsupported_dialect( ): """Rejects each ALTER TABLE action when its capability is disabled.""" monkeypatch.setattr(dummy_dialect, capability, lambda: False) - expression = AlterTableExpression( + expression = AlterTableStatement( dummy_dialect, table=Table(dummy_dialect, "records"), actions=[action_factory(dummy_dialect)], @@ -661,21 +721,18 @@ def test_capability_gated_action_rejects_unsupported_dialect( def test_add_column_action_with_not_null_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with NOT NULL constraint (replacing the nullable=False functionality).""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="username", data_type=VarCharType(dummy_dialect, 50), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)], # Use constraint instead of nullable flag + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))], # Use constraint instead of nullable flag comment=ColumnCommentClause(dummy_dialect, "Username (cannot be null)"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -689,21 +746,18 @@ def test_add_column_action_with_not_null_constraint(self, dummy_dialect: DummyDi def test_add_column_action_with_null_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with explicit NULL constraint (replacing the nullable=True functionality).""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="description", data_type=TextType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NULL)], # Explicitly allow NULL + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNullableConstraint(dummy_dialect))], # Explicitly allow NULL comment=ColumnCommentClause(dummy_dialect, "Description field"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "products"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -717,21 +771,18 @@ def test_add_column_action_with_null_constraint(self, dummy_dialect: DummyDialec def test_add_column_action_with_default_constraint_literal(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with DEFAULT constraint using a literal value.""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="status", data_type=VarCharType(dummy_dialect, 20), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value="active")], # Default value + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, "active"))], # Default value comment=ColumnCommentClause(dummy_dialect, "Status field with default value"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -745,13 +796,11 @@ def test_add_column_action_with_default_constraint_literal(self, dummy_dialect: def test_add_column_action_with_default_constraint_expression(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with DEFAULT constraint using an expression.""" - from rhosocial.activerecord.backend.expression import FunctionCall from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression # Create a function call as default value now_func = FunctionCall(dummy_dialect, "NOW") @@ -759,12 +808,12 @@ def test_add_column_action_with_default_constraint_expression(self, dummy_dialec column_def = ColumnDefinition(dummy_dialect, name="created_at", data_type=TimestampType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value=now_func)], # Default function + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, now_func))], # Default function comment=ColumnCommentClause(dummy_dialect, "Timestamp with default function"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "logs"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "logs"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -778,30 +827,28 @@ def test_add_column_action_with_default_constraint_expression(self, dummy_dialec def test_add_column_action_with_check_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with CHECK constraint.""" - from rhosocial.activerecord.backend.expression import Column as ExprColumn, Literal, ComparisonPredicate + from rhosocial.activerecord.backend.expression import Column as ExprColumn from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression # Create a check condition: age > 0 check_condition = ComparisonPredicate( - dummy_dialect, ">", ExprColumn(dummy_dialect, "age"), Literal(dummy_dialect, 0, inline_literals=True) + dummy_dialect, ">", ExprColumn(dummy_dialect, "age"), Literal(dummy_dialect, 0, inline=True) ) column_def = ColumnDefinition(dummy_dialect, name="age", data_type=IntegerType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.CHECK, check_condition=check_condition) + ColumnConstraint(dummy_dialect, branch=ColumnCheckConstraint(dummy_dialect, check_condition)) ], # Check constraint comment=ColumnCommentClause(dummy_dialect, "Age must be positive"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "people"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "people"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -816,21 +863,18 @@ def test_add_column_action_with_check_constraint(self, dummy_dialect: DummyDiale def test_add_column_action_with_primary_key_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with PRIMARY KEY constraint.""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="id", data_type=IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)], # Primary key constraint + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))], # Primary key constraint comment=ColumnCommentClause(dummy_dialect, "Primary key column"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "new_table"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "new_table"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -844,21 +888,18 @@ def test_add_column_action_with_primary_key_constraint(self, dummy_dialect: Dumm def test_add_column_action_with_unique_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with UNIQUE constraint.""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="email", data_type=VarCharType(dummy_dialect, 100), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.UNIQUE)], # Unique constraint + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnUniqueConstraint(dummy_dialect))], # Unique constraint comment=ColumnCommentClause(dummy_dialect, "Unique email address"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "users"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -872,23 +913,20 @@ def test_add_column_action_with_unique_constraint(self, dummy_dialect: DummyDial def test_add_column_action_with_foreign_key_constraint(self, dummy_dialect: DummyDialect): """Tests ADD COLUMN with FOREIGN KEY constraint.""" from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression column_def = ColumnDefinition(dummy_dialect, name="user_id", data_type=IntegerType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dummy_dialect, "users"), ["id"])) + ColumnConstraint(dummy_dialect, branch=ColumnForeignKeyConstraint(dummy_dialect, (Table(dummy_dialect, "users"), ["id"]))) ], # Foreign key constraint comment=ColumnCommentClause(dummy_dialect, "Reference to users table"), ) - add_action = AddColumn(dummy_dialect, column=column_def) + add_action = AddColumnAction(dummy_dialect, column=column_def) - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[add_action]) + alter_expr = AlterTableStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[add_action]) sql, params = alter_expr.to_sql() # The exact format depends on the dialect's implementation of format_column_definition @@ -901,27 +939,11 @@ def test_add_column_action_with_foreign_key_constraint(self, dummy_dialect: Dumm assert '"id"' in sql assert params == () - def test_add_column_action_with_foreign_key_constraint_missing_reference(self, dummy_dialect: DummyDialect): - """Tests ADD COLUMN with FOREIGN KEY constraint but missing foreign_key_reference should raise ValueError.""" - from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - from rhosocial.activerecord.backend.expression.statements import AddColumn, AlterTableExpression - - column_def = ColumnDefinition(dummy_dialect, - name="user_id", - data_type=IntegerType(dummy_dialect), - constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.FOREIGN_KEY) - ], # Foreign key constraint without reference - comment=ColumnCommentClause(dummy_dialect, "Reference to users table"), - ) - add_action = AddColumn(dummy_dialect, column=column_def) - - alter_expr = AlterTableExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), actions=[add_action]) - - # Should raise ValueError when to_sql() is called - with pytest.raises(ValueError, match=r"FOREIGN KEY constraint must have a foreign key reference specified."): - alter_expr.to_sql() + """Refused where the branch is built. + + The branch is what states this constraint, so a constraint + without its payload cannot be written at all. + """ + + with pytest.raises(ValueError, match="needs references"): + ColumnForeignKeyConstraint(dummy_dialect, None) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py index f89f266e5..c7229bbda 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py @@ -1,61 +1,59 @@ -# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + TablePrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, TableConstraint, - TableConstraintType, ComparisonPredicate, Literal, ) -from rhosocial.activerecord.backend.expression import Column -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType -from rhosocial.activerecord.backend.expression.objects import Table +# tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_composite_pk.py dialect = DummyDialect() class TestCompositePKDDL: def test_create_table_dual_pk(self): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'order_items'), 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))]), ], table_constraints=[ - TableConstraint(dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"], - ), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["order_id", "product_id"])), ], ) sql, params = expr.to_sql() assert "PRIMARY KEY (\"order_id\", \"product_id\")" in sql def test_create_table_triple_pk(self): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'store_inventory'), 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))]), ], 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"]), + ) ], ) sql, params = expr.to_sql() assert "PRIMARY KEY" in sql @@ -64,20 +62,17 @@ def test_create_table_triple_pk(self): assert "batch_id" in sql def test_pk_columns_not_null(self): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 't'), columns=[ ColumnDefinition(dialect, "a", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "b", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["a", "b"], - ), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["a", "b"])), ], ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_alter_sequence.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_alter_sequence.py index d8df90e2d..d856673a8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_alter_sequence.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_alter_sequence.py @@ -2,9 +2,9 @@ import pytest from unittest.mock import patch from rhosocial.activerecord.backend.expression.statements import ( - CreateSequenceExpression, - DropSequenceExpression, - AlterSequenceExpression, + CreateSequenceStatement, + DropSequenceStatement, + AlterSequenceStatement, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -17,7 +17,7 @@ class TestCreateDropAlterSequenceStatements: # region CREATE SEQUENCE tests def test_basic_create_sequence(self, dummy_dialect: DummyDialect): """Tests basic CREATE SEQUENCE statement.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq")) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq")) sql, params = create_seq.to_sql() assert 'CREATE SEQUENCE "user_id_seq"' in sql @@ -25,7 +25,7 @@ def test_basic_create_sequence(self, dummy_dialect: DummyDialect): def test_create_sequence_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE IF NOT EXISTS statement.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "order_id_seq"), if_not_exists=True) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "order_id_seq"), if_not_exists=True) sql, params = create_seq.to_sql() assert 'CREATE SEQUENCE IF NOT EXISTS "order_id_seq"' in sql @@ -33,7 +33,7 @@ def test_create_sequence_if_not_exists(self, dummy_dialect: DummyDialect): def test_create_sequence_with_start(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with START WITH.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "invoice_seq"), start=1000) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "invoice_seq"), start=1000) sql, params = create_seq.to_sql() assert 'CREATE SEQUENCE "invoice_seq"' in sql @@ -42,7 +42,7 @@ def test_create_sequence_with_start(self, dummy_dialect: DummyDialect): def test_create_sequence_with_increment(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with INCREMENT BY.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "even_seq"), start=2, increment=2) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "even_seq"), start=2, increment=2) sql, params = create_seq.to_sql() assert "START WITH 2" in sql @@ -51,7 +51,7 @@ def test_create_sequence_with_increment(self, dummy_dialect: DummyDialect): def test_create_sequence_with_min_max(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with MINVALUE and MAXVALUE.""" - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "bounded_seq"), start=1, minvalue=1, maxvalue=1000 ) sql, params = create_seq.to_sql() @@ -62,7 +62,7 @@ def test_create_sequence_with_min_max(self, dummy_dialect: DummyDialect): def test_create_sequence_with_cycle(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with CYCLE option.""" - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "cycling_seq"), start=1, maxvalue=100, cycle=True ) sql, params = create_seq.to_sql() @@ -73,7 +73,7 @@ def test_create_sequence_with_cycle(self, dummy_dialect: DummyDialect): def test_create_sequence_no_cycle_explicit(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with explicit NO CYCLE.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "non_cycling_seq"), start=1, no_cycle=True) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "non_cycling_seq"), start=1, no_cycle=True) sql, params = create_seq.to_sql() assert "NO CYCLE" in sql @@ -81,7 +81,7 @@ def test_create_sequence_no_cycle_explicit(self, dummy_dialect: DummyDialect): def test_create_sequence_with_cache(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with CACHE.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "high_throughput_seq"), start=1, cache=100) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "high_throughput_seq"), start=1, cache=100) sql, params = create_seq.to_sql() assert "CACHE 100" in sql @@ -89,7 +89,7 @@ def test_create_sequence_with_cache(self, dummy_dialect: DummyDialect): def test_create_sequence_with_order(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with ORDER.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "ordered_seq"), start=1, order=True) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "ordered_seq"), start=1, order=True) sql, params = create_seq.to_sql() assert "ORDER" in sql @@ -97,7 +97,7 @@ def test_create_sequence_with_order(self, dummy_dialect: DummyDialect): def test_create_sequence_with_owned_by(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with OWNED BY.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), start=1, owned_by="users.id") + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), start=1, owned_by="users.id") sql, params = create_seq.to_sql() assert "OWNED BY users.id" in sql @@ -105,7 +105,7 @@ def test_create_sequence_with_owned_by(self, dummy_dialect: DummyDialect): def test_create_sequence_all_options(self, dummy_dialect: DummyDialect): """Tests CREATE SEQUENCE with all options.""" - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "complex_seq"), if_not_exists=True, @@ -134,7 +134,7 @@ def test_create_sequence_all_options(self, dummy_dialect: DummyDialect): # region DROP SEQUENCE tests def test_basic_drop_sequence(self, dummy_dialect: DummyDialect): """Tests basic DROP SEQUENCE statement.""" - drop_seq = DropSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "old_seq")) + drop_seq = DropSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "old_seq")) sql, params = drop_seq.to_sql() assert sql == 'DROP SEQUENCE "old_seq"' @@ -142,7 +142,7 @@ def test_basic_drop_sequence(self, dummy_dialect: DummyDialect): def test_drop_sequence_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP SEQUENCE IF EXISTS statement.""" - drop_seq = DropSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "deprecated_seq"), if_exists=True) + drop_seq = DropSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "deprecated_seq"), if_exists=True) sql, params = drop_seq.to_sql() assert sql == 'DROP SEQUENCE IF EXISTS "deprecated_seq"' @@ -151,7 +151,7 @@ def test_drop_sequence_if_exists(self, dummy_dialect: DummyDialect): # region ALTER SEQUENCE tests def test_alter_sequence_restart(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with RESTART.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), restart=1000) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), restart=1000) sql, params = alter_seq.to_sql() assert 'ALTER SEQUENCE "user_id_seq"' in sql @@ -160,7 +160,7 @@ def test_alter_sequence_restart(self, dummy_dialect: DummyDialect): def test_alter_sequence_increment(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with INCREMENT BY.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "order_seq"), increment=1) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "order_seq"), increment=1) sql, params = alter_seq.to_sql() assert "INCREMENT BY 1" in sql @@ -168,7 +168,7 @@ def test_alter_sequence_increment(self, dummy_dialect: DummyDialect): def test_alter_sequence_min_max(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with MINVALUE and MAXVALUE.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "bounded_seq"), minvalue=10, maxvalue=999999) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "bounded_seq"), minvalue=10, maxvalue=999999) sql, params = alter_seq.to_sql() assert "MINVALUE 10" in sql @@ -177,7 +177,7 @@ def test_alter_sequence_min_max(self, dummy_dialect: DummyDialect): def test_alter_sequence_cycle(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with CYCLE.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "cycling_seq"), cycle=True) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "cycling_seq"), cycle=True) sql, params = alter_seq.to_sql() assert "CYCLE" in sql @@ -186,7 +186,7 @@ def test_alter_sequence_cycle(self, dummy_dialect: DummyDialect): def test_alter_sequence_no_cycle(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with NO CYCLE.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "non_cycling_seq"), no_cycle=True) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "non_cycling_seq"), no_cycle=True) sql, params = alter_seq.to_sql() assert "NO CYCLE" in sql @@ -194,7 +194,7 @@ def test_alter_sequence_no_cycle(self, dummy_dialect: DummyDialect): def test_alter_sequence_cache(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with CACHE.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "cached_seq"), cache=50) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "cached_seq"), cache=50) sql, params = alter_seq.to_sql() assert "CACHE 50" in sql @@ -202,7 +202,7 @@ def test_alter_sequence_cache(self, dummy_dialect: DummyDialect): def test_alter_sequence_order(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with ORDER.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "ordered_seq"), order=True) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "ordered_seq"), order=True) sql, params = alter_seq.to_sql() assert "ORDER" in sql @@ -210,7 +210,7 @@ def test_alter_sequence_order(self, dummy_dialect: DummyDialect): def test_alter_sequence_no_order(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with NO ORDER.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "unordered_seq"), no_order=True) + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "unordered_seq"), no_order=True) sql, params = alter_seq.to_sql() assert "NO ORDER" in sql @@ -218,7 +218,7 @@ def test_alter_sequence_no_order(self, dummy_dialect: DummyDialect): def test_alter_sequence_owned_by(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with OWNED BY.""" - alter_seq = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), owned_by="users.id") + alter_seq = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "user_id_seq"), owned_by="users.id") sql, params = alter_seq.to_sql() assert "OWNED BY users.id" in sql @@ -226,7 +226,7 @@ def test_alter_sequence_owned_by(self, dummy_dialect: DummyDialect): def test_alter_sequence_owned_by_none(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with OWNED BY NONE.""" - alter_seq = AlterSequenceExpression( + alter_seq = AlterSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "orphan_seq"), owned_by="", # Empty string triggers OWNED BY NONE @@ -239,7 +239,7 @@ def test_alter_sequence_owned_by_none(self, dummy_dialect: DummyDialect): def test_alter_sequence_multiple_options(self, dummy_dialect: DummyDialect): """Tests ALTER SEQUENCE with multiple options.""" - alter_seq = AlterSequenceExpression( + alter_seq = AlterSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "complex_seq"), restart=500, increment=2, maxvalue=10000, cycle=True, cache=25 ) sql, params = alter_seq.to_sql() @@ -255,10 +255,10 @@ def test_alter_sequence_multiple_options(self, dummy_dialect: DummyDialect): # region Roundtrip and edge cases def test_sequence_roundtrip(self, dummy_dialect: DummyDialect): """Tests creating and dropping a sequence.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "roundtrip_seq"), start=1) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "roundtrip_seq"), start=1) create_sql, create_params = create_seq.to_sql() - drop_seq = DropSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "roundtrip_seq")) + drop_seq = DropSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "roundtrip_seq")) drop_sql, drop_params = drop_seq.to_sql() assert 'CREATE SEQUENCE "roundtrip_seq"' in create_sql @@ -277,7 +277,7 @@ def test_sequence_roundtrip(self, dummy_dialect: DummyDialect): ) def test_create_sequence_various_names(self, dummy_dialect: DummyDialect, sequence_name, expected_identifier): """Tests CREATE SEQUENCE with various name formats.""" - create_seq = CreateSequenceExpression( + create_seq = CreateSequenceStatement( dummy_dialect, Sequence(dummy_dialect, sequence_name) ) sql, params = create_seq.to_sql() @@ -287,7 +287,7 @@ def test_create_sequence_various_names(self, dummy_dialect: DummyDialect, sequen def test_sequence_with_special_characters(self, dummy_dialect: DummyDialect): """Tests sequence names with special characters.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, 'seq"name')) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, 'seq"name')) sql, params = create_seq.to_sql() assert sql.startswith('CREATE SEQUENCE "seq""name"') @@ -295,7 +295,7 @@ def test_sequence_with_special_characters(self, dummy_dialect: DummyDialect): def test_sequence_with_unicode(self, dummy_dialect: DummyDialect): """Tests sequence names with unicode characters.""" - create_seq = CreateSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "序列")) + create_seq = CreateSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "序列")) sql, params = create_seq.to_sql() assert 'CREATE SEQUENCE "序列"' in sql @@ -306,25 +306,25 @@ class TestAlterSequenceCapabilityGating: """ALTER SEQUENCE options must honour their capability bits.""" def test_cycle_gated(self, dummy_dialect: DummyDialect): - expr = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), cycle=True) + expr = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), cycle=True) with patch.object(type(dummy_dialect), "supports_sequence_cycle", return_value=False): with pytest.raises(UnsupportedFeatureError, match="CYCLE"): expr.to_sql() def test_cache_gated(self, dummy_dialect: DummyDialect): - expr = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), cache=10) + expr = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), cache=10) with patch.object(type(dummy_dialect), "supports_sequence_cache", return_value=False): with pytest.raises(UnsupportedFeatureError, match="CACHE"): expr.to_sql() def test_order_gated(self, dummy_dialect: DummyDialect): - expr = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), order=True) + expr = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), order=True) with patch.object(type(dummy_dialect), "supports_sequence_order", return_value=False): with pytest.raises(UnsupportedFeatureError, match="ORDER"): expr.to_sql() def test_owned_by_gated(self, dummy_dialect: DummyDialect): - expr = AlterSequenceExpression(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), owned_by="t.id") + expr = AlterSequenceStatement(dummy_dialect, sequence=Sequence(dummy_dialect, "s"), owned_by="t.id") with patch.object(type(dummy_dialect), "supports_sequence_owned_by", return_value=False): with pytest.raises(UnsupportedFeatureError, match="OWNED BY"): expr.to_sql() @@ -342,15 +342,15 @@ class TestSequenceMasterSwitch: "expr_factory", [ pytest.param( - lambda d: CreateSequenceExpression(d, sequence=Sequence(d, "s")), + lambda d: CreateSequenceStatement(d, sequence=Sequence(d, "s")), id="create", ), pytest.param( - lambda d: DropSequenceExpression(d, sequence=Sequence(d, "s")), + lambda d: DropSequenceStatement(d, sequence=Sequence(d, "s")), id="drop", ), pytest.param( - lambda d: AlterSequenceExpression(d, sequence=Sequence(d, "s"), restart=1), + lambda d: AlterSequenceStatement(d, sequence=Sequence(d, "s"), restart=1), id="alter", ), ], @@ -386,7 +386,7 @@ class TestSequenceOptionGating: ], ) def test_create_option_gated(self, dummy_dialect: DummyDialect, probe, options, fragment): - expr = CreateSequenceExpression( + expr = CreateSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "s"), **options ) with patch.object(type(dummy_dialect), probe, return_value=False): @@ -406,7 +406,7 @@ def test_create_option_gated(self, dummy_dialect: DummyDialect, probe, options, ], ) def test_alter_option_gated(self, dummy_dialect: DummyDialect, probe, options, fragment): - expr = AlterSequenceExpression( + expr = AlterSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "s"), **options ) with patch.object(type(dummy_dialect), probe, return_value=False): @@ -415,7 +415,7 @@ def test_alter_option_gated(self, dummy_dialect: DummyDialect, probe, options, f def test_unset_create_cycle_pair_renders_nothing(self, dummy_dialect: DummyDialect): """An unset pair is 'unspecified': nothing is spelled, no probe consulted.""" - expr = CreateSequenceExpression( + expr = CreateSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "s") ) with patch.object(type(dummy_dialect), "supports_sequence_cycle", return_value=False): @@ -424,7 +424,7 @@ def test_unset_create_cycle_pair_renders_nothing(self, dummy_dialect: DummyDiale def test_unset_alter_cycle_pair_renders_nothing(self, dummy_dialect: DummyDialect): """ALTER SEQUENCE has the same unset-pair behavior as CREATE.""" - expr = AlterSequenceExpression( + expr = AlterSequenceStatement( dummy_dialect, sequence=Sequence(dummy_dialect, "s") ) with patch.object(type(dummy_dialect), "supports_sequence_cycle", return_value=False): diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_fulltext_index.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_fulltext_index.py index 74bd9e018..248086b79 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_fulltext_index.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_fulltext_index.py @@ -3,8 +3,8 @@ import pytest from rhosocial.activerecord.backend.expression.statements import ( - CreateFulltextIndexExpression, - DropFulltextIndexExpression, + CreateFulltextIndexStatement, + DropFulltextIndexStatement, ) from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Index @@ -16,7 +16,7 @@ class TestCreateFulltextIndexStatements: def test_basic_create_fulltext_index(self, dummy_dialect: DummyDialect): """Tests basic CREATE FULLTEXT INDEX statement.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_articles_content"), table=Table(dummy_dialect, "articles"), columns=["title", "content"] ) sql, params = create_ft.to_sql() @@ -31,7 +31,7 @@ def test_basic_create_fulltext_index(self, dummy_dialect: DummyDialect): def test_create_fulltext_index_single_column(self, dummy_dialect: DummyDialect): """Tests CREATE FULLTEXT INDEX with single column.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_posts_body"), table=Table(dummy_dialect, "posts"), columns=["body"] ) sql, params = create_ft.to_sql() @@ -43,7 +43,7 @@ def test_create_fulltext_index_single_column(self, dummy_dialect: DummyDialect): def test_create_fulltext_index_with_parser(self, dummy_dialect: DummyDialect): """Tests CREATE FULLTEXT INDEX with parser.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_documents_body"), table=Table(dummy_dialect, "documents"), columns=["body"], parser="ngram" ) sql, params = create_ft.to_sql() @@ -55,7 +55,7 @@ def test_create_fulltext_index_with_parser(self, dummy_dialect: DummyDialect): def test_create_fulltext_index_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE FULLTEXT INDEX IF NOT EXISTS.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_content_ft"), table=Table(dummy_dialect, "content"), columns=["text"], if_not_exists=True ) sql, params = create_ft.to_sql() @@ -66,7 +66,7 @@ def test_create_fulltext_index_if_not_exists(self, dummy_dialect: DummyDialect): def test_create_fulltext_index_multiple_columns(self, dummy_dialect: DummyDialect): """Tests CREATE FULLTEXT INDEX with multiple columns.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_search"), table=Table(dummy_dialect, "search_table"), @@ -86,7 +86,7 @@ class TestDropFulltextIndexStatements: def test_basic_drop_fulltext_index(self, dummy_dialect: DummyDialect): """Tests basic DROP FULLTEXT INDEX statement.""" - drop_ft = DropFulltextIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_articles_content"), table=Table(dummy_dialect, "articles")) + drop_ft = DropFulltextIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_articles_content"), table=Table(dummy_dialect, "articles")) sql, params = drop_ft.to_sql() assert "DROP INDEX" in sql @@ -97,7 +97,7 @@ def test_basic_drop_fulltext_index(self, dummy_dialect: DummyDialect): def test_drop_fulltext_index_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP FULLTEXT INDEX IF EXISTS.""" - drop_ft = DropFulltextIndexExpression( + drop_ft = DropFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_old_ft"), table=Table(dummy_dialect, "old_table"), if_exists=True ) sql, params = drop_ft.to_sql() @@ -109,7 +109,7 @@ def test_drop_fulltext_index_if_exists(self, dummy_dialect: DummyDialect): def test_drop_fulltext_index_without_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP FULLTEXT INDEX without IF EXISTS.""" - drop_ft = DropFulltextIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_content"), table=Table(dummy_dialect, "posts")) + drop_ft = DropFulltextIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_content"), table=Table(dummy_dialect, "posts")) sql, params = drop_ft.to_sql() assert 'DROP INDEX "idx_content"' in sql @@ -123,12 +123,12 @@ class TestFulltextIndexRoundtrip: def test_fulltext_index_roundtrip(self, dummy_dialect: DummyDialect): """Tests creating and dropping a FULLTEXT index.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_test_ft"), table=Table(dummy_dialect, "test_table"), columns=["content"] ) create_sql, create_params = create_ft.to_sql() - drop_ft = DropFulltextIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_test_ft"), table=Table(dummy_dialect, "test_table")) + drop_ft = DropFulltextIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_test_ft"), table=Table(dummy_dialect, "test_table")) drop_sql, drop_params = drop_ft.to_sql() assert 'CREATE FULLTEXT INDEX "idx_test_ft"' in create_sql @@ -147,7 +147,7 @@ def test_fulltext_index_roundtrip(self, dummy_dialect: DummyDialect): ) def test_create_fulltext_index_various_names(self, dummy_dialect: DummyDialect, index_name, expected_identifier): """Tests CREATE FULLTEXT INDEX with various index name formats.""" - create_ft = CreateFulltextIndexExpression( + create_ft = CreateFulltextIndexStatement( dummy_dialect, index=Index(dummy_dialect, index_name), table=Table(dummy_dialect, "articles"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_function.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_function.py index 1791c303a..851ffe249 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_function.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_function.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_function.py -from rhosocial.activerecord.backend.expression.statements import CreateFunctionExpression, DropFunctionExpression +from rhosocial.activerecord.backend.expression.statements import CreateFunctionStatement, DropFunctionStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Function @@ -9,7 +9,7 @@ class TestCreateFunctionStatements: def test_basic_create_function(self, dummy_dialect: DummyDialect): """Tests basic CREATE FUNCTION.""" - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "calculate_total")) + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "calculate_total")) sql, params = create_func.to_sql() assert "CREATE FUNCTION" in sql @@ -19,7 +19,7 @@ def test_basic_create_function(self, dummy_dialect: DummyDialect): def test_create_function_with_parameters(self, dummy_dialect: DummyDialect): """Tests CREATE FUNCTION with parameters.""" - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "calculate_total"), parameters=[{"name": "price", "type": "DECIMAL(10,2)"}, {"name": "quantity", "type": "INTEGER"}], @@ -34,7 +34,7 @@ def test_create_function_with_parameters(self, dummy_dialect: DummyDialect): def test_create_function_with_returns(self, dummy_dialect: DummyDialect): """Tests CREATE FUNCTION with RETURN clause.""" - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "get_user_count"), returns="INTEGER") + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "get_user_count"), returns="INTEGER") sql, params = create_func.to_sql() assert "RETURNS INTEGER" in sql @@ -42,14 +42,14 @@ def test_create_function_with_returns(self, dummy_dialect: DummyDialect): def test_create_function_with_language(self, dummy_dialect: DummyDialect): """Tests CREATE FUNCTION with LANGUAGE clause.""" - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "plpgsql_function"), language="plpgsql") + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "plpgsql_function"), language="plpgsql") sql, params = create_func.to_sql() assert "LANGUAGE plpgsql" in sql def test_create_function_with_body(self, dummy_dialect: DummyDialect): """Tests CREATE FUNCTION with body.""" - create_func = CreateFunctionExpression( + create_func = CreateFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "add_numbers"), parameters=[{"name": "a", "type": "INTEGER"}, {"name": "b", "type": "INTEGER"}], @@ -63,7 +63,7 @@ def test_create_function_with_body(self, dummy_dialect: DummyDialect): def test_create_function_or_replace(self, dummy_dialect: DummyDialect): """Tests CREATE OR REPLACE FUNCTION.""" - create_func = CreateFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "existing_function"), or_replace=True) + create_func = CreateFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "existing_function"), or_replace=True) sql, params = create_func.to_sql() assert "OR REPLACE" in sql @@ -74,7 +74,7 @@ class TestDropFunctionStatements: def test_basic_drop_function(self, dummy_dialect: DummyDialect): """Tests basic DROP FUNCTION.""" - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "old_function")) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "old_function")) sql, params = drop_func.to_sql() assert sql == 'DROP FUNCTION "old_function"' @@ -82,7 +82,7 @@ def test_basic_drop_function(self, dummy_dialect: DummyDialect): def test_drop_function_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP FUNCTION IF EXISTS.""" - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "maybe_exists"), if_exists=True) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "maybe_exists"), if_exists=True) sql, params = drop_func.to_sql() assert "IF EXISTS" in sql @@ -90,7 +90,7 @@ def test_drop_function_if_exists(self, dummy_dialect: DummyDialect): def test_drop_function_with_parameters(self, dummy_dialect: DummyDialect): """Tests DROP FUNCTION with parameter types.""" - drop_func = DropFunctionExpression( + drop_func = DropFunctionStatement( dummy_dialect, function=Function(dummy_dialect, "calculate_total"), parameters=["INTEGER", "INTEGER"] ) sql, params = drop_func.to_sql() @@ -99,7 +99,7 @@ def test_drop_function_with_parameters(self, dummy_dialect: DummyDialect): def test_drop_function_with_cascade(self, dummy_dialect: DummyDialect): """Tests DROP FUNCTION with CASCADE.""" - drop_func = DropFunctionExpression(dummy_dialect, function=Function(dummy_dialect, "dependent_function"), cascade=True) + drop_func = DropFunctionStatement(dummy_dialect, function=Function(dummy_dialect, "dependent_function"), cascade=True) sql, params = drop_func.to_sql() assert "CASCADE" in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_index.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_index.py index 2bbb227ac..d7d91588c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_index.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_index.py @@ -1,7 +1,7 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_index.py import pytest from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal -from rhosocial.activerecord.backend.expression.statements import CreateIndexExpression, DropIndexExpression +from rhosocial.activerecord.backend.expression.statements import CreateIndexStatement, DropIndexStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Index from rhosocial.activerecord.backend.expression.objects import Table @@ -12,7 +12,7 @@ class TestCreateDropIndexStatements: def test_basic_create_index(self, dummy_dialect: DummyDialect): """Tests basic CREATE INDEX statement.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_email"), table=Table(dummy_dialect, "users"), columns=["email"] ) sql, params = create_index.to_sql() @@ -24,7 +24,7 @@ def test_basic_create_index(self, dummy_dialect: DummyDialect): def test_create_unique_index(self, dummy_dialect: DummyDialect): """Tests CREATE UNIQUE INDEX statement.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_username"), table=Table(dummy_dialect, "users"), columns=["username"], unique=True ) sql, params = create_index.to_sql() @@ -36,7 +36,7 @@ def test_create_unique_index(self, dummy_dialect: DummyDialect): def test_create_index_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE INDEX IF NOT EXISTS statement.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_email"), table=Table(dummy_dialect, "users"), columns=["email"], if_not_exists=True ) sql, params = create_index.to_sql() @@ -46,7 +46,7 @@ def test_create_index_if_not_exists(self, dummy_dialect: DummyDialect): def test_create_composite_index(self, dummy_dialect: DummyDialect): """Tests CREATE INDEX with multiple columns.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_orders_user_date"), table=Table(dummy_dialect, "orders"), columns=["user_id", "created_at"] ) sql, params = create_index.to_sql() @@ -59,7 +59,7 @@ def test_create_composite_index(self, dummy_dialect: DummyDialect): def test_create_index_with_type(self, dummy_dialect: DummyDialect): """Tests CREATE INDEX with index type.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_name_hash"), table=Table(dummy_dialect, "users"), columns=["name"], index_type="HASH" ) sql, params = create_index.to_sql() @@ -73,22 +73,22 @@ def test_create_index_with_where_clause(self, dummy_dialect: DummyDialect): dummy_dialect, "=", Column(dummy_dialect, "status"), - Literal(dummy_dialect, "active", inline_literals=True) + Literal(dummy_dialect, "active", inline=True) ) - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_active_users"), table=Table(dummy_dialect, "users"), columns=["email"], where=where_condition ) sql, params = create_index.to_sql() assert 'CREATE INDEX "idx_active_users"' in sql # DDL clauses accept no bind parameters: WHERE literals are inline - # (the CreateIndexExpression inlines its WHERE at construction). + # (the CreateIndexStatement inlines its WHERE at construction). assert 'WHERE "status" = \'active\'' in sql assert params == () def test_create_index_with_include(self, dummy_dialect: DummyDialect): """Tests CREATE INDEX with INCLUDE clause.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_email"), table=Table(dummy_dialect, "users"), columns=["email"], include=["id", "name"] ) sql, params = create_index.to_sql() @@ -98,7 +98,7 @@ def test_create_index_with_include(self, dummy_dialect: DummyDialect): def test_create_index_with_tablespace(self, dummy_dialect: DummyDialect): """Tests CREATE INDEX with TABLESPACE.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_large_table"), table=Table(dummy_dialect, "large_table"), columns=["id"], tablespace="fast_ssd" ) sql, params = create_index.to_sql() @@ -111,7 +111,7 @@ def test_create_index_with_expression_column(self, dummy_dialect: DummyDialect): from rhosocial.activerecord.backend.expression import FunctionCall lower_expr = FunctionCall(dummy_dialect, "LOWER", Column(dummy_dialect, "email")) - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_users_email_lower"), table=Table(dummy_dialect, "users"), columns=[lower_expr] ) sql, params = create_index.to_sql() @@ -121,7 +121,7 @@ def test_create_index_with_expression_column(self, dummy_dialect: DummyDialect): def test_basic_drop_index(self, dummy_dialect: DummyDialect): """Tests basic DROP INDEX statement.""" - drop_index = DropIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_users_email")) + drop_index = DropIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_users_email")) sql, params = drop_index.to_sql() assert sql == 'DROP INDEX "idx_users_email"' @@ -129,7 +129,7 @@ def test_basic_drop_index(self, dummy_dialect: DummyDialect): def test_drop_index_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP INDEX IF EXISTS statement.""" - drop_index = DropIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_old_index"), if_exists=True) + drop_index = DropIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_old_index"), if_exists=True) sql, params = drop_index.to_sql() assert sql == 'DROP INDEX IF EXISTS "idx_old_index"' @@ -137,7 +137,7 @@ def test_drop_index_if_exists(self, dummy_dialect: DummyDialect): def test_drop_index_with_table_name(self, dummy_dialect: DummyDialect): """Tests DROP INDEX with table context.""" - drop_index = DropIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_orders_status"), table=Table(dummy_dialect, "orders")) + drop_index = DropIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_orders_status"), table=Table(dummy_dialect, "orders")) sql, params = drop_index.to_sql() assert sql == 'DROP INDEX "idx_orders_status" ON "orders"' @@ -145,7 +145,7 @@ def test_drop_index_with_table_name(self, dummy_dialect: DummyDialect): def test_drop_index_if_exists_with_table_name(self, dummy_dialect: DummyDialect): """Tests DROP INDEX IF EXISTS with table context.""" - drop_index = DropIndexExpression( + drop_index = DropIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_old_index"), table=Table(dummy_dialect, "old_table"), if_exists=True ) sql, params = drop_index.to_sql() @@ -155,12 +155,12 @@ def test_drop_index_if_exists_with_table_name(self, dummy_dialect: DummyDialect) def test_index_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialect): """Tests creating an index and then dropping it.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_test"), table=Table(dummy_dialect, "test_table"), columns=["id"] ) create_sql, create_params = create_index.to_sql() - drop_index = DropIndexExpression(dummy_dialect, index=Index(dummy_dialect, "idx_test")) + drop_index = DropIndexStatement(dummy_dialect, index=Index(dummy_dialect, "idx_test")) drop_sql, drop_params = drop_index.to_sql() assert 'CREATE INDEX "idx_test"' in create_sql @@ -179,7 +179,7 @@ def test_index_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialect ) def test_create_index_various_names(self, dummy_dialect: DummyDialect, index_name, expected_identifier): """Tests CREATE INDEX with various index name formats.""" - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, index_name), table=Table(dummy_dialect, "users"), @@ -196,9 +196,9 @@ def test_create_index_all_options(self, dummy_dialect: DummyDialect): dummy_dialect, "=", Column(dummy_dialect, "active"), - Literal(dummy_dialect, True, inline_literals=True) + Literal(dummy_dialect, True, inline=True) ) - create_index = CreateIndexExpression( + create_index = CreateIndexStatement( dummy_dialect, index=Index(dummy_dialect, "idx_complex"), table=Table(dummy_dialect, "users"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_schema.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_schema.py index f34d020e4..8574615c8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_schema.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_schema.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_schema.py import pytest -from rhosocial.activerecord.backend.expression.statements import CreateSchemaExpression, DropSchemaExpression +from rhosocial.activerecord.backend.expression.statements import CreateSchemaStatement, DropSchemaStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Schema @@ -10,7 +10,7 @@ class TestCreateDropSchemaStatements: def test_basic_create_schema(self, dummy_dialect: DummyDialect): """Tests basic CREATE SCHEMA statement.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "my_schema")) + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "my_schema")) sql, params = create_schema.to_sql() assert sql == 'CREATE SCHEMA "my_schema"' @@ -18,7 +18,7 @@ def test_basic_create_schema(self, dummy_dialect: DummyDialect): def test_create_schema_with_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE SCHEMA IF NOT EXISTS statement.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "my_schema"), if_not_exists=True) + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "my_schema"), if_not_exists=True) sql, params = create_schema.to_sql() assert sql == 'CREATE SCHEMA IF NOT EXISTS "my_schema"' @@ -26,7 +26,7 @@ def test_create_schema_with_if_not_exists(self, dummy_dialect: DummyDialect): def test_create_schema_with_authorization(self, dummy_dialect: DummyDialect): """Tests CREATE SCHEMA with AUTHORIZATION clause.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "app_schema"), authorization="app_user") + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "app_schema"), authorization="app_user") sql, params = create_schema.to_sql() assert 'CREATE SCHEMA "app_schema"' in sql @@ -35,7 +35,7 @@ def test_create_schema_with_authorization(self, dummy_dialect: DummyDialect): def test_create_schema_if_not_exists_with_authorization(self, dummy_dialect: DummyDialect): """Tests CREATE SCHEMA IF NOT EXISTS with AUTHORIZATION.""" - create_schema = CreateSchemaExpression( + create_schema = CreateSchemaStatement( dummy_dialect, schema=Schema(dummy_dialect, "secure_schema"), if_not_exists=True, authorization="admin" ) sql, params = create_schema.to_sql() @@ -46,7 +46,7 @@ def test_create_schema_if_not_exists_with_authorization(self, dummy_dialect: Dum def test_basic_drop_schema(self, dummy_dialect: DummyDialect): """Tests basic DROP SCHEMA statement.""" - drop_schema = DropSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "old_schema")) + drop_schema = DropSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "old_schema")) sql, params = drop_schema.to_sql() assert sql == 'DROP SCHEMA "old_schema"' @@ -54,7 +54,7 @@ def test_basic_drop_schema(self, dummy_dialect: DummyDialect): def test_drop_schema_with_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP SCHEMA IF EXISTS statement.""" - drop_schema = DropSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "test_schema"), if_exists=True) + drop_schema = DropSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "test_schema"), if_exists=True) sql, params = drop_schema.to_sql() assert sql == 'DROP SCHEMA IF EXISTS "test_schema"' @@ -62,7 +62,7 @@ def test_drop_schema_with_if_exists(self, dummy_dialect: DummyDialect): def test_drop_schema_with_cascade(self, dummy_dialect: DummyDialect): """Tests DROP SCHEMA CASCADE statement.""" - drop_schema = DropSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "legacy_schema"), cascade=True) + drop_schema = DropSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "legacy_schema"), cascade=True) sql, params = drop_schema.to_sql() assert sql == 'DROP SCHEMA "legacy_schema" CASCADE' @@ -70,7 +70,7 @@ def test_drop_schema_with_cascade(self, dummy_dialect: DummyDialect): def test_drop_schema_if_exists_with_cascade(self, dummy_dialect: DummyDialect): """Tests DROP SCHEMA IF EXISTS CASCADE statement.""" - drop_schema = DropSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "deprecated_schema"), if_exists=True, cascade=True) + drop_schema = DropSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "deprecated_schema"), if_exists=True, cascade=True) sql, params = drop_schema.to_sql() assert sql == 'DROP SCHEMA IF EXISTS "deprecated_schema" CASCADE' @@ -88,7 +88,7 @@ def test_drop_schema_if_exists_with_cascade(self, dummy_dialect: DummyDialect): ) def test_create_schema_various_names(self, dummy_dialect: DummyDialect, schema_name, expected_identifier): """Tests CREATE SCHEMA with various schema name formats.""" - create_schema = CreateSchemaExpression( + create_schema = CreateSchemaStatement( dummy_dialect, schema=Schema(dummy_dialect, schema_name) ) sql, params = create_schema.to_sql() @@ -98,10 +98,10 @@ def test_create_schema_various_names(self, dummy_dialect: DummyDialect, schema_n def test_schema_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialect): """Tests creating a schema and then dropping it.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "roundtrip_test")) + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "roundtrip_test")) create_sql, create_params = create_schema.to_sql() - drop_schema = DropSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "roundtrip_test")) + drop_schema = DropSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "roundtrip_test")) drop_sql, drop_params = drop_schema.to_sql() assert create_sql == 'CREATE SCHEMA "roundtrip_test"' @@ -111,7 +111,7 @@ def test_schema_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialec def test_schema_with_special_characters(self, dummy_dialect: DummyDialect): """Tests schema names with special characters are properly quoted.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, 'schema"name')) + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, 'schema"name')) sql, params = create_schema.to_sql() assert sql == 'CREATE SCHEMA "schema""name"' @@ -119,7 +119,7 @@ def test_schema_with_special_characters(self, dummy_dialect: DummyDialect): def test_schema_with_unicode_characters(self, dummy_dialect: DummyDialect): """Tests schema names with unicode characters.""" - create_schema = CreateSchemaExpression(dummy_dialect, schema=Schema(dummy_dialect, "用户模式")) + create_schema = CreateSchemaStatement(dummy_dialect, schema=Schema(dummy_dialect, "用户模式")) sql, params = create_schema.to_sql() assert sql == 'CREATE SCHEMA "用户模式"' diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_trigger.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_trigger.py index e35b6960f..3339244ed 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_trigger.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_trigger.py @@ -1,7 +1,7 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_trigger.py from rhosocial.activerecord.backend.expression.statements import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, TriggerTiming, TriggerEvent, TriggerLevel, @@ -16,7 +16,7 @@ class TestCreateTriggerStatements: def test_basic_create_trigger_before_insert(self, dummy_dialect: DummyDialect): """Tests basic CREATE TRIGGER BEFORE INSERT.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "before_insert_trigger"), table=Table(dummy_dialect, "users"), @@ -39,7 +39,7 @@ def test_basic_create_trigger_before_insert(self, dummy_dialect: DummyDialect): def test_create_trigger_after_update(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER AFTER UPDATE.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "after_update_trigger"), table=Table(dummy_dialect, "orders"), @@ -55,7 +55,7 @@ def test_create_trigger_after_update(self, dummy_dialect: DummyDialect): def test_create_trigger_after_delete(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER AFTER DELETE.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "after_delete_trigger"), table=Table(dummy_dialect, "logs"), @@ -70,7 +70,7 @@ def test_create_trigger_after_delete(self, dummy_dialect: DummyDialect): def test_create_trigger_instead_of(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER INSTEAD OF (for views).""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "instead_of_insert"), table=Table(dummy_dialect, "user_view"), @@ -84,7 +84,7 @@ def test_create_trigger_instead_of(self, dummy_dialect: DummyDialect): def test_create_trigger_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER IF NOT EXISTS.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "some_trigger"), table=Table(dummy_dialect, "t"), @@ -99,7 +99,7 @@ def test_create_trigger_if_not_exists(self, dummy_dialect: DummyDialect): def test_create_trigger_multiple_events(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER with multiple events.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "multi_event_trigger"), table=Table(dummy_dialect, "audit"), @@ -113,7 +113,7 @@ def test_create_trigger_multiple_events(self, dummy_dialect: DummyDialect): def test_create_trigger_update_of_columns(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER with UPDATE OF column_list.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "update_columns_trigger"), table=Table(dummy_dialect, "orders"), @@ -130,7 +130,7 @@ def test_create_trigger_update_of_columns(self, dummy_dialect: DummyDialect): def test_create_trigger_statement_level(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER with FOR EACH STATEMENT.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "statement_trigger"), table=Table(dummy_dialect, "logs"), @@ -145,7 +145,7 @@ def test_create_trigger_statement_level(self, dummy_dialect: DummyDialect): def test_create_trigger_with_referencing(self, dummy_dialect: DummyDialect): """Tests CREATE TRIGGER with REFERENCING clause.""" - create_trigger = CreateTriggerExpression( + create_trigger = CreateTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "referencing_trigger"), table=Table(dummy_dialect, "orders"), @@ -165,7 +165,7 @@ class TestDropTriggerStatements: def test_basic_drop_trigger(self, dummy_dialect: DummyDialect): """Tests basic DROP TRIGGER.""" - drop_trigger = DropTriggerExpression(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger"), table=Table(dummy_dialect, "users")) + drop_trigger = DropTriggerStatement(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger"), table=Table(dummy_dialect, "users")) sql, params = drop_trigger.to_sql() assert sql == 'DROP TRIGGER "old_trigger" ON "users"' @@ -173,7 +173,7 @@ def test_basic_drop_trigger(self, dummy_dialect: DummyDialect): def test_drop_trigger_without_table(self, dummy_dialect: DummyDialect): """Tests DROP TRIGGER without table name.""" - drop_trigger = DropTriggerExpression(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger")) + drop_trigger = DropTriggerStatement(dummy_dialect, trigger=Trigger(dummy_dialect, "old_trigger")) sql, params = drop_trigger.to_sql() assert sql == 'DROP TRIGGER "old_trigger"' @@ -181,7 +181,7 @@ def test_drop_trigger_without_table(self, dummy_dialect: DummyDialect): def test_drop_trigger_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP TRIGGER IF EXISTS.""" - drop_trigger = DropTriggerExpression( + drop_trigger = DropTriggerStatement( dummy_dialect, trigger=Trigger(dummy_dialect, "maybe_exists"), table=Table(dummy_dialect, "users"), if_exists=True ) sql, params = drop_trigger.to_sql() @@ -191,7 +191,7 @@ def test_drop_trigger_if_exists(self, dummy_dialect: DummyDialect): def test_drop_trigger_if_exists_without_table(self, dummy_dialect: DummyDialect): """Tests DROP TRIGGER IF EXISTS without table name.""" - drop_trigger = DropTriggerExpression(dummy_dialect, trigger=Trigger(dummy_dialect, "maybe_exists"), if_exists=True) + drop_trigger = DropTriggerStatement(dummy_dialect, trigger=Trigger(dummy_dialect, "maybe_exists"), if_exists=True) sql, params = drop_trigger.to_sql() assert "IF EXISTS" in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_view.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_view.py index 96618902d..c86cb8a65 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_view.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_drop_view.py @@ -5,9 +5,9 @@ Literal, ComparisonPredicate, FunctionCall, - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption @@ -27,13 +27,13 @@ class TestCreateDropViewStatements: def test_basic_create_view(self, dummy_dialect: DummyDialect): """Tests basic CREATE VIEW statement.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "user_view"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "user_view"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "user_view"' in sql @@ -42,14 +42,14 @@ def test_basic_create_view(self, dummy_dialect: DummyDialect): def test_create_or_replace_view(self, dummy_dialect: DummyDialect): """Tests CREATE OR REPLACE VIEW statement.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[FunctionCall(dummy_dialect, "COUNT", Column(dummy_dialect, "id"))], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "orders")), group_by_having=GroupByHavingClause(dummy_dialect, group_by=[Column(dummy_dialect, "user_id")]), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "order_counts"), query=query, @@ -63,7 +63,7 @@ def test_create_or_replace_view(self, dummy_dialect: DummyDialect): def test_create_view_with_column_aliases(self, dummy_dialect: DummyDialect): """Tests CREATE VIEW with column aliases.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), @@ -73,7 +73,7 @@ def test_create_view_with_column_aliases(self, dummy_dialect: DummyDialect): group_by_having=GroupByHavingClause(dummy_dialect, group_by=[Column(dummy_dialect, "user_id")]), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "user_order_summary"), query=query, @@ -91,13 +91,13 @@ def test_create_view_with_column_aliases(self, dummy_dialect: DummyDialect): def test_create_temporary_view(self, dummy_dialect: DummyDialect): """Tests CREATE TEMPORARY VIEW statement.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "session_id"), Column(dummy_dialect, "data")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "temp_sessions")), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "temp_user_session"), query=query, @@ -118,7 +118,7 @@ def test_create_temporary_view(self, dummy_dialect: DummyDialect): ) def test_create_view_with_check_options(self, dummy_dialect: DummyDialect, check_option, expected_part): """Tests CREATE VIEW with different check options.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "status")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "entities")), @@ -133,7 +133,7 @@ def test_create_view_with_check_options(self, dummy_dialect: DummyDialect, check ) options = ViewOptions(check_option=check_option) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "active_entities"), query=query, options=options) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "active_entities"), query=query, options=options) sql, params = create_view.to_sql() assert 'CREATE VIEW "active_entities"' in sql @@ -142,7 +142,7 @@ def test_create_view_with_check_options(self, dummy_dialect: DummyDialect, check def test_drop_view_basic(self, dummy_dialect: DummyDialect): """Tests basic DROP VIEW statement.""" - drop_view = DropViewExpression(dummy_dialect, view=View(dummy_dialect, "old_view")) + drop_view = DropViewStatement(dummy_dialect, view=View(dummy_dialect, "old_view")) sql, params = drop_view.to_sql() assert sql == 'DROP VIEW "old_view"' @@ -150,7 +150,7 @@ def test_drop_view_basic(self, dummy_dialect: DummyDialect): def test_drop_view_with_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP VIEW IF EXISTS statement.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "possibly_missing_view"), if_exists=True, # IF EXISTS option @@ -162,7 +162,7 @@ def test_drop_view_with_if_exists(self, dummy_dialect: DummyDialect): def test_drop_view_with_cascade(self, dummy_dialect: DummyDialect): """Tests DROP VIEW ... CASCADE statement.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "master_view"), cascade=True, # CASCADE option to drop dependent objects @@ -174,7 +174,7 @@ def test_drop_view_with_cascade(self, dummy_dialect: DummyDialect): def test_drop_view_if_exists_cascade(self, dummy_dialect: DummyDialect): """Tests DROP VIEW IF EXISTS ... CASCADE statement.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "dependent_view"), if_exists=True, # IF EXISTS @@ -187,7 +187,7 @@ def test_drop_view_if_exists_cascade(self, dummy_dialect: DummyDialect): def test_create_view_complex_aggregate(self, dummy_dialect: DummyDialect): """Tests CREATE VIEW with complex aggregate query.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "department"), @@ -202,7 +202,7 @@ def test_create_view_complex_aggregate(self, dummy_dialect: DummyDialect): ), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "dept_stats"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "dept_stats"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "dept_stats"' in sql @@ -224,13 +224,13 @@ def test_create_view_complex_aggregate(self, dummy_dialect: DummyDialect): ) def test_create_view_various_names(self, dummy_dialect: DummyDialect, view_name, expected_identifier): """Tests CREATE VIEW with various view name formats.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Literal(dummy_dialect, 1)], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "dual")), # Using dual table for scalar SELECT ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, view_name), query=query ) sql, params = create_view.to_sql() @@ -241,9 +241,9 @@ def test_create_view_various_names(self, dummy_dialect: DummyDialect, view_name, def test_create_view_scalar_query(self, dummy_dialect: DummyDialect): """Tests CREATE VIEW with a scalar query (no FROM clause).""" # Create a scalar query - just selecting a constant - query = QueryExpression(dummy_dialect, select=[Literal(dummy_dialect, 42)]) + query = QueryStatement(dummy_dialect, select=[Literal(dummy_dialect, 42)]) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "scalar_value_view"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "scalar_value_view"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "scalar_value_view"' in sql @@ -263,7 +263,7 @@ def test_create_view_with_where_clause_object(self, dummy_dialect: DummyDialect) ) ) # The where clause would be part of the query object, not as a separate parameter - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -277,7 +277,7 @@ def test_create_view_with_where_clause_object(self, dummy_dialect: DummyDialect) ), # Using overloaded operator ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "active_users"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "active_users"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "active_users"' in sql @@ -287,7 +287,7 @@ def test_create_view_with_where_clause_object(self, dummy_dialect: DummyDialect) def test_create_view_with_order_by_limit(self, dummy_dialect: DummyDialect): """Tests CREATE VIEW with ORDER BY and LIMIT clauses.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "created_at")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "recent_items")), @@ -295,7 +295,7 @@ def test_create_view_with_order_by_limit(self, dummy_dialect: DummyDialect): limit_offset=LimitOffsetClause(dummy_dialect, limit=10), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "top_recent_items"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "top_recent_items"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "top_recent_items"' in sql @@ -313,11 +313,11 @@ def test_create_view_with_order_by_limit(self, dummy_dialect: DummyDialect): ) def test_create_view_replace_option(self, dummy_dialect: DummyDialect, replace_flag): """Tests CREATE VIEW with different replace options.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "test_table")) ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "test_view"), query=query, replace=replace_flag) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "test_view"), query=query, replace=replace_flag) sql, params = create_view.to_sql() if replace_flag: @@ -330,7 +330,7 @@ def test_create_view_replace_option(self, dummy_dialect: DummyDialect, replace_f def test_drop_view_complex_name(self, dummy_dialect: DummyDialect): """Tests DROP VIEW with complex view name.""" complex_name = "schema.special-view.with.dots" - drop_view = DropViewExpression(dummy_dialect, view=View(dummy_dialect, complex_name)) + drop_view = DropViewStatement(dummy_dialect, view=View(dummy_dialect, complex_name)) sql, params = drop_view.to_sql() # The name should be properly quoted/identified @@ -358,7 +358,7 @@ def test_create_view_join_based(self, dummy_dialect: DummyDialect): join_type="INNER JOIN", ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "id", "u"), @@ -368,7 +368,7 @@ def test_create_view_join_based(self, dummy_dialect: DummyDialect): from_=join_expr, ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "user_profile_view"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "user_profile_view"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "user_profile_view"' in sql @@ -380,16 +380,16 @@ def test_create_view_join_based(self, dummy_dialect: DummyDialect): def test_view_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialect): """Tests creating a view and then dropping it.""" # First create a view - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "source_table")), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "roundtrip_test_view"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "roundtrip_test_view"), query=query) create_sql, create_params = create_view.to_sql() # Then drop the same view - drop_view = DropViewExpression(dummy_dialect, view=View(dummy_dialect, "roundtrip_test_view")) + drop_view = DropViewStatement(dummy_dialect, view=View(dummy_dialect, "roundtrip_test_view")) drop_sql, drop_params = drop_view.to_sql() # Verify both statements are correctly formed @@ -402,7 +402,7 @@ def test_view_roundtrip_creation_and_deletion(self, dummy_dialect: DummyDialect) def test_create_view_with_nested_query(self, dummy_dialect: DummyDialect): """Tests CREATE VIEW with a nested query (subquery in FROM clause).""" # Inner query - QueryExpression( + QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), @@ -413,7 +413,7 @@ def test_create_view_with_nested_query(self, dummy_dialect: DummyDialect): ) # For now, using a simple table expression to simulate subquery - simple_query = QueryExpression( + simple_query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), @@ -423,7 +423,7 @@ def test_create_view_with_nested_query(self, dummy_dialect: DummyDialect): group_by_having=GroupByHavingClause(dummy_dialect, group_by=[Column(dummy_dialect, "user_id")]), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "user_latest_order"), query=simple_query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "user_latest_order"), query=simple_query) sql, params = create_view.to_sql() assert 'CREATE VIEW "user_latest_order"' in sql @@ -446,7 +446,7 @@ def test_create_view_with_window_function(self, dummy_dialect: DummyDialect): dummy_dialect, "ROW_NUMBER", window_spec=window_spec, alias="rank_in_dept" ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "employee_name"), @@ -457,7 +457,7 @@ def test_create_view_with_window_function(self, dummy_dialect: DummyDialect): from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "employees")), ) - create_view = CreateViewExpression(dummy_dialect, view=View(dummy_dialect, "ranked_employees"), query=query) + create_view = CreateViewStatement(dummy_dialect, view=View(dummy_dialect, "ranked_employees"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "ranked_employees"' in sql @@ -473,9 +473,9 @@ def test_create_view_with_window_function(self, dummy_dialect: DummyDialect): ) def test_create_view_temporary_option(self, dummy_dialect: DummyDialect, temporary_flag): """Tests CREATE VIEW with different temporary options.""" - query = QueryExpression(dummy_dialect, select=[Literal(dummy_dialect, 1)]) + query = QueryStatement(dummy_dialect, select=[Literal(dummy_dialect, 1)]) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dummy_dialect, view=View(dummy_dialect, "temp_test_view"), query=query, temporary=temporary_flag ) sql, params = create_view.to_sql() @@ -489,7 +489,7 @@ def test_create_view_temporary_option(self, dummy_dialect: DummyDialect, tempora def test_drop_view_nonexistent_no_error(self, dummy_dialect: DummyDialect): """Tests that DROP VIEW IF EXISTS doesn't error on nonexistent views.""" - drop_view = DropViewExpression( + drop_view = DropViewStatement( dummy_dialect, view=View(dummy_dialect, "nonexistent_view"), if_exists=True, # Should not raise error if view doesn't exist diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_table.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_table.py index 050928b06..68efb2559 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_table.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_table.py @@ -1,21 +1,53 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_create_table.py import inspect -from typing import Set, Tuple import pytest -from rhosocial.activerecord.backend.dialect import SQLDialectBase -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + CreateTableStatement, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.types import CustomType +from rhosocial.activerecord.backend.expression.types import DateType, DecimalType, IntegerType, SmallIntType, TextType, TimestampType, VarCharType +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.dialect.mixins import ( + PartitionMixin, + DDLColumnMixin, + TableNameMixin, + NamespaceMixin, + TableMixin, + ExpressionMixin, + DataTypeMixin, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect import SQLDialectBase +from typing import Set, Tuple from rhosocial.activerecord.backend.expression import ( Literal, Column, ComparisonPredicate, FunctionCall, - CreateTableExpression, - CreateTableAsExpression, - CreateTableLikeExpression, - CreateTableCloneExpression, - CreateTableFromTemplateExpression, + CreateTableStatement, + CreateTableAsStatement, + CreateTableLikeStatement, + CreateTableCloneStatement, + CreateTableFromTemplateStatement, CreateTableCloneMode, CreateTableOptions, ColumnDefinition, @@ -23,33 +55,16 @@ TableCommentClause, IndexDefinition, ) -from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( TableConstraint, - TableConstraintType, ReferentialAction, ForeignKeyConstraint, ColumnConstraint, - ColumnConstraintType, PartitionClause, PartitionStrategy, - QueryExpression, + QueryStatement, StorageOptionsExpression, ) -from rhosocial.activerecord.backend.expression.query_parts import WhereClause -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.dialect.mixins import ( - PartitionMixin, - DDLColumnMixin, - TableNameMixin, - NamespaceMixin, - TableMixin, - ExpressionMixin, - DataTypeMixin, -) -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression.types import DateType, DecimalType, IntegerType, SmallIntType, TextType, TimestampType, VarCharType -from rhosocial.activerecord.backend.expression.types import CustomType class PartitionTestDialect( @@ -173,7 +188,7 @@ def test_partition_mixin_signatures_match_protocol(self, method_name: str): class TestCreateTableStatements: - """Tests for CreateTableExpression with various configurations and options.""" + """Tests for CreateTableStatement with various configurations and options.""" def test_basic_create_table(self, dummy_dialect: DummyDialect): """Tests a basic CREATE TABLE statement.""" @@ -182,15 +197,15 @@ def test_basic_create_table(self, dummy_dialect: DummyDialect): "id", IntegerType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect)), + ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect)), ], ), - ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 255), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 255), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "email", TextType(dummy_dialect)), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) sql, params = create_table_expr.to_sql() assert sql.startswith('CREATE TABLE "users"') @@ -201,9 +216,9 @@ def test_basic_create_table(self, dummy_dialect: DummyDialect): def test_create_table_with_if_not_exists(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with IF NOT EXISTS flag.""" - columns = [ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))])] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'products'), columns=columns, if_not_exists=True) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'products'), columns=columns, if_not_exists=True) sql, params = create_table_expr.to_sql() assert "CREATE TABLE IF NOT EXISTS" in sql @@ -214,12 +229,12 @@ def test_create_temporary_table(self, dummy_dialect: DummyDialect): """Tests CREATE TEMPORARY TABLE statement.""" columns = [ ColumnDefinition(dummy_dialect, - "session_id", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)] + "session_id", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))] ), ColumnDefinition(dummy_dialect, "data", TextType(dummy_dialect)), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'temp_sessions'), columns=columns, temporary=True) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'temp_sessions'), columns=columns, temporary=True) sql, params = create_table_expr.to_sql() assert "CREATE TEMPORARY TABLE" in sql @@ -229,12 +244,12 @@ def test_create_temporary_table(self, dummy_dialect: DummyDialect): def test_create_table_with_unique_constraint(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with UNIQUE column constraint.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition(dummy_dialect, "username", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.UNIQUE)]), - ColumnDefinition(dummy_dialect, "email", VarCharType(dummy_dialect, 100), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.UNIQUE)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), + ColumnDefinition(dummy_dialect, "username", VarCharType(dummy_dialect, 50), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnUniqueConstraint(dummy_dialect))]), + ColumnDefinition(dummy_dialect, "email", VarCharType(dummy_dialect, 100), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnUniqueConstraint(dummy_dialect))]), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) sql, params = create_table_expr.to_sql() assert '"username" VARCHAR(50) UNIQUE' in sql @@ -244,23 +259,23 @@ def test_create_table_with_unique_constraint(self, dummy_dialect: DummyDialect): def test_create_table_with_default_constraint(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with DEFAULT column constraints.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 100)), ColumnDefinition(dummy_dialect, "status", VarCharType(dummy_dialect, 20), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value="active")], + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, "active"))], ), ColumnDefinition(dummy_dialect, "created_at", TimestampType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value=FunctionCall(dummy_dialect, "NOW")) + ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, FunctionCall(dummy_dialect, "NOW"))) ], ), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns) sql, params = create_table_expr.to_sql() # DDL clauses accept no bind parameters: DEFAULT literal values are @@ -273,18 +288,18 @@ def test_create_table_with_check_constraint(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with CHECK column constraints.""" # Create a check predicate for age > 0 (inline literal for DDL) age_check = ComparisonPredicate( - dummy_dialect, ">", Column(dummy_dialect, "age"), Literal(dummy_dialect, 0, inline_literals=True) + dummy_dialect, ">", Column(dummy_dialect, "age"), Literal(dummy_dialect, 0, inline=True) ) columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 100)), ColumnDefinition(dummy_dialect, - "age", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.CHECK, check_condition=age_check)] + "age", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnCheckConstraint(dummy_dialect, age_check))] ), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'people'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'people'), columns=columns) sql, params = create_table_expr.to_sql() assert '"age" INTEGER CHECK ("age" > 0)' in sql @@ -293,18 +308,18 @@ def test_create_table_with_check_constraint(self, dummy_dialect: DummyDialect): def test_create_table_with_foreign_key_constraint(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with FOREIGN KEY column constraints.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "user_id", IntegerType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dummy_dialect, "users"), ["id"])) + ColumnConstraint(dummy_dialect, branch=ColumnForeignKeyConstraint(dummy_dialect, (Table(dummy_dialect, "users"), ["id"]))) ], ), ColumnDefinition(dummy_dialect, "product_name", VarCharType(dummy_dialect, 100)), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=columns) sql, params = create_table_expr.to_sql() assert '"user_id" INTEGER REFERENCES "users"("id")' in sql @@ -330,8 +345,8 @@ def test_create_table_with_table_level_constraints(self, dummy_dialect: DummyDia ] table_constraints = [ - TableConstraint(dummy_dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["id"]), - TableConstraint(dummy_dialect, constraint_type=TableConstraintType.UNIQUE, columns=["name"]), + TableConstraint(dummy_dialect, branch=TablePrimaryKeyConstraint(dummy_dialect, ["id"])), + TableConstraint(dummy_dialect, branch=TableUniqueConstraint(dummy_dialect, ["name"])), ForeignKeyConstraint(dummy_dialect, foreign_key_table=Table(dummy_dialect, "categories"), foreign_key_columns=["id"], @@ -340,7 +355,7 @@ def test_create_table_with_table_level_constraints(self, dummy_dialect: DummyDia ), ] - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'products'), columns=columns, table_constraints=table_constraints ) sql, params = create_table_expr.to_sql() @@ -353,7 +368,7 @@ def test_create_table_with_table_level_constraints(self, dummy_dialect: DummyDia def test_create_table_with_storage_options(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with storage options.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "data", TextType(dummy_dialect)), ] @@ -361,7 +376,7 @@ def test_create_table_with_storage_options(self, dummy_dialect: DummyDialect): dummy_dialect, {"engine": "InnoDB", "charset": "utf8mb4", "page_size": 8192} ) - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'documents'), columns=columns, storage_options=storage_opts ) sql, params = create_table_expr.to_sql() @@ -378,11 +393,11 @@ def test_create_table_with_storage_options(self, dummy_dialect: DummyDialect): def test_create_table_with_tablespace(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with tablespace specification.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 100)), ] - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'large_table'), columns=columns, tablespace="fast_ssd" ) sql, params = create_table_expr.to_sql() @@ -400,14 +415,14 @@ def test_create_table_as_query_result(self, dummy_dialect: DummyDialect): dummy_dialect, "=", Column(dummy_dialect, "status"), Literal(dummy_dialect, "active") ) ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), where=where_clause, ) - create_table_expr = CreateTableAsExpression( + create_table_expr = CreateTableAsStatement( dummy_dialect, table=Table(dummy_dialect, "active_users"), as_query=query ) sql, params = create_table_expr.to_sql() @@ -421,18 +436,18 @@ def test_create_table_as_query_result(self, dummy_dialect: DummyDialect): def test_create_table_as_with_no_data(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE AS ... WITH NO DATA.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Literal(dummy_dialect, 1)], ) - expr = CreateTableAsExpression(dummy_dialect, table=Table(dummy_dialect, "t"), as_query=query, no_data=True) + expr = CreateTableAsStatement(dummy_dialect, table=Table(dummy_dialect, "t"), as_query=query, no_data=True) sql, params = expr.to_sql() assert sql.endswith("WITH NO DATA") assert params == (1,) def test_create_table_like_generic_render(self, dummy_dialect: DummyDialect): """Dummy advertises LIKE, so the generic reusable renderer is exercised.""" - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dummy_dialect, table=Table(dummy_dialect, "copy"), like_table=Table(dummy_dialect, "users"), @@ -449,13 +464,13 @@ def test_create_table_like_unsupported_raises(self): dialect = SQLiteDialect() assert dialect.supports_create_table_like() is False - expr = CreateTableLikeExpression(dialect, table=Table(dialect, "copy"), like_table=Table(dialect, "users")) + expr = CreateTableLikeStatement(dialect, table=Table(dialect, "copy"), like_table=Table(dialect, "users")) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_create_table_like_carries_namespace(self, dummy_dialect: DummyDialect): """The Table object reaches the statement intact, namespace included.""" - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dummy_dialect, table=Table(dummy_dialect, "copy"), like_table=Table(dummy_dialect, "users", schema_name="sales"), @@ -476,14 +491,14 @@ def test_create_table_like_rejects_bare_string(self, dummy_dialect: DummyDialect before it checks whether the dialect supports the form at all -- a str named in place of a table is wrong regardless of the dialect. """ - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dummy_dialect, table=Table(dummy_dialect, "copy"), like_table="users" ) with pytest.raises(TypeError, match=r"like_table must be a Table, got str"): expr.to_sql() def test_create_table_clone_generic_render(self, dummy_dialect: DummyDialect): - expr = CreateTableCloneExpression( + expr = CreateTableCloneStatement( dummy_dialect, table=Table(dummy_dialect, "clone_t"), source_table=Table(dummy_dialect, "src"), @@ -499,12 +514,12 @@ def test_create_table_clone_unsupported_raises(self): dialect = SQLiteDialect() assert dialect.supports_create_table_clone() is False - expr = CreateTableCloneExpression(dialect, table=Table(dialect, "clone_t"), source_table=Table(dialect, "src")) + expr = CreateTableCloneStatement(dialect, table=Table(dialect, "clone_t"), source_table=Table(dialect, "src")) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_create_table_clone_mode_selects_copy(self, dummy_dialect: DummyDialect): - expr = CreateTableCloneExpression( + expr = CreateTableCloneStatement( dummy_dialect, table=Table(dummy_dialect, "copy_t"), source_table=Table(dummy_dialect, "src"), @@ -515,8 +530,8 @@ def test_create_table_clone_mode_selects_copy(self, dummy_dialect: DummyDialect) assert "CLONE" not in sql def test_create_table_from_template_generic_render(self, dummy_dialect: DummyDialect): - query = QueryExpression(dummy_dialect, select=[Literal(dummy_dialect, 1)]) - expr = CreateTableFromTemplateExpression(dummy_dialect, table=Table(dummy_dialect, "t"), template=query) + query = QueryStatement(dummy_dialect, select=[Literal(dummy_dialect, 1)]) + expr = CreateTableFromTemplateStatement(dummy_dialect, table=Table(dummy_dialect, "t"), template=query) sql, params = expr.to_sql() assert "USING TEMPLATE" in sql assert params == (1,) @@ -526,29 +541,19 @@ def test_create_table_from_template_unsupported_raises(self): dialect = SQLiteDialect() assert dialect.supports_create_table_using_template() is False - query = QueryExpression(dialect, select=[Literal(dialect, 1)]) - expr = CreateTableFromTemplateExpression(dialect, table=Table(dialect, "t"), template=query) + query = QueryStatement(dialect, select=[Literal(dialect, 1)]) + expr = CreateTableFromTemplateStatement(dialect, table=Table(dialect, "t"), template=query) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_create_table_clone_mode_requires_type(self, dummy_dialect: DummyDialect): with pytest.raises(TypeError): - CreateTableCloneExpression( + CreateTableCloneStatement( dummy_dialect, table=Table(dummy_dialect, "t"), source_table=Table(dummy_dialect, "src"), mode="CLONE" ) def test_create_table_options_or_replace(self, dummy_dialect: DummyDialect): - expr = CreateTableExpression( - dummy_dialect, - table=Table(dummy_dialect, 't'), - columns=[], - table_options=CreateTableOptions(dummy_dialect, or_replace=True), - ) - sql, _ = expr.to_sql() - assert sql.startswith("CREATE OR REPLACE TABLE") - - def test_create_table_options_or_replace(self, dummy_dialect: DummyDialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 't'), columns=[], @@ -585,7 +590,7 @@ def test_create_table_options_gated(self): dialect = SQLiteDialect() assert dialect.supports_create_or_replace_table() is False - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, 't'), columns=[], @@ -597,7 +602,7 @@ def test_create_table_options_gated(self): def test_create_table_with_indexes(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with indexes.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, "email", VarCharType(dummy_dialect, 100)), ColumnDefinition(dummy_dialect, "created_at", TimestampType(dummy_dialect)), ] @@ -607,7 +612,7 @@ def test_create_table_with_indexes(self, dummy_dialect: DummyDialect): IndexDefinition(dummy_dialect, "idx_users_created", ["created_at"], unique=False), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns, indexes=indexes) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), columns=columns, indexes=indexes) # Inline indexes are a dialect convenience (MySQL/MariaDB/ClickHouse); # the SQL-standard behavior is a standalone CREATE INDEX, so the generic @@ -618,17 +623,17 @@ def test_create_table_with_indexes(self, dummy_dialect: DummyDialect): def test_create_table_with_nullable_setting(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with nullable settings.""" columns = [ - ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))]), ColumnDefinition(dummy_dialect, - "name", VarCharType(dummy_dialect, 100), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL)] + "name", VarCharType(dummy_dialect, 100), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect))] ), # Explicitly NOT NULL using constraint ColumnDefinition(dummy_dialect, - "description", TextType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.NULL)] + "description", TextType(dummy_dialect), constraints=[ColumnConstraint(dummy_dialect, branch=ColumnNullableConstraint(dummy_dialect))] ), # Explicitly allow NULLs using constraint ColumnDefinition(dummy_dialect, "age", IntegerType(dummy_dialect)), # No constraints - uses database default ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'profiles'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'profiles'), columns=columns) sql, params = create_table_expr.to_sql() assert '"profiles"' in sql @@ -642,13 +647,13 @@ def test_create_table_with_comment(self, dummy_dialect: DummyDialect): ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect))], comment=ColumnCommentClause(dummy_dialect, "Primary identifier"), ), ColumnDefinition(dummy_dialect, "name", VarCharType(dummy_dialect, 100), comment=ColumnCommentClause(dummy_dialect, "User's display name")), ] - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users_with_comments'), columns=columns) + create_table_expr = CreateTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users_with_comments'), columns=columns) sql, params = create_table_expr.to_sql() assert '"users_with_comments"' in sql @@ -663,7 +668,7 @@ def test_create_table_partition_unsupported_by_dummy(self, dummy_dialect: DummyD ColumnDefinition(dummy_dialect, "created_date", DateType(dummy_dialect)), ] - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'log_entries'), columns=columns, @@ -678,14 +683,14 @@ def test_create_table_partition_unsupported_by_dummy(self, dummy_dialect: DummyD create_table_expr.to_sql() def test_create_table_partition_success_path_uses_partition_protocol(self): - """Tests CreateTableExpression appends PartitionClause SQL and params.""" + """Tests CreateTableStatement appends PartitionClause SQL and params.""" dialect = PartitionTestDialect() partition = PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, keys=[FunctionCall(dialect, "bucket", Literal(dialect, "tenant"))], ) - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dialect, table=Table(dialect, 'events'), columns=[ColumnDefinition(dialect, "tenant", TextType(dialect))], @@ -712,7 +717,7 @@ def test_create_table_partition_requires_partition_clause(self, dummy_dialect: D columns = [ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect))] with pytest.raises(TypeError, match="partition must be a PartitionClause"): - CreateTableExpression( + CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'invalid_partition'), columns=columns, @@ -754,8 +759,12 @@ def test_partition_clause_rejects_malicious_method_without_logging_value(self, d ) message = str(exc_info.value) + # The message names the type and the vocabulary's own words, never the + # value that was passed in: an error can reach a log, and a log is not + # the place a caller-supplied string should be interpolated into. assert "method must be a PartitionStrategy" in message assert malicious_method not in message + assert "RANGE" in message # the members, which we do control def test_partition_clause_requires_keys(self, dummy_dialect: DummyDialect): """Tests PartitionClause requires at least one key expression.""" @@ -815,7 +824,7 @@ def test_create_table_with_inherits(self, dummy_dialect: DummyDialect): """Tests CREATE TABLE with INHERITS clause (PostgreSQL specific).""" columns = [ColumnDefinition(dummy_dialect, "id", IntegerType(dummy_dialect)), ColumnDefinition(dummy_dialect, "extra_field", VarCharType(dummy_dialect, 50))] - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'child_table'), columns=columns, inherits=["parent_table", "audit_table"] ) sql, params = create_table_expr.to_sql() @@ -830,17 +839,17 @@ def test_create_table_complex_example(self, dummy_dialect: DummyDialect): columns = [ ColumnDefinition(dummy_dialect, "id", - CustomType(dummy_dialect, "SERIAL"), + CustomType(dummy_dialect, raw="SERIAL"), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dummy_dialect, branch=ColumnPrimaryKeyConstraint(dummy_dialect)), + ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect)), ], ), ColumnDefinition(dummy_dialect, "user_id", IntegerType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dummy_dialect, "users"), ["id"])) + ColumnConstraint(dummy_dialect, branch=ColumnForeignKeyConstraint(dummy_dialect, (Table(dummy_dialect, "users"), ["id"]))) ], comment=ColumnCommentClause(dummy_dialect, "Reference to users table"), ), @@ -848,40 +857,37 @@ def test_create_table_complex_example(self, dummy_dialect: DummyDialect): "amount", DecimalType(dummy_dialect, precision=10, scale=2), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dummy_dialect, - ColumnConstraintType.CHECK, - check_condition=ComparisonPredicate( + ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect)), + ColumnConstraint(dummy_dialect, branch=ColumnCheckConstraint(dummy_dialect, ComparisonPredicate( dummy_dialect, ">=", Column(dummy_dialect, "amount"), - Literal(dummy_dialect, 0, inline_literals=True), - ), - ), + Literal(dummy_dialect, 0, inline=True), + ))), ], ), ColumnDefinition(dummy_dialect, "status", VarCharType(dummy_dialect, 20), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value="pending"), - ColumnConstraint(dummy_dialect, ColumnConstraintType.NOT_NULL), # Use constraint instead of nullable flag + ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, "pending")), + ColumnConstraint(dummy_dialect, branch=ColumnNotNullConstraint(dummy_dialect)), # Use constraint instead of nullable flag ], ), ColumnDefinition(dummy_dialect, "created_at", TimestampType(dummy_dialect), constraints=[ - ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT, default_value=FunctionCall(dummy_dialect, "NOW")) + ColumnConstraint(dummy_dialect, branch=ColumnDefaultConstraint(dummy_dialect, FunctionCall(dummy_dialect, "NOW"))) ], ), ] table_constraints = [ - TableConstraint(dummy_dialect, constraint_type=TableConstraintType.UNIQUE, columns=["user_id", "created_at"]) + TableConstraint(dummy_dialect, branch=TableUniqueConstraint(dummy_dialect, ["user_id", "created_at"])) ] - create_table_expr = CreateTableExpression( + create_table_expr = CreateTableStatement( dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=columns, @@ -901,189 +907,3 @@ def test_create_table_complex_example(self, dummy_dialect: DummyDialect): assert 'UNIQUE ("user_id", "created_at")' in sql assert params == () - def test_create_table_with_default_constraint_missing_value_raises_error(self, dummy_dialect: DummyDialect): - """Tests that CREATE TABLE with DEFAULT constraint but no value raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - - columns = [ - ColumnDefinition(dummy_dialect, - "status", - VarCharType(dummy_dialect, 20), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.DEFAULT)], # No default value provided - ) - ] - - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) - - with pytest.raises(ValueError, match=r"DEFAULT constraint must have a default value specified."): - create_table_expr.to_sql() - - def test_create_table_with_check_constraint_missing_condition_raises_error(self, dummy_dialect: DummyDialect): - """Tests that CREATE TABLE with CHECK constraint but no condition raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - - columns = [ - ColumnDefinition(dummy_dialect, - "age", - IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.CHECK)], # No check condition provided - ) - ] - - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=columns) - - with pytest.raises(ValueError, match=r"CHECK constraint must have a check condition specified."): - create_table_expr.to_sql() - - def test_create_table_with_foreign_key_constraint_missing_reference_raises_error(self, dummy_dialect: DummyDialect): - """Tests that CREATE TABLE with FOREIGN KEY constraint but no reference raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - - columns = [ - ColumnDefinition(dummy_dialect, - "user_id", - IntegerType(dummy_dialect), - constraints=[ColumnConstraint(dummy_dialect, ColumnConstraintType.FOREIGN_KEY)], # No foreign key reference provided - ) - ] - - create_table_expr = CreateTableExpression(dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=columns) - - with pytest.raises(ValueError, match=r"FOREIGN KEY constraint must have a foreign key reference specified."): - create_table_expr.to_sql() - - def test_create_table_with_primary_key_table_constraint_missing_columns_raises_error( - self, dummy_dialect: DummyDialect - ): - """Tests that CREATE TABLE with PRIMARY KEY table constraint but no columns raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - # Missing columns parameter - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises(ValueError, match=r"PRIMARY KEY constraint must have at least one column specified."): - create_table_expr.to_sql() - - def test_create_table_with_unique_table_constraint_missing_columns_raises_error(self, dummy_dialect: DummyDialect): - """Tests that CREATE TABLE with UNIQUE table constraint but no columns raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.UNIQUE, - # Missing columns parameter - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises(ValueError, match=r"UNIQUE constraint must have at least one column specified."): - create_table_expr.to_sql() - - def test_create_table_with_check_table_constraint_missing_condition_raises_error(self, dummy_dialect: DummyDialect): - """Tests that CREATE TABLE with CHECK table constraint but no condition raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.CHECK, - # Missing check_condition parameter - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'test_table'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises(ValueError, match=r"CHECK constraint must have a check condition specified."): - create_table_expr.to_sql() - - def test_create_table_with_foreign_key_table_constraint_missing_local_columns_raises_error( - self, dummy_dialect: DummyDialect - ): - """Tests that CREATE TABLE with FOREIGN KEY table constraint but no local columns raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - foreign_key_table=Table(dummy_dialect, "users"), - foreign_key_columns=["id"], - # Missing local columns (columns parameter) - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises(ValueError, match=r"FOREIGN KEY constraint must have at least one local column specified."): - create_table_expr.to_sql() - - def test_create_table_with_foreign_key_table_constraint_missing_foreign_columns_raises_error( - self, dummy_dialect: DummyDialect - ): - """Tests that CREATE TABLE with FOREIGN KEY table constraint but no foreign columns raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - columns=["user_id"], - foreign_key_table=Table(dummy_dialect, "users"), - # Missing foreign_key_columns parameter - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises( - ValueError, match=r"FOREIGN KEY constraint must have at least one foreign key column specified." - ): - create_table_expr.to_sql() - - def test_create_table_with_foreign_key_table_constraint_missing_foreign_table_raises_error( - self, dummy_dialect: DummyDialect - ): - """Tests that CREATE TABLE with FOREIGN KEY table constraint but no foreign table raises ValueError.""" - from rhosocial.activerecord.backend.expression.statements import TableConstraint, TableConstraintType - - table_constraints = [ - TableConstraint(dummy_dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - columns=["user_id"], - foreign_key_columns=["id"], - # Missing foreign_key_table parameter - ) - ] - - create_table_expr = CreateTableExpression( - dummy_dialect, table=Table(dummy_dialect, 'orders'), columns=[], table_constraints=table_constraints - ) - - with pytest.raises(ValueError, match=r"FOREIGN KEY constraint must have a foreign key table specified."): - create_table_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_delete.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_delete.py index 8d4bc0ccb..fe42aec44 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_delete.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_delete.py @@ -1,11 +1,24 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_delete.py import pytest +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression import bases +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression import ( + BetweenPredicate, + InPredicate, + IsNullPredicate, + LikePredicate, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, - QueryExpression, - DeleteExpression, + RawSQL, + QueryStatement, + DeleteStatement, JoinClause, LogicalPredicate, NumericColumn, @@ -13,17 +26,6 @@ ComparisonPredicate, StringColumn, ) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression import ( - InPredicate, - IsNullPredicate, - BetweenPredicate, - LikePredicate, -) -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.expression import bases # For set_dialect_recursive -from rhosocial.activerecord.backend.expression.types import VarCharType -from rhosocial.activerecord.backend.expression.objects import Table class TestDeleteStatements: @@ -54,19 +56,21 @@ def set_dialect_recursive(self, expr, dialect): self.set_dialect_recursive(expr.left, dialect) if hasattr(expr, "right"): self.set_dialect_recursive(expr.right, dialect) - if hasattr(expr, "expr"): # For predicates + if hasattr(expr, "expr"): # For predicates that still name it `expr` self.set_dialect_recursive(expr.expr, dialect) + if hasattr(expr, "operand"): # For predicates (is_null / in / between) + self.set_dialect_recursive(expr.operand, dialect) if hasattr(expr, "value") and isinstance( expr.value, bases.BaseExpression ): # For Literal, if value is an expression self.set_dialect_recursive(expr.value, dialect) - if hasattr(expr, "subquery"): # For QueryExpression in from_ or assignments + if hasattr(expr, "subquery"): # For QueryStatement in from_ or assignments self.set_dialect_recursive(expr.subquery, dialect) if isinstance(expr, NamedRelationRef): # A row source renders through the object it points at, # and that object needs the dialect as much as the source. self.set_dialect_recursive(expr.relation, dialect) - if isinstance(expr, QueryExpression): # For QueryExpression itself + if isinstance(expr, QueryStatement): # For QueryStatement itself for s_elem in expr.select: self.set_dialect_recursive(s_elem, dialect) if expr.from_: @@ -84,8 +88,8 @@ def set_dialect_recursive(self, expr, dialect): self.set_dialect_recursive(expr.right_table, dialect) self.set_dialect_recursive(expr.condition, dialect) - if isinstance(expr, LogicalPredicate): # LogicalPredicate has 'predicates' - for pred in expr.predicates: + if isinstance(expr, LogicalPredicate): # LogicalPredicate has 'operands' + for pred in expr.operands: self.set_dialect_recursive(pred, dialect) # Handle specific expression types that have left/right attributes @@ -163,11 +167,11 @@ def set_dialect_recursive(self, expr, dialect): ), pytest.param( Table(None, "products"), - QueryExpression( + QueryStatement( None, select=[Column(None, "id")], from_="archived_products", - where=ComparisonPredicate(None, "<", Column(None, "deleted_at"), RawSQLExpression(None, "NOW()")), + where=ComparisonPredicate(None, "<", Column(None, "deleted_at"), RawSQL(None, "NOW()")), ), ComparisonPredicate(None, "=", Column(None, "id", "products"), Column(None, "id")), None, @@ -251,7 +255,7 @@ def test_delete_expression_combinations( self.set_dialect_recursive(item, dummy_dialect) dialect_returning_param = ReturningClause(dummy_dialect, expressions=returning_param) - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables=dialect_table_param, using=dialect_using_param, @@ -263,16 +267,16 @@ def test_delete_expression_combinations( assert params == expected_params def test_delete_from_unsupported_source_type_raises_type_error(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises TypeError for unsupported FROM source types.""" + """Tests that DeleteStatement raises TypeError for unsupported FROM source types.""" unsupported_source = 123 # An integer, not a string or BaseExpression where = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)) with pytest.raises( TypeError, - match=r"using must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, QueryExpression, got ", # noqa: E501 + match=r"using must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, QueryStatement, got ", # noqa: E501 ): - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'users'), using=unsupported_source, where=where) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'users'), using=unsupported_source, where=where) delete_expr.to_sql() # --- Additional tests from the original test_delete_statements.py --- @@ -289,7 +293,9 @@ def test_delete_from_unsupported_source_type_raises_type_error(self, dummy_diale ( "users", ComparisonPredicate(None, "!=", Column(None, "status"), Literal(None, "active")), # Mock - 'DELETE FROM "users" WHERE "status" != ?', + # `!=` and `<>` are one operation; the formatter writes the + # standard spelling. See ComparisonOperator. + 'DELETE FROM "users" WHERE "status" <> ?', ("active",), ), ( @@ -318,7 +324,7 @@ def test_basic_delete_original( dummy_dialect, "=", Column(dummy_dialect, "category_id"), Literal(dummy_dialect, 5) ) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, table), where=where_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, table), where=where_condition) sql, params = delete_expr.to_sql() assert sql == expected_sql assert params == expected_params @@ -346,13 +352,15 @@ def test_delete_with_various_operators(self, dummy_dialect: DummyDialect, compar value_expr = Literal(dummy_dialect, right_value) where_condition = ComparisonPredicate(dummy_dialect, comparison_op, column_expr, value_expr) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'temp_records'), where=where_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'temp_records'), where=where_condition) sql, params = delete_expr.to_sql() assert 'DELETE FROM "temp_records" WHERE' in sql if comparison_op in ["IS", "IS NOT"]: assert comparison_op.replace(" ", " ") in sql # "IS NULL" or "IS NOT NULL" else: - assert comparison_op in sql + # Compare against what the operator renders, not what the caller + # passed: `!=` and `<>` are one operation, standard spelling wins. + assert ComparisonPredicate.coerce_operator(comparison_op).symbol in sql if comparison_op not in ["IS", "IS NOT"]: assert params == (right_value,) @@ -366,7 +374,7 @@ def test_delete_with_complex_where(self, dummy_dialect: DummyDialect): ) complex_condition = LogicalPredicate(dummy_dialect, "AND", condition1, condition2) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'temp_records'), where=complex_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'temp_records'), where=complex_condition) sql, params = delete_expr.to_sql() assert sql == 'DELETE FROM "temp_records" WHERE "status" = ? AND "user_id" IN (?, ?, ?)' assert params == ("cancelled", 101, 102, 103) @@ -384,7 +392,7 @@ def test_delete_with_in_condition(self, dummy_dialect: DummyDialect, values_list """Tests DELETE with IN condition for different value lists.""" in_condition = InPredicate(dummy_dialect, Column(dummy_dialect, "user_id"), Literal(dummy_dialect, values_list)) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'records'), where=in_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'records'), where=in_condition) sql, params = delete_expr.to_sql() if not values_list: # Empty list case assert "IN ()" in sql @@ -402,9 +410,9 @@ def test_delete_with_in_condition(self, dummy_dialect: DummyDialect, values_list # from_ parameter is also converted but we can still test the converted value def test_delete_expression_invalid_where_type_after_construction(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises TypeError for invalid where parameter type after construction.""" + """Tests that DeleteStatement raises TypeError for invalid where parameter type after construction.""" # Manually set an invalid type after construction to test validation - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -419,8 +427,8 @@ def test_delete_expression_invalid_where_type_after_construction(self, dummy_dia delete_expr.validate(strict=True) def test_delete_expression_invalid_using_type_after_construction(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises TypeError for invalid using parameter type.""" - delete_expr = DeleteExpression( + """Tests that DeleteStatement raises TypeError for invalid using parameter type.""" + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -433,14 +441,14 @@ def test_delete_expression_invalid_using_type_after_construction(self, dummy_dia with pytest.raises( TypeError, - match=r"using must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, QueryExpression, got ", # noqa: E501 + match=r"using must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, QueryStatement, got ", # noqa: E501 ): delete_expr.validate(strict=True) def test_delete_expression_invalid_where_type_initial(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises TypeError for invalid where parameter type (initial case).""" + """Tests that DeleteStatement raises TypeError for invalid where parameter type (initial case).""" # This tests the case where an invalid type is passed initially - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -455,8 +463,8 @@ def test_delete_expression_invalid_where_type_initial(self, dummy_dialect: Dummy delete_expr.validate(strict=True) def test_delete_expression_invalid_returning_type(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises TypeError for invalid returning parameter type.""" - delete_expr = DeleteExpression( + """Tests that DeleteStatement raises TypeError for invalid returning parameter type.""" + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -477,7 +485,7 @@ def test_delete_expression_invalid_tables_type(self, dummy_dialect: DummyDialect arrives at the formatter as a one-element list; the formatter is the party that knows which entries it can render after DELETE FROM. """ - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables="invalid_type", where=ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)), @@ -488,8 +496,8 @@ def test_delete_expression_invalid_tables_type(self, dummy_dialect: DummyDialect delete_expr.to_sql() def test_delete_expression_empty_tables_value_error(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises ValueError for empty tables parameter.""" - delete_expr = DeleteExpression( + """Tests that DeleteStatement raises ValueError for empty tables parameter.""" + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -510,7 +518,7 @@ def test_delete_expression_invalid_table_element_type(self, dummy_dialect: Dummy statement could tell the mistake apart from a correct DELETE; the formatter names the entry and where it sits. """ - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables=[Column(dummy_dialect, "invalid_table")], where=ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)), @@ -521,13 +529,13 @@ def test_delete_expression_invalid_table_element_type(self, dummy_dialect: Dummy delete_expr.to_sql() def test_delete_expression_empty_table_list_value_error(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression raises ValueError for empty table list.""" + """Tests that DeleteStatement raises ValueError for empty table list.""" with pytest.raises(ValueError, match=r"Table list cannot be empty for a DELETE statement."): - DeleteExpression(dummy_dialect, tables=[]) + DeleteStatement(dummy_dialect, tables=[]) def test_delete_expression_single_table_in_list(self, dummy_dialect: DummyDialect): - """Tests DeleteExpression with single table in list.""" - delete_expr = DeleteExpression( + """Tests DeleteStatement with single table in list.""" + delete_expr = DeleteStatement( dummy_dialect, tables=[Table(dummy_dialect, 'users')], where=ComparisonPredicate( dummy_dialect, "=", @@ -546,8 +554,8 @@ def test_delete_expression_single_table_in_list(self, dummy_dialect: DummyDialec assert params == (1,) def test_delete_expression_multiple_tables_in_list(self, dummy_dialect: DummyDialect): - """Tests DeleteExpression with multiple tables in list.""" - delete_expr = DeleteExpression( + """Tests DeleteStatement with multiple tables in list.""" + delete_expr = DeleteStatement( dummy_dialect, tables=[Table(dummy_dialect, 'users'), Table(dummy_dialect, 'profiles')], where=ComparisonPredicate( @@ -573,7 +581,7 @@ def test_delete_expression_keeps_the_objects_it_was_given(self, dummy_dialect: D users = Table(dummy_dialect, "users") orders = Table(dummy_dialect, "orders") - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dummy_dialect, tables=[users, orders], where=ComparisonPredicate( @@ -592,8 +600,8 @@ def test_delete_expression_keeps_the_objects_it_was_given(self, dummy_dialect: D assert params == (123,) def test_delete_expression_validate_with_strict_false(self, dummy_dialect: DummyDialect): - """Tests that DeleteExpression.validate with strict=False skips validation.""" - delete_expr = DeleteExpression( + """Tests that DeleteStatement.validate with strict=False skips validation.""" + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=ComparisonPredicate( dummy_dialect, "=", @@ -608,7 +616,7 @@ def test_delete_expression_validate_with_strict_false(self, dummy_dialect: Dummy delete_expr.validate(strict=False) # Should not raise any exception # Also test with valid parameters and strict=False - delete_expr_valid = DeleteExpression( + delete_expr_valid = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'products'), where=ComparisonPredicate( dummy_dialect, "=", @@ -637,7 +645,7 @@ def test_delete_with_like_condition(self, dummy_dialect: DummyDialect, op, patte else: raise ValueError(f"Unsupported LIKE operation: {op}") - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=like_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=like_condition) sql, params = delete_expr.to_sql() expected_sql = f'DELETE FROM "users" WHERE {expected_sql_part}' @@ -654,7 +662,7 @@ def test_delete_with_combined_like_and_other_conditions(self, dummy_dialect: Dum age_condition = age_col > Literal(dummy_dialect, 18) combined_condition = like_condition & age_condition - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=combined_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=combined_condition) sql, params = delete_expr.to_sql() assert 'DELETE FROM "users" WHERE' in sql @@ -671,7 +679,7 @@ def test_delete_with_range_condition(self, dummy_dialect: DummyDialect): Literal(dummy_dialect, "2023-12-31"), ) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'old_logs'), where=between_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'old_logs'), where=between_condition) sql, params = delete_expr.to_sql() assert 'DELETE FROM "old_logs" WHERE "date_created" BETWEEN ? AND ?' == sql assert params == ("2023-01-01", "2023-12-31") @@ -690,7 +698,7 @@ def test_delete_with_like_condition(self, dummy_dialect: DummyDialect, table_nam dummy_dialect, "LIKE", Column(dummy_dialect, column_name), Literal(dummy_dialect, pattern) ) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, table_name), where=like_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, table_name), where=like_condition) sql, params = delete_expr.to_sql() assert f'DELETE FROM "{table_name}" WHERE "{column_name}" LIKE ?' == sql assert params == (pattern,) @@ -710,7 +718,7 @@ def test_multiple_conditional_deletes_combined(self, dummy_dialect: DummyDialect and_condition = LogicalPredicate(dummy_dialect, "AND", condition1, condition2) final_condition = LogicalPredicate(dummy_dialect, "OR", and_condition, condition3) - delete_expr = DeleteExpression(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=final_condition) + delete_expr = DeleteStatement(dummy_dialect, tables=Table(dummy_dialect, 'users'), where=final_condition) sql, params = delete_expr.to_sql() # Verify the structure contains the expected elements assert 'DELETE FROM "users" WHERE' in sql @@ -718,10 +726,10 @@ def test_multiple_conditional_deletes_combined(self, dummy_dialect: DummyDialect assert params == (65, "retired", "2023-01-01") def test_delete_expression_without_where_clause(self, dummy_dialect: DummyDialect): - """Test DeleteExpression with no WHERE clause to cover the else branch where where=None.""" + """Test DeleteStatement with no WHERE clause to cover the else branch where where=None.""" - # Create a DeleteExpression without a WHERE clause - delete_expr = DeleteExpression( + # Create a DeleteStatement without a WHERE clause + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), # No where clause provided ) @@ -732,7 +740,7 @@ def test_delete_expression_without_where_clause(self, dummy_dialect: DummyDialec assert params == () def test_delete_expression_with_where_clause_object(self, dummy_dialect: DummyDialect): - """Test DeleteExpression with a WhereClause object to cover the isinstance(where, WhereClause) branch.""" + """Test DeleteStatement with a WhereClause object to cover the isinstance(where, WhereClause) branch.""" from rhosocial.activerecord.backend.expression.query_parts import WhereClause # Create a WhereClause object using comparison operator @@ -745,8 +753,8 @@ def test_delete_expression_with_where_clause_object(self, dummy_dialect: DummyDi ) ) - # Create a DeleteExpression with the WhereClause object - delete_expr = DeleteExpression( + # Create a DeleteStatement with the WhereClause object + delete_expr = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'users'), where=where_clause_obj, # Pass WhereClause object directly @@ -787,27 +795,11 @@ def test_format_window_specification_with_no_components_raises_error(self, dummy ): dummy_dialect.format_window_specification(window_spec) - def test_format_column_definition_with_default_constraint_but_no_value_raises_error( - self, dummy_dialect: DummyDialect - ): - """Tests that format_column_definition raises ValueError when DEFAULT constraint has no value.""" - from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - ) - - # Create a ColumnDefinition with DEFAULT constraint but no value - col_def = ColumnDefinition(dummy_dialect, - name="test_col", - data_type=VarCharType(dummy_dialect, 255), - constraints=[ - ColumnConstraint(dummy_dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value=None, # No default value provided but constraint type is DEFAULT - ) - ], - ) - - with pytest.raises(ValueError, match=r"DEFAULT constraint must have a default value specified."): - dummy_dialect.format_column_definition(col_def) + """Refused where the branch is built. + + The branch is what states this constraint, so a constraint + without its payload cannot be written at all. + """ + + with pytest.raises(ValueError, match="needs a value"): + ColumnDefaultConstraint(dummy_dialect, None) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_drop_table.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_drop_table.py index 7838d279b..4a266246d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_drop_table.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_drop_table.py @@ -1,37 +1,37 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_drop_table.py import pytest -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Table class TestDropTableStatements: - """Tests for DropTableExpression with various configurations and options.""" + """Tests for DropTableStatement with various configurations and options.""" def test_basic_drop_table(self, dummy_dialect: DummyDialect): """Tests a basic DROP TABLE statement.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users')) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users')) sql, params = drop_expr.to_sql() assert sql == 'DROP TABLE "users"' assert params == () def test_drop_table_with_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP TABLE with IF EXISTS option.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'temporary_table'), if_exists=True) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'temporary_table'), if_exists=True) sql, params = drop_expr.to_sql() assert sql == 'DROP TABLE IF EXISTS "temporary_table"' assert params == () def test_drop_table_quoted_identifier(self, dummy_dialect: DummyDialect): """Tests DROP TABLE with identifier that contains special characters.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users with spaces')) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users with spaces')) sql, params = drop_expr.to_sql() assert sql == 'DROP TABLE "users with spaces"' # Should be properly quoted assert params == () def test_drop_table_with_special_chars(self, dummy_dialect: DummyDialect): """Tests DROP TABLE with table names containing quotes and other special characters.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'table"with"quotes')) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'table"with"quotes')) sql, params = drop_expr.to_sql() # The internal quotes should be doubled for SQL standard compliance assert sql == 'DROP TABLE "table""with""quotes"' @@ -51,7 +51,7 @@ def test_drop_table_with_special_chars(self, dummy_dialect: DummyDialect): ) def test_drop_table_various_table_names(self, dummy_dialect: DummyDialect, table_name, expected_sql): """Tests DROP TABLE with various table name formats.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() assert sql == expected_sql assert params == () @@ -65,7 +65,7 @@ def test_drop_table_various_table_names(self, dummy_dialect: DummyDialect, table ) def test_drop_table_with_if_exists_option(self, dummy_dialect: DummyDialect, if_exists): """Tests DROP TABLE with different IF EXISTS options.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), if_exists=if_exists) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'test_table'), if_exists=if_exists) sql, params = drop_expr.to_sql() if if_exists: @@ -98,7 +98,7 @@ def test_drop_table_special_characters_escaped(self, dummy_dialect: DummyDialect """Tests that table names with special characters are properly escaped.""" # Create a table name with double quotes table_name = 'table"name' - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() # The quote should be escaped by doubling it in standard SQL assert sql == 'DROP TABLE "table""name"' @@ -107,7 +107,7 @@ def test_drop_table_special_characters_escaped(self, dummy_dialect: DummyDialect def test_drop_table_unicode_characters(self, dummy_dialect: DummyDialect): """Tests DROP TABLE with unicode table names.""" table_name = "用户表" # Chinese characters for "user table" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() # Unicode characters should be preserved in identifiers assert 'DROP TABLE "用户表"' == sql @@ -116,7 +116,7 @@ def test_drop_table_unicode_characters(self, dummy_dialect: DummyDialect): def test_drop_table_backticks_handled_correctly(self, dummy_dialect: DummyDialect): """Tests that backticks in table name are handled appropriately.""" table_name = "table`name" # Table name with backtick (uncommon but possible) - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() # Should be handled with double quotes regardless of original backticks assert sql == 'DROP TABLE "table`name"' @@ -127,7 +127,7 @@ def test_drop_multiple_similar_table_names(self, dummy_dialect: DummyDialect): table_names = ["users", "Users", "USERS", "users_backup", "users_temp"] for table_name in table_names: - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() assert f'DROP TABLE "{table_name}"' == sql assert params == () @@ -144,14 +144,14 @@ def test_drop_table_consistent_identifier_formatting(self, dummy_dialect: DummyD ] for table_name, expected_identifier in test_cases: - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name)) sql, params = drop_expr.to_sql() assert f"DROP TABLE {expected_identifier}" == sql assert params == () def test_drop_table_properties_accessible(self, dummy_dialect: DummyDialect): - """Tests that DropTableExpression properties are correctly accessible.""" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'test_table'), if_exists=True) + """Tests that DropTableStatement properties are correctly accessible.""" + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'test_table'), if_exists=True) # Check that properties are accessible assert drop_expr.table.name == "test_table" @@ -164,9 +164,9 @@ def test_drop_table_properties_accessible(self, dummy_dialect: DummyDialect): def test_drop_table_if_exists_false_same_as_basic_drop(self, dummy_dialect: DummyDialect): """Tests that if_exists=False produces same result as basic DROP.""" - basic_drop = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users')) + basic_drop = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users')) - explicit_false_drop = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), if_exists=False) + explicit_false_drop = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), if_exists=False) basic_sql, basic_params = basic_drop.to_sql() explicit_sql, explicit_params = explicit_false_drop.to_sql() @@ -177,7 +177,7 @@ def test_drop_table_if_exists_false_same_as_basic_drop(self, dummy_dialect: Dumm def test_drop_table_with_long_name(self, dummy_dialect: DummyDialect): """Tests DROP TABLE with a very long table name.""" long_name = "very_long_table_name_that_exceeds_typical_database_identifier_limits_but_should_still_work" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, long_name)) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, long_name)) sql, params = drop_expr.to_sql() assert f'DROP TABLE "{long_name}"' == sql assert params == () @@ -185,7 +185,7 @@ def test_drop_table_with_long_name(self, dummy_dialect: DummyDialect): def test_drop_table_complex_scenario(self, dummy_dialect: DummyDialect): """Tests a complex DROP TABLE scenario with various options.""" table_name = "my_schema.users_backup_2023" - drop_expr = DropTableExpression(dummy_dialect, table=Table(dummy_dialect, table_name), if_exists=True) + drop_expr = DropTableStatement(dummy_dialect, table=Table(dummy_dialect, table_name), if_exists=True) sql, params = drop_expr.to_sql() assert sql == f'DROP TABLE IF EXISTS "{table_name}"' assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_explain.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_explain.py index 669e09260..058ae28ef 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_explain.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_explain.py @@ -4,11 +4,11 @@ ComparisonPredicate, Column, Literal, - QueryExpression, - InsertExpression, - UpdateExpression, - DeleteExpression, - ExplainExpression, + QueryStatement, + InsertStatement, + UpdateStatement, + DeleteStatement, + ExplainStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements import ExplainOptions, ExplainType, ExplainFormat @@ -24,13 +24,13 @@ class TestExplainStatements: def test_basic_explain_query(self, dummy_dialect: DummyDialect): """Tests basic EXPLAIN statement with a SELECT query.""" # Create a base query to EXPLAIN - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - explain_expr = ExplainExpression(dummy_dialect, statement=query) + explain_expr = ExplainStatement(dummy_dialect, statement=query) sql, params = explain_expr.to_sql() # Basic EXPLAIN should exist assert sql.startswith("EXPLAIN ") @@ -46,7 +46,7 @@ def test_basic_explain_query(self, dummy_dialect: DummyDialect): ) def test_explain_with_types(self, dummy_dialect: DummyDialect, explain_type, expected_prefix): """Tests EXPLAIN with different types (BASIC, ANALYZE).""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")), @@ -61,7 +61,7 @@ def test_explain_with_types(self, dummy_dialect: DummyDialect, explain_type, exp # Create EXPLAIN options options = ExplainOptions(type=explain_type) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=options) sql, params = explain_expr.to_sql() assert sql.startswith(expected_prefix) assert 'SELECT "id" FROM "products" WHERE "price" > ?' in sql @@ -77,14 +77,14 @@ def test_explain_with_types(self, dummy_dialect: DummyDialect, explain_type, exp ) def test_explain_with_formats(self, dummy_dialect: DummyDialect, format_type, expected_format_clause): """Tests EXPLAIN with different output formats.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "employees")) ) # Create EXPLAIN options with specific format options = ExplainOptions(type=ExplainType.BASIC, format=format_type) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=options) sql, params = explain_expr.to_sql() assert "EXPLAIN" in sql assert expected_format_clause in sql @@ -92,7 +92,7 @@ def test_explain_with_formats(self, dummy_dialect: DummyDialect, format_type, ex def test_explain_with_analyze_and_format(self, dummy_dialect: DummyDialect): """Tests EXPLAIN with both ANALYZE and FORMAT options.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "total")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "orders")), @@ -109,7 +109,7 @@ def test_explain_with_analyze_and_format(self, dummy_dialect: DummyDialect): # Create EXPLAIN options with ANALYZE and JSON format options = ExplainOptions(type=ExplainType.ANALYZE, format=ExplainFormat.JSON) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=options) sql, params = explain_expr.to_sql() assert sql.startswith("EXPLAIN ANALYZE") assert "FORMAT JSON" in sql @@ -127,30 +127,30 @@ def test_explain_with_analyze_and_format(self, dummy_dialect: DummyDialect): def test_explain_with_different_statements(self, dummy_dialect: DummyDialect, statement_type): """Tests EXPLAIN with different types of SQL statements.""" if statement_type == "SELECT": - stmt = QueryExpression( + stmt = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "test_table")) ) elif statement_type == "INSERT": - stmt = InsertExpression( + stmt = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'test_table'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), ) elif statement_type == "UPDATE": - stmt = UpdateExpression( + stmt = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'test_table'), assignments={"name": Literal(dummy_dialect, "updated")}, where=ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)), ) elif statement_type == "DELETE": - stmt = DeleteExpression( + stmt = DeleteStatement( dummy_dialect, tables=Table(dummy_dialect, 'test_table'), where=ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)), ) - explain_expr = ExplainExpression(dummy_dialect, statement=stmt) + explain_expr = ExplainStatement(dummy_dialect, statement=stmt) sql, params = explain_expr.to_sql() assert sql.startswith("EXPLAIN ") # Should contain the appropriate statement type @@ -169,7 +169,7 @@ def test_explain_with_different_statements(self, dummy_dialect: DummyDialect, st ) def test_explain_with_extended_options(self, dummy_dialect: DummyDialect, buffers, costs, timing, verbose): """Tests EXPLAIN with extended options like BUFFERS, COSTS, TIMING, VERBOSE.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "perf_test")) ) @@ -182,7 +182,7 @@ def test_explain_with_extended_options(self, dummy_dialect: DummyDialect, buffer analyze=timing, # Timing requires ANALYZE according to SQL standard ) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=options) sql, params = explain_expr.to_sql() assert sql.startswith("EXPLAIN ") @@ -198,21 +198,21 @@ def test_explain_with_extended_options(self, dummy_dialect: DummyDialect, buffer assert params == () def test_explain_expression_initialization(self, dummy_dialect: DummyDialect): - """Tests ExplainExpression initialization with various parameters.""" + """Tests ExplainStatement initialization with various parameters.""" from rhosocial.activerecord.backend.expression.statements import ExplainOptions - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Test initialization with no options (None) - explain_expr_none = ExplainExpression(dummy_dialect, statement=query, options=None) + explain_expr_none = ExplainStatement(dummy_dialect, statement=query, options=None) assert explain_expr_none.statement == query assert explain_expr_none.options is None # Test initialization with basic options basic_options = ExplainOptions() - explain_expr_basic = ExplainExpression(dummy_dialect, statement=query, options=basic_options) + explain_expr_basic = ExplainStatement(dummy_dialect, statement=query, options=basic_options) assert explain_expr_basic.statement == query assert explain_expr_basic.options == basic_options @@ -220,7 +220,7 @@ def test_explain_expression_initialization(self, dummy_dialect: DummyDialect): detailed_options = ExplainOptions( analyze=True, verbose=True, buffers=True, costs=False, format=ExplainFormat.JSON ) - explain_expr_detailed = ExplainExpression(dummy_dialect, statement=query, options=detailed_options) + explain_expr_detailed = ExplainStatement(dummy_dialect, statement=query, options=detailed_options) assert explain_expr_detailed.statement == query assert explain_expr_detailed.options == detailed_options assert explain_expr_detailed.options.analyze is True @@ -230,16 +230,16 @@ def test_explain_expression_initialization(self, dummy_dialect: DummyDialect): assert explain_expr_detailed.options.format == ExplainFormat.JSON def test_explain_expression_to_sql_delegation(self, dummy_dialect: DummyDialect): - """Tests that ExplainExpression.to_sql properly delegates to dialect.""" + """Tests that ExplainStatement.to_sql properly delegates to dialect.""" from rhosocial.activerecord.backend.expression.statements import ExplainOptions - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")) ) options = ExplainOptions(analyze=True, format=ExplainFormat.TEXT) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=options) # Check that to_sql properly delegates to dialect sql, params = explain_expr.to_sql() @@ -252,16 +252,16 @@ def test_explain_expression_to_sql_delegation(self, dummy_dialect: DummyDialect) def test_explain_with_settings_option(self, dummy_dialect: DummyDialect): """Tests EXPLAIN with SETTINGS option.""" - from rhosocial.activerecord.backend.expression.statements import ExplainOptions, QueryExpression + from rhosocial.activerecord.backend.expression.statements import ExplainOptions, QueryStatement from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) explain_options = ExplainOptions(settings=True) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=explain_options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=explain_options) sql, params = explain_expr.to_sql() assert "EXPLAIN" in sql @@ -270,16 +270,16 @@ def test_explain_with_settings_option(self, dummy_dialect: DummyDialect): def test_explain_with_wal_option(self, dummy_dialect: DummyDialect): """Tests EXPLAIN with WAL option.""" - from rhosocial.activerecord.backend.expression.statements import ExplainOptions, QueryExpression + from rhosocial.activerecord.backend.expression.statements import ExplainOptions, QueryStatement from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) explain_options = ExplainOptions(wal=True) - explain_expr = ExplainExpression(dummy_dialect, statement=query, options=explain_options) + explain_expr = ExplainStatement(dummy_dialect, statement=query, options=explain_options) sql, params = explain_expr.to_sql() assert "EXPLAIN" in sql diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_insert.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_insert.py index b7a6716e8..a20eab8bd 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_insert.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_insert.py @@ -3,10 +3,10 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, + RawSQL, ComparisonPredicate, - QueryExpression, - InsertExpression, + QueryStatement, + InsertStatement, ValuesSource, SelectSource, DefaultValuesSource, @@ -72,9 +72,9 @@ def test_on_conflict_clause_init_errors(self, dummy_dialect: DummyDialect, confl with pytest.raises(ValueError, match=error_message): OnConflictClause(dummy_dialect, conflict_target=["id"], **conflict_options) - # The original test for InsertExpression validation: + # The original test for InsertStatement validation: # This test (test_insert_validation_error_on_conflict_with_default_values) - # correctly triggers the first validation branch in InsertExpression.__init__: + # correctly triggers the first validation branch in InsertStatement.__init__: # "if isinstance(source, DefaultValuesSource) and (columns or on_conflict):" # The second validation branch remains unreachable with current InsertDataSource hierarchy. @@ -181,12 +181,12 @@ def test_on_conflict_clause_init_errors(self, dummy_dialect: DummyDialect, confl OnConflictClause( None, conflict_target=["p_id"], - update_assignments={"qty": RawSQLExpression(None, "excluded.qty")}, + update_assignments={"qty": RawSQL(None, "excluded.qty")}, update_where=ComparisonPredicate( None, "<", Column(None, "qty", "products"), - RawSQLExpression(None, "excluded.qty"), + RawSQL(None, "excluded.qty"), ) ), 2, @@ -303,7 +303,7 @@ def set_dialect_recursive(expr, dialect): ) # Apply recursively to the entire OnConflictClause object on_conflict_expr = on_conflict_param # Assign the updated object - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=into_param, columns=columns_param, @@ -390,14 +390,14 @@ def test_insert_with_select_source(self, dummy_dialect: DummyDialect): Literal(dummy_dialect, "active"), ) ) - select_query = QueryExpression( + select_query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name"), Column(dummy_dialect, "email")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "old_users")), where=where_clause, ) source = SelectSource(dummy_dialect, select_query=select_query) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'new_users'), columns=["name", "email"], source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'new_users'), columns=["name", "email"], source=source) sql, params = insert_expr.to_sql() expected_select = 'SELECT "name", "email" FROM "old_users" WHERE "status" = ?' assert sql == f'INSERT INTO "new_users" ("name", "email") {expected_select}' @@ -406,7 +406,7 @@ def test_insert_with_select_source(self, dummy_dialect: DummyDialect): def test_insert_with_default_values_source(self, dummy_dialect: DummyDialect): """Tests INSERT ... DEFAULT VALUES using DefaultValuesSource.""" source = DefaultValuesSource(dummy_dialect) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'settings'), source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'settings'), source=source) sql, params = insert_expr.to_sql() assert sql == 'INSERT INTO "settings" DEFAULT VALUES' assert params == () @@ -415,9 +415,9 @@ def test_insert_with_returning_clause(self, dummy_dialect: DummyDialect): """Tests an INSERT statement with a RETURNING clause.""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]) returning_clause = ReturningClause( - dummy_dialect, expressions=[Column(dummy_dialect, "id"), RawSQLExpression(dummy_dialect, "created_at")] + dummy_dialect, expressions=[Column(dummy_dialect, "id"), RawSQL(dummy_dialect, "created_at")] ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name"], source=source, returning=returning_clause ) sql, params = insert_expr.to_sql() @@ -428,7 +428,7 @@ def test_insert_on_conflict_do_nothing(self, dummy_dialect: DummyDialect): """Tests ON CONFLICT DO NOTHING for upsert.""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, 1), Literal(dummy_dialect, "test")]]) on_conflict = OnConflictClause(dummy_dialect, conflict_target=["id"], do_nothing=True) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), columns=["id", "name"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -445,10 +445,10 @@ def test_insert_on_conflict_do_update(self, dummy_dialect: DummyDialect): conflict_target=[Column(dummy_dialect, "id")], update_assignments={ "name": Column(dummy_dialect, "name", "excluded"), - "updated_at": RawSQLExpression(dummy_dialect, "CURRENT_TIMESTAMP"), + "updated_at": RawSQL(dummy_dialect, "CURRENT_TIMESTAMP"), }, ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), columns=["id", "name"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -476,7 +476,7 @@ def test_insert_on_conflict_do_update_with_where(self, dummy_dialect: DummyDiale Literal(dummy_dialect, True), ) ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), columns=["id", "name"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -491,12 +491,12 @@ def test_insert_on_conflict_do_update_with_where(self, dummy_dialect: DummyDiale def test_insert_validation_error_default_values_with_cols(self, dummy_dialect: DummyDialect): """Tests validation for using DefaultValuesSource with columns.""" with pytest.raises(ValueError, match="'DEFAULT VALUES' source cannot be used with 'columns'."): - InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'settings'), source=DefaultValuesSource(dummy_dialect), columns=["id"]) + InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'settings'), source=DefaultValuesSource(dummy_dialect), columns=["id"]) def test_insert_validation_error_on_conflict_with_invalid_source(self, dummy_dialect: DummyDialect): """Tests that 'on_conflict' raises an error with an unsupported source like DefaultValuesSource.""" with pytest.raises(ValueError, match="'on_conflict' is only supported for 'VALUES' or 'SELECT' sources."): - InsertExpression( + InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'settings'), source=DefaultValuesSource(dummy_dialect), @@ -506,7 +506,7 @@ def test_insert_validation_error_on_conflict_with_invalid_source(self, dummy_dia def test_insert_with_table_expression(self, dummy_dialect: DummyDialect): """Tests that 'into' can be a NamedRelationRef.""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["name"], source=source ) sql, params = insert_expr.to_sql() @@ -536,7 +536,7 @@ def test_insert_empty_string_values( """Tests inserting empty string values.""" values_list_expr = [[Literal(dummy_dialect, val) for val in row] for row in values_data] source = ValuesSource(dummy_dialect, values_list=values_list_expr) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'test_table'), columns=columns_param, source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'test_table'), columns=columns_param, source=source) sql, params = insert_expr.to_sql() assert sql == expected_sql assert params == expected_params @@ -569,7 +569,7 @@ def test_insert_null_values( """Tests inserting explicit NULL values.""" values_list_expr = [[Literal(dummy_dialect, val) for val in row] for row in values_data] source = ValuesSource(dummy_dialect, values_list=values_list_expr) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'test_table'), columns=columns_param, source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'test_table'), columns=columns_param, source=source) sql, params = insert_expr.to_sql() assert sql == expected_sql assert params == expected_params @@ -588,7 +588,7 @@ def test_insert_large_number_of_columns(self, dummy_dialect: DummyDialect, num_c values_list_expr = [[Literal(dummy_dialect, val) for val in values]] source = ValuesSource(dummy_dialect, values_list=values_list_expr) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'large_table'), columns=columns, source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'large_table'), columns=columns, source=source) expected_columns_sql = ", ".join([f'"{c}"' for c in columns]) expected_placeholders = ", ".join(["?" for _ in range(num_columns)]) @@ -613,7 +613,7 @@ def test_insert_very_long_string_value(self, dummy_dialect: DummyDialect, string values_list_expr = [[Literal(dummy_dialect, long_string)]] source = ValuesSource(dummy_dialect, values_list=values_list_expr) - insert_expr = InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'long_string_table'), columns=["text_col"], source=source) + insert_expr = InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'long_string_table'), columns=["text_col"], source=source) expected_sql = 'INSERT INTO "long_string_table" ("text_col") VALUES (?)' expected_params = (long_string,) @@ -635,7 +635,7 @@ def test_on_conflict_multiple_columns_target(self, dummy_dialect: DummyDialect): ], ) on_conflict = OnConflictClause(dummy_dialect, conflict_target=["col1", "col2"], do_nothing=True) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["id", "email", "name"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -653,11 +653,11 @@ def test_on_conflict_with_expression_target(self, dummy_dialect: DummyDialect): on_conflict = OnConflictClause( dummy_dialect, conflict_target=[ - RawSQLExpression(dummy_dialect, 'lower("email")') - ], # Using RawSQLExpression for the target + RawSQL(dummy_dialect, 'lower("email")') + ], # Using RawSQL for the target do_nothing=True, ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["id", "email"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -680,13 +680,13 @@ def test_on_conflict_update_multiple_columns_complex(self, dummy_dialect: DummyD conflict_target=["id"], update_assignments={ "name": Column(dummy_dialect, "name", "excluded"), # col1 = excluded.col1 - "score": RawSQLExpression( + "score": RawSQL( dummy_dialect, f"{dummy_dialect.format_identifier('users')}.{dummy_dialect.format_identifier('score')} + {dummy_dialect.format_identifier('excluded')}.{dummy_dialect.format_identifier('new_score')}", # noqa: E501 ), }, ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["id", "name", "new_score"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -725,7 +725,7 @@ def test_on_conflict_update_where_with_complex_predicate(self, dummy_dialect: Du Column(dummy_dialect, "priority", "users") )) ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), columns=["id", "status", "priority"], source=source, on_conflict=on_conflict ) sql, params = insert_expr.to_sql() @@ -752,7 +752,7 @@ def test_insert_multiple_on_conflict_clauses(self, dummy_dialect: DummyDialect): conflict_target=["name"], update_assignments={"name": Column(dummy_dialect, "name", "excluded")}, ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), columns=["id", "name"], @@ -776,7 +776,7 @@ def test_insert_multiple_on_conflict_clauses_all_do_nothing(self, dummy_dialect: OnConflictClause(dummy_dialect, conflict_target=["col_b"], do_nothing=True), OnConflictClause(dummy_dialect, conflict_target=["col_c"], do_nothing=True), ] - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 't'), columns=["val"], source=source, on_conflict=clauses ) sql, params = insert_expr.to_sql() @@ -806,7 +806,7 @@ def test_insert_multiple_on_conflict_with_where(self, dummy_dialect: DummyDialec Column(dummy_dialect, "qty", "excluded"), ) ) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), columns=["id", "qty"], @@ -827,7 +827,7 @@ def test_insert_single_on_conflict_scalar_remains_unchanged(self, dummy_dialect: """Tests that passing a single OnConflictClause still produces identical SQL (backward compat).""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, 1)]]) clause = OnConflictClause(dummy_dialect, conflict_target=["id"], do_nothing=True) - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 't'), columns=["val"], source=source, on_conflict=clause ) sql, params = insert_expr.to_sql() @@ -848,7 +848,7 @@ def test_insert_expression_invalid_into_type_after_construction(self, dummy_dial Construction succeeds: the statement keeps what it was given, and the dialect that would render it as an INTO target is the one that refuses. """ - insert_expr = InsertExpression( + insert_expr = InsertStatement( dummy_dialect, into=123, source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -857,8 +857,8 @@ def test_insert_expression_invalid_into_type_after_construction(self, dummy_dial insert_expr.to_sql() def test_insert_expression_invalid_source_type(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError for invalid source parameter type.""" - insert_expr = InsertExpression( + """Tests that InsertStatement raises TypeError for invalid source parameter type.""" + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -870,8 +870,8 @@ def test_insert_expression_invalid_source_type(self, dummy_dialect: DummyDialect insert_expr.validate(strict=True) def test_insert_expression_invalid_columns_type(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError for invalid columns parameter type.""" - insert_expr = InsertExpression( + """Tests that InsertStatement raises TypeError for invalid columns parameter type.""" + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -883,8 +883,8 @@ def test_insert_expression_invalid_columns_type(self, dummy_dialect: DummyDialec insert_expr.validate(strict=True) def test_insert_expression_invalid_on_conflict_type(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError for invalid on_conflict parameter type.""" - insert_expr = InsertExpression( + """Tests that InsertStatement raises TypeError for invalid on_conflict parameter type.""" + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -896,16 +896,16 @@ def test_insert_expression_invalid_on_conflict_type(self, dummy_dialect: DummyDi insert_expr.validate(strict=True) def test_insert_expression_invalid_on_conflict_constructor_type(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError when on_conflict is not a clause or list of clauses.""" + """Tests that InsertStatement raises TypeError when on_conflict is not a clause or list of clauses.""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]) with pytest.raises(TypeError, match=r"on_conflict must be OnConflictClause or list of OnConflictClause"): - InsertExpression(dummy_dialect, into=Table(dummy_dialect, 'users'), source=source, on_conflict=123) + InsertStatement(dummy_dialect, into=Table(dummy_dialect, 'users'), source=source, on_conflict=123) def test_insert_expression_invalid_on_conflict_list_item(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError when a list item is not an OnConflictClause.""" + """Tests that InsertStatement raises TypeError when a list item is not an OnConflictClause.""" source = ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]) with pytest.raises(TypeError, match=r"on_conflict\[1\] must be OnConflictClause, got "): - InsertExpression( + InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=source, @@ -916,8 +916,8 @@ def test_insert_expression_invalid_on_conflict_list_item(self, dummy_dialect: Du ) def test_insert_expression_invalid_returning_type(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression raises TypeError for invalid returning parameter type.""" - insert_expr = InsertExpression( + """Tests that InsertStatement raises TypeError for invalid returning parameter type.""" + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -929,8 +929,8 @@ def test_insert_expression_invalid_returning_type(self, dummy_dialect: DummyDial insert_expr.validate(strict=True) def test_insert_expression_validate_with_strict_false(self, dummy_dialect: DummyDialect): - """Tests that InsertExpression.validate with strict=False skips validation.""" - insert_expr = InsertExpression( + """Tests that InsertStatement.validate with strict=False skips validation.""" + insert_expr = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'users'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "test")]]), @@ -942,7 +942,7 @@ def test_insert_expression_validate_with_strict_false(self, dummy_dialect: Dummy insert_expr.validate(strict=False) # Should not raise any exception # Also test with valid parameters and strict=False - insert_expr_valid = InsertExpression( + insert_expr_valid = InsertStatement( dummy_dialect, into=Table(dummy_dialect, 'products'), source=ValuesSource(dummy_dialect, values_list=[[Literal(dummy_dialect, "widget")]]), diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_materialized_view.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_materialized_view.py index be179da34..f8ccbcc9d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_materialized_view.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_materialized_view.py @@ -4,10 +4,10 @@ Column, Literal, FunctionCall, - QueryExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + QueryStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause @@ -21,13 +21,13 @@ class TestCreateMaterializedViewStatements: def test_basic_create_materialized_view(self, dummy_dialect: DummyDialect): """Tests basic CREATE MATERIALIZED VIEW statement.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) - create_mv = CreateMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "user_summary"), query=query) + create_mv = CreateMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "user_summary"), query=query) sql, params = create_mv.to_sql() assert 'CREATE MATERIALIZED VIEW "user_summary"' in sql @@ -38,7 +38,7 @@ def test_basic_create_materialized_view(self, dummy_dialect: DummyDialect): def test_create_materialized_view_with_aggregates(self, dummy_dialect: DummyDialect): """Tests CREATE MATERIALIZED VIEW with aggregate query.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), @@ -48,7 +48,7 @@ def test_create_materialized_view_with_aggregates(self, dummy_dialect: DummyDial group_by_having=GroupByHavingClause(dummy_dialect, group_by=[Column(dummy_dialect, "user_id")]), ) - create_mv = CreateMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "order_summary"), query=query) + create_mv = CreateMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "order_summary"), query=query) sql, params = create_mv.to_sql() assert 'CREATE MATERIALIZED VIEW "order_summary"' in sql @@ -59,7 +59,7 @@ def test_create_materialized_view_with_aggregates(self, dummy_dialect: DummyDial def test_create_materialized_view_with_column_aliases(self, dummy_dialect: DummyDialect): """Tests CREATE MATERIALIZED VIEW with column aliases.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "user_id"), @@ -69,7 +69,7 @@ def test_create_materialized_view_with_column_aliases(self, dummy_dialect: Dummy group_by_having=GroupByHavingClause(dummy_dialect, group_by=[Column(dummy_dialect, "user_id")]), ) - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, "user_totals"), query=query, column_aliases=["user_id", "total_amount"] ) sql, params = create_mv.to_sql() @@ -80,11 +80,11 @@ def test_create_materialized_view_with_column_aliases(self, dummy_dialect: Dummy def test_create_materialized_view_with_tablespace(self, dummy_dialect: DummyDialect): """Tests CREATE MATERIALIZED VIEW with tablespace.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")) ) - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, "product_cache"), query=query, tablespace="fast_storage" ) sql, params = create_mv.to_sql() @@ -94,13 +94,13 @@ def test_create_materialized_view_with_tablespace(self, dummy_dialect: DummyDial def test_create_materialized_view_with_no_data(self, dummy_dialect: DummyDialect): """Tests CREATE MATERIALIZED VIEW WITH NO DATA.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "categories")), ) - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, "category_mv"), query=query, no_data=True ) sql, params = create_mv.to_sql() @@ -111,13 +111,13 @@ def test_create_materialized_view_with_no_data(self, dummy_dialect: DummyDialect def test_create_materialized_view_with_data_explicit(self, dummy_dialect: DummyDialect): """Tests CREATE MATERIALIZED VIEW WITH DATA (explicit).""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "categories")), ) - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, "populated_mv"), query=query, with_data=True ) sql, params = create_mv.to_sql() @@ -132,7 +132,7 @@ class TestDropMaterializedViewStatements: def test_basic_drop_materialized_view(self, dummy_dialect: DummyDialect): """Tests basic DROP MATERIALIZED VIEW statement.""" - drop_mv = DropMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "old_summary")) + drop_mv = DropMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "old_summary")) sql, params = drop_mv.to_sql() assert sql == 'DROP MATERIALIZED VIEW "old_summary"' @@ -140,7 +140,7 @@ def test_basic_drop_materialized_view(self, dummy_dialect: DummyDialect): def test_drop_materialized_view_with_if_exists(self, dummy_dialect: DummyDialect): """Tests DROP MATERIALIZED VIEW IF EXISTS statement.""" - drop_mv = DropMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "possibly_missing_mv"), if_exists=True) + drop_mv = DropMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "possibly_missing_mv"), if_exists=True) sql, params = drop_mv.to_sql() assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "possibly_missing_mv"' @@ -148,7 +148,7 @@ def test_drop_materialized_view_with_if_exists(self, dummy_dialect: DummyDialect def test_drop_materialized_view_with_cascade(self, dummy_dialect: DummyDialect): """Tests DROP MATERIALIZED VIEW ... CASCADE statement.""" - drop_mv = DropMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "parent_mv"), cascade=True) + drop_mv = DropMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "parent_mv"), cascade=True) sql, params = drop_mv.to_sql() assert sql == 'DROP MATERIALIZED VIEW "parent_mv" CASCADE' @@ -156,7 +156,7 @@ def test_drop_materialized_view_with_cascade(self, dummy_dialect: DummyDialect): def test_drop_materialized_view_if_exists_cascade(self, dummy_dialect: DummyDialect): """Tests DROP MATERIALIZED VIEW IF EXISTS ... CASCADE statement.""" - drop_mv = DropMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "dependent_mv"), if_exists=True, cascade=True) + drop_mv = DropMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "dependent_mv"), if_exists=True, cascade=True) sql, params = drop_mv.to_sql() assert sql == 'DROP MATERIALIZED VIEW IF EXISTS "dependent_mv" CASCADE' @@ -168,7 +168,7 @@ class TestRefreshMaterializedViewStatements: def test_basic_refresh_materialized_view(self, dummy_dialect: DummyDialect): """Tests basic REFRESH MATERIALIZED VIEW statement.""" - refresh_mv = RefreshMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "user_summary")) + refresh_mv = RefreshMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "user_summary")) sql, params = refresh_mv.to_sql() assert sql == 'REFRESH MATERIALIZED VIEW "user_summary"' @@ -176,7 +176,7 @@ def test_basic_refresh_materialized_view(self, dummy_dialect: DummyDialect): def test_refresh_materialized_view_concurrent(self, dummy_dialect: DummyDialect): """Tests REFRESH MATERIALIZED VIEW CONCURRENTLY statement.""" - refresh_mv = RefreshMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "product_stats"), concurrent=True) + refresh_mv = RefreshMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "product_stats"), concurrent=True) sql, params = refresh_mv.to_sql() assert 'REFRESH MATERIALIZED VIEW CONCURRENTLY "product_stats"' == sql @@ -184,7 +184,7 @@ def test_refresh_materialized_view_concurrent(self, dummy_dialect: DummyDialect) def test_refresh_materialized_view_with_data(self, dummy_dialect: DummyDialect): """Tests REFRESH MATERIALIZED VIEW WITH DATA statement.""" - refresh_mv = RefreshMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "sales_summary"), with_data=True) + refresh_mv = RefreshMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "sales_summary"), with_data=True) sql, params = refresh_mv.to_sql() assert sql == 'REFRESH MATERIALIZED VIEW "sales_summary" WITH DATA' @@ -192,7 +192,7 @@ def test_refresh_materialized_view_with_data(self, dummy_dialect: DummyDialect): def test_refresh_materialized_view_with_no_data(self, dummy_dialect: DummyDialect): """Tests REFRESH MATERIALIZED VIEW WITH NO DATA statement.""" - refresh_mv = RefreshMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "empty_view"), no_data=True) + refresh_mv = RefreshMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "empty_view"), no_data=True) sql, params = refresh_mv.to_sql() assert sql == 'REFRESH MATERIALIZED VIEW "empty_view" WITH NO DATA' @@ -200,7 +200,7 @@ def test_refresh_materialized_view_with_no_data(self, dummy_dialect: DummyDialec def test_refresh_materialized_view_concurrent_with_data(self, dummy_dialect: DummyDialect): """Tests REFRESH MATERIALIZED VIEW CONCURRENTLY WITH DATA statement.""" - refresh_mv = RefreshMaterializedViewExpression( + refresh_mv = RefreshMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, "complex_stats"), concurrent=True, with_data=True ) sql, params = refresh_mv.to_sql() @@ -214,7 +214,7 @@ class TestMaterializedViewRoundtrip: def test_materialized_view_create_drop_refresh_cycle(self, dummy_dialect: DummyDialect): """Tests creating, refreshing, and dropping a materialized view.""" - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "department"), @@ -225,15 +225,15 @@ def test_materialized_view_create_drop_refresh_cycle(self, dummy_dialect: DummyD ) # Create - create_mv = CreateMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary"), query=query) + create_mv = CreateMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary"), query=query) create_sql, create_params = create_mv.to_sql() # Refresh - refresh_mv = RefreshMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary")) + refresh_mv = RefreshMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary")) refresh_sql, refresh_params = refresh_mv.to_sql() # Drop - drop_mv = DropMaterializedViewExpression(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary")) + drop_mv = DropMaterializedViewStatement(dummy_dialect, view=MaterializedView(dummy_dialect, "dept_avg_salary")) drop_sql, drop_params = drop_mv.to_sql() assert 'CREATE MATERIALIZED VIEW "dept_avg_salary"' in create_sql @@ -254,9 +254,9 @@ def test_materialized_view_create_drop_refresh_cycle(self, dummy_dialect: DummyD ) def test_materialized_view_various_names(self, dummy_dialect: DummyDialect, view_name: str): """Tests materialized view statements with various name formats.""" - query = QueryExpression(dummy_dialect, select=[Literal(dummy_dialect, 1)]) + query = QueryStatement(dummy_dialect, select=[Literal(dummy_dialect, 1)]) - create_mv = CreateMaterializedViewExpression( + create_mv = CreateMaterializedViewStatement( dummy_dialect, view=MaterializedView(dummy_dialect, view_name), query=query, diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_merge.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_merge.py index caf875ccd..2a54a3b51 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_merge.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_merge.py @@ -3,7 +3,7 @@ Column, Literal, ComparisonPredicate, - MergeExpression, + MergeStatement, MergeAction, MergeActionType, ) @@ -43,7 +43,7 @@ def test_merge_expression_basic(self, dummy_dialect: DummyDialect): }, ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -81,7 +81,7 @@ def test_merge_with_update_action(self, dummy_dialect: DummyDialect): }, ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -115,7 +115,7 @@ def test_merge_with_insert_action(self, dummy_dialect: DummyDialect): }, ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -148,7 +148,7 @@ def test_merge_with_delete_action(self, dummy_dialect: DummyDialect): ), ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -200,7 +200,7 @@ def test_merge_with_conditions(self, dummy_dialect: DummyDialect): ), ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -242,7 +242,7 @@ def test_merge_delete_action(self, dummy_dialect: DummyDialect): ), ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_cancel, @@ -295,7 +295,7 @@ def test_merge_with_not_matched_by_source_delete(self, dummy_dialect: DummyDiale # WHEN NOT MATCHED BY SOURCE: DELETE (remove unmatched records from target) when_not_matched_by_source_delete = MergeAction(dummy_dialect, action_type=MergeActionType.DELETE) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -336,7 +336,7 @@ def test_merge_with_not_matched_by_source_update(self, dummy_dialect: DummyDiale dummy_dialect, action_type=MergeActionType.UPDATE, assignments={"status": Literal(dummy_dialect, "inactive")} ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, @@ -379,7 +379,7 @@ def test_merge_with_not_matched_by_source_and_condition(self, dummy_dialect: Dum ), ) - merge_expr = MergeExpression( + merge_expr = MergeStatement( dummy_dialect, target_table=target_table, source=source_values, diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_query.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_query.py index 1b2b01ef6..682b4e56e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_query.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_query.py @@ -6,7 +6,7 @@ Literal, FunctionCall, ComparisonPredicate, - QueryExpression, + QueryStatement, SelectModifier, NumericColumn, StringColumn, @@ -28,9 +28,9 @@ class TestQueryStatements: - """Tests for QueryExpression and related functionality.""" + """Tests for QueryStatement and related functionality.""" - # --- QueryExpression with DISTINCT/ALL modifiers --- + # --- QueryStatement with DISTINCT/ALL modifiers --- @pytest.mark.parametrize( "modifier, expected_prefix", [ @@ -40,8 +40,8 @@ class TestQueryStatements: ], ) def test_query_expression_select_modifier(self, dummy_dialect: DummyDialect, modifier, expected_prefix): - """Tests QueryExpression with DISTINCT/ALL modifiers.""" - query = QueryExpression( + """Tests QueryStatement with DISTINCT/ALL modifiers.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name"), Column(dummy_dialect, "category")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")), @@ -54,11 +54,11 @@ def test_query_expression_select_modifier(self, dummy_dialect: DummyDialect, mod # --- FOR UPDATE clause --- def test_query_expression_with_for_update_clause(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with FOR UPDATE clause.""" + """Tests QueryStatement with FOR UPDATE clause.""" for_update_clause = ForUpdateClause( dummy_dialect, of_columns=[Column(dummy_dialect, "id"), "name"], nowait=False, skip_locked=True ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -69,11 +69,11 @@ def test_query_expression_with_for_update_clause(self, dummy_dialect: DummyDiale assert params == () def test_query_expression_with_for_update_nowait(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with FOR UPDATE NOWAIT.""" + """Tests QueryStatement with FOR UPDATE NOWAIT.""" for_update_clause = ForUpdateClause( dummy_dialect, of_columns=[Column(dummy_dialect, "id")], nowait=True, skip_locked=False ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "locks")), @@ -186,7 +186,7 @@ def test_query_with_window_clause(self, dummy_dialect: DummyDialect): window_clause = WindowClause(dummy_dialect, definitions=[window_def1, window_def2]) # Create a query that would reference these named windows - QueryExpression( + QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "employee_name"), @@ -194,7 +194,7 @@ def test_query_with_window_clause(self, dummy_dialect: DummyDialect): FunctionCall(dummy_dialect, "ROW_NUMBER", window_spec="dept_ranking"), ], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "employees")), - # Note: The WindowClause would need to be integrated into QueryExpression to be fully functional + # Note: The WindowClause would need to be integrated into QueryStatement to be fully functional ) # Since we are testing the window clause itself, let's test it independently @@ -306,9 +306,9 @@ def test_window_definition_to_sql(self, dummy_dialect: DummyDialect): assert sql2 == expected2 assert params2 == () - # --- QueryExpression validation tests --- + # --- QueryStatement validation tests --- def test_query_expression_having_without_group_by_raises_error(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression with GroupByHavingClause raises ValueError when HAVING is used without GROUP BY.""" # noqa: E501 + """Tests that QueryStatement with GroupByHavingClause raises ValueError when HAVING is used without GROUP BY.""" # noqa: E501 having_condition = ComparisonPredicate( dummy_dialect, ">", @@ -316,7 +316,7 @@ def test_query_expression_having_without_group_by_raises_error(self, dummy_diale Literal(dummy_dialect, 5), ) - # The validation should happen in the GroupByHavingClause constructor itself, not in QueryExpression + # The validation should happen in the GroupByHavingClause constructor itself, not in QueryStatement with pytest.raises(ValueError, match="HAVING clause requires GROUP BY clause"): GroupByHavingClause( dummy_dialect, @@ -325,7 +325,7 @@ def test_query_expression_having_without_group_by_raises_error(self, dummy_diale ) def test_query_expression_offset_without_limit_with_support(self, dummy_dialect: DummyDialect, monkeypatch): - """Tests QueryExpression with OFFSET without LIMIT where dialect supports it.""" + """Tests QueryStatement with OFFSET without LIMIT where dialect supports it.""" # Mock the dialect to return True for supports_offset_without_limit def mock_supports_offset_without_limit(): @@ -337,7 +337,7 @@ def mock_supports_offset_without_limit(): limit_offset_clause = LimitOffsetClause(dummy_dialect, offset=10) # This should not raise an error since dialect supports offset without limit - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -348,7 +348,7 @@ def mock_supports_offset_without_limit(): assert params == (10,) def test_query_expression_offset_without_limit_without_support(self, dummy_dialect: DummyDialect, monkeypatch): - """Tests that QueryExpression raises ValueError when OFFSET is used without LIMIT and dialect doesn't support it.""" # noqa: E501 + """Tests that QueryStatement raises ValueError when OFFSET is used without LIMIT and dialect doesn't support it.""" # noqa: E501 # Mock the dialect to return False for supports_offset_without_limit def mock_supports_offset_without_limit(): @@ -362,13 +362,13 @@ def mock_supports_offset_without_limit(): LimitOffsetClause(dummy_dialect, offset=10) def test_query_expression_order_by_simple_expressions(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with ORDER BY using simple expressions without directions.""" + """Tests QueryStatement with ORDER BY using simple expressions without directions.""" # Create the ORDER BY clause object with simple expressions order_by_clause = OrderByClause( dummy_dialect, expressions=[Column(dummy_dialect, "name"), Column(dummy_dialect, "id")] ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id"), Column(dummy_dialect, "name")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -379,7 +379,7 @@ def test_query_expression_order_by_simple_expressions(self, dummy_dialect: Dummy assert params == () def test_query_expression_with_qualify_clause(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with QUALIFY clause.""" + """Tests QueryStatement with QUALIFY clause.""" # Create the qualifying condition qualify_condition = ComparisonPredicate( dummy_dialect, @@ -398,7 +398,7 @@ def test_query_expression_with_qualify_clause(self, dummy_dialect: DummyDialect) # Create QUALIFY clause qualify_clause = QualifyClause(dummy_dialect, condition=qualify_condition) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[ Column(dummy_dialect, "category"), @@ -570,7 +570,7 @@ def test_query_with_like_condition(self, dummy_dialect: DummyDialect, op, patter else: raise ValueError(f"Unsupported LIKE operation: {op}") - query = QueryExpression(dummy_dialect, select=[name_col], from_="users", where=like_condition) + query = QueryStatement(dummy_dialect, select=[name_col], from_="users", where=like_condition) sql, params = query.to_sql() # The actual SQL will be: SELECT "name" FROM "users" WHERE "name" LIKE ? @@ -588,7 +588,7 @@ def test_query_with_combined_like_and_other_conditions(self, dummy_dialect: Dumm age_condition = age_col > Literal(dummy_dialect, 18) combined_condition = like_condition & age_condition - query = QueryExpression(dummy_dialect, select=[name_col, age_col], from_="users", where=combined_condition) + query = QueryStatement(dummy_dialect, select=[name_col, age_col], from_="users", where=combined_condition) sql, params = query.to_sql() assert 'SELECT "name", "age" FROM "users" WHERE' in sql @@ -601,8 +601,8 @@ def test_query_with_combined_like_and_other_conditions(self, dummy_dialect: Dumm # so we focus on parameters that are not automatically converted def test_query_expression_invalid_select_type_after_construction(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid select parameter type after construction.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid select parameter type after construction.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], # Valid initial value from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), @@ -614,8 +614,8 @@ def test_query_expression_invalid_select_type_after_construction(self, dummy_dia query.validate(strict=True) def test_query_expression_invalid_from_type_after_construction(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid from_ parameter type after construction.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid from_ parameter type after construction.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), # Valid initial value @@ -625,13 +625,13 @@ def test_query_expression_invalid_from_type_after_construction(self, dummy_diale with pytest.raises( TypeError, - match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 + match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 ): query.validate(strict=True) def test_query_expression_invalid_where_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid where parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid where parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -641,8 +641,8 @@ def test_query_expression_invalid_where_type(self, dummy_dialect: DummyDialect): query.validate(strict=True) def test_query_expression_invalid_group_by_having_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid group_by_having parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid group_by_having parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -652,8 +652,8 @@ def test_query_expression_invalid_group_by_having_type(self, dummy_dialect: Dumm query.validate(strict=True) def test_query_expression_invalid_order_by_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid order_by parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid order_by parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -663,8 +663,8 @@ def test_query_expression_invalid_order_by_type(self, dummy_dialect: DummyDialec query.validate(strict=True) def test_query_expression_invalid_qualify_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid qualify parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid qualify parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -674,8 +674,8 @@ def test_query_expression_invalid_qualify_type(self, dummy_dialect: DummyDialect query.validate(strict=True) def test_query_expression_invalid_limit_offset_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid limit_offset parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid limit_offset parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -685,8 +685,8 @@ def test_query_expression_invalid_limit_offset_type(self, dummy_dialect: DummyDi query.validate(strict=True) def test_query_expression_invalid_for_update_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid for_update parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid for_update parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -696,8 +696,8 @@ def test_query_expression_invalid_for_update_type(self, dummy_dialect: DummyDial query.validate(strict=True) def test_query_expression_invalid_select_modifier_type(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression raises TypeError for invalid select_modifier parameter type.""" - query = QueryExpression( + """Tests that QueryStatement raises TypeError for invalid select_modifier parameter type.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type to trigger validation error @@ -707,8 +707,8 @@ def test_query_expression_invalid_select_modifier_type(self, dummy_dialect: Dumm query.validate(strict=True) def test_query_expression_validate_with_strict_false(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression.validate with strict=False skips validation.""" - query = QueryExpression( + """Tests that QueryStatement.validate with strict=False skips validation.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) # Manually assign invalid type that would normally cause an error @@ -718,7 +718,7 @@ def test_query_expression_validate_with_strict_false(self, dummy_dialect: DummyD query.validate(strict=False) # Should not raise any exception # Also test with valid parameters and strict=False - query_valid = QueryExpression( + query_valid = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name"), Column(dummy_dialect, "email")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "customers")), @@ -733,8 +733,8 @@ def test_query_expression_validate_with_strict_false(self, dummy_dialect: DummyD assert True # Just to ensure the test passes def test_query_expression_from_invalid_type_with_strict_false(self, dummy_dialect: DummyDialect): - """Tests that QueryExpression.from_ parameter validation respects strict=False setting.""" - query = QueryExpression( + """Tests that QueryStatement.from_ parameter validation respects strict=False setting.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), # Valid initial value @@ -748,18 +748,18 @@ def test_query_expression_from_invalid_type_with_strict_false(self, dummy_dialec # Verify that strict=True would raise an error for the same invalid parameter with pytest.raises( TypeError, - match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 + match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 ): query.validate(strict=True) def test_query_expression_with_count_distinct(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with COUNT(DISTINCT column_name) function.""" + """Tests QueryStatement with COUNT(DISTINCT column_name) function.""" from rhosocial.activerecord.backend.expression.functions import count # Create a COUNT(DISTINCT column) expression count_distinct_expr = count(dummy_dialect, Column(dummy_dialect, "category"), is_distinct=True) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[count_distinct_expr], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "products")) ) sql, params = query.to_sql() @@ -769,11 +769,11 @@ def test_query_expression_with_count_distinct(self, dummy_dialect: DummyDialect) assert params == () def test_query_expression_from_with_valid_list_types(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with valid types in from_ list.""" + """Tests QueryStatement with valid types in from_ list.""" from rhosocial.activerecord.backend.expression import ValuesExpression # Test with list containing valid types: string, NamedRelationRef, ValuesExpression - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "col1"), Column(dummy_dialect, "col2")], from_=[ @@ -794,8 +794,8 @@ def test_query_expression_from_with_valid_list_types(self, dummy_dialect: DummyD assert params == ("test",) def test_query_expression_from_with_invalid_list_item_type(self, dummy_dialect: DummyDialect): - """Tests QueryExpression validation with invalid type in from_ list.""" - query = QueryExpression( + """Tests QueryStatement validation with invalid type in from_ list.""" + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=[NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))], # Valid initial value @@ -806,15 +806,15 @@ def test_query_expression_from_with_invalid_list_item_type(self, dummy_dialect: with pytest.raises( TypeError, - match=r"from_ list item at index 1 must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 + match=r"from_ list item at index 1 must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 ): query.validate(strict=True) def test_query_expression_from_with_invalid_list_item_type_complex(self, dummy_dialect: DummyDialect): - """Tests QueryExpression validation with complex invalid type in from_ list.""" + """Tests QueryStatement validation with complex invalid type in from_ list.""" from rhosocial.activerecord.backend.expression import FunctionCall - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "id")], from_=[NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))], # Valid initial value @@ -828,17 +828,17 @@ def test_query_expression_from_with_invalid_list_item_type_complex(self, dummy_d with pytest.raises( TypeError, - match=r"from_ list item at index 1 must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 + match=r"from_ list item at index 1 must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, ValuesExpression, TableFunctionExpression, LateralExpression, GraphTableExpression, got ", # noqa: E501 ): query.validate(strict=True) - # --- Wildcard (SELECT *) tests --- + # --- AllColumnsWildcard (SELECT *) tests --- def test_query_expression_with_wildcard(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with wildcard (SELECT *).""" - from rhosocial.activerecord.backend.expression import WildcardExpression + """Tests QueryStatement with wildcard (SELECT *).""" + from rhosocial.activerecord.backend.expression import AllColumnsWildcard - query = QueryExpression( - dummy_dialect, select=[WildcardExpression(dummy_dialect)], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) + query = QueryStatement( + dummy_dialect, select=[AllColumnsWildcard(dummy_dialect)], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) ) sql, params = query.to_sql() @@ -846,12 +846,12 @@ def test_query_expression_with_wildcard(self, dummy_dialect: DummyDialect): assert params == () def test_query_expression_with_qualified_wildcard(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with qualified wildcard (SELECT table.*).""" - from rhosocial.activerecord.backend.expression import WildcardExpression + """Tests QueryStatement with qualified wildcard (SELECT table.*).""" + from rhosocial.activerecord.backend.expression import AllColumnsWildcard - query = QueryExpression( + query = QueryStatement( dummy_dialect, - select=[WildcardExpression(dummy_dialect, table="users")], + select=[AllColumnsWildcard(dummy_dialect, relation=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")))], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), ) sql, params = query.to_sql() @@ -860,12 +860,12 @@ def test_query_expression_with_qualified_wildcard(self, dummy_dialect: DummyDial assert params == () def test_query_expression_with_multiple_wildcards_and_columns(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with multiple wildcards and columns.""" - from rhosocial.activerecord.backend.expression import WildcardExpression + """Tests QueryStatement with multiple wildcards and columns.""" + from rhosocial.activerecord.backend.expression import AllColumnsWildcard - query = QueryExpression( + query = QueryStatement( dummy_dialect, - select=[WildcardExpression(dummy_dialect, table="users"), Column(dummy_dialect, "name", "profiles")], + select=[AllColumnsWildcard(dummy_dialect, relation=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))), Column(dummy_dialect, "name", "profiles")], from_=[NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), NamedRelationRef(dummy_dialect, Table(dummy_dialect, "profiles"))], ) sql, params = query.to_sql() @@ -874,39 +874,39 @@ def test_query_expression_with_multiple_wildcards_and_columns(self, dummy_dialec assert params == () def test_query_expression_with_count_wildcard(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with COUNT(*) using WildcardExpression.""" - from rhosocial.activerecord.backend.expression import WildcardExpression, count + """Tests QueryStatement with COUNT(*) using RowCountWildcard.""" + from rhosocial.activerecord.backend.expression import RowCountWildcard, count - # Test COUNT(*) using WildcardExpression - count_expr = count(dummy_dialect, WildcardExpression(dummy_dialect)) - query = QueryExpression(dummy_dialect, select=[count_expr], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))) + # Test COUNT(*) using the row-count wildcard + count_expr = count(dummy_dialect, RowCountWildcard(dummy_dialect)) + query = QueryStatement(dummy_dialect, select=[count_expr], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))) sql, params = query.to_sql() assert sql == 'SELECT COUNT(*) FROM "users"' assert params == () - def test_query_expression_with_count_qualified_wildcard(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with COUNT(table.*) using qualified WildcardExpression.""" - from rhosocial.activerecord.backend.expression import WildcardExpression, count + def test_query_expression_with_count_qualified_wildcard_refused(self, dummy_dialect: DummyDialect): + """COUNT(table.*) is backend territory: the core refuses it (4.10.2). - # Test COUNT(table.*) using qualified WildcardExpression - qualified_wildcard = WildcardExpression(dummy_dialect, table="users") - count_expr = count(dummy_dialect, qualified_wildcard) - query = QueryExpression(dummy_dialect, select=[count_expr], from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users"))) - sql, params = query.to_sql() + The qualified form counts non-NULL rows of one relation, which is a + different contract from COUNT(*); supporting backends define their own + derived node for it. + """ + from rhosocial.activerecord.backend.expression import AllColumnsWildcard, count - # Note: COUNT(users.*) is not standard SQL, but this tests the integration - # Standard SQL would use COUNT(*) or COUNT(column_name) - assert sql == 'SELECT COUNT(*) FROM "users"' - assert params == () + qualified_wildcard = AllColumnsWildcard( + dummy_dialect, relation=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")) + ) + with pytest.raises(ValueError, match="all-columns wildcard"): + count(dummy_dialect, qualified_wildcard) def test_query_expression_with_group_by_and_count_star(self, dummy_dialect: DummyDialect): - """Tests QueryExpression with SELECT name, count(*) FROM users GROUP BY name.""" + """Tests QueryStatement with SELECT name, count(*) FROM users GROUP BY name.""" from rhosocial.activerecord.backend.expression import count - from rhosocial.activerecord.backend.expression import WildcardExpression + from rhosocial.activerecord.backend.expression import RowCountWildcard # Create the COUNT(*) expression - count_expr = count(dummy_dialect, WildcardExpression(dummy_dialect)) + count_expr = count(dummy_dialect, RowCountWildcard(dummy_dialect)) # Create GROUP BY/HAVING clause with GROUP BY name group_by_having = GroupByHavingClause( @@ -915,7 +915,7 @@ def test_query_expression_with_group_by_and_count_star(self, dummy_dialect: Dumm having=None, # No HAVING clause ) - query = QueryExpression( + query = QueryStatement( dummy_dialect, select=[Column(dummy_dialect, "name"), count_expr], # SELECT name, count(*) from_=NamedRelationRef(dummy_dialect, Table(dummy_dialect, "users")), # FROM users diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_truncate.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_truncate.py index 0e278877e..0310f36a5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_truncate.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_truncate.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_truncate.py -from rhosocial.activerecord.backend.expression import TruncateExpression +from rhosocial.activerecord.backend.expression import TruncateStatement from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect from rhosocial.activerecord.backend.expression.objects import Table @@ -9,7 +9,7 @@ class TestTruncateStatements: def test_basic_truncate(self, dummy_dialect: DummyDialect): """Tests basic TRUNCATE TABLE statement.""" - truncate_expr = TruncateExpression(dummy_dialect, table=Table(dummy_dialect, "users")) + truncate_expr = TruncateStatement(dummy_dialect, table=Table(dummy_dialect, "users")) sql, params = truncate_expr.to_sql() assert 'TRUNCATE TABLE "users"' in sql @@ -17,7 +17,7 @@ def test_basic_truncate(self, dummy_dialect: DummyDialect): def test_truncate_with_restart_identity(self, dummy_dialect: DummyDialect): """Tests TRUNCATE TABLE with RESTART IDENTITY option.""" - truncate_expr = TruncateExpression(dummy_dialect, table=Table(dummy_dialect, "orders"), restart_identity=True) + truncate_expr = TruncateStatement(dummy_dialect, table=Table(dummy_dialect, "orders"), restart_identity=True) sql, params = truncate_expr.to_sql() assert 'TRUNCATE TABLE "orders"' in sql @@ -26,7 +26,7 @@ def test_truncate_with_restart_identity(self, dummy_dialect: DummyDialect): def test_truncate_with_cascade(self, dummy_dialect: DummyDialect): """Tests TRUNCATE TABLE with CASCADE option.""" - truncate_expr = TruncateExpression(dummy_dialect, table=Table(dummy_dialect, "products"), cascade=True) + truncate_expr = TruncateStatement(dummy_dialect, table=Table(dummy_dialect, "products"), cascade=True) sql, params = truncate_expr.to_sql() assert 'TRUNCATE TABLE "products"' in sql @@ -35,7 +35,7 @@ def test_truncate_with_cascade(self, dummy_dialect: DummyDialect): def test_truncate_with_all_options(self, dummy_dialect: DummyDialect): """Tests TRUNCATE TABLE with both RESTART IDENTITY and CASCADE options.""" - truncate_expr = TruncateExpression(dummy_dialect, table=Table(dummy_dialect, "inventory"), restart_identity=True, cascade=True) + truncate_expr = TruncateStatement(dummy_dialect, table=Table(dummy_dialect, "inventory"), restart_identity=True, cascade=True) sql, params = truncate_expr.to_sql() assert 'TRUNCATE TABLE "inventory"' in sql @@ -45,7 +45,7 @@ def test_truncate_with_all_options(self, dummy_dialect: DummyDialect): def test_truncate_has_no_dialect_options(self, dummy_dialect: DummyDialect): """The generic TRUNCATE expression carries no dialect_options bag.""" - truncate_expr = TruncateExpression(dummy_dialect, table=Table(dummy_dialect, "logs")) + truncate_expr = TruncateStatement(dummy_dialect, table=Table(dummy_dialect, "logs")) assert not hasattr(truncate_expr, "dialect_options") sql, params = truncate_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_update.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_update.py index 535f63912..20fbb9158 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_update.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_statements_update.py @@ -4,9 +4,9 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, - QueryExpression, - UpdateExpression, + RawSQL, + QueryStatement, + UpdateStatement, JoinClause, LogicalPredicate, ReturningClause, @@ -25,9 +25,9 @@ class TestUpdateStatements: """Tests for the refactored UPDATE statement expressions.""" def test_update_empty_assignments_raises_error(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises ValueError for empty assignments.""" + """Tests that UpdateStatement raises ValueError for empty assignments.""" with pytest.raises(ValueError, match="Assignments cannot be empty for an UPDATE statement."): - UpdateExpression(dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={}) + UpdateStatement(dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={}) @pytest.mark.parametrize( "table_param, assignments_param, where_param, returning_param, expected_sql, expected_params, test_id", @@ -54,7 +54,7 @@ def test_update_empty_assignments_raises_error(self, dummy_dialect: DummyDialect ), pytest.param( Table(None, "orders"), - {"status": Literal(None, "shipped"), "updated_at": RawSQLExpression(None, "CURRENT_TIMESTAMP")}, + {"status": Literal(None, "shipped"), "updated_at": RawSQL(None, "CURRENT_TIMESTAMP")}, ComparisonPredicate(None, "=", Column(None, "id"), Literal(None, 1)), None, 'UPDATE "orders" SET "status" = ?, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = ?', @@ -72,10 +72,10 @@ def test_update_empty_assignments_raises_error(self, dummy_dialect: DummyDialect "update_with_returning", id="update_with_returning", ), - # pytest.param( # Commented out because it uses old-style parameters in QueryExpression + # pytest.param( # Commented out because it uses old-style parameters in QueryStatement # "accounts", # {"balance": Column(None, "balance") + Literal(None, 100)}, - # Column(None, "user_id") == QueryExpression(None, select=[Column(None, "id")], from_="users", where=Column(None, "name") == Literal(None, "Alice")), # noqa: E501 + # Column(None, "user_id") == QueryStatement(None, select=[Column(None, "id")], from_="users", where=Column(None, "name") == Literal(None, "Alice")), # noqa: E501 # None, # 'UPDATE "accounts" SET "balance" = "balance" + ? WHERE "user_id" = (SELECT "id" FROM "users" WHERE "name" = ?)', # noqa: E501 # (100, "Alice"), @@ -126,13 +126,13 @@ def set_dialect_recursive(expr, dialect): expr.value, bases.BaseExpression ): # For Literal, if value is an expression set_dialect_recursive(expr.value, dialect) - if hasattr(expr, "subquery"): # For QueryExpression in from_ or assignments + if hasattr(expr, "subquery"): # For QueryStatement in from_ or assignments set_dialect_recursive(expr.subquery, dialect) if isinstance(expr, NamedRelationRef): # A row source renders through the object it points at, # and that object needs the dialect as much as the source. set_dialect_recursive(expr.relation, dialect) - if isinstance(expr, QueryExpression): # For QueryExpression itself + if isinstance(expr, QueryStatement): # For QueryStatement itself for s_elem in expr.select: set_dialect_recursive(s_elem, dialect) if expr.from_: @@ -175,7 +175,7 @@ def set_dialect_recursive(expr, dialect): set_dialect_recursive(item, dummy_dialect) dialect_returning_param = ReturningClause(dummy_dialect, expressions=returning_param) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=dialect_table_param, assignments=dialect_assignments, @@ -201,7 +201,7 @@ def set_dialect_recursive(expr, dialect): id="from_table_expr", ), pytest.param( - QueryExpression( + QueryStatement( None, select=[Column(None, "id", "s"), Column(None, "status_val", "s")], from_=NamedRelationRef(None, Table(None, "sub_users"), alias="s"), @@ -304,13 +304,13 @@ def set_dialect_recursive(expr, dialect): expr.value, bases.BaseExpression ): # For Literal, if value is an expression set_dialect_recursive(expr.value, dialect) - if hasattr(expr, "subquery"): # For QueryExpression in from_ or assignments + if hasattr(expr, "subquery"): # For QueryStatement in from_ or assignments set_dialect_recursive(expr.subquery, dialect) if isinstance(expr, NamedRelationRef): # A row source renders through the object it points at, # and that object needs the dialect as much as the source. set_dialect_recursive(expr.relation, dialect) - if isinstance(expr, QueryExpression): # For QueryExpression itself + if isinstance(expr, QueryStatement): # For QueryStatement itself for s_elem in expr.select: set_dialect_recursive(s_elem, dialect) if expr.from_: @@ -327,8 +327,8 @@ def set_dialect_recursive(expr, dialect): set_dialect_recursive(expr.right_table, dialect) set_dialect_recursive(expr.condition, dialect) - if isinstance(expr, LogicalPredicate): # LogicalPredicate has 'predicates' - for pred in expr.predicates: + if isinstance(expr, LogicalPredicate): # LogicalPredicate has 'operands' + for pred in expr.operands: set_dialect_recursive(pred, dialect) # Apply dialect recursively to from_param @@ -352,7 +352,7 @@ def set_dialect_recursive(expr, dialect): where_clause_param = WhereClause(dummy_dialect, condition=dialect_where_to_use) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, # The UPDATE names the relation it writes to, so the target is an object. table=( @@ -374,23 +374,23 @@ def set_dialect_recursive(expr, dialect): # endregion FROM Clause Tests def test_update_from_unsupported_source_type_raises_type_error(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises TypeError for unsupported FROM source types.""" + """Tests that UpdateStatement raises TypeError for unsupported FROM source types.""" unsupported_source = 123 # An integer, not a string or BaseExpression assignments = {"name": Literal(dummy_dialect, "New Name")} where = ComparisonPredicate(dummy_dialect, "=", Column(dummy_dialect, "id"), Literal(dummy_dialect, 1)) with pytest.raises(TypeError, match=r"Unsupported FROM source type: "): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments=assignments, from_=unsupported_source, where=where ) update_expr.to_sql() def test_update_expression_without_where_clause(self, dummy_dialect: DummyDialect): - """Test UpdateExpression with no WHERE clause to cover the else branch where where=None.""" + """Test UpdateStatement with no WHERE clause to cover the else branch where where=None.""" - # Create an UpdateExpression without a WHERE clause - update_expr = UpdateExpression( + # Create an UpdateStatement without a WHERE clause + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"status": Literal(dummy_dialect, "updated")}, # No where clause provided @@ -402,7 +402,7 @@ def test_update_expression_without_where_clause(self, dummy_dialect: DummyDialec assert params == ("updated",) def test_update_expression_with_where_clause_object(self, dummy_dialect: DummyDialect): - """Test UpdateExpression with a WhereClause object to cover the isinstance(where, WhereClause) branch.""" + """Test UpdateStatement with a WhereClause object to cover the isinstance(where, WhereClause) branch.""" from rhosocial.activerecord.backend.expression.query_parts import WhereClause # Create a WhereClause object using comparison operator @@ -413,8 +413,8 @@ def test_update_expression_with_where_clause_object(self, dummy_dialect: DummyDi ), ) - # Create an UpdateExpression with the WhereClause object - update_expr = UpdateExpression( + # Create an UpdateStatement with the WhereClause object + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"last_updated": Literal(dummy_dialect, "2023-01-01")}, @@ -444,7 +444,7 @@ def test_update_with_like_condition(self, dummy_dialect: DummyDialect, op, patte else: raise ValueError(f"Unsupported LIKE operation: {op}") - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"status": Literal(dummy_dialect, "active")}, @@ -466,7 +466,7 @@ def test_update_with_combined_like_and_other_conditions(self, dummy_dialect: Dum age_condition = age_col > Literal(dummy_dialect, 18) combined_condition = like_condition & age_condition - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"last_updated": Literal(dummy_dialect, "2023-01-01")}, @@ -484,8 +484,8 @@ def test_update_with_combined_like_and_other_conditions(self, dummy_dialect: Dum # so we don't validate its type in the expression validation. def test_update_expression_invalid_assignments_type(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises TypeError for invalid assignments parameter type.""" - update_expr = UpdateExpression( + """Tests that UpdateStatement raises TypeError for invalid assignments parameter type.""" + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) # Manually assign invalid type to trigger validation error @@ -495,8 +495,8 @@ def test_update_expression_invalid_assignments_type(self, dummy_dialect: DummyDi update_expr.validate(strict=True) def test_update_expression_invalid_from_type(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises TypeError for invalid from_ parameter type.""" - update_expr = UpdateExpression( + """Tests that UpdateStatement raises TypeError for invalid from_ parameter type.""" + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) # Manually assign invalid type to trigger validation error @@ -504,13 +504,13 @@ def test_update_expression_invalid_from_type(self, dummy_dialect: DummyDialect): with pytest.raises( TypeError, - match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, got ", # noqa: E501 + match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, got ", # noqa: E501 ): update_expr.validate(strict=True) def test_update_expression_invalid_where_type(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises TypeError for invalid where parameter type.""" - update_expr = UpdateExpression( + """Tests that UpdateStatement raises TypeError for invalid where parameter type.""" + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) # Manually assign invalid type to trigger validation error @@ -520,8 +520,8 @@ def test_update_expression_invalid_where_type(self, dummy_dialect: DummyDialect) update_expr.validate(strict=True) def test_update_expression_invalid_returning_type(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression raises TypeError for invalid returning parameter type.""" - update_expr = UpdateExpression( + """Tests that UpdateStatement raises TypeError for invalid returning parameter type.""" + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) # Manually assign invalid type to trigger validation error @@ -531,8 +531,8 @@ def test_update_expression_invalid_returning_type(self, dummy_dialect: DummyDial update_expr.validate(strict=True) def test_update_expression_validate_with_strict_false(self, dummy_dialect: DummyDialect): - """Tests that UpdateExpression.validate with strict=False skips validation.""" - update_expr = UpdateExpression( + """Tests that UpdateStatement.validate with strict=False skips validation.""" + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) # Manually assign invalid type that would normally cause an error @@ -542,24 +542,24 @@ def test_update_expression_validate_with_strict_false(self, dummy_dialect: Dummy update_expr.validate(strict=False) # Should not raise any exception # Also test with valid parameters and strict=False - update_expr_valid = UpdateExpression( + update_expr_valid = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'products'), assignments={"price": Literal(dummy_dialect, 19.99)}, - where=Column(dummy_dialect, "status") == Literal(dummy_dialect, "active"), + where=StringColumn(dummy_dialect, "status") == Literal(dummy_dialect, "active"), ) update_expr_valid.validate(strict=False) # Should not raise any exception assert True # Just to ensure the test passes def test_update_expression_from_complex_type_validation_branches(self, dummy_dialect: DummyDialect): - """Tests UpdateExpression.from_ parameter validation to cover all branches in the validate method. + """Tests UpdateStatement.from_ parameter validation to cover all branches in the validate method. - This test covers the complex validation logic in UpdateExpression.validate() where: + This test covers the complex validation logic in UpdateStatement.validate() where: 1. self.from_ is not None 2. not isinstance(self.from_, valid_types) and not isinstance(self.from_, list) is True 3. The type name check 'from_type_name not in valid_type_names' occurs """ - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) @@ -576,12 +576,12 @@ class MockInvalidType: with pytest.raises( TypeError, - match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperationExpression, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, got ", # noqa: E501 + match=r"from_ must be one of: str, NamedRelationRef, Subquery, SetOperation, JoinClause, list, ValuesExpression, TableFunctionExpression, LateralExpression, got ", # noqa: E501 ): update_expr.validate(strict=True) def test_update_expression_from_valid_complex_type_validation(self, dummy_dialect: DummyDialect): - """Tests UpdateExpression.from_ parameter validation for valid complex types. + """Tests UpdateStatement.from_ parameter validation for valid complex types. This test covers the case where: 1. self.from_ is not None @@ -589,27 +589,27 @@ def test_update_expression_from_valid_complex_type_validation(self, dummy_dialec 3. The type name check 'from_type_name not in valid_type_names' is False (type name IS in the list) """ # Create an update expression with valid parameters - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) - # Create a mock SetOperationExpression-like class that has a name in valid_type_names - class SetOperationExpression: + # Create a mock SetOperation-like class that has a name in valid_type_names + class SetOperation: pass - # This should not raise an error because the type name "SetOperationExpression" is in valid_type_names - update_expr.from_ = SetOperationExpression() + # This should not raise an error because the type name "SetOperation" is in valid_type_names + update_expr.from_ = SetOperation() # This should pass validation since the type name is in the valid list update_expr.validate(strict=True) # Should not raise any exception def test_update_expression_from_none_validation(self, dummy_dialect: DummyDialect): - """Tests UpdateExpression.from_ parameter validation when from_ is None. + """Tests UpdateStatement.from_ parameter validation when from_ is None. This covers the case where: 1. self.from_ is None (outer condition is False, so inner validation is skipped) """ - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")}, @@ -620,13 +620,13 @@ def test_update_expression_from_none_validation(self, dummy_dialect: DummyDialec update_expr.validate(strict=True) # Should not raise any exception def test_update_expression_from_valid_type_validation(self, dummy_dialect: DummyDialect): - """Tests UpdateExpression.from_ parameter validation with valid types. + """Tests UpdateStatement.from_ parameter validation with valid types. This covers the case where: 1. self.from_ is not None 2. isinstance(self.from_, valid_types) is True (so AND expression short-circuits) """ - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) @@ -637,13 +637,13 @@ def test_update_expression_from_valid_type_validation(self, dummy_dialect: Dummy update_expr.validate(strict=True) # Should not raise any exception def test_update_expression_from_list_validation(self, dummy_dialect: DummyDialect): - """Tests UpdateExpression.from_ parameter validation with list type. + """Tests UpdateStatement.from_ parameter validation with list type. This covers the case where: 1. self.from_ is not None 2. isinstance(self.from_, valid_types) is False AND isinstance(self.from_, list) is True """ - update_expr = UpdateExpression( + update_expr = UpdateStatement( dummy_dialect, table=Table(dummy_dialect, 'users'), assignments={"name": Literal(dummy_dialect, "test")} ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_table_name_type_validation.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_table_name_type_validation.py index 51f183840..6932e30ba 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_table_name_type_validation.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_table_name_type_validation.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_test/feature/backend/dummy2/test_table_name_type_validation.py -"""Tests for relation-object type validation in CreateTableExpression and DropTableExpression. +"""Tests for relation-object type validation in CreateTableStatement and DropTableStatement. Both statements name the relation they act on, so both take a :class:`~rhosocial.activerecord.backend.expression.objects.Table` rather than a @@ -12,20 +12,22 @@ """ import pytest - -from typing import List - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) -from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.types import IntegerType -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from typing import List + + def primary_key_columns(dialect: DummyDialect) -> List[ColumnDefinition]: @@ -35,18 +37,18 @@ def primary_key_columns(dialect: DummyDialect) -> List[ColumnDefinition]: dialect, name="id", data_type=IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ) ] class TestCreateTableExpressionTypeValidation: - """Tests for CreateTableExpression relation type validation.""" + """Tests for CreateTableStatement relation type validation.""" def test_create_table_keeps_the_object_it_was_given(self, dummy_dialect: DummyDialect): """The statement holds the caller's object rather than rebuilding one.""" table = Table(dummy_dialect, "users") - expr = CreateTableExpression(dummy_dialect, table, primary_key_columns(dummy_dialect)) + expr = CreateTableStatement(dummy_dialect, table, primary_key_columns(dummy_dialect)) assert expr.table is table assert expr.table.name == "users" @@ -54,7 +56,7 @@ def test_create_table_keeps_the_object_it_was_given(self, dummy_dialect: DummyDi def test_create_table_renders_the_object_name(self, dummy_dialect: DummyDialect): """Naming the table is the object's job, reached through to_sql().""" - expr = CreateTableExpression( + expr = CreateTableStatement( dummy_dialect, Table(dummy_dialect, "users"), primary_key_columns(dummy_dialect) ) @@ -65,7 +67,7 @@ def test_create_table_renders_the_object_name(self, dummy_dialect: DummyDialect) def test_create_table_reports_the_table_name(self, dummy_dialect: DummyDialect): """``table_name`` reads through to the object it names.""" - expr = CreateTableExpression( + expr = CreateTableStatement( dummy_dialect, Table(dummy_dialect, "users"), primary_key_columns(dummy_dialect) ) @@ -74,7 +76,7 @@ def test_create_table_reports_the_table_name(self, dummy_dialect: DummyDialect): def test_create_table_refuses_an_unqualified_namespace(self, dummy_dialect: DummyDialect): """A qualified name is the object's to carry; rendering is the dialect's.""" table = Table(dummy_dialect, "users", schema_name="public") - expr = CreateTableExpression(dummy_dialect, table, primary_key_columns(dummy_dialect)) + expr = CreateTableStatement(dummy_dialect, table, primary_key_columns(dummy_dialect)) assert expr.table is table assert expr.table.schema_name == "public" @@ -100,19 +102,19 @@ def test_create_table_rejects_a_non_table(self, dummy_dialect: DummyDialect, val render it as the table's name is the one that refuses, so the error arrives from ``to_sql()``. """ - expr = CreateTableExpression(dummy_dialect, value, primary_key_columns(dummy_dialect)) + expr = CreateTableStatement(dummy_dialect, value, primary_key_columns(dummy_dialect)) with pytest.raises(TypeError) as exc_info: expr.to_sql() assert expected in str(exc_info.value) class TestDropTableExpressionTypeValidation: - """Tests for DropTableExpression relation type validation.""" + """Tests for DropTableStatement relation type validation.""" def test_drop_table_keeps_the_object_it_was_given(self, dummy_dialect: DummyDialect): """The statement holds the caller's object rather than rebuilding one.""" table = Table(dummy_dialect, "users") - expr = DropTableExpression(dummy_dialect, table) + expr = DropTableStatement(dummy_dialect, table) assert expr.table is table assert expr.table.name == "users" @@ -124,7 +126,7 @@ def test_drop_table_keeps_the_object_it_was_given(self, dummy_dialect: DummyDial def test_drop_table_honours_its_own_flags(self, dummy_dialect: DummyDialect): """IF EXISTS belongs to the statement, not to the object.""" - expr = DropTableExpression(dummy_dialect, Table(dummy_dialect, "users"), if_exists=True) + expr = DropTableStatement(dummy_dialect, Table(dummy_dialect, "users"), if_exists=True) sql, params = expr.to_sql() assert sql == 'DROP TABLE IF EXISTS "users"' @@ -132,7 +134,7 @@ def test_drop_table_honours_its_own_flags(self, dummy_dialect: DummyDialect): def test_drop_table_refuses_an_unqualified_namespace(self, dummy_dialect: DummyDialect): """A qualified object renders only where the dialect has a namespace.""" - expr = DropTableExpression( + expr = DropTableStatement( dummy_dialect, Table(dummy_dialect, "users", schema_name="public"), cascade=True ) @@ -156,7 +158,7 @@ def test_drop_table_rejects_a_non_table(self, dummy_dialect: DummyDialect, value The statement collects the value it was given; the dialect that would render it after DROP TABLE is the one that refuses. """ - expr = DropTableExpression(dummy_dialect, value) + expr = DropTableStatement(dummy_dialect, value) with pytest.raises(TypeError) as exc_info: expr.to_sql() assert expected in str(exc_info.value) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_casting_mixin.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_casting_mixin.py index 7f42864cb..905ef3c61 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_casting_mixin.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_casting_mixin.py @@ -7,6 +7,7 @@ nest like any other expression. """ +from rhosocial.activerecord.backend.expression.functions import cast from rhosocial.activerecord.backend.expression import Literal, Column, FunctionCall from rhosocial.activerecord.backend.expression.core import CastExpression from rhosocial.activerecord.backend.expression.types import CustomType @@ -43,7 +44,7 @@ def test_column_cast_with_numeric_precision(self, dummy_dialect: DummyDialect): def test_literal_cast(self, dummy_dialect: DummyDialect): """Test Literal with type cast.""" lit = Literal(dummy_dialect, "123") - expr = lit.cast(CustomType(lit.dialect, raw="INTEGER")) + expr = cast(dummy_dialect, lit, CustomType(lit.dialect, raw="INTEGER")) sql, params = expr.to_sql() assert sql == "CAST(? AS INTEGER)" assert params == ("123",) diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_dispatch_registry.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_dispatch_registry.py index c2716b60e..3d4155afe 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_dispatch_registry.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_dispatch_registry.py @@ -5,7 +5,7 @@ ``format_data_type_`` and the class behind that name is found by lookup. That design has one sharp edge, and this file is about it. -:meth:`~...dialect.mixins.data_type.DataTypeMixin._type_class_for` answers a +:meth:`~...dialect.mixins.data_type.DataTypeMixin.type_class_for` answers a dispatch name by walking the live subclass tree of ``DataType`` — so it sees only the classes something has already **imported**. On a cold ``import .dialect``, a name whose class lives in a module nobody has @@ -235,9 +235,9 @@ def test_an_unknown_name_still_resolves_to_nothing(): from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect dialect = DummyDialect() - assert dialect._type_class_for("nosuchtype") is None + assert dialect.type_class_for("nosuchtype") is None # A core-prefixed name is not a backend name, so no import is attempted. - assert dialect._type_class_for("jsonb") is not None + assert dialect.type_class_for("jsonb") is not None def test_a_formatters_own_annotation_is_where_the_class_comes_from(): @@ -258,14 +258,14 @@ def format_data_type_annotated_widget(self, data_type: JsonBType): # The annotation's class does not declare the dispatch key, so it is not # trusted for it — the name resolves to nothing, which is the honest answer. - assert AnnotatedDialect()._type_class_for("annotated_widget") is None + assert AnnotatedDialect().type_class_for("annotated_widget") is None # With the class's own name, the annotation is the answer. class DeclaredNameDialect(DummyDialect): def format_data_type_jsonb(self, data_type: JsonBType): return "JSONB", () - assert DeclaredNameDialect()._type_class_for("jsonb") is JsonBType + assert DeclaredNameDialect().type_class_for("jsonb") is JsonBType def test_the_mixin_is_what_provides_the_mapping(): @@ -278,7 +278,7 @@ def test_the_mixin_is_what_provides_the_mapping(): dialect_class = type(dialect) assert DataTypeMixin in dialect_class.__mro__ assert dialect_class.supports_data_types is DataTypeMixin.supports_data_types - assert dialect_class._type_class_for is DataTypeMixin._type_class_for + assert dialect_class.type_class_for is DataTypeMixin.type_class_for def test_data_type_subclasses_are_reachable_without_a_registry(): diff --git a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_domain_ddl.py b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_domain_ddl.py index 97d773088..41f09315a 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_domain_ddl.py +++ b/tests/rhosocial/activerecord_test/feature/backend/dummy2/test_type_domain_ddl.py @@ -11,7 +11,10 @@ DomainMixin, UserDefinedTypeMixin, ) -from rhosocial.activerecord.backend.expression import FunctionCall, Literal +from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + Literal, +) from rhosocial.activerecord.backend.expression.serialization import ( deserialize, deserialize_json, @@ -22,18 +25,18 @@ ) from rhosocial.activerecord.backend.expression.statements import ( AddDomainCheckAction, - AlterDomainExpression, - AlterTypeExpression, - CreateDomainExpression, - CreateTypeExpression, + AlterDomainStatement, + AlterTypeStatement, + CreateDomainStatement, + CreateTypeStatement, DomainCheckConstraint, DomainNullability, DomainValueExpression, DropDomainCheckAction, DropDomainDefaultAction, - DropDomainExpression, + DropDomainStatement, DropDomainNotNullAction, - DropTypeExpression, + DropTypeStatement, RenameDomainAction, SetDomainDefaultAction, SetDomainNotNullAction, @@ -68,7 +71,7 @@ def schema_dialect(): def _condition(dialect): - return DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + return DomainValueExpression(dialect) > Literal(dialect, 0, inline=True) def test_legacy_names_are_same_objects(): @@ -87,7 +90,7 @@ def test_dummy_protocols_and_mixins_are_concrete(dummy_dialect): def test_type_lifecycle(schema_dialect): dummy_dialect = schema_dialect definition = _DummyTypeDefinition(dummy_dialect, IntegerType(dummy_dialect)) - create = CreateTypeExpression( + create = CreateTypeStatement( dummy_dialect, Type(dummy_dialect, "status", schema_name="app"), definition, @@ -100,7 +103,7 @@ def test_type_lifecycle(schema_dialect): assert create_params == () action = _DummyTypeAlterAction(dummy_dialect, "status_v2") - alter = AlterTypeExpression( + alter = AlterTypeStatement( dummy_dialect, Type(dummy_dialect, "status", schema_name="app"), [action], @@ -111,7 +114,7 @@ def test_type_lifecycle(schema_dialect): assert "RENAME TO" in alter_sql assert alter_params == () - drop = DropTypeExpression( + drop = DropTypeStatement( dummy_dialect, Type(dummy_dialect, "status", schema_name="app"), if_exists=True, @@ -125,11 +128,11 @@ def test_type_lifecycle(schema_dialect): def test_domain_lifecycle(dummy_dialect): condition = _condition(dummy_dialect) check = DomainCheckConstraint(dummy_dialect, condition, name="positive") - create = CreateDomainExpression( + create = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "positive"), IntegerType(dummy_dialect), - default=Literal(dummy_dialect, 1, inline_literals=True), + default=Literal(dummy_dialect, 1, inline=True), nullability=DomainNullability.NOT_NULL, checks=[check], collation="pg_catalog.default", @@ -144,7 +147,7 @@ def test_domain_lifecycle(dummy_dialect): assert "NOT NULL" in create_sql assert create_params == () - nullable = CreateDomainExpression( + nullable = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "nullable"), IntegerType(dummy_dialect), @@ -164,12 +167,12 @@ def test_domain_lifecycle(dummy_dialect): (RenameDomainAction(dummy_dialect, "positive_v2"), "RENAME TO"), ) for action, fragment in actions: - alter = AlterDomainExpression(dummy_dialect, Domain(dummy_dialect, "positive"), [action]) + alter = AlterDomainStatement(dummy_dialect, Domain(dummy_dialect, "positive"), [action]) alter_sql, alter_params = alter.to_sql() assert f'ALTER DOMAIN "positive" {fragment}' in alter_sql assert alter_params == () - drop = DropDomainExpression(dummy_dialect, Domain(dummy_dialect, "positive")) + drop = DropDomainStatement(dummy_dialect, Domain(dummy_dialect, "positive")) drop_sql, drop_params = drop.to_sql() assert drop_sql == 'DROP DOMAIN "positive"' assert drop_params == () @@ -178,11 +181,11 @@ def test_domain_lifecycle(dummy_dialect): def test_dummy_rejects_multiple_ddl_actions(dummy_dialect): action = _DummyTypeAlterAction(dummy_dialect) with pytest.raises(UnsupportedFeatureError): - AlterTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), [action, action]).to_sql() + AlterTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), [action, action]).to_sql() domain_action = DropDomainDefaultAction(dummy_dialect) with pytest.raises(UnsupportedFeatureError): - AlterDomainExpression( + AlterDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), [domain_action, domain_action], @@ -229,13 +232,13 @@ def test_type_constructor_validation(dummy_dialect): """ definition = _DummyTypeDefinition(dummy_dialect, IntegerType(dummy_dialect)) with pytest.raises(ValueError): - CreateTypeExpression(dummy_dialect, Type(dummy_dialect, ""), definition) + CreateTypeStatement(dummy_dialect, Type(dummy_dialect, ""), definition) # The definition slot is checked by the formatter that renders the type body. - bad_definition = CreateTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), object()) + bad_definition = CreateTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), object()) with pytest.raises(TypeError): bad_definition.to_sql() with pytest.raises(ValueError): - CreateTypeExpression( + CreateTypeStatement( dummy_dialect, Type(dummy_dialect, "status"), definition, @@ -243,25 +246,25 @@ def test_type_constructor_validation(dummy_dialect): or_replace=True, ) with pytest.raises(ValueError): - AlterTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), []) - bad_action = AlterTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), [object()]) + AlterTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), []) + bad_action = AlterTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), [object()]) with pytest.raises(TypeError): bad_action.to_sql() with pytest.raises(ValueError): - DropTypeExpression(dummy_dialect, Type(dummy_dialect, " ")) + DropTypeStatement(dummy_dialect, Type(dummy_dialect, " ")) def test_domain_constructor_validation(dummy_dialect): with pytest.raises(TypeError): - CreateDomainExpression(dummy_dialect, Domain(dummy_dialect, "domain"), object()) + CreateDomainStatement(dummy_dialect, Domain(dummy_dialect, "domain"), object()) with pytest.raises(TypeError): - CreateDomainExpression( + CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect), nullability=None, ) - normalized = CreateDomainExpression( + normalized = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect), @@ -271,13 +274,13 @@ def test_domain_constructor_validation(dummy_dialect): with pytest.raises(TypeError): DomainCheckConstraint(dummy_dialect, object()) with pytest.raises(ValueError): - AlterDomainExpression(dummy_dialect, Domain(dummy_dialect, "domain"), []) + AlterDomainStatement(dummy_dialect, Domain(dummy_dialect, "domain"), []) with pytest.raises(ValueError): RenameDomainAction(dummy_dialect, "") def test_domain_capability_gating(dummy_dialect, monkeypatch): - named = CreateDomainExpression( + named = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect), @@ -295,7 +298,7 @@ def test_domain_capability_gating(dummy_dialect, monkeypatch): monkeypatch.setattr(type(dummy_dialect), "supports_named_domain_checks", lambda self: True) monkeypatch.setattr(type(dummy_dialect), "supports_multiple_domain_checks", lambda self: False) - multiple = CreateDomainExpression( + multiple = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect), @@ -316,25 +319,29 @@ def test_domain_capability_gating(dummy_dialect, monkeypatch): DropDomainCheckAction(dummy_dialect).to_sql() -def test_domain_ddl_rejects_bind_parameters(dummy_dialect): - default = FunctionCall(dummy_dialect, "COALESCE", Literal(dummy_dialect, 1)) - expression = CreateDomainExpression( - dummy_dialect, - Domain(dummy_dialect, "domain"), - IntegerType(dummy_dialect), - default=default, - ) - with pytest.raises(ValueError, match="bind parameters"): - expression.to_sql() +def test_domain_ddl_carries_bind_parameters_the_caller_left(dummy_dialect): + """Whether to inline is the caller's decision, not the framework's. - condition = DomainValueExpression(dummy_dialect) == Literal(dummy_dialect, 1) + A value the caller left as a bind value stays one: the statement renders + with a placeholder and carries the value in ``params``. A value the + caller marked inline renders as escaped text and binds nothing. + """ + from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate + + condition = ComparisonPredicate( + dummy_dialect, "=", DomainValueExpression(dummy_dialect), RawSQLPredicate(dummy_dialect, "1", (1,)) + ) check = DomainCheckConstraint(dummy_dialect, condition) - with pytest.raises(ValueError, match="bind parameters"): - check.to_sql() + sql, params = check.to_sql() + assert params == (1,) + + lifted = DomainValueExpression(dummy_dialect) == Literal(dummy_dialect, 1, inline=True) + lifted_check = DomainCheckConstraint(dummy_dialect, lifted) + assert lifted_check.to_sql() == ("CHECK (VALUE = 1)", ()) def test_domain_string_default_is_escaped(dummy_dialect): - expression = CreateDomainExpression( + expression = CreateDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect), @@ -349,13 +356,13 @@ def test_sqlite_type_and_domain_fail_fast(): dialect = SQLiteDialect(version=(3, 45, 0)) definition = _DummyTypeDefinition(dialect, IntegerType(dialect)) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, Type(dialect, "status"), definition).to_sql() + CreateTypeStatement(dialect, Type(dialect, "status"), definition).to_sql() with pytest.raises(UnsupportedFeatureError): - DropTypeExpression(dialect, Type(dialect, "status")).to_sql() + DropTypeStatement(dialect, Type(dialect, "status")).to_sql() with pytest.raises(UnsupportedFeatureError): - CreateDomainExpression(dialect, Domain(dialect, "domain"), IntegerType(dialect)).to_sql() + CreateDomainStatement(dialect, Domain(dialect, "domain"), IntegerType(dialect)).to_sql() with pytest.raises(UnsupportedFeatureError): - DropDomainExpression(dialect, Domain(dialect, "domain")).to_sql() + DropDomainStatement(dialect, Domain(dialect, "domain")).to_sql() with pytest.raises(UnsupportedFeatureError): DomainValueExpression(dialect).to_sql() with pytest.raises(UnsupportedFeatureError): @@ -402,16 +409,16 @@ def test_core_type_and_domain_roundtrips(dummy_dialect): expressions = ( definition, action, - CreateTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), definition), - AlterTypeExpression(dummy_dialect, Type(dummy_dialect, "status"), [action]), - DropTypeExpression(dummy_dialect, Type(dummy_dialect, "status")), - CreateDomainExpression(dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect)), - AlterDomainExpression( + CreateTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), definition), + AlterTypeStatement(dummy_dialect, Type(dummy_dialect, "status"), [action]), + DropTypeStatement(dummy_dialect, Type(dummy_dialect, "status")), + CreateDomainStatement(dummy_dialect, Domain(dummy_dialect, "domain"), IntegerType(dummy_dialect)), + AlterDomainStatement( dummy_dialect, Domain(dummy_dialect, "domain"), [DropDomainDefaultAction(dummy_dialect)], ), - DropDomainExpression(dummy_dialect, Domain(dummy_dialect, "domain")), + DropDomainStatement(dummy_dialect, Domain(dummy_dialect, "domain")), ) codecs = ( (serialize, deserialize), diff --git a/tests/rhosocial/activerecord_test/feature/backend/expression/test_fts5_signatures.py b/tests/rhosocial/activerecord_test/feature/backend/expression/test_fts5_signatures.py index 7ed26bc0b..3fe4fd0fe 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/expression/test_fts5_signatures.py +++ b/tests/rhosocial/activerecord_test/feature/backend/expression/test_fts5_signatures.py @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.impl.sqlite.expression import ( - SQLiteFTS5MatchExpression, + SQLiteFTS5MatchPredicate, ) @@ -32,13 +32,13 @@ class TestFTS5MatchExpressionSignature: """format_fts5_match_expression accepts a single expression object.""" def test_match_normal(self, dialect): - expr = SQLiteFTS5MatchExpression(dialect, table="articles", query="python") + expr = SQLiteFTS5MatchPredicate(dialect, table="articles", query="python") sql, params = dialect.format_fts5_match_expression(expr) assert "MATCH" in sql assert params == ("python",) def test_match_with_columns(self, dialect): - expr = SQLiteFTS5MatchExpression( + expr = SQLiteFTS5MatchPredicate( dialect, table="articles", query="python", columns=["title", "body"] ) sql, params = dialect.format_fts5_match_expression(expr) @@ -47,7 +47,7 @@ def test_match_with_columns(self, dialect): assert "body:python" in params[0] def test_match_negate_raises(self, dialect): - expr = SQLiteFTS5MatchExpression( + expr = SQLiteFTS5MatchPredicate( dialect, table="articles", query="python", negate=True ) with pytest.raises(ValueError, match="NOT MATCH"): diff --git a/tests/rhosocial/activerecord_test/feature/backend/expression/test_virtual_table_signatures.py b/tests/rhosocial/activerecord_test/feature/backend/expression/test_virtual_table_signatures.py index bd0a6f898..887278dc4 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/expression/test_virtual_table_signatures.py +++ b/tests/rhosocial/activerecord_test/feature/backend/expression/test_virtual_table_signatures.py @@ -2,8 +2,8 @@ """Tests for virtual table format method signature compliance. Verifies: -- format_create_virtual_table accepts a CreateVirtualTableExpression -- format_drop_virtual_table accepts a DropVirtualTableExpression +- format_create_virtual_table_statement accepts a CreateVirtualTableStatement +- format_drop_virtual_table_statement accepts a DropVirtualTableStatement - 4 tests: create/drop × expression construction + SQL output """ @@ -11,8 +11,8 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.impl.sqlite.expression import ( - CreateVirtualTableExpression, - DropVirtualTableExpression, + CreateVirtualTableStatement, + DropVirtualTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -23,15 +23,15 @@ def dialect(): # ============================================================================= -# Part 1: CreateVirtualTableExpression +# Part 1: CreateVirtualTableStatement # ============================================================================= class TestCreateVirtualTableExpression: - """CreateVirtualTableExpression produces correct SQL.""" + """CreateVirtualTableStatement produces correct SQL.""" def test_create_basic(self, dialect): - expr = CreateVirtualTableExpression( + expr = CreateVirtualTableStatement( dialect, module="rtree", table=Table(dialect, "places"), columns=["id", "minx", "maxx", "miny", "maxy"] ) @@ -43,7 +43,7 @@ def test_create_basic(self, dialect): def test_create_rejects_malicious_module(self, dialect): with pytest.raises(ValueError, match="Unsafe virtual table module"): - expr = CreateVirtualTableExpression( + expr = CreateVirtualTableStatement( dialect, module="bad'; DROP TABLE x; --", table=Table(dialect, "t"), columns=["c"] ) @@ -51,21 +51,21 @@ def test_create_rejects_malicious_module(self, dialect): # ============================================================================= -# Part 2: DropVirtualTableExpression +# Part 2: DropVirtualTableStatement # ============================================================================= class TestDropVirtualTableExpression: - """DropVirtualTableExpression produces correct SQL.""" + """DropVirtualTableStatement produces correct SQL.""" def test_drop_basic(self, dialect): - expr = DropVirtualTableExpression(dialect, table=Table(dialect, "my_table")) + expr = DropVirtualTableStatement(dialect, table=Table(dialect, "my_table")) sql, params = expr.to_sql() assert sql == 'DROP TABLE "my_table"' assert params == () def test_drop_if_exists(self, dialect): - expr = DropVirtualTableExpression( + expr = DropVirtualTableStatement( dialect, table=Table(dialect, "my_table"), if_exists=True ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/introspection/test_table_types.py b/tests/rhosocial/activerecord_test/feature/backend/introspection/test_table_types.py new file mode 100644 index 000000000..872c9be30 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/introspection/test_table_types.py @@ -0,0 +1,89 @@ +# tests/rhosocial/activerecord_test/feature/backend/introspection/test_table_types.py +"""The table kinds the introspection vocabulary can name. + +A catalog reports more than "table" and "view", and the introspector is the +only layer that sees the difference: once a materialized view has been folded +into ``BASE_TABLE`` no later layer can tell it from one. These tests hold the +vocabulary open for the kinds a catalog actually reports. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.introspection.types import TableType + +pytestmark = [pytest.mark.feature, pytest.mark.backend] + + +class TestTableTypeSpellings: + def test_materialized_view_is_distinct_from_a_base_table(self): + assert TableType.MATERIALIZED_VIEW.value == "MATERIALIZED_VIEW" + assert TableType.MATERIALIZED_VIEW is not TableType.BASE_TABLE + + def test_materialized_view_is_distinct_from_a_view(self): + # A view is recomputed on every read; a materialized view holds rows + # and is refreshed. Sharing VIEW would erase the refresh semantics. + assert TableType.MATERIALIZED_VIEW is not TableType.VIEW + + def test_partitioned_table_is_distinct_from_a_base_table(self): + assert TableType.PARTITIONED_TABLE.value == "PARTITIONED_TABLE" + assert TableType.PARTITIONED_TABLE is not TableType.BASE_TABLE + + def test_foreign_table_is_distinct_from_an_external_table(self): + assert TableType.FOREIGN_TABLE.value == "FOREIGN_TABLE" + assert TableType.FOREIGN_TABLE is not TableType.EXTERNAL + + def test_every_member_has_its_own_spelling(self): + spellings = [member.value for member in TableType] + assert len(spellings) == len(set(spellings)) + + +class TestSQLiteTableKinds: + """`PRAGMA table_list` reports four kinds; the parser must keep them apart. + + Rows are handed to the parser directly: this is about the mapping, and a + database would only add a fixture between the four words and the answer. + """ + + @staticmethod + def _types(rows): + backend = SQLiteBackend(database=":memory:") + parsed = backend.introspector.parse_tables(rows, "main") + return {table.name: table.table_type for table in parsed} + + def test_ordinary_table_and_view(self): + kinds = self._types( + [{"name": "t", "type": "table"}, {"name": "v", "type": "view"}] + ) + assert kinds == {"t": TableType.BASE_TABLE, "v": TableType.VIEW} + + def test_shadow_tables_are_system_owned_not_external(self): + # fts_data / fts_idx and friends are created and maintained by the + # virtual-table module. Reporting them as EXTERNAL would put them in + # the same bucket as the FTS5 table the user actually created. + kinds = self._types([{"name": "fts_data", "type": "shadow"}]) + assert kinds == {"fts_data": TableType.SYSTEM_TABLE} + + def test_virtual_tables_are_external(self): + kinds = self._types([{"name": "fts", "type": "virtual"}]) + assert kinds == {"fts": TableType.EXTERNAL} + + def test_all_four_kinds_in_one_answer(self): + kinds = self._types( + [ + {"name": "t", "type": "table"}, + {"name": "v", "type": "view"}, + {"name": "fts", "type": "virtual"}, + {"name": "fts_data", "type": "shadow"}, + ] + ) + assert kinds == { + "t": TableType.BASE_TABLE, + "v": TableType.VIEW, + "fts": TableType.EXTERNAL, + "fts_data": TableType.SYSTEM_TABLE, + } + + def test_unknown_kind_does_not_become_a_base_table(self): + kinds = self._types([{"name": "x", "type": "something-new"}]) + assert kinds == {"x": TableType.EXTERNAL} diff --git a/tests/rhosocial/activerecord_test/feature/backend/migration/test_migration.py b/tests/rhosocial/activerecord_test/feature/backend/migration/test_migration.py index a21360f4f..b607d8c1c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/migration/test_migration.py +++ b/tests/rhosocial/activerecord_test/feature/backend/migration/test_migration.py @@ -14,26 +14,19 @@ import sys import types -from unittest.mock import MagicMock import pytest - - -def register_temp_module(name: str, functions: dict): - """Register a temporary module in sys.modules for testing.""" - mod = types.ModuleType(name) - for func_name, func in functions.items(): - setattr(mod, func_name, func) - mod.__all__ = list(functions.keys()) - sys.modules[name] = mod - return mod - - -def unregister_temp_module(name: str): - """Remove a previously registered temporary module.""" - if name in sys.modules: - del sys.modules[name] - +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.named_expression.exceptions import ( + ProcedureError, +) +from rhosocial.activerecord.backend.named_expression.procedure import ( + Procedure, + ProcedureContext, +) from rhosocial.activerecord.backend.migration import ( MigrationDirection, NamedMigration, @@ -52,14 +45,24 @@ def unregister_temp_module(name: str): NamedMigrationResolver, MigrationRunner, ) -from rhosocial.activerecord.backend.named_expression.procedure import ( - Procedure, - ProcedureContext, -) -from rhosocial.activerecord.backend.named_expression.exceptions import ( - ProcedureError, -) -from rhosocial.activerecord.backend.expression.objects import Table +from unittest.mock import MagicMock + + +def register_temp_module(name: str, functions: dict): + """Register a temporary module in sys.modules for testing.""" + mod = types.ModuleType(name) + for func_name, func in functions.items(): + setattr(mod, func_name, func) + mod.__all__ = list(functions.keys()) + sys.modules[name] = mod + return mod + + +def unregister_temp_module(name: str): + """Remove a previously registered temporary module.""" + if name in sys.modules: + del sys.modules[name] + class TestMigrationDirection: @@ -509,10 +512,9 @@ def test_dry_run_unsupported_feature(self): # Register a named expression that produces an expression not supported # by SQLite — a table with PARTITION BY clause from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( PartitionClause, @@ -527,10 +529,10 @@ def unsupported_expr(dialect): col_def = ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ) col_expr = ColExpr(dialect, "id") - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'test_unsupported'), columns=[col_def], @@ -589,24 +591,24 @@ def _register(self, name, functions): def _make_create_users_expr(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, "email", SQLiteTextType(dialect=dialect)), @@ -615,10 +617,10 @@ def _make_create_users_expr(self, dialect): def _make_drop_users_expr(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - DropTableExpression, + DropTableStatement, ) - return DropTableExpression(dialect, table=Table(dialect, 'users'), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, 'users'), if_exists=True) def test_up_creates_table(self, sqlite_backend): """Running a UP migration should create the expected table.""" @@ -676,7 +678,6 @@ def down(self, ctx): def test_record_store_records_up(self, sqlite_backend, tmp_path): """Running UP with a record store should write a record.""" - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore self._register("mig_sqlite_test3.expressions", { "create_users_table": self._make_create_users_expr, @@ -701,7 +702,6 @@ def down(self, ctx): def test_record_store_tracks_down(self, sqlite_backend, tmp_path): """After DOWN, the record store should report the migration as not applied.""" - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore self._register("mig_sqlite_test4.expressions", { "create_users_table": self._make_create_users_expr, @@ -758,7 +758,6 @@ def down(self, ctx): def test_error_records_as_failure(self, sqlite_backend, tmp_path): """When up() raises, the record should show success=False.""" - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore self._register("mig_sqlite_test6.expressions", { "create_users_table": self._make_create_users_expr, @@ -787,7 +786,6 @@ def down(self, ctx): def test_multiple_migrations(self, sqlite_backend, tmp_path): """Run two sequential migrations.""" - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore self._register("mig_sqlite_test7.expressions", { "create_users_table": self._make_create_users_expr, @@ -806,23 +804,22 @@ class V002CreatePosts(NamedMigration): dependencies = ["v001"] def up(self, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - expr = CreateTableExpression( + expr = CreateTableStatement( sqlite_backend.dialect, table=Table(sqlite_backend.dialect, 'posts'), columns=[ ColumnDefinition(sqlite_backend.dialect, "id", SQLiteIntegerType(sqlite_backend.dialect), - constraints=[ColumnConstraint(sqlite_backend.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(sqlite_backend.dialect, branch=ColumnPrimaryKeyConstraint(sqlite_backend.dialect))], ), ColumnDefinition(sqlite_backend.dialect, "title", SQLiteTextType(dialect=sqlite_backend.dialect)), ], @@ -830,9 +827,9 @@ def up(self, ctx): sqlite_backend.execute(*expr.to_sql()) def down(self, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - DropTableExpression, + DropTableStatement, ) - expr = DropTableExpression(sqlite_backend.dialect, table=Table(sqlite_backend.dialect, 'posts'), if_exists=True) + expr = DropTableStatement(sqlite_backend.dialect, table=Table(sqlite_backend.dialect, 'posts'), if_exists=True) sqlite_backend.execute(*expr.to_sql()) self._register("mig_sqlite_test7.migrations", { @@ -855,7 +852,6 @@ def down(self, ctx): def test_dependency_check_passes(self, sqlite_backend, tmp_path): """When dependency is applied, the migration should run successfully.""" - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore self._register("mig_sqlite_test8.expressions", { "create_users_table": self._make_create_users_expr, @@ -900,23 +896,22 @@ class V001ParamMig(NamedMigration): table_name: str = "default_table" def up(self, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - expr = CreateTableExpression( + expr = CreateTableStatement( ctx.dialect, table=Table(ctx.dialect, self.table_name), columns=[ ColumnDefinition(sqlite_backend.dialect, "id", SQLiteIntegerType(sqlite_backend.dialect), - constraints=[ColumnConstraint(sqlite_backend.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(sqlite_backend.dialect, branch=ColumnPrimaryKeyConstraint(sqlite_backend.dialect))], ), ColumnDefinition(sqlite_backend.dialect, "value", SQLiteTextType(dialect=sqlite_backend.dialect)), ], @@ -924,9 +919,9 @@ def up(self, ctx): sqlite_backend.execute(*expr.to_sql()) def down(self, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - DropTableExpression, + DropTableStatement, ) - expr = DropTableExpression( + expr = DropTableStatement( ctx.dialect, table=Table(ctx.dialect, self.table_name), if_exists=True, @@ -1096,7 +1091,6 @@ async def test_async_record_store(self, tmp_path): AsyncSQLiteBackend, ) from rhosocial.activerecord.backend.migration.async_runner import AsyncMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore backend = AsyncSQLiteBackend(database=":memory:") await backend.connect() @@ -1139,23 +1133,22 @@ class V001ParamMig(AsyncNamedMigration): table_name: str = "default_table" async def run(self, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - expr = CreateTableExpression( + expr = CreateTableStatement( ctx.dialect, table=Table(ctx.dialect, self.table_name), columns=[ ColumnDefinition(ctx.dialect, "id", SQLiteIntegerType(ctx.dialect), - constraints=[ColumnConstraint(ctx.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(ctx.dialect, branch=ColumnPrimaryKeyConstraint(ctx.dialect))], ), ColumnDefinition(ctx.dialect, "value", SQLiteTextType(dialect=ctx.dialect)), ], @@ -1184,23 +1177,23 @@ async def run(self, ctx): def _make_create_users_expr_static(dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition(dialect, "name", SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, "email", SQLiteTextType(dialect=dialect)), @@ -1210,9 +1203,9 @@ def _make_create_users_expr_static(dialect): def _make_drop_users_expr_static(dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - DropTableExpression, + DropTableStatement, ) - return DropTableExpression(dialect, table=Table(dialect, 'users'), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, 'users'), if_exists=True) class TestBatchMigrationRunner: @@ -1269,7 +1262,6 @@ def test_topological_order(self): def test_migrate_up_in_order(self, sqlite_backend, tmp_path): from rhosocial.activerecord.backend.migration.batch_runner import BatchMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore register_temp_module("batch_up.migrations", dict( V001=type("V001", (NamedMigration,), { @@ -1298,7 +1290,6 @@ def test_migrate_up_in_order(self, sqlite_backend, tmp_path): def test_pending_versions(self, tmp_path): from rhosocial.activerecord.backend.migration.batch_runner import BatchMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore register_temp_module("batch_pending.migrations", dict( V001=type("V001", (NamedMigration,), { @@ -1312,7 +1303,6 @@ def test_pending_versions(self, tmp_path): )) try: store = JSONFileMigrationRecordStore(tmp_path / "batch_pending.json") - from rhosocial.activerecord.backend.migration.record import MigrationRecord from datetime import datetime, timezone store.record(MigrationRecord( version="v001", @@ -1329,7 +1319,6 @@ def test_pending_versions(self, tmp_path): def test_status_report(self, tmp_path): from rhosocial.activerecord.backend.migration.batch_runner import BatchMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore register_temp_module("batch_status.migrations", dict( V001=type("V001", (NamedMigration,), { @@ -1343,7 +1332,6 @@ def test_status_report(self, tmp_path): )) try: store = JSONFileMigrationRecordStore(tmp_path / "batch_status.json") - from rhosocial.activerecord.backend.migration.record import MigrationRecord from datetime import datetime, timezone store.record(MigrationRecord( version="v001", @@ -1378,7 +1366,6 @@ def test_missing_dependency_raises(self): def test_migrate_down_in_reverse(self, sqlite_backend, tmp_path): from rhosocial.activerecord.backend.migration.batch_runner import BatchMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore register_temp_module("batch_down.migrations", dict( V001=type("V001", (NamedMigration,), { @@ -1413,7 +1400,6 @@ def test_migrate_down_in_reverse(self, sqlite_backend, tmp_path): def test_single_transaction_rolls_back_on_failure(self, sqlite_backend, tmp_path): """single_transaction=True should roll back all migrations on failure.""" from rhosocial.activerecord.backend.migration.batch_runner import BatchMigrationRunner - from rhosocial.activerecord.backend.migration import JSONFileMigrationRecordStore register_temp_module("batch_txn.migrations", dict( V001=type("V001", (NamedMigration,), { @@ -1449,23 +1435,23 @@ def test_single_transaction_rolls_back_on_failure(self, sqlite_backend, tmp_path def _run_create_users(backend, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - expr = CreateTableExpression( + expr = CreateTableStatement( backend.dialect, table=Table(backend.dialect, 'users'), columns=[ ColumnDefinition(backend.dialect, "id", SQLiteIntegerType(backend.dialect), - constraints=[ColumnConstraint(backend.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(backend.dialect, branch=ColumnPrimaryKeyConstraint(backend.dialect))], ), ColumnDefinition(backend.dialect, "name", SQLiteTextType(dialect=backend.dialect)), ], @@ -1474,30 +1460,30 @@ def _run_create_users(backend, ctx): def _run_drop_users(backend, ctx): - from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableExpression - expr = DropTableExpression(backend.dialect, table=Table(backend.dialect, 'users'), if_exists=True) + from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableStatement + expr = DropTableStatement(backend.dialect, table=Table(backend.dialect, 'users'), if_exists=True) backend.execute(*expr.to_sql()) def _run_create_posts(backend, ctx): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( SQLiteIntegerType, SQLiteTextType, ) - expr = CreateTableExpression( + expr = CreateTableStatement( backend.dialect, table=Table(backend.dialect, 'posts'), columns=[ ColumnDefinition(backend.dialect, "id", SQLiteIntegerType(backend.dialect), - constraints=[ColumnConstraint(backend.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(backend.dialect, branch=ColumnPrimaryKeyConstraint(backend.dialect))], ), ColumnDefinition(backend.dialect, "title", SQLiteTextType(dialect=backend.dialect)), ], @@ -1506,6 +1492,6 @@ def _run_create_posts(backend, ctx): def _run_drop_posts(backend, ctx): - from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableExpression - expr = DropTableExpression(backend.dialect, table=Table(backend.dialect, 'posts'), if_exists=True) + from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableStatement + expr = DropTableStatement(backend.dialect, table=Table(backend.dialect, 'posts'), if_exists=True) backend.execute(*expr.to_sql()) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/backend/named_expression/example_queries.py b/tests/rhosocial/activerecord_test/feature/backend/named_expression/example_queries.py index 454621977..f63eb4eec 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/named_expression/example_queries.py +++ b/tests/rhosocial/activerecord_test/feature/backend/named_expression/example_queries.py @@ -2,58 +2,104 @@ """ Example named queries for testing. -This module contains sample query definitions for testing -the named procedure functionality (which calls named queries). +Sample query definitions for the named-procedure tests, which call named +queries. Each function takes the dialect it was resolved against and returns a +:class:`~rhosocial.activerecord.backend.expression.statements.dql.QueryStatement`. """ -from rhosocial.activerecord.backend.expression.statements.dql import Select +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements.dql import ( + LimitOffsetClause, + QueryStatement, +) + + +def _column(dialect, name: str) -> Column: + """A projection: a bare column reference.""" + return Column(dialect, name) + + +def _equals(dialect, column: str, value) -> ComparisonPredicate: + """`` = ``.""" + return ComparisonPredicate( + dialect, + "=", + Column(dialect, column), + Literal(dialect, value), + ) def get_all_users(dialect, limit: int = 100): """Get all users with optional limit.""" - return Select( - targets=["id", "name", "email"], + return QueryStatement( + dialect, + select=[_column(dialect, n) for n in ("id", "name", "email")], from_="users", - limit=limit, + limit_offset=LimitOffsetClause(dialect, limit=limit), ) def get_active_orders(dialect, status: str = "active"): """Get orders by status.""" - return Select( - targets=["id", "user_id", "total", "status", "created_at"], + return QueryStatement( + dialect, + select=[ + _column(dialect, n) + for n in ("id", "user_id", "total", "status", "created_at") + ], from_="orders", - where={"status": status}, + where=_equals(dialect, "status", status), ) def get_monthly_summary(dialect, month: str): """Get monthly summary data.""" - return Select( - targets=[ - "id", - "user_id", - "total", - "status", + return QueryStatement( + dialect, + select=[ + _column(dialect, n) + for n in ("id", "user_id", "total", "status") ], from_="orders", - where={"month": month}, + where=_equals(dialect, "month", month), ) def create_order_record(dialect, user_id: int, total: float, month: str): - """Insert a new order record.""" - return Select( - targets=["id"], + """Select the order record a caller is about to create. + + The name predates the expression layer and is kept for the named-query + fixture it identifies; the query itself is a projection over ``orders``. + """ + return QueryStatement( + dialect, + select=[_column(dialect, "id")], from_="orders", - where={"user_id": user_id, "total": total, "month": month}, + where=ComparisonPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, + "=", + Column(dialect, "user_id"), + Literal(dialect, user_id), + ), + ComparisonPredicate( + dialect, + "=", + Column(dialect, "total"), + Literal(dialect, total), + ), + ), ) def get_user_summary(dialect, user_id: int): """Get summary for a specific user.""" - return Select( - targets=["id", "name", "email"], + return QueryStatement( + dialect, + select=[_column(dialect, n) for n in ("id", "name", "email")], from_="users", - where={"id": user_id}, + where=_equals(dialect, "id", user_id), ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_cli.py b/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_cli.py index 3aed3898a..d6955457a 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_cli.py +++ b/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_cli.py @@ -7,10 +7,10 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Literal, - ExplainExpression, - DeleteExpression, + ExplainStatement, + DeleteStatement, ) from rhosocial.activerecord.backend.named_expression.cli import ( @@ -408,7 +408,7 @@ def test_execute_dry_run(self, capsys): module = types.ModuleType("test_queries") def active_users(dialect, limit: int = 100): - return QueryExpression(dialect, [Literal(dialect, 1)]) + return QueryStatement(dialect, [Literal(dialect, 1)]) module.active_users = active_users @@ -440,7 +440,7 @@ def test_execute_with_params(self, capsys): module = types.ModuleType("test_queries") def active_users(dialect, limit: int = 100): - return QueryExpression(dialect, [Literal(dialect, 1)]) + return QueryStatement(dialect, [Literal(dialect, 1)]) module.active_users = active_users @@ -677,7 +677,7 @@ def test_force_allows_non_select_dry_run(self, capsys): module.__all__ = ["insert_user"] def insert_user(dialect, name: str): - return DeleteExpression(dialect, Table(dialect, 'users')) + return DeleteStatement(dialect, Table(dialect, 'users')) module.insert_user = insert_user sys.modules["test_force_module"] = module @@ -941,8 +941,8 @@ def test_non_executable_exits(self): def test_explain_without_flag_exits(self): from rhosocial.activerecord.backend.named_expression.cli import _execute_expression - inner = QueryExpression(_DIALECT, [Literal(_DIALECT, 1)]) - expr = ExplainExpression(_DIALECT, inner) + inner = QueryStatement(_DIALECT, [Literal(_DIALECT, 1)]) + expr = ExplainStatement(_DIALECT, inner) args = Namespace( dry_run=False, force=False, @@ -955,7 +955,7 @@ def test_explain_without_flag_exits(self): def test_non_select_without_force_exits(self): from rhosocial.activerecord.backend.named_expression.cli import _execute_expression - expr = DeleteExpression(_DIALECT, Table(_DIALECT, 't')) + expr = DeleteStatement(_DIALECT, Table(_DIALECT, 't')) args = Namespace( dry_run=False, force=False, @@ -968,7 +968,7 @@ def test_non_select_without_force_exits(self): def test_execute_success(self): from rhosocial.activerecord.backend.named_expression.cli import _execute_expression - expr = QueryExpression(_DIALECT, [Literal(_DIALECT, 1)]) + expr = QueryStatement(_DIALECT, [Literal(_DIALECT, 1)]) args = Namespace( dry_run=False, force=False, @@ -998,11 +998,11 @@ def test_classify_clause(self): def test_classify_dql(self): from rhosocial.activerecord.backend.named_expression.cli import _classify_expression - result = _classify_expression(QueryExpression(_DIALECT, [Literal(_DIALECT, 1)])) + result = _classify_expression(QueryStatement(_DIALECT, [Literal(_DIALECT, 1)])) assert result == "DQL" def test_classify_dml(self): from rhosocial.activerecord.backend.named_expression.cli import _classify_expression - result = _classify_expression(DeleteExpression(_DIALECT, Table(_DIALECT, 't'))) + result = _classify_expression(DeleteStatement(_DIALECT, Table(_DIALECT, 't'))) assert result == "DML" diff --git a/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_resolver.py b/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_resolver.py index 85c765067..7b4cba9e3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_resolver.py +++ b/tests/rhosocial/activerecord_test/feature/backend/named_expression/test_resolver.py @@ -32,9 +32,9 @@ class DummyCallable: """Dummy callable for testing.""" def __call__(self, dialect, limit: int = 100): - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement - return QueryExpression(dialect) + return QueryStatement(dialect) class TestNamedExpressionResolverInit: @@ -238,12 +238,12 @@ def bad_class_func(dialect): resolver.execute(mock_dialect, {}) def test_execute_extra_params(self, mock_dialect): - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement module = types.ModuleType("test_queries") def func(dialect, limit: int = 100): - return QueryExpression(dialect) + return QueryStatement(dialect) module.func = func with patch("importlib.import_module", return_value=module): @@ -362,26 +362,26 @@ def _dialect(self): def test_classify_clause(self): from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import RawSQLExpression + from rhosocial.activerecord.backend.expression import RawSQL - result = _classify(RawSQLExpression(self._dialect(), "SELECT 1")) + result = _classify(RawSQL(self._dialect(), "SELECT 1")) assert result == ["CLAUSE"] def test_classify_dql(self): from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import QueryExpression, Literal + from rhosocial.activerecord.backend.expression import QueryStatement, Literal d = self._dialect() - q = QueryExpression(d, [Literal(d, 1)]) + q = QueryStatement(d, [Literal(d, 1)]) result = _classify(q) assert result == ["DQL"] def test_classify_dml(self): from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import InsertExpression, Literal + from rhosocial.activerecord.backend.expression import InsertStatement, Literal d = self._dialect() - expr = InsertExpression(d, Table(d, 't'), [Literal(d, 1)]) + expr = InsertStatement(d, Table(d, 't'), [Literal(d, 1)]) result = _classify(expr) assert result == ["DML"] @@ -394,10 +394,10 @@ def test_classify_create_table_is_clause(self): which test_classify_ddl_by_stubbed_statement_type covers. """ from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import CreateTableExpression + from rhosocial.activerecord.backend.expression import CreateTableStatement d = self._dialect() - expr = CreateTableExpression(d, Table(d, "t"), []) + expr = CreateTableStatement(d, Table(d, "t"), []) result = _classify(expr) assert result == ["CLAUSE"] @@ -408,10 +408,10 @@ def test_classify_begin_transaction_is_clause(self): has no core expression reaching it either. """ from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import BeginTransactionExpression + from rhosocial.activerecord.backend.expression import BeginTransactionStatement d = self._dialect() - expr = BeginTransactionExpression(d) + expr = BeginTransactionStatement(d) result = _classify(expr) assert result == ["CLAUSE"] @@ -431,13 +431,13 @@ def test_classify_function_call_is_clause(self): def test_classify_explain(self): from rhosocial.activerecord.backend.named_expression.resolver import _classify - from rhosocial.activerecord.backend.expression import ExplainExpression + from rhosocial.activerecord.backend.expression import ExplainStatement d = self._dialect() - from rhosocial.activerecord.backend.expression import QueryExpression, Literal + from rhosocial.activerecord.backend.expression import QueryStatement, Literal - inner = QueryExpression(d, [Literal(d, 1)]) - expr = ExplainExpression(d, inner) + inner = QueryStatement(d, [Literal(d, 1)]) + expr = ExplainStatement(d, inner) result = _classify(expr) assert result == ["EXPLAIN"] @@ -488,20 +488,20 @@ def f(dialect, x: int = 1): def test_probe_tags_success(self): from rhosocial.activerecord.backend.named_expression.resolver import _probe_tags - from rhosocial.activerecord.backend.expression import QueryExpression, Literal + from rhosocial.activerecord.backend.expression import QueryStatement, Literal def f(dialect, x: int = 1): - return QueryExpression(dialect, [Literal(dialect, 1)]) + return QueryStatement(dialect, [Literal(dialect, 1)]) result = _probe_tags(f, dialect=self._dialect()) assert result == ["DQL"] def test_probe_tags_returns_clause(self): from rhosocial.activerecord.backend.named_expression.resolver import _probe_tags - from rhosocial.activerecord.backend.expression import RawSQLExpression + from rhosocial.activerecord.backend.expression import RawSQL def f(dialect, x: int = 1): - return RawSQLExpression(dialect, "1") + return RawSQL(dialect, "1") result = _probe_tags(f, dialect=self._dialect()) assert result == ["CLAUSE"] @@ -603,10 +603,10 @@ def f(dialect, x: int = 1): def test_probe_tags_returns_clause_by_stubbed_dialect(self): from rhosocial.activerecord.backend.named_expression.resolver import _probe_tags - from rhosocial.activerecord.backend.expression import RawSQLExpression + from rhosocial.activerecord.backend.expression import RawSQL def f(dialect, x: int = 1): - return RawSQLExpression(dialect, "1") + return RawSQL(dialect, "1") from rhosocial.activerecord.backend.dialect import SQLDialectBase result = _probe_tags(f, dialect=MagicMock(spec=SQLDialectBase)) diff --git a/tests/rhosocial/activerecord_test/feature/backend/schema/test_serialization.py b/tests/rhosocial/activerecord_test/feature/backend/schema/test_serialization.py index 8352e5e5e..ceaed59f3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/schema/test_serialization.py +++ b/tests/rhosocial/activerecord_test/feature/backend/schema/test_serialization.py @@ -18,12 +18,12 @@ DecimalType, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, IndexInfo, IndexColumnInfo, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ColumnNullable, TableType, IndexType, @@ -64,7 +64,7 @@ def test_nested_array_type(self): _data_type_to_dict, _data_type_from_dict, ) - dt = ArrayType(None, IntegerType(), dimensions=2) + dt = ArrayType(None, element_type=IntegerType(), dimensions=2) restored = _data_type_from_dict(_data_type_to_dict(dt)) assert isinstance(restored.element_type, IntegerType) assert restored.dimensions == 2 @@ -88,13 +88,13 @@ def test_decimal_type(self): class TestSnapshotSerialization: @pytest.fixture def snapshot(self): - db_info = DatabaseInfo( + db_info = IntrospectionDatabaseInfo( name="testdb", version="16.0", version_tuple=(16, 0, 0), vendor="postgresql", ) - col = ColumnInfo( + col = IntrospectionColumnInfo( name="id", table_name="users", schema="public", @@ -104,7 +104,7 @@ def snapshot(self): nullable=ColumnNullable.NOT_NULL, is_primary_key=True, ) - col2 = ColumnInfo( + col2 = IntrospectionColumnInfo( name="name", table_name="users", schema="public", @@ -113,7 +113,7 @@ def snapshot(self): parsed_data_type=VarCharType(None, 255), nullable=ColumnNullable.NULLABLE, ) - tbl = TableInfo( + tbl = IntrospectionTableInfo( name="users", schema="public", columns=[col, col2], @@ -247,7 +247,7 @@ def test_index_type_change(self): class TestFKEquivalence: def test_same_fk(self): differ = SchemaDiffer() - old_fk = ForeignKeyInfo( + old_fk = IntrospectionForeignKeyInfo( name="fk_user", table_name="orders", columns=["user_id"], @@ -255,7 +255,7 @@ def test_same_fk(self): referenced_columns=["id"], on_delete=ReferentialAction.CASCADE, ) - new_fk = ForeignKeyInfo( + new_fk = IntrospectionForeignKeyInfo( name="fk_user", table_name="orders", columns=["user_id"], @@ -267,12 +267,12 @@ def test_same_fk(self): def test_referenced_table_change(self): differ = SchemaDiffer() - old_fk = ForeignKeyInfo( + old_fk = IntrospectionForeignKeyInfo( name="fk_ref", table_name="t", columns=["x"], referenced_table="a", referenced_columns=["id"], ) - new_fk = ForeignKeyInfo( + new_fk = IntrospectionForeignKeyInfo( name="fk_ref", table_name="t", columns=["x"], referenced_table="b", referenced_columns=["id"], @@ -281,12 +281,12 @@ def test_referenced_table_change(self): def test_on_delete_change(self): differ = SchemaDiffer() - old_fk = ForeignKeyInfo( + old_fk = IntrospectionForeignKeyInfo( name="fk", table_name="t", columns=["x"], referenced_table="a", referenced_columns=["id"], on_delete=ReferentialAction.CASCADE, ) - new_fk = ForeignKeyInfo( + new_fk = IntrospectionForeignKeyInfo( name="fk", table_name="t", columns=["x"], referenced_table="a", referenced_columns=["id"], on_delete=ReferentialAction.SET_NULL, @@ -302,7 +302,7 @@ def test_on_delete_change(self): class TestDiffTableIntegration: def test_index_modification_detected(self): differ = SchemaDiffer() - old_tbl = TableInfo( + old_tbl = IntrospectionTableInfo( name="t", indexes=[ IndexInfo( @@ -311,7 +311,7 @@ def test_index_modification_detected(self): ), ], ) - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="t", indexes=[ IndexInfo( @@ -326,20 +326,20 @@ def test_index_modification_detected(self): def test_fk_modification_detected(self): differ = SchemaDiffer() - old_tbl = TableInfo( + old_tbl = IntrospectionTableInfo( name="t", foreign_keys=[ - ForeignKeyInfo( + IntrospectionForeignKeyInfo( name="fk", table_name="t", columns=["x"], referenced_table="a", referenced_columns=["id"], ), ], ) - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="t", foreign_keys=[ - ForeignKeyInfo( + IntrospectionForeignKeyInfo( name="fk", table_name="t", columns=["x"], referenced_table="b", referenced_columns=["id"], @@ -352,7 +352,7 @@ def test_fk_modification_detected(self): def test_unchanged_index_not_reported(self): differ = SchemaDiffer() - old_tbl = TableInfo( + old_tbl = IntrospectionTableInfo( name="t", indexes=[ IndexInfo( @@ -361,7 +361,7 @@ def test_unchanged_index_not_reported(self): ), ], ) - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="t", indexes=[ IndexInfo( @@ -385,26 +385,26 @@ class TestSnapshotDiffRoundtrip: @pytest.fixture def base_snapshot(self): - db_info = DatabaseInfo( + db_info = IntrospectionDatabaseInfo( name="testdb", version="16.0", version_tuple=(16, 0, 0), vendor="postgresql", ) cols = [ - ColumnInfo( + IntrospectionColumnInfo( name="id", table_name="users", schema="public", ordinal_position=1, data_type="INTEGER", parsed_data_type=IntegerType(), nullable=ColumnNullable.NOT_NULL, is_primary_key=True, ), - ColumnInfo( + IntrospectionColumnInfo( name="name", table_name="users", schema="public", ordinal_position=2, data_type="VARCHAR(255)", parsed_data_type=VarCharType(None, 255), nullable=ColumnNullable.NULLABLE, ), ] - tbl = TableInfo( + tbl = IntrospectionTableInfo( name="users", schema="public", columns=cols, table_type=TableType.BASE_TABLE, ) @@ -425,10 +425,10 @@ def test_identical_snapshots_produce_empty_diff(self, base_snapshot): def test_added_table(self, base_snapshot): db_info = base_snapshot.database_info - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="posts", schema="public", columns=[ - ColumnInfo( + IntrospectionColumnInfo( name="id", table_name="posts", schema="public", ordinal_position=1, data_type="INTEGER", parsed_data_type=IntegerType(), @@ -465,13 +465,13 @@ def test_removed_table(self, base_snapshot): def test_added_column(self, base_snapshot): db_info = base_snapshot.database_info old_cols = base_snapshot.tables["users"].columns - new_col = ColumnInfo( + new_col = IntrospectionColumnInfo( name="email", table_name="users", schema="public", ordinal_position=3, data_type="VARCHAR(255)", parsed_data_type=VarCharType(None, 255), nullable=ColumnNullable.NULLABLE, ) - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="users", schema="public", columns=list(old_cols) + [new_col], table_type=TableType.BASE_TABLE, @@ -498,7 +498,7 @@ def test_index_change(self, base_snapshot): name="idx_name", table_name="users", is_unique=False, columns=[IndexColumnInfo("name")], ) - new_tbl = TableInfo( + new_tbl = IntrospectionTableInfo( name="users", schema="public", columns=old_tbl.columns, indexes=[idx], diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_backend_explain.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_backend_explain.py index 4af1f1d00..33d8a0b38 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_backend_explain.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_backend_explain.py @@ -14,7 +14,7 @@ import pytest -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.explain import ( BaseExplainResult, SyncExplainBackendProtocol, @@ -22,12 +22,12 @@ SyncExplainBackendMixin, AsyncExplainBackendMixin, ) -from rhosocial.activerecord.backend.expression import WildcardExpression +from rhosocial.activerecord.backend.expression import AllColumnsWildcard from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements import ( ExplainOptions, ExplainType, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.explain import ( @@ -68,9 +68,9 @@ def sync_backend(): @pytest.fixture() def query_expr(dialect): """Simple SELECT * FROM items expression.""" - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "items")), ) @@ -281,17 +281,17 @@ def indexed_backend(self): @pytest.fixture() def full_scan_result(self, indexed_backend, dialect): - expr = RawSQLExpression(dialect, "SELECT * FROM orders") + expr = RawSQL(dialect, "SELECT * FROM orders") return indexed_backend.explain(expr) @pytest.fixture() def index_lookup_result(self, indexed_backend, dialect): - expr = RawSQLExpression(dialect, "SELECT * FROM orders WHERE status = 'pending'") + expr = RawSQL(dialect, "SELECT * FROM orders WHERE status = 'pending'") return indexed_backend.explain(expr) @pytest.fixture() def covering_index_result(self, indexed_backend, dialect): - expr = RawSQLExpression(dialect, "SELECT sku FROM order_items WHERE order_id = 1") + expr = RawSQL(dialect, "SELECT sku FROM order_items WHERE order_id = 1") return indexed_backend.explain(expr) # --- full scan --- @@ -377,17 +377,17 @@ def qp_opts(self): @pytest.fixture() def full_scan_result(self, indexed_backend, dialect, qp_opts): - expr = RawSQLExpression(dialect, "SELECT * FROM orders") + expr = RawSQL(dialect, "SELECT * FROM orders") return indexed_backend.explain(expr, qp_opts) @pytest.fixture() def index_lookup_result(self, indexed_backend, dialect, qp_opts): - expr = RawSQLExpression(dialect, "SELECT * FROM orders WHERE status = 'pending'") + expr = RawSQL(dialect, "SELECT * FROM orders WHERE status = 'pending'") return indexed_backend.explain(expr, qp_opts) @pytest.fixture() def covering_index_result(self, indexed_backend, dialect, qp_opts): - expr = RawSQLExpression(dialect, "SELECT sku FROM order_items WHERE order_id = 1") + expr = RawSQL(dialect, "SELECT sku FROM order_items WHERE order_id = 1") return indexed_backend.explain(expr, qp_opts) # --- full scan --- diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_explain.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_explain.py index 4a7d41cfe..f73a2bd7b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_explain.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_explain.py @@ -1,8 +1,9 @@ # tests/rhosocial/activerecord_test/feature/backend/sqlite/test_explain.py -from rhosocial.activerecord.backend.expression.statements import ExplainType, ExplainOptions, ExplainExpression +from rhosocial.activerecord.backend.expression.statements import ExplainType, ExplainOptions, ExplainStatement from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.column_types import NumericColumn from rhosocial.activerecord.backend.expression.objects import Table @@ -10,16 +11,16 @@ def test_format_explain_basic(): """Test basic EXPLAIN SQL formatting""" dialect = SQLiteDialect() - # Test basic EXPLAIN - need to create an ExplainExpression + # Test basic EXPLAIN - need to create an ExplainStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement # Create a query expression to explain - query_expr = QueryExpression( + query_expr = QueryStatement( dialect=dialect, select=[Literal(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")) ) - explain_expr = ExplainExpression(dialect, statement=query_expr) + explain_expr = ExplainStatement(dialect, statement=query_expr) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql assert "SELECT" in explain_sql @@ -30,19 +31,19 @@ def test_format_explain_query_plan(): """Test EXPLAIN QUERY PLAN SQL formatting""" dialect = SQLiteDialect() - from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression import NumericColumn from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement - query_expr = QueryExpression( + query_expr = QueryStatement( dialect=dialect, select=[Literal(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), - where=Column(dialect, "id") == Literal(dialect, 1), + where=NumericColumn(dialect, "id") == Literal(dialect, 1), ) options = ExplainOptions(analyze=False, type=ExplainType.QUERY_PLAN) - explain_expr = ExplainExpression(dialect, statement=query_expr, options=options) + explain_expr = ExplainStatement(dialect, statement=query_expr, options=options) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql @@ -54,29 +55,29 @@ def test_format_explain_with_complex_sql(): # Test with JOIN from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement # Create a more complex query - query_expr = QueryExpression( + query_expr = QueryStatement( dialect=dialect, select=[Literal(dialect, "u.*"), Literal(dialect, "o.total")], from_=NamedRelationRef(dialect, Table(dialect, "users"), alias="u"), # Note: JOIN expressions would be handled differently in a real scenario ) - explain_expr = ExplainExpression(dialect, statement=query_expr) + explain_expr = ExplainStatement(dialect, statement=query_expr) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql # Test with subquery - QueryExpression( + QueryStatement( dialect=dialect, select=[Column(dialect, "user_id")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), - where=Column(dialect, "total") > Literal(dialect, 100), + where=NumericColumn(dialect, "total") > Literal(dialect, 100), ) - main_query = QueryExpression( + main_query = QueryStatement( dialect=dialect, select=[Literal(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -84,7 +85,7 @@ def test_format_explain_with_complex_sql(): ) options = ExplainOptions(type=ExplainType.QUERY_PLAN) - explain_expr = ExplainExpression(dialect, statement=main_query, options=options) + explain_expr = ExplainStatement(dialect, statement=main_query, options=options) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql @@ -95,10 +96,10 @@ def test_format_explain_with_options(): from rhosocial.activerecord.backend.expression import Literal from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement # Create a query expression to explain - query_expr = QueryExpression( + query_expr = QueryStatement( dialect=dialect, select=[Literal(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")) ) @@ -108,14 +109,14 @@ def test_format_explain_with_options(): for format_type in [ExplainFormat.TEXT, ExplainFormat.JSON]: options = ExplainOptions(format=format_type) - explain_expr = ExplainExpression(dialect, statement=query_expr, options=options) + explain_expr = ExplainStatement(dialect, statement=query_expr, options=options) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql # Test with costs option # Note: SQLite ignores costs option but we still test the interface options = ExplainOptions(costs=False) - explain_expr = ExplainExpression(dialect, statement=query_expr, options=options) + explain_expr = ExplainStatement(dialect, statement=query_expr, options=options) explain_sql, params = dialect.format_explain_statement(explain_expr) assert "EXPLAIN" in explain_sql @@ -126,7 +127,7 @@ def test_format_explain_integration(): from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import Literal, Column from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression, ExplainExpression, ExplainOptions + from rhosocial.activerecord.backend.expression.statements import QueryStatement, ExplainStatement, ExplainOptions from rhosocial.activerecord.backend.expression.statements import ExplainType as ExpType # Renamed to avoid conflict backend = SQLiteBackend(database=":memory:") @@ -150,22 +151,22 @@ def test_format_explain_integration(): ) # Test basic SELECT explain - # We need to use the proper ExplainExpression approach - query_expr = QueryExpression( + # We need to use the proper ExplainStatement approach + query_expr = QueryStatement( dialect=backend.dialect, select=[Literal(backend.dialect, "*")], from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "users")), # No where clause to avoid parameter binding issues in EXPLAIN ) - explain_expr = ExplainExpression(backend.dialect, statement=query_expr) + explain_expr = ExplainStatement(backend.dialect, statement=query_expr) explain_sql, params = backend.dialect.format_explain_statement(explain_expr) result = backend.execute(explain_sql, params, options=ExecutionOptions(stmt_type=StatementType.SELECT)) assert result is not None # Test SELECT with JOIN explain - query_expr = QueryExpression( + query_expr = QueryStatement( dialect=backend.dialect, select=[Column(backend.dialect, "name", "u1")], from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "users"), alias="u1"), @@ -173,7 +174,7 @@ def test_format_explain_integration(): ) options = ExplainOptions(type=ExpType.QUERY_PLAN) - explain_expr = ExplainExpression(backend.dialect, statement=query_expr, options=options) + explain_expr = ExplainStatement(backend.dialect, statement=query_expr, options=options) explain_sql, params = backend.dialect.format_explain_statement(explain_expr) result = backend.execute(explain_sql, params, options=ExecutionOptions(stmt_type=StatementType.SELECT)) @@ -188,7 +189,7 @@ def test_format_explain_with_transactions(): from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import Literal from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.statements import QueryExpression, ExplainExpression, ExplainOptions + from rhosocial.activerecord.backend.expression.statements import QueryStatement, ExplainStatement, ExplainOptions from rhosocial.activerecord.backend.expression.statements import ExplainType as ExpType # Renamed to avoid conflict backend = SQLiteBackend(database=":memory:") @@ -213,8 +214,8 @@ def test_format_explain_with_transactions(): ) # Test simple query - # We need to use the proper ExplainExpression approach - query_expr = QueryExpression( + # We need to use the proper ExplainStatement approach + query_expr = QueryStatement( dialect=backend.dialect, select=[Literal(backend.dialect, "*")], from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "test")), @@ -222,21 +223,21 @@ def test_format_explain_with_transactions(): ) options = ExplainOptions(type=ExpType.QUERY_PLAN) - explain_expr = ExplainExpression(backend.dialect, statement=query_expr, options=options) + explain_expr = ExplainStatement(backend.dialect, statement=query_expr, options=options) explain_sql, params = backend.dialect.format_explain_statement(explain_expr) result = backend.execute(explain_sql, params, options=ExecutionOptions(stmt_type=StatementType.SELECT)) assert result is not None # Test aggregate query - agg_query = QueryExpression( + agg_query = QueryStatement( dialect=backend.dialect, select=[Literal(backend.dialect, "COUNT(*) as cnt")], from_=NamedRelationRef(backend.dialect, Table(backend.dialect, "test")), # group_by_having=GroupByHavingClause(backend.dialect, group_by=[Column(backend.dialect, "value")]) # Simplified # noqa: E501 ) - explain_expr = ExplainExpression(backend.dialect, statement=agg_query) + explain_expr = ExplainStatement(backend.dialect, statement=agg_query) explain_sql, params = backend.dialect.format_explain_statement(explain_expr) result = backend.execute(explain_sql, params, options=ExecutionOptions(stmt_type=StatementType.SELECT)) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_roundtrip_complex_with.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_roundtrip_complex_with.py index ff28cd665..bfc245909 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_roundtrip_complex_with.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_roundtrip_complex_with.py @@ -5,9 +5,9 @@ https://sqlite.org/lang_with.html — Outlandish Recursive Query Examples -These queries are built as deep expression trees (CTEExpression, -WithQueryExpression, SetOperationExpression, ValuesExpression, QueryExpression, -FunctionCall, arithmetic operators, concat_op, ExistsExpression, etc.) and +These queries are built as deep expression trees (CTEClause, +WithStatement, SetOperation, ValuesExpression, QueryStatement, +FunctionCall, arithmetic operators, concat_op, ExistsPredicate, etc.) and round-tripped through dict / JSON / XML to stress-test the serialization machinery with deeply nested recursive structures. """ @@ -15,6 +15,7 @@ import pytest import sqlite3 +from rhosocial.activerecord.backend.expression import StringColumn from rhosocial.activerecord.backend.expression.types import CustomType from rhosocial.activerecord.backend.expression import ( Column, @@ -22,15 +23,14 @@ NumericColumn, Literal, FunctionCall, - QueryExpression, + QueryStatement, Subquery, - WildcardExpression, -) + AllColumnsWildcard,) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.query_sources import ( - CTEExpression, - WithQueryExpression, - SetOperationExpression, + CTEClause, + WithStatement, + SetOperation, ValuesExpression, ) from rhosocial.activerecord.backend.expression.query_parts import ( @@ -40,7 +40,7 @@ JoinClause, ) from rhosocial.activerecord.backend.expression.advanced_functions import ( - ExistsExpression, + ExistsPredicate, CaseExpression, ) from rhosocial.activerecord.backend.expression.functions.string import concat_op @@ -55,10 +55,38 @@ deserialize_xml, ) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.testsuite.utils.expression import assert_params_equal +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table +def _same(a, b): + """Structural equality for a parameter tree. + + Expressions define no ``__eq__``, so comparing two parameter dicts + directly compares nested nodes by identity and fails on a round-trip that + worked. Two nodes match when they are the same class and their parameters + match all the way down. + """ + if isinstance(a, BaseExpression) or isinstance(b, BaseExpression): + return ( + type(a) is type(b) + and _same(a.get_params(), b.get_params()) + ) + if isinstance(a, dict) and isinstance(b, dict): + return set(a) == set(b) and all(_same(a[k], b[k]) for k in a) + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) + return type(a) is type(b) and a == b + + +def assert_params_equal(a, b, path="params"): + assert _same(a, b), ( + f"{path}: the round-trip changed the parameters\n" + f" before: {a}\n" + f" after : {b}" + ) + + @pytest.fixture def sqlite_dialect(): d = SQLiteDialect(version=(3, 53, 0)) @@ -70,27 +98,27 @@ def build_mandelbrot(d): def _make_axis_union(cte_name, column, start, step, limit_): base = ValuesExpression(d, [(start,)]) - rec = QueryExpression( + rec = QueryStatement( d, select=[NumericColumn(d, column) + Literal(d, step)], from_=NamedRelationRef(d, Table(d, cte_name)), - where=Column(d, column) < Literal(d, limit_), + where=NumericColumn(d, column) < Literal(d, limit_), ) - return SetOperationExpression(d, left=base, right=rec, operation="UNION", all_=True) + return SetOperation(d, left=base, right=rec, op="union_all") - xaxis_cte = CTEExpression( + xaxis_cte = CTEClause( d, name="xaxis", query=_make_axis_union("xaxis", "x", -2.0, 0.05, 1.2), columns=["x"] ) - yaxis_cte = CTEExpression( + yaxis_cte = CTEClause( d, name="yaxis", query=_make_axis_union("yaxis", "y", -1.0, 0.1, 1.0), columns=["y"] ) - m_seed = QueryExpression( + m_seed = QueryStatement( d, select=[Literal(d, 0), Column(d, "x"), Column(d, "y"), Literal(d, 0.0), Literal(d, 0.0)], from_=[NamedRelationRef(d, Table(d, "xaxis")), NamedRelationRef(d, Table(d, "yaxis"))], ) - m_step = QueryExpression( + m_step = QueryStatement( d, select=[ NumericColumn(d, "iter") + Literal(d, 1), @@ -98,7 +126,7 @@ def _make_axis_union(cte_name, column, start, step, limit_): Column(d, "cy"), NumericColumn(d, "x") * NumericColumn(d, "x") - NumericColumn(d, "y") * NumericColumn(d, "y") + NumericColumn(d, "cx"), - Literal(d, 2.0) * NumericColumn(d, "x") * NumericColumn(d, "y") + NumericColumn(d, "cy"), + NumericColumn(d, "x") * NumericColumn(d, "y") * Literal(d, 2.0) + NumericColumn(d, "cy"), ], from_=NamedRelationRef(d, Table(d, "m")), where=( @@ -107,62 +135,62 @@ def _make_axis_union(cte_name, column, start, step, limit_): ) & (NumericColumn(d, "iter") < Literal(d, 28)), ) - m_cte = CTEExpression( + m_cte = CTEClause( d, name="m", - query=SetOperationExpression(d, left=m_seed, right=m_step, operation="UNION", all_=True), + query=SetOperation(d, left=m_seed, right=m_step, op="union_all"), columns=["iter", "cx", "cy", "x", "y"], ) - m2_query = QueryExpression( + m2_query = QueryStatement( d, select=[FunctionCall(d, "MAX", Column(d, "iter")), Column(d, "cx"), Column(d, "cy")], from_=NamedRelationRef(d, Table(d, "m")), group_by_having=GroupByHavingClause(d, group_by=[Column(d, "cx"), Column(d, "cy")]), ) - m2_cte = CTEExpression(d, name="m2", query=m2_query, columns=["iter", "cx", "cy"]) + m2_cte = CTEClause(d, name="m2", query=m2_query, columns=["iter", "cx", "cy"]) min_expr = FunctionCall(d, "MIN", NumericColumn(d, "iter") / Literal(d, 7), Literal(d, 4)) - substr_expr = FunctionCall(d, "SUBSTR", Literal(d, " .+*#"), Literal(d, 1) + min_expr, Literal(d, 1)) - a_query = QueryExpression( + substr_expr = FunctionCall(d, "SUBSTR", Literal(d, " .+*#"), min_expr + Literal(d, 1), Literal(d, 1)) + a_query = QueryStatement( d, select=[FunctionCall(d, "GROUP_CONCAT", substr_expr, Literal(d, ""))], from_=NamedRelationRef(d, Table(d, "m2")), group_by_having=GroupByHavingClause(d, group_by=[Column(d, "cy")]), ) - a_cte = CTEExpression(d, name="a", query=a_query, columns=["t"]) + a_cte = CTEClause(d, name="a", query=a_query, columns=["t"]) - main = QueryExpression( + main = QueryStatement( d, select=[FunctionCall(d, "GROUP_CONCAT", FunctionCall(d, "RTRIM", Column(d, "t")), Literal(d, b"\x0a"))], from_=NamedRelationRef(d, Table(d, "a")), ) - return WithQueryExpression( - d, ctes=[xaxis_cte, yaxis_cte, m_cte, m2_cte, a_cte], main_query=main, recursive=True + return WithStatement( + d, ctes=[xaxis_cte, yaxis_cte, m_cte, m2_cte, a_cte], query=main, recursive=True ) def build_sudoku(d, puzzle="53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79"): """Build the Sudoku solver query from the SQLite docs as an expression tree.""" - input_cte = CTEExpression( + input_cte = CTEClause( d, name="input", query=ValuesExpression(d, [(puzzle,)]), columns=["sud"] ) digits_seed = ValuesExpression(d, [("1", 1)]) - digits_step = QueryExpression( + digits_step = QueryStatement( d, select=[ cast(d, NumericColumn(d, "lp") + Literal(d, 1), CustomType(d, raw="TEXT")), NumericColumn(d, "lp") + Literal(d, 1), ], from_=NamedRelationRef(d, Table(d, "digits")), - where=Column(d, "lp") < Literal(d, 9), + where=NumericColumn(d, "lp") < Literal(d, 9), ) - digits_cte = CTEExpression( + digits_cte = CTEClause( d, name="digits", - query=SetOperationExpression(d, left=digits_seed, right=digits_step, operation="UNION", all_=True), + query=SetOperation(d, left=digits_seed, right=digits_step, op="union_all"), columns=["z", "lp"], ) @@ -170,7 +198,7 @@ def build_sudoku(d, puzzle="53..7....6..195....98....6.8...6...34..8.3..17...2.. ind = NumericColumn(d, "ind") z = Column(d, "z") - x_seed = QueryExpression( + x_seed = QueryStatement( d, select=[Column(d, "sud"), FunctionCall(d, "INSTR", Column(d, "sud"), Literal(d, "."))], from_=NamedRelationRef(d, Table(d, "input")), @@ -192,23 +220,23 @@ def build_sudoku(d, puzzle="53..7....6..195....98....6.8...6...34..8.3..17...2.. six = Literal(d, 6) twenty_seven = Literal(d, 27) - row_cond = Column(d, "z", "z") == FunctionCall( + row_cond = StringColumn(d, "z", "z") == FunctionCall( d, "SUBSTR", s, ((ind - one) / nine) * nine + lp, one ) - col_cond = Column(d, "z", "z") == FunctionCall( + col_cond = StringColumn(d, "z", "z") == FunctionCall( d, "SUBSTR", s, ((ind - one) % nine) + (lp - one) * nine + one, one ) - box_cond = Column(d, "z", "z") == FunctionCall( + box_cond = StringColumn(d, "z", "z") == FunctionCall( d, "SUBSTR", s, (((ind - one) / three) % three) * three + ((ind - one) / twenty_seven) * twenty_seven + lp + ((lp - one) / three) * six, one, ) - not_exists = ~ExistsExpression( + not_exists = ~ExistsPredicate( d, Subquery( d, - QueryExpression( + QueryStatement( d, select=[one], from_=NamedRelationRef(d, Table(d, "digits"), alias="lp"), @@ -217,26 +245,26 @@ def build_sudoku(d, puzzle="53..7....6..195....98....6.8...6...34..8.3..17...2.. ), ) - x_step = QueryExpression( + x_step = QueryStatement( d, select=[s_candidate, FunctionCall(d, "INSTR", s_candidate, Literal(d, "."))], from_=[NamedRelationRef(d, Table(d, "x")), NamedRelationRef(d, Table(d, "digits"), alias="z")], where=(ind > zero) & not_exists, ) - x_cte = CTEExpression( + x_cte = CTEClause( d, name="x", - query=SetOperationExpression(d, left=x_seed, right=x_step, operation="UNION", all_=True), + query=SetOperation(d, left=x_seed, right=x_step, op="union_all"), columns=["s", "ind"], ) - main = QueryExpression( + main = QueryStatement( d, select=[Column(d, "s")], from_=NamedRelationRef(d, Table(d, "x")), - where=Column(d, "ind") == Literal(d, 0), + where=NumericColumn(d, "ind") == Literal(d, 0), ) - return WithQueryExpression(d, ctes=[input_cte, digits_cte, x_cte], main_query=main, recursive=True) + return WithStatement(d, ctes=[input_cte, digits_cte, x_cte], query=main, recursive=True) def build_bom(d): @@ -252,13 +280,13 @@ def build_bom(d): SELECT child_id, SUM(total_qty) AS required_count FROM BOM_Explosion GROUP BY child_id; """ - anchor = QueryExpression( + anchor = QueryStatement( d, select=[Column(d, "child_id"), Column(d, "quantity"), Column(d, "quantity").as_("total_qty")], from_=NamedRelationRef(d, Table(d, "parts_tree")), - where=Column(d, "parent_id") == Literal(d, 1), + where=NumericColumn(d, "parent_id") == Literal(d, 1), ) - step = QueryExpression( + step = QueryStatement( d, select=[ Column(d, "child_id", "c"), @@ -271,17 +299,17 @@ def build_bom(d): ], where=ComparisonPredicate(d, "=", Column(d, "parent_id", "c"), Column(d, "child_id", "b")), ) - union = SetOperationExpression(d, left=anchor, right=step, operation="UNION", all_=True) - cte = CTEExpression( + union = SetOperation(d, left=anchor, right=step, op="union_all") + cte = CTEClause( d, name="BOM_Explosion", query=union, columns=["child_id", "quantity", "total_qty"] ) - main = QueryExpression( + main = QueryStatement( d, select=[Column(d, "child_id"), FunctionCall(d, "SUM", Column(d, "total_qty")).as_("required_count")], from_=NamedRelationRef(d, Table(d, "BOM_Explosion")), group_by_having=GroupByHavingClause(d, group_by=[Column(d, "child_id")]), ) - return WithQueryExpression(d, ctes=[cte], main_query=main) + return WithStatement(d, ctes=[cte], query=main) def build_flight_paths(d): @@ -297,7 +325,7 @@ def build_flight_paths(d): ) SELECT * FROM FlightPaths WHERE destination = 'JFK' ORDER BY price ASC LIMIT 1; """ - anchor = QueryExpression( + anchor = QueryStatement( d, select=[ Column(d, "destination"), @@ -306,9 +334,9 @@ def build_flight_paths(d): concat_op(d, Literal(d, "TPE -> "), Column(d, "destination")), ], from_=NamedRelationRef(d, Table(d, "flights")), - where=Column(d, "departure") == Literal(d, "TPE"), + where=StringColumn(d, "departure") == Literal(d, "TPE"), ) - step = QueryExpression( + step = QueryStatement( d, select=[ Column(d, "destination", "f"), @@ -328,21 +356,21 @@ def build_flight_paths(d): concat_op(d, Literal(d, "%"), Column(d, "destination", "f"), Literal(d, "%")), ) ) - & (Column(d, "depth", "p") < Literal(d, 5)), + & (NumericColumn(d, "depth", "p") < Literal(d, 5)), ) - union = SetOperationExpression(d, left=anchor, right=step, operation="UNION", all_=True) - cte = CTEExpression( + union = SetOperation(d, left=anchor, right=step, op="union_all") + cte = CTEClause( d, name="FlightPaths", query=union, columns=["destination", "price", "depth", "path"] ) - main = QueryExpression( + main = QueryStatement( d, - select=[WildcardExpression(d)], + select=[AllColumnsWildcard(d)], from_=NamedRelationRef(d, Table(d, "FlightPaths")), - where=Column(d, "destination") == Literal(d, "JFK"), + where=StringColumn(d, "destination") == Literal(d, "JFK"), order_by=OrderByClause(d, [Column(d, "price")]), limit_offset=LimitOffsetClause(d, limit=Literal(d, 1)), ) - return WithQueryExpression(d, ctes=[cte], main_query=main) + return WithStatement(d, ctes=[cte], query=main) def build_game_of_life(d): @@ -358,7 +386,7 @@ def build_game_of_life(d): gen = NumericColumn(d, "gen") def neighbor(dx, dy): - return QueryExpression( + return QueryStatement( d, select=[ (x + Literal(d, dx)).as_("x"), @@ -371,12 +399,12 @@ def neighbor(dx, dy): offsets = [(0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1)] expansion = neighbor(*offsets[0]) for dx, dy in offsets[1:]: - expansion = SetOperationExpression( - d, left=expansion, right=neighbor(dx, dy), operation="UNION", all_=True + expansion = SetOperation( + d, left=expansion, right=neighbor(dx, dy), op="union_all" ) nb = Subquery(d, expansion, alias="nb") - neighbors = QueryExpression( + neighbors = QueryStatement( d, select=[Column(d, "x"), Column(d, "y"), Column(d, "gen"), FunctionCall(d, "COUNT", Literal(d, "*")).as_("cnt")], from_=nb, @@ -389,7 +417,7 @@ def neighbor(dx, dy): alive = CaseExpression( d, cases=[ - (l_x_null & (Column(d, "cnt", "n") == Literal(d, 3)), Literal(d, 1)), + (l_x_null & (NumericColumn(d, "cnt", "n") == Literal(d, 3)), Literal(d, 1)), ((~l_x_null) & InPredicate(d, Column(d, "cnt", "n"), Literal(d, (2, 3))), Literal(d, 1)), ], else_result=Literal(d, 0), @@ -400,7 +428,7 @@ def neighbor(dx, dy): & ComparisonPredicate(d, "=", Column(d, "gen", "l"), Column(d, "gen", "n")) ) join = JoinClause(d, left_table=nsub, right_table=live, join_type="LEFT JOIN", condition=on) - return QueryExpression( + return QueryStatement( d, select=[ Column(d, "x", "n"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_traversal_sqlite.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_traversal_sqlite.py index 26dffe3cf..c80afabc8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_traversal_sqlite.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_expression_traversal_sqlite.py @@ -14,7 +14,7 @@ COVERAGE: - INSERT/UPDATE/DELETE with dialect_options (e.g., INSERT ... ON CONFLICT IGNORE) - DDL statements with dialect_options (CREATE INDEX, DROP TABLE with IF EXISTS) - - SQLite-specific expressions (SQLiteReindexExpression, SQLiteMatchPredicate, + - SQLite-specific expressions (SQLiteReindexStatement, SQLiteMatchPredicate, SQLiteTableListExpression, SQLiteColumnInfoExpression) See also: dummy2/test_expression_traversal.py for generic expression discovery tests. @@ -25,21 +25,21 @@ from rhosocial.activerecord.backend.expression import serialization from rhosocial.activerecord.backend.expression import Literal from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.statements.dml import DeleteExpression, ValuesSource -from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableExpression -from rhosocial.activerecord.backend.expression.statements.ddl_index import CreateIndexExpression, DropIndexExpression +from rhosocial.activerecord.backend.expression.statements.dml import DeleteStatement, ValuesSource +from rhosocial.activerecord.backend.expression.statements.ddl_table import DropTableStatement +from rhosocial.activerecord.backend.expression.statements.ddl_index import CreateIndexStatement, DropIndexStatement from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import SQLiteReindexExpression +from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import SQLiteReindexStatement from rhosocial.activerecord.backend.impl.sqlite.expression.vacuum import ( - SQLiteVacuumExpression, - SQLiteAnalyzeExpression, + SQLiteVacuumStatement, + SQLiteAnalyzeStatement, ) from rhosocial.activerecord.backend.impl.sqlite.expression.attach import ( - SQLiteAttachExpression, - SQLiteDetachExpression, + SQLiteAttachStatement, + SQLiteDetachStatement, ) from rhosocial.activerecord.backend.impl.sqlite.expression.predicates import SQLiteMatchPredicate -from rhosocial.activerecord.backend.impl.sqlite.expression.dml import SQLiteInsertExpression +from rhosocial.activerecord.backend.impl.sqlite.expression.dml import SQLiteInsertStatement from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import SQLiteColumnInfoExpression from rhosocial.activerecord.backend.impl.sqlite.expression.table_list import SQLiteTableListExpression from rhosocial.activerecord.backend.expression.objects import Table @@ -59,7 +59,7 @@ def test_insert_with_or_ignore(self, sqlite_dialect): source = ValuesSource( sqlite_dialect, values_list=[[Literal(sqlite_dialect, "John"), Literal(sqlite_dialect, 30)]] ) - expr = SQLiteInsertExpression( + expr = SQLiteInsertStatement( sqlite_dialect, into=Table(sqlite_dialect, "users"), source=source, @@ -71,26 +71,26 @@ def test_insert_with_or_ignore(self, sqlite_dialect): assert restored.or_ignore == expr.or_ignore def test_delete_has_no_dialect_options(self, sqlite_dialect): - expr = DeleteExpression(sqlite_dialect, tables=Table(sqlite_dialect, "users")) + expr = DeleteStatement(sqlite_dialect, tables=Table(sqlite_dialect, "users")) assert not hasattr(expr, "dialect_options") spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_drop_table_roundtrip(self, sqlite_dialect): - expr = DropTableExpression(sqlite_dialect, table=Table(sqlite_dialect, 'users'), if_exists=True) + expr = DropTableStatement(sqlite_dialect, table=Table(sqlite_dialect, 'users'), if_exists=True) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_create_index_roundtrip(self, sqlite_dialect): - expr = CreateIndexExpression(sqlite_dialect, Index(sqlite_dialect, "idx_users"), Table(sqlite_dialect, "users"), ["name"], unique=True) + expr = CreateIndexStatement(sqlite_dialect, Index(sqlite_dialect, "idx_users"), Table(sqlite_dialect, "users"), ["name"], unique=True) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_drop_index_roundtrip(self, sqlite_dialect): - expr = DropIndexExpression(sqlite_dialect, Index(sqlite_dialect, "idx_users"), if_exists=True) + expr = DropIndexStatement(sqlite_dialect, Index(sqlite_dialect, "idx_users"), if_exists=True) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() @@ -100,19 +100,19 @@ class TestSQLiteSpecificExpressionTraversal: """Test SQLite-specific expressions.""" def test_reindex_table_name(self, sqlite_dialect): - expr = SQLiteReindexExpression(sqlite_dialect, table=Table(sqlite_dialect, "users")) + expr = SQLiteReindexStatement(sqlite_dialect, table=Table(sqlite_dialect, "users")) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_reindex_index_name(self, sqlite_dialect): - expr = SQLiteReindexExpression(sqlite_dialect, index=Index(sqlite_dialect, "idx_users")) + expr = SQLiteReindexStatement(sqlite_dialect, index=Index(sqlite_dialect, "idx_users")) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_reindex_expressions(self, sqlite_dialect): - expr = SQLiteReindexExpression(sqlite_dialect, expressions=True) + expr = SQLiteReindexStatement(sqlite_dialect, expressions=True) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() @@ -136,36 +136,36 @@ def test_match_predicate_negated_raises_error(self, sqlite_dialect): expr.to_sql() def test_vacuum_expression(self, sqlite_dialect): - expr = SQLiteVacuumExpression(sqlite_dialect) + expr = SQLiteVacuumStatement(sqlite_dialect) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_vacuum_into_expression(self, sqlite_dialect): - expr = SQLiteVacuumExpression(sqlite_dialect, into="backup.db") + expr = SQLiteVacuumStatement(sqlite_dialect, into="backup.db") spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_vacuum_schema_expression(self, sqlite_dialect): - expr = SQLiteVacuumExpression(sqlite_dialect, schema="aux") + expr = SQLiteVacuumStatement(sqlite_dialect, schema="aux") spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_analyze_expression(self, sqlite_dialect): - expr = SQLiteAnalyzeExpression(sqlite_dialect) + expr = SQLiteAnalyzeStatement(sqlite_dialect) spec = serialization.serialize(expr) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_attach_detach_expression(self, sqlite_dialect): - attach = SQLiteAttachExpression(sqlite_dialect, database="aux.db", schema="aux") + attach = SQLiteAttachStatement(sqlite_dialect, database="aux.db", schema="aux") spec = serialization.serialize(attach) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == attach.to_sql() - detach = SQLiteDetachExpression(sqlite_dialect, schema="aux") + detach = SQLiteDetachStatement(sqlite_dialect, schema="aux") spec = serialization.serialize(detach) restored = serialization.deserialize(spec, sqlite_dialect) assert restored.to_sql() == detach.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_extension_integration_scenarios.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_extension_integration_scenarios.py index f22f53739..2ba82a791 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_extension_integration_scenarios.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_extension_integration_scenarios.py @@ -9,53 +9,55 @@ import json import pytest -from rhosocial.activerecord.testsuite.utils import ( - requires_functions, - requires_protocol, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, ) - -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.protocols import ( - SQLiteFTS5Support, - SQLiteRTreeSupport, +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteTextType +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.impl.sqlite.functions import ( + json_extract as json_extract_func, ) -from rhosocial.activerecord.backend.impl.sqlite.expression import ( - SQLiteFTS5CreateVirtualTable, - SQLiteMatchPredicate, - DropVirtualTableExpression, - SQLiteRTreeCreateVirtualTable, - SQLiteRTreeRangeQuery, - SQLiteGeopolyCreateVirtualTable, - SQLiteGeopolyContainsExpression, - SQLiteGeopolyAreaExpression, +from rhosocial.activerecord.backend.expression.functions import ( + json_extract_text, ) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression import ( Column, ComparisonPredicate, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, - CreateTableExpression, + CreateTableStatement, FunctionCall, - InsertExpression, + InsertStatement, JoinClause, Literal, - QueryExpression, + QueryStatement, SelectSource, Subquery, ValuesSource, ) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.functions import ( - json_extract_text, +from rhosocial.activerecord.backend.impl.sqlite.expression import ( + SQLiteFTS5CreateVirtualTable, + SQLiteMatchPredicate, + DropVirtualTableStatement, + SQLiteRTreeCreateVirtualTable, + SQLiteRTreeRangeQuery, + SQLiteGeopolyCreateVirtualTable, + SQLiteGeopolyContainsExpression, + SQLiteGeopolyAreaExpression, ) -from rhosocial.activerecord.backend.impl.sqlite.functions import ( - json_extract as json_extract_func, +from rhosocial.activerecord.backend.impl.sqlite.protocols import ( + SQLiteFTS5Support, + SQLiteRTreeSupport, ) -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteTextType -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.testsuite.utils import ( + requires_functions, + requires_protocol, +) + # ============================================================================= @@ -102,11 +104,11 @@ def test_full_scenario(self, backend): # --- Setup: JSON metadata table --- backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect, table=Table(dialect, 'doc_meta'), columns=[ ColumnDefinition(dialect, "doc_id", SQLiteIntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)) ]), ColumnDefinition(dialect, "extra", SQLiteTextType(dialect=dialect)), ] @@ -127,7 +129,7 @@ def test_full_scenario(self, backend): ] backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'docs_fts'), columns=["rowid", "title", "body", "author"], source=ValuesSource(dialect, [ @@ -138,7 +140,7 @@ def test_full_scenario(self, backend): options=insert ) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'doc_locations'), source=ValuesSource(dialect, [ [Literal(dialect, d[0]), Literal(dialect, 0), @@ -149,7 +151,7 @@ def test_full_scenario(self, backend): options=insert ) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'doc_meta'), columns=["doc_id", "extra"], source=ValuesSource(dialect, [ @@ -161,7 +163,7 @@ def test_full_scenario(self, backend): # --- Text search via expression --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[Column(dialect, "rowid"), Column(dialect, "title")], from_=NamedRelationRef(dialect, Table(dialect, "docs_fts")), @@ -187,7 +189,7 @@ def test_full_scenario(self, backend): # --- Combined text + spatial search via expressions --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[ Column(dialect, "rowid", table="docs_fts"), @@ -218,7 +220,7 @@ def test_full_scenario(self, backend): # --- JSON metadata query via expression --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[Column(dialect, "doc_id")], from_=NamedRelationRef(dialect, Table(dialect, "doc_meta")), @@ -232,10 +234,10 @@ def test_full_scenario(self, backend): # --- Cleanup --- backend.execute( - *DropVirtualTableExpression(dialect, table=Table(dialect, "docs_fts")).to_sql(), options=ddl + *DropVirtualTableStatement(dialect, table=Table(dialect, "docs_fts")).to_sql(), options=ddl ) backend.execute( - *DropVirtualTableExpression(dialect, table=Table(dialect, "doc_locations")).to_sql(), options=ddl + *DropVirtualTableStatement(dialect, table=Table(dialect, "doc_locations")).to_sql(), options=ddl ) @@ -275,7 +277,7 @@ def test_full_scenario(self, backend): # Regular hexagon centered at (0,0) radius 3 backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'zones'), columns=["name", "category", "config", "_shape"], source=ValuesSource(dialect, [[ @@ -290,7 +292,7 @@ def test_full_scenario(self, backend): options=insert ) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'zones'), columns=["name", "category", "config", "_shape"], source=ValuesSource(dialect, [[ @@ -315,10 +317,10 @@ def test_full_scenario(self, backend): # Sync content from zones to FTS (real app uses triggers) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'zone_fts'), columns=["rowid", "name", "category"], - source=SelectSource(dialect, QueryExpression( + source=SelectSource(dialect, QueryStatement( dialect, select=[ Column(dialect, "rowid"), @@ -350,7 +352,7 @@ def test_full_scenario(self, backend): # --- Search zones with FTS5 --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "zone_fts")), @@ -362,7 +364,7 @@ def test_full_scenario(self, backend): # --- Combined: search zone by text + geofence check --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[ Column(dialect, "name", table="z"), @@ -403,10 +405,10 @@ def test_full_scenario(self, backend): # --- Cleanup --- backend.execute( - *DropVirtualTableExpression(dialect, table=Table(dialect, "zones")).to_sql(), options=ddl + *DropVirtualTableStatement(dialect, table=Table(dialect, "zones")).to_sql(), options=ddl ) backend.execute( - *DropVirtualTableExpression(dialect, table=Table(dialect, "zone_fts")).to_sql(), options=ddl + *DropVirtualTableStatement(dialect, table=Table(dialect, "zone_fts")).to_sql(), options=ddl ) @@ -437,11 +439,11 @@ def test_full_scenario(self, backend): # --- Main data table --- backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect, table=Table(dialect, 'features'), columns=[ ColumnDefinition(dialect, "id", SQLiteIntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)) ]), ColumnDefinition(dialect, "name", SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, "description", SQLiteTextType(dialect=dialect)), @@ -481,7 +483,7 @@ def test_full_scenario(self, backend): ] backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'features_fts'), columns=["rowid", "name", "description"], source=ValuesSource(dialect, [ @@ -492,7 +494,7 @@ def test_full_scenario(self, backend): options=insert ) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'features_rtree'), source=ValuesSource(dialect, [ [Literal(dialect, f[0]), Literal(dialect, f[4]), @@ -505,7 +507,7 @@ def test_full_scenario(self, backend): # --- FTS5 search: find features matching "hospital" via expression --- rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[Column(dialect, "rowid"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "features_fts")), @@ -527,11 +529,11 @@ def test_full_scenario(self, backend): # --- Combined FTS5 + spatial + JSON filter via expressions --- backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect, table=Table(dialect, 'feature_props'), columns=[ ColumnDefinition(dialect, "feature_id", SQLiteIntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)) ]), ColumnDefinition(dialect, "props", SQLiteTextType(dialect=dialect)), ] @@ -539,7 +541,7 @@ def test_full_scenario(self, backend): options=ddl ) backend.execute( - *InsertExpression( + *InsertStatement( dialect, into=Table(dialect, 'feature_props'), columns=["feature_id", "props"], source=ValuesSource(dialect, [ @@ -550,7 +552,7 @@ def test_full_scenario(self, backend): ) rows = backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect, select=[ Column(dialect, "rowid", table="features_fts"), @@ -603,7 +605,7 @@ def test_full_scenario(self, backend): # --- Cleanup --- for tbl in ["features_rtree", "features_fts"]: backend.execute( - *DropVirtualTableExpression( + *DropVirtualTableStatement( dialect, table=Table(dialect, tbl) ).to_sql(), options=ddl, diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5.py index d102b699e..ad3af3b2c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5.py @@ -17,7 +17,7 @@ SQLiteFTS5HighlightExpression, SQLiteFTS5SnippetExpression, SQLiteMatchPredicate, - DropVirtualTableExpression, + DropVirtualTableStatement, ) from rhosocial.activerecord.backend.impl.sqlite.protocols import ( SQLiteFTS5Support, @@ -653,7 +653,7 @@ def test_drop_virtual_table(self, backend): rows = backend.fetch_all("SELECT name FROM sqlite_master WHERE name='tmp'") assert len(rows) == 1 - drop_sql, _ = DropVirtualTableExpression(dialect, table=Table(dialect, "tmp")).to_sql() + drop_sql, _ = DropVirtualTableStatement(dialect, table=Table(dialect, "tmp")).to_sql() backend.execute(drop_sql, options=ExecutionOptions(stmt_type=StatementType.DDL)) rows = backend.fetch_all("SELECT name FROM sqlite_master WHERE name='tmp'") diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5_signatures.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5_signatures.py new file mode 100644 index 000000000..c685093c2 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5_signatures.py @@ -0,0 +1,79 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite/test_fts5_signatures.py +"""Tests for FTS5 format method signature compliance. + +Verifies: +- format_fts5_match_expression accepts a single expression object +- Return type annotations are Tuple[str, tuple] for all FTS5 format methods +- 3 tests for match expression signature (normal, empty columns, negate=True) +- 4 tests for return type annotations on create/rank/highlight/snippet +""" + +import inspect + +import pytest + +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.sqlite.expression import ( + SQLiteFTS5MatchPredicate, +) + + +@pytest.fixture +def dialect(): + return SQLiteDialect(version=(3, 35, 0)) + + +# ============================================================================= +# Part 1: format_fts5_match_expression signature +# ============================================================================= + + +class TestFTS5MatchExpressionSignature: + """format_fts5_match_expression accepts a single expression object.""" + + def test_match_normal(self, dialect): + expr = SQLiteFTS5MatchPredicate(dialect, table="articles", query="python") + sql, params = dialect.format_fts5_match_expression(expr) + assert "MATCH" in sql + assert params == ("python",) + + def test_match_with_columns(self, dialect): + expr = SQLiteFTS5MatchPredicate( + dialect, table="articles", query="python", columns=["title", "body"] + ) + sql, params = dialect.format_fts5_match_expression(expr) + assert "MATCH" in sql + assert "title:python" in params[0] + assert "body:python" in params[0] + + def test_match_negate_raises(self, dialect): + expr = SQLiteFTS5MatchPredicate( + dialect, table="articles", query="python", negate=True + ) + with pytest.raises(ValueError, match="NOT MATCH"): + dialect.format_fts5_match_expression(expr) + + +# ============================================================================= +# Part 2: Return type annotations +# ============================================================================= + + +class TestFTS5ReturnTypeAnnotations: + """Verify return type annotations on all FTS5 format methods.""" + + def test_format_fts5_create_return_annotation(self, dialect): + sig = inspect.signature(dialect.format_fts5_create_virtual_table) + assert sig.return_annotation is not inspect.Parameter.empty + + def test_format_fts5_rank_return_annotation(self, dialect): + sig = inspect.signature(dialect.format_fts5_rank_expression) + assert sig.return_annotation is not inspect.Parameter.empty + + def test_format_fts5_highlight_return_annotation(self, dialect): + sig = inspect.signature(dialect.format_fts5_highlight_expression) + assert sig.return_annotation is not inspect.Parameter.empty + + def test_format_fts5_snippet_return_annotation(self, dialect): + sig = inspect.signature(dialect.format_fts5_snippet_expression) + assert sig.return_annotation is not inspect.Parameter.empty diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_generated_columns.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_generated_columns.py index 0f3c48427..d6db2da50 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_generated_columns.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_generated_columns.py @@ -2,19 +2,22 @@ """Tests for SQLite generated columns support.""" import pytest -from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.statements import ( + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteRealType, SQLiteTextType +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, GeneratedColumnType, - GeneratedColumnExpression, - CreateTableExpression, + CreateTableStatement, ColumnConstraint, - ColumnConstraintType, ) -from rhosocial.activerecord.backend.expression import RawSQLExpression -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteRealType, SQLiteTextType -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect class TestGeneratedColumnsVersionSupport: @@ -51,9 +54,9 @@ def test_virtual_generated_column(self): col_def = ColumnDefinition(dialect, name="full_name", data_type=SQLiteTextType(dialect=dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"first_name" || \' \' || "last_name"'), + expression=RawSQL(dialect, '"first_name" || \' \' || "last_name"'), storage_type=GeneratedColumnType.VIRTUAL, ), ) @@ -71,9 +74,9 @@ def test_stored_generated_column(self): col_def = ColumnDefinition(dialect, name="total_price", data_type=SQLiteRealType(dialect=dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"price" * "quantity"'), + expression=RawSQL(dialect, '"price" * "quantity"'), storage_type=GeneratedColumnType.STORED, ), ) @@ -91,9 +94,9 @@ def test_generated_column_default_virtual(self): col_def = ColumnDefinition(dialect, name="computed_value", data_type=SQLiteIntegerType(dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"base_value" + 1'), + expression=RawSQL(dialect, '"base_value" + 1'), ), ) @@ -109,10 +112,10 @@ def test_generated_column_with_constraints(self): col_def = ColumnDefinition(dialect, name="status_code", data_type=SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], - generated_expression=GeneratedColumnExpression( + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"raw_status"'), + expression=RawSQL(dialect, '"raw_status"'), ), ) @@ -128,9 +131,9 @@ def test_generated_column_raises_error_on_unsupported_version(self): col_def = ColumnDefinition(dialect, name="computed", data_type=SQLiteTextType(dialect=dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"source"'), + expression=RawSQL(dialect, '"source"'), ), ) @@ -148,23 +151,28 @@ def test_create_table_with_generated_column(self): dialect = SQLiteDialect(version=(3, 31, 0)) columns = [ - ColumnDefinition(dialect, - name="id", data_type=SQLiteIntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)] + ColumnDefinition( + dialect, + name="id", + data_type=SQLiteIntegerType(dialect), + constraints=[ColumnConstraint( + dialect, branch=ColumnPrimaryKeyConstraint(dialect) + )], ), ColumnDefinition(dialect, name="price", data_type=SQLiteRealType(dialect=dialect)), ColumnDefinition(dialect, name="quantity", data_type=SQLiteIntegerType(dialect)), ColumnDefinition(dialect, name="total", data_type=SQLiteRealType(dialect=dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"price" * "quantity"'), + expression=RawSQL(dialect, '"price" * "quantity"'), storage_type=GeneratedColumnType.STORED, ), ), ] - create_table = CreateTableExpression(dialect=dialect, table=Table(dialect, 'order_items'), columns=columns) + create_table = CreateTableStatement(dialect=dialect, table=Table(dialect, 'order_items'), columns=columns) sql, params = create_table.to_sql() @@ -178,23 +186,21 @@ def test_create_table_with_virtual_generated_column(self): dialect = SQLiteDialect(version=(3, 31, 0)) columns = [ - ColumnDefinition(dialect, - name="id", data_type=SQLiteIntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)] - ), + ColumnDefinition(dialect, name="id", data_type=SQLiteIntegerType(dialect)), ColumnDefinition(dialect, name="first_name", data_type=SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, name="last_name", data_type=SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, name="full_name", data_type=SQLiteTextType(dialect=dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, - expression=RawSQLExpression(dialect, '"first_name" || \' \' || "last_name"'), + expression=RawSQL(dialect, '"first_name" || \' \' || "last_name"'), storage_type=GeneratedColumnType.VIRTUAL, ), ), ] - create_table = CreateTableExpression(dialect=dialect, table=Table(dialect, 'users'), columns=columns) + create_table = CreateTableStatement(dialect=dialect, table=Table(dialect, 'users'), columns=columns) sql, params = create_table.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_geopoly.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_geopoly.py index 6c87f6b53..18578c76b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_geopoly.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_geopoly.py @@ -62,12 +62,22 @@ def test_basic(self): assert sql == 'CREATE VIRTUAL TABLE "zones" USING geopoly()' assert params == () - def test_with_content_table(self): - sql, _ = SQLiteGeopolyCreateVirtualTable( - SQLiteDialect(version=(3, 26, 0)), - table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), content_table=Table(SQLiteDialect(version=(3, 26, 0)), "zones_data") - ).to_sql() - assert "content='zones_data'" in sql + def test_there_is_no_content_table(self): + """Geopoly has no external content table, so the parameter is gone. + + ``content=`` is an R-Tree option that names the table the shapes live + in. Geopoly has no such option -- the shapes are the rows of the + table itself, and ``extra_columns`` hold the caller's auxiliary data + -- and a statement carrying ``content=`` is one SQLite refuses to + parse. So the node offers no parameter for it: asking for one is a + TypeError for an unknown argument, not a statement that cannot run. + """ + with pytest.raises(TypeError): + SQLiteGeopolyCreateVirtualTable( + SQLiteDialect(version=(3, 26, 0)), + table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), + content_table=Table(SQLiteDialect(version=(3, 26, 0)), "zones_data"), + ) def test_with_extra_columns(self): sql, _ = SQLiteGeopolyCreateVirtualTable( @@ -81,14 +91,13 @@ def test_with_extra_columns(self): ) def test_with_extra_columns_and_content(self): - sql, params = SQLiteGeopolyCreateVirtualTable( - SQLiteDialect(version=(3, 26, 0)), - table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), extra_columns=["name"], - content_table=Table(SQLiteDialect(version=(3, 26, 0)), "zones_data") - ).to_sql() - assert '"name"' in sql - assert "content='zones_data'" in sql - assert params == () + with pytest.raises(TypeError): + SQLiteGeopolyCreateVirtualTable( + SQLiteDialect(version=(3, 26, 0)), + table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), + extra_columns=["name"], + content_table=Table(SQLiteDialect(version=(3, 26, 0)), "zones_data"), + ) def test_empty_extra_columns(self): expr = SQLiteGeopolyCreateVirtualTable( @@ -122,13 +131,20 @@ def test_unsupported_version_raises_error(self): class TestSQLiteGeopolyInjectionSafety: """Geopoly expression safety: malicious identifiers must be rejected.""" - def test_content_table_malicious_identifier_rejected(self): - with pytest.raises(ValueError, match="Unsafe identifier"): - SQLiteGeopolyCreateVirtualTable( - SQLiteDialect(version=(3, 26, 0)), - table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), - content_table=Table(SQLiteDialect(version=(3, 26, 0)), "tab'; DROP TABLE users; --") - ).to_sql() + def test_extra_column_malicious_identifier_is_quoted_not_spliced(self): + """An auxiliary column name cannot escape its quoting. + + The column names reach the SQL as identifiers, so a name carrying a + double quote is quoted and that quote doubled -- which is what keeps + the statement one SQLite accepts. A single quote inside a + double-quoted identifier is just a character. + """ + sql, _ = SQLiteGeopolyCreateVirtualTable( + SQLiteDialect(version=(3, 26, 0)), + table=Table(SQLiteDialect(version=(3, 26, 0)), "zones"), + extra_columns=['col"name; DROP TABLE users; --'], + ).to_sql() + assert '"col""name; DROP TABLE users; --"' in sql class TestSQLiteGeopolyContainsExpressionConstruction: diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_rtree.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_rtree.py index 1c60cb922..7eb72e11f 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_rtree.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_rtree.py @@ -84,6 +84,10 @@ def test_with_content_table(self): table=Table(SQLiteDialect(version=(3, 6, 0)), "places"), content_table=Table(SQLiteDialect(version=(3, 6, 0)), "places_data") ).to_sql() assert "content='places_data'" in sql + # R-Tree counts each of content= and content_rowid= as a column of + # its own, and the total has to stay odd, so the two go together. + # Without the rowid name the statement is one SQLite refuses. + assert "content_rowid='rowid'" in sql assert params == () def test_with_content_rowid(self): @@ -103,6 +107,43 @@ def test_content_rowid_without_content(self): ).to_sql() assert "content_rowid" not in sql + def test_the_statement_sqlite_accepts(self): + """The generated statement has to be one SQLite can run. + + R-Tree counts content= and content_rowid= as columns of its own and + requires an odd total, so a content table without a rowid name is a + statement SQLite refuses. Asserting the SQL text is not enough: the + text can look right and still be a statement the server rejects. + """ + import sqlite3 + + dialect = SQLiteDialect(version=(3, 6, 0)) + sql, _ = SQLiteRTreeCreateVirtualTable( + dialect, + table=Table(dialect, "places"), + content_table=Table(dialect, "places_data"), + ).to_sql() + + connection = sqlite3.connect(":memory:") + connection.execute("CREATE TABLE places_data(id INTEGER PRIMARY KEY, minX REAL, maxX REAL)") + # No exception is the assertion. + connection.execute(sql) + + def test_the_statement_sqlite_accepts_with_a_named_rowid(self): + import sqlite3 + + dialect = SQLiteDialect(version=(3, 6, 0)) + sql, _ = SQLiteRTreeCreateVirtualTable( + dialect, + table=Table(dialect, "places"), + content_table=Table(dialect, "places_data"), + content_rowid="id", + ).to_sql() + + connection = sqlite3.connect(":memory:") + connection.execute("CREATE TABLE places_data(id INTEGER PRIMARY KEY, minX REAL, maxX REAL)") + connection.execute(sql) + def test_unsupported_version_raises_error(self): expr = SQLiteRTreeCreateVirtualTable( SQLiteDialect(version=(3, 5, 0)), diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_serialization.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_serialization.py index 35c4ca36f..f84fac9fe 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_serialization.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_serialization.py @@ -9,17 +9,17 @@ import pytest from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import SQLiteReindexExpression +from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import SQLiteReindexStatement from rhosocial.activerecord.backend.impl.sqlite.expression.vacuum import ( - SQLiteVacuumExpression, - SQLiteAnalyzeExpression, + SQLiteVacuumStatement, + SQLiteAnalyzeStatement, ) from rhosocial.activerecord.backend.impl.sqlite.expression.attach import ( - SQLiteAttachExpression, - SQLiteDetachExpression, + SQLiteAttachStatement, + SQLiteDetachStatement, ) from rhosocial.activerecord.backend.expression.serialization import serialize, deserialize -from rhosocial.activerecord.backend.expression.introspection import TableListExpression +from rhosocial.activerecord.backend.expression.introspection import TableListQueryStatement from rhosocial.activerecord.backend.expression.objects import Index, Table @@ -34,28 +34,28 @@ class TestSQLiteSpecificExpressionSerialization: def test_reindex_expression_table_name(self, sqlite_dialect): """Test REINDEX with table_name.""" - expr = SQLiteReindexExpression(sqlite_dialect, table=Table(sqlite_dialect, "users")) + expr = SQLiteReindexStatement(sqlite_dialect, table=Table(sqlite_dialect, "users")) spec = serialize(expr) restored = deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_reindex_expression_index_name(self, sqlite_dialect): """Test REINDEX with index_name.""" - expr = SQLiteReindexExpression(sqlite_dialect, index=Index(sqlite_dialect, "idx_users")) + expr = SQLiteReindexStatement(sqlite_dialect, index=Index(sqlite_dialect, "idx_users")) spec = serialize(expr) restored = deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_reindex_expression_expressions(self, sqlite_dialect): """Test REINDEX with expressions=True (requires SQLite 3.53.0+).""" - expr = SQLiteReindexExpression(sqlite_dialect, expressions=True) + expr = SQLiteReindexStatement(sqlite_dialect, expressions=True) spec = serialize(expr) restored = deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_reindex_expression_has_no_dialect_options(self, sqlite_dialect): """The SQLite REINDEX expression carries no dialect_options bag.""" - expr = SQLiteReindexExpression(sqlite_dialect, table=Table(sqlite_dialect, "users")) + expr = SQLiteReindexStatement(sqlite_dialect, table=Table(sqlite_dialect, "users")) assert not hasattr(expr, "dialect_options") spec = serialize(expr) assert "dialect_options" not in spec["params"] @@ -67,28 +67,28 @@ class TestSQLiteMaintenanceExpressionSerialization: """SQLite VACUUM / ANALYZE / ATTACH / DETACH serialization tests.""" def test_vacuum_expression_roundtrip(self, sqlite_dialect): - expr = SQLiteVacuumExpression(sqlite_dialect) + expr = SQLiteVacuumStatement(sqlite_dialect) spec = serialize(expr) restored = deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_vacuum_into_expression_roundtrip(self, sqlite_dialect): - expr = SQLiteVacuumExpression(sqlite_dialect, into="backup.db") + expr = SQLiteVacuumStatement(sqlite_dialect, into="backup.db") restored = deserialize(serialize(expr), sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_analyze_expression_roundtrip(self, sqlite_dialect): - expr = SQLiteAnalyzeExpression(sqlite_dialect) + expr = SQLiteAnalyzeStatement(sqlite_dialect) restored = deserialize(serialize(expr), sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_attach_expression_roundtrip(self, sqlite_dialect): - expr = SQLiteAttachExpression(sqlite_dialect, database="aux.db", schema="aux") + expr = SQLiteAttachStatement(sqlite_dialect, database="aux.db", schema="aux") restored = deserialize(serialize(expr), sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_detach_expression_roundtrip(self, sqlite_dialect): - expr = SQLiteDetachExpression(sqlite_dialect, schema="aux") + expr = SQLiteDetachStatement(sqlite_dialect, schema="aux") restored = deserialize(serialize(expr), sqlite_dialect) assert restored.to_sql() == expr.to_sql() @@ -97,22 +97,22 @@ class TestSQLiteIntrospectionExpressionSerialization: """SQLite introspection expression serialization tests.""" def test_table_list_expression_roundtrip(self, sqlite_dialect): - """Test TableListExpression round-trip.""" - expr = TableListExpression(sqlite_dialect, schema="main", include_views=True) + """Test TableListQueryStatement round-trip.""" + expr = TableListQueryStatement(sqlite_dialect, schema="main", include_views=True) spec = serialize(expr) restored = deserialize(spec, sqlite_dialect) assert restored.to_sql() == expr.to_sql() def test_table_list_expression_default_params(self, sqlite_dialect): """ - Test TableListExpression with default parameters. + Test TableListQueryStatement with default parameters. The generic introspection-based get_params() returns every declared __init__ parameter (including schema=None) for round-trip completeness. This test verifies that serialization preserves the full parameter set and the round-trip is lossless. """ - expr = TableListExpression(sqlite_dialect) + expr = TableListQueryStatement(sqlite_dialect) spec = serialize(expr) assert "schema" in spec["params"] assert spec["params"]["schema"] is None diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_datetime_interval_explain_examples.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_datetime_interval_explain_examples.py index 6a7988ff1..ab9eaf082 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_datetime_interval_explain_examples.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_datetime_interval_explain_examples.py @@ -8,7 +8,7 @@ ComparisonPredicate, Literal, LogicalPredicate, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.functions import date_add, date_diff, extract @@ -89,7 +89,7 @@ def _category_created_filter(dialect): def test_created_at_range_uses_datetime_index(temporal_backend, query_plan_options): dialect = temporal_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=NamedRelationRef(dialect, Table(dialect, "temporal_events")), @@ -113,7 +113,7 @@ def test_created_at_range_uses_datetime_index(temporal_backend, query_plan_optio def test_started_at_range_uses_composite_datetime_index(temporal_backend, query_plan_options): dialect = temporal_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "started_at"), Column(dialect, "ended_at")], from_=NamedRelationRef(dialect, Table(dialect, "temporal_events")), @@ -135,7 +135,7 @@ def test_started_at_range_uses_composite_datetime_index(temporal_backend, query_ def test_datetime_interval_expressions_work_with_indexed_filter(temporal_backend, query_plan_options): dialect = temporal_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[ Column(dialect, "id"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_security.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_security.py index b31b6bcba..60bc840d4 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_security.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_security.py @@ -8,15 +8,17 @@ """ import pytest - -from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteTextType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Function, Table, Trigger from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) -from rhosocial.activerecord.backend.expression.objects import Function, Table, Trigger +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteTextType +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + @pytest.fixture @@ -25,19 +27,44 @@ def dialect(): return SQLiteDialect() -def test_sqlite_escape_sql_string(dialect): - """Test SQLite inherits _escape_sql_string.""" - result = dialect._escape_sql_string("test's value") +def test_sqliteescape_sql_string(dialect): + """Test SQLite inherits escape_sql_string.""" + result = dialect.escape_sql_string("test's value") assert result == "test''s value" -def test_sqlite_validate_data_type(dialect): - """Test SQLite inherits _validate_data_type.""" - assert dialect._validate_data_type("TEXT") - assert dialect._validate_data_type("INTEGER") - assert dialect._validate_data_type("REAL") - assert dialect._validate_data_type("BLOB") - assert not dialect._validate_data_type("TEXT; DROP TABLE users--") +def test_sqlitea_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_sqlite_format_column_definition_data_type_validation(dialect): @@ -62,12 +89,9 @@ def test_sqlite_format_column_definition_data_type_rejects_injection(dialect): def test_sqlite_format_default_constraint_string_escaping(dialect): """Test DEFAULT constraint string is escaped.""" - constraint = ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) + constraint = ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test's value")) - sql, params = dialect.format_default_constraint(constraint) + sql, params = dialect.format_column_default_constraint(constraint.branch) assert sql == " DEFAULT 'test''s value'" assert params == () @@ -76,7 +100,7 @@ def test_sqlite_format_storage_options_string_escaping(dialect): """Test storage options string values are escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression storage_opts = StorageOptionsExpression(dialect, {"key": "value's"}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) assert "value''s" in sql assert "'; DROP" not in sql @@ -141,7 +165,7 @@ def test_format_identifier_empty_string(dialect): # ============================================================ -# format_column_info_query — uses format_identifier for table_name +# format_column_info_query_statement — uses format_identifier for table_name # ============================================================ @@ -152,26 +176,26 @@ def test_format_column_info_query_with_malicious_table_name(dialect): which makes it a table name literal, not executable SQL. Balanced quotes = no breakout. """ - from rhosocial.activerecord.backend.expression.introspection import ColumnInfoExpression + from rhosocial.activerecord.backend.expression.introspection import ColumnInfoQueryStatement - expr = ColumnInfoExpression(dialect, table_name='t"; DROP TABLE users--') - sql, params = dialect.format_column_info_query(expr) + expr = ColumnInfoQueryStatement(dialect, table_name='t"; DROP TABLE users--') + sql, params = dialect.format_column_info_query_statement(expr) # DROP TABLE appears inside the quoted identifier — verify balanced quotes assert sql.count('"') % 2 == 0, f"Unbalanced quotes: {sql}" def test_format_column_info_query_normal_table(dialect): """Column info query with normal table name works.""" - from rhosocial.activerecord.backend.expression.introspection import ColumnInfoExpression + from rhosocial.activerecord.backend.expression.introspection import ColumnInfoQueryStatement - expr = ColumnInfoExpression(dialect, table_name="users") - sql, params = dialect.format_column_info_query(expr) + expr = ColumnInfoQueryStatement(dialect, table_name="users") + sql, params = dialect.format_column_info_query_statement(expr) assert '"users"' in sql assert params == () # ============================================================ -# format_index_info_query — uses format_identifier for table_name +# format_index_info_query_statement — uses format_identifier for table_name # ============================================================ @@ -180,15 +204,15 @@ def test_format_index_info_query_with_malicious_table_name(dialect): DELETE appears inside the quoted identifier — verify balanced quotes. """ - from rhosocial.activerecord.backend.expression.introspection import IndexInfoExpression + from rhosocial.activerecord.backend.expression.introspection import IndexInfoQueryStatement - expr = IndexInfoExpression(dialect, table_name='t"; DELETE FROM users--') - sql, params = dialect.format_index_info_query(expr) + expr = IndexInfoQueryStatement(dialect, table_name='t"; DELETE FROM users--') + sql, params = dialect.format_index_info_query_statement(expr) assert sql.count('"') % 2 == 0, f"Unbalanced quotes: {sql}" # ============================================================ -# format_foreign_key_query — uses format_identifier for table_name +# format_foreign_key_query_statement — uses format_identifier for table_name # ============================================================ @@ -197,22 +221,22 @@ def test_format_foreign_key_query_with_malicious_table_name(dialect): DROP TABLE appears inside the quoted identifier — verify balanced quotes. """ - from rhosocial.activerecord.backend.expression.introspection import ForeignKeyExpression + from rhosocial.activerecord.backend.expression.introspection import ForeignKeyQueryStatement - expr = ForeignKeyExpression(dialect, table_name='t"; DROP TABLE users--') - sql, params = dialect.format_foreign_key_query(expr) + expr = ForeignKeyQueryStatement(dialect, table_name='t"; DROP TABLE users--') + sql, params = dialect.format_foreign_key_query_statement(expr) assert sql.count('"') % 2 == 0, f"Unbalanced quotes: {sql}" # ============================================================ -# format_drop_virtual_table — manual quoting replaced with format_identifier +# format_drop_virtual_table_statement — manual quoting replaced with format_identifier # ============================================================ def test_format_drop_virtual_table_normal(dialect): """Drop virtual table with normal table name.""" - from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableExpression - expr = DropVirtualTableExpression(dialect, table=Table(dialect, "my_fts_table"), if_exists=False) + from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableStatement + expr = DropVirtualTableStatement(dialect, table=Table(dialect, "my_fts_table"), if_exists=False) sql, params = expr.to_sql() assert sql == 'DROP TABLE "my_fts_table"' assert params == () @@ -220,8 +244,8 @@ def test_format_drop_virtual_table_normal(dialect): def test_format_drop_virtual_table_if_exists(dialect): """Drop virtual table with IF EXISTS.""" - from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableExpression - expr = DropVirtualTableExpression(dialect, table=Table(dialect, "my_fts_table"), if_exists=True) + from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableStatement + expr = DropVirtualTableStatement(dialect, table=Table(dialect, "my_fts_table"), if_exists=True) sql, params = expr.to_sql() assert sql == 'DROP TABLE IF EXISTS "my_fts_table"' assert params == () @@ -233,8 +257,8 @@ def test_format_drop_virtual_table_with_malicious_name(dialect): Before fix: f'DROP TABLE "{table_name}"' — no escaping of internal ". After fix: uses format_identifier which escapes " to "". """ - from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableExpression - expr = DropVirtualTableExpression(dialect, table=Table(dialect, 't"; DROP TABLE users--'), if_exists=False) + from rhosocial.activerecord.backend.impl.sqlite.expression import DropVirtualTableStatement + expr = DropVirtualTableStatement(dialect, table=Table(dialect, 't"; DROP TABLE users--'), if_exists=False) sql, params = expr.to_sql() assert "DROP TABLE" not in sql.split('"')[1::2] if len(sql.split('"')) > 2 else "DROP TABLE users" not in sql assert sql.count('"') % 2 == 0, f"Unbalanced quotes: {sql}" @@ -252,13 +276,13 @@ def test_format_create_trigger_function_name_quoted(dialect): After fix: uses format_identifier which quotes the function name. """ from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, TriggerLevel, ) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "test_trigger"), timing=TriggerTiming.AFTER, @@ -278,13 +302,13 @@ def test_format_create_trigger_malicious_function_name(dialect): Balanced quotes = no breakout from identifier context. """ from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, + CreateTriggerStatement, TriggerTiming, TriggerEvent, TriggerLevel, ) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "test_trigger"), timing=TriggerTiming.AFTER, @@ -298,7 +322,7 @@ def test_format_create_trigger_malicious_function_name(dialect): # ============================================================ -# Integration: format_storage_options via SQLite dialect +# Integration: format_storage_options_expression via SQLite dialect # ============================================================ @@ -307,7 +331,7 @@ def test_sqlite_format_storage_options_key_identifier_quoting(dialect): from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression malicious_key = 'key"; DROP TABLE users--' storage_opts = StorageOptionsExpression(dialect, {malicious_key: "value"}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) # "DROP TABLE" appears inside the quoted identifier — safe. # Verify balanced quotes (no breakout). assert sql.count('"') % 2 == 0, f"Unbalanced quotes in {sql}" diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_values_lateral.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_values_lateral.py index 08a1594e2..d14851551 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_values_lateral.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_dialect_values_lateral.py @@ -18,7 +18,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - QueryExpression, + QueryStatement, Subquery, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -138,7 +138,7 @@ def test_format_lateral_expression_raises(self, dialect: SQLiteDialect): def test_lateral_subquery_to_sql_raises(self, dialect: SQLiteDialect): subquery = Subquery( dialect, - QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "t"))), + QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "t"))), ) expr = LateralExpression(dialect, expression=subquery, alias="lat_data") with pytest.raises(UnsupportedFeatureError): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_maintenance_statements.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_maintenance_statements.py index ed79fc95c..9e17b5d4e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_maintenance_statements.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_maintenance_statements.py @@ -12,12 +12,12 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.impl.sqlite.expression.vacuum import ( - SQLiteVacuumExpression, - SQLiteAnalyzeExpression, + SQLiteVacuumStatement, + SQLiteAnalyzeStatement, ) from rhosocial.activerecord.backend.impl.sqlite.expression.attach import ( - SQLiteAttachExpression, - SQLiteDetachExpression, + SQLiteAttachStatement, + SQLiteDetachStatement, ) from rhosocial.activerecord.backend.impl.sqlite.protocols import SQLiteMaintenanceSupport @@ -30,32 +30,32 @@ def dialect(): class TestSQLiteVacuumStatement: def test_plain_vacuum(self, dialect): - sql, params = dialect.format_vacuum_statement(SQLiteVacuumExpression(dialect)) + sql, params = dialect.format_vacuum_statement(SQLiteVacuumStatement(dialect)) assert sql == "VACUUM" assert params == () def test_vacuum_schema(self, dialect): - expr = SQLiteVacuumExpression(dialect, schema="aux") + expr = SQLiteVacuumStatement(dialect, schema="aux") sql, _ = dialect.format_vacuum_statement(expr) assert sql == 'VACUUM "aux"' def test_vacuum_into(self, dialect): - expr = SQLiteVacuumExpression(dialect, into="backup.db") + expr = SQLiteVacuumStatement(dialect, into="backup.db") sql, _ = dialect.format_vacuum_statement(expr) assert sql == "VACUUM INTO 'backup.db'" def test_vacuum_into_escapes_quote(self, dialect): - expr = SQLiteVacuumExpression(dialect, into="my'db") + expr = SQLiteVacuumStatement(dialect, into="my'db") sql, _ = dialect.format_vacuum_statement(expr) assert sql == "VACUUM INTO 'my''db'" def test_vacuum_schema_and_into_conflict(self, dialect): with pytest.raises(ValueError, match="VACUUM cannot combine"): - SQLiteVacuumExpression(dialect, schema="aux", into="x.db") + SQLiteVacuumStatement(dialect, schema="aux", into="x.db") def test_vacuum_into_version_gate(self): old = SQLiteDialect(version=(3, 26, 0)) - expr = SQLiteVacuumExpression(old, into="backup.db") + expr = SQLiteVacuumStatement(old, into="backup.db") with pytest.raises(UnsupportedFeatureError, match="VACUUM INTO requires SQLite 3.27.0"): old.format_vacuum_statement(expr) @@ -66,33 +66,33 @@ def test_supports_vacuum_into_version(self): class TestSQLiteAnalyzeStatement: def test_plain_analyze(self, dialect): - sql, params = dialect.format_analyze_statement(SQLiteAnalyzeExpression(dialect)) + sql, params = dialect.format_analyze_statement(SQLiteAnalyzeStatement(dialect)) assert sql == "ANALYZE" assert params == () class TestSQLiteAttachDetachStatement: def test_attach(self, dialect): - expr = SQLiteAttachExpression(dialect, database="aux.db", schema="aux") + expr = SQLiteAttachStatement(dialect, database="aux.db", schema="aux") sql, _ = dialect.format_attach_statement(expr) assert sql == "ATTACH DATABASE 'aux.db' AS \"aux\"" def test_detach(self, dialect): - expr = SQLiteDetachExpression(dialect, schema="aux") + expr = SQLiteDetachStatement(dialect, schema="aux") sql, _ = dialect.format_detach_statement(expr) assert sql == 'DETACH DATABASE "aux"' def test_attach_requires_database(self, dialect): with pytest.raises(ValueError, match="ATTACH DATABASE requires a database"): - SQLiteAttachExpression(dialect, database="", schema="aux") + SQLiteAttachStatement(dialect, database="", schema="aux") def test_attach_requires_schema(self, dialect): with pytest.raises(ValueError, match="ATTACH DATABASE requires a schema"): - SQLiteAttachExpression(dialect, database="aux.db", schema="") + SQLiteAttachStatement(dialect, database="aux.db", schema="") def test_detach_requires_schema(self, dialect): with pytest.raises(ValueError, match="DETACH DATABASE requires a schema"): - SQLiteDetachExpression(dialect, schema="") + SQLiteDetachStatement(dialect, schema="") class TestSQLiteMaintenanceProtocolConformance: @@ -114,8 +114,8 @@ def test_vacuum_analyze_attach_detach_real(self, sqlite_file_backend): backend.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") backend.execute("INSERT INTO items (name) VALUES (?)", ("a",)) - backend.execute(dialect.format_vacuum_statement(SQLiteVacuumExpression(dialect))[0]) - backend.execute(dialect.format_analyze_statement(SQLiteAnalyzeExpression(dialect))[0]) + backend.execute(dialect.format_vacuum_statement(SQLiteVacuumStatement(dialect))[0]) + backend.execute(dialect.format_analyze_statement(SQLiteAnalyzeStatement(dialect))[0]) row = backend.fetch_one("SELECT count(*) AS count FROM items") assert row["count"] == 1 @@ -128,11 +128,11 @@ def test_attach_detach_roundtrip(self, sqlite_file_backend, tmp_path): other_db.write_bytes(b"") attach_sql, _ = dialect.format_attach_statement( - SQLiteAttachExpression(dialect, database=str(other_db), schema="other") + SQLiteAttachStatement(dialect, database=str(other_db), schema="other") ) backend.execute(attach_sql) detach_sql, _ = dialect.format_detach_statement( - SQLiteDetachExpression(dialect, schema="other") + SQLiteDetachStatement(dialect, schema="other") ) backend.execute(detach_sql) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_protocol_conformance.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_protocol_conformance.py index fd3d0dbc2..31de9ad22 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_protocol_conformance.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_protocol_conformance.py @@ -466,11 +466,11 @@ class TestSQLiteExpressionDialectSeparation: """ EXPRESSION_DIALECT_PAIRS = [ - ("SQLiteReindexExpression", "format_reindex_statement"), - ("SQLiteVacuumExpression", "format_vacuum_statement"), - ("SQLiteAnalyzeExpression", "format_analyze_statement"), - ("SQLiteAttachExpression", "format_attach_statement"), - ("SQLiteDetachExpression", "format_detach_statement"), + ("SQLiteReindexStatement", "format_reindex_statement"), + ("SQLiteVacuumStatement", "format_vacuum_statement"), + ("SQLiteAnalyzeStatement", "format_analyze_statement"), + ("SQLiteAttachStatement", "format_attach_statement"), + ("SQLiteDetachStatement", "format_detach_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -664,3 +664,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(sqlite_dialect.SQLiteDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.sqlite" + 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}" + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_statement_acceptance.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_statement_acceptance.py new file mode 100644 index 000000000..7a6ca61ed --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_statement_acceptance.py @@ -0,0 +1,293 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_statement_acceptance.py +"""The SQL the framework renders must be SQL SQLite accepts. + +Every other test in this package asserts the *shape* of a statement: which +options it carries, which names it quotes, how many parameters it holds. +That is a real check, and it is not enough. A statement can have exactly +the shape it should and still be one the server refuses to run -- the +shape says nothing about whether the server agrees. + +Two defects were found this way: an R-Tree statement whose option count +made the column total even, and a Geopoly statement carrying an option +Geopoly does not have. Both passed every test that existed, because every +test that existed compared text. + +So the rule this file pins is simple: the statement the framework renders +is handed to a real SQLite and executed. A rejection is the failure. + +Each case carries the setup the statement needs, and a fragment is placed +where it belongs -- a predicate belongs in a WHERE clause, a function call +belongs in a SELECT list -- because a fragment judged out of context says +nothing about the framework. +""" + +import sqlite3 + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.sqlite.expression.attach import ( + SQLiteAttachStatement, + SQLiteDetachStatement, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.fts5 import ( + SQLiteFTS5CreateVirtualTable, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.geopoly import ( + SQLiteGeopolyCreateVirtualTable, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.json import ( + SQLiteJsonExtractFunction, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.predicates import ( + SQLiteMatchPredicate, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.reindex import ( + SQLiteReindexStatement, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.rtree import ( + SQLiteRTreeCreateVirtualTable, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.vacuum import ( + SQLiteAnalyzeStatement, + SQLiteVacuumStatement, +) +from rhosocial.activerecord.backend.impl.sqlite.expression.virtual_table import ( + CreateVirtualTableStatement, + DropVirtualTableStatement, +) + +DIALECT = SQLiteDialect(version=(3, 40, 0)) + + +def accepts(sql, setup=(), params=()): + """Execute *sql* on a fresh SQLite; a rejection raises.""" + connection = sqlite3.connect(":memory:") + try: + for statement in setup: + connection.execute(statement) + connection.execute(sql, tuple(params)) + finally: + connection.close() + + +def rendered(expr): + """The statement the framework renders for *expr*.""" + return expr.to_sql() + + +class TestGeopolyStatements: + """Geopoly has no content table, and its statement must still be one SQLite runs. + + The module is optional in a SQLite build, so these cases are skipped + where it is absent. Whether it is there is asked of the SQLite itself + rather than of the dialect: the dialect's own probe falls back to a + version check when it has no compile options to read, and a build can + carry a version number without the module. + """ + + def _skip_without_geopoly(self): + connection = sqlite3.connect(":memory:") + try: + connection.execute("CREATE VIRTUAL TABLE probe USING geopoly()") + except sqlite3.OperationalError: + pytest.skip("Geopoly not available in this SQLite build") + finally: + connection.close() + + def test_plain(self): + self._skip_without_geopoly() + sql, _ = rendered(SQLiteGeopolyCreateVirtualTable(DIALECT, table=Table(DIALECT, "z"))) + accepts(sql) + + def test_with_auxiliary_columns(self): + self._skip_without_geopoly() + sql, _ = rendered( + SQLiteGeopolyCreateVirtualTable( + DIALECT, table=Table(DIALECT, "z"), extra_columns=["name", "kind"] + ) + ) + accepts(sql) + + def test_with_a_name_that_needs_quoting(self): + """An auxiliary column name is quoted, so the statement still runs.""" + self._skip_without_geopoly() + sql, _ = rendered( + SQLiteGeopolyCreateVirtualTable( + DIALECT, table=Table(DIALECT, "z"), extra_columns=['col"name'] + ) + ) + accepts(sql) + + +class TestRTreeStatements: + """R-Tree counts content= and content_rowid= as columns of its own.""" + def test_plain(self): + sql, _ = rendered( + SQLiteRTreeCreateVirtualTable(DIALECT, table=Table(DIALECT, "t"), dimensions=2) + ) + accepts(sql) + + def test_with_a_content_table(self): + sql, _ = rendered( + SQLiteRTreeCreateVirtualTable( + DIALECT, + table=Table(DIALECT, "t"), + dimensions=2, + content_table=Table(DIALECT, "src"), + ) + ) + accepts(sql, setup=["CREATE TABLE src(id INTEGER PRIMARY KEY, minX REAL, maxX REAL)"]) + + def test_with_a_content_table_and_a_named_rowid(self): + sql, _ = rendered( + SQLiteRTreeCreateVirtualTable( + DIALECT, + table=Table(DIALECT, "t"), + dimensions=2, + content_table=Table(DIALECT, "src"), + content_rowid="id", + ) + ) + accepts(sql, setup=["CREATE TABLE src(id INTEGER PRIMARY KEY, minX REAL, maxX REAL)"]) + + def test_three_dimensions(self): + sql, _ = rendered( + SQLiteRTreeCreateVirtualTable(DIALECT, table=Table(DIALECT, "t"), dimensions=3) + ) + accepts(sql) + + +class TestFTS5Statements: + """FTS5's content options are not columns, so either may stand alone.""" + + def test_plain(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable(DIALECT, table=Table(DIALECT, "ft"), columns=["a", "b"]) + ) + accepts(sql) + + def test_with_a_content_table(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable( + DIALECT, table=Table(DIALECT, "ft"), columns=["a"], content="src" + ) + ) + accepts(sql, setup=["CREATE TABLE src(id INTEGER PRIMARY KEY, a TEXT)"]) + + def test_with_a_content_table_and_a_named_rowid(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable( + DIALECT, + table=Table(DIALECT, "ft"), + columns=["a"], + content="src", + content_rowid="id", + ) + ) + accepts(sql, setup=["CREATE TABLE src(id INTEGER PRIMARY KEY, a TEXT)"]) + + def test_with_a_rowid_name_alone(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable( + DIALECT, table=Table(DIALECT, "ft"), columns=["a"], content_rowid="id" + ) + ) + accepts(sql) + + def test_with_a_tokenizer(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable( + DIALECT, table=Table(DIALECT, "ft"), columns=["a"], tokenize="porter" + ) + ) + accepts(sql) + + def test_with_prefix_indexes(self): + sql, _ = rendered( + SQLiteFTS5CreateVirtualTable( + DIALECT, table=Table(DIALECT, "ft"), columns=["a"], prefix=[2, 3] + ) + ) + accepts(sql) + + +class TestMaintenanceStatements: + """VACUUM, ANALYZE, ATTACH, DETACH and REINDEX.""" + + def test_vacuum(self): + sql, _ = rendered(SQLiteVacuumStatement(DIALECT)) + accepts(sql) + + def test_vacuum_into(self, tmp_path): + sql, _ = rendered(SQLiteVacuumStatement(DIALECT, into=str(tmp_path / "out.db"))) + connection = sqlite3.connect(":memory:") + try: + connection.execute("CREATE TABLE t(a)") + connection.execute("INSERT INTO t VALUES (1)") + connection.commit() + connection.execute(sql) + finally: + connection.close() + + def test_analyze(self): + sql, _ = rendered(SQLiteAnalyzeStatement(DIALECT)) + accepts(sql) + + def test_attach(self): + # ':memory:' so the attach leaves no file behind. + sql, _ = rendered(SQLiteAttachStatement(DIALECT, database=":memory:", schema="o")) + accepts(sql) + + def test_detach(self): + sql, _ = rendered(SQLiteDetachStatement(DIALECT, schema="o")) + accepts(sql, setup=["ATTACH DATABASE ':memory:' AS o"]) + + def test_reindex(self): + sql, _ = rendered(SQLiteReindexStatement(DIALECT)) + accepts(sql) + + +class TestVirtualTableStatements: + """CREATE and DROP of a virtual table.""" + + def test_create(self): + sql, _ = rendered( + CreateVirtualTableStatement( + DIALECT, + module="rtree", + table=Table(DIALECT, "v"), + columns=["id", "minX", "maxX"], + ) + ) + accepts(sql) + + def test_drop(self): + sql, _ = rendered(DropVirtualTableStatement(DIALECT, table=Table(DIALECT, "v"))) + accepts(sql, setup=["CREATE VIRTUAL TABLE v USING rtree(id, minX, maxX)"]) + + +class TestJsonAndMatchFragments: + """A function call and a predicate, each where it belongs.""" + + def test_json_extract_as_a_select_item(self): + sql, params = rendered( + SQLiteJsonExtractFunction( + DIALECT, expression=Column(DIALECT, "j"), path=Column(DIALECT, "p") + ) + ) + accepts( + f"SELECT {sql} FROM t", + setup=["CREATE TABLE t(j TEXT, p TEXT)"], + params=params, + ) + + def test_match_predicate_in_a_where_clause(self): + sql, params = rendered(SQLiteMatchPredicate(DIALECT, table="ft", query="hello")) + accepts( + f"SELECT * FROM ft WHERE {sql}", + setup=["CREATE VIRTUAL TABLE ft USING fts5(x)"], + params=params, + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_view_execution.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_view_execution.py index e0ceb2313..7ebf09b90 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_view_execution.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_sqlite_view_execution.py @@ -7,36 +7,40 @@ """ import pytest - -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import MaterializedView, View +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteRealType, SQLiteTextType +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause, WhereClause +from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate, RawSQL +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression import ( Column, ComparisonPredicate, Literal, FunctionCall, - QueryExpression, - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - CreateTableExpression, - InsertExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, + CreateTableStatement, + InsertStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ForeignKeyConstraint, ValuesSource, ) -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate, RawSQLExpression -from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause, WhereClause -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.options import ExecutionOptions -from rhosocial.activerecord.backend.schema import StatementType -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteRealType, SQLiteTextType -from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.objects import MaterializedView, View +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend + @pytest.fixture @@ -51,20 +55,20 @@ def sqlite_backend(): ColumnDefinition(dialect, name="id", data_type=SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))], ), - ColumnDefinition(dialect, name="name", data_type=SQLiteTextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, name="name", data_type=SQLiteTextType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, name="email", data_type=SQLiteTextType(dialect=dialect)), ColumnDefinition(dialect, name="status", data_type=SQLiteTextType(dialect=dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=RawSQLExpression(dialect, "'active'")) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, RawSQL(dialect, "'active'"))) ], ), ] - create_users = CreateTableExpression(dialect, table=Table(dialect, 'users'), columns=users_columns) + create_users = CreateTableStatement(dialect, table=Table(dialect, 'users'), columns=users_columns) sql, params = create_users.to_sql() backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -74,7 +78,7 @@ def sqlite_backend(): ColumnDefinition(dialect, name="id", data_type=SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))], ), ColumnDefinition(dialect, name="user_id", data_type=SQLiteIntegerType(dialect)), ColumnDefinition(dialect, name="amount", data_type=SQLiteRealType(dialect=dialect)), @@ -87,7 +91,7 @@ def sqlite_backend(): foreign_key_columns=["id"], ) - create_orders = CreateTableExpression( + create_orders = CreateTableStatement( dialect, table=Table(dialect, 'orders'), columns=orders_columns, table_constraints=[orders_fk_constraint] ) @@ -102,7 +106,7 @@ def sqlite_backend(): ] for name, email, status in insert_users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect, into=Table(dialect, 'users'), source=ValuesSource(dialect, [[Literal(dialect, name), Literal(dialect, email), Literal(dialect, status)]]), @@ -118,7 +122,7 @@ def sqlite_backend(): ] for user_id, amount, order_date in insert_orders: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect, into=Table(dialect, 'orders'), source=ValuesSource( @@ -141,13 +145,13 @@ def test_create_view_basic(self, sqlite_backend): """Test basic CREATE VIEW executes successfully.""" dialect = sqlite_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=NamedRelationRef(dialect, 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() @@ -171,14 +175,14 @@ def test_create_view_with_where(self, sqlite_backend): # Using RawSQLPredicate to inline the condition value dialect = sqlite_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), where=WhereClause(dialect, condition=RawSQLPredicate(dialect, "\"status\" = 'active'")), ) - 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() @@ -199,7 +203,7 @@ def test_create_view_with_aggregates(self, sqlite_backend): """Test CREATE VIEW with aggregate functions.""" dialect = sqlite_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[ Column(dialect, "user_id"), @@ -210,7 +214,7 @@ def test_create_view_with_aggregates(self, sqlite_backend): group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "user_id")]), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_order_summary"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_order_summary"), query=query) sql, params = create_view.to_sql() @@ -231,11 +235,11 @@ def test_create_temporary_view(self, sqlite_backend): """Test CREATE TEMPORARY VIEW executes successfully.""" dialect = sqlite_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")) ) - create_view = CreateViewExpression(dialect, view=View(dialect, "temp_user_view"), query=query, temporary=True) + create_view = CreateViewStatement(dialect, view=View(dialect, "temp_user_view"), query=query, temporary=True) sql, params = create_view.to_sql() @@ -256,16 +260,16 @@ def test_create_view_if_not_exists(self, sqlite_backend): """Test CREATE VIEW IF NOT EXISTS (SQLite's OR REPLACE equivalent).""" dialect = sqlite_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) # First create the view - create_view = CreateViewExpression(dialect, view=View(dialect, "test_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "test_view"), query=query) sql, params = create_view.to_sql() sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) # Try to create again with IF NOT EXISTS - create_view2 = CreateViewExpression( + create_view2 = CreateViewStatement( dialect, view=View(dialect, "test_view"), query=query, @@ -282,15 +286,15 @@ def test_drop_view(self, sqlite_backend): dialect = sqlite_backend.dialect # First create a view - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) - create_view = CreateViewExpression(dialect, view=View(dialect, "view_to_drop"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "view_to_drop"), query=query) sql, params = create_view.to_sql() sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) # Now drop it - drop_view = DropViewExpression(dialect, view=View(dialect, "view_to_drop")) + drop_view = DropViewStatement(dialect, view=View(dialect, "view_to_drop")) sql, params = drop_view.to_sql() sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -305,7 +309,7 @@ def test_drop_view_if_exists(self, sqlite_backend): """Test DROP VIEW IF EXISTS executes without error for non-existent view.""" dialect = sqlite_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() @@ -317,15 +321,15 @@ def test_drop_view_if_exists_for_existing_view(self, sqlite_backend): dialect = sqlite_backend.dialect # First create a view - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) - create_view = CreateViewExpression(dialect, view=View(dialect, "view_to_drop_if_exists"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "view_to_drop_if_exists"), query=query) sql, params = create_view.to_sql() sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) # Drop with IF EXISTS - drop_view = DropViewExpression(dialect, view=View(dialect, "view_to_drop_if_exists"), if_exists=True) + drop_view = DropViewStatement(dialect, view=View(dialect, "view_to_drop_if_exists"), if_exists=True) sql, params = drop_view.to_sql() sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -340,11 +344,11 @@ def test_create_view_with_column_aliases(self, sqlite_backend): """Test CREATE VIEW with explicit column aliases.""" dialect = sqlite_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")) ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( dialect, view=View(dialect, "aliased_view"), query=query, column_aliases=["user_id", "user_name"] ) @@ -368,9 +372,9 @@ def test_create_materialized_view_raises_error(self, sqlite_backend): """Test that CREATE MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = sqlite_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "users"))) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, 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() @@ -382,7 +386,7 @@ def test_drop_materialized_view_raises_error(self, sqlite_backend): """Test that DROP MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = sqlite_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() @@ -393,7 +397,7 @@ def test_refresh_materialized_view_raises_error(self, sqlite_backend): """Test that REFRESH MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = sqlite_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() @@ -420,11 +424,11 @@ def test_create_view_with_join(self, sqlite_backend): dialect, left_table=users_table, right_table=orders_table, condition=join_condition, join_type="INNER JOIN" ) - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "name", "u"), Column(dialect, "amount", "o")], from_=join_expr ) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_orders_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_orders_view"), query=query) sql, params = create_view.to_sql() result = sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -450,7 +454,7 @@ def test_create_view_with_subquery(self, sqlite_backend): # Create a simpler view that demonstrates subquery-like behavior # Since SQLite handles views as query definitions, we test simpler aggregate views - query = QueryExpression( + query = QueryStatement( dialect, select=[ Column(dialect, "user_id"), @@ -460,7 +464,7 @@ def test_create_view_with_subquery(self, sqlite_backend): group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "user_id")]), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "order_counts_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "order_counts_view"), query=query) sql, params = create_view.to_sql() result = sqlite_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_virtual_table_signatures.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_virtual_table_signatures.py new file mode 100644 index 000000000..6d2faa235 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite/test_virtual_table_signatures.py @@ -0,0 +1,73 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite/test_virtual_table_signatures.py +"""Tests for virtual table format method signature compliance. + +Verifies: +- format_create_virtual_table_statement accepts a CreateVirtualTableStatement +- format_drop_virtual_table_statement accepts a DropVirtualTableStatement +- 4 tests: create/drop × expression construction + SQL output +""" + +import pytest + +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.sqlite.expression import ( + CreateVirtualTableStatement, + DropVirtualTableStatement, +) +from rhosocial.activerecord.backend.expression.objects import Table + + +@pytest.fixture +def dialect(): + return SQLiteDialect(version=(3, 35, 0)) + + +# ============================================================================= +# Part 1: CreateVirtualTableStatement +# ============================================================================= + + +class TestCreateVirtualTableExpression: + """CreateVirtualTableStatement produces correct SQL.""" + + def test_create_basic(self, dialect): + expr = CreateVirtualTableStatement( + dialect, module="rtree", table=Table(dialect, "places"), + columns=["id", "minx", "maxx", "miny", "maxy"] + ) + sql, params = expr.to_sql() + assert "CREATE VIRTUAL TABLE" in sql + assert "rtree" in sql + assert '"places"' in sql + assert params == () + + def test_create_rejects_malicious_module(self, dialect): + with pytest.raises(ValueError, match="Unsafe virtual table module"): + expr = CreateVirtualTableStatement( + dialect, module="bad'; DROP TABLE x; --", + table=Table(dialect, "t"), columns=["c"] + ) + expr.to_sql() + + +# ============================================================================= +# Part 2: DropVirtualTableStatement +# ============================================================================= + + +class TestDropVirtualTableExpression: + """DropVirtualTableStatement produces correct SQL.""" + + def test_drop_basic(self, dialect): + expr = DropVirtualTableStatement(dialect, table=Table(dialect, "my_table")) + sql, params = expr.to_sql() + assert sql == 'DROP TABLE "my_table"' + assert params == () + + def test_drop_if_exists(self, dialect): + expr = DropVirtualTableStatement( + dialect, table=Table(dialect, "my_table"), if_exists=True + ) + sql, params = expr.to_sql() + assert sql == 'DROP TABLE IF EXISTS "my_table"' + assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dml.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dml.py index 54493119b..d5c842dca 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dml.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dml.py @@ -19,9 +19,9 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - InsertExpression, - UpdateExpression, - DeleteExpression, + InsertStatement, + UpdateStatement, + DeleteStatement, ValuesSource, ComparisonPredicate, ) @@ -90,14 +90,14 @@ def backend_no_returning(tmp_path): def _make_insert_expr(dialect, name, email): - """Build a single-row InsertExpression for users table.""" + """Build a single-row InsertStatement for users table.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, name), Literal(dialect, email)]]) - return InsertExpression(dialect, into=Table(dialect, 'users'), columns=["name", "email"], source=source) + return InsertStatement(dialect, into=Table(dialect, 'users'), columns=["name", "email"], source=source) def _make_update_expr(dialect, pk_val, new_name): - """Build an UpdateExpression: SET name=? WHERE id=?""" - return UpdateExpression( + """Build an UpdateStatement: SET name=? WHERE id=?""" + return UpdateStatement( dialect, table=Table(dialect, 'users'), assignments={"name": Literal(dialect, new_name)}, @@ -106,8 +106,8 @@ def _make_update_expr(dialect, pk_val, new_name): def _make_delete_expr(dialect, pk_val): - """Build a DeleteExpression: DELETE FROM users WHERE id=?""" - return DeleteExpression( + """Build a DeleteStatement: DELETE FROM users WHERE id=?""" + return DeleteStatement( dialect, tables=Table(dialect, 'users'), where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, pk_val)), @@ -340,7 +340,7 @@ def test_uuid_and_datetime(self, backend_with_typed): ], ) exprs = [ - InsertExpression( + InsertStatement( dialect, into=Table(dialect, 'typed_data'), columns=["uuid_col", "dt_col", "dec_col", "bool_col"], source=source ) ] diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dql.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dql.py index 38ded13eb..096d3d99f 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dql.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_dql.py @@ -6,7 +6,7 @@ - fetchmany pagination with various page_size / row_count combinations - Cursor lifecycle: normal consumption, early break, exception - Column adapter and column mapping processing - - DQLExpression type coverage: QueryExpression, WithQueryExpression, SetOperationExpression + - DQLExpression type coverage: QueryStatement, WithStatement, SetOperation For async tests, see sqlite_async/test_batch_dql.py. """ @@ -16,15 +16,15 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - WildcardExpression, + AllColumnsWildcard, ComparisonPredicate, - SetOperationExpression, + SetOperation, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.statements import QueryExpression +from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.expression.query_sources import ( - WithQueryExpression, - CTEExpression, + WithStatement, + CTEClause, ) from rhosocial.activerecord.backend.expression.query_parts import ( WhereClause, @@ -81,9 +81,9 @@ def backend_with_empty_table(sqlite_backend): def _select_all_expr(dialect, table="items", order_col="id"): """Build: SELECT * FROM items ORDER BY id""" - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, table)), order_by=OrderByClause(dialect, expressions=[(Column(dialect, order_col), "ASC")]), ) @@ -91,9 +91,9 @@ def _select_all_expr(dialect, table="items", order_col="id"): def _select_where_expr(dialect, category): """Build: SELECT * FROM items WHERE category = ? ORDER BY id""" - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "items")), where=WhereClause( dialect, @@ -282,37 +282,37 @@ class TestBatchDQLExpressionTypes: """Tests that all DQLExpression variants work with execute_batch_dql.""" def test_query_expression(self, backend_with_items): - """Basic QueryExpression.""" + """Basic QueryStatement.""" dialect = backend_with_items.dialect expr = _select_all_expr(dialect) pages = _collect_pages(backend_with_items.execute_batch_dql(expr, page_size=50)) assert sum(p.page_size for p in pages) == 100 def test_with_query_expression_cte(self, backend_with_items): - """WithQueryExpression (CTE).""" + """WithStatement (CTE).""" dialect = backend_with_items.dialect # WITH expensive AS (SELECT * FROM items WHERE price > 50) # SELECT * FROM expensive ORDER BY id - cte_query = QueryExpression( + cte_query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "items")), where=WhereClause( dialect, condition=ComparisonPredicate(dialect, ">", Column(dialect, "price"), Literal(dialect, 50.0)), ), ) - cte = CTEExpression(dialect, name="expensive", query=cte_query) + cte = CTEClause(dialect, name="expensive", query=cte_query) - main_query = QueryExpression( + main_query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "expensive")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), ) - with_expr = WithQueryExpression(dialect, ctes=[cte], main_query=main_query) + with_expr = WithStatement(dialect, ctes=[cte], query=main_query) pages = _collect_pages(backend_with_items.execute_batch_dql(with_expr, page_size=20)) @@ -323,10 +323,10 @@ def test_with_query_expression_cte(self, backend_with_items): assert row["price"] > 50.0 def test_set_operation_expression(self, backend_with_items): - """SetOperationExpression (UNION).""" + """SetOperation (UNION).""" dialect = backend_with_items.dialect - left = QueryExpression( + left = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "price")], from_=NamedRelationRef(dialect, Table(dialect, "items")), @@ -337,7 +337,7 @@ def test_set_operation_expression(self, backend_with_items): ), ), ) - right = QueryExpression( + right = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "price")], from_=NamedRelationRef(dialect, Table(dialect, "items")), @@ -346,7 +346,7 @@ def test_set_operation_expression(self, backend_with_items): condition=ComparisonPredicate(dialect, "=", Column(dialect, "category"), Literal(dialect, "books")), ), ) - union_expr = SetOperationExpression(dialect, left=left, right=right, operation="UNION ALL") + union_expr = SetOperation(dialect, left=left, right=right, op="union_all") pages = _collect_pages(backend_with_items.execute_batch_dql(union_expr, page_size=20)) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_transaction.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_transaction.py index 97f438183..cc088bb83 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_transaction.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_batch_transaction.py @@ -16,7 +16,7 @@ from rhosocial.activerecord.backend.expression import ( Literal, - InsertExpression, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.options import ExecutionOptions @@ -55,7 +55,7 @@ def _make_insert(dialect, name, email, batch_tag="default"): source = ValuesSource( dialect, values_list=[[Literal(dialect, name), Literal(dialect, email), Literal(dialect, batch_tag)]] ) - return InsertExpression(dialect, into=Table(dialect, 'users'), columns=["name", "email", "batch_tag"], source=source) + return InsertStatement(dialect, into=Table(dialect, 'users'), columns=["name", "email", "batch_tag"], source=source) def _count_rows(backend, where_clause="1=1"): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_function_window.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_function_window.py index d05bcffa3..963e068fc 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_function_window.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_function_window.py @@ -4,10 +4,10 @@ Column, Literal, CaseExpression, - ExistsExpression, - AnyExpression, - AllExpression, -) + ExistsPredicate, + AnyPredicate, + AllPredicate, + NumericColumn,) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.types import RealType @@ -18,9 +18,9 @@ class TestAdvancedFunctionWindow: # --- CaseExpression --- def test_case_searched_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Tests a searched CASE expression (CASE WHEN ... THEN ... ELSE ... END).""" - condition1 = Column(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) + condition1 = NumericColumn(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) result1 = Literal(sqlite_dialect_3_8_0, "adult") - condition2 = Column(sqlite_dialect_3_8_0, "age") <= Literal(sqlite_dialect_3_8_0, 18) + condition2 = NumericColumn(sqlite_dialect_3_8_0, "age") <= Literal(sqlite_dialect_3_8_0, 18) result2 = Literal(sqlite_dialect_3_8_0, "minor") case_expr = CaseExpression(sqlite_dialect_3_8_0, cases=[(condition1, result1), (condition2, result2)]) @@ -55,13 +55,13 @@ def test_cast_method(self, sqlite_dialect_3_8_0: SQLiteDialect): assert "CAST" in sql.upper() assert params == () - # --- ExistsExpression --- + # --- ExistsPredicate --- def test_exists_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Tests EXISTS expression.""" from rhosocial.activerecord.backend.expression.core import Subquery subquery = Subquery(sqlite_dialect_3_8_0, "SELECT 1 FROM orders WHERE user_id = users.id", ()) - exists_expr = ExistsExpression(sqlite_dialect_3_8_0, subquery) + exists_expr = ExistsPredicate(sqlite_dialect_3_8_0, subquery) sql, params = exists_expr.to_sql() assert "EXISTS" in sql.upper() assert params == () @@ -71,7 +71,7 @@ def test_not_exists_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): from rhosocial.activerecord.backend.expression.core import Subquery subquery = Subquery(sqlite_dialect_3_8_0, "SELECT 1 FROM orders WHERE user_id = users.id", ()) - exists_expr = ExistsExpression(sqlite_dialect_3_8_0, subquery, is_not=True) + exists_expr = ExistsPredicate(sqlite_dialect_3_8_0, subquery, is_not=True) sql, params = exists_expr.to_sql() assert "NOT EXISTS" in sql.upper() assert params == () @@ -79,7 +79,7 @@ def test_not_exists_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): # --- Any/All Expressions --- def test_any_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Tests ANY expression.""" - any_expr = AnyExpression( + any_expr = AnyPredicate( sqlite_dialect_3_8_0, Column(sqlite_dialect_3_8_0, "price"), ">", @@ -92,7 +92,7 @@ def test_any_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_all_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Tests ALL expression.""" - all_expr = AllExpression( + all_expr = AllPredicate( sqlite_dialect_3_8_0, Column(sqlite_dialect_3_8_0, "price"), ">", Literal(sqlite_dialect_3_8_0, [50, 75]) ) sql, params = all_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_functions.py index 945ebe430..64c80edb0 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_advanced_functions.py @@ -4,14 +4,14 @@ """ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import Column, Literal, NumericColumn from rhosocial.activerecord.backend.expression.advanced_functions import ( CaseExpression, - ExistsExpression, - AnyExpression, - AllExpression, - JSONDocumentExpression, - ArrayExpression, + ExistsPredicate, + AnyPredicate, + AllPredicate, + JSONDocumentValue, + ArrayOperation, OrderedSetAggregation, ) from rhosocial.activerecord.backend.expression.core import Subquery @@ -25,7 +25,7 @@ class TestCaseExpression: def test_case_expression_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test basic CASE expression functionality.""" # Create a basic CASE expression with one condition-result pair - condition = Column(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) + condition = NumericColumn(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) result = Literal(sqlite_dialect_3_8_0, "adult") case_expr = CaseExpression(sqlite_dialect_3_8_0, cases=[(condition, result)]) @@ -35,9 +35,9 @@ def test_case_expression_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_case_expression_multiple_conditions(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test CASE expression with multiple conditions.""" - condition1 = Column(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 65) + condition1 = NumericColumn(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 65) result1 = Literal(sqlite_dialect_3_8_0, "senior") - condition2 = Column(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) + condition2 = NumericColumn(sqlite_dialect_3_8_0, "age") > Literal(sqlite_dialect_3_8_0, 18) result2 = Literal(sqlite_dialect_3_8_0, "adult") case_expr = CaseExpression( @@ -82,12 +82,12 @@ def test_cast_method_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): class TestExistsExpression: - """Tests for ExistsExpression class.""" + """Tests for ExistsPredicate class.""" def test_exists_expression_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test basic EXISTS expression functionality.""" subquery = Subquery(sqlite_dialect_3_8_0, "SELECT 1 FROM orders WHERE user_id = users.id", ()) - exists_expr = ExistsExpression(sqlite_dialect_3_8_0, subquery) + exists_expr = ExistsPredicate(sqlite_dialect_3_8_0, subquery) sql, params = exists_expr.to_sql() assert "EXISTS" in sql.upper() assert params == () @@ -95,19 +95,19 @@ def test_exists_expression_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_not_exists_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test NOT EXISTS expression functionality.""" subquery = Subquery(sqlite_dialect_3_8_0, "SELECT 1 FROM orders WHERE user_id = users.id", ()) - exists_expr = ExistsExpression(sqlite_dialect_3_8_0, subquery, is_not=True) + exists_expr = ExistsPredicate(sqlite_dialect_3_8_0, subquery, is_not=True) sql, params = exists_expr.to_sql() assert "NOT EXISTS" in sql.upper() assert params == () class TestAnyAllExpression: - """Tests for AnyExpression and AllExpression classes.""" + """Tests for AnyPredicate and AllPredicate classes.""" def test_any_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test ANY expression functionality.""" # ANY with a list might generate different SQL in SQLite - any_expr = AnyExpression( + any_expr = AnyPredicate( sqlite_dialect_3_8_0, Column(sqlite_dialect_3_8_0, "price"), ">", @@ -120,7 +120,7 @@ def test_any_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_all_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test ALL expression functionality.""" - all_expr = AllExpression( + all_expr = AllPredicate( sqlite_dialect_3_8_0, Column(sqlite_dialect_3_8_0, "price"), ">", Literal(sqlite_dialect_3_8_0, [50, 75]) ) sql, params = all_expr.to_sql() @@ -130,11 +130,11 @@ def test_all_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): class TestJSONExpression: - """Tests for JSONDocumentExpression class.""" + """Tests for JSONDocumentValue class.""" def test_json_extract_path(self, sqlite_dialect_3_38_0: SQLiteDialect): """Test JSON path extraction.""" - json_expr = JSONDocumentExpression(sqlite_dialect_3_38_0, Column(sqlite_dialect_3_38_0, "data"), "$.name") + json_expr = JSONDocumentValue(sqlite_dialect_3_38_0, Column(sqlite_dialect_3_38_0, "data"), "$.name") sql, params = json_expr.to_sql() # In SQLite, this uses the -> operator with the path embedded as a literal assert sql == '"data"->\'$.name\'' @@ -142,7 +142,7 @@ def test_json_extract_path(self, sqlite_dialect_3_38_0: SQLiteDialect): def test_json_extract_as_text(self, sqlite_dialect_3_38_0: SQLiteDialect): """Test JSON path extraction as text.""" - json_expr = JSONDocumentExpression( + json_expr = JSONDocumentValue( sqlite_dialect_3_38_0, Column(sqlite_dialect_3_38_0, "metadata"), "$.settings.theme", operation="->>" ) sql, params = json_expr.to_sql() @@ -151,14 +151,14 @@ def test_json_extract_as_text(self, sqlite_dialect_3_38_0: SQLiteDialect): assert params == () -class TestArrayExpression: - """Tests for ArrayExpression class.""" +class TestArrayValue: + """Tests for ArrayValue class.""" def test_array_constructor_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test that array constructor is not supported in SQLite.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - array_expr = ArrayExpression( + array_expr = ArrayOperation( sqlite_dialect_3_8_0, "CONSTRUCTOR", elements=[ @@ -178,7 +178,7 @@ def test_array_access_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test that array access is not supported in SQLite.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - array_access = ArrayExpression( + array_access = ArrayOperation( sqlite_dialect_3_8_0, "ACCESS", base_expr=Column(sqlite_dialect_3_8_0, "tags"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_column_type.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_column_type.py index 50f270c5e..061316315 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_column_type.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_column_type.py @@ -21,6 +21,7 @@ import pytest +from rhosocial.activerecord.backend.expression import BooleanColumn, NumericColumn, StringColumn, TimestampColumn from rhosocial.activerecord.backend.expression.column_types import ( ArrayColumn, BinaryColumn, diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_core.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_core.py index d8ad21e27..da0b12fe2 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_core.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_core.py @@ -23,7 +23,7 @@ def test_literal_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_literal_repr(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test Literal repr method.""" literal = Literal(sqlite_dialect_3_8_0, "test_value") - assert repr(literal) == "Literal('test_value', inline_literals=False)" + assert repr(literal) == "Literal('test_value')" def test_literal_none(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test Literal with None value.""" @@ -63,36 +63,38 @@ class TestColumn: class TestWildcard: - """Tests for WildcardExpression class.""" + """Tests for the wildcard classes.""" def test_wildcard_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test basic WildcardExpression functionality.""" - from rhosocial.activerecord.backend.expression import WildcardExpression + """Test the all-columns wildcard.""" + from rhosocial.activerecord.backend.expression import AllColumnsWildcard - wildcard = WildcardExpression(sqlite_dialect_3_8_0) + wildcard = AllColumnsWildcard(sqlite_dialect_3_8_0) sql, params = wildcard.to_sql() assert sql == "*" assert params == () def test_wildcard_with_table(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test WildcardExpression with table qualifier.""" - from rhosocial.activerecord.backend.expression import WildcardExpression + """Test the relation-qualified all-columns wildcard.""" + from rhosocial.activerecord.backend.expression import AllColumnsWildcard - wildcard = WildcardExpression(sqlite_dialect_3_8_0, table="users") + wildcard = AllColumnsWildcard( + sqlite_dialect_3_8_0, relation=NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) + ) sql, params = wildcard.to_sql() assert sql == '"users".*' assert params == () def test_wildcard_in_query(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test WildcardExpression used in a QueryExpression.""" + """Test the all-columns wildcard in a QueryStatement.""" from rhosocial.activerecord.backend.expression import ( - WildcardExpression, - QueryExpression, + AllColumnsWildcard, + QueryStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - wildcard = WildcardExpression(sqlite_dialect_3_8_0) - query = QueryExpression( + wildcard = AllColumnsWildcard(sqlite_dialect_3_8_0) + query = QueryStatement( sqlite_dialect_3_8_0, select=[wildcard], from_=NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) ) sql, params = query.to_sql() @@ -100,15 +102,17 @@ def test_wildcard_in_query(self, sqlite_dialect_3_8_0: SQLiteDialect): assert params == () def test_wildcard_with_table_in_query(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test qualified WildcardExpression used in a QueryExpression.""" + """Test the qualified all-columns wildcard in a QueryStatement.""" from rhosocial.activerecord.backend.expression import ( - WildcardExpression, - QueryExpression, + AllColumnsWildcard, + QueryStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - wildcard = WildcardExpression(sqlite_dialect_3_8_0, table="users") - query = QueryExpression( + wildcard = AllColumnsWildcard( + sqlite_dialect_3_8_0, relation=NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) + ) + query = QueryStatement( sqlite_dialect_3_8_0, select=[wildcard], from_=NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) ) sql, params = query.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_functions.py index 81c1fa3fc..807b1815c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_functions.py @@ -9,12 +9,14 @@ Column, Literal, FunctionCall, - RawSQLExpression, - QueryExpression, - UpdateExpression, - DeleteExpression, -) -from rhosocial.activerecord.backend.expression.statements import InsertExpression, ReturningClause, ValuesSource + RawSQL, + QueryStatement, + UpdateStatement, + DeleteStatement, + NumericColumn, + StringColumn, + TimestampColumn,) +from rhosocial.activerecord.backend.expression.statements import InsertStatement, ReturningClause, ValuesSource from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.objects import Table @@ -24,10 +26,10 @@ class TestSQLiteDateTimeFunctions: def test_current_timestamp_in_insert(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test CURRENT_TIMESTAMP in INSERT operations.""" - # Using RawSQLExpression for CURRENT_TIMESTAMP - raw_timestamp = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + # Using RawSQL for CURRENT_TIMESTAMP + raw_timestamp = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") - insert_expr = InsertExpression( + insert_expr = InsertStatement( sqlite_dialect_3_8_0, into=Table(sqlite_dialect_3_8_0, 'users'), source=ValuesSource( @@ -44,13 +46,13 @@ def test_current_timestamp_in_insert(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_current_timestamp_in_update(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test CURRENT_TIMESTAMP in UPDATE operations.""" - raw_timestamp = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + raw_timestamp = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") - update_expr = UpdateExpression( + update_expr = UpdateStatement( sqlite_dialect_3_8_0, table=Table(sqlite_dialect_3_8_0, 'users'), assignments={"updated_at": raw_timestamp, "username": Literal(sqlite_dialect_3_8_0, "updated_john")}, - where=Column(sqlite_dialect_3_8_0, "id") == Literal(sqlite_dialect_3_8_0, 1), + where=NumericColumn(sqlite_dialect_3_8_0, "id") == Literal(sqlite_dialect_3_8_0, 1), ) sql, params = update_expr.to_sql() @@ -65,7 +67,7 @@ def test_datetime_function_in_select(self, sqlite_dialect_3_8_0: SQLiteDialect): now_func = FunctionCall(sqlite_dialect_3_8_0, "DATETIME", Literal(sqlite_dialect_3_8_0, "now")) date_func = FunctionCall(sqlite_dialect_3_8_0, "DATE", Literal(sqlite_dialect_3_8_0, "now")) - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id"), now_func, date_func], from_="users", # Use string instead of Column @@ -84,11 +86,11 @@ def test_date_arithmetic_in_where_clause(self, sqlite_dialect_3_8_0: SQLiteDiale sqlite_dialect_3_8_0, "DATE", Literal(sqlite_dialect_3_8_0, "now"), Literal(sqlite_dialect_3_8_0, "-1 day") ) - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id")], from_="users", - where=Column(sqlite_dialect_3_8_0, "created_at") > date_func, + where=TimestampColumn(sqlite_dialect_3_8_0, "created_at") > date_func, ) sql, params = query_expr.to_sql() @@ -99,10 +101,10 @@ def test_date_arithmetic_in_where_clause(self, sqlite_dialect_3_8_0: SQLiteDiale def test_current_timestamp_in_delete(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test CURRENT_TIMESTAMP concept in DELETE operations (for audit trails).""" # Create a table with a timestamp column for audit purposes - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( sqlite_dialect_3_8_0, tables=Table(sqlite_dialect_3_8_0, 'temp_users'), - where=Column(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "inactive"), + where=StringColumn(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "inactive"), ) sql, params = delete_expr.to_sql() @@ -112,19 +114,19 @@ def test_current_timestamp_in_delete(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_multiple_datetime_functions_in_single_query(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test multiple datetime functions in a single query.""" # Various datetime functions - current_timestamp = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + current_timestamp = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") now_func = FunctionCall(sqlite_dialect_3_8_0, "DATETIME", Literal(sqlite_dialect_3_8_0, "now")) today_func = FunctionCall(sqlite_dialect_3_8_0, "DATE", Literal(sqlite_dialect_3_8_0, "now")) time_func = FunctionCall(sqlite_dialect_3_8_0, "TIME", Literal(sqlite_dialect_3_8_0, "now")) - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[current_timestamp, now_func, today_func, time_func], from_="dual", # Using dual as dummy table ) sql, params = query_expr.to_sql() - assert "CURRENT_TIMESTAMP" in sql # RawSQLExpression should appear directly + assert "CURRENT_TIMESTAMP" in sql # RawSQL should appear directly assert "DATETIME(" in sql # FunctionCall should appear with parameter placeholder assert "DATE(" in sql # FunctionCall should appear with parameter placeholder assert "TIME(" in sql # FunctionCall should appear with parameter placeholder @@ -134,13 +136,13 @@ def test_multiple_datetime_functions_in_single_query(self, sqlite_dialect_3_8_0: def test_datetime_with_returning_clause(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test datetime functions with RETURNING clause.""" if sqlite_dialect_3_8_0.supports_returning_insert(): - raw_timestamp = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + raw_timestamp = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") - update_expr = UpdateExpression( + update_expr = UpdateStatement( sqlite_dialect_3_8_0, table=Table(sqlite_dialect_3_8_0, 'users'), assignments={"updated_at": raw_timestamp, "status": Literal(sqlite_dialect_3_8_0, "active")}, - where=Column(sqlite_dialect_3_8_0, "id") == Literal(sqlite_dialect_3_8_0, 1), + where=NumericColumn(sqlite_dialect_3_8_0, "id") == Literal(sqlite_dialect_3_8_0, 1), returning=ReturningClause( sqlite_dialect_3_8_0, expressions=[Column(sqlite_dialect_3_8_0, "id"), Column(sqlite_dialect_3_8_0, "updated_at")], @@ -162,7 +164,7 @@ def test_strftime_function_in_operations(self, sqlite_dialect_3_8_0: SQLiteDiale Literal(sqlite_dialect_3_8_0, "now"), ) - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[strftime_func, Column(sqlite_dialect_3_8_0, "id")], from_="events" ) @@ -174,11 +176,12 @@ def test_strftime_function_in_operations(self, sqlite_dialect_3_8_0: SQLiteDiale def test_datetime_comparison_with_literals(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test datetime comparisons with literal values.""" # Compare datetime with literal - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id")], from_="logs", - where=Column(sqlite_dialect_3_8_0, "timestamp") > Literal(sqlite_dialect_3_8_0, "2023-01-01 00:00:00"), + where=TimestampColumn(sqlite_dialect_3_8_0, "timestamp") + > Literal(sqlite_dialect_3_8_0, "2023-01-01 00:00:00"), ) sql, params = query_expr.to_sql() @@ -189,7 +192,7 @@ def test_julianday_function(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test JULIANDAY function in operations.""" julianday_func = FunctionCall(sqlite_dialect_3_8_0, "JULIANDAY", Literal(sqlite_dialect_3_8_0, "now")) - query_expr = QueryExpression( + query_expr = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id"), julianday_func], from_="calendar_events" ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_interval_expressions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_interval_expressions.py index 95b509902..a396f3c8c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_interval_expressions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_datetime_interval_expressions.py @@ -4,7 +4,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Literal, QueryStatement from rhosocial.activerecord.backend.expression.datetime import ( DateTimeField, IntervalUnit, @@ -236,7 +236,7 @@ def test_query_expression_integration(self, sqlite_dialect_3_8_0: SQLiteDialect) shifted = date_add( sqlite_dialect_3_8_0, Column(sqlite_dialect_3_8_0, "created_at"), 1, "day",) - query = QueryExpression( + query = QueryStatement( sqlite_dialect_3_8_0, select=[ extract( diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_column_constraints.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_column_constraints.py index 3a00ba309..df46ddd16 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_column_constraints.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_column_constraints.py @@ -12,12 +12,22 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnGeneratedConstraint, GeneratedColumnType, - GeneratedColumnExpression, + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteNumericType, SQLiteTextType +from rhosocial.activerecord.backend.impl.sqlite.expression.types import ( + SQLiteIntegerType, + SQLiteNumericType, + SQLiteTextType, +) from rhosocial.activerecord.backend.expression.objects import Table @@ -27,9 +37,9 @@ class TestColumnConstraintHandlers: def test_handle_primary_key_constraint(self): """Test PRIMARY KEY constraint handler.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY) + constraint = ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)) - sql, params = dialect.format_primary_key_constraint(constraint) + sql, params = dialect.format_column_primary_key_constraint(constraint.branch) assert sql == " PRIMARY KEY" assert params == () @@ -37,9 +47,9 @@ def test_handle_primary_key_constraint(self): def test_handle_not_null_constraint(self): """Test NOT NULL constraint handler.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL) + constraint = ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)) - sql, params = dialect.format_not_null_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == " NOT NULL" assert params == () @@ -47,9 +57,9 @@ def test_handle_not_null_constraint(self): def test_handle_null_constraint(self): """Test NULL constraint handler.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NULL) + constraint = ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect)) - sql, params = dialect.format_null_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == " NULL" assert params == () @@ -57,9 +67,9 @@ def test_handle_null_constraint(self): def test_handle_unique_constraint(self): """Test UNIQUE constraint handler.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.UNIQUE) + constraint = ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)) - sql, params = dialect.format_column_unique_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == " UNIQUE" assert params == () @@ -67,24 +77,24 @@ def test_handle_unique_constraint(self): def test_handle_default_constraint_with_simple_value(self): """Test DEFAULT constraint handler with simple value.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value="test_default") + constraint = ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test_default")) - sql, params = dialect.format_default_constraint(constraint) + sql, params = dialect.format_column_default_constraint(constraint.branch) assert sql == " DEFAULT 'test_default'" assert params == () def test_handle_default_constraint_with_expression(self): """Test DEFAULT constraint handler with expression value.""" - from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression import bases, ColumnDefaultConstraint dialect = SQLiteDialect() # Use spec to make isinstance check work mock_expr = MagicMock(spec=bases.BaseExpression) mock_expr.to_sql.return_value = ("CURRENT_TIMESTAMP", ()) - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=mock_expr) + constraint = ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, mock_expr)) - sql, params = dialect.format_default_constraint(constraint) + sql, params = dialect.format_column_default_constraint(constraint.branch) assert sql == " DEFAULT CURRENT_TIMESTAMP" assert params == () @@ -92,41 +102,33 @@ def test_handle_default_constraint_with_expression(self): def test_handle_default_constraint_missing_value(self): """Test DEFAULT constraint handler raises error when value is missing.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=None) - - with pytest.raises(ValueError, match="DEFAULT constraint must have a default value"): - dialect.format_default_constraint(constraint) + with pytest.raises(ValueError, match="needs a value"): + ColumnDefaultConstraint(dialect, None) def test_handle_check_constraint_with_condition(self): """Test CHECK constraint handler with condition.""" dialect = SQLiteDialect() mock_condition = Mock() mock_condition.to_sql.return_value = ("age > 0", ()) - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.CHECK, check_condition=mock_condition) + constraint = ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, mock_condition)) - sql, params = dialect.format_check_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == " CHECK (age > 0)" assert params == () def test_handle_check_constraint_without_condition(self): - """Test CHECK constraint handler without condition returns empty.""" + """A predicate-less CHECK is reported, not silently dropped.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.CHECK, check_condition=None) - - sql, params = dialect.format_check_constraint(constraint) - - assert sql == "" - assert params == () + with pytest.raises(ValueError, match="needs a condition"): + ColumnCheckConstraint(dialect, None) def test_handle_foreign_key_constraint(self): """Test FOREIGN KEY constraint handler.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dialect, "users"), ["id"]) - ) + constraint = ColumnConstraint(dialect, branch=ColumnForeignKeyConstraint(dialect, (Table(dialect, "users"), ["id"]))) - sql, params = dialect.format_column_fk_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == ' REFERENCES "users"("id")' assert params == () @@ -134,11 +136,9 @@ def test_handle_foreign_key_constraint(self): def test_handle_foreign_key_constraint_multiple_columns(self): """Test FOREIGN KEY constraint with multiple columns.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dialect, "orders"), ["user_id", "order_id"]) - ) + constraint = ColumnConstraint(dialect, branch=ColumnForeignKeyConstraint(dialect, (Table(dialect, "orders"), ["user_id", "order_id"]))) - sql, params = dialect.format_column_fk_constraint(constraint) + sql, params = dialect.format_column_constraint(constraint) assert sql == ' REFERENCES "orders"("user_id", "order_id")' assert params == () @@ -146,10 +146,8 @@ def test_handle_foreign_key_constraint_multiple_columns(self): def test_handle_foreign_key_constraint_missing_reference(self): """Test FOREIGN KEY constraint handler raises error when reference is missing.""" dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, constraint_type=ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=None) - - with pytest.raises(ValueError, match="Foreign key constraint must have a foreign_key_reference"): - dialect.format_column_fk_constraint(constraint) + with pytest.raises(ValueError, match="needs references"): + ColumnForeignKeyConstraint(dialect, None) class TestGeneratedColumnHandler: @@ -161,7 +159,7 @@ def test_handle_generated_column_virtual(self): mock_expr = Mock() mock_expr.to_sql.return_value = ("first_name || ' ' || last_name", ()) - gen = GeneratedColumnExpression( + gen = ColumnGeneratedConstraint( dialect, expression=mock_expr, storage_type=GeneratedColumnType.VIRTUAL, @@ -177,7 +175,7 @@ def test_handle_generated_column_stored(self): mock_expr = Mock() mock_expr.to_sql.return_value = ("price * quantity", ()) - gen = GeneratedColumnExpression( + gen = ColumnGeneratedConstraint( dialect, expression=mock_expr, storage_type=GeneratedColumnType.STORED, @@ -193,7 +191,7 @@ def test_handle_generated_column_unsupported_version(self): mock_expr = Mock() mock_expr.to_sql.return_value = ("col1 + 1", ()) - gen = GeneratedColumnExpression( + gen = ColumnGeneratedConstraint( dialect, expression=mock_expr, storage_type=GeneratedColumnType.VIRTUAL, @@ -222,7 +220,7 @@ def test_format_column_definition_with_primary_key(self): col_def = ColumnDefinition(dialect, name="id", data_type=SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ) sql, params = dialect.format_column_definition(col_def) @@ -237,8 +235,8 @@ def test_format_column_definition_with_multiple_constraints(self): name="email", data_type=SQLiteTextType(dialect, 255), constraints=[ - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ) @@ -253,7 +251,7 @@ def test_format_column_definition_with_default(self): col_def = ColumnDefinition(dialect, name="status", data_type=SQLiteTextType(dialect, 50), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value="active")], + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))], ) sql, params = dialect.format_column_definition(col_def) @@ -270,7 +268,7 @@ def test_format_column_definition_with_check(self): col_def = ColumnDefinition(dialect, name="age", data_type=SQLiteIntegerType(dialect), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.CHECK, check_condition=mock_condition)], + constraints=[ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, mock_condition))], ) sql, params = dialect.format_column_definition(col_def) @@ -285,9 +283,7 @@ def test_format_column_definition_with_foreign_key(self): name="user_id", data_type=SQLiteIntegerType(dialect), constraints=[ - ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=(Table(dialect, "users"), ["id"]) - ) + ColumnConstraint(dialect, branch=ColumnForeignKeyConstraint(dialect, (Table(dialect, "users"), ["id"]))) ], ) @@ -305,7 +301,7 @@ def test_format_column_definition_with_generated_column(self): col_def = ColumnDefinition(dialect, name="full_name", data_type=SQLiteTextType(dialect, 255), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, expression=mock_expr, storage_type=GeneratedColumnType.VIRTUAL, @@ -327,9 +323,9 @@ def test_format_column_definition_with_all_constraint_types(self): name="price", data_type=SQLiteNumericType(dialect, 10, 2), constraints=[ - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value=0.0), - ColumnConstraint(dialect, constraint_type=ColumnConstraintType.CHECK, check_condition=mock_condition), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0)), + ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, mock_condition)), ], ) @@ -344,7 +340,7 @@ def test_format_column_definition_null_constraint(self): col_def = ColumnDefinition(dialect, name="optional_field", data_type=SQLiteTextType(dialect, 100), - constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))], ) sql, params = dialect.format_column_definition(col_def) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_comprehensive.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_comprehensive.py index 3b835bd97..78b614313 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_comprehensive.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_comprehensive.py @@ -7,6 +7,7 @@ from unittest.mock import Mock from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.query_parts import JoinType class TestSQLiteDialectComprehensive: @@ -105,7 +106,7 @@ def test_unsupported_features_comprehensive(self, method_name): def test_formatting_methods_unsupported_comprehensive(self, operation, method_name, expected_error_part): """Comprehensive test for unsupported formatting methods""" from unittest.mock import MagicMock - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayOperation dialect = SQLiteDialect() method = getattr(dialect, method_name) @@ -117,7 +118,7 @@ def test_formatting_methods_unsupported_comprehensive(self, operation, method_na if method_name == "format_grouping_clause": method(GroupingClause(dialect, operation, [])) elif method_name == "format_array_expression": - mock_expr = MagicMock(spec=ArrayExpression) + mock_expr = MagicMock(spec=ArrayOperation) mock_expr.operation = "CONSTRUCTOR" mock_expr.elements = [] mock_expr.base_expr = None @@ -140,7 +141,13 @@ def test_formatting_methods_unsupported_comprehensive(self, operation, method_na assert expected_error_part in str(exc_info.value) - @pytest.mark.parametrize("join_type", ["RIGHT JOIN", "FULL OUTER JOIN", "FULL JOIN"]) + # The node's contract declares join_type as a JoinType member, so a Mock has to + # hold one; a bare string is not what the formatter ever sees from a real + # node, and reading it used to work only because it did text operations. + @pytest.mark.parametrize( + "join_type", + [JoinType.RIGHT, JoinType.RIGHT_OUTER, JoinType.FULL, JoinType.FULL_OUTER], + ) def test_format_join_clause_unsupported_joins_comprehensive(self, join_type): """Comprehensive test for unsupported JOIN types""" dialect = SQLiteDialect() @@ -150,8 +157,7 @@ def test_format_join_clause_unsupported_joins_comprehensive(self, join_type): with pytest.raises(UnsupportedFeatureError) as exc_info: dialect.format_join_clause(mock_join_expr) - expected_keyword = join_type.upper().split()[0] - assert expected_keyword in str(exc_info.value) + assert join_type.phrase in str(exc_info.value) def test_dml_returning_clause_version_check_comprehensive(self): """Comprehensive test for DML RETURNING clause version check""" diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_formatting.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_formatting.py index e81d2772b..bba11d3b1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_formatting.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_formatting.py @@ -7,6 +7,7 @@ from unittest.mock import Mock from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.query_parts import JoinType class TestSQLiteDialectFormatting: @@ -41,34 +42,44 @@ def test_format_identifier_parametrized(self, identifier, expected): result = dialect.format_identifier(identifier) assert result == expected + # A Mock rather than a JoinClause, so join_type has to hold the member the + # node's contract declares -- the formatter reads the member, and reading a + # bare string only ever worked because it did text operations instead. def test_format_join_clause_right_join_error(self): """Test RIGHT JOIN formatting error""" dialect = SQLiteDialect() mock_join_expr = Mock() - mock_join_expr.join_type = "RIGHT JOIN" + mock_join_expr.join_type = JoinType.RIGHT with pytest.raises(UnsupportedFeatureError, match="does not support RIGHT JOIN"): dialect.format_join_clause(mock_join_expr) def test_format_join_clause_full_join_error(self): - """Test FULL OUTER JOIN formatting error""" + """Test FULL JOIN formatting error""" dialect = SQLiteDialect() mock_join_expr = Mock() - mock_join_expr.join_type = "FULL OUTER JOIN" + mock_join_expr.join_type = JoinType.FULL with pytest.raises(UnsupportedFeatureError, match="does not support FULL JOIN"): dialect.format_join_clause(mock_join_expr) def test_format_join_clause_full_outer_join_error(self): - """Test FULL OUTER JOIN formatting error (alternative form)""" + """Test FULL OUTER JOIN formatting error. + + The message names the join that was asked for; it read "FULL JOIN" before + only because the formatter discarded the type and hardcoded the wording. + """ dialect = SQLiteDialect() mock_join_expr = Mock() - mock_join_expr.join_type = "FULL OUTER JOIN" + mock_join_expr.join_type = JoinType.FULL_OUTER - with pytest.raises(UnsupportedFeatureError, match="does not support FULL JOIN"): + with pytest.raises(UnsupportedFeatureError, match="does not support FULL OUTER JOIN"): dialect.format_join_clause(mock_join_expr) - @pytest.mark.parametrize("join_type", ["RIGHT JOIN", "FULL OUTER JOIN", "FULL JOIN"]) + @pytest.mark.parametrize( + "join_type", + [JoinType.RIGHT, JoinType.RIGHT_OUTER, JoinType.FULL, JoinType.FULL_OUTER], + ) def test_format_join_clause_unsupported_joins(self, join_type): """Parametrized test for unsupported JOIN types""" dialect = SQLiteDialect() @@ -78,8 +89,9 @@ def test_format_join_clause_unsupported_joins(self, join_type): with pytest.raises(UnsupportedFeatureError) as exc_info: dialect.format_join_clause(mock_join_expr) - expected_keyword = join_type.upper().split()[0] - assert expected_keyword in str(exc_info.value) + # The whole phrase is named, so RIGHT OUTER is distinguishable from + # RIGHT -- the old split-on-space check could not tell them apart. + assert join_type.phrase in str(exc_info.value) def test_format_grouping_clause_rollup_error(self): """Test ROLLUP formatting error""" @@ -142,12 +154,12 @@ def test_formatting_grouping_methods_unsupported(self, operation, expected_error def test_format_array_expression_error(self): """Test array expression formatting error""" from unittest.mock import MagicMock - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayOperation dialect = SQLiteDialect() - # Create a mock ArrayExpression to pass to format_array_expression - mock_expr = MagicMock(spec=ArrayExpression) + # Create a mock ArrayOperation to pass to format_array_expression + mock_expr = MagicMock(spec=ArrayOperation) mock_expr.operation = "CONSTRUCTOR" mock_expr.elements = [] mock_expr.base_expr = None @@ -224,14 +236,14 @@ def test_format_qualify_clause_error(self): def test_formatting_methods_unsupported(self, operation, method_name, expected_error_part): """Parametrized test for unsupported formatting methods""" from unittest.mock import MagicMock - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayOperation dialect = SQLiteDialect() method = getattr(dialect, method_name) with pytest.raises(UnsupportedFeatureError) as exc_info: if method_name == "format_array_expression": - mock_expr = MagicMock(spec=ArrayExpression) + mock_expr = MagicMock(spec=ArrayOperation) mock_expr.operation = "CONSTRUCTOR" mock_expr.elements = [] mock_expr.base_expr = None diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_table_constraints.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_table_constraints.py index 3f5eee7b0..392a4e03e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_table_constraints.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_table_constraints.py @@ -1,49 +1,51 @@ +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnUniqueConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, +) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, + TableConstraint, +) # tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_dialect_table_constraints.py """Regression tests for TABLE-level constraint formatting (Sprint: unique table constraint shadowed by a same-named COLUMN-level handler). History: ``SQLiteBackend``'s mixin defined a *column*-level -``format_unique_constraint(constraint) -> Tuple[str, tuple]`` which shadowed -the *table*-level ``format_unique_constraint(t_const) -> str`` from +``format_table_unique_constraint(constraint) -> Tuple[str, tuple]`` which shadowed +the *table*-level ``format_table_unique_constraint(t_const) -> str`` from ``backend.dialect.mixins.ddl_column``. Dispatching a -``TableConstraintType.UNIQUE`` therefore received a tuple where the table +``TableUniqueConstraint`` therefore received a tuple where the table dispatcher joins plain strings -> TypeError. The column-level handler is now renamed ``format_column_unique_constraint``; these tests pin both levels. """ -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, - TableConstraint, - TableConstraintType, -) -from rhosocial.activerecord.backend.expression.types import ( - IntegerType, TextType, -) -from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.expression.objects import Table _dialect = SQLiteDialect() dialect = _dialect -def _make_table(ctype, *, named=None): - return CreateTableExpression( +def _make_table(branch_cls, *, named=None): + return CreateTableStatement( dialect=SQLiteDialect(), table=Table(SQLiteDialect(), 'demo'), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect)), ColumnDefinition(dialect, "code", TextType(dialect), - constraints=[ColumnConstraint(dialect, - ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=ctype, columns=["code"], - name=named) + TableConstraint(dialect, branch=branch_cls(dialect, ["code"]), name=named) ], ) @@ -53,25 +55,25 @@ class TestTableLevelUnique: def test_unique_table_constraint_compiles(self): """The original failure: UNIQUE table constraint used to raise TypeError (tuple joined as str).""" - expr = _make_table(TableConstraintType.UNIQUE) + expr = _make_table(TableUniqueConstraint) sql = "".join(str(part) for part in expr.to_sql()) assert "UNIQUE" in sql assert '"code"' in sql def test_unique_full_statement_shape(self): - expr = _make_table(TableConstraintType.UNIQUE) + expr = _make_table(TableUniqueConstraint) stmt = expr.to_sql()[0] assert stmt == ('CREATE TABLE "demo" ("id" INTEGER, ' '"code" TEXT NOT NULL, UNIQUE ("code"))') def test_named_unique_keeps_constraint_name(self): - expr = _make_table(TableConstraintType.UNIQUE, + expr = _make_table(TableUniqueConstraint, named="uq_demo_code") stmt = expr.to_sql()[0] assert 'CONSTRAINT "uq_demo_code" UNIQUE' in stmt def test_primary_key_still_works(self): - expr = _make_table(TableConstraintType.PRIMARY_KEY) + expr = _make_table(TablePrimaryKeyConstraint) stmt = expr.to_sql()[0] assert 'PRIMARY KEY ("code")' in stmt @@ -80,13 +82,12 @@ class TestLevelSeparation: def test_impl_mixin_no_longer_shadows(self): """Guard: the backend mixin must not define the table-level name.""" - assert "format_unique_constraint" not in vars(SQLiteDialect) + assert "format_table_unique_constraint" not in vars(SQLiteDialect) def test_column_level_handler_reachable_under_new_name(self): dialect = SQLiteDialect() - constraint = ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.UNIQUE) - sql, params = dialect.format_column_unique_constraint(constraint) + constraint = ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)) + sql, params = dialect.format_column_constraint(constraint) assert sql == " UNIQUE" assert params == () @@ -94,9 +95,9 @@ def test_dispatch_resolves_to_base_for_table_input(self): """Table dispatcher must resolve to the base str-returning method.""" dialect = SQLiteDialect() owner = None - # walk MRO: find defining class of format_unique_constraint + # walk MRO: find defining class of format_table_unique_constraint for klass in type(dialect).__mro__: - if "format_unique_constraint" in vars(klass): + if "format_table_unique_constraint" in vars(klass): owner = klass break assert owner is not None diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_basic.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_basic.py index aa58305a2..8ce3f3f4f 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_basic.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_basic.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_basic.py import pytest -from rhosocial.activerecord.backend.expression import Literal, Column, Identifier, RawSQLExpression +from rhosocial.activerecord.backend.expression import Literal, Column, Identifier, RawSQL from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect @@ -38,7 +38,7 @@ def test_literal_various_types(self, sqlite_dialect_3_8_0: SQLiteDialect, value, def test_literal_repr(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test Literal repr method.""" literal = Literal(sqlite_dialect_3_8_0, "test_value") - assert repr(literal) == "Literal('test_value', inline_literals=False)" + assert repr(literal) == "Literal('test_value')" def test_column_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test basic Column functionality.""" @@ -90,15 +90,15 @@ def test_identifier_with_special_chars(self, sqlite_dialect_3_8_0: SQLiteDialect assert params == () def test_raw_sql_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test RawSQLExpression functionality.""" - raw_sql = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + """Test RawSQL functionality.""" + raw_sql = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") sql, params = raw_sql.to_sql() assert sql == "CURRENT_TIMESTAMP" assert params == () def test_raw_sql_with_params(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test RawSQLExpression with parameters.""" - raw_sql = RawSQLExpression(sqlite_dialect_3_8_0, "datetime('now', ?)", ("localtime",)) + """Test RawSQL with parameters.""" + raw_sql = RawSQL(sqlite_dialect_3_8_0, "datetime('now', ?)", ("localtime",)) sql, params = raw_sql.to_sql() assert sql == "datetime('now', ?)" assert params == ("localtime",) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_functions.py index 787648921..ac5480a20 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_functions.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_functions.py import pytest -from rhosocial.activerecord.backend.expression import FunctionCall, Column, Literal, count, sum_, avg, RawSQLExpression +from rhosocial.activerecord.backend.expression import FunctionCall, Column, Literal, count, sum_, avg, RawSQL from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect @@ -69,7 +69,7 @@ def test_count_function(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_count_wildcard(self, sqlite_dialect_3_8_0: SQLiteDialect): """Tests the count function with wildcard.""" - from rhosocial.activerecord.backend.expression import WildcardExpression + from rhosocial.activerecord.backend.expression import RowCountWildcard # Test with string "*" func_call = count(sqlite_dialect_3_8_0, "*") @@ -77,8 +77,8 @@ def test_count_wildcard(self, sqlite_dialect_3_8_0: SQLiteDialect): assert sql == "COUNT(*)" assert params == () - # Test with WildcardExpression - wildcard = WildcardExpression(sqlite_dialect_3_8_0) + # Test with the row-count wildcard + wildcard = RowCountWildcard(sqlite_dialect_3_8_0) func_call = count(sqlite_dialect_3_8_0, wildcard) sql, params = func_call.to_sql() assert sql == "COUNT(*)" @@ -115,8 +115,8 @@ def test_function_with_distinct(self, sqlite_dialect_3_8_0: SQLiteDialect): assert params == () def test_function_with_raw_sql_arg(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Tests a function call with RawSQLExpression as argument.""" - raw_sql = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + """Tests a function call with RawSQL as argument.""" + raw_sql = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") func_call = FunctionCall(sqlite_dialect_3_8_0, "DATE", raw_sql) sql, params = func_call.to_sql() assert sql == "DATE(CURRENT_TIMESTAMP)" diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_predicates.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_predicates.py index 37d1dc65c..b8d89313e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_predicates.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_expressions_predicates.py @@ -3,7 +3,7 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - RawSQLExpression, + RawSQL, Subquery, ComparisonPredicate, LogicalPredicate, @@ -23,8 +23,12 @@ class TestPredicateExpressions: [ ((Column, ("age",)), "=", (Literal, (18,)), '"age" = ?', (18,)), ((Column, ("price",)), ">", (Literal, (100.0,)), '"price" > ?', (100.0,)), - ((Column, ("status",)), "!=", (Literal, ("inactive",)), '"status" != ?', ("inactive",)), - ((RawSQLExpression, ("NOW()",)), "<", (Column, ("end_date",)), 'NOW() < "end_date"', ()), + # `!=` and `<>` are one operation, so the node accepts either and the formatter + # writes the standard spelling; the operator travels the artifact as + # the word "ne". See ComparisonOperator. + ((Column, ("status",)), "!=", (Literal, ("inactive",)), '"status" <> ?', ("inactive",)), + ((Column, ("status",)), "<>", (Literal, ("inactive",)), '"status" <> ?', ("inactive",)), + ((RawSQL, ("NOW()",)), "<", (Column, ("end_date",)), 'NOW() < "end_date"', ()), ], ) def test_comparison_predicate( diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions.py index f3abb130d..03bd0906a 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions.py @@ -8,7 +8,7 @@ Column, Literal, concat_op, - QueryExpression, + QueryStatement, count, lower, upper, @@ -71,7 +71,7 @@ def test_concat_op_in_query_context(self, sqlite_dialect_3_8_0: SQLiteDialect): Column(sqlite_dialect_3_8_0, "last_name"), ) - query = QueryExpression( + query = QueryStatement( sqlite_dialect_3_8_0, select=[full_name_expr], from_=NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions_integration.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions_integration.py index 033afe76a..45e96cef1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions_integration.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_functions_integration.py @@ -7,7 +7,7 @@ """ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal, FunctionCall, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Literal, FunctionCall, QueryStatement from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend @@ -84,7 +84,7 @@ def test_sqrt_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "SQRT", Literal(dialect, 16)).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -99,7 +99,7 @@ def test_round__execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "ROUND", Column(dialect, "value"), Literal(dialect, 1)).as_("rounded") - query = QueryExpression(dialect, select=[result, Column(dialect, "value")], from_="test_data") + query = QueryStatement(dialect, select=[result, Column(dialect, "value")], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -114,7 +114,7 @@ def test_pow_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "POW", Column(dialect, "value"), Literal(dialect, 2)).as_("powered") - query = QueryExpression(dialect, select=[result, Column(dialect, "value")], from_="test_data") + query = QueryStatement(dialect, select=[result, Column(dialect, "value")], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -130,7 +130,7 @@ def test_power_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "POWER", Literal(dialect, 2), Literal(dialect, 3)).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -145,7 +145,7 @@ def test_mod_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "MOD", Column(dialect, "value"), Literal(dialect, 10)).as_("modded") - query = QueryExpression(dialect, select=[result, Column(dialect, "value")], from_="test_data") + query = QueryStatement(dialect, select=[result, Column(dialect, "value")], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -161,7 +161,7 @@ def test_ceil_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "CEIL", Literal(dialect, 3.14)).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -176,7 +176,7 @@ def test_floor_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "FLOOR", Literal(dialect, 3.14)).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -191,7 +191,7 @@ def test_trunc_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "TRUNC", Literal(dialect, 3.14)).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -214,7 +214,7 @@ def test_max__execution(self, sqlite_backend_with_data): Literal(dialect, 9), Literal(dialect, 2), ).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -237,7 +237,7 @@ def test_min__execution(self, sqlite_backend_with_data): Literal(dialect, 9), Literal(dialect, 2), ).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -252,7 +252,7 @@ def test_avg_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "AVG", Column(dialect, "value")).as_("avg_value") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -272,7 +272,7 @@ def test_json_function_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "JSON", Literal(dialect, '{"a": 1}')).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -289,7 +289,7 @@ def test_json_array_execution(self, sqlite_backend_with_data): result = FunctionCall(dialect, "JSON_ARRAY", Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, 3)).as_( "result" ) - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -311,7 +311,7 @@ def test_json_object_execution(self, sqlite_backend_with_data): Literal(dialect, "b"), Literal(dialect, 2), ).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -328,7 +328,7 @@ def test_json_extract_execution(self, sqlite_backend_with_data): result = FunctionCall(dialect, "JSON_EXTRACT", Column(dialect, "json_data"), Literal(dialect, "$.a")).as_( "extracted" ) - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -344,7 +344,7 @@ def test_json_type_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "JSON_TYPE", Column(dialect, "json_data"), Literal(dialect, "$.a")).as_("result") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -359,7 +359,7 @@ def test_json_valid_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "JSON_VALID", Column(dialect, "json_data")).as_("valid") - query = QueryExpression(dialect, select=[result, Column(dialect, "json_data")], from_="test_data") + query = QueryStatement(dialect, select=[result, Column(dialect, "json_data")], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -375,7 +375,7 @@ def test_json_quote_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "JSON_QUOTE", Literal(dialect, "hello")).as_("result") - query = QueryExpression(dialect, select=[result]) + query = QueryStatement(dialect, select=[result]) sql, params = query.to_sql() query_result = backend.execute( @@ -392,7 +392,7 @@ def test_json_remove_execution(self, sqlite_backend_with_data): result = FunctionCall(dialect, "JSON_REMOVE", Column(dialect, "json_data"), Literal(dialect, "$.a")).as_( "removed" ) - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -409,7 +409,7 @@ def test_json_set_execution(self, sqlite_backend_with_data): result = FunctionCall( dialect, "JSON_SET", Column(dialect, "json_data"), Literal(dialect, "$.c"), Literal(dialect, 3) ).as_("result") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -426,7 +426,7 @@ def test_json_insert_execution(self, sqlite_backend_with_data): result = FunctionCall( dialect, "JSON_INSERT", Column(dialect, "json_data"), Literal(dialect, "$.c"), Literal(dialect, 3) ).as_("result") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -443,7 +443,7 @@ def test_json_replace_execution(self, sqlite_backend_with_data): result = FunctionCall( dialect, "JSON_REPLACE", Column(dialect, "json_data"), Literal(dialect, "$.a"), Literal(dialect, 10) ).as_("result") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -460,7 +460,7 @@ def test_json_patch_execution(self, sqlite_backend_with_data): result = FunctionCall(dialect, "JSON_PATCH", Column(dialect, "json_data"), Literal(dialect, '{"a": 10}')).as_( "result" ) - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -475,7 +475,7 @@ def test_json_array_length_execution(self, sqlite_backend_with_data): backend, dialect = sqlite_backend_with_data result = FunctionCall(dialect, "JSON_ARRAY_LENGTH", Column(dialect, "json_data")).as_("length") - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( @@ -493,7 +493,7 @@ def test_json_object_retrieve_execution(self, sqlite_backend_with_data): result = FunctionCall(dialect, "JSON_EXTRACT", Column(dialect, "json_data"), Literal(dialect, "$.a")).as_( "retrieved" ) - query = QueryExpression(dialect, select=[result], from_="test_data") + query = QueryStatement(dialect, select=[result], from_="test_data") sql, params = query.to_sql() query_result = backend.execute( diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_json_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_json_functions.py index d895edb93..f88f76310 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_json_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_json_functions.py @@ -5,7 +5,12 @@ """ from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + serialize, +) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteJsonExtractFunction from rhosocial.activerecord.backend.impl.sqlite.functions.json import ( json, json_array, @@ -88,6 +93,20 @@ def test_json_extract(self, sqlite_dialect_3_38_0: SQLiteDialect): assert "JSON_EXTRACT(" in sql assert params == ("$.a",) + def test_json_extract_is_the_classed_node(self, sqlite_dialect_3_38_0: SQLiteDialect): + """Single-path extraction is its own node, not a generic call. + + The SQLite-specific node is defined in this package and registered for + deserialization there; its manifest params are ``expression`` and + ``path``. + """ + result = json_extract( + sqlite_dialect_3_38_0, Column(sqlite_dialect_3_38_0, "data"), Literal(sqlite_dialect_3_38_0, "$.a") + ) + assert isinstance(result, SQLiteJsonExtractFunction) + assert set(result.get_params()) == {"expression", "path"} + assert deserialize(serialize(result), sqlite_dialect_3_38_0).to_sql() == result.to_sql() + def test_json_extract_multiple_paths(self, sqlite_dialect_3_38_0: SQLiteDialect): """Test json_extract() with multiple paths.""" result = json_extract(sqlite_dialect_3_38_0, Literal(sqlite_dialect_3_38_0, '{"a": 1}'), Literal(sqlite_dialect_3_38_0, "$.a"), Literal(sqlite_dialect_3_38_0, "$.b")) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_materialized_view.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_materialized_view.py index 90ac1047c..7184ae95b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_materialized_view.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_materialized_view.py @@ -13,12 +13,12 @@ from rhosocial.activerecord.backend.expression import ( Column, FunctionCall, - QueryExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, + CreateViewStatement, + DropViewStatement, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause @@ -52,12 +52,12 @@ class TestSQLiteMaterializedViewErrors: def test_create_materialized_view_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that CREATE MATERIALIZED VIEW raises UnsupportedFeatureError.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id"), Column(sqlite_dialect, "name")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "users")), ) - create_mv = CreateMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "user_summary"), query=query) + create_mv = CreateMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "user_summary"), query=query) with pytest.raises(UnsupportedFeatureError) as exc_info: create_mv.to_sql() @@ -68,7 +68,7 @@ def test_create_materialized_view_raises_error(self, sqlite_dialect: SQLiteDiale def test_create_materialized_view_with_aggregates_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that CREATE MATERIALIZED VIEW with aggregates raises error.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[ Column(sqlite_dialect, "user_id"), @@ -77,7 +77,7 @@ def test_create_materialized_view_with_aggregates_raises_error(self, sqlite_dial from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "orders")), group_by_having=GroupByHavingClause(sqlite_dialect, group_by=[Column(sqlite_dialect, "user_id")]), ) - create_mv = CreateMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "order_counts"), query=query) + create_mv = CreateMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "order_counts"), query=query) with pytest.raises(UnsupportedFeatureError) as exc_info: create_mv.to_sql() @@ -86,7 +86,7 @@ def test_create_materialized_view_with_aggregates_raises_error(self, sqlite_dial def test_drop_materialized_view_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that DROP MATERIALIZED VIEW raises UnsupportedFeatureError.""" - drop_mv = DropMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "old_summary")) + drop_mv = DropMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "old_summary")) with pytest.raises(UnsupportedFeatureError) as exc_info: drop_mv.to_sql() @@ -96,7 +96,7 @@ def test_drop_materialized_view_raises_error(self, sqlite_dialect: SQLiteDialect def test_drop_materialized_view_if_exists_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that DROP MATERIALIZED VIEW IF EXISTS raises error.""" - drop_mv = DropMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "possibly_missing"), if_exists=True) + drop_mv = DropMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "possibly_missing"), if_exists=True) with pytest.raises(UnsupportedFeatureError) as exc_info: drop_mv.to_sql() @@ -105,7 +105,7 @@ def test_drop_materialized_view_if_exists_raises_error(self, sqlite_dialect: SQL def test_refresh_materialized_view_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that REFRESH MATERIALIZED VIEW raises UnsupportedFeatureError.""" - refresh_mv = RefreshMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "user_summary")) + refresh_mv = RefreshMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "user_summary")) with pytest.raises(UnsupportedFeatureError) as exc_info: refresh_mv.to_sql() @@ -115,7 +115,7 @@ def test_refresh_materialized_view_raises_error(self, sqlite_dialect: SQLiteDial def test_refresh_materialized_view_concurrent_raises_error(self, sqlite_dialect: SQLiteDialect): """Test that REFRESH MATERIALIZED VIEW CONCURRENTLY raises error.""" - refresh_mv = RefreshMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "stats"), concurrent=True) + refresh_mv = RefreshMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "stats"), concurrent=True) with pytest.raises(UnsupportedFeatureError) as exc_info: refresh_mv.to_sql() @@ -156,12 +156,12 @@ def test_supports_cascade_view_false(self, sqlite_dialect: SQLiteDialect): def test_basic_create_view(self, sqlite_dialect: SQLiteDialect): """Test basic CREATE VIEW works.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id"), Column(sqlite_dialect, "name")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "users")), ) - create_view = CreateViewExpression(sqlite_dialect, view=View(sqlite_dialect, "user_view"), query=query) + create_view = CreateViewStatement(sqlite_dialect, view=View(sqlite_dialect, "user_view"), query=query) sql, params = create_view.to_sql() assert 'CREATE VIEW "user_view"' in sql @@ -169,46 +169,46 @@ def test_basic_create_view(self, sqlite_dialect: SQLiteDialect): def test_create_temporary_view(self, sqlite_dialect: SQLiteDialect): """Test CREATE TEMPORARY VIEW works.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "sessions")) ) - create_view = CreateViewExpression(sqlite_dialect, view=View(sqlite_dialect, "temp_session_view"), query=query, temporary=True) + create_view = CreateViewStatement(sqlite_dialect, view=View(sqlite_dialect, "temp_session_view"), query=query, temporary=True) sql, params = create_view.to_sql() assert 'CREATE TEMPORARY VIEW "temp_session_view"' in sql def test_create_view_if_not_exists(self, sqlite_dialect: SQLiteDialect): """Test CREATE VIEW IF NOT EXISTS works (SQLite style).""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "products")) ) - create_view = CreateViewExpression(sqlite_dialect, view=View(sqlite_dialect, "product_view"), query=query, replace=True) + create_view = CreateViewStatement(sqlite_dialect, view=View(sqlite_dialect, "product_view"), query=query, replace=True) sql, params = create_view.to_sql() assert 'CREATE VIEW IF NOT EXISTS "product_view"' in sql def test_drop_view(self, sqlite_dialect: SQLiteDialect): """Test DROP VIEW works.""" - drop_view = DropViewExpression(sqlite_dialect, view=View(sqlite_dialect, "old_view")) + drop_view = DropViewStatement(sqlite_dialect, view=View(sqlite_dialect, "old_view")) sql, params = drop_view.to_sql() assert sql == 'DROP VIEW "old_view"' def test_drop_view_if_exists(self, sqlite_dialect: SQLiteDialect): """Test DROP VIEW IF EXISTS works.""" - drop_view = DropViewExpression(sqlite_dialect, view=View(sqlite_dialect, "possibly_missing"), if_exists=True) + drop_view = DropViewStatement(sqlite_dialect, view=View(sqlite_dialect, "possibly_missing"), if_exists=True) sql, params = drop_view.to_sql() assert sql == 'DROP VIEW IF EXISTS "possibly_missing"' def test_create_view_with_column_aliases(self, sqlite_dialect: SQLiteDialect): """Test CREATE VIEW with column aliases.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id"), Column(sqlite_dialect, "name")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "users")), ) - create_view = CreateViewExpression( + create_view = CreateViewStatement( sqlite_dialect, view=View(sqlite_dialect, "aliased_view"), query=query, column_aliases=["user_id", "user_name"] ) sql, params = create_view.to_sql() @@ -221,10 +221,10 @@ class TestSQLiteMaterializedViewAlternatives: def test_error_message_suggests_alternatives(self, sqlite_dialect: SQLiteDialect): """Test that error message suggests alternatives.""" - query = QueryExpression( + query = QueryStatement( sqlite_dialect, select=[Column(sqlite_dialect, "id")], from_=NamedRelationRef(sqlite_dialect, Table(sqlite_dialect, "users")) ) - create_mv = CreateMaterializedViewExpression(sqlite_dialect, view=MaterializedView(sqlite_dialect, "test_mv"), query=query) + create_mv = CreateMaterializedViewStatement(sqlite_dialect, view=MaterializedView(sqlite_dialect, "test_mv"), query=query) with pytest.raises(UnsupportedFeatureError) as exc_info: create_mv.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_operators.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_operators.py index e108b6cec..342c70bac 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_operators.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_operators.py @@ -4,8 +4,8 @@ SQLOperation, BinaryExpression, UnaryExpression, - RawSQLExpression, - BinaryArithmeticExpression, + RawSQL, + ArithmeticOperation, ) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect @@ -80,14 +80,14 @@ def test_unary_negation_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): assert params == () -class TestBinaryArithmeticExpression: - """Tests for BinaryArithmeticExpression representing arithmetic operations.""" +class TestArithmeticOperation: + """Tests for ArithmeticOperation representing arithmetic operations.""" def test_addition_arithmetic_expression(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test addition arithmetic expression.""" left = Column(sqlite_dialect_3_8_0, "price") right = Literal(sqlite_dialect_3_8_0, 10) - arithmetic_expr = BinaryArithmeticExpression(sqlite_dialect_3_8_0, "+", left, right) + arithmetic_expr = ArithmeticOperation(sqlite_dialect_3_8_0, "add", left, right) sql, params = arithmetic_expr.to_sql() assert sql == '"price" + ?' @@ -97,7 +97,7 @@ def test_subtraction_arithmetic_expression(self, sqlite_dialect_3_8_0: SQLiteDia """Test subtraction arithmetic expression.""" left = Column(sqlite_dialect_3_8_0, "balance") right = Literal(sqlite_dialect_3_8_0, 50) - arithmetic_expr = BinaryArithmeticExpression(sqlite_dialect_3_8_0, "-", left, right) + arithmetic_expr = ArithmeticOperation(sqlite_dialect_3_8_0, "sub", left, right) sql, params = arithmetic_expr.to_sql() assert sql == '"balance" - ?' @@ -107,7 +107,7 @@ def test_multiplication_arithmetic_expression(self, sqlite_dialect_3_8_0: SQLite """Test multiplication arithmetic expression.""" left = Column(sqlite_dialect_3_8_0, "quantity") right = Column(sqlite_dialect_3_8_0, "unit_price") - arithmetic_expr = BinaryArithmeticExpression(sqlite_dialect_3_8_0, "*", left, right) + arithmetic_expr = ArithmeticOperation(sqlite_dialect_3_8_0, "mul", left, right) sql, params = arithmetic_expr.to_sql() assert sql == '"quantity" * "unit_price"' @@ -117,7 +117,7 @@ def test_division_arithmetic_expression(self, sqlite_dialect_3_8_0: SQLiteDialec """Test division arithmetic expression.""" left = Column(sqlite_dialect_3_8_0, "total") right = Literal(sqlite_dialect_3_8_0, 12) - arithmetic_expr = BinaryArithmeticExpression(sqlite_dialect_3_8_0, "/", left, right) + arithmetic_expr = ArithmeticOperation(sqlite_dialect_3_8_0, "div", left, right) sql, params = arithmetic_expr.to_sql() assert sql == '"total" / ?' @@ -127,26 +127,26 @@ def test_modulo_arithmetic_expression(self, sqlite_dialect_3_8_0: SQLiteDialect) """Test modulo arithmetic expression.""" left = Column(sqlite_dialect_3_8_0, "value") right = Literal(sqlite_dialect_3_8_0, 10) - arithmetic_expr = BinaryArithmeticExpression(sqlite_dialect_3_8_0, "%", left, right) + arithmetic_expr = ArithmeticOperation(sqlite_dialect_3_8_0, "mod", left, right) sql, params = arithmetic_expr.to_sql() assert sql == '"value" % ?' assert params == (10,) -class TestRawSQLExpression: - """Tests for RawSQLExpression representing raw SQL strings.""" +class TestRawSQL: + """Tests for RawSQL representing raw SQL strings.""" def test_raw_sql_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test basic RawSQLExpression.""" - raw_expr = RawSQLExpression(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") + """Test basic RawSQL.""" + raw_expr = RawSQL(sqlite_dialect_3_8_0, "CURRENT_TIMESTAMP") sql, params = raw_expr.to_sql() assert sql == "CURRENT_TIMESTAMP" assert params == () def test_raw_sql_with_params(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test RawSQLExpression with parameters.""" - raw_expr = RawSQLExpression(sqlite_dialect_3_8_0, "datetime('now', 'localtime')", ()) + """Test RawSQL with parameters.""" + raw_expr = RawSQL(sqlite_dialect_3_8_0, "datetime('now', 'localtime')", ()) sql, params = raw_expr.to_sql() assert sql == "datetime('now', 'localtime')" assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts.py index a6c16f379..e72fe3546 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts.py @@ -8,7 +8,8 @@ GroupByHavingClause, LimitOffsetClause, OrderByClause, -) + NumericColumn, + StringColumn,) from rhosocial.activerecord.backend.expression.predicates import ( LogicalPredicate, ) @@ -20,7 +21,7 @@ class TestQueryParts: def test_where_clause_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test basic WHERE clause generation.""" - condition = Column(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "active") + condition = StringColumn(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "active") where_clause = WhereClause(sqlite_dialect_3_8_0, condition=condition) sql, params = where_clause.to_sql() @@ -29,8 +30,8 @@ def test_where_clause_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): def test_where_clause_complex(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test complex WHERE clause with multiple conditions.""" - condition1 = Column(sqlite_dialect_3_8_0, "age") >= Literal(sqlite_dialect_3_8_0, 18) - condition2 = Column(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "active") + condition1 = NumericColumn(sqlite_dialect_3_8_0, "age") >= Literal(sqlite_dialect_3_8_0, 18) + condition2 = StringColumn(sqlite_dialect_3_8_0, "status") == Literal(sqlite_dialect_3_8_0, "active") complex_condition = LogicalPredicate(sqlite_dialect_3_8_0, "AND", condition1, condition2) where_clause = WhereClause(sqlite_dialect_3_8_0, condition=complex_condition) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts_join.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts_join.py index 686683c5d..92d1a3b8b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts_join.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_parts_join.py @@ -158,7 +158,10 @@ def test_unsupported_full_outer_join(self, sqlite_dialect_3_8_0: SQLiteDialect): Column(sqlite_dialect_3_8_0, "user_id", "orders"), ), ) - with pytest.raises(UnsupportedFeatureError, match="does not support FULL JOIN"): + # The message names the join that was actually asked for. It read + # "FULL JOIN" here only because the formatter discarded the type and + # hardcoded the wording; the type is a member now, so the message is right. + with pytest.raises(UnsupportedFeatureError, match="does not support FULL OUTER JOIN"): join_expr.to_sql() def test_chained_join_with_unsupported_type(self, sqlite_dialect_3_8_0: SQLiteDialect): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_sources_set_operation.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_sources_set_operation.py index 112bcfe28..81dee0d3c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_sources_set_operation.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_sources_set_operation.py @@ -1,21 +1,21 @@ # tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_query_sources_set_operation.py -from rhosocial.activerecord.backend.expression import Column, Subquery, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Subquery, QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.objects import Table class TestSQLiteSetOperationExpression: - """Tests for SetOperationExpression with UNION, INTERSECT, EXCEPT operations for SQLite backend.""" + """Tests for SetOperation with UNION, INTERSECT, EXCEPT operations for SQLite backend.""" def test_union_operation_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test basic UNION operation between two queries.""" left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION", alias="combined_results" + set_op = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union" ) sql, params = set_op.to_sql() @@ -28,13 +28,11 @@ def test_union_all_operation(self, sqlite_dialect_3_8_0: SQLiteDialect): left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table1", ()) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table2", ()) - set_op = SetOperationExpression( + set_op = SetOperation( sqlite_dialect_3_8_0, left=left_query, right=right_query, - operation="UNION", - alias="union_all_results", - all_=True, # This should add ALL to the operation + op="union_all", ) sql, params = set_op.to_sql() @@ -47,8 +45,8 @@ def test_intersect_operation(self, sqlite_dialect_3_8_0: SQLiteDialect): left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table1 WHERE col1 = ?", ("value1",)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table2 WHERE col2 = ?", ("value2",)) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="INTERSECT", alias="intersection" + set_op = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="intersect" ) sql, params = set_op.to_sql() @@ -61,8 +59,8 @@ def test_except_operation(self, sqlite_dialect_3_8_0: SQLiteDialect): left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table1 WHERE active = ?", (True,)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table2 WHERE archived = ?", (True,)) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="EXCEPT", alias="except_results" + set_op = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="except" ) sql, params = set_op.to_sql() @@ -77,13 +75,13 @@ def test_nested_set_operations(self, sqlite_dialect_3_8_0: SQLiteDialect): query3 = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM table3 WHERE col = ?", ("c",)) # First set operation - inner_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=query1, right=query2, operation="UNION", alias="inner_union" + inner_op = SetOperation( + sqlite_dialect_3_8_0, left=query1, right=query2, op="union" ) # Outer set operation using the inner one - outer_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=inner_op, right=query3, operation="UNION", alias="outer_union" + outer_op = SetOperation( + sqlite_dialect_3_8_0, left=inner_op, right=query3, op="union" ) sql, params = outer_op.to_sql() @@ -95,7 +93,7 @@ def test_set_operation_with_complex_queries(self, sqlite_dialect_3_8_0: SQLiteDi """Test set operation with more complex query structures.""" # Create complex left query left_table = NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "users")) - left_query = QueryExpression( + left_query = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id"), Column(sqlite_dialect_3_8_0, "name")], from_=left_table, @@ -103,14 +101,14 @@ def test_set_operation_with_complex_queries(self, sqlite_dialect_3_8_0: SQLiteDi # Create complex right query right_table = NamedRelationRef(sqlite_dialect_3_8_0, Table(sqlite_dialect_3_8_0, "customers")) - right_query = QueryExpression( + right_query = QueryStatement( sqlite_dialect_3_8_0, select=[Column(sqlite_dialect_3_8_0, "id"), Column(sqlite_dialect_3_8_0, "name")], from_=right_table, ) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION", alias="complex_union" + set_op = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union" ) sql, params = set_op.to_sql() @@ -125,8 +123,8 @@ def test_set_operation_parameters_handling(self, sqlite_dialect_3_8_0: SQLiteDia left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM users WHERE age > ? AND status = ?", (18, "active")) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM customers WHERE region = ?", ("west",)) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION", alias="param_union" + set_op = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union" ) sql, params = set_op.to_sql() @@ -135,164 +133,74 @@ def test_set_operation_parameters_handling(self, sqlite_dialect_3_8_0: SQLiteDia assert params == (18, "active", "west") assert len(params) == 3 - def test_set_operation_with_alias_formatting(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test that alias is properly formatted in the SQL.""" + def test_set_operation_word_form_is_stored_lowercase(self, sqlite_dialect_3_8_0: SQLiteDialect): + """The op word form is case-insensitive at construction, canonical lowercase stored.""" left_query = Subquery(sqlite_dialect_3_8_0, "SELECT col FROM table1", ()) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT col FROM table2", ()) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION", alias="custom_alias" - ) - - sql, params = set_op.to_sql() - - # Verify alias is included in the formatted result - assert "custom_alias" in sql - assert params == () - - def test_set_operation_case_insensitive_operation_names(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test that different case operation names work.""" - left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t1", ()) - right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t2", ()) - - # Test with uppercase (normal case) - set_op_upper = SetOperationExpression( - sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION", alias="test" + set_op_upper = SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="UNION" ) + assert set_op_upper.op == "union" sql_upper, _ = set_op_upper.to_sql() assert "UNION" in sql_upper def test_set_operation_all_variants(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test all operation variants with and without ALL.""" + """Test all operation word forms, including the union_all variant.""" query1 = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t1 WHERE a = ?", (1,)) query2 = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t2 WHERE b = ?", (2,)) - operations = ["UNION", "INTERSECT", "EXCEPT"] - - for op in operations: - # Test without ALL - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, left=query1, right=query2, operation=op, alias=f"test_{op.lower()}" + for op in ("union", "union_all", "intersect", "except"): + set_op = SetOperation( + sqlite_dialect_3_8_0, left=query1, right=query2, op=op ) sql, params = set_op.to_sql() - assert op in sql.upper() + assert op.replace("_", " ").upper() in sql.upper() assert params == (1, 2) - # Test with ALL - set_op_all = SetOperationExpression( - sqlite_dialect_3_8_0, left=query1, right=query2, operation=op, alias=f"test_{op.lower()}_all", all_=True - ) - - sql_all, params_all = set_op_all.to_sql() - assert f"{op} ALL" in sql_all.upper() - assert params_all == (1, 2) - def test_set_operation_without_alias(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test set operation expression without alias.""" + """The node carries no alias: the SQL is the bare compound.""" left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - # Create SetOperationExpression without alias - set_op = SetOperationExpression(sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION") + set_op = SetOperation(sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union") sql, params = set_op.to_sql() # Verify that no alias is present in the SQL - assert "AS" not in sql.upper() or "AS " not in sql # Check that alias is not in SQL + assert "AS" not in sql.upper() or "AS " not in sql # Verify UNION is still present assert "UNION" in sql.upper() # Verify parameters are still handled assert params == (18, "active") - def test_set_operation_with_order_by(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test set operation with ORDER BY clause.""" - from rhosocial.activerecord.backend.expression.query_parts import OrderByClause - - left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with ORDER BY clause - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, - left=left_query, - right=right_query, - operation="UNION", - order_by_clause=OrderByClause(sqlite_dialect_3_8_0, [Column(sqlite_dialect_3_8_0, "id")]), - ) - - sql, params = set_op.to_sql() - - # Verify ORDER BY is present in the SQL - assert "ORDER BY" in sql.upper() - assert '"id"' in sql or "id" in sql - # Verify parameters are still handled - assert params == (18, "active") - - def test_set_operation_with_limit_offset(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test set operation with LIMIT and OFFSET clauses.""" - from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause - - left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with LIMIT/OFFSET clause - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, - left=left_query, - right=right_query, - operation="UNION", - limit_offset_clause=LimitOffsetClause(sqlite_dialect_3_8_0, limit=10, offset=5), - ) - - sql, params = set_op.to_sql() - - # Verify LIMIT and OFFSET are present in the SQL - assert "LIMIT" in sql.upper() - assert "OFFSET" in sql.upper() - # Verify parameters are still handled (including limit and offset values) - assert params == (18, "active", 10, 5) - - def test_set_operation_with_multiple_clauses(self, sqlite_dialect_3_8_0: SQLiteDialect): - """Test set operation with multiple clauses (ORDER BY, LIMIT). Excluding FOR UPDATE for SQLite.""" - from rhosocial.activerecord.backend.expression.query_parts import OrderByClause, LimitOffsetClause - - left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) - right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - - # Create SetOperationExpression with multiple clauses (excluding FOR UPDATE for SQLite) - set_op = SetOperationExpression( - sqlite_dialect_3_8_0, - left=left_query, - right=right_query, - operation="UNION", - order_by_clause=OrderByClause(sqlite_dialect_3_8_0, [Column(sqlite_dialect_3_8_0, "name")]), - limit_offset_clause=LimitOffsetClause(sqlite_dialect_3_8_0, limit=5), - ) - - sql, params = set_op.to_sql() + def test_trailing_clauses_are_not_node_parameters(self, sqlite_dialect_3_8_0: SQLiteDialect): + """Trailing clauses belong to the statement level; the node takes none.""" + left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t1", ()) + right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id FROM t2", ()) + import pytest - # Verify all clauses are present in the SQL - assert "UNION" in sql.upper() - assert "ORDER BY" in sql.upper() - assert "LIMIT" in sql.upper() - # Verify parameters are still handled (including limit value) - assert params == (18, "active", 5) + with pytest.raises(TypeError): + SetOperation( + sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union", + order_by_clause=None, + ) def test_set_operation_with_explain_basic(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test set operation with EXPLAIN clause.""" - from rhosocial.activerecord.backend.expression.statements import ExplainExpression, ExplainOptions + from rhosocial.activerecord.backend.expression.statements import ExplainStatement, ExplainOptions left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - # Create SetOperationExpression first - set_op = SetOperationExpression(sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION") + # Create SetOperation first + set_op = SetOperation(sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union") # Wrap the set operation in an EXPLAIN expression explain_options = ExplainOptions() - explain_expr = ExplainExpression(sqlite_dialect_3_8_0, set_op, explain_options) + explain_expr = ExplainStatement(sqlite_dialect_3_8_0, set_op, explain_options) sql, params = explain_expr.to_sql() @@ -305,17 +213,17 @@ def test_set_operation_with_explain_basic(self, sqlite_dialect_3_8_0: SQLiteDial def test_set_operation_with_explain_query_plan(self, sqlite_dialect_3_8_0: SQLiteDialect): """Test set operation with EXPLAIN QUERY PLAN clause.""" - from rhosocial.activerecord.backend.expression.statements import ExplainExpression, ExplainOptions, ExplainType + from rhosocial.activerecord.backend.expression.statements import ExplainStatement, ExplainOptions, ExplainType left_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM users WHERE age > ?", (18,)) right_query = Subquery(sqlite_dialect_3_8_0, "SELECT id, name FROM customers WHERE status = ?", ("active",)) - # Create SetOperationExpression first - set_op = SetOperationExpression(sqlite_dialect_3_8_0, left=left_query, right=right_query, operation="UNION") + # Create SetOperation first + set_op = SetOperation(sqlite_dialect_3_8_0, left=left_query, right=right_query, op="union") # Wrap the set operation in an EXPLAIN QUERY PLAN expression explain_options = ExplainOptions(type=ExplainType.QUERY_PLAN) - explain_expr = ExplainExpression(sqlite_dialect_3_8_0, set_op, explain_options) + explain_expr = ExplainStatement(sqlite_dialect_3_8_0, set_op, explain_options) sql, params = explain_expr.to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_specific_functions.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_specific_functions.py index 0da6c2508..7b02b0f98 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_specific_functions.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite2/test_sqlite_specific_functions.py @@ -136,7 +136,7 @@ def test_unhex_function_basic(self, sqlite_dialect_3_45_0: SQLiteDialect): assert params == ("68656C6C6F",) -class TestSQLiteTrimFunctions: +class TestSQLiteTrimExpressions: """Tests for SQLite-specific trim functions.""" def test_trim_sqlite_basic(self, sqlite_dialect_3_35_0: SQLiteDialect): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_column_mapping.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_column_mapping.py index 182cd3e48..41856c3a3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_column_mapping.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_column_mapping.py @@ -247,9 +247,9 @@ def test_insert_with_returning_columns_sql_construction(mapped_table_backend): returning_expressions = [ExprColumn(backend.dialect, col) for col in insert_options.returning_columns] returning_clause = BaseReturningClause(backend.dialect, returning_expressions) - from rhosocial.activerecord.backend.expression import InsertExpression + from rhosocial.activerecord.backend.expression import InsertStatement - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=backend.dialect, into=Table(backend.dialect, insert_options.table), source=values_source, @@ -352,9 +352,9 @@ def test_update_with_returning_columns_sql_construction(mapped_table_backend): returning_expressions = [ExprColumn(backend.dialect, col) for col in update_options.returning_columns] returning_clause = BaseReturningClause(backend.dialect, returning_expressions) - from rhosocial.activerecord.backend.expression import UpdateExpression + from rhosocial.activerecord.backend.expression import UpdateStatement - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=backend.dialect, table=Table(backend.dialect, update_options.table), assignments=assignments, @@ -456,9 +456,9 @@ def test_delete_with_returning_columns_sql_construction(mapped_table_backend): returning_expressions = [ExprColumn(backend.dialect, col) for col in delete_options.returning_columns] returning_clause = BaseReturningClause(backend.dialect, returning_expressions) - from rhosocial.activerecord.backend.expression import DeleteExpression + from rhosocial.activerecord.backend.expression import DeleteStatement - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dialect=backend.dialect, tables=Table(backend.dialect, delete_options.table), where=delete_options.where, diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_sqlite_examples.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_sqlite_examples.py index 9a16eaf9d..1cb618e96 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_sqlite_examples.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite3/test_sqlite_examples.py @@ -315,7 +315,7 @@ def test_sudoku_modulo_division_expression(sqlite_backend): # Test division: (ind-1)/9 ind_col = NumericColumn(dialect, "ind", table="x") - div_expr = (ind_col - Literal(dialect, 1)) / Literal(dialect, 9) # Uses BinaryArithmeticExpression internally + div_expr = (ind_col - Literal(dialect, 1)) / Literal(dialect, 9) # Uses ArithmeticOperation internally sql, params = div_expr.to_sql() assert "/" in sql @@ -323,7 +323,7 @@ def test_sudoku_modulo_division_expression(sqlite_backend): print(f"Division expression: {sql} with params {params}") # Test modulo: ((ind-1)%9) - mod_expr = (ind_col - Literal(dialect, 1)) % Literal(dialect, 9) # Uses BinaryArithmeticExpression internally + mod_expr = (ind_col - Literal(dialect, 1)) % Literal(dialect, 9) # Uses ArithmeticOperation internally sql, params = mod_expr.to_sql() assert "%" in sql @@ -366,15 +366,14 @@ def test_sudoku_exists_expression(sqlite_backend): Column, Literal, FunctionCall, - QueryExpression, - ExistsExpression, - ) + QueryStatement, + ExistsPredicate,) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef dialect = sqlite_backend.dialect # Create a subquery for the EXISTS clause - subquery = QueryExpression( + subquery = QueryStatement( dialect, select=[Literal(dialect, 1)], # SELECT 1 from_=NamedRelationRef(dialect, Table(dialect, "digits"), alias="lp"), @@ -384,7 +383,7 @@ def test_sudoku_exists_expression(sqlite_backend): ), ) - exists_expr = ExistsExpression(dialect, subquery) + exists_expr = ExistsPredicate(dialect, subquery) sql, params = exists_expr.to_sql() assert "EXISTS" in sql.upper() @@ -399,8 +398,8 @@ def test_sudoku_not_exists_expression(sqlite_backend): """ from rhosocial.activerecord.backend.expression import ( Literal, - QueryExpression, - ExistsExpression, + QueryStatement, + ExistsPredicate, LogicalPredicate, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -408,14 +407,14 @@ def test_sudoku_not_exists_expression(sqlite_backend): dialect = sqlite_backend.dialect # Create a subquery for the NOT EXISTS clause - subquery = QueryExpression( + subquery = QueryStatement( dialect, select=[Literal(dialect, 1)], # SELECT 1 from_=NamedRelationRef(dialect, Table(dialect, "digits"), alias="lp"), ) # Create EXISTS expression and negate it with NOT - exists_expr = ExistsExpression(dialect, subquery) + exists_expr = ExistsPredicate(dialect, subquery) not_exists_expr = LogicalPredicate(dialect, "NOT", exists_expr) sql, params = not_exists_expr.to_sql() @@ -432,13 +431,13 @@ def test_sudoku_full_cte_expression(sqlite_backend): from rhosocial.activerecord.backend.expression import ( Literal, ValuesExpression, - QueryExpression, + QueryStatement, Column, NumericColumn, FunctionCall, - CTEExpression, - WithQueryExpression, - SetOperationExpression, + CTEClause, + WithStatement, + SetOperation, concat_op, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -456,7 +455,7 @@ def test_sudoku_full_cte_expression(sqlite_backend): print(f"Sudoku input: {sudoku_input}") # Build the input CTE: input(sud) AS (VALUES(?)) - input_cte = CTEExpression( + input_cte = CTEClause( dialect=dialect, name="input", query=ValuesExpression( @@ -481,7 +480,7 @@ def test_sudoku_full_cte_expression(sqlite_backend): cast_expr = cast(dialect, lp_column + Literal(dialect, 1), CustomType(dialect, raw="TEXT")) lp_plus_one = lp_column + Literal(dialect, 1) - recursive_query = QueryExpression( + recursive_query = QueryStatement( dialect=dialect, select=[cast_expr, lp_plus_one], from_=digits_table, where=(lp_column < Literal(dialect, 9)) ) @@ -497,7 +496,7 @@ def test_sudoku_full_cte_expression(sqlite_backend): print(f"Sudoku input: {sudoku_input}") # Build the input CTE: input(sud) AS (VALUES(?)) - input_cte = CTEExpression( + input_cte = CTEClause( dialect=dialect, name="input", query=ValuesExpression( @@ -521,16 +520,16 @@ def test_sudoku_full_cte_expression(sqlite_backend): cast_expr = cast(dialect, lp_column + Literal(dialect, 1), CustomType(dialect, raw="TEXT")) lp_plus_one = lp_column + Literal(dialect, 1) - recursive_query = QueryExpression( + recursive_query = QueryStatement( dialect=dialect, select=[cast_expr, lp_plus_one], from_=digits_table, where=(lp_column < Literal(dialect, 9)) ) - # Use SetOperationExpression to build the UNION ALL - digits_union = SetOperationExpression( - dialect=dialect, left=initial_digits_values, right=recursive_query, operation="UNION", all_=True, alias=None + # Use SetOperation to build the UNION ALL + digits_union = SetOperation( + dialect=dialect, left=initial_digits_values, right=recursive_query, op="union_all" ) - digits_cte = CTEExpression(dialect=dialect, name="digits", query=digits_union, columns=["z", "lp"]) + digits_cte = CTEClause(dialect=dialect, name="digits", query=digits_union, columns=["z", "lp"]) # Build the x CTE: x(s, ind) AS (SELECT sud, instr(sud, '.') FROM input UNION ALL ...) # Initial part @@ -538,7 +537,7 @@ def test_sudoku_full_cte_expression(sqlite_backend): sud_column = Column(dialect, "sud", table="input") instr_call = FunctionCall(dialect, "INSTR", sud_column, Literal(dialect, ".")) - initial_x_query = QueryExpression(dialect=dialect, select=[sud_column, instr_call], from_=input_table) + initial_x_query = QueryStatement(dialect=dialect, select=[sud_column, instr_call], from_=input_table) # Recursive part - simplified for demonstration x_table = NamedRelationRef(dialect, Table(dialect, "x")) @@ -556,14 +555,14 @@ def test_sudoku_full_cte_expression(sqlite_backend): # Build the NOT EXISTS subquery from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ComparisonPredicate, Literal, FunctionCall, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - from rhosocial.activerecord.backend.expression.advanced_functions import ExistsExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ExistsPredicate # Subquery for NOT EXISTS: SELECT 1 FROM digits AS lp WHERE ... lp_table = NamedRelationRef(dialect, Table(dialect, "digits"), alias="lp") @@ -598,7 +597,7 @@ def test_sudoku_full_cte_expression(sqlite_backend): subquery_where = cond1 | cond2 | cond3 # Create the subquery for NOT EXISTS - not_exists_subquery = QueryExpression( + not_exists_subquery = QueryStatement( dialect=dialect, select=[Literal(dialect, 1)], # SELECT 1 from_=lp_table, @@ -606,27 +605,27 @@ def test_sudoku_full_cte_expression(sqlite_backend): ) # Create the NOT EXISTS expression - not_exists_expr = ExistsExpression(dialect, not_exists_subquery, is_not=True) + not_exists_expr = ExistsPredicate(dialect, not_exists_subquery, is_not=True) # FROM x, digits AS z (cross join using list) from_clause = [x_table, NamedRelationRef(dialect, Table(dialect, "digits"), alias="z")] - recursive_x_query = QueryExpression( + recursive_x_query = QueryStatement( dialect=dialect, select=[new_string_expr, new_instr_call], from_=from_clause, # Cross join where=(ind_column > Literal(dialect, 0)) & not_exists_expr, # Add the NOT EXISTS constraint ) - # Use SetOperationExpression to build the UNION ALL for x CTE - x_union = SetOperationExpression( - dialect=dialect, left=initial_x_query, right=recursive_x_query, operation="UNION", all_=True, alias=None + # Use SetOperation to build the UNION ALL for x CTE + x_union = SetOperation( + dialect=dialect, left=initial_x_query, right=recursive_x_query, op="union_all" ) - x_cte = CTEExpression(dialect=dialect, name="x", query=x_union, columns=["s", "ind"]) + x_cte = CTEClause(dialect=dialect, name="x", query=x_union, columns=["s", "ind"]) # Final query: SELECT s FROM x WHERE ind=0 - final_query = QueryExpression( + final_query = QueryStatement( dialect=dialect, select=[Column(dialect, "s")], from_=NamedRelationRef(dialect, Table(dialect, "x")), @@ -634,10 +633,10 @@ def test_sudoku_full_cte_expression(sqlite_backend): ) # Build the complete WITH query - with_query = WithQueryExpression( + with_query = WithStatement( dialect=dialect, ctes=[input_cte, digits_cte, x_cte], # input, digits, and x CTEs - main_query=final_query, + query=final_query, recursive=True, ) @@ -777,14 +776,14 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): from rhosocial.activerecord.backend.expression import ( Literal, ValuesExpression, - QueryExpression, + QueryStatement, Column, NumericColumn, BinaryExpression, FunctionCall, - CTEExpression, - WithQueryExpression, - SetOperationExpression, + CTEClause, + WithStatement, + SetOperation, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -811,21 +810,19 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): # SELECT x+0.05 FROM xaxis WHERE x<1.2 x_plus_increment = x_col + Literal(dialect, 0.05) - recursive_xaxis_query = QueryExpression( + recursive_xaxis_query = QueryStatement( dialect=dialect, select=[x_plus_increment], from_=xaxis_table, where=(x_col < Literal(dialect, 1.2)) ) - # Use SetOperationExpression to build the UNION ALL - xaxis_union = SetOperationExpression( + # Use SetOperation to build the UNION ALL + xaxis_union = SetOperation( dialect=dialect, left=initial_xaxis_values, right=recursive_xaxis_query, - operation="UNION", - all_=True, - alias=None, + op="union_all", ) - xaxis_cte = CTEExpression(dialect=dialect, name="xaxis", query=xaxis_union, columns=["x"]) + xaxis_cte = CTEClause(dialect=dialect, name="xaxis", query=xaxis_union, columns=["x"]) # Build the yaxis CTE: yaxis(y) AS (VALUES(-1.0) UNION ALL SELECT y+0.1 FROM yaxis WHERE y<1.0) # Initial value part @@ -838,21 +835,19 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): # SELECT y+0.1 FROM yaxis WHERE y<1.0 y_plus_increment = y_col + Literal(dialect, 0.1) - recursive_yaxis_query = QueryExpression( + recursive_yaxis_query = QueryStatement( dialect=dialect, select=[y_plus_increment], from_=yaxis_table, where=(y_col < Literal(dialect, 1.0)) ) - # Use SetOperationExpression to build the UNION ALL - yaxis_union = SetOperationExpression( + # Use SetOperation to build the UNION ALL + yaxis_union = SetOperation( dialect=dialect, left=initial_yaxis_values, right=recursive_yaxis_query, - operation="UNION", - all_=True, - alias=None, + op="union_all", ) - yaxis_cte = CTEExpression(dialect=dialect, name="yaxis", query=yaxis_union, columns=["y"]) + yaxis_cte = CTEClause(dialect=dialect, name="yaxis", query=yaxis_union, columns=["y"]) # Build the m CTE: m(iter, cx, cy, x, y) AS ( # SELECT 0, x, y, 0.0, 0.0 FROM xaxis, yaxis @@ -861,7 +856,7 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): # WHERE (x*x + y*y) < 4.0 AND iter<28 # ) # Initial part: SELECT 0, x, y, 0.0, 0.0 FROM xaxis, yaxis - initial_m_query = QueryExpression( + initial_m_query = QueryStatement( dialect=dialect, select=[ Literal(dialect, 0), # iter @@ -884,22 +879,22 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): # SELECT iter+1, cx, cy, x*x-y*y + cx, 2.0*x*y + cy FROM m WHERE (x*x + y*y) < 4.0 AND iter<28 new_iter = iter_col + Literal(dialect, 1) new_x = (x_m_col * x_m_col - y_m_col * y_m_col) + cx_col - new_y = Literal(dialect, 2.0) * x_m_col * y_m_col + cy_col + new_y = (x_m_col * y_m_col) * Literal(dialect, 2.0) + cy_col condition1 = (x_m_col * x_m_col + y_m_col * y_m_col) < Literal(dialect, 4.0) condition2 = iter_col < Literal(dialect, 28) where_condition = condition1 & condition2 - recursive_m_query = QueryExpression( + recursive_m_query = QueryStatement( dialect=dialect, select=[new_iter, cx_col, cy_col, new_x, new_y], from_=m_table, where=where_condition ) - # Use SetOperationExpression to build the UNION ALL for m CTE - m_union = SetOperationExpression( - dialect=dialect, left=initial_m_query, right=recursive_m_query, operation="UNION", all_=True, alias=None + # Use SetOperation to build the UNION ALL for m CTE + m_union = SetOperation( + dialect=dialect, left=initial_m_query, right=recursive_m_query, op="union_all" ) - m_cte = CTEExpression(dialect=dialect, name="m", query=m_union, columns=["iter", "cx", "cy", "x", "y"]) + m_cte = CTEClause(dialect=dialect, name="m", query=m_union, columns=["iter", "cx", "cy", "x", "y"]) # Build the m2 CTE: m2(iter, cx, cy) AS ( # SELECT max(iter), cx, cy FROM m GROUP BY cx, cy @@ -911,7 +906,7 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause - m2_query = QueryExpression( + m2_query = QueryStatement( dialect=dialect, select=[max_iter, m2_cx, m2_cy], from_=m2_from, @@ -922,7 +917,7 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): ), ) - m2_cte = CTEExpression(dialect=dialect, name="m2", query=m2_query, columns=["iter", "cx", "cy"]) + m2_cte = CTEClause(dialect=dialect, name="m2", query=m2_query, columns=["iter", "cx", "cy"]) # Build the a CTE: a(t) AS ( # SELECT group_concat( substr(' .+*#', 1+min(iter/7,4), 1), '') @@ -949,7 +944,7 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): ) group_concat_expr = FunctionCall(dialect, "GROUP_CONCAT", substr_expr, Literal(dialect, "")) - a_query = QueryExpression( + a_query = QueryStatement( dialect=dialect, select=[group_concat_expr], from_=a_from, @@ -958,7 +953,7 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): ), ) - a_cte = CTEExpression(dialect=dialect, name="a", query=a_query, columns=["t"]) + a_cte = CTEClause(dialect=dialect, name="a", query=a_query, columns=["t"]) # Final query: SELECT group_concat(rtrim(t),x'0a') FROM a final_from = NamedRelationRef(dialect, Table(dialect, "a")) @@ -967,11 +962,11 @@ def test_mandelbrot_set_expression_system(self, sqlite_backend): dialect, "GROUP_CONCAT", rtrim_expr, Literal(dialect, "\n") ) # Using \n instead of x'0a' - final_query = QueryExpression(dialect=dialect, select=[final_group_concat], from_=final_from) + final_query = QueryStatement(dialect=dialect, select=[final_group_concat], from_=final_from) # Build the complete WITH query - with_query = WithQueryExpression( - dialect=dialect, ctes=[xaxis_cte, yaxis_cte, m_cte, m2_cte, a_cte], main_query=final_query + with_query = WithStatement( + dialect=dialect, ctes=[xaxis_cte, yaxis_cte, m_cte, m2_cte, a_cte], query=final_query ) # Generate the final SQL diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_alter_table_integration.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_alter_table_integration.py index b3a9d79ac..311979a75 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_alter_table_integration.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_alter_table_integration.py @@ -2,49 +2,57 @@ """ Integration tests for ALTER TABLE operations via the expression system. -These tests exercise AlterTableExpression and its action subclasses against +These tests exercise AlterTableStatement and its action subclasses against a real SQLite database, then use the introspector to verify the schema was actually modified. This validates the end-to-end correctness of the Expression -> Dialect -> SQL -> Database -> Introspector pipeline. """ import pytest - from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - AlterTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, -) -from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, - DropColumn, - RenameObject, - AddTableConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteTextType +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.introspection.types import ColumnNullable +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements.ddl_table import ( TableConstraint, - TableConstraintType, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.introspection.types import ColumnNullable -from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.expression.types import SQLiteIntegerType, SQLiteTextType -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, + DropColumnAction, + RenameColumnAction, + AddTableConstraintAction, +) +from rhosocial.activerecord.backend.expression.statements import ( + CreateTableStatement, + AlterTableStatement, + ColumnDefinition, + ColumnConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import TableCheckConstraint + @pytest.fixture def backend_with_users(sqlite_backend: SQLiteBackend) -> SQLiteBackend: """Create a backend with a simple 'users' table for ALTER TABLE tests.""" - create_expr = CreateTableExpression( + create_expr = CreateTableStatement( dialect=sqlite_backend.dialect, table=Table(sqlite_backend.dialect, 'users'), columns=[ ColumnDefinition(sqlite_backend.dialect, "id", SQLiteIntegerType(sqlite_backend.dialect), - constraints=[ColumnConstraint(sqlite_backend.dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ + ColumnConstraint( + sqlite_backend.dialect, + branch=ColumnPrimaryKeyConstraint(sqlite_backend.dialect), + ) + ], ), ColumnDefinition(sqlite_backend.dialect, "name", SQLiteTextType(dialect=sqlite_backend.dialect)), ], @@ -64,11 +72,14 @@ class TestAlterTableAddColumn: def test_add_column_appears_in_introspector(self, backend_with_users): """ADD COLUMN should be reflected by the introspector.""" - add_action = AddColumn( + add_action = AddColumnAction( backend_with_users.dialect, - column=ColumnDefinition(backend_with_users.dialect, "email", SQLiteTextType(dialect=backend_with_users.dialect)), - ) - alter_expr = AlterTableExpression( +ColumnDefinition( + backend_with_users.dialect, + "email", + SQLiteTextType(dialect=backend_with_users.dialect), + ) ) + alter_expr = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[add_action], @@ -81,15 +92,20 @@ def test_add_column_appears_in_introspector(self, backend_with_users): def test_add_column_with_not_null(self, backend_with_users): """ADD COLUMN with NOT NULL constraint should be introspectable.""" - add_action = AddColumn( + add_action = AddColumnAction( backend_with_users.dialect, column=ColumnDefinition(backend_with_users.dialect, "status", SQLiteTextType(dialect=backend_with_users.dialect), - constraints=[ColumnConstraint(backend_with_users.dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ + ColumnConstraint( + backend_with_users.dialect, + branch=ColumnNotNullConstraint(backend_with_users.dialect), + ) + ], ), ) - alter_expr = AlterTableExpression( + alter_expr = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[add_action], @@ -114,11 +130,14 @@ def test_drop_column_removed_from_introspector(self, backend_with_users): pytest.skip("SQLite DROP COLUMN requires 3.35.0+") # First add a column to drop - add_action = AddColumn( + add_action = AddColumnAction( backend_with_users.dialect, - column=ColumnDefinition(backend_with_users.dialect, "temp_field", SQLiteTextType(dialect=backend_with_users.dialect)), - ) - alter_add = AlterTableExpression( +ColumnDefinition( + backend_with_users.dialect, + "temp_field", + SQLiteTextType(dialect=backend_with_users.dialect), + ) ) + alter_add = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[add_action], @@ -130,11 +149,11 @@ def test_drop_column_removed_from_introspector(self, backend_with_users): assert "temp_field" in [c.name for c in columns] # Now drop it - drop_action = DropColumn( + drop_action = DropColumnAction( backend_with_users.dialect, column_name="temp_field", ) - alter_drop = AlterTableExpression( + alter_drop = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[drop_action], @@ -151,12 +170,12 @@ class TestAlterTableRenameColumn: def test_rename_column_reflected_in_introspector(self, backend_with_users): """RENAME COLUMN should change the column name in introspector results.""" - rename_action = RenameObject( + rename_action = RenameColumnAction( backend_with_users.dialect, old_name="name", new_name="full_name", ) - alter_expr = AlterTableExpression( + alter_expr = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[rename_action], @@ -188,11 +207,14 @@ def test_add_check_constraint_enforces_non_null(self, backend_with_users): from rhosocial.activerecord.backend.expression import Column # Add an email column - add_action = AddColumn( + add_action = AddColumnAction( backend_with_users.dialect, - column=ColumnDefinition(backend_with_users.dialect, "email", SQLiteTextType(dialect=backend_with_users.dialect)), - ) - alter_add = AlterTableExpression( +ColumnDefinition( + backend_with_users.dialect, + "email", + SQLiteTextType(dialect=backend_with_users.dialect), + ) ) + alter_add = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[add_action], @@ -206,16 +228,17 @@ def test_add_check_constraint_enforces_non_null(self, backend_with_users): ) # Add CHECK constraint: email IS NOT NULL - add_constraint = AddTableConstraint( + add_constraint = AddTableConstraintAction( backend_with_users.dialect, - constraint=TableConstraint(backend_with_users.dialect, - constraint_type=TableConstraintType.CHECK, - check_condition=Column( - backend_with_users.dialect, "email" - ).is_not_null(), + constraint=TableConstraint( + backend_with_users.dialect, + branch=TableCheckConstraint( + backend_with_users.dialect, + Column(backend_with_users.dialect, "email").is_not_null(), + ), ), ) - alter_constraint = AlterTableExpression( + alter_constraint = AlterTableStatement( backend_with_users.dialect, table=Table(backend_with_users.dialect, "users"), actions=[add_constraint], @@ -241,7 +264,7 @@ class TestSQLiteIfExistsGuard: def test_sqlite_rejects_add_column_if_not_exists(self, sqlite_backend): """ADD COLUMN IF NOT EXISTS must raise on SQLite.""" - action = AddColumn( + action = AddColumnAction( sqlite_backend.dialect, column=ColumnDefinition(sqlite_backend.dialect, "email", SQLiteTextType(dialect=sqlite_backend.dialect)), if_not_exists=True, @@ -251,7 +274,7 @@ def test_sqlite_rejects_add_column_if_not_exists(self, sqlite_backend): def test_sqlite_rejects_drop_column_if_exists(self, sqlite_backend): """DROP COLUMN IF EXISTS must raise on SQLite.""" - action = DropColumn( + action = DropColumnAction( sqlite_backend.dialect, column_name="email", if_exists=True, @@ -261,14 +284,14 @@ def test_sqlite_rejects_drop_column_if_exists(self, sqlite_backend): def test_sqlite_allows_bare_add_and_drop(self, sqlite_backend): """Bare ADD/DROP COLUMN (no qualifier) must still work on SQLite.""" - add_action = AddColumn( + add_action = AddColumnAction( sqlite_backend.dialect, column=ColumnDefinition(sqlite_backend.dialect, "email", SQLiteTextType(dialect=sqlite_backend.dialect)), ) add_sql, _ = add_action.to_sql() assert "IF NOT EXISTS" not in add_sql - drop_action = DropColumn(sqlite_backend.dialect, column_name="email") + drop_action = DropColumnAction(sqlite_backend.dialect, column_name="email") drop_sql, _ = drop_action.to_sql() assert "IF EXISTS" not in drop_sql @@ -278,15 +301,15 @@ class TestActionDialectBinding: def test_action_has_dialect_after_construction(self, sqlite_backend): """Action should have its dialect accessible after construction.""" - action = AddColumn( + action = AddColumnAction( sqlite_backend.dialect, column=ColumnDefinition(sqlite_backend.dialect, "x", SQLiteIntegerType(sqlite_backend.dialect)), ) assert action.dialect is sqlite_backend.dialect def test_action_to_sql_works_directly(self, sqlite_backend): - """Action.to_sql() should work without wrapping in AlterTableExpression.""" - action = AddColumn( + """Action.to_sql() should work without wrapping in AlterTableStatement.""" + action = AddColumnAction( sqlite_backend.dialect, column=ColumnDefinition(sqlite_backend.dialect, "x", SQLiteIntegerType(sqlite_backend.dialect)), ) @@ -298,7 +321,7 @@ def test_action_isinstance_tosql_protocol(self, sqlite_backend): """Action should satisfy ToSQLProtocol.""" from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol - action = AddColumn( + action = AddColumnAction( sqlite_backend.dialect, column=ColumnDefinition(sqlite_backend.dialect, "x", SQLiteIntegerType(sqlite_backend.dialect)), ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_cli_introspect_e2e.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_cli_introspect_e2e.py new file mode 100644 index 000000000..4390bc969 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_cli_introspect_e2e.py @@ -0,0 +1,193 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_cli_introspect_e2e.py +"""The CLI's introspection must work end to end, through the dialect. + +Two things are pinned here, and the second is the reason this file exists. + +First: every introspect subcommand produces real output for a real database. +A CLI that exits zero and prints nothing would pass a smoke test and be useless. + +Second, and more importantly: the CLI *reaches the dialect formatters*. An +earlier trace concluded it did not -- that trace built its own parser and never +went through ``cli.get_handler``, so it observed nothing and reported nothing as +a finding. A trace that observes nothing is indistinguishable from one that +observed the wrong thing. This test goes through the shipped ``main()``, so the +claim is checked rather than assumed. +""" + +import contextlib +import io +import sqlite3 + +import pytest + +from rhosocial.activerecord.backend.impl.sqlite import cli as sqlite_cli +from rhosocial.activerecord.backend.impl.sqlite.__main__ import main + + +@pytest.fixture(scope="module") +def db_file(tmp_path_factory): + db = tmp_path_factory.mktemp("cli_introspect") / "probe.sqlite" + raw = sqlite3.connect(str(db)) + raw.executescript( + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT);" + "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id)," + " total NUMERIC);" + "CREATE VIEW active_users AS SELECT id, name FROM users;" + "CREATE INDEX idx_users_email ON users(email);" + "CREATE UNIQUE INDEX idx_users_name ON users(name);" + "CREATE TRIGGER trg_users AFTER INSERT ON users BEGIN SELECT 1; END;" + ) + raw.commit() + raw.close() + return db + + +def run(argv): + """Run main() and return (exit code, stripped stdout).""" + out, err = io.StringIO(), io.StringIO() + code = 0 + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + main(argv) + except SystemExit as e: + code = e.code or 0 + return code, out.getvalue().strip() + + +# --------------------------------------------------------------------------- +# Every advertised subcommand produces real output +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "argv_tail", + [ + ("tables",), + ("views",), + ("triggers",), + ("database",), + ], +) +def test_introspect_subcommand_returns_data(db_file, argv_tail): + code, out = run(["introspect", *argv_tail, "--db-file", str(db_file)]) + assert code == 0, out + assert out, "exited 0 with no output -- the CLI printed nothing" + assert out not in ("[]", "None"), out + + +@pytest.mark.parametrize( + "argv_tail", + [ + ("columns", "users"), + ("indexes", "users"), + ("foreign-keys", "orders"), + ], +) +def test_introspect_named_subcommands(db_file, argv_tail): + """Subcommands that take a target name.""" + code, out = run(["introspect", *argv_tail, "--db-file", str(db_file)]) + assert code == 0, (argv_tail, out) + assert out, argv_tail + + +def test_introspect_tables_include_system_branch(db_file): + """``--include-system`` takes the PRAGMA branch, not the sqlite_master tree. + + Both branches have to work; this is the one the tree rewrite does not touch. + """ + code, out = run(["introspect", "tables", "--include-system", "--db-file", str(db_file)]) + assert code == 0, out + assert out, "no output from the include-system branch" + + +def test_introspect_accepts_json_output(db_file): + code, out = run(["introspect", "views", "-o", "json", "--db-file", str(db_file)]) + assert code == 0, out + assert out, "no output from the json formatter" + + +# --------------------------------------------------------------------------- +# The CLI reaches the dialect -- the claim the earlier trace got wrong +# --------------------------------------------------------------------------- + + +def test_cli_dispatches_through_get_handler(monkeypatch): + """``main()`` resolves the handler via ``get_handler``. + + The wrong trace built its own parser and called ``args.func``, which does + not exist; it therefore ran no handler at all. Pinning the dispatch shape + makes that specific mistake impossible to repeat. + """ + seen = [] + original = sqlite_cli.get_handler + + def spy(command): + handler = original(command) + seen.append(command) + return handler + + monkeypatch.setattr(sqlite_cli, "get_handler", spy) + run(["introspect", "views", "--db-file", ":memory:"]) + assert "introspect" in seen, "get_handler was never consulted" + + +def test_cli_views_reach_the_dialect_formatter(db_file, monkeypatch): + """``introspect views`` must call the SQLite view-list formatter. + + This is the assertion whose absence let the wrong conclusion stand: the + trace saw no ``format_*`` calls and reported the CLI as bypassing the + dialect, when in fact it had not run a handler at all. + """ + from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + + calls = [] + original = SQLiteDialect.format_view_list_query_statement + + def spy(self, expr): + calls.append(expr) + return original(self, expr) + + monkeypatch.setattr(SQLiteDialect, "format_view_list_query_statement", spy) + code, out = run(["introspect", "views", "--db-file", str(db_file)]) + assert code == 0, out + assert calls, "format_view_list_query_statement was never called by the CLI" + + +# --------------------------------------------------------------------------- +# Each invocation is a fresh look, so a schema change is seen immediately +# --------------------------------------------------------------------------- + + +def test_a_schema_change_between_runs_is_visible(db_file): + """Two CLI runs in one process must not share an answer. + + This is the property that lets the CLI skip reasoning about the + introspection cache. A command is one process in normal use, but a + script -- or this test -- can drive ``main()`` more than once, and if + two runs reached the same cached metadata a table created between + them would be invisible. Nothing in the CLI invalidates anything, so + the guarantee has to come from each run building its own manager. + + The DDL is committed by a separate connection, the way any external + tool would do it: the CLI must observe a change it did not make. + + Asked for JSON rather than the default table on purpose. The rendered + table wraps every cell to five characters, so a name like + ``added_later`` never appears as one contiguous string -- asserting on + that text would test the renderer's wrapping, not the answer. + """ + code, before = run(["introspect", "tables", "--db-file", str(db_file), "-o", "json"]) + assert code == 0, before + assert "added_later" not in before + + raw = sqlite3.connect(str(db_file)) + with raw: + raw.execute("CREATE TABLE added_later (id INTEGER PRIMARY KEY, note TEXT)") + raw.close() + + code, after = run(["introspect", "tables", "--db-file", str(db_file), "-o", "json"]) + assert code == 0, after + assert "added_later" in after, ( + "a table created between two runs in the same process was not " + "reported -- the second run answered from the first run's metadata" + ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspect_surface.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspect_surface.py new file mode 100644 index 000000000..3a210b17b --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspect_surface.py @@ -0,0 +1,195 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspect_surface.py +"""What the SQLite introspect command offers, and what it actually asks. + +Three defects are pinned here, and none of them is visible to a test that only +asks "did the command print something": + +* a generated column was missing from ``columns`` altogether, because the + question was ``PRAGMA table_info``, which the engine does not answer for + generated columns -- the parser downstream of it had been written for + ``table_xinfo`` all along; +* a ``NAME`` the type could not use was accepted and ignored, so + ``introspect tables users`` printed the whole catalogue and read as though it + had been narrowed; +* the advertised types were a hand-written list that no test compared against + the dialect switches, so a family could be offered with nothing behind it. +""" + +import contextlib +import io +import json +import sqlite3 + +import pytest + +from rhosocial.activerecord.backend.impl.sqlite.cli import introspect as introspect_cli +from rhosocial.activerecord.backend.impl.sqlite.__main__ import main +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + + +@pytest.fixture() +def db_file(tmp_path): + db = tmp_path / "surface.sqlite" + raw = sqlite3.connect(str(db)) + raw.executescript( + "CREATE TABLE gen (" + " a INTEGER," + " b INTEGER GENERATED ALWAYS AS (a * 2) VIRTUAL," + " c INTEGER GENERATED ALWAYS AS (a + 1) STORED" + ");" + "CREATE VIEW v_gen AS SELECT a, b FROM gen;" + "CREATE TRIGGER trg_gen AFTER INSERT ON gen BEGIN SELECT 1; END;" + ) + raw.commit() + raw.close() + return db + + +def run(argv): + """Run main() and return (exit code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + code = 0 + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + main(argv) + except SystemExit as e: + code = e.code or 0 + return code, out.getvalue().strip(), err.getvalue() + + +class _Args: + """The two attributes the rules look at, and nothing else.""" + + def __init__(self, type_, name=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): + dialect = SQLiteDialect(version=(3, 45, 0)) + missing = [ + (name, switch) + for name, switch in introspect_cli._TYPE_CAPABILITIES.items() + if not hasattr(dialect, switch) + ] + assert missing == [] + + 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): + code, out, _ = run(["introspect", "--help"]) + assert code == 0 + for name in introspect_cli.INTROSPECT_TYPES: + assert name in out + + def test_every_type_has_a_branch(self): + """A type with no branch prints nothing and exits 0 -- the worst shape.""" + import inspect + + source = inspect.getsource(introspect_cli.handle) + for name in introspect_cli.INTROSPECT_TYPES: + assert f'args.type == "{name}"' in source, name + + +class TestGeneratedColumns: + """``columns`` asks the pragma that can see generated columns. + + The introspector's half is pinned in ``test_introspection_columns.py``; + this is the same fact through the shipped command, because the command is + where the omission was noticed. + """ + + def test_the_command_reports_them(self, db_file): + code, out, _ = run( + ["introspect", "columns", "gen", "--db-file", str(db_file), "-o", "json"] + ) + + assert code == 0 + payload = json.loads(out) + assert [row["name"] for row in payload] == ["a", "b", "c"] + assert [row["is_generated"] for row in payload] == [False, True, True] + + +class TestOneView: + def test_a_named_view_is_described(self, db_file): + code, out, _ = run( + ["introspect", "views", "v_gen", "--db-file", str(db_file), "-o", "json"] + ) + + assert code == 0 + payload = json.loads(out) + assert len(payload) == 1 + assert payload[0]["name"] == "v_gen" + assert payload[0]["definition"] + + def test_without_a_name_the_family_is_listed(self, db_file): + code, out, _ = run(["introspect", "views", "--db-file", str(db_file), "-o", "json"]) + + assert code == 0 + assert [row["name"] for row in json.loads(out)] == ["v_gen"] + + def test_an_unknown_view_exits_one(self, db_file): + code, _, err = run(["introspect", "views", "nope", "--db-file", str(db_file)]) + + assert code == 1 + assert "not found" in err + + +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"]) + def test_a_required_name_must_be_given(self, type_, capsys): + """A missing NAME is an incomplete invocation, like a missing connection.""" + with pytest.raises(SystemExit) as excinfo: + introspect_cli._validate_name(_Args(type_)) + + assert excinfo.value.code == 1 + assert "required" in capsys.readouterr().err + + @pytest.mark.parametrize("type_", ["views", "triggers"]) + 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")) + + def test_the_command_refuses_it_too(self, db_file): + """The rule is applied by the command, not only by the helper.""" + code, _, err = run(["introspect", "tables", "gen", "--db-file", str(db_file)]) + + assert code == 2 + assert "takes no name" in err + + +class TestTheCapabilityGate: + def test_a_refused_family_exits_two(self, capsys): + class _Dialect: + @staticmethod + def supports_trigger_introspection(): + 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 CLI tests stand a stub in for the backend.""" + introspect_cli._require_capability(_Args("triggers"), object()) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_cache.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_cache.py index 10cbdfe10..04b907c0c 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_cache.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_cache.py @@ -8,7 +8,11 @@ import time +from rhosocial.activerecord.backend.introspection import InMemoryIntrospectionCache from rhosocial.activerecord.backend.introspection.types import IntrospectionScope +from rhosocial.activerecord.backend.impl.sqlite.introspection import ( + SyncSQLiteIntrospector, +) class TestCacheManagement: @@ -25,8 +29,8 @@ def test_clear_cache(self, backend_with_tables): # Clear cache intro.clear_cache() - # Verify cache is empty by checking internal cache dict - assert len(intro._cache) == 0 + # The cache reports its size; an empty cache has none. + assert len(intro.cache) == 0 def test_cache_hit(self, backend_with_tables): """Test that cached results are returned.""" @@ -65,7 +69,7 @@ def test_invalidate_all_scopes(self, backend_with_tables): # Invalidate all intro.invalidate_cache() - assert len(intro._cache) == 0 + assert len(intro.cache) == 0 def test_invalidate_specific_scope(self, backend_with_tables): """Test invalidating specific scope.""" @@ -127,16 +131,27 @@ class TestCacheExpiration: """Tests for cache expiration behavior.""" def test_cache_ttl(self, backend_with_tables): - """Test that cache has TTL configured.""" + """The manager owns a cache, and its TTL is configured.""" intro = backend_with_tables.introspector - assert hasattr(intro, "_cache_ttl") - assert intro._cache_ttl > 0 + assert intro.cache.ttl > 0 + + def test_cache_is_a_component_not_borrowed_state(self, sqlite_backend): + """The cache is reachable and replaceable, and holds no state on the manager.""" + intro = sqlite_backend.introspector + # The manager no longer carries cache fields of its own. + assert not hasattr(intro, "_cache_ttl") + assert not hasattr(intro, "_cache_lock") + # ...and the component it owns can be swapped for another. + from rhosocial.activerecord.backend.introspection import InMemoryIntrospectionCache + replacement = InMemoryIntrospectionCache(ttl=1) + intro.cache = replacement + assert intro.cache is replacement def test_expired_cache_not_returned(self, sqlite_backend): """Test that expired cache entries are not returned.""" intro = sqlite_backend.introspector - # Set very short TTL - intro._cache_ttl = 0.01 # 10ms + # Set very short TTL on the cache component + intro.cache.ttl = 0.01 # 10ms # Get database info db_info1 = intro.get_database_info() @@ -154,10 +169,15 @@ def test_expired_cache_not_returned(self, sqlite_backend): class TestCacheThreadSafety: """Tests for cache thread safety.""" - def test_cache_lock_exists(self, backend_with_tables): - """Test that cache lock exists on introspector.""" + def test_cache_is_the_thing_that_is_thread_safe(self, backend_with_tables): + """The lock belongs to the cache, because the cache holds the state. + + The introspector used to own a ``_cache_lock`` beside its dict. Both + moved into the component, so this now asserts the component is the + one protecting itself. + """ intro = backend_with_tables.introspector - assert hasattr(intro, "_cache_lock") + assert intro.cache._lock is not None def test_concurrent_cache_access(self, backend_with_tables): """Test that cache lock prevents races when clearing and reading simultaneously. @@ -178,10 +198,9 @@ def test_concurrent_cache_access(self, backend_with_tables): def read_cache(): try: for _ in range(20): - # Access the internal cache directly (thread-safe via lock) - with intro._cache_lock: - cached = dict(intro._cache) - results.append(cached) + # Read through the cache's own entry point: the lock is + # inside the component and the caller never composes it. + results.append(intro.cache.get("table:main:users")) except Exception as e: errors.append(e) @@ -211,7 +230,7 @@ class TestCacheKeys: def test_cache_key_generation(self, sqlite_backend): """Test that cache keys are generated correctly.""" intro = sqlite_backend.introspector - key = intro._make_cache_key(IntrospectionScope.TABLE, "users", schema="main") + key = intro.cache.key(IntrospectionScope.TABLE, "users", schema="main") assert "table" in key assert "users" in key @@ -220,7 +239,7 @@ def test_cache_key_generation(self, sqlite_backend): def test_cache_key_with_extra(self, sqlite_backend): """Test cache key with extra component.""" intro = sqlite_backend.introspector - key = intro._make_cache_key(IntrospectionScope.TABLE, schema="main", extra="True") + key = intro.cache.key(IntrospectionScope.TABLE, schema="main", extra="True") assert "table" in key assert "True" in key @@ -228,10 +247,128 @@ def test_cache_key_with_extra(self, sqlite_backend): def test_cache_key_uniqueness(self, sqlite_backend): """Test that different parameters produce different keys.""" intro = sqlite_backend.introspector - key1 = intro._make_cache_key(IntrospectionScope.TABLE, "users") - key2 = intro._make_cache_key(IntrospectionScope.TABLE, "posts") - key3 = intro._make_cache_key(IntrospectionScope.COLUMN, "users") + key1 = intro.cache.key(IntrospectionScope.TABLE, "users") + key2 = intro.cache.key(IntrospectionScope.TABLE, "posts") + key3 = intro.cache.key(IntrospectionScope.COLUMN, "users") assert key1 != key2 assert key1 != key3 assert key2 != key3 + + +class CountingExecutor: + """An executor that counts the questions put to the database.""" + + def __init__(self, backend): + from rhosocial.activerecord.backend.introspection.executor import ( + SyncIntrospectorExecutor, + ) + self._inner = SyncIntrospectorExecutor(backend) + self.calls = 0 + + def execute(self, sql, params=()): + self.calls += 1 + return self._inner.execute(sql, params) + + +def _walk(backend): + """Ask everything the introspector knows, the way a caller would.""" + introspector = backend.introspector + answers = 0 + for table in introspector.list_tables(): + answers += len(introspector.list_columns(table.name)) + answers += len(introspector.list_indexes(table.name)) + answers += 1 if introspector.get_primary_key(table.name) else 0 + return answers + + +class TestCacheRemovesQuestions: + """What the cache does, stated as questions rather than milliseconds. + + Timings belong in the example script -- they move between machines and + between runs. What does not move is the count: a cached pass asks the + database nothing, and a pass with nothing cached asks it everything. + """ + + def _prepare(self, backend, ttl=None): + """Install a manager whose executor and cache are both observable. + + Built through the public seam -- ``set_introspector`` takes a + manager, and the manager takes its cache -- so the test does not + depend on where either is stored. + """ + cache = InMemoryIntrospectionCache(ttl=ttl) if ttl is not None else None + executor = CountingExecutor(backend) + backend.set_introspector( + SyncSQLiteIntrospector(backend, executor, cache=cache) + ) + return executor + + def test_uncached_pass_asks_the_database_every_time(self, backend_with_tables): + executor = self._prepare(backend_with_tables, ttl=0) + first = _walk(backend_with_tables) + second_executor_calls = executor.calls + _walk(backend_with_tables) + + assert executor.calls > 0 + assert executor.calls == second_executor_calls * 2, ( + "a cache with no TTL should ask the same number of questions " + "twice, not answer the second walk from memory" + ) + assert first >= 0 + + def test_cached_pass_asks_nothing_the_second_time(self, backend_with_tables): + executor = self._prepare(backend_with_tables) + _walk(backend_with_tables) + cold_calls = executor.calls + assert cold_calls > 0 + + executor.calls = 0 + _walk(backend_with_tables) + assert executor.calls == 0, ( + "the second walk of the same schema should be answered from the " + "cache; {} questions were put to the database".format(executor.calls) + ) + + def test_answers_survive_the_cache(self, backend_with_tables): + """A cached answer is the same value, not a fresh but equal one.""" + introspector = backend_with_tables.introspector + introspector.list_tables() + tables_first = introspector.list_tables() + tables_second = introspector.list_tables() + assert tables_second is tables_first + + def test_the_cache_can_be_replaced_with_another_implementation(self, sqlite_backend): + """The manager takes the cache it is given.""" + from rhosocial.activerecord.backend.introspection import IntrospectionCache + + class FixedCache: + """A cache that answers everything from one entry.""" + + ttl = 0 + + def key(self, scope, *args, schema=None, extra=None): + return "one" + + def get(self, key): + return "sentinel" + + def set(self, key, data): + pass + + def invalidate(self, scope=None, name=None): + pass + + def clear(self): + pass + + def __len__(self): + return 1 + + introspector = sqlite_backend.introspector + replacement = FixedCache() + assert isinstance(replacement, IntrospectionCache) + introspector.cache = replacement + assert introspector.cache is replacement + # Every question now reaches the one entry, so every answer is it. + assert introspector.list_tables() == "sentinel" diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_columns.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_columns.py index 15aa46c90..fcaf15771 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_columns.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_columns.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.introspection.types import ( - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, ) @@ -17,14 +17,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_all_columns_present(self, backend_with_tables): """Test that all columns are returned.""" @@ -60,7 +60,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" assert col.table_name == "users" @@ -155,17 +155,19 @@ def test_column_primary_key(self, backend_with_tables): assert name_col.is_primary_key is False def test_column_unique_constraint(self, backend_with_tables): - """Test unique constraint detection.""" + """Test unique constraint detection. + + SQLite records uniqueness on the index, not on the column, so the + column list reads ``PRAGMA index_list``/``index_xinfo`` too. ``users`` + declares ``email TEXT UNIQUE``, which is the index this asserts on. + """ columns = backend_with_tables.introspector.list_columns("users") - # email has UNIQUE constraint - # Note: SQLite doesn't expose UNIQUE constraint directly in PRAGMA table_info - # The uniqueness is implemented via an index, not a column constraint email_col = next(c for c in columns if c.name == "email") - # Check that unique index exists for email - indexes = backend_with_tables.introspector.list_indexes("users") - email_idx = next((i for i in indexes if "email" in [c.name for c in i.columns]), None) - assert email_idx is not None or email_col.is_unique is False + assert email_col.is_unique is True + + name_col = next(c for c in columns if c.name == "name") + assert name_col.is_unique is False def test_column_default_value_string(self, backend_with_tables): """Test default value detection for string.""" @@ -220,3 +222,53 @@ def test_column_table_name(self, backend_with_tables): for col in columns: assert col.table_name == "posts" + + +class TestGeneratedColumns: + """A generated column is a column, and ``table_info`` does not report it. + + SQLite answers ``PRAGMA table_info`` for the columns a table was written + with; a generated column is not one of them, and the pragma does not even + carry the ``hidden`` field that says so. ``PRAGMA table_xinfo`` does, with + ``hidden`` 2 for VIRTUAL and 3 for STORED. The parser had been written for + the xinfo rows all along, so the missing half was the question, not the + answer. + """ + + def test_generated_columns_are_listed(self, sqlite_backend): + sqlite_backend.executescript( + """ + CREATE TABLE generated_probe ( + a INTEGER, + b INTEGER GENERATED ALWAYS AS (a * 2) VIRTUAL, + c INTEGER GENERATED ALWAYS AS (a + 1) STORED + ); + """ + ) + + columns = sqlite_backend.introspector.list_columns("generated_probe") + + assert [col.name for col in columns] == ["a", "b", "c"] + assert [col.is_generated for col in columns] == [False, True, True] + + def test_the_expression_stays_unread(self, sqlite_backend): + """The flag is answered; the expression is not, and says so by being None. + + ``table_xinfo`` says *that* a column is generated, not what it is made + of -- that lives in the table's DDL. The field is None rather than a + guess, and this pins the distinction so it is not mistaken for a bug. + """ + sqlite_backend.executescript( + """ + CREATE TABLE generated_expr ( + a INTEGER, + b INTEGER GENERATED ALWAYS AS (a * 2) VIRTUAL + ); + """ + ) + + columns = sqlite_backend.introspector.list_columns("generated_expr") + b_col = next(col for col in columns if col.name == "b") + + assert b_col.is_generated is True + assert b_col.generated_expression is None diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_database.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_database.py index 05e71a7f8..8ca1f6c9a 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_database.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_database.py @@ -9,17 +9,17 @@ import sqlite3 -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, sqlite_backend): - """Test get_database_info returns valid DatabaseInfo.""" + """Test get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = sqlite_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name == "main" assert db_info.vendor == "SQLite" assert db_info.version == sqlite3.sqlite_version diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_expr_params.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_expr_params.py index 0e9dc5776..2a5e0eb2d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_expr_params.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_expr_params.py @@ -9,24 +9,24 @@ from rhosocial.activerecord.backend.expression.introspection import ( - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, ) class TestTableListExpressionExecution: - """Tests for TableListExpression execution with SQLite.""" + """Tests for TableListQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_tables): - """Test TableListExpression with constructor params can execute.""" - expr = TableListExpression( + """Test TableListQueryStatement with constructor params can execute.""" + expr = TableListQueryStatement( dialect=backend_with_tables.dialect, schema="main", include_views=True, include_system=False ) sql, params = expr.to_sql() @@ -36,59 +36,39 @@ def test_constructor_params_execution(self, backend_with_tables): assert result is not None def test_constructor_table_type_filter(self, backend_with_view): - """Test TableListExpression with table_type filter.""" - expr = TableListExpression(dialect=backend_with_view.dialect, schema="main", table_type="table") + """Test TableListQueryStatement with table_type filter.""" + expr = TableListQueryStatement(dialect=backend_with_view.dialect, schema="main", table_type="table") sql, params = expr.to_sql() result = backend_with_view.execute(sql, params) assert result is not None - def test_fluent_vs_constructor_equivalence(self, backend_with_tables): - """Test fluent API and constructor params produce same SQL.""" - expr1 = TableListExpression(dialect=backend_with_tables.dialect, schema="main", include_views=False) - expr2 = TableListExpression(dialect=backend_with_tables.dialect).schema("main").include_views(False) - - sql1, params1 = expr1.to_sql() - sql2, params2 = expr2.to_sql() - assert sql1 == sql2 - assert params1 == params2 - class TestTableInfoExpressionExecution: - """Tests for TableInfoExpression execution with SQLite.""" + """Tests for TableInfoQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_tables): - """Test TableInfoExpression with constructor params can execute.""" - expr = TableInfoExpression(dialect=backend_with_tables.dialect, table_name="users", schema="main") + """Test TableInfoQueryStatement with constructor params can execute.""" + expr = TableInfoQueryStatement(dialect=backend_with_tables.dialect, table_name="users", schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_tables.execute(sql, params) assert result is not None - def test_fluent_vs_constructor_equivalence(self, backend_with_tables): - """Test fluent API and constructor params produce same SQL.""" - expr1 = TableInfoExpression(dialect=backend_with_tables.dialect, table_name="users", schema="main") - expr2 = TableInfoExpression(dialect=backend_with_tables.dialect, table_name="users").schema("main") - - sql1, params1 = expr1.to_sql() - sql2, params2 = expr2.to_sql() - assert sql1 == sql2 - assert params1 == params2 - class TestColumnInfoExpressionExecution: - """Tests for ColumnInfoExpression execution with SQLite.""" + """Tests for ColumnInfoQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_tables): - """Test ColumnInfoExpression with constructor params can execute.""" - expr = ColumnInfoExpression(dialect=backend_with_tables.dialect, table_name="users", schema="main") + """Test ColumnInfoQueryStatement with constructor params can execute.""" + expr = ColumnInfoQueryStatement(dialect=backend_with_tables.dialect, table_name="users", schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_tables.execute(sql, params) assert result is not None def test_include_hidden_param(self, backend_with_tables): - """Test ColumnInfoExpression with include_hidden parameter.""" - expr = ColumnInfoExpression( + """Test ColumnInfoQueryStatement with include_hidden parameter.""" + expr = ColumnInfoQueryStatement( dialect=backend_with_tables.dialect, table_name="users", schema="main", include_hidden=True ) sql, params = expr.to_sql() @@ -97,11 +77,11 @@ def test_include_hidden_param(self, backend_with_tables): class TestIndexInfoExpressionExecution: - """Tests for IndexInfoExpression execution with SQLite.""" + """Tests for IndexInfoQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_tables): - """Test IndexInfoExpression with constructor params can execute.""" - expr = IndexInfoExpression(dialect=backend_with_tables.dialect, table_name="users", schema="main") + """Test IndexInfoQueryStatement with constructor params can execute.""" + expr = IndexInfoQueryStatement(dialect=backend_with_tables.dialect, table_name="users", schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_tables.execute(sql, params) @@ -109,11 +89,11 @@ def test_constructor_params_execution(self, backend_with_tables): class TestForeignKeyExpressionExecution: - """Tests for ForeignKeyExpression execution with SQLite.""" + """Tests for ForeignKeyQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_tables): - """Test ForeignKeyExpression with constructor params can execute.""" - expr = ForeignKeyExpression(dialect=backend_with_tables.dialect, table_name="posts", schema="main") + """Test ForeignKeyQueryStatement with constructor params can execute.""" + expr = ForeignKeyQueryStatement(dialect=backend_with_tables.dialect, table_name="posts", schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_tables.execute(sql, params) @@ -121,38 +101,38 @@ def test_constructor_params_execution(self, backend_with_tables): class TestViewListExpressionExecution: - """Tests for ViewListExpression execution with SQLite.""" + """Tests for ViewListQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_view): - """Test ViewListExpression with constructor params can execute.""" - expr = ViewListExpression(dialect=backend_with_view.dialect, schema="main", include_system=False) + """Test ViewListQueryStatement with constructor params can execute.""" + expr = ViewListQueryStatement(dialect=backend_with_view.dialect, schema="main", include_system=False) sql, params = expr.to_sql() assert sql is not None result = backend_with_view.execute(sql, params) assert result is not None def test_include_system_param(self, sqlite_backend): - """Test ViewListExpression with include_system parameter.""" - expr = ViewListExpression(dialect=sqlite_backend.dialect, schema="main", include_system=True) + """Test ViewListQueryStatement with include_system parameter.""" + expr = ViewListQueryStatement(dialect=sqlite_backend.dialect, schema="main", include_system=True) sql, params = expr.to_sql() result = sqlite_backend.execute(sql, params) assert result is not None class TestViewInfoExpressionExecution: - """Tests for ViewInfoExpression execution with SQLite.""" + """Tests for ViewInfoQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_view): - """Test ViewInfoExpression with constructor params can execute.""" - expr = ViewInfoExpression(dialect=backend_with_view.dialect, view_name="user_posts_summary", schema="main") + """Test ViewInfoQueryStatement with constructor params can execute.""" + expr = ViewInfoQueryStatement(dialect=backend_with_view.dialect, view_name="user_posts_summary", schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_view.execute(sql, params) assert result is not None def test_include_columns_param(self, backend_with_view): - """Test ViewInfoExpression with include_columns parameter.""" - expr = ViewInfoExpression( + """Test ViewInfoQueryStatement with include_columns parameter.""" + expr = ViewInfoQueryStatement( dialect=backend_with_view.dialect, view_name="user_posts_summary", schema="main", include_columns=True ) sql, params = expr.to_sql() @@ -161,30 +141,30 @@ def test_include_columns_param(self, backend_with_view): class TestTriggerListExpressionExecution: - """Tests for TriggerListExpression execution with SQLite.""" + """Tests for TriggerListQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_trigger): - """Test TriggerListExpression with constructor params can execute.""" - expr = TriggerListExpression(dialect=backend_with_trigger.dialect, schema="main") + """Test TriggerListQueryStatement with constructor params can execute.""" + expr = TriggerListQueryStatement(dialect=backend_with_trigger.dialect, schema="main") sql, params = expr.to_sql() assert sql is not None result = backend_with_trigger.execute(sql, params) assert result is not None def test_table_name_filter(self, backend_with_trigger): - """Test TriggerListExpression with table_name filter.""" - expr = TriggerListExpression(dialect=backend_with_trigger.dialect, schema="main", table_name="users") + """Test TriggerListQueryStatement with table_name filter.""" + expr = TriggerListQueryStatement(dialect=backend_with_trigger.dialect, schema="main", table_name="users") sql, params = expr.to_sql() result = backend_with_trigger.execute(sql, params) assert result is not None class TestTriggerInfoExpressionExecution: - """Tests for TriggerInfoExpression execution with SQLite.""" + """Tests for TriggerInfoQueryStatement execution with SQLite.""" def test_constructor_params_execution(self, backend_with_trigger): - """Test TriggerInfoExpression with constructor params can execute.""" - expr = TriggerInfoExpression( + """Test TriggerInfoQueryStatement with constructor params can execute.""" + expr = TriggerInfoQueryStatement( dialect=backend_with_trigger.dialect, trigger_name="update_user_timestamp", schema="main" ) sql, params = expr.to_sql() @@ -193,8 +173,8 @@ def test_constructor_params_execution(self, backend_with_trigger): assert result is not None def test_with_table_name(self, backend_with_trigger): - """Test TriggerInfoExpression with table_name parameter.""" - expr = TriggerInfoExpression( + """Test TriggerInfoQueryStatement with table_name parameter.""" + expr = TriggerInfoQueryStatement( dialect=backend_with_trigger.dialect, trigger_name="update_user_timestamp", schema="main", diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_foreign_keys.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_foreign_keys.py index 2ee6a2d15..f1d4895d6 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_foreign_keys.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_foreign_keys.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.introspection.types import ( - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, ) @@ -17,14 +17,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.""" @@ -85,7 +85,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): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_no_dropped_data.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_no_dropped_data.py new file mode 100644 index 000000000..3ebbebc9f --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_no_dropped_data.py @@ -0,0 +1,200 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_no_dropped_data.py +"""The introspector must not throw away what the database already told it. + +A review compared four CLIs over one database and found fields the library +computed and the CLI never showed. Most of that was the CLI's own projection +and is fixed elsewhere; these are the cases where the *library* had the value +and dropped it, or asked the engine a question and ignored the answer: + +* a view's definition -- the SELECT read ``sqlite_master`` without the ``sql`` + column, so every view reported ``definition=None``; +* a column's uniqueness -- SQLite records it on the index, so reporting + ``False`` for a column under a unique index asserted something untrue; +* a trigger's timing and events -- present in the DDL, left empty; +* the database's encoding, size and table count -- three pragmas away, left + ``None``. + +Each test below creates the shape it needs rather than relying on a shared +fixture, so a failure names the field that regressed. +""" + +import pytest + +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + + +@pytest.fixture +def probe_db(sqlite_backend): + """A database with the shapes these five answers depend on.""" + b = sqlite_backend + b.introspector.clear_cache() + b.executescript( + """ + DROP TABLE IF EXISTS arx_posts; + DROP TABLE IF EXISTS arx_users; + DROP TABLE IF EXISTS arx_metrics; + DROP VIEW IF EXISTS arx_active; + DROP TRIGGER IF EXISTS arx_ai; + + CREATE TABLE arx_users ( + id INTEGER PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name TEXT NOT NULL + ); + CREATE TABLE arx_posts ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL + REFERENCES arx_users(id) ON DELETE CASCADE, + title TEXT NOT NULL + ); + CREATE TABLE arx_metrics ( + id INTEGER PRIMARY KEY, + width INTEGER, height INTEGER, + area INTEGER GENERATED ALWAYS AS (width * height) STORED + ); + + CREATE INDEX arx_posts_title ON arx_posts(title) + WHERE title IS NOT NULL; + CREATE INDEX arx_posts_user_desc ON arx_posts(user_id DESC); + + CREATE VIEW arx_active AS + SELECT id, email FROM arx_users WHERE id > 0; + + CREATE TRIGGER arx_ai AFTER INSERT ON arx_posts + BEGIN + UPDATE arx_users SET name = name WHERE id = NEW.user_id; + END; + """ + ) + yield b + b.introspector.clear_cache() + b.executescript( + """ + DROP TRIGGER IF EXISTS arx_ai; + DROP VIEW IF EXISTS arx_active; + DROP TABLE IF EXISTS arx_metrics; + DROP TABLE IF EXISTS arx_posts; + DROP TABLE IF EXISTS arx_users; + """ + ) + b.introspector.clear_cache() + + +class TestViewDefinition: + def test_definition_comes_back(self, probe_db): + """``SELECT name`` alone cannot answer what a view is made of.""" + views = probe_db.introspector.list_views() + view = next(v for v in views if v.name == "arx_active") + + assert view.definition is not None + assert "SELECT" in view.definition.upper() + assert "arx_users" in view.definition + + +class TestColumnUniqueness: + def test_unique_column_is_reported_unique(self, probe_db): + """``email`` is under a UNIQUE index; the flag must say so. + + SQLite keeps uniqueness on the index, not on the column, so a parser + that only reads the column row reports ``False`` for every column -- + which is a wrong answer, not a missing one, because a caller building + a model from it would drop the constraint. + """ + columns = probe_db.introspector.list_columns("arx_users") + by_name = {c.name: c for c in columns} + + assert by_name["email"].is_unique is True + assert by_name["name"].is_unique is False + # A primary key is unique by definition, and the row says nothing + # about it. + assert by_name["id"].is_unique is True + + def test_partial_unique_index_does_not_make_a_column_unique(self, probe_db): + """A partial index constrains only some rows, so it is not a promise. + + ``arx_posts_title`` has a WHERE clause; asserting uniqueness from it + would claim more than the schema does. + """ + probe_db.introspector.clear_cache() + probe_db.executescript( + "CREATE UNIQUE INDEX arx_posts_partial ON arx_posts(title) " + "WHERE title <> ''" + ) + try: + columns = probe_db.introspector.list_columns("arx_posts") + by_name = {c.name: c for c in columns} + assert by_name["title"].is_unique is False + finally: + probe_db.executescript("DROP INDEX arx_posts_partial") + + +class TestTriggerTimingAndEvents: + def test_timing_and_events_come_from_the_ddl(self, probe_db): + """The header is only in the DDL; parsing it beats leaving blanks.""" + triggers = probe_db.introspector.list_triggers() + trigger = next(t for t in triggers if t.name == "arx_ai") + + assert trigger.timing == "AFTER" + assert trigger.events == ["INSERT"] + + def test_before_delete_is_read(self, probe_db): + """A second header, so the parser is not matching one spelling.""" + probe_db.introspector.clear_cache() + probe_db.executescript( + "CREATE TRIGGER arx_bd BEFORE DELETE ON arx_posts " + "BEGIN SELECT 1; END" + ) + try: + triggers = probe_db.introspector.list_triggers() + trigger = next(t for t in triggers if t.name == "arx_bd") + assert trigger.timing == "BEFORE" + assert trigger.events == ["DELETE"] + finally: + probe_db.executescript("DROP TRIGGER arx_bd") + + +class TestDatabaseFacts: + def test_encoding_size_and_table_count_are_answered(self, probe_db): + """Three pragmas and a count, none of which needs a second round trip. + + Leaving them ``None`` is the same as not asking, and each is one + ``PRAGMA`` away. + """ + info = probe_db.introspector.get_database_info() + + assert info.encoding is not None + assert info.size_bytes is not None and info.size_bytes > 0 + # Tables only: the view and the trigger this fixture creates are not + # tables, and counting them would inflate the answer. + assert info.table_count is not None and info.table_count >= 3 + + def test_size_is_pages_times_page_size(self, probe_db): + """The number is derived, so check the derivation, not just presence.""" + info = probe_db.introspector.get_database_info() + pages = probe_db.introspector.pragma.get("page_count") + page_size = probe_db.introspector.pragma.get("page_size") + + assert int(pages["page_count"]) * int(page_size["page_size"]) == info.size_bytes + + +class TestCacheStillSeparatesTheFamilies: + def test_columns_and_database_info_do_not_share_a_key(self, probe_db): + """The two new answers live under different scopes. + + The database facts and the column list are different questions, and one + cache key would make one answer the other's question. The cache's own + length is the observable: two distinct entries, not one reused. + """ + introspector = probe_db.introspector + introspector.clear_cache() + + introspector.get_database_info() + introspector.list_columns("arx_users") + + assert len(introspector._cache) == 2 + + db_key = introspector._cache.key(IntrospectionScope.DATABASE) + col_key = introspector._cache.key(IntrospectionScope.COLUMN, "arx_users") + assert db_key != col_key + assert introspector._cache.get(db_key) is not None + assert introspector._cache.get(col_key) is not None diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_tables.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_tables.py index 9ca5ca89e..7318da119 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_tables.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_tables.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.introspection.types import ( - TableInfo, + IntrospectionTableInfo, TableType, ) @@ -58,11 +58,11 @@ def test_list_tables_include_system_tables(self, sqlite_backend): assert len(system_tables) > 0 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() for table in tables: - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) assert isinstance(table.table_type, TableType) assert table.schema == "main" @@ -90,7 +90,7 @@ def test_get_table_info_existing_table(self, backend_with_tables): table_info = backend_with_tables.introspector.get_table_info("users") assert table_info is not None - assert isinstance(table_info, TableInfo) + assert isinstance(table_info, IntrospectionTableInfo) assert table_info.name == "users" assert table_info.schema == "main" assert table_info.table_type == TableType.BASE_TABLE diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_to_sql.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_to_sql.py index c4d2fe1a3..2dfddc3f7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_to_sql.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_to_sql.py @@ -10,16 +10,16 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.introspection import ( IntrospectionExpression, - DatabaseInfoExpression, - TableListExpression, - TableInfoExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, - TriggerInfoExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + TableInfoQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, + TriggerInfoQueryStatement, ) @@ -38,166 +38,253 @@ def test_base_to_sql_raises_not_implemented(self, sqlite_dialect): class TestDatabaseInfoToSql: - """Cover DatabaseInfoExpression.to_sql() delegation.""" + """Cover DatabaseInfoQueryStatement.to_sql() delegation.""" def test_database_info_to_sql(self, sqlite_dialect): - expr = DatabaseInfoExpression(sqlite_dialect) + expr = DatabaseInfoQueryStatement(sqlite_dialect) sql, params = expr.to_sql() assert isinstance(sql, str) assert len(sql) > 0 class TestTableListToSql: - """Cover TableListExpression.to_sql() delegation and setters.""" + """Cover TableListQueryStatement.to_sql() delegation.""" def test_table_list_to_sql(self, sqlite_dialect): - expr = TableListExpression(sqlite_dialect) + expr = TableListQueryStatement(sqlite_dialect) sql, params = expr.to_sql() assert isinstance(sql, str) - def test_include_system_setter(self, sqlite_dialect): - expr = TableListExpression(sqlite_dialect) - result = expr.include_system(True) - assert result is expr + def test_include_system(self, sqlite_dialect): + expr = TableListQueryStatement(sqlite_dialect, include_system=True) sql, params = expr.to_sql() assert isinstance(sql, str) + assert expr.get_params()["include_system"] is True - def test_table_type_setter(self, sqlite_dialect): - expr = TableListExpression(sqlite_dialect) - result = expr.table_type("TABLE") - assert result is expr + def test_table_type(self, sqlite_dialect): + expr = TableListQueryStatement(sqlite_dialect, table_type="TABLE") sql, params = expr.to_sql() assert isinstance(sql, str) + assert expr.get_params()["table_type"] == "TABLE" class TestTableInfoToSql: - """Cover TableInfoExpression.to_sql() delegation and setters.""" + """Cover TableInfoQueryStatement.to_sql() delegation.""" def test_table_info_to_sql(self, sqlite_dialect): - expr = TableInfoExpression(sqlite_dialect, table_name="users") + expr = TableInfoQueryStatement(sqlite_dialect, table_name="users") sql, params = expr.to_sql() assert isinstance(sql, str) assert len(sql) > 0 - def test_include_indexes_setter(self, sqlite_dialect): - """Cover introspection.py:346-347 — include_indexes setter.""" - expr = TableInfoExpression(sqlite_dialect, table_name="users") - result = expr.include_indexes(False) - assert result is expr + def test_include_indexes(self, sqlite_dialect): + expr = TableInfoQueryStatement(sqlite_dialect, table_name="users", include_indexes=False) + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["include_indexes"] is False - def test_include_foreign_keys_setter(self, sqlite_dialect): - """Cover introspection.py:365-366 — include_foreign_keys setter.""" - expr = TableInfoExpression(sqlite_dialect, table_name="users") - result = expr.include_foreign_keys(False) - assert result is expr + def test_include_foreign_keys(self, sqlite_dialect): + expr = TableInfoQueryStatement( + sqlite_dialect, table_name="users", include_foreign_keys=False + ) + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["include_foreign_keys"] is False class TestColumnInfoToSql: - """Cover ColumnInfoExpression.to_sql() delegation and setters.""" + """Cover ColumnInfoQueryStatement.to_sql() delegation.""" def test_column_info_to_sql(self, sqlite_dialect): - expr = ColumnInfoExpression(sqlite_dialect, table_name="users") + expr = ColumnInfoQueryStatement(sqlite_dialect, table_name="users") sql, params = expr.to_sql() assert isinstance(sql, str) - def test_table_name_setter(self, sqlite_dialect): - """Cover introspection.py:438-439 — table_name setter.""" - expr = ColumnInfoExpression(sqlite_dialect, table_name="old") - result = expr.table_name("new") - assert result is expr + def test_table_name(self, sqlite_dialect): + expr = ColumnInfoQueryStatement(sqlite_dialect, table_name="users") + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["table_name"] == "users" - def test_include_hidden_setter(self, sqlite_dialect): - """Cover introspection.py:457-458 — include_hidden setter.""" - expr = ColumnInfoExpression(sqlite_dialect, table_name="users") - result = expr.include_hidden(True) - assert result is expr + def test_include_hidden(self, sqlite_dialect): + expr = ColumnInfoQueryStatement(sqlite_dialect, table_name="users", include_hidden=True) + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["include_hidden"] is True class TestIndexInfoToSql: - """Cover IndexInfoExpression.to_sql() delegation and setters.""" + """Cover IndexInfoQueryStatement.to_sql() delegation.""" def test_index_info_to_sql(self, sqlite_dialect): - expr = IndexInfoExpression(sqlite_dialect, table_name="users") + expr = IndexInfoQueryStatement(sqlite_dialect, table_name="users") sql, params = expr.to_sql() assert isinstance(sql, str) - def test_table_name_setter(self, sqlite_dialect): - """Cover introspection.py:524-525 — table_name setter.""" - expr = IndexInfoExpression(sqlite_dialect, table_name="old") - result = expr.table_name("new") - assert result is expr + def test_table_name(self, sqlite_dialect): + expr = IndexInfoQueryStatement(sqlite_dialect, table_name="users") + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["table_name"] == "users" class TestForeignKeyToSql: - """Cover ForeignKeyExpression.to_sql() delegation and setters.""" + """Cover ForeignKeyQueryStatement.to_sql() delegation.""" def test_foreign_key_to_sql(self, sqlite_dialect): - expr = ForeignKeyExpression(sqlite_dialect, table_name="users") + expr = ForeignKeyQueryStatement(sqlite_dialect, table_name="users") sql, params = expr.to_sql() assert isinstance(sql, str) - def test_table_name_setter(self, sqlite_dialect): - """Cover introspection.py:589-590 — table_name setter.""" - expr = ForeignKeyExpression(sqlite_dialect, table_name="old") - result = expr.table_name("new") - assert result is expr + def test_table_name(self, sqlite_dialect): + expr = ForeignKeyQueryStatement(sqlite_dialect, table_name="users") + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["table_name"] == "users" class TestViewListToSql: - """Cover ViewListExpression.to_sql() delegation.""" + """Cover ViewListQueryStatement.to_sql() delegation.""" def test_view_list_to_sql(self, sqlite_dialect): - expr = ViewListExpression(sqlite_dialect) + expr = ViewListQueryStatement(sqlite_dialect) sql, params = expr.to_sql() assert isinstance(sql, str) class TestViewInfoToSql: - """Cover ViewInfoExpression.to_sql() delegation and setters.""" + """Cover ViewInfoQueryStatement.to_sql() delegation.""" def test_view_info_to_sql(self, sqlite_dialect): - expr = ViewInfoExpression(sqlite_dialect, view_name="user_summary") + expr = ViewInfoQueryStatement(sqlite_dialect, view_name="user_summary") sql, params = expr.to_sql() assert isinstance(sql, str) - def test_view_name_setter(self, sqlite_dialect): - """Cover introspection.py:728-729 — view_name setter.""" - expr = ViewInfoExpression(sqlite_dialect, view_name="old_view") - result = expr.view_name("new_view") - assert result is expr + def test_view_name(self, sqlite_dialect): + expr = ViewInfoQueryStatement(sqlite_dialect, view_name="user_summary") + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["view_name"] == "user_summary" - def test_include_columns_setter(self, sqlite_dialect): - """Cover introspection.py:747-748 — include_columns setter.""" - expr = ViewInfoExpression(sqlite_dialect, view_name="user_summary") - result = expr.include_columns(False) - assert result is expr + def test_include_columns(self, sqlite_dialect): + expr = ViewInfoQueryStatement( + sqlite_dialect, view_name="user_summary", include_columns=False + ) + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["include_columns"] is False class TestTriggerListToSql: - """Cover TriggerListExpression.to_sql() delegation.""" + """Cover TriggerListQueryStatement.to_sql() delegation.""" def test_trigger_list_to_sql(self, sqlite_dialect): - expr = TriggerListExpression(sqlite_dialect) + expr = TriggerListQueryStatement(sqlite_dialect) sql, params = expr.to_sql() assert isinstance(sql, str) class TestTriggerInfoToSql: - """Cover TriggerInfoExpression.to_sql() delegation and setters.""" + """Cover TriggerInfoQueryStatement.to_sql() delegation.""" def test_trigger_info_to_sql(self, sqlite_dialect): - expr = TriggerInfoExpression(sqlite_dialect, trigger_name="update_ts") + expr = TriggerInfoQueryStatement(sqlite_dialect, trigger_name="update_ts") + sql, params = expr.to_sql() + assert isinstance(sql, str) + + def test_trigger_name(self, sqlite_dialect): + expr = TriggerInfoQueryStatement(sqlite_dialect, trigger_name="update_ts") + sql, params = expr.to_sql() + assert isinstance(sql, str) + assert expr.get_params()["trigger_name"] == "update_ts" + + def test_trigger_table_name(self, sqlite_dialect): + expr = TriggerInfoQueryStatement( + sqlite_dialect, trigger_name="update_ts", table_name="users" + ) sql, params = expr.to_sql() assert isinstance(sql, str) + assert expr.get_params()["table_name"] == "users" + + +class TestSqliteTableCountToSql: + """Cover SQLiteTableCountStatement.to_sql() delegation.""" - def test_trigger_name_setter(self, sqlite_dialect): - """Cover introspection.py:888-889 — trigger_name setter.""" - expr = TriggerInfoExpression(sqlite_dialect, trigger_name="old") - result = expr.trigger_name("new") - assert result is expr + def test_counts_tables_of_the_default_database(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteTableCountStatement, + ) + + sql, params = SQLiteTableCountStatement(sqlite_dialect).to_sql() + + assert sql == ( + 'SELECT COUNT(*) AS "table_count" FROM main.sqlite_master ' + """WHERE "type" = 'table' AND "name" NOT LIKE 'sqlite_%'""" + ) + assert params == () + + def test_names_the_database_it_counts(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteTableCountStatement, + ) + + expr = SQLiteTableCountStatement(sqlite_dialect, "aux") + sql, params = expr.to_sql() + + assert "FROM aux.sqlite_master" in sql + assert expr.get_params() == {"schema": "aux"} + + +class TestSqliteTableDdlToSql: + """Cover SQLiteTableDdlStatement.to_sql() delegation.""" + + def test_reads_the_stored_statement(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteTableDdlStatement, + ) + + sql, params = SQLiteTableDdlStatement(sqlite_dialect, "users").to_sql() + + assert sql == ( + 'SELECT "sql" FROM main.sqlite_master ' + """WHERE "type" = 'table' AND "name" = ? AND "tbl_name" = ?""" + ) + assert params == ("users", "users") + + def test_names_the_database_it_reads(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteTableDdlStatement, + ) + + expr = SQLiteTableDdlStatement(sqlite_dialect, "users", "aux") + sql, params = expr.to_sql() + + assert "FROM aux.sqlite_master" in sql + assert expr.get_params() == {"table_name": "users", "schema": "aux"} + + +class TestSqliteIndexDdlToSql: + """Cover SQLiteIndexDdlStatement.to_sql() delegation.""" + + def test_reads_the_stored_statement(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteIndexDdlStatement, + ) + + sql, params = SQLiteIndexDdlStatement(sqlite_dialect, "idx_users_email").to_sql() + + assert sql == ( + 'SELECT "sql" FROM main.sqlite_master ' """WHERE "type" = 'index' AND "name" = ?""" + ) + assert params == ("idx_users_email",) + + def test_names_the_database_it_reads(self, sqlite_dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLiteIndexDdlStatement, + ) + + expr = SQLiteIndexDdlStatement(sqlite_dialect, "idx_users_email", "aux") + sql, params = expr.to_sql() - def test_for_table_setter(self, sqlite_dialect): - """Cover introspection.py:906-907 — for_table setter.""" - expr = TriggerInfoExpression(sqlite_dialect, trigger_name="update_ts") - result = expr.for_table("users") - assert result is expr + assert "FROM aux.sqlite_master" in sql + assert expr.get_params() == {"index_name": "idx_users_email", "schema": "aux"} diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_triggers.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_triggers.py index 0661b9321..b85fc00e4 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_triggers.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_triggers.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.introspection.types import ( - TriggerInfo, + IntrospectionTriggerInfo, ) @@ -30,11 +30,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_schema(self, backend_with_trigger): """Test that schema is correctly set.""" @@ -74,7 +74,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, sqlite_backend): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_views.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_views.py index 18da6fe0f..5345c63ca 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_views.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_introspection_views.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.introspection.types import ( - ViewInfo, + IntrospectionViewInfo, ) @@ -30,11 +30,11 @@ def test_list_views_with_view(self, backend_with_view): assert "user_posts_summary" in view_names 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() for view in views: - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) def test_list_views_schema(self, backend_with_view): """Test that schema is correctly set.""" @@ -68,7 +68,7 @@ def test_get_view_info_existing(self, backend_with_view): view_info = backend_with_view.introspector.get_view_info("user_posts_summary") assert view_info is not None - assert isinstance(view_info, ViewInfo) + assert isinstance(view_info, IntrospectionViewInfo) assert view_info.name == "user_posts_summary" assert view_info.schema == "main" diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_pragma_introspector.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_pragma_introspector.py index 118e7aa5a..50f5562fb 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_pragma_introspector.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_pragma_introspector.py @@ -322,59 +322,75 @@ def test_table_info_with_temp_schema(self, sqlite_backend): assert len(result) == 2 -class TestPragmaIntrospectorSQLGeneration: - """Tests for PRAGMA SQL generation helpers.""" +class TestPragmaSQLGeneration: + """The PRAGMA statements are where PRAGMA SQL is produced.""" - def test_pragma_sql_without_argument(self): - """Test _pragma_sql generates correct SQL without argument.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin + @pytest.fixture + def dialect(self): + from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - sql, params = PragmaMixin._pragma_sql("journal_mode") + return SQLiteDialect(version=(3, 35, 0)) + + def test_query_without_argument(self, dialect): + sql, params = dialect.pragma_query_statement("journal_mode").to_sql() assert sql == "PRAGMA journal_mode" assert params == () - def test_pragma_sql_with_argument(self): - """Test _pragma_sql generates correct SQL with argument.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin - - sql, params = PragmaMixin._pragma_sql("table_info", "'users'") + def test_query_with_argument(self, dialect): + sql, params = dialect.pragma_query_statement("table_info", "'users'").to_sql() assert sql == "PRAGMA table_info('users')" assert params == () - def test_pragma_sql_with_schema(self): - """Test _pragma_sql generates correct SQL with schema.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin - - sql, params = PragmaMixin._pragma_sql("journal_mode", schema="main") + def test_query_with_schema(self, dialect): + sql, params = dialect.pragma_query_statement("journal_mode", schema="main").to_sql() assert sql == "PRAGMA main.journal_mode" assert params == () - def test_pragma_sql_with_argument_and_schema(self): - """Test _pragma_sql generates correct SQL with argument and schema.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin - - sql, params = PragmaMixin._pragma_sql("table_info", "'users'", schema="temp") + def test_query_with_argument_and_schema(self, dialect): + sql, params = dialect.pragma_query_statement( + "table_info", "'users'", schema="temp" + ).to_sql() assert sql == "PRAGMA temp.table_info('users')" assert params == () - def test_set_pragma_sql(self): - """Test _set_pragma_sql generates correct SQL.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin - - sql, params = PragmaMixin._set_pragma_sql("cache_size", 5000) + def test_set(self, dialect): + sql, params = dialect.pragma_set_statement("cache_size", 5000).to_sql() assert sql == "PRAGMA cache_size = 5000" assert params == () - def test_set_pragma_sql_with_schema(self): - """Test _set_pragma_sql generates correct SQL with schema.""" - from rhosocial.activerecord.backend.impl.sqlite.introspection.pragma_introspector import PragmaMixin - - sql, params = PragmaMixin._set_pragma_sql("cache_size", 5000, schema="main") + def test_set_with_schema(self, dialect): + sql, params = dialect.pragma_set_statement("cache_size", 5000, schema="main").to_sql() assert sql == "PRAGMA main.cache_size = 5000" assert params == () + + def test_set_keeps_keyword_spelling(self, dialect): + sql, _ = dialect.pragma_set_statement("journal_mode", "WAL").to_sql() + + assert sql == "PRAGMA journal_mode = WAL" + + def test_set_quotes_a_value_that_is_not_a_token(self, dialect): + from rhosocial.activerecord.backend.impl.sqlite.expression.introspection import ( + SQLitePragmaSetStatement, + ) + + sql, _ = SQLitePragmaSetStatement(dialect, "journal_mode", "WAL; DROP TABLE users").to_sql() + + assert sql == "PRAGMA journal_mode = 'WAL; DROP TABLE users'" + + def test_query_rejects_an_unknown_pragma(self, dialect): + with pytest.raises(ValueError): + dialect.pragma_query_statement("no_such_pragma") + + def test_set_rejects_a_read_only_pragma(self, dialect): + with pytest.raises(ValueError): + dialect.pragma_set_statement("integrity_check", "x") + + def test_set_rejects_a_value_the_pragma_does_not_accept(self, dialect): + with pytest.raises(ValueError): + dialect.pragma_set_statement("journal_mode", "NOT_A_MODE") diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_features.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_features.py index 0a6de3a58..8d21a305b 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_features.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_features.py @@ -12,7 +12,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexExpression +from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexStatement from rhosocial.activerecord.backend.impl.sqlite.functions.json import ( json_array_insert, jsonb_array_insert, @@ -61,7 +61,7 @@ class TestSQLiteReindexExpression: def test_reindex_all(self): """Test REINDEX without parameters.""" dialect = SQLiteDialect(version=(3, 35, 0)) - expr = SQLiteReindexExpression(dialect) + expr = SQLiteReindexStatement(dialect) sql, params = expr.to_sql() assert sql == "REINDEX" assert params == () @@ -69,7 +69,7 @@ def test_reindex_all(self): def test_reindex_table(self): """Test REINDEX with table name.""" dialect = SQLiteDialect(version=(3, 35, 0)) - expr = SQLiteReindexExpression(dialect, table=Table(dialect, "users")) + expr = SQLiteReindexStatement(dialect, table=Table(dialect, "users")) sql, params = expr.to_sql() assert sql == 'REINDEX "users"' assert params == () @@ -77,7 +77,7 @@ def test_reindex_table(self): def test_reindex_index(self): """Test REINDEX with index name.""" dialect = SQLiteDialect(version=(3, 35, 0)) - expr = SQLiteReindexExpression(dialect, index=Index(dialect, "idx_users_email")) + expr = SQLiteReindexStatement(dialect, index=Index(dialect, "idx_users_email")) sql, params = expr.to_sql() assert sql == 'REINDEX "idx_users_email"' assert params == () @@ -85,7 +85,7 @@ def test_reindex_index(self): def test_reindex_expressions_supported(self): """Test REINDEX EXPRESSIONS on supported version.""" dialect = SQLiteDialect(version=(3, 53, 0)) - expr = SQLiteReindexExpression(dialect, expressions=True) + expr = SQLiteReindexStatement(dialect, expressions=True) sql, params = expr.to_sql() assert sql == "REINDEX EXPRESSIONS" assert params == () @@ -95,7 +95,7 @@ def test_reindex_expressions_unsupported_version(self): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError dialect = SQLiteDialect(version=(3, 52, 0)) - expr = SQLiteReindexExpression(dialect, expressions=True) + expr = SQLiteReindexStatement(dialect, expressions=True) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() assert "REINDEX EXPRESSIONS" in str(exc_info.value) @@ -104,21 +104,21 @@ def test_reindex_mutually_exclusive_index_and_expressions(self): """Test that index_name and expressions are mutually exclusive.""" dialect = SQLiteDialect(version=(3, 53, 0)) with pytest.raises(ValueError) as exc_info: - SQLiteReindexExpression(dialect, index=Index(dialect, "idx"), expressions=True) + SQLiteReindexStatement(dialect, index=Index(dialect, "idx"), expressions=True) assert "cannot be combined" in str(exc_info.value) def test_reindex_mutually_exclusive_table_and_expressions(self): """Test that table_name and expressions are mutually exclusive.""" dialect = SQLiteDialect(version=(3, 53, 0)) with pytest.raises(ValueError) as exc_info: - SQLiteReindexExpression(dialect, table=Table(dialect, "users"), expressions=True) + SQLiteReindexStatement(dialect, table=Table(dialect, "users"), expressions=True) assert "cannot be combined" in str(exc_info.value) def test_reindex_mutually_exclusive_index_and_table(self): """Test that index_name and table_name are mutually exclusive.""" dialect = SQLiteDialect(version=(3, 53, 0)) with pytest.raises(ValueError) as exc_info: - SQLiteReindexExpression(dialect, index=Index(dialect, "idx"), table=Table(dialect, "users")) + SQLiteReindexStatement(dialect, index=Index(dialect, "idx"), table=Table(dialect, "users")) assert "Cannot specify both" in str(exc_info.value) def test_supports_reindex_always_true(self): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_integration.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_integration.py index d3fce5c96..38f5a331d 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_integration.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_3_53_integration.py @@ -14,7 +14,7 @@ import pytest from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend -from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexExpression +from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteReindexStatement from rhosocial.activerecord.backend.impl.sqlite.functions import ( json_array_insert, jsonb_array_insert, @@ -110,7 +110,7 @@ def test_reindex_expressions_sql_generation(self, sqlite_backend: SQLiteBackend) sqlite_backend.introspect_and_adapt() dialect = sqlite_backend._dialect - reindex_expr = SQLiteReindexExpression(dialect, expressions=True) + reindex_expr = SQLiteReindexStatement(dialect, expressions=True) sql, params = reindex_expr.to_sql() assert sql == "REINDEX EXPRESSIONS" @@ -159,7 +159,7 @@ def test_reindex_expressions_executes(self, sqlite_backend: SQLiteBackend): """) # Execute REINDEX EXPRESSIONS - reindex_expr = SQLiteReindexExpression(dialect, expressions=True) + reindex_expr = SQLiteReindexStatement(dialect, expressions=True) sql, params = reindex_expr.to_sql() # Should execute without error @@ -180,7 +180,7 @@ def test_reindex_expressions_on_empty_db(self, sqlite_backend: SQLiteBackend): ); """) - reindex_expr = SQLiteReindexExpression(dialect, expressions=True) + reindex_expr = SQLiteReindexStatement(dialect, expressions=True) sql, params = reindex_expr.to_sql() # Should execute without error @@ -246,10 +246,10 @@ def test_json_array_insert_executes(self, sqlite_backend: SQLiteBackend): col_data = Column(dialect, "data", table="test_json") json_expr = json_array_insert(dialect, col_data, Literal(dialect, 0), position=0) - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[ json_expr.as_("modified"), @@ -287,10 +287,10 @@ def test_jsonb_array_insert_executes(self, sqlite_backend: SQLiteBackend): col_data = Column(dialect, "data", table="test_jsonb") json_expr = jsonb_array_insert(dialect, col_data, Literal(dialect, "new_value"), position=1) - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.sources import NamedRelationRef - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[ json_expr.as_("modified"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_object_naming.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_object_naming.py new file mode 100644 index 000000000..fab014960 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_object_naming.py @@ -0,0 +1,150 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_object_naming.py +"""SQLite's schema is an attached database, not the SQL-standard construct. + +The distinction these tests pin is the whole point of +:class:`SQLiteObjectNameMixin`: the DDL probes stay ``False`` -- SQLite has no +``CREATE SCHEMA`` -- while object *naming* accepts a schema level for SQLite's +own kind of table. One flag cannot answer both questions, and a single flag would +either make ``main.sqlite_master`` inexpressible or make SQLite look as though it +had schemas it could create. + +So the acceptance rule is keyed on the node's identity, and these tests pin each +side of it: + +* a **core** table carrying ``schema_name`` is still refused, because SQLite has + no schema of that kind; +* a :class:`SQLiteTable` carrying ``schema_name`` renders, because it stands for + an attached-database name; +* ``catalog_name`` is refused either way -- SQLite has no level above the + attached-database name. + +The pragma-path coverage of table listing is a different thing and is not what +this file is about; this is the path that builds a SELECT through the dialect's +object-name formatter. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.sqlite.expression import SQLiteTable +from rhosocial.activerecord.backend.expression.objects.relation import Table + + +@pytest.fixture +def dialect(): + d = SQLiteDialect() + d._version = (3, 46, 1) + return d + + +# --------------------------------------------------------------------------- +# The two questions are answered separately +# --------------------------------------------------------------------------- + + +def test_ddl_probes_stay_false(dialect): + """The naming fix must not have moved the DDL answers. + + This is the regression guard for the design: answering the naming question + by flipping a DDL flag would make SQLite look as though it supports schemas. + """ + assert dialect.supports_schema() is False + assert dialect.supports_schema_qualification() is False + assert dialect.supports_catalog() is False + assert dialect.supports_catalog_qualification() is False + + +def test_core_table_with_schema_is_still_refused(dialect): + """A standard schema is still refused: SQLite does not have one. + + The node that carries a ``schema_name`` decides what it means, so the core + node keeps the old refusal rather than silently starting to work. + """ + with pytest.raises(UnsupportedFeatureError): + Table(dialect, "users", schema_name="public").to_sql() + + +def test_core_table_with_catalog_is_refused(dialect): + with pytest.raises(UnsupportedFeatureError): + Table(dialect, "users", catalog_name="db").to_sql() + + +# --------------------------------------------------------------------------- +# SQLiteTable carries the attached-database name +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "schema,expected", + [ + ("main", "main.sqlite_master"), + ("other", "other.sqlite_master"), + ("attached_1", "attached_1.sqlite_master"), + ], +) +def test_sqlite_table_renders_its_schema_level(dialect, schema, expected): + """The attached-database name reaches the SQL, unquoted.""" + sql, params = SQLiteTable( + dialect, + "sqlite_master", + schema_name=schema, + schema_need_quote=False, + name_need_quote=False, + ).to_sql() + assert sql == expected + assert params == () + + +def test_sqlite_table_without_a_schema_is_still_usable(dialect): + """No schema means a plain table name, exactly as before.""" + sql, params = SQLiteTable(dialect, "sqlite_master").to_sql() + assert sql == '"sqlite_master"' + assert params == () + + +def test_sqlite_table_quoting_is_per_level(dialect): + """Each level's quoting flag is honoured independently.""" + sql, _ = SQLiteTable( + dialect, + "sqlite_master", + schema_name="main", + schema_need_quote=False, + name_need_quote=True, + ).to_sql() + assert sql == 'main."sqlite_master"' + + +def test_sqlite_table_refuses_a_catalog(dialect): + """SQLite has nothing above the attached-database name. + + The refusal names where the database belongs, so the caller is told how to + spell it rather than only that it was wrong. + """ + with pytest.raises(UnsupportedFeatureError) as exc: + SQLiteTable(dialect, "sqlite_master", catalog_name="db").to_sql() + assert "schema_name" in (exc.value.suggestion or "") + + +def test_sqlite_table_is_a_table(dialect): + """It must stay usable wherever a relation object is accepted.""" + from rhosocial.activerecord.backend.expression.objects.relation import ( + RelationObject, + ) + from rhosocial.activerecord.backend.expression.sources.relation import ( + NamedRelationRef, + ) + + assert issubclass(SQLiteTable, RelationObject) + sql, params = NamedRelationRef( + dialect, + SQLiteTable( + dialect, + "sqlite_master", + schema_name="main", + schema_need_quote=False, + name_need_quote=False, + ), + ).to_sql() + assert sql == "main.sqlite_master" + assert params == () diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_specific_params.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_specific_params.py index 2e2f5ef3d..f4dd624fa 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_specific_params.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_sqlite_specific_params.py @@ -43,16 +43,16 @@ def test_use_xinfo_pragma_false_explicit(self): assert params.get("use_xinfo_pragma") is False def test_include_hidden_triggers_xinfo_pragma(self): - """include_hidden=True on base ColumnInfoExpression should use table_xinfo.""" + """include_hidden=True on base ColumnInfoQueryStatement should use table_xinfo.""" dialect = SQLiteDialect(version=(3, 26, 0)) from rhosocial.activerecord.backend.expression.introspection import ( - ColumnInfoExpression, + ColumnInfoQueryStatement, ) - expr_standard = ColumnInfoExpression(dialect=dialect, table_name="users", schema="main") + expr_standard = ColumnInfoQueryStatement(dialect=dialect, table_name="users", schema="main") sql_standard, _ = expr_standard.to_sql() - expr_hidden = ColumnInfoExpression(dialect=dialect, table_name="users", schema="main", include_hidden=True) + expr_hidden = ColumnInfoQueryStatement(dialect=dialect, table_name="users", schema="main", include_hidden=True) sql_hidden, _ = expr_hidden.to_sql() assert "table_info" in sql_standard and "table_xinfo" not in sql_standard @@ -85,10 +85,10 @@ def test_table_list_pragma_vs_sqlite_master(self): """PRAGMA table_list and sqlite_master queries produce different SQL.""" dialect = SQLiteDialect(version=(3, 37, 0)) from rhosocial.activerecord.backend.expression.introspection import ( - TableListExpression, + TableListQueryStatement, ) - expr_query = TableListExpression(dialect=dialect, schema="main") + expr_query = TableListQueryStatement(dialect=dialect, schema="main") sql_query, _ = expr_query.to_sql() # SQLite 3.37.0+ uses PRAGMA table_list when schema='main' diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_status_introspector.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_status_introspector.py index de84b18d8..d1282ca77 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_status_introspector.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite4/test_status_introspector.py @@ -240,26 +240,26 @@ class TestSQLiteStatusIntrospectorMixin: """Tests for SQLiteStatusIntrospectorMixin helper methods.""" def test_parse_pragma_value_int(self, sqlite_backend): - """Test _parse_pragma_value handles integers.""" + """Test parse_pragma_value handles integers.""" status = sqlite_backend.introspector.status - result = status._parse_pragma_value(42) + result = status.parse_pragma_value(42) assert result == 42 assert isinstance(result, int) def test_parse_pragma_value_str_int(self, sqlite_backend): - """Test _parse_pragma_value parses string integers.""" + """Test parse_pragma_value parses string integers.""" status = sqlite_backend.introspector.status - result = status._parse_pragma_value("42") + result = status.parse_pragma_value("42") assert result == 42 assert isinstance(result, int) def test_parse_pragma_value_str_non_int(self, sqlite_backend): - """Test _parse_pragma_value preserves non-integer strings.""" + """Test parse_pragma_value preserves non-integer strings.""" status = sqlite_backend.introspector.status - result = status._parse_pragma_value("wal") + result = status.parse_pragma_value("wal") assert result == "wal" assert isinstance(result, str) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_backend_comprehensive.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_backend_comprehensive.py index 26832a2a7..a7f8cf1bb 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_backend_comprehensive.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_backend_comprehensive.py @@ -1163,14 +1163,14 @@ async def trans2(): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # noqa: E402 from rhosocial.activerecord.backend.expression import ( # noqa: E402 - InsertExpression, + InsertStatement, ValuesSource, Literal, Column, ComparisonPredicate, ReturningClause, - UpdateExpression, - DeleteExpression, + UpdateStatement, + DeleteStatement, ) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -1194,8 +1194,8 @@ def test_returning_not_supported_dialect(self): # 1. Instantiate a dialect for an older SQLite version dialect = SQLiteDialect(version=(3, 34, 0)) - # 2. Create an InsertExpression with a ReturningClause - insert_expr = InsertExpression( + # 2. Create an InsertStatement with a ReturningClause + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -1227,8 +1227,8 @@ async def test_returning_with_insert(self, backend): options=ExecutionOptions(stmt_type=StatementType.DDL), ) - # Create an InsertExpression with a ReturningClause - insert_expr = InsertExpression( + # Create an InsertStatement with a ReturningClause + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name", "email"], @@ -1273,7 +1273,7 @@ async def test_returning_with_update(self, backend): ) # Update with RETURNING - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, 'users'), assignments={"name": Literal(dialect, "Updated"), "email": Literal(dialect, "new@example.com")}, @@ -1315,7 +1315,7 @@ async def test_returning_with_delete(self, backend): ) # Delete with RETURNING - delete_expr = DeleteExpression( + delete_expr = DeleteStatement( dialect=dialect, tables=Table(dialect, 'users'), where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), @@ -1351,8 +1351,8 @@ async def test_returning_invalid_columns(self, backend): options=ExecutionOptions(stmt_type=StatementType.DDL), ) - # Create an InsertExpression with an invalid column in the ReturningClause - insert_expr = InsertExpression( + # Create an InsertStatement with an invalid column in the ReturningClause + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], @@ -1401,7 +1401,7 @@ async def test_returning_multiple_rows(self, backend): ) # Update multiple rows with RETURNING - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, 'users'), assignments={"active": Literal(dialect, 0)}, @@ -1437,7 +1437,7 @@ async def test_returning_with_transaction(self, backend): # Transaction with RETURNING async with backend.transaction(): - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'users'), columns=["name"], diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dml.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dml.py index 007ba530e..3a7056d55 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dml.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dml.py @@ -18,9 +18,9 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - InsertExpression, - UpdateExpression, - DeleteExpression, + InsertStatement, + UpdateStatement, + DeleteStatement, ValuesSource, ComparisonPredicate, ) @@ -90,14 +90,14 @@ async def async_backend_no_returning(tmp_path): def _make_insert_expr(dialect, name, email): - """Build a single-row InsertExpression for users table.""" + """Build a single-row InsertStatement for users table.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, name), Literal(dialect, email)]]) - return InsertExpression(dialect, into=Table(dialect, 'users'), columns=["name", "email"], source=source) + return InsertStatement(dialect, into=Table(dialect, 'users'), columns=["name", "email"], source=source) def _make_update_expr(dialect, pk_val, new_name): - """Build an UpdateExpression: SET name=? WHERE id=?""" - return UpdateExpression( + """Build an UpdateStatement: SET name=? WHERE id=?""" + return UpdateStatement( dialect, table=Table(dialect, 'users'), assignments={"name": Literal(dialect, new_name)}, @@ -106,8 +106,8 @@ def _make_update_expr(dialect, pk_val, new_name): def _make_delete_expr(dialect, pk_val): - """Build a DeleteExpression: DELETE FROM users WHERE id=?""" - return DeleteExpression( + """Build a DeleteStatement: DELETE FROM users WHERE id=?""" + return DeleteStatement( dialect, tables=Table(dialect, 'users'), where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, pk_val)), @@ -349,7 +349,7 @@ async def test_uuid_and_datetime(self, async_backend_with_typed): ], ) exprs = [ - InsertExpression( + InsertStatement( dialect, into=Table(dialect, 'typed_data'), columns=["uuid_col", "dt_col", "dec_col", "bool_col"], source=source ) ] diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dql.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dql.py index aef935948..d3fdb77f7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dql.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_dql.py @@ -6,7 +6,7 @@ - fetchmany pagination with various page_size / row_count combinations - Cursor lifecycle: normal consumption, early break, exception - Column adapter and column mapping processing - - DQLExpression type coverage: QueryExpression, WithQueryExpression, SetOperationExpression + - DQLExpression type coverage: QueryStatement, WithStatement, SetOperation """ import pytest @@ -15,15 +15,15 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - WildcardExpression, + AllColumnsWildcard, ComparisonPredicate, - SetOperationExpression, + SetOperation, ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef -from rhosocial.activerecord.backend.expression.statements import QueryExpression +from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.expression.query_sources import ( - WithQueryExpression, - CTEExpression, + WithStatement, + CTEClause, ) from rhosocial.activerecord.backend.expression.query_parts import ( WhereClause, @@ -80,9 +80,9 @@ async def async_backend_with_empty_table(async_sqlite_backend): def _select_all_expr(dialect, table="items", order_col="id"): """Build: SELECT * FROM items ORDER BY id""" - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, table)), order_by=OrderByClause(dialect, expressions=[(Column(dialect, order_col), "ASC")]), ) @@ -90,9 +90,9 @@ def _select_all_expr(dialect, table="items", order_col="id"): def _select_where_expr(dialect, category): """Build: SELECT * FROM items WHERE category = ? ORDER BY id""" - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "items")), where=WhereClause( dialect, @@ -287,37 +287,37 @@ class TestAsyncBatchDQLExpressionTypes: """Async expression type tests.""" async def test_query_expression(self, async_backend_with_items): - """Basic QueryExpression.""" + """Basic QueryStatement.""" dialect = async_backend_with_items.dialect expr = _select_all_expr(dialect) pages = await _async_collect_pages(async_backend_with_items.execute_batch_dql(expr, page_size=50)) assert sum(p.page_size for p in pages) == 100 async def test_with_query_expression_cte(self, async_backend_with_items): - """WithQueryExpression (CTE).""" + """WithStatement (CTE).""" dialect = async_backend_with_items.dialect # WITH expensive AS (SELECT * FROM items WHERE price > 50) # SELECT * FROM expensive ORDER BY id - cte_query = QueryExpression( + cte_query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "items")), where=WhereClause( dialect, condition=ComparisonPredicate(dialect, ">", Column(dialect, "price"), Literal(dialect, 50.0)), ), ) - cte = CTEExpression(dialect, name="expensive", query=cte_query) + cte = CTEClause(dialect, name="expensive", query=cte_query) - main_query = QueryExpression( + main_query = QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "expensive")), order_by=OrderByClause(dialect, expressions=[(Column(dialect, "id"), "ASC")]), ) - with_expr = WithQueryExpression(dialect, ctes=[cte], main_query=main_query) + with_expr = WithStatement(dialect, ctes=[cte], query=main_query) pages = await _async_collect_pages(async_backend_with_items.execute_batch_dql(with_expr, page_size=20)) @@ -328,10 +328,10 @@ async def test_with_query_expression_cte(self, async_backend_with_items): assert row["price"] > 50.0 async def test_set_operation_expression(self, async_backend_with_items): - """SetOperationExpression (UNION).""" + """SetOperation (UNION).""" dialect = async_backend_with_items.dialect - left = QueryExpression( + left = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "price")], from_=NamedRelationRef(dialect, Table(dialect, "items")), @@ -342,7 +342,7 @@ async def test_set_operation_expression(self, async_backend_with_items): ), ), ) - right = QueryExpression( + right = QueryStatement( dialect, select=[Column(dialect, "name"), Column(dialect, "price")], from_=NamedRelationRef(dialect, Table(dialect, "items")), @@ -351,7 +351,7 @@ async def test_set_operation_expression(self, async_backend_with_items): condition=ComparisonPredicate(dialect, "=", Column(dialect, "category"), Literal(dialect, "books")), ), ) - union_expr = SetOperationExpression(dialect, left=left, right=right, operation="UNION ALL") + union_expr = SetOperation(dialect, left=left, right=right, op="union_all") pages = await _async_collect_pages(async_backend_with_items.execute_batch_dql(union_expr, page_size=20)) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_transaction.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_transaction.py index c10d52e8d..b106bf976 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_transaction.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_batch_transaction.py @@ -15,7 +15,7 @@ from rhosocial.activerecord.backend.expression import ( Literal, - InsertExpression, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.options import ExecutionOptions @@ -54,7 +54,7 @@ def _make_insert(dialect, name, email, batch_tag="default"): source = ValuesSource( dialect, values_list=[[Literal(dialect, name), Literal(dialect, email), Literal(dialect, batch_tag)]] ) - return InsertExpression(dialect, into=Table(dialect, 'users'), columns=["name", "email", "batch_tag"], source=source) + return InsertStatement(dialect, into=Table(dialect, 'users'), columns=["name", "email", "batch_tag"], source=source) async def _async_count_rows(backend, where_clause="1=1"): diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection.py index be19a7e38..d9d81460e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection.py @@ -12,13 +12,13 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, IndexInfo, - ForeignKeyInfo, - ViewInfo, - TriggerInfo, + IntrospectionForeignKeyInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, ColumnNullable, ) @@ -28,10 +28,10 @@ class TestAsyncDatabaseIntrospection: @pytest.mark.asyncio async def test_async_get_database_info(self, async_sqlite_backend): - """Test async get_database_info returns valid DatabaseInfo.""" + """Test async get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = await async_sqlite_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name == "main" assert db_info.vendor == "SQLite" assert db_info.version == sqlite3.sqlite_version @@ -88,7 +88,7 @@ async def test_async_get_table_info(self, async_backend_with_tables): """Test async get_table_info returns complete table information.""" table_info = await async_backend_with_tables.introspector.get_table_info("users") - assert isinstance(table_info, TableInfo) + assert isinstance(table_info, IntrospectionTableInfo) assert table_info.name == "users" assert table_info.columns is not None assert len(table_info.columns) == 5 @@ -138,7 +138,7 @@ async def test_async_get_column_info(self, async_backend_with_tables): """Test async get_column_info returns specific column.""" column = await async_backend_with_tables.introspector.get_column_info("users", "email") - assert isinstance(column, ColumnInfo) + assert isinstance(column, IntrospectionColumnInfo) assert column.name == "email" assert column.nullable == ColumnNullable.NOT_NULL @@ -210,7 +210,7 @@ async def test_async_list_foreign_keys(self, async_backend_with_tables): assert len(fks) == 1 fk = fks[0] - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) assert fk.referenced_table == "users" assert "user_id" in fk.columns assert "id" in fk.referenced_columns @@ -251,7 +251,7 @@ async def test_async_get_view_info(self, async_backend_with_view): """Test async get_view_info returns view definition.""" view = await async_backend_with_view.introspector.get_view_info("user_posts_summary") - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) assert view.name == "user_posts_summary" assert view.definition is not None @@ -292,7 +292,7 @@ async def test_async_get_trigger_info(self, async_backend_with_trigger): """Test async get_trigger_info returns trigger definition.""" trigger = await async_backend_with_trigger.introspector.get_trigger_info("update_user_timestamp") - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) assert trigger.name == "update_user_timestamp" assert trigger.table_name == "users" assert trigger.definition is not None @@ -346,7 +346,7 @@ async def test_concurrent_introspection_calls(self, async_backend_with_tables): async_backend_with_tables.introspector.list_views(), ) - assert isinstance(results[0], DatabaseInfo) + assert isinstance(results[0], IntrospectionDatabaseInfo) assert isinstance(results[1], list) assert isinstance(results[2], list) assert isinstance(results[3], list) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection_cache.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection_cache.py new file mode 100644 index 000000000..3379236d2 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection_cache.py @@ -0,0 +1,167 @@ +# tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_introspection_cache.py +"""The async introspection manager caches, and it is the same manager. + +The sync cache has its own tests; this file exists because the async half +was once broken in a way no sync test could see. The async manager did not +inherit the manager base, so every method reached for a cache that was +never created and construction failed outright. Async introspection had +therefore stopped working while the sync suite stayed green. + +The assertions below are about the contract, not the fix: a manager owns a +cache, a second pass asks the database nothing, and invalidating makes a +schema change visible. If the async manager ever drifts away from the sync +one again, one of these fails. +""" + +from rhosocial.activerecord.backend.impl.sqlite.introspection import ( + AsyncSQLiteIntrospector, +) +from rhosocial.activerecord.backend.introspection import ( + InMemoryIntrospectionCache, + IntrospectionCache, +) +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, +) + + +class CountingExecutor: + """An executor that counts the questions put to the database.""" + + def __init__(self, backend): + self._inner = AsyncIntrospectorExecutor(backend) + self.calls = 0 + + async def execute(self, sql, params=()): + self.calls += 1 + return await self._inner.execute(sql, params) + + +def _install_counting(backend, cache=None): + """Replace the manager with one whose executor can be counted. + + Built through the public seam: ``set_introspector`` takes a manager, + and the manager takes its cache. No private field is touched, so this + stays valid however the manager is stored. + """ + executor = CountingExecutor(backend) + backend.set_introspector( + AsyncSQLiteIntrospector(backend, executor, cache=cache) + ) + return executor + + +async def _walk(backend): + """Ask everything the introspector knows, the way a caller would.""" + introspector = backend.introspector + answers = 0 + for table in await introspector.list_tables(): + answers += len(await introspector.list_columns(table.name)) + answers += len(await introspector.list_indexes(table.name)) + answers += 1 if await introspector.get_primary_key(table.name) else 0 + return answers + + +async def test_the_async_manager_owns_a_cache(async_sqlite_backend): + """A cache object, plus the operations the manager promises over it.""" + introspector = async_sqlite_backend.introspector + assert isinstance(introspector.cache, IntrospectionCache) + introspector.invalidate_cache() + introspector.clear_cache() + + +async def test_cached_pass_asks_nothing_the_second_time(async_backend_with_tables): + executor = _install_counting(async_backend_with_tables) + + await _walk(async_backend_with_tables) + cold_calls = executor.calls + assert cold_calls > 0 + + executor.calls = 0 + await _walk(async_backend_with_tables) + assert executor.calls == 0, ( + "the second walk of the same schema should be answered from the " + "cache; {} questions were put to the database".format(executor.calls) + ) + + +async def test_uncached_pass_asks_the_database_every_time(async_backend_with_tables): + executor = _install_counting( + async_backend_with_tables, cache=InMemoryIntrospectionCache(ttl=0) + ) + + await _walk(async_backend_with_tables) + first = executor.calls + await _walk(async_backend_with_tables) + assert executor.calls == first * 2, ( + "a cache with no TTL should ask the same number of questions twice, " + "not answer the second walk from memory" + ) + + +async def test_invalidate_makes_a_schema_change_visible(async_backend_with_tables): + """The reason invalidation exists: the answer outlived the schema.""" + backend = async_backend_with_tables + introspector = backend.introspector + + before = [t.name for t in await introspector.list_tables()] + assert "added_later" not in before + + await backend.executescript( + "CREATE TABLE added_later (id INTEGER PRIMARY KEY, note TEXT)" + ) + + stale = [t.name for t in await introspector.list_tables()] + assert "added_later" not in stale, ( + "the cache should still hold the pre-DDL answer until invalidated -- " + "if it does not, this test is no longer observing the cache" + ) + + introspector.invalidate_cache() + after = [t.name for t in await introspector.list_tables()] + assert "added_later" in after + + +async def test_the_cache_can_be_replaced_with_another_implementation( + async_sqlite_backend, +): + """A different cache, plugged in through the property.""" + + class FixedCache: + """A cache that answers everything from one entry.""" + + ttl = 0 + + def key(self, scope, *args, schema=None, extra=None): + return "one" + + def get(self, key): + return "sentinel" + + def set(self, key, data): + pass + + def invalidate(self, scope=None, name=None): + pass + + def clear(self): + pass + + def __len__(self): + return 1 + + introspector = async_sqlite_backend.introspector + replacement = FixedCache() + assert isinstance(replacement, IntrospectionCache) + + introspector.cache = replacement + assert introspector.cache is replacement + assert await introspector.list_tables() == "sentinel" + + +async def test_the_cache_can_be_given_at_construction(async_backend_with_tables): + """Injection at creation, so a backend can choose in one place.""" + cache = InMemoryIntrospectionCache(ttl=0) + executor = _install_counting(async_backend_with_tables, cache=cache) + assert async_backend_with_tables.introspector.cache is cache + assert executor.calls == 0 diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_status_introspector.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_status_introspector.py index 44a614cf2..93a4de9c3 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_status_introspector.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_async/test_async_status_introspector.py @@ -260,28 +260,28 @@ class TestAsyncSQLiteStatusIntrospectorMixin: @pytest.mark.asyncio async def test_parse_pragma_value_int(self, async_sqlite_memory_backend): - """Test _parse_pragma_value handles integers.""" + """Test parse_pragma_value handles integers.""" status = async_sqlite_memory_backend.introspector.status - result = status._parse_pragma_value(42) + result = status.parse_pragma_value(42) assert result == 42 assert isinstance(result, int) @pytest.mark.asyncio async def test_parse_pragma_value_str_int(self, async_sqlite_memory_backend): - """Test _parse_pragma_value parses string integers.""" + """Test parse_pragma_value parses string integers.""" status = async_sqlite_memory_backend.introspector.status - result = status._parse_pragma_value("42") + result = status.parse_pragma_value("42") assert result == 42 assert isinstance(result, int) @pytest.mark.asyncio async def test_parse_pragma_value_str_non_int(self, async_sqlite_memory_backend): - """Test _parse_pragma_value preserves non-integer strings.""" + """Test parse_pragma_value preserves non-integer strings.""" status = async_sqlite_memory_backend.introspector.status - result = status._parse_pragma_value("wal") + result = status.parse_pragma_value("wal") assert result == "wal" assert isinstance(result, str) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_dialect_integration.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_dialect_integration.py index a685592a0..5370e64e7 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_dialect_integration.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_dialect_integration.py @@ -13,13 +13,13 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.impl.sqlite.pragma import PragmaCategory from rhosocial.activerecord.backend.impl.sqlite.expression import ( - SQLiteFTS5MatchExpression, + SQLiteFTS5MatchPredicate, SQLiteFTS5CreateVirtualTable, SQLiteFTS5RankExpression, SQLiteFTS5HighlightExpression, SQLiteFTS5SnippetExpression, - CreateVirtualTableExpression, - DropVirtualTableExpression, + CreateVirtualTableStatement, + DropVirtualTableStatement, SQLiteRTreeCreateVirtualTable, SQLiteRTreeRangeQuery, SQLiteGeopolyCreateVirtualTable, @@ -133,7 +133,7 @@ class TestDialectVirtualTableFormatting: def test_format_create_virtual_table(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = CreateVirtualTableExpression( + expr = CreateVirtualTableStatement( d, module="rtree", table=Table(d, "places"), columns=["id", "minx", "maxx", "miny", "maxy"] ) @@ -143,27 +143,27 @@ def test_format_create_virtual_table(self): def test_format_drop_virtual_table(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = DropVirtualTableExpression(d, table=Table(d, "my_table")) + expr = DropVirtualTableStatement(d, table=Table(d, "my_table")) sql, params = expr.to_sql() assert sql == 'DROP TABLE "my_table"' assert params == () def test_format_drop_virtual_table_if_exists(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = DropVirtualTableExpression(d, table=Table(d, "my_table"), if_exists=True) + expr = DropVirtualTableStatement(d, table=Table(d, "my_table"), if_exists=True) sql, params = expr.to_sql() assert sql == 'DROP TABLE IF EXISTS "my_table"' def test_format_drop_virtual_table_quotes_malicious_name(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = DropVirtualTableExpression(d, table=Table(d, 't"; DROP TABLE users--')) + expr = DropVirtualTableStatement(d, table=Table(d, 't"; DROP TABLE users--')) sql, params = expr.to_sql() assert sql.count('"') % 2 == 0 # balanced quotes prevent injection assert sql.startswith('DROP TABLE "') def test_format_drop_virtual_table_with_special_chars(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = DropVirtualTableExpression(d, table=Table(d, "my table")) + expr = DropVirtualTableStatement(d, table=Table(d, "my table")) sql, _ = expr.to_sql() assert '"my table"' in sql @@ -179,7 +179,7 @@ def test_fts5_expressions_via_dialect(self): assert '"articles"' in sql sql, params = d.format_fts5_match_expression( - SQLiteFTS5MatchExpression(d, table="articles", query="python") + SQLiteFTS5MatchPredicate(d, table="articles", query="python") ) assert "MATCH" in sql assert params == ("python",) @@ -225,7 +225,7 @@ def test_geopoly_expressions_via_dialect(self): assert "CREATE VIRTUAL TABLE" in sql assert "geopoly" in sql - sql, params = d.format_geopoly_contains_query( + sql, params = d.format_geopoly_contains_expression( SQLiteGeopolyContainsExpression(d, table=Table(d, "zones"), longitude=1.0, latitude=2.0) ) assert "geopoly_contains_point" in sql @@ -238,7 +238,7 @@ def test_geopoly_expressions_via_dialect(self): def test_format_create_virtual_table_safe_unknown_module_allowed(self): d = SQLiteDialect(version=(3, 35, 0)) - expr = CreateVirtualTableExpression( + expr = CreateVirtualTableStatement( d, module="my_module", table=Table(d, "t"), columns=["c"] ) sql, _ = expr.to_sql() @@ -247,7 +247,7 @@ def test_format_create_virtual_table_safe_unknown_module_allowed(self): def test_format_create_virtual_table_rejects_malicious_module(self): d = SQLiteDialect(version=(3, 35, 0)) with pytest.raises(ValueError, match="Unsafe virtual table module"): - expr = CreateVirtualTableExpression( + expr = CreateVirtualTableStatement( d, module="malicious' DROP TABLE users; --", table=Table(d, "t"), columns=["c"] ) diff --git a/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_pragma_base.py b/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_pragma_base.py index 19fa1f82c..5f8cad7a5 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_pragma_base.py +++ b/tests/rhosocial/activerecord_test/feature/backend/sqlite_pragma_extension/test_pragma_base.py @@ -57,41 +57,6 @@ def test_pragma_info_creation(self): assert info.min_version == (3, 0, 0) assert info.value_type == str # noqa: E721 - def test_pragma_info_get_sql(self): - """Test PragmaInfo get_sql method.""" - info = PragmaInfo( - name="foreign_keys", - category=PragmaCategory.CONFIGURATION, - description="Test pragma", - ) - - sql = info.get_sql() - assert sql == "PRAGMA foreign_keys" - - def test_pragma_info_get_set_sql(self): - """Test PragmaInfo get_set_sql method.""" - info = PragmaInfo( - name="foreign_keys", - category=PragmaCategory.CONFIGURATION, - description="Test pragma", - read_only=False, - ) - - sql = info.get_set_sql(value=1) - assert sql == "PRAGMA foreign_keys = 1" - - def test_pragma_info_get_set_sql_with_argument(self): - """Test PragmaInfo get_set_sql with argument.""" - info = PragmaInfo( - name="table_info", - category=PragmaCategory.INFORMATION, - description="Test pragma", - requires_argument=True, - ) - - sql = info.get_set_sql(argument="users") - assert "table_info(users)" in sql - class TestPragmaQueries: """Test pragma query functions.""" diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_test/feature/backend/test_auto_increment_ddl.py index 56820df2b..0b1b6f619 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_auto_increment_ddl.py @@ -10,18 +10,22 @@ """ import pytest - -from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - ColumnDefinition, - ColumnConstraint, - ColumnConstraintType, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( BooleanType, IntegerType, TextType, TimestampType, VarCharType, ) +from rhosocial.activerecord.backend.expression import ( + CreateTableStatement, + ColumnDefinition, + ColumnConstraint, +) from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.expression.objects import Table + @pytest.fixture @@ -31,20 +35,22 @@ def dialect(): def _pk(): # _pk() 在测试函数内调用,那里 dialect 是 fixture。 - from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - return [ColumnConstraint(SQLiteDialect(), ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + dialect = SQLiteDialect() + return [ + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)) + ] def _build_table(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, 'test_tbl'), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "flag", BooleanType(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,20 +69,20 @@ 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()), ColumnDefinition(dialect, "flag", BooleanType(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) assert "NUMERIC" 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()), diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_cast_rendering.py b/tests/rhosocial/activerecord_test/feature/backend/test_cast_rendering.py index 47df69c6b..46fcbfe66 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_cast_rendering.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_cast_rendering.py @@ -12,6 +12,7 @@ reaches the statement again. """ +from rhosocial.activerecord.backend.expression.functions import cast import pytest from rhosocial.activerecord.backend.expression import Column, Literal @@ -26,7 +27,6 @@ JsonType, RealType, TextType, - UUIDType, VarCharType, ) @@ -136,7 +136,7 @@ def test_chaining_is_associative_in_structure(self, dialect): class TestCastIsNotLimitedToColumns: def test_a_literal(self, dialect): - sql, params = Literal(dialect, 42).cast(IntegerType(dialect)).to_sql() + sql, params = cast(dialect, Literal(dialect, 42), IntegerType(dialect)).to_sql() assert "INTEGER" in sql assert params == (42,) diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_cast_type_safety.py b/tests/rhosocial/activerecord_test/feature/backend/test_cast_type_safety.py index 901a19823..ef390cdfb 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_cast_type_safety.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_cast_type_safety.py @@ -23,24 +23,6 @@ is_valid_type_name, validate_type_name, ) -from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONTextExpression, - JSONDocumentExpression, -) -from rhosocial.activerecord.backend.expression.core import ( - NumericValueExpression, - IntegerValueExpression, - StringValueExpression, - BooleanValueExpression, - BinaryValueExpression, - UUIDValueExpression, - JSONValueExpression, - ArrayValueExpression, - TimestampValueExpression, - DateValueExpression, - TimeValueExpression, - IntervalValueExpression, -) #: Payloads that turn a cast target into a second statement, a subquery, or a #: comment. Each one would be harmless as a bound value, which is exactly the diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_clause_pair_guard.py b/tests/rhosocial/activerecord_test/feature/backend/test_clause_pair_guard.py index f2d4fdeac..08d6a4bab 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_clause_pair_guard.py @@ -24,7 +24,7 @@ "neither". One pair is mandatory in its grammar rather than optional: -``AlterConstraint.enforced`` / ``not_enforced`` -- the action *is* the +``AlterConstraintAction.enforced`` / ``not_enforced`` -- the action *is* the enforcement keyword, so "neither" is refused as well. That case carries ``neither_error`` and is asserted accordingly. """ @@ -32,61 +32,64 @@ import re import pytest - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression.objects import ( - Function, - MaterializedView, - Schema, - Sequence, - Table, - View, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnForeignKeyConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, ) -from rhosocial.activerecord.backend.expression.pivot import UnpivotExpression -from rhosocial.activerecord.backend.expression.query_sources import ( - CTEExpression, - SetOperationExpression, -) -from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterConstraint, -) -from rhosocial.activerecord.backend.expression.statements.ddl_function import ( - DropFunctionExpression, +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionStatement, + SetTransactionStatement, ) -from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - DropSchemaExpression, +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + DropViewStatement, + RefreshMaterializedViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( - AlterSequenceExpression, - CreateSequenceExpression, +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, - CreateTableAsExpression, - DropTableExpression, + CreateTableAsStatement, + DropTableStatement, IdentityClause, IndexDefinition, TableConstraint, - TableConstraintType, ) -from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, +from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceStatement, + CreateSequenceStatement, ) -from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - DropViewExpression, - RefreshMaterializedViewExpression, +from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression -from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, +from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionStatement, ) -from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect -from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterConstraintAction, +) +from rhosocial.activerecord.backend.expression.query_sources import ( + CTEClause, +) +from rhosocial.activerecord.backend.expression.pivot import Unpivot +from rhosocial.activerecord.backend.expression.objects import ( + Function, + MaterializedView, + Schema, + Sequence, + Table, + View, +) +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def _dummy(): @@ -98,17 +101,14 @@ 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): 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, ) @@ -120,9 +120,10 @@ 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, ) @@ -134,9 +135,10 @@ def _column_check(d, **kw): return ColumnConstraint( d, - ColumnConstraintType.CHECK, + branch=ColumnCheckConstraint( + d, ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + ), name="ck", - check_condition=ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")), **kw, ) @@ -144,9 +146,8 @@ def _column_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, ) @@ -213,24 +214,24 @@ def matches_b(self, sql): #: a substring of the negative one ("CYCLE" inside "NO CYCLE", and friends). PAIR_CASES = ( PairCase( - "CreateSequenceExpression.cycle", - lambda d, **kw: CreateSequenceExpression(d, Sequence(d, "s"), **kw), + "CreateSequenceStatement.cycle", + lambda d, **kw: CreateSequenceStatement(d, Sequence(d, "s"), **kw), "cycle", "no_cycle", r"(? Tuple[str, tuple]: return f" PARTITION BY {expr.method} ({', '.join(parts)})", tuple(params) -def test_escape_sql_string_basic(dialect): +def testescape_sql_string_basic(dialect): """Test basic single quote escaping.""" - result = dialect._escape_sql_string("test") + result = dialect.escape_sql_string("test") assert result == "test" - result = dialect._escape_sql_string("it's") + result = dialect.escape_sql_string("it's") assert result == "it''s" - result = dialect._escape_sql_string("'") + result = dialect.escape_sql_string("'") assert result == "''" -def test_escape_sql_string_multiple_quotes(dialect): +def testescape_sql_string_multiple_quotes(dialect): """Test escaping multiple single quotes.""" - result = dialect._escape_sql_string("it's John's car") + result = dialect.escape_sql_string("it's John's car") assert result == "it''s John''s car" - result = dialect._escape_sql_string("''") + result = dialect.escape_sql_string("''") assert result == "''''" -def test_validate_data_type_valid(dialect): - """Test valid data types pass validation.""" - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("INTEGER") - assert dialect._validate_data_type("NUMERIC(10, 2)") - assert dialect._validate_data_type("TIMESTAMP WITHOUT TIME ZONE") - assert dialect._validate_data_type("INT") +class TestEscapeReachesTheDialect: + """An inline literal must be escaped by the *dialect*, not by the base class. + + A backend may spell string escaping its own way: MySQL and MariaDB double + the backslash because it is an escape character under the default SQL + mode, and BigQuery uses the backslash escape for the quote itself. When a + formatter reached for ``SQLDialectBase.escape_sql_string`` instead of + ``self.escape_sql_string``, the base class's spelling won and the + backend's policy was silently dropped -- a MariaDB JSON document + containing a backslash came out under-escaped, and the stored value + differed from the one that was sent. + + These cases pin the rule: the escaping a caller sees is the escaping the + dialect declares. + """ + def _dialect_with_backslash_policy(self): + """A dialect whose escaping doubles the backslash, like MySQL's.""" -def test_validate_data_type_invalid(dialect): - """Test invalid data types are rejected.""" - assert not dialect._validate_data_type("VARCHAR(255); DROP TABLE users--") - assert not dialect._validate_data_type("/* comment */ INTEGER") - assert not dialect._validate_data_type("INTEGER; DELETE FROM users") - assert not dialect._validate_data_type("' OR '1'='1") + class BackslashDialect(TestDialect): + name = "backslash" + + @staticmethod + def escape_sql_string(value: str) -> str: + return value.replace("\\", "\\\\").replace("'", "''") + + def supports_alter_column_properties(self) -> bool: + return True + + return BackslashDialect() + + def test_the_public_helper_uses_the_override(self): + dialect = self._dialect_with_backslash_policy() + assert dialect.escape_sql_string("a\\b'c") == "a\\\\b''c" + + def test_an_inline_literal_uses_the_override(self): + """A literal rendered inline carries the dialect's escaping.""" + from rhosocial.activerecord.backend.expression.core import Literal + + dialect = self._dialect_with_backslash_policy() + sql, params = Literal(dialect, "a\\b'c", inline=True).to_sql() + + assert sql == "'a\\\\b''c'" + assert params == () + + def test_an_alter_column_default_uses_the_override(self): + """``ALTER COLUMN ... SET DEFAULT`` inlines its value, so it escapes. + + The value travels through the same path as the literal above: the + formatter must ask the dialect, not the base class. + """ + from rhosocial.activerecord.backend.expression.statements import ( + AlterColumnAction, + ) + + dialect = self._dialect_with_backslash_policy() + action = AlterColumnAction(dialect, column_name="c", operation="SET DEFAULT", + new_value="a\\b'c") + sql, params = dialect.format_alter_column_action(action) + assert "a\\\\b''c" in sql + assert params == () + + +def test_a_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_format_column_definition_data_type_validation(dialect): @@ -128,7 +210,6 @@ def test_column_definition_rejects_string_data_type(dialect): def test_format_cast_expression_valid(dialect): """A cast renders the type it was handed.""" from rhosocial.activerecord.backend.expression.core import CastExpression, Column - from rhosocial.activerecord.backend.expression.types import IntegerType expr = CastExpression(dialect, Column(dialect, "column"), IntegerType(dialect)) @@ -189,12 +270,9 @@ def test_trim_direction_rejects_invalid(dialect): def test_format_default_constraint_string_escaping(dialect): """Test DEFAULT constraint string is escaped.""" - constraint = ColumnConstraint(dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) + constraint = ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test's value")) - sql, params = dialect.format_default_constraint(constraint) + sql, params = dialect.format_column_default_constraint(constraint.branch) assert "test''s value" in sql assert "'; DROP" not in sql @@ -203,7 +281,7 @@ def test_format_storage_options_string_escaping(dialect): """Test storage options string values are escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression storage_opts = StorageOptionsExpression(dialect, {"key": "value's"}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) assert "value''s" in sql assert "'; DROP" not in sql @@ -218,7 +296,7 @@ def test_format_storage_options_key_identifier_quoting(dialect): from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression malicious_key = 'key"; DROP TABLE users--' storage_opts = StorageOptionsExpression(dialect, {malicious_key: "value"}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) # DROP TABLE may appear inside quoted identifier, that's safe. # Verify quotes are balanced (no breakout). @@ -232,7 +310,7 @@ def test_format_storage_options_mixed_safe_and_malicious_keys(dialect): safe_key = "fillfactor" malicious_key = 'evil"; DELETE FROM t--' storage_opts = StorageOptionsExpression(dialect, {safe_key: 70, malicious_key: "x"}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) assert "fillfactor" in sql or "FILLFACTOR" in sql # DELETE may appear inside quoted identifier — verify balanced quotes @@ -244,7 +322,7 @@ def test_format_storage_options_int_value_not_parameterized(dialect): """Numeric storage options are embedded as literals (design decision).""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression storage_opts = StorageOptionsExpression(dialect, {"fillfactor": 70}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) assert "70" in sql assert params == () @@ -253,29 +331,28 @@ def test_format_storage_options_none_value_uses_placeholder(dialect): """None or unknown type values use parameterized placeholder.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression storage_opts = StorageOptionsExpression(dialect, {"option": None}) - sql, params = dialect.format_storage_options(storage_opts) + sql, params = dialect.format_storage_options_expression(storage_opts) assert "NULL" in sql def test_format_storage_options_empty(dialect): """Empty storage options returns empty string.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {})) assert sql == "" assert params == () def test_format_partition_method_validation(dialect): """Test partition method is allowlist-validated and columns are quoted.""" - from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, PartitionClause, ) col_def = ColumnDefinition(dialect, name="id", data_type=IntegerType(dialect)) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'test_table'), columns=[col_def], @@ -293,7 +370,6 @@ def test_format_partition_method_validation(dialect): def test_format_partition_method_rejects_invalid_method_without_echoing_value(dialect): """Invalid partition method is rejected before SQL generation.""" - from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements.ddl_table import ( PartitionClause, ) @@ -323,7 +399,6 @@ def dialect(): def test_format_identifier_prevents_name_injection(): """format_identifier must double-quote embedded quotes.""" - from rhosocial.activerecord.backend.dialect.base import SQLDialectBase d = SQLDialectBase() # core base wraps in double-quotes and escapes embedded " assert d.format_identifier("normal") == '"normal"' diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_execution_confirmation_helper.py b/tests/rhosocial/activerecord_test/feature/backend/test_execution_confirmation_helper.py index 31beab63d..8ed3f868e 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_execution_confirmation_helper.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_execution_confirmation_helper.py @@ -29,11 +29,11 @@ from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, IdentityClause, - InsertExpression, - QueryExpression, + InsertStatement, + QueryStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.types import IntegerType @@ -76,12 +76,12 @@ class TestDdlConfirmation: def test_accepted_ddl_runs_teardown(self, backend): dialect = SQLiteDialect() table = Table(dialect, "probe_accepted") - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, table, [ColumnDefinition(dialect, "id", IntegerType(dialect))] ) outcome = confirm_expression_execution( - backend, expression, teardown=[DropTableExpression(dialect, table)] + backend, expression, teardown=[DropTableStatement(dialect, table)] ) assert outcome is ExecutionOutcome.ACCEPTED @@ -95,7 +95,7 @@ def test_accepted_ddl_runs_teardown(self, backend): def test_rejected_expression_raises_with_context(self, backend): """Rendered-but-refused SQL is the failure this helper exists to catch.""" dialect = SQLiteDialect() - expression = InsertExpression( + expression = InsertStatement( dialect, into=Table(dialect, "missing_table"), source=ValuesSource(dialect, [[Literal(dialect, 1)]]), @@ -135,7 +135,7 @@ class TestQueryShapedConfirmation: def test_select_over_prepared_schema_is_accepted(self, backend): dialect = SQLiteDialect() - expression = QueryExpression( + expression = QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "probe_query"), diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_numeric_value_operations.py b/tests/rhosocial/activerecord_test/feature/backend/test_numeric_value_operations.py index 785f1df26..50ba75379 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_numeric_value_operations.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_numeric_value_operations.py @@ -17,9 +17,7 @@ import pytest from rhosocial.activerecord.backend.expression import NumericColumn -from rhosocial.activerecord.backend.expression.core import ( - NumericValueExpression, -) +from rhosocial.activerecord.backend.expression.value_types import NumericValue @pytest.fixture @@ -89,22 +87,28 @@ def test_the_argument_is_a_parameter(self, price, method, args): class TestResultTypes: def test_abs_keeps_the_operand_type(self, price): """ABS of a fraction is a fraction; ABS of a whole number is a whole - number, which is why these are two classes rather than one.""" - assert isinstance(price.abs(), NumericValueExpression) + number, which is why these are two classes rather than one. + + Asserted on the node's own class rather than on a value class: the + operations are the same either way -- they always were -- so what + distinguishes the two is which node was chosen, and the node is where + that now lives. + """ + assert type(price.abs()).__name__ == "AbsoluteFunction" def test_sign_is_a_number(self, price): """Sign is -1, 0 or 1, but the backends do not answer integers for it: measured on PostgreSQL, an integer argument comes back double and a numeric one numeric, so the result carries the numeric surface.""" - assert isinstance(price.sign(), NumericValueExpression) + assert isinstance(price.sign(), NumericValue) def test_sqrt_is_a_number(self, price): - assert isinstance(price.sqrt(), NumericValueExpression) + assert isinstance(price.sqrt(), NumericValue) @pytest.mark.parametrize("method", ["ceil", "floor"]) def test_rounding_keeps_the_operand_type(self, price, method): """CEIL(numeric) is numeric in every backend; CEIL(integer) is not.""" - assert isinstance(getattr(price, method)(), NumericValueExpression) + assert isinstance(getattr(price, method)(), NumericValue) class TestChaining: diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_sequence_capability_conformance.py b/tests/rhosocial/activerecord_test/feature/backend/test_sequence_capability_conformance.py index 66e48aa58..3132c9943 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_sequence_capability_conformance.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_sequence_capability_conformance.py @@ -36,9 +36,9 @@ from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements import ( - AlterSequenceExpression, - CreateSequenceExpression, - DropSequenceExpression, + AlterSequenceStatement, + CreateSequenceStatement, + DropSequenceStatement, ) @@ -56,9 +56,9 @@ #: The three expression classes, so a rename of ``format_method`` fails here. SEQUENCE_EXPRESSIONS = ( - CreateSequenceExpression, - DropSequenceExpression, - AlterSequenceExpression, + CreateSequenceStatement, + DropSequenceStatement, + AlterSequenceStatement, ) @@ -177,7 +177,7 @@ def _render_alter_start(dialect_cls: type): behind the classification. """ dialect = dialect_cls() - expr = AlterSequenceExpression(dialect, Sequence(dialect, "s"), start=5) + expr = AlterSequenceStatement(dialect, Sequence(dialect, "s"), start=5) try: return True, expr.to_sql()[0] except UnsupportedFeatureError: @@ -198,14 +198,14 @@ class TestAlterSequenceStartSplit: def test_false_probe_refuses_alter_start(self): """A dialect whose ALTER probe is False must not render START.""" dialect = AlterStartRefusingDialect() - expr = AlterSequenceExpression(dialect, Sequence(dialect, "s"), start=5) + expr = AlterSequenceStatement(dialect, Sequence(dialect, "s"), start=5) with pytest.raises(UnsupportedFeatureError, match="ALTER SEQUENCE START"): expr.to_sql() def test_false_probe_leaves_create_start_alone(self): """The split is real: the same dialect still renders CREATE ... START.""" dialect = AlterStartRefusingDialect() - sql, _ = CreateSequenceExpression( + sql, _ = CreateSequenceStatement( dialect, Sequence(dialect, "s"), start=5 ).to_sql() assert "START WITH 5" in sql @@ -213,7 +213,7 @@ def test_false_probe_leaves_create_start_alone(self): def test_true_probe_renders_alter_start(self): """The two dialects that accept ALTER ... START declare True and render.""" dialect = DummyDialect() - sql, _ = AlterSequenceExpression( + sql, _ = AlterSequenceStatement( dialect, Sequence(dialect, "s"), start=5 ).to_sql() assert "START WITH 5" in sql @@ -221,7 +221,7 @@ def test_true_probe_renders_alter_start(self): def test_restart_is_not_gated_by_the_start_probe(self): """RESTART is ALTER-only and independent of the START probe.""" dialect = AlterStartRefusingDialect() - sql, _ = AlterSequenceExpression( + sql, _ = AlterSequenceStatement( dialect, Sequence(dialect, "s"), restart=5 ).to_sql() assert "RESTART WITH 5" in sql @@ -271,84 +271,84 @@ def _withdrawing_dialect(probe_name: str) -> type: SEQUENCE_OPTION_CASES = ( ( "supports_sequence_cycle", - CreateSequenceExpression, + CreateSequenceStatement, {"cycle": True}, "CYCLE", "SEQUENCE CYCLE", ), ( "supports_sequence_cycle", - CreateSequenceExpression, + CreateSequenceStatement, {"no_cycle": True}, "NO CYCLE", "SEQUENCE CYCLE", ), ( "supports_sequence_cache", - CreateSequenceExpression, + CreateSequenceStatement, {"cache": 10}, "CACHE 10", "SEQUENCE CACHE", ), ( "supports_sequence_cache", - CreateSequenceExpression, + CreateSequenceStatement, {"no_cache": True}, "NO CACHE", "SEQUENCE CACHE", ), ( "supports_sequence_order", - CreateSequenceExpression, + CreateSequenceStatement, {"order": True}, "ORDER", "SEQUENCE ORDER", ), ( "supports_sequence_order", - CreateSequenceExpression, + CreateSequenceStatement, {"no_order": True}, "NO ORDER", "SEQUENCE ORDER", ), ( "supports_sequence_cycle", - AlterSequenceExpression, + AlterSequenceStatement, {"cycle": True}, "CYCLE", "ALTER SEQUENCE CYCLE", ), ( "supports_sequence_cycle", - AlterSequenceExpression, + AlterSequenceStatement, {"no_cycle": True}, "NO CYCLE", "ALTER SEQUENCE CYCLE", ), ( "supports_sequence_cache", - AlterSequenceExpression, + AlterSequenceStatement, {"cache": 10}, "CACHE 10", "ALTER SEQUENCE CACHE", ), ( "supports_sequence_cache", - AlterSequenceExpression, + AlterSequenceStatement, {"no_cache": True}, "NO CACHE", "ALTER SEQUENCE CACHE", ), ( "supports_sequence_order", - AlterSequenceExpression, + AlterSequenceStatement, {"order": True}, "ORDER", "ALTER SEQUENCE ORDER", ), ( "supports_sequence_order", - AlterSequenceExpression, + AlterSequenceStatement, {"no_order": True}, "NO ORDER", "ALTER SEQUENCE ORDER", @@ -408,16 +408,16 @@ def test_gate_is_per_option(self, probe_name, node, kwargs, fragment, feature): def test_neither_spelling_renders_nothing(self): """The reference dialect renders no option when the pair is unset.""" dialect = DummyDialect() - sql = CreateSequenceExpression(dialect, Sequence(dialect, "s")).to_sql()[0] + sql = CreateSequenceStatement(dialect, Sequence(dialect, "s")).to_sql()[0] assert "CYCLE" not in sql and "ORDER" not in sql and "CACHE" not in sql - sql = AlterSequenceExpression(dialect, Sequence(dialect, "s")).to_sql()[0] + sql = AlterSequenceStatement(dialect, Sequence(dialect, "s")).to_sql()[0] assert sql == 'ALTER SEQUENCE "s"' def test_both_spellings_of_a_pair_are_refused(self): dialect = DummyDialect() with pytest.raises(ValueError, match="cycle and no_cycle are mutually exclusive"): - CreateSequenceExpression(dialect, Sequence(dialect, "s"), cycle=True, no_cycle=True) + CreateSequenceStatement(dialect, Sequence(dialect, "s"), cycle=True, no_cycle=True) with pytest.raises(ValueError, match="cache and no_cache are mutually exclusive"): - AlterSequenceExpression(dialect, Sequence(dialect, "s"), cache=10, no_cache=True) + AlterSequenceStatement(dialect, Sequence(dialect, "s"), cache=10, no_cache=True) with pytest.raises(ValueError, match="order and no_order are mutually exclusive"): - CreateSequenceExpression(dialect, Sequence(dialect, "s"), order=True, no_order=True) + CreateSequenceStatement(dialect, Sequence(dialect, "s"), order=True, no_order=True) diff --git a/tests/rhosocial/activerecord_test/feature/backend/test_to_sql_dispatch_contract.py b/tests/rhosocial/activerecord_test/feature/backend/test_to_sql_dispatch_contract.py index 577646c54..93717b3a8 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/test_to_sql_dispatch_contract.py +++ b/tests/rhosocial/activerecord_test/feature/backend/test_to_sql_dispatch_contract.py @@ -7,7 +7,7 @@ which the caller then treats as the rendered SQL. Three backends hit that independently before it was closed: -* ``CreateFunctionExpression(...).to_sql()`` answering ``None``, because +* ``CreateFunctionStatement(...).to_sql()`` answering ``None``, because postgres wired ``CreateRoutineSupport`` / ``DropRoutineSupport`` into a base list without implementing their formatters; * ``Domain.to_sql()`` answering ``None`` on snowflake, where diff --git a/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_acquire_cancellation_async.py b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_acquire_cancellation_async.py new file mode 100644 index 000000000..f4b06c2e3 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_acquire_cancellation_async.py @@ -0,0 +1,64 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/connection/test_pool_acquire_cancellation_async.py +"""A cancelled acquire must give its permit back. + +A readiness probe gives up after a couple of seconds. When the database is +restarting, the acquire it gives up on is mid-connect -- so the cancellation +lands after the semaphore permit was taken and before any backend was handed +out. ``CancelledError`` is a ``BaseException``, and the preparation code only +caught ``Exception``: the permit stayed taken, and the pool it belonged to +answered ``available=0, in_use=0`` while every later request waited out the +acquire timeout. + +These tests pin the property, not the implementation: cancel an acquire that is +stuck creating a connection, then acquire again. +""" +import asyncio + +import pytest + + +class TestAcquireCancellation: + async def test_a_cancelled_acquire_returns_its_permit(self, async_pool_and_model): + pool, _model = async_pool_and_model + # Force the create path: nothing idle, so acquire has to connect. + pool._available.clear() + pool._stats.current_available = 0 + + original_create = pool._create_backend + + async def stuck(*_args, **_kwargs): + await asyncio.sleep(3_600) + + pool._create_backend = stuck + task = asyncio.create_task(pool.acquire()) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + pool._create_backend = original_create + backend = await asyncio.wait_for(pool.acquire(), timeout=5) + await pool.release(backend) + + async def test_the_pool_survives_repeated_cancellations(self, async_pool_and_model): + """One probe per interval adds up: the pool must not erode.""" + pool, _model = async_pool_and_model + original_create = pool._create_backend + + async def stuck(*_args, **_kwargs): + await asyncio.sleep(3_600) + + for _ in range(pool.config.max_size + 2): + pool._available.clear() + pool._stats.current_available = 0 + pool._create_backend = stuck + task = asyncio.create_task(pool.acquire()) + await asyncio.sleep(0.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + pool._create_backend = original_create + backend = await asyncio.wait_for(pool.acquire(), timeout=5) + await pool.release(backend) + assert pool._stats.current_in_use == 0 diff --git a/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths.py b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths.py index ef5c2c633..3e27cde13 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths.py +++ b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths.py @@ -21,6 +21,8 @@ """ import pytest +from rhosocial.activerecord.backend.errors import TransactionError + from rhosocial.activerecord.connection.pool import ( get_current_transaction_backend, get_current_connection_backend, @@ -211,3 +213,38 @@ def test_no_savepoint_created_when_reentering_via_pool(self, sync_pool_and_model # No new savepoint should have been registered. assert mgr._active_savepoints == saved_sp assert mgr.transaction_level == 1 + + +class TestRollbackFailureKeepsTheOriginalError: + """Sync twin of TestAsyncRollbackFailureKeepsTheOriginalError.""" + + @staticmethod + def _break_rollback(backend) -> None: # noqa: ANN001 - a test double + def broken(*_args, **_kwargs): # noqa: ANN002, ANN003, ANN202 + raise TransactionError("No active transaction to rollback") + + backend.rollback_transaction = broken + + def test_path2_keeps_the_body_error(self, sync_pool_and_model) -> None: + pool, _model = sync_pool_and_model + with pool.connection(): + with pytest.raises(RuntimeError, match="the body failed"): + with pool.transaction() as tx: + self._break_rollback(tx) + raise RuntimeError("the body failed") + + def test_path3_keeps_the_body_error(self, sync_pool_and_model) -> None: + pool, _model = sync_pool_and_model + with pytest.raises(RuntimeError, match="the body failed"): + with pool.transaction() as backend: + self._break_rollback(backend) + raise RuntimeError("the body failed") + + def test_tokens_are_cleared_after_a_failed_rollback(self, sync_pool_and_model) -> None: + pool, _model = sync_pool_and_model + with pytest.raises(RuntimeError): + with pool.transaction() as backend: + self._break_rollback(backend) + raise RuntimeError("the body failed") + assert get_current_transaction_backend() is None + assert get_current_connection_backend() is None diff --git a/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths_async.py b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths_async.py index a9240fe89..824e6d430 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/connection/test_pool_transaction_paths_async.py @@ -8,6 +8,8 @@ """ import pytest +from rhosocial.activerecord.backend.errors import TransactionError + from rhosocial.activerecord.connection.pool import ( get_current_async_transaction_backend, get_current_async_connection_backend, @@ -147,3 +149,46 @@ async def test_no_savepoint_created_when_reentering_via_pool(self, async_pool_an async with pool.transaction(): assert mgr._active_savepoints == saved_sp assert mgr.transaction_level == 1 + + +class TestAsyncRollbackFailureKeepsTheOriginalError: + """A failing rollback must not replace the error that caused it. + + This is the shape of a real ledger entry: an ``IntegrityError`` inside a + transaction, then a rollback on a transaction that was already gone + (``TransactionError: No active transaction to rollback``), and the caller + was told about the rollback instead of the duplicate key. + """ + + @staticmethod + def _break_rollback(backend) -> None: # noqa: ANN001 - a test double + async def broken(*_args, **_kwargs): # noqa: ANN002, ANN003, ANN202 + raise TransactionError("No active transaction to rollback") + + backend.rollback_transaction = broken + + async def test_path2_keeps_the_body_error(self, async_pool_and_model) -> None: + pool, _model = async_pool_and_model + async with pool.connection(): + with pytest.raises(RuntimeError, match="the body failed"): + async with pool.transaction() as tx: + self._break_rollback(tx) + raise RuntimeError("the body failed") + + async def test_path3_keeps_the_body_error(self, async_pool_and_model) -> None: + pool, _model = async_pool_and_model + with pytest.raises(RuntimeError, match="the body failed"): + async with pool.transaction() as backend: + self._break_rollback(backend) + raise RuntimeError("the body failed") + + async def test_tokens_are_cleared_after_a_failed_rollback( + self, async_pool_and_model + ) -> None: + pool, _model = async_pool_and_model + with pytest.raises(RuntimeError): + async with pool.transaction() as backend: + self._break_rollback(backend) + raise RuntimeError("the body failed") + assert get_current_async_transaction_backend() is None + assert get_current_async_connection_backend() is None diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_comment_clauses.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_comment_clauses.py index de147318a..714349077 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_comment_clauses.py +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_comment_clauses.py @@ -6,7 +6,7 @@ * ``ColumnCommentClause`` / ``TableCommentClause`` — the CREATE TABLE inline comment *clauses* (part of the statement grammar; MySQL/MariaDB/ClickHouse family, BigQuery, Snowflake); -* ``CommentOnExpression`` — the standalone ``COMMENT ON`` *statement* that +* ``CommentOnStatement`` — the standalone ``COMMENT ON`` *statement* that annotates existing objects (PostgreSQL/Oracle/Firebird/Snowflake), rendered by the generic ``CommentOnMixin``. @@ -22,8 +22,8 @@ ColumnCommentClause, ColumnDefinition, CommentObjectType, - CommentOnExpression, - CreateTableExpression, + CommentOnStatement, + CreateTableStatement, CreateTableOptions, TableCommentClause, ) @@ -113,7 +113,7 @@ def test_create_table_options_rejects_bare_string(dummy): def test_generic_create_table_table_comment_spacing(dummy): """The table-comment clause must be separated from the column list.""" col = ColumnDefinition(dummy, "id", IntegerType(dummy)) - expr = CreateTableExpression( + expr = CreateTableStatement( dummy, Table(dummy, 't'), [col], @@ -139,7 +139,7 @@ def test_table_comment_clause_gated(sqlite): def test_comment_on_table(dummy): - sql, params = CommentOnExpression( + sql, params = CommentOnStatement( dummy, CommentObjectType.TABLE, Table(dummy, "t"), "meta" ).to_sql() assert sql == 'COMMENT ON TABLE "t" IS \'meta\'' @@ -147,14 +147,14 @@ def test_comment_on_table(dummy): def test_comment_on_column_dotted_target(dummy): - sql, _ = CommentOnExpression( + sql, _ = CommentOnStatement( dummy, CommentObjectType.COLUMN, Table(dummy, "t"), "col", column="c" ).to_sql() assert sql == 'COMMENT ON COLUMN "t"."c" IS \'col\'' def test_comment_on_schema_qualified(schema_dummy): - sql, _ = CommentOnExpression( + sql, _ = CommentOnStatement( schema_dummy, CommentObjectType.TABLE, Table(schema_dummy, "t", schema_name="s"), @@ -164,7 +164,7 @@ def test_comment_on_schema_qualified(schema_dummy): def test_comment_on_null_clears(dummy): - sql, params = CommentOnExpression( + sql, params = CommentOnStatement( dummy, CommentObjectType.TABLE, Table(dummy, "t"), None ).to_sql() assert sql == 'COMMENT ON TABLE "t" IS NULL' @@ -172,26 +172,26 @@ def test_comment_on_null_clears(dummy): def test_comment_on_escapes_apostrophe(dummy): - sql, _ = CommentOnExpression( + sql, _ = CommentOnStatement( dummy, CommentObjectType.TABLE, Table(dummy, "t"), "it's" ).to_sql() assert sql == "COMMENT ON TABLE \"t\" IS 'it''s'" def test_comment_on_accepts_string_object_type(dummy): - sql, _ = CommentOnExpression(dummy, "TABLE", Table(dummy, "t"), "x").to_sql() + sql, _ = CommentOnStatement(dummy, "TABLE", Table(dummy, "t"), "x").to_sql() assert sql == 'COMMENT ON TABLE "t" IS \'x\'' def test_comment_on_empty_object_name_rejected(dummy): with pytest.raises(ValueError, match="name"): - CommentOnExpression(dummy, CommentObjectType.TABLE, Table(dummy, " "), "x") + CommentOnStatement(dummy, CommentObjectType.TABLE, Table(dummy, " "), "x") def test_comment_on_gated_by_default(sqlite): """A dialect without COMMENT ON raises a clean UnsupportedFeatureError.""" assert sqlite.supports_comment_on() is False with pytest.raises(UnsupportedFeatureError, match="COMMENT ON"): - CommentOnExpression( + CommentOnStatement( sqlite, CommentObjectType.TABLE, Table(sqlite, "t"), "x" ).to_sql() diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_clause_expressions.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_clause_expressions.py index ce80fd4ee..af3f6c2ce 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_clause_expressions.py +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_clause_expressions.py @@ -7,27 +7,32 @@ """ import pytest - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + TableForeignKeyConstraint, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.backend.impl.dummy.backend import DummyDialect +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.statements import ( + ForeignKeyConstraint, + ReferentialAction, +) from rhosocial.activerecord.backend.expression import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, ColumnCommentClause, DefaultValueClause, IdentityClause, ReferencesClause, TableConstraint, - TableConstraintType, ) -from rhosocial.activerecord.backend.expression.statements import ( - ForeignKeyConstraint, - ReferentialAction, -) -from rhosocial.activerecord.backend.expression.types import IntegerType -from rhosocial.activerecord.backend.impl.dummy.backend import DummyDialect -from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect -from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + @pytest.fixture @@ -80,22 +85,23 @@ def test_default_value_clause_literal_is_inlined(dummy): def test_default_constraint_accepts_bare_value(dummy): - constraint = ColumnConstraint(dummy, ColumnConstraintType.DEFAULT, default_value=5) - sql, _ = dummy.format_default_constraint(constraint) + constraint = ColumnConstraint(dummy, branch=ColumnDefaultConstraint(dummy, 5)) + sql, _ = dummy.format_column_default_constraint(constraint.branch) assert sql == " DEFAULT 5" def test_default_constraint_accepts_clause(dummy): clause = DefaultValueClause(dummy, "x") - constraint = ColumnConstraint(dummy, ColumnConstraintType.DEFAULT, default_value=clause) - sql, _ = dummy.format_default_constraint(constraint) + constraint = ColumnConstraint(dummy, branch=ColumnDefaultConstraint(dummy, clause)) + sql, _ = dummy.format_column_default_constraint(constraint.branch) assert sql == " DEFAULT 'x'" def test_default_constraint_requires_value(dummy): - constraint = ColumnConstraint(dummy, ColumnConstraintType.DEFAULT, default_value=None) - with pytest.raises(ValueError, match="default value"): - dummy.format_default_constraint(constraint) + """A DEFAULT without a value states nothing, so the branch refuses it + where it is built rather than rendering a clause with a hole in it.""" + with pytest.raises(ValueError, match="needs a value"): + ColumnDefaultConstraint(dummy, None) def test_sqlite_boolean_default_is_inlined(sqlite): @@ -182,12 +188,11 @@ def test_identity_attribute_is_refused_where_unsupported(sqlite): def test_generic_column_definition_constraints_and_generated(sqlite): from rhosocial.activerecord.backend.dialect.mixins.ddl_column import DDLColumnMixin from rhosocial.activerecord.backend.expression.statements import ( - GeneratedColumnExpression, GeneratedColumnType, ) - constraint = ColumnConstraint(sqlite, ColumnConstraintType.NOT_NULL) - generated = GeneratedColumnExpression(sqlite, IntegerType(sqlite), GeneratedColumnType.STORED) + constraint = ColumnConstraint(sqlite, branch=ColumnNotNullConstraint(sqlite)) + generated = ColumnGeneratedConstraint(sqlite, IntegerType(sqlite), GeneratedColumnType.STORED) col = ColumnDefinition( sqlite, "col", IntegerType(sqlite), [constraint], generated_expression=generated ) @@ -309,20 +314,22 @@ def supports_foreign_key_on_delete(self): def test_column_fk_constraint_delegates_to_references(dummy): constraint = ColumnConstraint( dummy, - ColumnConstraintType.FOREIGN_KEY, - foreign_key_reference=(Table(dummy, "authors"), ["id"]), - on_delete=ReferentialAction.CASCADE, + branch=ColumnForeignKeyConstraint( + dummy, (Table(dummy, "authors"), ["id"]), on_delete=ReferentialAction.CASCADE + ), ) - sql, _ = dummy.format_column_fk_constraint(constraint) + sql, _ = dummy.format_column_fk_constraint(constraint.branch) assert sql == ' REFERENCES "authors"("id") ON DELETE CASCADE' -def test_column_fk_constraint_requires_reference(dummy): - constraint = ColumnConstraint(dummy, ColumnConstraintType.FOREIGN_KEY) - with pytest.raises(ValueError, match="foreign_key_reference"): - dummy.format_column_fk_constraint(constraint) - - + """Refused where the branch is built. + + The branch is what states this constraint, so a constraint + without its payload cannot be written at all. + """ + + with pytest.raises(ValueError, match="needs references"): + ColumnForeignKeyConstraint(dummy, None) def test_table_fk_constraint_uses_actions(dummy): fk = ForeignKeyConstraint( dummy, @@ -333,7 +340,7 @@ def test_table_fk_constraint_uses_actions(dummy): on_update=ReferentialAction.RESTRICT, match_type="FULL", ) - sql, _ = dummy.format_foreign_key_constraint(fk) + sql, _ = dummy.format_table_foreign_key_constraint(fk.branch) assert sql == ( 'FOREIGN KEY ("author_id") REFERENCES "authors"("id") ' "MATCH FULL ON DELETE CASCADE ON UPDATE RESTRICT" @@ -343,33 +350,33 @@ def test_table_fk_constraint_uses_actions(dummy): def test_table_fk_constraint_without_actions(dummy): tc = TableConstraint( dummy, - TableConstraintType.FOREIGN_KEY, - columns=["author_id"], - foreign_key_table=Table(dummy, "authors"), - foreign_key_columns=["id"], + branch=TableForeignKeyConstraint( + dummy, ["author_id"], (Table(dummy, "authors"), ["id"]) + ), ) - sql, _ = dummy.format_foreign_key_constraint(tc) + sql, _ = dummy.format_table_foreign_key_constraint(tc.branch) assert sql == 'FOREIGN KEY ("author_id") REFERENCES "authors"("id")' def test_table_fk_constraint_requires_columns(dummy): - tc = TableConstraint( - dummy, - TableConstraintType.FOREIGN_KEY, - foreign_key_table=Table(dummy, "authors"), - foreign_key_columns=["id"], - ) + tc = TableConstraint(dummy, branch=TableForeignKeyConstraint(dummy, [], (Table(dummy, "authors"), ["id"]))) with pytest.raises(ValueError, match="local column"): - dummy.format_foreign_key_constraint(tc) + dummy.format_table_foreign_key_constraint(tc.branch) def test_table_fk_constraint_requires_referenced_columns(dummy): - tc = TableConstraint(dummy, TableConstraintType.FOREIGN_KEY, columns=["a"], foreign_key_table=Table(dummy, "authors")) + """A FOREIGN KEY that names no referenced column points nowhere specific.""" + tc = TableConstraint( + dummy, + branch=TableForeignKeyConstraint(dummy, ["a"], (Table(dummy, "authors"), None)), + ) with pytest.raises(ValueError, match="foreign key column"): - dummy.format_foreign_key_constraint(tc) + dummy.format_table_foreign_key_constraint(tc.branch) def test_table_fk_constraint_requires_table(dummy): - tc = TableConstraint(dummy, TableConstraintType.FOREIGN_KEY, columns=["a"], foreign_key_columns=["id"]) - with pytest.raises(ValueError, match="foreign key table"): - dummy.format_foreign_key_constraint(tc) + """What a foreign key points at is what the branch states, so a branch + without it is refused where it is built -- there is nothing to render.""" + + with pytest.raises(ValueError, match="needs references"): + TableForeignKeyConstraint(dummy, ["a"], (None, None)) diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py index 108e18096..33f2b10ad 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py @@ -1,27 +1,11 @@ -# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py -"""Tests for the dialect-free ActiveRecord DDLSource declaration contract.""" - -from typing import Optional - -try: - from typing import Annotated -except ImportError: - from typing_extensions import Annotated - -import pytest - -from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( - PartitionClause, - PartitionStrategy, -) -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraintType, - IndexDefinition, - TableConstraint, - TableConstraintType, +from rhosocial.activerecord.backend.expression.statements import ( + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) -from rhosocial.activerecord.backend.expression.types import DataType +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import ( CharacterSetAttribute, CollationAttribute, @@ -38,7 +22,28 @@ UseIndex, UseSqlType, ) -from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.expression.types import DataType +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + IndexDefinition, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.expression.core import Column +from typing import Optional +# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py +"""Tests for the dialect-free ActiveRecord DDLSource declaration contract.""" + + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + class Plain(ActiveRecord): @@ -64,11 +69,7 @@ class CompositeWithDeclaredPrimaryKey(ActiveRecord): __table_name__ = "composite_with_declared_primary_key" __primary_key__ = ("order_id", "product_id") __table_constraints__ = [ - TableConstraint( - None, - TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"], - ), + TableConstraint(None, branch=TablePrimaryKeyConstraint(None, ["order_id", "product_id"])), ] order_id: int @@ -78,12 +79,7 @@ class CompositeWithDeclaredPrimaryKey(ActiveRecord): class WithTableConstraint(ActiveRecord): __table_name__ = "with_table_constraint" __table_constraints__ = [ - TableConstraint( - None, - TableConstraintType.UNIQUE, - name="uq_code_status", - columns=["code", "status"], - ), + TableConstraint(None, branch=TableUniqueConstraint(None, ["code", "status"]), name="uq_code_status"), ] id: int @@ -117,8 +113,8 @@ class WithNullable(ActiveRecord): __table_name__ = "with_nullable" id: int - nickname: Annotated[Optional[str], UseConstraint(ColumnConstraintType.NULL)] = None - note: Annotated[Optional[str], UseConstraint(ColumnConstraintType.NOT_NULL)] = None + nickname: Annotated[Optional[str], UseConstraint(ColumnNullableConstraint)] = None + note: Annotated[Optional[str], UseConstraint(ColumnNotNullConstraint)] = None required: str = "x" @@ -182,7 +178,7 @@ def test_marker_type_contracts(): with pytest.raises(TypeError): UseColumnAttributes() with pytest.raises(ValueError): - UseConstraint(ColumnConstraintType.PRIMARY_KEY) + UseConstraint(ColumnPrimaryKeyConstraint) with pytest.raises(ValueError): IdentityAttribute(generation="SOMETIMES") with pytest.raises(ValueError): @@ -201,46 +197,46 @@ def test_column_options_base_contract(): def test_single_primary_key_is_added_to_column_declaration(): - constraint_types = { - constraint.constraint_type + declared = { + type(constraint.branch) for constraint in Plain.column_constraints("id") } - assert ColumnConstraintType.PRIMARY_KEY in constraint_types - assert ColumnConstraintType.NOT_NULL in constraint_types + assert ColumnPrimaryKeyConstraint in declared + assert ColumnNotNullConstraint in declared def test_composite_primary_key_is_added_as_table_constraint(): - order_id_types = { - constraint.constraint_type + order_id_branches = { + type(constraint.branch) for constraint in Composite.column_constraints("order_id") } - assert ColumnConstraintType.PRIMARY_KEY not in order_id_types - assert ColumnConstraintType.NOT_NULL in order_id_types + assert ColumnPrimaryKeyConstraint not in order_id_branches + assert ColumnNotNullConstraint in order_id_branches primary_keys = [ constraint for constraint in Composite.table_constraints() - if constraint.constraint_type == TableConstraintType.PRIMARY_KEY + if isinstance(constraint.branch, TablePrimaryKeyConstraint) ] assert len(primary_keys) == 1 - assert primary_keys[0].columns == ["order_id", "product_id"] + assert primary_keys[0].branch.columns == ["order_id", "product_id"] def test_declared_composite_primary_key_is_not_duplicated(): primary_keys = [ constraint for constraint in CompositeWithDeclaredPrimaryKey.table_constraints() - if constraint.constraint_type == TableConstraintType.PRIMARY_KEY + if isinstance(constraint.branch, TablePrimaryKeyConstraint) ] assert len(primary_keys) == 1 - assert primary_keys[0].columns == ["order_id", "product_id"] + assert primary_keys[0].branch.columns == ["order_id", "product_id"] def test_table_constraints_are_returned_unchanged(): constraints = WithTableConstraint.table_constraints() assert len(constraints) == 1 assert constraints[0].name == "uq_code_status" - assert constraints[0].columns == ["code", "status"] + assert constraints[0].branch.columns == ["code", "status"] def test_field_and_table_indexes_remain_separate_declarations(): @@ -263,21 +259,21 @@ def test_column_attributes_are_returned_without_dialect_selection(): def test_nullable_declarations_follow_primary_key_rules(): - nickname_types = { - constraint.constraint_type + nickname_branches = { + type(constraint.branch) for constraint in WithNullable.column_constraints("nickname") } - note_types = { - constraint.constraint_type + note_branches = { + type(constraint.branch) for constraint in WithNullable.column_constraints("note") } - required_types = { - constraint.constraint_type + required_branches = { + type(constraint.branch) for constraint in WithNullable.column_constraints("required") } - assert nickname_types == {ColumnConstraintType.NULL} - assert note_types == {ColumnConstraintType.NOT_NULL} - assert required_types == {ColumnConstraintType.NOT_NULL} + assert nickname_branches == {ColumnNullableConstraint} + assert note_branches == {ColumnNotNullConstraint} + assert required_branches == {ColumnNotNullConstraint} def test_comment_and_generated_declarations_are_exposed(): diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_uuid_expressions.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_uuid_expressions.py index 1cd65ced2..c4962a0cb 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_uuid_expressions.py +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_uuid_expressions.py @@ -18,7 +18,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.protocols import UUIDSupport from rhosocial.activerecord.backend.expression import ( - UUIDCastExpression, + UUIDCastFunction, UUIDConstantExpression, UUIDGenerationExpression, Literal, @@ -67,7 +67,7 @@ def test_sqlite_reports_no_uuid_operations(probe): [ lambda d: UUIDGenerationExpression(d), lambda d: UUIDConstantExpression(d, "nil"), - lambda d: UUIDCastExpression(d, Literal(d, "x")), + lambda d: UUIDCastFunction(d, Literal(d, "x")), ], ids=["generation", "constant", "cast"], ) @@ -123,7 +123,7 @@ def test_constant_rejects_an_unknown_kind(): def test_cast_substitutes_the_operand_and_keeps_its_parameters(): dialect = _dialect(_FULL) - expr = UUIDCastExpression(dialect, Literal(dialect, "not-a-uuid")) + expr = UUIDCastFunction(dialect, Literal(dialect, "not-a-uuid")) assert expr.to_sql() == ("CAST(? AS UUID)", ("not-a-uuid",)) @@ -134,7 +134,7 @@ def test_constant_and_cast_stay_unsupported_when_only_generation_is_declared(): with pytest.raises(UnsupportedFeatureError, match="UUID constants"): UUIDConstantExpression(dialect, "nil").to_sql() with pytest.raises(UnsupportedFeatureError, match="UUID cast"): - UUIDCastExpression(dialect, Literal(dialect, "x")).to_sql() + UUIDCastFunction(dialect, Literal(dialect, "x")).to_sql() # --------------------------------------------------------------------------- @@ -147,7 +147,7 @@ def test_constant_and_cast_stay_unsupported_when_only_generation_is_declared(): [ lambda d: UUIDGenerationExpression(d), lambda d: UUIDConstantExpression(d, "nil"), - lambda d: UUIDCastExpression(d, Literal(d, "x")), + lambda d: UUIDCastFunction(d, Literal(d, "x")), ], ids=["generation", "constant", "cast"], ) @@ -161,9 +161,9 @@ def test_uuid_expressions_support_comparison_and_aliasing(factory): def test_format_methods_are_declared_for_dispatch(): - assert UUIDGenerationExpression(_dialect()).format_method == "format_uuid_generation" - assert UUIDConstantExpression(_dialect(), "nil").format_method == "format_uuid_constant" + assert UUIDGenerationExpression(_dialect()).format_method == "format_uuid_generation_expression" + assert UUIDConstantExpression(_dialect(), "nil").format_method == "format_uuid_constant_expression" assert ( - UUIDCastExpression(_dialect(), Literal(_dialect(), "x")).format_method + UUIDCastFunction(_dialect(), Literal(_dialect(), "x")).format_method == "format_uuid_cast" ) diff --git a/tests/rhosocial/activerecord_test/feature/basic/fields/test_basic_mapped_models.py b/tests/rhosocial/activerecord_test/feature/basic/fields/test_basic_mapped_models.py index 47ef382c9..abfba2a3b 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fields/test_basic_mapped_models.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fields/test_basic_mapped_models.py @@ -13,7 +13,7 @@ """ -# Wildcard import all test cases from the testsuite's test file. +# Wildcard-family import all test cases from the testsuite's test file. from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py index 2a1829c7c..279822b64 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_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_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py index 0fe0a3823..17e011263 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_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_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py index 514439790..8486a795f 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_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_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py index a283e5780..6486914f8 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_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: diff --git a/tests/rhosocial/activerecord_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_test/feature/basic/transaction/test_async_owner_task.py index a27119c90..26ceee3ce 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/transaction/test_async_owner_task.py +++ b/tests/rhosocial/activerecord_test/feature/basic/transaction/test_async_owner_task.py @@ -1,32 +1,36 @@ # 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()`. +"""Async contract for `AsyncTransactionManager._owner_task` safety check. + +`AsyncTransactionManager` tracks which asyncio task owns an active outermost +transaction. Nesting is a savepoint, and only the owner may nest: when another +task enters the same manager while a transaction is active, it MUST raise +`TransactionError` naming the cause. + +Earlier this was a `UserWarning` emitted by `transaction()`, and nothing more. +It was not enough: + +* the second task could still proceed, so both drove the same level counter -- + two "Beginning transaction (level 0)" in a row, then a commit that found + level 0 and raised "No active transaction to commit", a message that names + neither the cause nor the caller; +* `pool.transaction()` calls `begin_transaction()` directly, bypassing the + warning entirely, so the deployments that needed the check most never saw it. 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 +- T1 a single task may nest without error +- T2 a different task entering while an outer is active is refused +- T3 the refusal message names the cause and the fix +- T4 the owner's transaction survives a refused attempt +- T5 owner_task is cleared after the outermost transaction exits cleanly +- T6 the same task re-entering after a clean exit re-acquires ownership +- T7 another task may use the connection once the owner has committed """ import asyncio -import warnings import pytest +from rhosocial.activerecord.backend.errors import TransactionError + @pytest.fixture async def mgr_and_backend(async_pool_and_model): @@ -34,15 +38,13 @@ async def mgr_and_backend(async_pool_and_model): 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. + on the async transaction manager with no-ops, because the ownership check + fires *before* `_do_begin()` is awaited. 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() @@ -68,35 +70,26 @@ async def _noop(*args, **kwargs): pass -class TestAsyncOwnerTaskNoWarningSameTask: - async def test_same_task_nested_transaction_no_warning(self, mgr_and_backend): +class TestAsyncOwnerTaskSameTask: + async def test_same_task_nested_transaction_is_allowed(self, mgr_and_backend): _backend, mgr = mgr_and_backend - with warnings.catch_warnings(): - warnings.simplefilter("error") + async with mgr.transaction(): async with mgr.transaction(): - async with mgr.transaction(): - pass + assert mgr.transaction_level == 2 - async def test_same_task_reentry_after_clean_exit_no_warning(self, mgr_and_backend): + async def test_same_task_reentry_after_clean_exit_is_allowed(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 - + 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. - """ +class TestAsyncOwnerTaskCrossTaskRefusal: + async def test_cross_task_share_is_refused(self, mgr_and_backend): + """A different task entering during an active outer transaction MUST + raise TransactionError -- the second task cannot be allowed to drive + the owner's level counter.""" _backend, mgr = mgr_and_backend outer_started = asyncio.Event() @@ -107,27 +100,28 @@ async def owner(): outer_started.set() await proceed.wait() - async def second_task(caught): - with warnings.catch_warnings(record=True) as captured: - warnings.simplefilter("always") + async def second_task(refusals): + try: async with mgr.transaction(): - pass - caught.extend(captured) + refusals.append(None) + except TransactionError as exc: + refusals.append(exc) - caught = [] + refusals: list = [] 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): + second = asyncio.create_task(second_task(refusals)) + try: + await asyncio.wait_for(outer_started.wait(), timeout=5.0) + await asyncio.sleep(0.05) + finally: + proceed.set() + await asyncio.gather(owner_task, second) + + assert refusals and isinstance(refusals[0], TransactionError), ( + "the second task must be refused, not silently allowed" + ) + + async def test_refusal_message_names_the_cause_and_the_fix(self, mgr_and_backend): _backend, mgr = mgr_and_backend outer_started = asyncio.Event() @@ -138,46 +132,63 @@ async def owner(): 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) + async def second_task(refusals): + try: + await mgr.begin() + except TransactionError as exc: + refusals.append(exc) - caught = [] + refusals: list = [] 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 + second = asyncio.create_task(second_task(refusals)) + try: + await asyncio.wait_for(outer_started.wait(), timeout=5.0) + await asyncio.sleep(0.05) + finally: + proceed.set() + await asyncio.gather(owner_task, second) + + message = str(refusals[0]) + assert "owned by another task" in message + assert "AsyncBackendPool" in message + + async def test_the_owner_keeps_its_transaction_after_a_refusal(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + + async with mgr.transaction(): + assert mgr.transaction_level == 1 + + async def intruder(): + with pytest.raises(TransactionError): + await mgr.begin() + + await asyncio.create_task(intruder()) + + assert mgr.transaction_level == 1, "the refusal must not disturb the owner" + assert mgr.is_active + + async def test_another_task_may_use_the_connection_after_the_commit(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + + async with mgr.transaction(): + pass + + async def later(): + async with mgr.transaction(): + assert mgr.transaction_level == 1 + + await asyncio.create_task(later()) 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 - """ + """After a clean outermost commit, _owner_task MUST reset to 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): @@ -189,13 +200,10 @@ async def test_owner_task_cleared_after_outermost_rolls_back(self, mgr_and_backe assert mgr._owner_task is None 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).""" + """After a clean reset the same task re-entering re-acquires ownership.""" _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 + async with mgr.transaction(): + pass diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_artifact_contract.py b/tests/rhosocial/activerecord_test/feature/expression/test_artifact_contract.py new file mode 100644 index 000000000..3046d9a29 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_artifact_contract.py @@ -0,0 +1,492 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_artifact_contract.py +"""Runtime contract for the artifact codec: shape, round-trip, refusal. + +This replaces the earlier serialization test files wholesale, and it drops +something they all depended on: **synthesizing an instance for every +registered class**. That machinery existed because a class index keyed by +import path could not tell you what to construct. The registry says which class +a kind *is*, and an artifact says exactly which parameters it carries -- so +reading needs neither a table nor a guess. + +What is left to test is the part that is genuinely the codec's own: + +Every kind is reachable, and reaches nothing else + The registry is the only name authority, three whitelists stay separate, + and a fresh one resolves nothing at all (§7.1). + +The artifact is a bare node + ``{"kind": ..., "params": {...}}`` and nothing else. No envelope, no class + path, no version field (§4.11.3). + +Every shape of value survives the trip + Nested expressions, tuples, lists, dicts, enums, dataclasses and the + standard-library scalars. These are written out by hand rather than + generated, because each one is a *statement about what the codec must + carry*, and a generated instance cannot make that statement. + +Writing never needs a registry + The node reports its own kind, so a caller who only writes never builds one + (§7.2) -- checked in a subprocess, since by now this process has one. + +Reading trusts nothing + The artifact is external input: an unknown kind, a leftover envelope field, + an unrecognised parameter, a missing kind, an over-deep tree and a name + aimed at an import each fail by name. +""" + +import datetime +import decimal +import json +import subprocess +import sys +import uuid + +import pytest + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.registry import ( + ExpressionRegistry, + RegistryError, +) +from rhosocial.activerecord.backend.expression.serialization import ( + ExpressionDeserializationError, + default_registry, + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + Column, + Literal, +) +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + +def _dialect(): + return DummyDialect() + + +# --------------------------------------------------------------------------- +# The values an artifact has to be able to carry +# --------------------------------------------------------------------------- + +#: One case per *shape* of parameter value, not per class. What the codec owes +#: a caller is that each of these comes back; how many classes happen to use +#: each shape is the library's business, not the format's. The twelve value +#: kinds are a separate axis and get their own round-trip below. +CARRYABLE = { + # One node carries every value, so these four differ only in what they hold. + # Which *value kind* each is comes from the host type (LiteralKind), not from + # the node's class -- the kinds are enumerated separately below. + "scalar-int": lambda d: Literal(d, 42), + "scalar-str": lambda d: Literal(d, "hi"), + "scalar-bool": lambda d: Literal(d, True), + "scalar-null": lambda d: Literal(d, None), + "column": lambda d: Column(d, "id"), + "bare-column": lambda d: Column(d, "id", table=Table(d, "users")), + "wildcard": lambda d: AllColumnsWildcard(d), + "qualified": lambda d: Column(d, "id", table=Table(d, "users"), alias="u"), + # Nested expressions: the predicate holds two columns, so a reader that + # lost a nesting level would still produce *an* expression, just a wrong one. + "predicate": lambda d: ComparisonPredicate(d, "=", Column(d, "a"), Literal(d, 1)), + "where": lambda d: WhereClause( + d, ComparisonPredicate(d, "=", Column(d, "a"), Literal(d, 1)) + ), + # A tuple is not a list. JSON draws no distinction; the artifact must. + "tuple": lambda d: Table(d, "users"), + # Standard-library scalars, each with its own registered codec. + "date": lambda d: Literal(d, datetime.date(2020, 1, 2)), + "timestamp": lambda d: Literal(d, datetime.datetime(2020, 1, 2, 3, 4, 5)), + "decimal": lambda d: Literal(d, decimal.Decimal("1.25")), + "uuid": lambda d: Literal(d, uuid.UUID(int=7)), + "bytes": lambda d: Literal(d, b"\x01\x02"), + "timedelta": lambda d: Literal(d, datetime.timedelta(days=1, hours=2)), +} + + +def _params_equal(a, b): + """Structural equality for a parameter tree. + + Expressions define no ``__eq__``, so comparing two parameter dicts + directly compares nested nodes by identity and fails on a round-trip that + worked. Two nodes count as equal when they are the same class and their + parameters match all the way down -- the property a rebuild must preserve. + """ + if isinstance(a, BaseExpression) or isinstance(b, BaseExpression): + if type(a) is not type(b): + return False + return _params_equal(a.get_params(), b.get_params()) + if isinstance(a, dict) and isinstance(b, dict): + return set(a) == set(b) and all(_params_equal(a[k], b[k]) for k in a) + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return len(a) == len(b) and all(_params_equal(x, y) for x, y in zip(a, b)) + return type(a) is type(b) and a == b + + +def _render(instance): + """The SQL this instance renders, or None when it renders nothing.""" + try: + return instance.to_sql()[0] + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Reachability: the registry is the only name authority +# --------------------------------------------------------------------------- + + +def _declares_a_formatter(cls) -> bool: + """Does this class name the formatter that renders it? + + ``inspect.isabstract`` does not answer this. The framework bases are not + declared abstract -- they declare no ``format_method`` and raise when it is + read -- so they are concrete enough to construct and yet are not nodes. A + base that names a category is not a kind. + """ + prop = getattr(cls, "format_method", None) + if prop is None: + return False + if getattr(prop, "isabstractmethod", False): + return False + try: + return isinstance(prop, str) and bool(prop) + except Exception: + return False + + +def _declared_kinds(): + """Every kind the expression tree defines, read from the tree itself.""" + import importlib + import inspect + import pkgutil + + package = "rhosocial.activerecord.backend.expression" + root = importlib.import_module(package) + modules = [package] + [ + n for _, n, _ in pkgutil.walk_packages(root.__path__, package + ".") + ] + kinds = set() + for name in modules: + try: + mod = importlib.import_module(name) + except Exception: + continue + for attr in vars(mod).values(): + if ( + inspect.isclass(attr) + and issubclass(attr, BaseExpression) + and _declares_a_formatter(attr) + ): + kinds.add(attr.kind()) + return kinds + + +def test_the_framework_registry_resolves_every_kind_it_declares(): + """A kind in the tree that the registry cannot resolve is a broken name. + + Read from the tree rather than from a hand-written list, so a new class is + covered the moment it exists instead of the day someone remembers it. + """ + registry = default_registry() + declared = _declared_kinds() + registered = set(registry.node_kinds()) + assert declared <= registered, ( + f"kinds the tree defines but the registry cannot resolve: " + f"{sorted(declared - registered)}" + ) + + +def test_a_fresh_registry_resolves_nothing(): + """§7.1: the registry starts empty. It registers; it does not discover.""" + empty = ExpressionRegistry() + assert empty.node_kinds() == () + with pytest.raises(RegistryError): + empty.resolve_node("column") + + +def test_registration_is_the_only_way_a_name_becomes_known(): + empty = ExpressionRegistry() + with pytest.raises(RegistryError): + empty.resolve_node(Column.kind()) + empty.register_node(Column) + assert empty.resolve_node(Column.kind()) is Column + + +def test_a_kind_is_resolved_through_one_table_only(): + """Enums and dataclasses are not registered kinds, and are not resolved. + + They used to sit in two sibling whitelists, which raised a real hazard: an + enum and a node sharing a name are different things, and a reader had to be + careful not to resolve one as the other. There is nothing to be careful about + now -- a string enum *is* a JSON string and needs no table, and a dataclass + has no place in the artifact at all. Stating the absence is what stops a + later change quietly reinstating a second table. + """ + registry = default_registry() + for gone in ( + "_enums", "_dataclasses", + "resolve_enum", "register_enum", + "resolve_dataclass", "register_dataclass", + ): + assert not hasattr(registry, gone), "the %s whitelist is back" % gone + + # And whatever a kind is called, it resolves to exactly one class. + assert registry.resolve_node("column") is Column + + +# --------------------------------------------------------------------------- +# The artifact's shape +# --------------------------------------------------------------------------- + + +def test_the_artifact_is_a_bare_node(): + """§4.11.3: the artifact *is* the node. There is no envelope around it.""" + artifact = serialize(Column(_dialect(), "id")) + assert set(artifact) == {"kind", "params"} + assert artifact["kind"] == "column" + assert artifact["params"]["name"] == "id" + + +def test_the_artifact_names_no_import_path(): + """The wire format carries a kind, never a dotted path. + + A dotted path in the artifact is what would force a reader to import + whatever the string pointed at. + """ + artifact = json.dumps(serialize(Column(_dialect(), "id"))) + assert "rhosocial" not in artifact + assert "." not in artifact.split('"kind":')[1].split(",")[0].strip('"') + + +def test_the_artifact_is_json_native(): + """An artifact crosses a wire, so it must survive ``json.dumps`` intact.""" + dialect = _dialect() + for label, build in CARRYABLE.items(): + json.dumps(serialize(build(dialect))) # raises TypeError if not native + + +def test_key_order_is_stable(): + """Two encodings of one node are the same text. + + A reader that has to diff artifacts, or cache them by content, cannot do it + if the key order depends on dict iteration. + """ + dialect = _dialect() + for label, build in CARRYABLE.items(): + once = serialize(build(dialect)) + twice = serialize(build(dialect)) + assert json.dumps(once) == json.dumps(twice), label + + +def test_serialization_never_builds_a_registry(): + """§7.2: writing is registry-free. + + Run in a subprocess: by the time this test executes, this process has + already built a registry for the tests above, so the assertion here would + be vacuous. + """ + script = ( + "import rhosocial.activerecord.backend.expression.serialization as S\n" + "seen = []\n" + "real = S.default_registry\n" + "S.default_registry = lambda: seen.append(1) or real()\n" + "from rhosocial.activerecord.backend.expression.core import Column\n" + "from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect\n" + "S.serialize(Column(DummyDialect(), 'id'))\n" + "print('BUILT' if seen else 'CLEAN')\n" + ) + out = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, timeout=120 + ) + assert out.returncode == 0, out.stderr + assert out.stdout.strip().endswith("CLEAN"), ( + "serialize() consulted the registry; writing must not need one" + ) + + +# --------------------------------------------------------------------------- +# Round-trip: dict, JSON and XML are three renderings of one artifact +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("channel", ["dict", "json", "xml"]) +def test_every_carryable_value_survives_every_channel(channel): + """Parameters and SQL both come back unchanged. + + Comparing only the parameters would pass for an encoding that dropped + something the formatter needs; comparing only the SQL would pass for one + that reordered a list. + """ + dialect = _dialect() + for label, build in CARRYABLE.items(): + instance = build(dialect) + expected_params = instance.get_params() + expected_sql = _render(instance) + + if channel == "dict": + restored = deserialize(serialize(instance), dialect) + elif channel == "json": + restored = deserialize_json(serialize_json(instance), dialect) + else: + restored = deserialize_xml(serialize_xml(instance), dialect) + + assert type(restored) is type(instance), ( + f"{label}/{channel}: rebuilt as {type(restored).__name__}" + ) + assert _params_equal(restored.get_params(), expected_params), ( + f"{label}/{channel}: parameters changed\n" + f" before: {expected_params}\n" + f" after : {restored.get_params()}" + ) + assert _render(restored) == expected_sql, f"{label}/{channel}: SQL changed" + + +def test_a_tuple_does_not_come_back_as_a_list(): + """JSON draws no distinction between the two; the artifact has to.""" + dialect = _dialect() + artifact = serialize( + ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")) + ) + assert isinstance(artifact["params"]["left"], dict) + restored = deserialize_json(json.dumps(artifact), dialect) + assert isinstance(restored.get_params()["left"], Column) + + +def test_a_datetime_survives_as_a_datetime(): + """A value with no JSON form must come back as the same type, not a string. + + This is the case the codec's standard-library entries exist for: without + one, the value is written as its repr and read back as a string, which the + owning constructor then has to reject or silently coerce. + """ + dialect = _dialect() + original = Literal(dialect, datetime.datetime(2020, 1, 2, 3, 4, 5)) + restored = deserialize_json(serialize_json(original), dialect) + assert isinstance(restored.value, datetime.datetime) + assert restored.value == original.value + + +# --------------------------------------------------------------------------- +# Reading: the artifact is untrusted input +# --------------------------------------------------------------------------- + + +def test_an_unknown_kind_is_refused_by_name(): + with pytest.raises(ExpressionDeserializationError) as caught: + deserialize({"kind": "no_such_kind", "params": {}}, _dialect()) + assert "no_such_kind" in str(caught.value) + + +def test_a_leftover_envelope_field_is_refused(): + """§4.11.3: a bare node carries exactly ``kind`` and ``params``. + + Passing an extra field through means a reader tolerates an envelope it does + not understand, which is how two incompatible formats end up sharing a key. + """ + artifact = serialize(Column(_dialect(), "id")) + artifact["type"] = "rhosocial.activerecord.backend.expression.core.Column" + with pytest.raises(ExpressionDeserializationError) as caught: + deserialize(artifact, _dialect()) + assert "type" in str(caught.value) + + +def test_an_unrecognised_parameter_is_refused_by_name(): + """A reader does not drop what it does not recognise.""" + artifact = serialize(Column(_dialect(), "id")) + artifact["params"]["no_such_param"] = 1 + with pytest.raises(ExpressionDeserializationError) as caught: + deserialize(artifact, _dialect()) + assert "no_such_param" in str(caught.value) + + +def test_a_missing_kind_is_refused(): + with pytest.raises(ExpressionDeserializationError): + deserialize({"params": {}}, _dialect()) + + +def test_a_name_in_an_artifact_never_reaches_an_import(): + """§4.11.4: resolution is by whitelist, never by import path. + + Importing whatever module a name points at turns a data field into a code + execution path. The registry can only answer for names someone registered. + """ + with pytest.raises(Exception) as caught: + deserialize({"kind": "os.system", "params": {}}, _dialect()) + assert "os.system" in str(caught.value) + + +def test_an_over_deep_tree_is_refused(): + """Untrusted input must not be able to exhaust the stack.""" + node = serialize(Literal(_dialect(), 1)) + for _ in range(200): + node = {"__expr__": node} + with pytest.raises(ExpressionDeserializationError): + deserialize(node, _dialect()) + + +def test_the_readers_registry_is_the_callers(): + """§7.2: the caller decides which kinds a read may produce. + + An empty registry accepts nothing, which is the point: a channel that + should accept one kind can be given exactly that kind. + """ + dialect = _dialect() + artifact = serialize(Literal(dialect, 7)) + with pytest.raises(ExpressionDeserializationError): + deserialize(artifact, dialect, registry=ExpressionRegistry()) + + narrow = ExpressionRegistry() + narrow.register_value_kinds() + assert isinstance(deserialize(artifact, dialect, registry=narrow), Literal) + + +# --------------------------------------------------------------------------- +# The halves must agree: constructor parameters and stored attributes +# --------------------------------------------------------------------------- + + +def test_no_get_params_override(): + """``get_params()`` is not an extension point; an override is a defect. + + A round-trip rebuilds by calling the constructor with whatever + ``get_params()`` returned, so an override is precisely where those two + halves drift: the class emits a key the constructor renames, merges or + refuses, and the state is silently gone. The repair belongs in + ``__init__`` -- store the value under the name it was given. + """ + import importlib + import inspect + import pkgutil + + base = BaseExpression.__dict__["get_params"] + package = "rhosocial.activerecord.backend.expression" + root = importlib.import_module(package) + modules = [package] + [ + n for _, n, _ in pkgutil.walk_packages(root.__path__, package + ".") + ] + overriders = [] + for module in modules: + try: + mod = importlib.import_module(module) + except Exception: + continue + for cls in vars(mod).values(): + if ( + inspect.isclass(cls) + and issubclass(cls, BaseExpression) + and "get_params" in cls.__dict__ + and cls.__dict__["get_params"] is not base + ): + overriders.append(cls.__name__) + assert not overriders, ( + "these override get_params(); the constructor must accept what " + f"get_params emits: {sorted(set(overriders))}" + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_float_non_finite.py b/tests/rhosocial/activerecord_test/feature/expression/test_float_non_finite.py new file mode 100644 index 000000000..878eba3ec --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_float_non_finite.py @@ -0,0 +1,151 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_float_non_finite.py +"""The two members of the float type JSON cannot carry, and the kinds that do. + +NaN and +/-Infinity are ordinary members of a float -- IEEE 754 defines them, +Python's Decimal has them, and arithmetic produces them without asking. They are +not errors and not edge cases, so refusing to write one omits part of the type +rather than enforcing a rule. + +What makes them awkward is narrower: JSON's number grammar (RFC 8259) has no +literal for them. `json.dumps` emits a bare `NaN` anyway, which its own +documentation calls non-compliant, and that went unnoticed for as long as the +only reader of a Python artifact was a Python program. + +The answer is the one this vocabulary already uses for every other host type +JSON cannot represent natively -- date, time, interval, binary, decimal all +travel as text under a kind that names how to read them back. So do these. A +port reads a string under a known kind, and no parser needs a special case. +""" + +import json +import math + +import pytest + +from rhosocial.activerecord.backend.expression import serialization as ser +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.values import LiteralKind +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + +NON_FINITE = [("nan", float("nan")), ("+inf", float("inf")), ("-inf", float("-inf"))] + + +@pytest.fixture +def dialect(): + return DummyDialect() + + +@pytest.mark.parametrize("label,value", NON_FINITE) +def test_each_gets_a_kind_of_its_own(dialect, label, value): + """The host type is the same float in all three cases; the kind differs.""" + encoded = ser.encode_artifact(Literal(dialect, value)) + expected = "nan_value" if math.isnan(value) else "infinity_value" + assert encoded["kind"] == expected + + +def test_the_payloads_are_json_strings_not_bare_literals(dialect): + """The whole point: strict JSON can parse what this port writes.""" + for _, value in NON_FINITE: + text = json.dumps(ser.encode_artifact(Literal(dialect, value)), + separators=(",", ":"), ensure_ascii=False) + assert "NaN" not in text and "Infinity" not in text + json.loads(text) # raises if any parser-worthy rule was broken + + +def test_the_infinity_sign_is_spelled_not_inferred(dialect): + """The sign is the content of the value, so it travels.""" + assert ser.encode_artifact(Literal(dialect, float("inf")))["params"]["value"] == "+inf" + assert ser.encode_artifact(Literal(dialect, float("-inf")))["params"]["value"] == "-inf" + + +def test_nan_carries_no_spellings_to_disagree_about(dialect): + """A NaN is a NaN; giving it a payload that could say otherwise would be + a place for two ports to differ for no reason.""" + encoded = ser.encode_artifact(Literal(dialect, float("nan"))) + assert encoded["params"]["value"] == "nan" + + +@pytest.mark.parametrize("label,value", NON_FINITE) +def test_every_one_round_trips(dialect, label, value): + encoded = ser.encode_artifact(Literal(dialect, value)) + back = ser.decode_artifact(encoded, dialect).value + if math.isnan(value): + assert math.isnan(back) + else: + assert back == value + assert math.copysign(1, back) == math.copysign(1, value), "the sign must survive" + + +@pytest.mark.parametrize("label,value", NON_FINITE) +def test_inline_travels_with_them_too(dialect, label, value): + """inline is a property of the position; a new kind must carry it like the rest.""" + encoded = ser.encode_artifact(Literal(dialect, value, inline=True)) + assert encoded["params"]["inline"] is True + + +@pytest.mark.parametrize("label,value", NON_FINITE) +def test_it_still_binds_at_runtime(dialect, label, value): + """The node exists so the artifact can carry the value; binding never stopped working.""" + sql, params = Literal(dialect, value).to_sql() + assert sql == "?" + assert params == (value,) + + +class TestFloatValueStaysAFiniteNumber: + """The split has to be clean, or float_value becomes the thing it replaced.""" + + def test_a_finite_float_is_still_a_json_number(self, dialect): + encoded = ser.encode_artifact(Literal(dialect, 1.5)) + assert encoded["kind"] == "float_value" + assert encoded["params"]["value"] == 1.5 + assert isinstance(encoded["params"]["value"], float) + + @pytest.mark.parametrize("payload", [ + '{"kind":"float_value","params":{"value":NaN,"inline":false}}', + '{"kind":"float_value","params":{"value":Infinity,"inline":false}}', + '{"kind":"float_value","params":{"value":-Infinity,"inline":false}}', + ]) + def test_reading_a_non_finite_float_value_is_refused(self, dialect, payload): + """Not because the value is invalid, but because the kind is wrong for it.""" + with pytest.raises(ValueError, match="nan_value or infinity_value"): + ser.decode_artifact(json.loads(payload), dialect) + + @pytest.mark.parametrize("raw", ['"NaN"', '"1.5"', "true", "null", "{}", "[]"]) + def test_reading_a_non_number_is_refused(self, dialect, raw): + """It would bind as a string and fail as a database error, naming neither + the artifact nor the position.""" + with pytest.raises(ValueError): + ser.decode_artifact( + json.loads('{"kind":"float_value","params":{"value":%s,"inline":false}}' % raw), + dialect, + ) + + @pytest.mark.parametrize("value", [1.5, -0.25, 0.0, -0.0, 3.14159, 1e300]) + def test_finite_floats_survive_the_round_trip(self, dialect, value): + encoded = ser.encode_artifact(Literal(dialect, value)) + assert ser.decode_artifact(encoded, dialect).value == value + + +def test_a_non_finite_operand_in_a_predicate_travels(dialect): + """Not only at the top level -- the values nest, so the kind has to work there.""" + tree = ComparisonPredicate(dialect, ">", Column(dialect, "score"), Literal(dialect, float("inf"))) + assert ser.encode_artifact(tree)["params"]["right"]["kind"] == "infinity_value" + + +def test_decimal_text_is_not_subject_to_any_of_this(dialect): + """Decimal travels as text, which *is* JSON, so its NaN and Infinity need + nothing new -- and the host type considers them ordinary numbers.""" + import decimal + + for value in (decimal.Decimal("NaN"), decimal.Decimal("Infinity")): + kind = LiteralKind.of(value) + assert kind.wire == "decimal_value" + assert kind.pack(value) in ("NaN", "Infinity") + + +def test_the_kinds_are_in_the_registry(dialect): + """A kind that is not registered cannot be resolved on the way back.""" + registry = ser.default_registry() + for wire in ("nan_value", "infinity_value"): + assert wire in registry.node_kinds() \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_kind_naming.py b/tests/rhosocial/activerecord_test/feature/expression/test_kind_naming.py new file mode 100644 index 000000000..0cc828467 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_kind_naming.py @@ -0,0 +1,431 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_kind_naming.py +"""Contract tests for the expression registry and the artifact codec. + +Three things are enforced here. + +The registry is an independent module and starts empty (§7.1) + It knows no expressions, no core/backend split, no backend names. A fresh + :class:`ExpressionRegistry` resolves nothing. The framework assembles its + own instance; the registry never discovers anything itself. + +Write and read are asymmetric about the registry (§7.2) + Serializing must not build or consult one — the node reports its own kind, + so a caller who only serializes never pays for a registry. Only + deserializing needs one, and the caller supplies it. + +Parameters are matched by name, and ``**kwargs`` is banned + A catch-all makes a parameter's identity a function of how many extras + arrived, so it cannot be validated on read and its range cannot be declared + (§4.8). :func:`decode_artifact` rejects such a class; these tests keep new + ones from appearing and keep the one existing exception visible. +""" + +import ast +import importlib +import inspect +import json +import pkgutil +import subprocess +import sys +import textwrap + +import pytest + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.registry import ( + ExpressionRegistry, + RegistryError, + kind_of, +) +from rhosocial.activerecord.backend.expression.serialization import ( + ExpressionDeserializationError, + deserialize, + serialize, +) + +#: Packages scanned for expression classes: the core tree plus the backends +#: built into this distribution. +_EXPRESSION_PACKAGES = ( + "rhosocial.activerecord.backend.expression", + "rhosocial.activerecord.backend.impl.sqlite.expression", + "rhosocial.activerecord.backend.impl.dummy.expression", +) + +#: No class may accept ``**kwargs``; the last violation was removed, so there +#: is no allowance list to keep in sync. + +#: Classes whose sole parameter is ``*args``-style. Not banned yet; recorded as +#: the baseline a future ban would shrink. +_ALLOWED_BARE_VARIADIC = { + "AbsoluteFunction", "AbsoluteIntegerFunction", "CeilFunction", + "CeilIntegerFunction", "CosineFunction", "ExponentialFunction", + "FloorFunction", "FloorIntegerFunction", "JSONBuildObjectFunction", + "LogarithmFunction", "ModuloFunction", "PowerFunction", "RoundFunction", + "RoundIntegerFunction", "SignFunction", "SineFunction", + "SquareRootFunction", "TangentFunction", "TruncateFunction", + "TruncateIntegerFunction", +} + +#: Stored ``kind``/``KIND``/``kind_name`` fields that disagree with the derived +#: kind. A stored value is a second source of truth (§4.9.2), and a consumer +#: reading it would emit a different kind than the whitelist derives. Nothing +#: is stored any more; the check below keeps it that way. +_KNOWN_KIND_DIVERGENCES = {} + + +def _iter_expression_classes(): + """Yield every concrete expression class in the framework's packages.""" + seen_modules, seen_classes = set(), set() + for pkgname in _EXPRESSION_PACKAGES: + pkg = importlib.import_module(pkgname) + if hasattr(pkg, "__path__"): + modnames = [ + m for _, m, _ in pkgutil.walk_packages( + path=pkg.__path__, prefix=pkgname + ".", onerror=lambda x: None + ) + ] + else: + modnames = [pkgname] + for modname in modnames: + if modname in seen_modules: + continue + seen_modules.add(modname) + try: + mod = importlib.import_module(modname) + except Exception: # pragma: no cover - optional backend modules + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if not isinstance(obj, type) or obj.__module__ != modname: + continue + if not issubclass(obj, BaseExpression) or obj is BaseExpression: + continue + if getattr(obj, "__abstractmethods__", None): + continue # a base other nodes build on, not a node + if id(obj) in seen_classes: + continue + seen_classes.add(id(obj)) + yield obj + + +_ALL = sorted(_iter_expression_classes(), key=lambda c: c.__name__) + + +def _sqlite_dialect(): + from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + + return SQLiteDialect() + + +# --------------------------------------------------------------------------- +# Rule 1: the registry is an independent module that starts empty +# --------------------------------------------------------------------------- + + +def test_a_fresh_registry_is_empty_and_knows_nothing(): + """§7.1: the registry does not discover, and ships nothing.""" + registry = ExpressionRegistry() + assert registry.node_kinds() == () + with pytest.raises(RegistryError): + registry.resolve_node("integer_value") + assert registry.node_kinds() == () + + +def test_the_registry_module_does_not_import_expressions(): + """The module must not grow an opinion about what expressions exist.""" + import re + + import rhosocial.activerecord.backend.expression.registry as registry_module + + source = open(registry_module.__file__, encoding="utf-8").read() + + # Match real import statements, not the prose that explains why they are + # banned — the module's docstring names importlib to say it must not use it. + assert not re.search(r"^\s*(import\s+importlib|from\s+importlib)", source, re.M), ( + "the registry module imports packages; discovery belongs to the " + "framework's assembly point, not here" + ) + + # It also holds no backend names: those are the backends' own (§5.1). + for backend in ("SQLite", "Postgres", "MySQL", "ClickHouse", "BigQuery"): + assert backend not in source, f"the registry names a backend: {backend}" + + +def test_framework_default_registry_is_built_on_first_deserialization(): + """The framework assembles one instance; it is not empty once filled.""" + from rhosocial.activerecord.backend.expression import serialization + + registry = serialization.default_registry() + kinds = registry.node_kinds() + # The point is "assembled", not a headcount: a number here has to be edited + # every time a node is added or a class stops being one, and editing it is + # how the assertion stops meaning anything. + assert kinds and kinds == tuple(sorted(kinds)) + assert "query_statement" in kinds + assert "integer_value" in kinds + + # And it is the same instance on every call. + assert serialization.default_registry() is registry + + +# --------------------------------------------------------------------------- +# Rule 2: write needs no registry, read does +# --------------------------------------------------------------------------- + + +def test_serializing_never_builds_a_registry(): + """§7.2: "only json.loads needs it" — a serialize-only caller pays nothing. + + Run in a subprocess so the module global starts genuinely unset; asserting + this in-process would only prove nothing has deserialized *yet*. + """ + probe = textwrap.dedent( + """ + import rhosocial.activerecord.backend.expression.serialization as S + from rhosocial.activerecord.backend.expression.serialization import serialize + from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + from rhosocial.activerecord.backend.expression import core as C + from rhosocial.activerecord.backend.expression import query_parts as Q + from rhosocial.activerecord.backend.expression import predicates as P + from rhosocial.activerecord.backend.expression.core import Literal + import rhosocial.activerecord.backend.expression.statements.dql as dql + d = SQLiteDialect() + for _ in range(5): + serialize(dql.QueryStatement( + d, select=[C.Column(d, "id")], from_=[Q.Table(d, "t")], + where=Q.WhereClause(d, P.ComparisonPredicate( + dialect=d, op=">", left=C.Column(d, "a"), right=Literal(d, 1))))) + serialize(Literal(d, 1)) + print("BUILT" if S._default_registry is not None else "NOT_BUILT") + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True + ) + assert "NOT_BUILT" in result.stdout, ( + f"serialization built a registry: {result.stdout}{result.stderr[-400:]}" + ) + + +def test_serialized_node_carries_only_kind_and_params(): + """A bare node: no envelope, and the dialect is absent (§4.11.3).""" + from rhosocial.activerecord.backend.expression.core import Literal + + dialect = _sqlite_dialect() + artifact = serialize(Literal(dialect, 42)) + assert list(artifact) == ["kind", "params"] + assert artifact["kind"] == "integer_value" + assert artifact["params"]["value"] == 42 + + # Nothing that would freeze a dialect or a connection. + text = json.dumps(artifact) + for forbidden in ("dialect", "envelope", "version", "connection"): + assert forbidden not in text + + +def test_deserialization_takes_the_registry_from_the_caller(): + """An injected registry decides which kinds a read may produce.""" + from rhosocial.activerecord.backend.expression.core import Literal + + dialect = _sqlite_dialect() + artifact = serialize(Literal(dialect, 42)) + + # The caller's own table is authoritative: an empty one accepts nothing. + # One exception type covers every rejection — unknown kind, or a kind this + # channel's allowlist withheld — so a reader catches a single thing. + with pytest.raises(ExpressionDeserializationError) as excinfo: + deserialize(artifact, dialect, registry=ExpressionRegistry()) + assert "integer_value" in str(excinfo.value) + + # A table holding just that kind reads it back. + narrow = ExpressionRegistry() + narrow.register_value_kinds() + restored = deserialize(artifact, dialect, registry=narrow) + assert isinstance(restored, Literal) + assert restored.to_sql() == (Literal(dialect, 42)).to_sql() + + +def test_kind_whitelist_has_no_collisions(): + """Every kind resolves to exactly one class. + + Two same-named classes derive one kind (§4.11.1), so an artifact could not + say which it meant. This is the check that caught ``edge_table``. + """ + by_kind = {} + for expr_class in _ALL: + kind = kind_of(expr_class) + assert by_kind.get(kind, expr_class) is expr_class, ( + f"kind '{kind}' is claimed by both {by_kind[kind].__name__} and " + f"{expr_class.__name__}" + ) + by_kind[kind] = expr_class + + +# --------------------------------------------------------------------------- +# Rule 3: parameters by name; **kwargs banned +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("expr_class", _ALL, ids=lambda c: c.__name__) +def test_constructor_takes_no_kwargs(expr_class): + """No expression constructor may accept ``**kwargs``. + + There is no allowance list: the last violation (CollateFunction) was + removed, so any new one is a defect rather than a recorded exception. + """ + var_keyword = [ + name + for name, param in inspect.signature(expr_class.__init__).parameters.items() + if param.kind is inspect.Parameter.VAR_KEYWORD + ] + assert not var_keyword, ( + f"{expr_class.__module__}.{expr_class.__name__} accepts " + f"**{var_keyword[0]}; a catch-all cannot be matched by name on read and " + f"cannot declare its serialized range (§4.8, §4.11.4)" + ) + + +def test_the_last_kwargs_violation_was_removed(): + """CollateFunction was the only class with a catch-all; it now round-trips. + + It used to take ``**collation_options``, of which exactly one key was ever + read (``schema``, for schema-qualified COLLATE). That became a named + ``schema`` parameter, which is what makes the class serializable at all: + a catch-all cannot be matched by name on read, so the artifact it produced + was unreadable. + """ + collation = importlib.import_module( + "rhosocial.activerecord.backend.expression.collation" + ) + core = importlib.import_module("rhosocial.activerecord.backend.expression.core") + dialect = _sqlite_dialect() + node = collation.CollateFunction(dialect, core.Column(dialect, "name"), "NOCASE") + assert not any( + p.kind is p.VAR_KEYWORD + for p in inspect.signature(collation.CollateFunction.__init__).parameters.values() + ), "CollateFunction still accepts **kwargs" + artifact = serialize(node) + assert artifact["kind"] == "collate_function" + restored = deserialize(artifact, dialect) + assert json.dumps(artifact) == json.dumps(serialize(restored)) + assert node.to_sql()[0] == restored.to_sql()[0] + + +def test_unregistered_kind_is_refused_explicitly(): + """An artifact naming a kind nobody registered fails, naming the kind.""" + with pytest.raises(ExpressionDeserializationError) as excinfo: + deserialize({"kind": "no_such_kind", "params": {}}, _sqlite_dialect()) + assert "no_such_kind" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# Naming: derived centrally, never stored +# --------------------------------------------------------------------------- + + +def test_kind_is_a_central_classmethod_no_expression_overrides_it(): + """§4.9.2: the base derives every kind; subclasses supply data, not logic.""" + base_method = BaseExpression.kind.__func__ + overriders = sorted( + expr_class.__name__ + for expr_class in _ALL + if "kind" in expr_class.__dict__ + and expr_class.__dict__["kind"].__func__ is not base_method + ) + assert overriders == [], f"these override kind(): {overriders}" + + +def test_backend_declares_reserved_tokens_as_data(): + """SQLite owns its compound identifier; the core names no backend.""" + declared = [ + obj for obj in _ALL + if obj.__module__.startswith("rhosocial.activerecord.backend.impl.sqlite") + and "SQLite" in obj.__name__ + and obj.KIND_RESERVED == ("SQLite",) + ] + assert declared, "no SQLite expression declares KIND_RESERVED" + for obj in declared: + assert obj.kind().startswith("sqlite_"), ( + f"{obj.__name__} declares SQLite as reserved but derives " + f"{obj.kind()!r}" + ) + assert BaseExpression.KIND_RESERVED == (), "the core must name no backend" + + +def test_stored_kind_never_contradicts_the_derived_kind(): + drifted = {} + for expr_class in _ALL: + for attr in ("kind", "KIND", "kind_name"): + if attr not in expr_class.__dict__: + continue + value = expr_class.__dict__[attr] + if isinstance(value, str) and value and value != kind_of(expr_class): + drifted[f"{expr_class.__name__}.{attr}"] = value + assert drifted == _KNOWN_KIND_DIVERGENCES, ( + "stored kind fields changed — drop fixed entries from " + f"_KNOWN_KIND_DIVERGENCES: {drifted}" + ) + + +#: Backend identifier prefixes a kind may carry. A capability several dialects +#: implement is named after the capability (``format_comment_statement``), not +#: after whichever backend's subclass reuses it, so the prefix is optional. +_BACKEND_PREFIXES = ( + "sqlite_", "mysql_", "mariadb_", "postgres_", "oracle_", + "snowflake_", "clickhouse_", "sqlserver_", "bigquery_", "firebird_", +) + + +def _declared_format_method(cls): + """The literal this class's own ``format_method`` yields, or ``None``.""" + try: + source = textwrap.dedent(inspect.getsource(cls)) + except (OSError, TypeError): + return None + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.FunctionDef) and node.name == "format_method": + for stmt in ast.walk(node): + if isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Constant): + return stmt.value.value + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "format_method": + if isinstance(node.value, ast.Constant): + return node.value.value + return None + + +def test_every_declared_formatter_names_its_kind(): + """§3: a statement/expression node's formatter is ``format_`` plus its kind. + + The backend prefix may be dropped, but no other word may be added, removed + or respelled — that is what keeps the class name, the kind and the dialect + method from drifting apart. + + Scoped to ``*Statement`` / ``*Expression``. The object-reference family + (``Table``, ``IntegerColumn``, ``Literal``, ...) deliberately shares one + renderer across several kinds — ``format_column`` serves ten column types — + so those method names describe the capability, not the kind. + """ + offenders = [] + for expr_class in _ALL: + if not expr_class.__name__.endswith(("Statement", "Expression")): + continue + method = _declared_format_method(expr_class) + if method is None: + continue + kind = expr_class.kind() + allowed = {"format_" + kind} + for prefix in _BACKEND_PREFIXES: + if kind.startswith(prefix): + allowed.add("format_" + kind[len(prefix):]) + if method not in allowed: + offenders.append(f"{expr_class.__name__}: {method} (kind {kind})") + assert offenders == [], ( + "formatter names that do not name their kind: " + repr(offenders) + ) + + +def test_expression_packages_were_actually_scanned(): + """Guard the guard: an empty scan makes every rule above vacuous.""" + assert len(_ALL) > 300, f"only found {len(_ALL)} expression classes" \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_order_by_wire.py b/tests/rhosocial/activerecord_test/feature/expression/test_order_by_wire.py new file mode 100644 index 000000000..648d10314 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_order_by_wire.py @@ -0,0 +1,108 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_order_by_wire.py +"""The ORDER BY element has exactly one spelling per meaning on the wire. + +Two things are pinned here, and both came from a cross-language round trip +rather than from a thought about the design. + +The first is that an element with no direction must survive. ``direction: null`` +is what this library itself writes, and it is also what Go writes for a sort +with no direction -- a reader that crashed on it would reject its own output. + +The second is that ``OrderByItem`` and the ``(operand, direction)`` pair are not +two spellings to be chosen freely. They mean the same thing unless null +ordering is involved, and preserving whichever the caller passed would put a +choice on the wire that every other language would then have to guess at. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect +from rhosocial.activerecord.backend.expression import serialization as ser +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.query_parts import ( + OrderByClause, + OrderByItem, + SortDirection, +) + + +@pytest.fixture +def dialect(): + return DummyDialect() + + +def encoded_expressions(clause): + return ser.encode_artifact(clause)["params"]["expressions"] + + +def test_a_sort_with_no_direction_round_trips(dialect): + """The pair with a null direction is the shape Go writes for the same sort.""" + clause = OrderByClause(dialect, expressions=[(Column(dialect, "id"), None)]) + assert clause.to_sql()[0] == 'ORDER BY "id"' + encoded = encoded_expressions(clause) + assert encoded[0][1] is None, "the absent direction must travel as null" + + +def test_the_pair_and_the_item_produce_the_same_bytes(dialect): + """Building it either way must not change the artifact. + + An artifact has to be byte-identical across four languages. That it does + not depend on how the caller happened to spell the same sort is what makes + such an artifact possible at all. + """ + as_item = OrderByClause(dialect, expressions=[OrderByItem(dialect, Column(dialect, "id"))]) + as_pair = OrderByClause(dialect, expressions=[(Column(dialect, "id"), None)]) + assert ser.encode_artifact(as_item) == ser.encode_artifact(as_pair) + + +def test_null_ordering_keeps_the_node_form(dialect): + """The pair cannot express NULLS FIRST/LAST, so that case stays a node. + + This is the one meaning the node form exists for; collapsing it into a + pair would have to drop the request. + """ + clause = OrderByClause( + dialect, expressions=[OrderByItem(dialect, Column(dialect, "id"), nulls="last")] + ) + entry = encoded_expressions(clause)[0] + assert entry["kind"] == "order_by_item" + assert entry["params"]["nulls"] == "last" + + +def test_a_direction_written_as_sql_still_normalizes(dialect): + """``DESC`` from habit and ``desc`` from the wire word are one thing. + + A caller writing the keyword must not ship the keyword: that is the same + two-spellings problem, one level up. + """ + clause = OrderByClause(dialect, expressions=[(Column(dialect, "id"), "DESC")]) + assert encoded_expressions(clause)[0][1] == "desc" + + +class TestSortDirectionParse: + """Absent is a value; wrong is an error. Neither is a crash.""" + + def test_absent_direction_is_none(self): + assert SortDirection.parse(None) is None + + @pytest.mark.parametrize("word", ["asc", "ASC", "desc", "DESC", " Desc "]) + def test_accepts_either_case_and_surrounding_space(self, word): + assert SortDirection.parse(word) is not None + + @pytest.mark.parametrize("word", ["sideways", 123, 4.5, [], {}]) + def test_rejects_anything_else_explicitly(self, word): + with pytest.raises(ValueError): + SortDirection.parse(word) + + +def test_the_directive_renders_no_keyword_without_a_direction(dialect): + """The formatter must not reach for a keyword that is not there. + + The constructor accepts an absent direction, so a formatter that assumed one + would turn a valid artifact into an AttributeError at render time -- which + is how this was found. + """ + clause = OrderByClause(dialect, expressions=[(Column(dialect, "id"), None)]) + sql, _ = clause.to_sql() + assert sql == 'ORDER BY "id"' + assert "ASC" not in sql and "DESC" not in sql \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_quote_flags.py b/tests/rhosocial/activerecord_test/feature/expression/test_quote_flags.py new file mode 100644 index 000000000..08dcc1bd8 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_quote_flags.py @@ -0,0 +1,103 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_quote_flags.py +"""A quoting flag is a bool, and the annotation is the only thing saying so. + +These flags have no third state. There is no "unspecified" to represent, +because the formatter never meets an absent one: the value is settled at +construction and carried on the artifact (expression-dialect §10.1). So the only +way a flag goes wrong is a caller passing something that is not a bool. + +Python does not enforce annotations, so nothing stopped `name_need_quote=None` -- +and None takes the branch that writes the identifier out with nothing escaped, +which is the decision the caller never made. The signature already said `bool`; +this is where the type is actually checked. + +The Go port made the same mistake from the other direction: there `bool`'s zero +value is false, so leaving the flag unset read as "do not quote". Both ends +arrive at "the identifier is written bare", and both suites pass either way, +because each is self-consistent. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import core as C +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.objects.base import SchemaObject +from rhosocial.activerecord.backend.expression.objects.relation import Table +from rhosocial.activerecord.backend.expression.sources.relation import NamedRelationRef +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + +@pytest.fixture +def dialect(): + return DummyDialect() + + +class TestTheValidator: + def test_a_bool_passes_through_untouched(self): + assert BaseExpression.quote_flag(True, "flag") is True + assert BaseExpression.quote_flag(False, "flag") is False + + @pytest.mark.parametrize("value", [None, 0, 1, "", "yes", [], {}]) + def test_anything_else_is_refused(self, value): + with pytest.raises(TypeError): + BaseExpression.quote_flag(value, "flag") + + def test_the_message_names_the_owner_and_the_flag(self): + """A type error that does not say where is a type error you read twice.""" + with pytest.raises(TypeError) as caught: + SchemaObject.quote_flag(None, "name_need_quote") + message = str(caught.value) + assert "SchemaObject" in message + assert "name_need_quote" in message + assert "NoneType" in message + + +class TestEveryFlagIsCheckedAtConstruction: + """Each owner, each flag -- the point is that none was missed.""" + + def test_column_flags(self, dialect): + for flag in ("name_need_quote", "alias_need_quote", + "schema_need_quote", "table_need_quote"): + with pytest.raises(TypeError, match=flag): + C.Column(dialect, "id", **{flag: None}) + + def test_identifier_flags(self, dialect): + for flag in ("name_need_quote", "schema_need_quote"): + with pytest.raises(TypeError, match=flag): + C.QualifiedIdentifier(dialect, "u", **{flag: None}) + + def test_schema_object_flags(self, dialect): + for flag in ("catalog_need_quote", "schema_need_quote", "name_need_quote"): + with pytest.raises(TypeError, match=flag): + Table(dialect, "t", **{flag: None}) + + def test_table_source_alias_flag(self, dialect): + with pytest.raises(TypeError, match="alias_need_quote"): + NamedRelationRef(dialect, Table(dialect, "t"), alias_need_quote=None) + + +class TestNormalUseIsUntouched: + """The check is on the way in; nothing about the ordinary path changes.""" + + def test_defaults_quote_every_position(self, dialect): + column = C.Column(dialect, "id", table="users", alias="pk") + assert column.name_need_quote is True + assert column.table_need_quote is True + assert column.alias_need_quote is True + assert column.to_sql()[0] == '"users"."id" AS "pk"' + + def test_false_still_writes_the_identifier_bare(self, dialect): + assert C.Column(dialect, "plain", name_need_quote=False).to_sql()[0] == "plain" + + def test_each_position_answers_for_itself(self, dialect): + column = C.Column(dialect, "id", table="users", table_need_quote=False) + assert column.to_sql()[0] == 'users."id"' + + def test_an_integer_zero_is_not_a_false(self, dialect): + """0 == False in Python, which is exactly why isinstance is the test. + + Accepting 0 would mean a caller passing a count silently decided not to + quote. + """ + with pytest.raises(TypeError): + C.Column(dialect, "id", name_need_quote=0) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/expression/test_registry_contract.py b/tests/rhosocial/activerecord_test/feature/expression/test_registry_contract.py new file mode 100644 index 000000000..d663f441d --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/expression/test_registry_contract.py @@ -0,0 +1,357 @@ +# tests/rhosocial/activerecord_test/feature/expression/test_registry_contract.py +"""Selective registration: what a registry does and does not decide. + +The default instance holds every kind the framework ships, so on its own it +shows nothing about *why* a registry exists. These tests use the difference: +the same artifacts read through tables that differ by a few kinds. + +The property under test is not "a narrow registry is narrow". It is that a +registry filters **what an artifact actually contains**, and nothing else: + +* a kind the artifact never mentions costs nothing to leave out, even when the + declaring class has a parameter of that type; +* a kind the artifact does mention is refused, by name, when it is left out. + +That combination is what makes a table usable as a boundary. A caller who +allows ``WhereClause`` but not ``GroupByClause`` can read every query that has +no grouping, and no query that has one -- without knowing, or enumerating, the +kinds any of those trees happen to be built from. + +Write-side asymmetry is deliberate and tested here too. ``serialize`` consults +no registry (§7.2), so a narrow table does not stop a tree being *written* -- +it decides what a reader will accept. See +:func:`test_serializing_ignores_the_registry_entirely` for what that does and +does not buy. +""" + +from datetime import timedelta + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.operators import RawSQL +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import ( + GroupByHavingClause, + WhereClause, +) +from rhosocial.activerecord.backend.expression.query_sources import ValuesExpression +from rhosocial.activerecord.backend.expression.values import LiteralKind +from rhosocial.activerecord.backend.expression.registry import ExpressionRegistry +from rhosocial.activerecord.backend.expression.serialization import ( + default_registry, + deserialize, + serialize, +) +from rhosocial.activerecord.backend.expression.statements.dml import InsertStatement +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.dummy.dialect import DummyDialect + + +@pytest.fixture +def dialect(): + return DummyDialect() + + +#: The kinds a literal contributes, by wire name. +_VALUE_KINDS = frozenset(member.wire for member in LiteralKind) + + +def _table(*kinds): + """A registry holding exactly these kinds and nothing else.""" + registry = ExpressionRegistry() + full = default_registry() + value_only = tuple(k for k in kinds if k in _VALUE_KINDS) + for kind in kinds: + if kind in _VALUE_KINDS: + # The twelve value kinds all resolve to Literal, and they are + # registered by kind rather than by class name. + continue + registry.register_node(full.resolve_node(kind)) + registry.register_value_kinds(*value_only) + return registry + + +#: Everything the two queries below are built from, minus the grouping clause. +#: ``QueryStatement`` declares a ``group_by_having`` parameter whether or not a +#: query uses one, and that declaration deliberately does not put +#: ``group_by_having_clause`` into the artifact. +#: ``literal`` does not appear here because a literal's kind is its *value* +#: kind: one class carries all twelve, and the artifact names whichever the +#: value calls for. The query below holds the integer 18, so the kind an +#: allow-list has to admit is ``integer_value``. +READS_QUERIES = ( + "query_statement", + "where_clause", + "comparison_predicate", + "column", + "integer_value", + "table", +) + + +def _query(dialect, grouped=False): + users = Table(dialect, "users") + where = WhereClause( + dialect, + ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 18)), + ) + if not grouped: + return QueryStatement( + dialect, select=[Column(dialect, "id")], from_=users, where=where + ) + return QueryStatement( + dialect, + select=[Column(dialect, "city")], + from_=users, + where=where, + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "city")]), + ) + + +def _insert(dialect): + users = Table(dialect, "users") + return InsertStatement( + dialect, + into=users, + source=ValuesExpression(dialect, [(Literal(dialect, 1),)]), + columns=["id"], + ) + + +def _kinds_in(artifact): + """Every kind the artifact names, walking the payload. + + A node is ``{kind, params}`` at every depth -- nested ones are not wrapped, + so one walk finds them all. + """ + found = set() + stack = [artifact] + while stack: + node = stack.pop() + if isinstance(node, dict): + if isinstance(node.get("kind"), str): + found.add(node["kind"]) + for value in node.values(): + if isinstance(value, (dict, list)): + stack.append(value) + elif isinstance(node, list): + stack.extend(v for v in node if isinstance(v, (dict, list))) + return found + + +# --------------------------------------------------------------------------- +# The default table +# --------------------------------------------------------------------------- + + +def test_the_default_registry_reads_everything_the_tree_produces(dialect): + """The baseline. Without it, the narrower tables prove nothing.""" + for grouped in (False, True): + artifact = serialize(_query(dialect, grouped)) + restored = deserialize(artifact, dialect, registry=default_registry()) + assert isinstance(restored, QueryStatement) + + +# --------------------------------------------------------------------------- +# A kind left out costs nothing until the artifact names it +# --------------------------------------------------------------------------- + + +def test_a_declared_parameter_does_not_put_its_kind_in_the_artifact(dialect): + """``group_by_having`` is declared either way; only a grouping carries it.""" + plain = serialize(_query(dialect, grouped=False)) + grouped = serialize(_query(dialect, grouped=True)) + assert plain["params"]["group_by_having"] is None + assert grouped["params"]["group_by_having"]["kind"] == "group_by_having_clause" + + +def test_an_artifact_without_the_kind_reads_without_the_kind(dialect): + """The case that makes a narrow table usable. + + ``QueryStatement`` declares a grouping parameter, so a reader that resolved + kinds from the *declaration* rather than from the payload would demand + ``group_by_having_clause`` here and refuse a query that has no grouping. + """ + artifact = serialize(_query(dialect, grouped=False)) + allowed = _table(*READS_QUERIES) + restored = deserialize(artifact, dialect, registry=allowed) + assert isinstance(restored, QueryStatement) + assert restored.get_params()["group_by_having"] is None + + +def test_an_artifact_naming_an_unregistered_kind_is_refused(dialect): + """The other half, and the reason a narrow table is worth anything.""" + artifact = serialize(_query(dialect, grouped=True)) + allowed = _table(*READS_QUERIES) + with pytest.raises(Exception) as caught: + deserialize(artifact, dialect, registry=allowed) + assert "group_by_having_clause" in str(caught.value) + + +def test_the_same_registry_gives_different_answers_for_different_artifacts(dialect): + """One table, two artifacts, two outcomes -- and nothing else differs.""" + allowed = _table(*READS_QUERIES) + plain = serialize(_query(dialect, grouped=False)) + grouped = serialize(_query(dialect, grouped=True)) + assert isinstance(deserialize(plain, dialect, registry=allowed), QueryStatement) + with pytest.raises(Exception): + deserialize(grouped, dialect, registry=allowed) + + +# --------------------------------------------------------------------------- +# A boundary stated as a policy +# --------------------------------------------------------------------------- + + +def test_a_read_only_table_refuses_to_produce_a_write(dialect): + """``GROUP BY`` not permitted: the table says so by what it omits.""" + read_only = _table(*READS_QUERIES) + with pytest.raises(Exception) as caught: + deserialize(serialize(_insert(dialect)), dialect, registry=read_only) + assert "insert_statement" in str(caught.value) + + +def test_a_table_must_cover_exactly_what_the_artifact_carries(dialect): + """The required set is the payload's own kinds -- computed, not guessed. + + Read off the artifact rather than written by hand, so this states the + property instead of restating a list that someone has to keep in step. + A tree carrying an INSERT needs ``insert_statement``, ``values_expression`` + and ``integer_value``; admitting the first alone is refused on the second. + Nothing the INSERT could conceivably contain but does not -- grouping, + joining, a WHERE clause -- is asked for. + """ + artifact = serialize(_insert(dialect)) + needed = _kinds_in(artifact) + + # Sanity: the walk sees the whole payload, not just the root. + assert {"insert_statement", "values_expression", "integer_value"} <= needed + assert not (needed & {"group_by_having_clause", "join_clause", "where_clause"}) + + missing_one = needed - {"values_expression"} + with pytest.raises(Exception) as caught: + deserialize(artifact, dialect, registry=_table(*missing_one)) + assert "values_expression" in str(caught.value) + + assert isinstance(deserialize(artifact, dialect, registry=_table(*needed)), InsertStatement) + + +def test_a_query_table_covers_only_what_its_payload_carries(dialect): + """The same statement, read through a table built for queries. + + No closure, no guesswork: the six kinds below are the ones the ungrouped + query names, and the grouped one names a seventh. + """ + plain = _kinds_in(serialize(_query(dialect, grouped=False))) + grouped = _kinds_in(serialize(_query(dialect, grouped=True))) + assert set(READS_QUERIES) == plain + assert grouped == plain | {"group_by_having_clause"} + + +# --------------------------------------------------------------------------- +# The write side, which is deliberately not gated +# --------------------------------------------------------------------------- + + +def test_serializing_ignores_the_registry_entirely(dialect): + """§7.2: the node reports its own kind, so writing consults no table. + + Stated explicitly because the consequence matters: a narrow registry is a + **read** boundary. It decides what a reader will accept from an artifact it + did not author. It does not stop a tree being written, and it does not + decide what may be executed -- a caller that constructs its own + ``GroupByHavingClause`` and runs it never touches a registry at all. + + A boundary that had to hold on the write side would need a different + mechanism, because the writer is the same process that would enforce it. + """ + grouped = _query(dialect, grouped=True) + narrow = _table(*READS_QUERIES) + + artifact = serialize(grouped) # no registry argument exists to pass + assert artifact["params"]["group_by_having"]["kind"] == "group_by_having_clause" + + # And the artifact just produced is refused by the very table that was + # never consulted while writing it. + with pytest.raises(Exception): + deserialize(artifact, dialect, registry=narrow) + + +# --------------------------------------------------------------------------- +# A kind the framework ships, and does not admit by default +# --------------------------------------------------------------------------- + + +def test_caller_vouched_text_is_left_out_of_the_default_table(dialect): + """``raw_sql`` defines a kind, names a formatter, and is left out anyway. + + ``REGISTER_BY_DEFAULT = False`` says nothing about the node being + unfinished. It says what admitting it would mean: ``to_sql()`` emits + ``expression`` verbatim, so a reader that resolved the kind out of an + artifact would rebuild a node whose SQL the *artifact* chose. The default + table is what an untrusted payload is resolved against, so the default + says no. + """ + assert RawSQL.kind() == "raw_sql" + assert RawSQL.REGISTER_BY_DEFAULT is False + assert "raw_sql" not in default_registry().node_kinds() + + # Writing is unaffected (§7.2): the kind still names itself. + artifact = serialize(RawSQL(dialect, "SELECT 1")) + assert artifact["kind"] == "raw_sql" + + with pytest.raises(Exception) as caught: + deserialize(artifact, dialect) + assert "raw_sql" in str(caught.value) + + +def test_a_caller_who_trusts_its_artifacts_can_register_it(dialect): + """The other half, and the reason this is a default rather than a ban. + + Nothing refuses the class. ``register_node`` is the same call any selective + table makes, so opting in is not a privileged path -- which is the whole + difference between a sensible default and a prohibition. + """ + opted_in = ExpressionRegistry() + opted_in.register_node(RawSQL) + assert opted_in.node_kinds() == ("raw_sql",) + + restored = deserialize( + serialize(RawSQL(dialect, "SELECT 1")), dialect, registry=opted_in + ) + assert isinstance(restored, RawSQL) + assert restored.expression == "SELECT 1" + assert restored.to_sql() == ("SELECT 1", ()) + + +def test_a_raw_sql_bind_value_survives_the_round_trip(dialect): + """The one place a non-JSON type is allowed, and it needs the value kinds. + + ``params`` are wrapped in ``Literal`` at construction, so each bind value + carries the kind that says what it is. Without that, ``b"\\x00"`` and a + string are the same three bytes on the wire and a reader cannot tell them + apart -- the values sent to the database are precisely the ones that must not + be guessed at. + + So opting ``raw_sql`` in is two registrations, not one: the node, and the + value vocabulary its parameters speak. + """ + opted_in = ExpressionRegistry() + opted_in.register_node(RawSQL) + opted_in.register_value_kinds() + + node = RawSQL( + dialect, + "SELECT * FROM t WHERE blob = ? AND span = ?", + (b"\x00\x01", timedelta(seconds=90)), + ) + restored = deserialize(serialize(node), dialect, registry=opted_in) + + # The driver is handed plain values, not nodes. + assert node.to_sql()[1] == (b"\x00\x01", timedelta(seconds=90)) + # ...and the values came back as themselves, not as text. + assert [p.value for p in restored.params] == [b"\x00\x01", timedelta(seconds=90)] + assert restored.to_sql() == node.to_sql() \ No newline at end of file diff --git a/tests/rhosocial/activerecord_test/feature/query/sqlite/test_collation_expression.py b/tests/rhosocial/activerecord_test/feature/query/sqlite/test_collation_expression.py index 1b1f7eaa0..cdc34e017 100644 --- a/tests/rhosocial/activerecord_test/feature/query/sqlite/test_collation_expression.py +++ b/tests/rhosocial/activerecord_test/feature/query/sqlite/test_collation_expression.py @@ -5,6 +5,7 @@ from enum import Enum +from rhosocial.activerecord.backend.expression.collation import collate import pytest from rhosocial.activerecord.backend.dialect.base import SQLDialectBase @@ -54,7 +55,7 @@ def test_enum_collate_generates_sql(self, dialect): assert params == () def test_literal_collate_preserves_parameter_binding(self, dialect): - expr = Literal(dialect, "Alice").collate("NOCASE") + expr = collate(Literal(dialect, "Alice"), "NOCASE") sql, params = expr.to_sql() @@ -85,10 +86,14 @@ def test_default_collation_validation_requires_dialect_override(self): expr.to_sql() def test_collate_expression_stores_backend_options(self, dialect): + # `schema` is a named parameter, not a catch-all. It used to arrive as + # **collation_options, which made the class unreadable: a **kwargs + # parameter cannot be matched by name when an artifact is rebuilt, so + # CollateFunction's payload could not be deserialized at all. expr = Column(dialect, "name").collate("case_insensitive", schema="public") assert expr.collation_name == "case_insensitive" - assert expr.collation_options == {"schema": "public"} + assert expr.schema == "public" def test_sqlite_rejects_schema_qualified_collation(self, dialect): expr = Column(dialect, "name").collate("case_insensitive", schema="public") diff --git a/tests/rhosocial/activerecord_test/feature/query/sqlite/test_sqlite_cte_query_explain.py b/tests/rhosocial/activerecord_test/feature/query/sqlite/test_sqlite_cte_query_explain.py index fc88c96d1..084c24c29 100644 --- a/tests/rhosocial/activerecord_test/feature/query/sqlite/test_sqlite_cte_query_explain.py +++ b/tests/rhosocial/activerecord_test/feature/query/sqlite/test_sqlite_cte_query_explain.py @@ -15,7 +15,7 @@ from typing import List, Dict, Any from rhosocial.activerecord.query import CTEQuery, AsyncCTEQuery -from rhosocial.activerecord.backend.expression import statements, core +from rhosocial.activerecord.backend.expression import statements, core, StringColumn from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -203,9 +203,9 @@ def test_cte_explain_with_except_of_active_queries(self, order_fixtures): def test_cte_explain_with_query_expression(self, order_fixtures): """ - Test EXPLAIN on CTE query that uses a QueryExpression as the underlying query. + Test EXPLAIN on CTE query that uses a QueryStatement as the underlying query. - This test verifies that EXPLAIN works correctly with CTE queries containing QueryExpression. + This test verifies that EXPLAIN works correctly with CTE queries containing QueryStatement. """ User, Order, OrderItem = order_fixtures @@ -230,19 +230,19 @@ def test_cte_explain_with_query_expression(self, order_fixtures): backend = Order.backend() dialect = backend.dialect - # Create a QueryExpression directly (this implements ToSQLProtocol) + # Create a QueryStatement directly (this implements ToSQLProtocol) from rhosocial.activerecord.backend.expression import query_parts - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=[core.Column(dialect, "id"), core.Column(dialect, "status"), core.Column(dialect, "total_amount")], from_=NamedRelationRef(dialect, Table(dialect, Order.table_name())), where=query_parts.WhereClause( - dialect, condition=core.Column(dialect, "status") == core.Literal(dialect, "active") + dialect, condition=StringColumn(dialect, "status") == core.Literal(dialect, "active") ), ) - # Create a CTE that uses the QueryExpression as its source + # Create a CTE that uses the QueryStatement as its source cte_query = CTEQuery(backend) cte_query.with_cte("query_expr_cte", query_expr) @@ -250,13 +250,13 @@ def test_cte_explain_with_query_expression(self, order_fixtures): plan = cte_query.from_cte("query_expr_cte").select("*").explain().aggregate() # Validate the explain output - _validate_explain_output(plan, "CTE QueryExpression") + _validate_explain_output(plan, "CTE QueryStatement") def test_cte_explain_with_query_expression_as_main_query(self, order_fixtures): """ - Test EXPLAIN on CTE query where the main query is a QueryExpression. + Test EXPLAIN on CTE query where the main query is a QueryStatement. - This test verifies that EXPLAIN works correctly with CTE queries where the main query is a QueryExpression. + This test verifies that EXPLAIN works correctly with CTE queries where the main query is a QueryStatement. """ User, Order, OrderItem = order_fixtures @@ -305,7 +305,7 @@ def test_cte_explain_with_query_expression_as_main_query(self, order_fixtures): ) # Validate the explain output - _validate_explain_output(plan, "CTE QueryExpression as Main Query") + _validate_explain_output(plan, "CTE QueryStatement as Main Query") @pytest.mark.sqlite @@ -525,19 +525,19 @@ async def test_async_cte_explain_with_query_expression(self, async_order_fixture backend = AsyncOrder.backend() dialect = backend.dialect - # Create a QueryExpression directly (this implements ToSQLProtocol) + # Create a QueryStatement directly (this implements ToSQLProtocol) from rhosocial.activerecord.backend.expression import query_parts - query_expr = statements.QueryExpression( + query_expr = statements.QueryStatement( dialect, select=[core.Column(dialect, "id"), core.Column(dialect, "status"), core.Column(dialect, "total_amount")], from_=NamedRelationRef(dialect, Table(dialect, AsyncOrder.table_name())), where=query_parts.WhereClause( - dialect, condition=core.Column(dialect, "status") == core.Literal(dialect, "active") + dialect, condition=StringColumn(dialect, "status") == core.Literal(dialect, "active") ), ) - # Create a CTE that uses the QueryExpression as its source + # Create a CTE that uses the QueryStatement as its source cte_query = AsyncCTEQuery(backend) cte_query.with_cte("query_expr_cte", query_expr) @@ -545,7 +545,7 @@ async def test_async_cte_explain_with_query_expression(self, async_order_fixture plan = await cte_query.from_cte("query_expr_cte").select("*").explain().aggregate() # Validate the explain output - _validate_explain_output(plan, "Async CTE QueryExpression") + _validate_explain_output(plan, "Async CTE QueryStatement") async def test_async_cte_explain_with_query_expression_as_main_query(self, async_order_fixtures): """ @@ -600,4 +600,4 @@ async def test_async_cte_explain_with_query_expression_as_main_query(self, async ) # Validate the explain output - _validate_explain_output(plan, "Async CTE QueryExpression as Main Query") + _validate_explain_output(plan, "Async CTE QueryStatement as Main Query") diff --git a/tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py b/tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py index 336488991..9ddce4956 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py @@ -1,10 +1,12 @@ # tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py """What aggregating and windowing a value gives back. -COUNT and SUM look alike and are not: one counts, the other adds, and the -count is a whole number however wide the thing being counted is. MIN and MAX -are the opposite — they hand back something of the input's kind, so the -minimum of a string column is a string and can still be compared and matched. +Each aggregate is its own node class now, so the identity of the function +travels on the node instead of inside a generic name string: COUNT is a +CountFunction, SUM a SumFunction, and so on. A count is a whole number however +wide the thing counted is; an average is numeric whatever it averages; MIN and +MAX hand back something of the operand's kind, which the node cannot know, so +they offer only the operations every value has. A window function is either a count or a value from another row, and confusing the two is how a name column ends up arithmetic. ROW_NUMBER counts; LAG @@ -14,7 +16,28 @@ # tests/rhosocial/activerecord_test/feature/query/test_aggregate_window_families.py import pytest -from rhosocial.activerecord.backend.expression import FunctionCall +from rhosocial.activerecord.backend.expression.declared import ( + IntegerAnnotation, + NumberAnnotation, + TextAnnotation, +) +from rhosocial.activerecord.backend.expression import ( + AvgFunction, + CountFunction, + FunctionCall, + MaxFunction, + MinFunction, + SumFunction, +) +from rhosocial.activerecord.backend.expression.value_types import ( + ArrayValue, + BooleanValue, + JSONValue, + NumericValue, + OpaqueValue, + TemporalValue, + TextValue, +) from rhosocial.activerecord.backend.expression import functions as F @@ -47,7 +70,7 @@ def columns(dialect): def test_count_is_always_a_whole_number(dialect, columns): """However wide the thing counted, the answer is a number of rows.""" for column in columns.values(): - assert isinstance(F.count(dialect, column), IntegerValueExpression) + assert isinstance(F.count(dialect, column), CountFunction) def test_count_takes_arithmetic_and_not_string_operations(dialect, columns): @@ -66,37 +89,53 @@ def test_avg_is_numeric_whatever_it_averages(dialect, columns): one. SQL Server is the exception that returns an integer here, which is exactly why it is not something to build an operation surface on.""" for column in columns.values(): - assert isinstance(F.avg(dialect, column), NumericValueExpression) + assert isinstance(F.avg(dialect, column), AvgFunction) # --------------------------------------------------------------------------- -# Adding, minimising and maximising keep the input's kind +# Adding, minimising and maximising are their own node classes # --------------------------------------------------------------------------- -@pytest.mark.parametrize("factory", ["sum_", "min_", "max_"]) -def test_a_reduction_keeps_the_input_family(dialect, columns, factory): - """SUM of a float column is fractional and MIN of a string column is a - string, which can still be compared and matched.""" +@pytest.mark.parametrize( + "factory, node_class", + [("sum_", SumFunction), ("min_", MinFunction), ("max_", MaxFunction)], +) +def test_a_reduction_is_its_own_node_class(dialect, columns, factory, node_class): + """The reduction's identity is the node class, not a name string. + + SUM / MIN / MAX used to be guessed into a value family from the operand's + column class; now each is its own node, and the operand kind does not + change which node comes back. + """ reduce_it = getattr(F, factory) - assert isinstance(reduce_it(dialect, columns["int"]), IntegerValueExpression) - assert isinstance(reduce_it(dialect, columns["float"]), NumericValueExpression) - assert isinstance(reduce_it(dialect, columns["str"]), StringValueExpression) + for column in columns.values(): + assert isinstance(reduce_it(dialect, column), node_class) + +@pytest.mark.parametrize( + "factory, node_class", + [("sum_", SumFunction), ("min_", MinFunction), ("max_", MaxFunction)], +) +def test_a_literal_input_is_still_the_classed_node(dialect, factory, node_class): + """A literal operand does not change which node comes back. -@pytest.mark.parametrize("factory", ["sum_", "min_", "max_"]) -def test_a_literal_input_has_no_family_to_keep(dialect, factory): - """A literal does not declare a kind, so the result is unknown rather than - guessed. Unknown is safe: the node is handed back untouched.""" + The reduction is classed by the function, not inferred from the operand + kind, so a literal -- which declares no kind -- yields the same node a + column would. + """ result = getattr(F, factory)(dialect, 1) - assert not isinstance(result, ArrayValueExpression) and not isinstance(result, BinaryValueExpression) and not isinstance(result, BooleanValueExpression) and not isinstance(result, DateValueExpression) and not isinstance(result, IntegerValueExpression) and not isinstance(result, IntervalValueExpression) and not isinstance(result, JSONValueExpression) and not isinstance(result, NumericValueExpression) and not isinstance(result, StringValueExpression) and not isinstance(result, TimeValueExpression) and not isinstance(result, TimestampValueExpression) and not isinstance(result, UUIDValueExpression) - assert isinstance(result, FunctionCall) + assert isinstance(result, node_class) -def test_min_of_a_string_column_is_still_a_string(dialect, columns): +def test_min_does_not_guess_the_operand_family(dialect, columns): + """MIN hands back something of the operand's kind, which the node cannot + know, so it offers only the operations every value has rather than + guessing a family from the operand.""" result = F.min_(dialect, columns["str"]) - assert hasattr(result, "like") - assert not hasattr(result, "sqrt") + assert isinstance(result, MinFunction) + assert not isinstance(result, (NumberAnnotation, TextAnnotation)) + assert hasattr(result, "is_null") # --------------------------------------------------------------------------- @@ -105,12 +144,12 @@ def test_min_of_a_string_column_is_still_a_string(dialect, columns): def test_building_a_json_aggregate_gives_json(dialect, columns): - assert isinstance(F.json_objectagg(dialect, columns["int"], columns["str"]), JSONValueExpression) - assert isinstance(F.json_arrayagg(dialect, columns["json"]), JSONValueExpression) + assert isinstance(F.json_objectagg(dialect, columns["int"], columns["str"]), JSONValue) + assert isinstance(F.json_arrayagg(dialect, columns["json"]), JSONValue) def test_collecting_into_an_array_gives_an_array(dialect, columns): - assert isinstance(F.array_agg(dialect, columns["int"]), ArrayValueExpression) + assert isinstance(F.array_agg(dialect, columns["int"]), ArrayValue) # --------------------------------------------------------------------------- @@ -121,7 +160,7 @@ def test_collecting_into_an_array_gives_an_array(dialect, columns): @pytest.mark.parametrize("factory", ["row_number", "rank", "dense_rank"]) def test_a_rank_is_a_whole_number(dialect, factory): for windowed in (getattr(F, factory)(dialect),): - assert isinstance(windowed, IntegerValueExpression) + assert isinstance(windowed, NumericValue) assert hasattr(windowed, "__add__") assert not hasattr(windowed, "upper") @@ -142,13 +181,13 @@ def test_a_value_from_another_row_keeps_its_family(dialect, columns, factory, ex it had. Now the class says it, and the operations come with it. """ windowed = getattr(F, factory)(dialect, columns["str"], *extra) - assert isinstance(windowed, StringValueExpression) + assert isinstance(windowed, TextAnnotation) assert hasattr(windowed, "like") assert not hasattr(windowed, "sqrt") def test_lag_over_a_float_column_is_fractional(dialect, columns): - assert isinstance(F.lag(dialect, columns["float"]), NumericValueExpression) + assert isinstance(F.lag(dialect, columns["float"]), NumberAnnotation) # --------------------------------------------------------------------------- @@ -167,13 +206,13 @@ def test_a_typed_aggregate_reports_its_family_for_introspection(dialect, columns anyway. """ result = F.count(dialect, columns["int"]) - assert isinstance(result, IntegerValueExpression) + assert isinstance(result, CountFunction) def test_a_typed_window_reports_its_family_for_introspection(dialect, columns): """Same for a window function: the class, not the removed ``family`` tag.""" result = F.lag(dialect, columns["int"]) - assert isinstance(result, IntegerValueExpression) + assert isinstance(result, IntegerAnnotation) def test_an_untyped_aggregate_reports_no_family(dialect, columns): @@ -185,24 +224,18 @@ def test_an_untyped_aggregate_reports_no_family(dialect, columns): statement without the tag that could contradict it. """ result = FunctionCall(dialect, "COUNT", columns["int"], is_aggregate=True) - assert not isinstance(result, ArrayValueExpression) and not isinstance(result, BinaryValueExpression) and not isinstance(result, BooleanValueExpression) and not isinstance(result, DateValueExpression) and not isinstance(result, IntegerValueExpression) and not isinstance(result, IntervalValueExpression) and not isinstance(result, JSONValueExpression) and not isinstance(result, NumericValueExpression) and not isinstance(result, StringValueExpression) and not isinstance(result, TimeValueExpression) and not isinstance(result, TimestampValueExpression) and not isinstance(result, UUIDValueExpression) - - - -from rhosocial.activerecord.backend.expression.core import ( - NumericValueExpression, - IntegerValueExpression, - StringValueExpression, - BooleanValueExpression, - BinaryValueExpression, - UUIDValueExpression, - JSONValueExpression, - ArrayValueExpression, - TimestampValueExpression, - DateValueExpression, - TimeValueExpression, - IntervalValueExpression, -) + assert not isinstance( + result, + ( + ArrayValue, + BooleanValue, + JSONValue, + NumericValue, + OpaqueValue, + TemporalValue, + TextValue, + ), + ) def test_declaring_a_family_does_not_change_the_sql(dialect, columns): """Typing is a surface decision, never a rendering one.""" assert F.count(dialect, columns["int"]).to_sql()[0] == "COUNT(\"i\")" diff --git a/tests/rhosocial/activerecord_test/feature/query/test_boolean_connectives.py b/tests/rhosocial/activerecord_test/feature/query/test_boolean_connectives.py index b36a1ec81..e95002c9e 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_boolean_connectives.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_boolean_connectives.py @@ -4,7 +4,7 @@ ``p1 & p2`` combines predicates for a WHERE clause. A truth *value* needs the same three operators with a different result: ``(flag & other)`` must be something a SELECT list can carry, so it stays a boolean value -(:class:`BooleanLogicExpression`) with the same SQL. One implementation, +(:class:`LogicalValue`) with the same SQL. One implementation, dispatched by the receiver, which is what these tests pin. """ @@ -12,8 +12,8 @@ import pytest from rhosocial.activerecord.backend.expression import BooleanColumn -from rhosocial.activerecord.backend.expression.bases import SQLPredicate, SQLValueExpression -from rhosocial.activerecord.backend.expression.core import BooleanLogicExpression +from rhosocial.activerecord.backend.expression.bases import SQLPredicate, SQLValue +from rhosocial.activerecord.backend.expression.core import LogicalValue from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate @@ -29,8 +29,8 @@ def test_a_boolean_values_connectives_keep_the_value_type(dialect): other = BooleanColumn(dialect, "other") combined = flag & other - assert isinstance(combined, BooleanLogicExpression) - assert isinstance(combined, SQLValueExpression) + assert isinstance(combined, LogicalValue) + assert isinstance(combined, SQLValue) assert not isinstance(combined, SQLPredicate) sql, params = combined.to_sql() @@ -41,10 +41,10 @@ def test_a_value_can_be_negated_and_chained(dialect): flag = BooleanColumn(dialect, "flag") other = BooleanColumn(dialect, "other") - assert isinstance(~flag, BooleanLogicExpression) + assert isinstance(~flag, LogicalValue) chained = (flag & other) | flag - assert isinstance(chained, BooleanLogicExpression) + assert isinstance(chained, LogicalValue) assert "OR" in chained.to_sql()[0] @@ -55,7 +55,7 @@ def test_a_predicate_combination_stays_a_predicate(dialect): combined = flag.is_true() & other.is_true() assert isinstance(combined, LogicalPredicate) assert isinstance(combined, SQLPredicate) - assert not isinstance(combined, SQLValueExpression) + assert not isinstance(combined, SQLValue) assert isinstance(~flag.is_true(), LogicalPredicate) diff --git a/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py b/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py index 173324928..a837422c5 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py @@ -27,7 +27,7 @@ import enum import typing import uuid -from typing import Any, ClassVar, Dict, List, Optional, Type, Union +from typing import Any, ClassVar, Dict, List, Optional, Union import pytest diff --git a/tests/rhosocial/activerecord_test/feature/query/test_document_and_conversion_families.py b/tests/rhosocial/activerecord_test/feature/query/test_document_and_conversion_families.py index 5473924d2..f1375b2ff 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_document_and_conversion_families.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_document_and_conversion_families.py @@ -12,41 +12,51 @@ import pytest from rhosocial.activerecord.backend.expression import functions as F, FunctionCall, Literal, XMLColumn -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue +from rhosocial.activerecord.backend.expression.column_types import annotation_for +from rhosocial.activerecord.backend.expression.type_name import is_valid_type_name +from rhosocial.activerecord.backend.expression.types import ( + CustomType, + JsonType, + VarCharType, + XmlType, +) from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression -from rhosocial.activerecord.backend.expression.core import ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - DateValueExpression, - IntegerValueExpression, - IntervalValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - TimeValueExpression, - TimestampValueExpression, - UUIDValueExpression, - XMLValueExpression, +from rhosocial.activerecord.backend.expression.declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, +) +from rhosocial.activerecord.backend.expression.value_types import ( + ArrayValue, + JSONValue, + NumericValue, + TemporalValue, + TextValue, + XMLValue, ) #: Every value class the lattice holds. "No typed result" is asserted by saying #: the result is none of these, so the list has to be the whole set: an #: assertion that forgets a class would pass for the wrong reason. TYPED_VALUE_CLASSES = ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - DateValueExpression, - IntegerValueExpression, - IntervalValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - TimeValueExpression, - TimestampValueExpression, - UUIDValueExpression, - XMLValueExpression, + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, ) @@ -60,9 +70,6 @@ def is_typed_as_one_of_these(result): while the answer they are about became possible. """ return isinstance(result, TYPED_VALUE_CLASSES) -from rhosocial.activerecord.backend.expression.column_types import value_class_of -from rhosocial.activerecord.backend.expression.type_name import is_valid_type_name -from rhosocial.activerecord.backend.expression.types import VarCharType, JsonType, CustomType, XmlType @pytest.fixture @@ -93,22 +100,22 @@ def columns(dialect): def test_arrow_extraction_gives_a_document(dialect, columns): - assert isinstance(F.json_extract(dialect, columns["json"], Literal(dialect, "$.a")), JSONValueExpression) + assert isinstance(F.json_extract(dialect, columns["json"], Literal(dialect, "$.a")), JSONValue) def test_double_arrow_extraction_gives_text(dialect, columns): """`->>` is a narrowing: a document becomes a string and cannot widen back.""" - assert isinstance(F.json_extract_text(dialect, columns["json"], "$.a"), StringValueExpression) + assert isinstance(F.json_extract_text(dialect, columns["json"], "$.a"), TextValue) def test_a_built_document_is_a_document(dialect, columns): - assert isinstance(F.json_build_object(dialect, "k", 1), JSONValueExpression) - assert isinstance(F.json_array_elements(dialect, columns["json"]), JSONValueExpression) + assert isinstance(F.json_build_object(dialect, "k", 1), JSONValue) + assert isinstance(F.json_array_elements(dialect, columns["json"]), JSONValue) def test_a_document_offers_the_json_accessors(dialect, columns): built = F.json_build_object(dialect, "k", 1) - assert isinstance(built, JSONValueExpression) + assert isinstance(built, JSONValue) assert hasattr(built, "json_path") assert not hasattr(built, "upper") @@ -121,7 +128,7 @@ def test_text_reports_a_string_family(dialect, columns): over-offer on this one class, not a claim that text is a document. """ text = F.json_extract_text(dialect, columns["json"], "$.a") - assert isinstance(text, StringValueExpression) + assert isinstance(text, TextValue) assert text.to_sql()[0].endswith("->>'$.a'") @@ -131,12 +138,11 @@ def test_text_reports_a_string_family(dialect, columns): def test_a_sequence_is_a_sequence(dialect, columns): - assert isinstance(F.unnest(dialect, columns["array"]), ArrayValueExpression) - assert isinstance(F.unnest(dialect, columns["array"]), ArrayValueExpression) + assert isinstance(F.unnest(dialect, columns["array"]), ArrayValue) def test_measuring_a_sequence_gives_a_whole_number(dialect, columns): - assert isinstance(F.array_length(dialect, columns["array"], 1), IntegerValueExpression) + assert isinstance(F.array_length(dialect, columns["array"], 1), NumericValue) # --------------------------------------------------------------------------- @@ -145,7 +151,7 @@ def test_measuring_a_sequence_gives_a_whole_number(dialect, columns): def test_greatest_answers_with_what_its_arguments_answer_with(dialect, columns): - assert isinstance(F.greatest(dialect, columns["int"], columns["int"]), IntegerValueExpression) + assert isinstance(F.greatest(dialect, columns["int"], columns["int"]), IntegerAnnotation) def test_greatest_of_mixed_kinds_is_unknown(dialect, columns): @@ -155,11 +161,11 @@ def test_greatest_of_mixed_kinds_is_unknown(dialect, columns): def test_least_agrees_with_greatest(dialect, columns): - assert isinstance(F.least(dialect, columns["float"], columns["float"]), NumericValueExpression) + assert isinstance(F.least(dialect, columns["float"], columns["float"]), NumberAnnotation) def test_nullif_keeps_the_value_it_tests(dialect, columns): - assert isinstance(F.nullif(dialect, columns["str"], None), StringValueExpression) + assert isinstance(F.nullif(dialect, columns["str"], None), TextAnnotation) # --------------------------------------------------------------------------- @@ -179,7 +185,7 @@ def test_an_argument_without_a_family_does_not_break_a_consensus(dialect, column """A literal says nothing, so it neither agrees nor disagrees.""" with_literal = CaseExpression(dialect, cases=[(columns["int"], Literal(dialect, 1))], else_result=columns["int"]) - assert isinstance(with_literal, IntegerValueExpression) + assert isinstance(with_literal, NumericValue) # --------------------------------------------------------------------------- @@ -228,13 +234,13 @@ def test_an_unknown_sql_type_is_still_a_name(): def test_cast_takes_its_type_from_the_target_type(dialect, columns): - """The cast answers with the class of the type it was handed.""" + """The cast answers with the surface of the type it was handed.""" assert isinstance( F.cast(dialect, columns["int"], VarCharType(dialect, length=10)), - StringValueExpression, + TextValue, ) assert isinstance( - F.cast(dialect, columns["int"], JsonType(dialect)), JSONValueExpression + F.cast(dialect, columns["int"], JsonType(dialect)), JSONValue ) @@ -255,15 +261,26 @@ def test_an_unmodelled_target_type_yields_no_typed_result(dialect, columns): def test_to_char_gives_text(dialect, columns): - assert isinstance(F.to_char(dialect, columns["int"]), StringValueExpression) + assert isinstance(F.to_char(dialect, columns["int"]), TextValue) def test_to_number_gives_a_number(dialect, columns): - assert isinstance(F.to_number(dialect, columns["int"]), NumericValueExpression) + assert isinstance(F.to_number(dialect, columns["int"]), NumericValue) + +def test_to_date_gives_a_date(dialect, columns): + """``TO_DATE`` answers with a **date**, not a timestamp. -def test_to_date_gives_a_timestamp(dialect, columns): - assert isinstance(F.to_date(dialect, columns["int"]), TimestampValueExpression) + Every backend's ``TO_DATE`` produces a calendar date, so typing it as a + timestamp offered the time-of-day operations on a value that has no time of + day. Asserting the date here is what stops that coming back. + """ + node = F.to_date(dialect, columns["int"]) + assert isinstance(node, TemporalValue) + # A date and a date-time carry the same surface -- the operations are the + # same -- so "not a timestamp" is a statement about the node's class, which + # is where the distinction now lives. + assert type(node).__name__ == "ToDateFunction" # --------------------------------------------------------------------------- @@ -274,8 +291,12 @@ def test_to_date_gives_a_timestamp(dialect, columns): @pytest.mark.parametrize("factory", ["current_user", "session_user", "system_user"]) def test_the_session_identity_is_a_name(dialect, factory): result = getattr(F, factory)(dialect) - assert isinstance(result, StringValueExpression) + # What makes it a *name* is the surface it carries, not the class it is: + # a node is its own class now, so the old ``isinstance(result, + # StringValue)`` would be asserting the wrapper this family no longer has. + # Text answers text operations and refuses numeric ones. assert hasattr(result, "upper") + assert not hasattr(result, "sqrt") # --------------------------------------------------------------------------- @@ -289,7 +310,7 @@ def test_the_xml_document_nodes_are_xml_values(dialect): This used to assert the opposite -- that no class in the lattice covered XML -- because the value family was a tag in a closed table and no tag existed. The class is the statement now: every constructor that builds a - document derives from ``XMLValueExpression``, so ``isinstance`` answers + document derives from ``XMLValue``, so ``isinstance`` answers from the object. What the class carries is the XML surface — the null tests and casting, and no comparison (see the refusal test below). """ @@ -307,11 +328,11 @@ def test_the_xml_document_nodes_are_xml_values(dialect): X.XMLQueryExpression, ) other_families = tuple( - cls for cls in TYPED_VALUE_CLASSES if cls is not XMLValueExpression + cls for cls in TYPED_VALUE_CLASSES if cls is not XMLValue ) for cls in document_nodes: - assert issubclass(cls, XMLValueExpression), cls.__name__ - assert issubclass(cls, SQLValueExpression), cls.__name__ + assert issubclass(cls, XMLValue), cls.__name__ + assert issubclass(cls, SQLValue), cls.__name__ # It is an XML value and none of the other typed classes: a document # is not text that can be upper-cased, not JSON to navigate, not a # sequence to unnest. @@ -322,15 +343,15 @@ def test_xmlserialize_yields_the_character_value_it_names(dialect): """Serialization leaves the XML family: the result is the text asked for. ``XMLSERIALIZE(... AS VARCHAR(100))`` answers with a character value, so - the node declares ``StringValueExpression`` and the string operations + the node declares ``StringValue`` and the string operations follow it out of the XML surface. """ from rhosocial.activerecord.backend.expression import xml as X - assert issubclass(X.XMLSerializeExpression, StringValueExpression) + assert issubclass(X.XMLSerializeExpression, TextValue) serialized = F.xmlserialize(dialect, "", "VARCHAR(100)") - assert isinstance(serialized, StringValueExpression) - assert not isinstance(serialized, XMLValueExpression) + assert isinstance(serialized, TextValue) + assert not isinstance(serialized, XMLValue) def test_xmlexists_is_a_predicate_not_a_value(): @@ -338,22 +359,31 @@ def test_xmlexists_is_a_predicate_not_a_value(): from rhosocial.activerecord.backend.expression import xml as X from rhosocial.activerecord.backend.expression.bases import SQLPredicate - assert issubclass(X.XMLExistsExpression, SQLPredicate) - assert not issubclass(X.XMLExistsExpression, XMLValueExpression) + assert issubclass(X.XMLExistsPredicate, SQLPredicate) + assert not issubclass(X.XMLExistsPredicate, XMLValue) def test_a_cast_to_xml_is_an_xml_value(dialect, columns): """The cast takes its type from the target: ``CAST(x AS XML)`` is XML.""" result = F.cast(dialect, columns["str"], XmlType(dialect)) - assert isinstance(result, XMLValueExpression) - assert not isinstance(result, StringValueExpression) + assert isinstance(result, XMLValue) + assert not isinstance(result, TextValue) def test_a_function_that_declares_no_result_can_say_xml(dialect): - """``as_xml()`` is how a caller states what a generic call yields.""" + """``as_xml()`` is how a caller states what a generic call yields. + + It returns an *annotation*: the XML surface, and nothing else. The text + surface is not merely unused here, it is gone -- which is the point of + stating the type, since ``.like()`` on an XML document is not a question any + backend answers. The SQL is untouched: only the declared type moved. + """ + from rhosocial.activerecord.backend.expression.declared import XMLAnnotation + call = FunctionCall(dialect, "MYSTERY_FUNC") typed = call.as_xml() - assert isinstance(typed, XMLValueExpression) + assert isinstance(typed, XMLAnnotation) + assert not hasattr(typed, "like") assert typed.to_sql() == call.to_sql() @@ -400,25 +430,27 @@ def test_a_type_the_core_does_not_hold_is_reported_unknown(): name the core held no class for; a class cannot, because the operations it offers come with it. """ - class ExtensionValue(SQLValueExpression): + class ExtensionValue(SQLValue): # A name on the class, which is what declaring a type used to mean. It - # confers nothing: nothing reads it, and value_class_of answers from the + # confers nothing: nothing reads it, and annotation_for answers from the # MRO, where this class appears under no mapped name. declared_type = "network" instance = ExtensionValue.__new__(ExtensionValue) assert ExtensionValue.declared_type == "network" - assert value_class_of(instance) is None + assert annotation_for(instance) is None assert not is_typed_as_one_of_these(instance) -def test_every_typed_expression_declares_a_family_from_the_lattice(): +def test_every_typed_value_declares_a_family_from_the_lattice(): """Each result class states its own family, and it has to be one the lattice knows -- otherwise it offers operations nothing downstream expects. This replaced a test that every family but XML had a wrapper in a registry. There is no registry now: a factory constructs the class it means, so there - is no table left to be complete.""" + is no table left to be complete. The family is named by the ``Value`` + suffix (规范 4.9), and the scan follows that suffix -- so a rename that + moves the family fails here rather than quietly emptying the set.""" import inspect from rhosocial.activerecord.backend.expression import core @@ -426,22 +458,32 @@ def test_every_typed_expression_declares_a_family_from_the_lattice(): declared = { name: cls for name, cls in inspect.getmembers(core, inspect.isclass) - if name.endswith("ValueExpression") and name != "SQLValueExpression" + if name.endswith("Value") and name != "SQLValue" } - assert declared, "the typed expressions moved" - # Every typed expression declares its type by being a class, so the only + assert declared, "the typed values moved" + # Every typed value declares its type by being a class, so the only # thing left to check is that each one is still reachable and distinct. assert len(set(declared.values())) == len(declared), "two names bind one class" def test_every_temporal_type_is_reachable(): - """The four temporal classes exist and are distinct. + """The four temporal kinds exist and are distinct. - They were one class with a label saying which it was; now each is its own, - so this pins that they are all still there. + They were one class with a label saying which it was, then four classes, and + now four members of :class:`LiteralKind` -- one closed vocabulary rather than + one class per type. This pins that a timestamp, a date, a time and an + interval are still told apart, because ``datetime`` is a subclass of ``date`` + and matching them in the wrong order would fold the first three together. """ - from rhosocial.activerecord.backend.expression import core - for name in ("TimestampValueExpression", "DateValueExpression", - "TimeValueExpression", "IntervalValueExpression"): - assert hasattr(core, name), name + from rhosocial.activerecord.backend.expression.values import LiteralKind + + for name, sample in ( + ("TIMESTAMP", __import__("datetime").datetime(2026, 10, 18, 1, 2, 3)), + ("DATE", __import__("datetime").date(2026, 10, 18)), + ("TIME", __import__("datetime").time(1, 2, 3)), + ("INTERVAL", __import__("datetime").timedelta(days=1)), + ): + kind = LiteralKind[name] + assert LiteralKind.match(sample) is kind, name + assert kind.wire.endswith("_value"), name diff --git a/tests/rhosocial/activerecord_test/feature/query/test_operation_symmetry.py b/tests/rhosocial/activerecord_test/feature/query/test_operation_symmetry.py index 3b6e39ca5..59bf59e46 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_operation_symmetry.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_operation_symmetry.py @@ -34,17 +34,17 @@ UUIDColumn, XMLColumn, ) -from rhosocial.activerecord.backend.expression.core import ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - IntegerValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - TimestampValueExpression, - UUIDValueExpression, - XMLValueExpression, +from rhosocial.activerecord.backend.expression.declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TextAnnotation, + TemporalAnnotation, + UUIDAnnotation, + XMLAnnotation, ) from rhosocial.activerecord.backend.expression.mixins import NotANumberMixin @@ -67,6 +67,15 @@ _REFUSALS = {name for name, value in vars(NotANumberMixin).items() if callable(value)} +#: Not operations on a value, so not part of what "the same operations" means. +#: ``collate`` and ``validate`` are a column's storage; ``get_params`` belongs to +#: the artifact rather than to the type; ``surface`` is the declaration of +#: which operations a node carries, which is the thing being compared here and so +#: cannot also be one of the things compared; and ``kind`` is a node's identity +#: on the wire (§4.9.2) -- defined once on the expression base, which an +#: annotation is not. +_NOT_OPERATIONS = {"collate", "validate", "get_params", "value_type_of", "kind"} + def _operations(cls): """The operation names *cls* offers: public callables + operator dunders.""" @@ -76,20 +85,20 @@ def _operations(cls): continue if callable(getattr(cls, name, None)): names.add(name) - return names - _REFUSALS + return names - _REFUSALS - _NOT_OPERATIONS FAMILIES = [ - (StringColumn, StringValueExpression), - (NumericColumn, NumericValueExpression), - (IntegerColumn, IntegerValueExpression), - (BooleanColumn, BooleanValueExpression), - (TimestampColumn, TimestampValueExpression), - (BinaryColumn, BinaryValueExpression), - (UUIDColumn, UUIDValueExpression), - (JSONColumn, JSONValueExpression), - (ArrayColumn, ArrayValueExpression), - (XMLColumn, XMLValueExpression), + (StringColumn, TextAnnotation), + (NumericColumn, NumberAnnotation), + (IntegerColumn, IntegerAnnotation), + (BooleanColumn, BooleanAnnotation), + (TimestampColumn, TemporalAnnotation), + (BinaryColumn, BinaryAnnotation), + (UUIDColumn, UUIDAnnotation), + (JSONColumn, JSONAnnotation), + (ArrayColumn, ArrayAnnotation), + (XMLColumn, XMLAnnotation), ] diff --git a/tests/rhosocial/activerecord_test/feature/query/test_string_concatenation_operator.py b/tests/rhosocial/activerecord_test/feature/query/test_string_concatenation_operator.py index 96ec5bfac..1222f111c 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_string_concatenation_operator.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_string_concatenation_operator.py @@ -20,7 +20,7 @@ from rhosocial.activerecord.backend.expression import functions as string_functions from rhosocial.activerecord.backend.expression.operators import ( BinaryExpression, - StringConcatExpression, + ConcatenationOperation, ) @@ -55,25 +55,31 @@ def operands(dialect): def test_concat_op_builds_an_intent_node(dialect, operands): left, right = operands expr = string_functions.concat_op(dialect, left, right) - assert isinstance(expr, StringConcatExpression) + assert isinstance(expr, ConcatenationOperation) -def test_intent_node_is_a_binary_expression(dialect, operands): - """Existing BinaryExpression handling must keep working.""" +def test_the_intent_node_is_not_a_bare_binary_expression(dialect, operands): + """The node is its own kind, not a BinaryExpression with a token. + + A bare BinaryExpression with op ``||`` says nothing about intent, which is + exactly why concat_op stopped producing one. + """ left, right = operands expr = string_functions.concat_op(dialect, left, right) - assert isinstance(expr, BinaryExpression) + assert not isinstance(expr, BinaryExpression) assert expr.left is left and expr.right is right -def test_the_operator_is_not_chosen_by_the_expression(dialect, operands): - """``op`` is fixed, but it is the *node type* that carries the meaning. +def test_the_operator_is_not_a_parameter(dialect, operands): + """Concatenation carries no ``op``: the kind is the intent. - A bare BinaryExpression with op ``||`` says nothing about intent, which is - exactly why concat_op stopped producing one. + ``left`` and ``right`` are the node's only construction parameters, so + nothing about the operator token travels in the artifact. """ left, right = operands - assert StringConcatExpression(dialect, left, right).op == "||" + expr = ConcatenationOperation(dialect, left, right) + assert not hasattr(expr, "op") + assert set(expr.get_params()) == {"left", "right"} # --------------------------------------------------------------------------- @@ -154,5 +160,5 @@ def test_logical_or_does_not_go_through_concatenation(operands): """OR has its own node and formatter, so the two never meet.""" left, _ = operands predicate = left.like("a%") | left.like("b%") - assert not isinstance(predicate, StringConcatExpression) + assert not isinstance(predicate, ConcatenationOperation) assert " OR " in predicate.to_sql()[0] diff --git a/tests/rhosocial/activerecord_test/feature/query/test_string_value_operations.py b/tests/rhosocial/activerecord_test/feature/query/test_string_value_operations.py index 06f0ac600..e226e9872 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_string_value_operations.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_string_value_operations.py @@ -17,9 +17,11 @@ # tests/rhosocial/activerecord_test/feature/query/test_string_value_operations.py import pytest +from rhosocial.activerecord.backend.expression.value_types import TextValue + from rhosocial.activerecord.backend.expression import ( LikePredicate, - StringValueExpression, + UpperFunction, build_json_path, ) from rhosocial.activerecord.backend.expression import functions as string_functions @@ -80,8 +82,8 @@ def test_string_operation_renders_its_function( def test_string_operations_return_a_typed_string_value(name): - """The result must be a string value, not a bare function call.""" - assert isinstance(name.upper(), StringValueExpression) + """The result must be a string-valued node, not a bare function call.""" + assert isinstance(name.upper(), UpperFunction) # --------------------------------------------------------------------------- @@ -155,7 +157,7 @@ def test_predicate_still_combines(name): def test_numeric_result_is_not_a_string_value(dialect, name, factory): """``LENGTH(x) LIKE '3'`` is a type error; the surface must not offer it.""" result = getattr(string_functions, factory)(dialect, name) - assert not isinstance(result, StringValueExpression) + assert not isinstance(result, TextValue) @pytest.mark.parametrize( @@ -173,7 +175,7 @@ def test_string_column_offers_them_but_the_result_is_not_a_string(name, method, """ assert hasattr(name, method), f"StringColumn lost a legal operation {method}()" result = getattr(name, method)(*args) - assert not isinstance(result, StringValueExpression) + assert not isinstance(result, TextValue) # --------------------------------------------------------------------------- @@ -182,9 +184,9 @@ def test_string_column_offers_them_but_the_result_is_not_a_string(name, method, def test_free_functions_still_work_and_are_now_typed(dialect, name): - """Existing call sites keep working; they gain the string type.""" + """Existing call sites keep working; they gain a string-valued node.""" result = string_functions.upper(dialect, name) - assert isinstance(result, StringValueExpression) + assert isinstance(result, UpperFunction) assert result.like("A%").to_sql()[0].endswith("LIKE ?") diff --git a/tests/rhosocial/activerecord_test/feature/query/test_temporal_value_families.py b/tests/rhosocial/activerecord_test/feature/query/test_temporal_value_families.py index 936d79ccb..c66e97c35 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_temporal_value_families.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_temporal_value_families.py @@ -16,19 +16,15 @@ import pytest from rhosocial.activerecord.backend.expression import functions as datetime_functions -from rhosocial.activerecord.backend.expression.core import ( - DateValueExpression, - TimeValueExpression, - TimestampValueExpression, - IntervalValueExpression, - NumericValueExpression, - IntegerValueExpression, +from rhosocial.activerecord.backend.expression.value_types import ( + NumericValue, + TemporalValue, ) from rhosocial.activerecord.backend.expression.datetime import ( - DateTimeAddExpression, - DateTimeSubtractExpression, + DatetimeAddExpression, + DatetimeSubtractExpression, ) -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation @pytest.fixture @@ -55,11 +51,11 @@ def timestamp(dialect): @pytest.mark.parametrize( "factory, cls", [ - ("now", TimestampValueExpression), - ("current_timestamp", TimestampValueExpression), - ("localtimestamp", TimestampValueExpression), - ("current_date", DateValueExpression), - ("current_time", TimeValueExpression), + ("now", TemporalValue), + ("current_timestamp", TemporalValue), + ("localtimestamp", TemporalValue), + ("current_date", TemporalValue), + ("current_time", TemporalValue), ], ) def test_the_present_keeps_its_precision(dialect, factory, cls): @@ -81,7 +77,15 @@ def test_the_present_keeps_its_precision(dialect, factory, cls): "factory", ["year", "month", "day", "hour", "minute", "second"] ) def test_a_component_is_a_whole_number(dialect, timestamp, factory): - assert isinstance(getattr(datetime_functions, factory)(dialect, timestamp), IntegerValueExpression) + """A calendar component is a whole number on every backend. + + Asserted on the node's own class: the integral node is a distinct class from + the fractional one precisely because ``EXTRACT(EPOCH FROM ts)`` is not whole, + and that distinction now lives in the node rather than in a value class whose + operations were identical anyway. + """ + node = getattr(datetime_functions, factory)(dialect, timestamp) + assert type(node).__name__ == "ExtractFieldExpression" def test_a_component_takes_arithmetic_and_not_string_operations(dialect, timestamp): @@ -102,12 +106,12 @@ def test_extraction_is_numeric(dialect, timestamp, factory): """``EXTRACT(EPOCH FROM ts)`` is fractional, so the family is too wide to be a whole number. Widening here is deliberate: claiming integer would offer ``bit_length`` on a value that may be a million and a half.""" - assert isinstance(getattr(datetime_functions, factory)(dialect, "year", timestamp), NumericValueExpression) + assert isinstance(getattr(datetime_functions, factory)(dialect, "year", timestamp), NumericValue) def test_date_diff_counts_whole_units(dialect): now = datetime_functions.now(dialect) - assert isinstance(now.date_diff("day", now), IntegerValueExpression) + assert isinstance(now.date_diff("day", now), NumericValue) # --------------------------------------------------------------------------- @@ -116,20 +120,20 @@ def test_date_diff_counts_whole_units(dialect): def test_truncation_stays_a_timestamp(dialect): - assert isinstance(datetime_functions.now(dialect).date_trunc("day"), TimestampValueExpression) + assert isinstance(datetime_functions.now(dialect).date_trunc("day"), TemporalValue) @pytest.mark.parametrize("method", ["date_add", "date_sub"]) def test_shifting_time_stays_a_timestamp(dialect, method): now = datetime_functions.now(dialect) result = getattr(now, method)(1, "days") - assert isinstance(result, TimestampValueExpression) - assert isinstance(result, (DateTimeAddExpression, DateTimeSubtractExpression)) + assert isinstance(result, TemporalValue) + assert isinstance(result, (DatetimeAddExpression, DatetimeSubtractExpression)) def test_an_interval_is_its_own_family(dialect): """A span is not a point in time, which is why date_add takes one.""" - assert isinstance(datetime_functions.interval(dialect, 1, "day"), IntervalValueExpression) + assert isinstance(datetime_functions.interval(dialect, 1, "day"), TemporalValue) # --------------------------------------------------------------------------- @@ -144,7 +148,7 @@ def test_a_timestamp_moves_by_an_interval(dialect): now = datetime_functions.now(dialect) one_day = datetime_functions.interval(dialect, 1, "day") expression = now + one_day - assert isinstance(expression, BinaryArithmeticExpression) + assert isinstance(expression, ArithmeticOperation) def test_two_points_subtract_to_an_interval(dialect): @@ -164,7 +168,7 @@ def test_interval_arithmetic_stays_between_intervals(dialect): one_day = datetime_functions.interval(dialect, 1, "day") two_days = datetime_functions.interval(dialect, 2, "day") for expression in (one_day + two_days, one_day - two_days, one_day * 2): - assert isinstance(expression, BinaryArithmeticExpression) + assert isinstance(expression, ArithmeticOperation) def test_a_number_does_not_move_a_point_in_time(dialect): @@ -183,8 +187,8 @@ def test_a_temporal_column_follows_the_same_gate(dialect, timestamp): """The column and the value are the same expression layer, so the gate has to see a temporal *column* as a temporal operand too.""" one_day = datetime_functions.interval(dialect, 1, "day") - assert isinstance(timestamp + one_day, BinaryArithmeticExpression) - assert isinstance(timestamp - timestamp, BinaryArithmeticExpression) + assert isinstance(timestamp + one_day, ArithmeticOperation) + assert isinstance(timestamp - timestamp, ArithmeticOperation) with pytest.raises(AttributeError, match="no sum"): timestamp + timestamp @@ -197,10 +201,10 @@ def test_a_temporal_column_follows_the_same_gate(dialect, timestamp): @pytest.mark.parametrize( "factory, args, result_cls", [ - ("extract", ("year",), NumericValueExpression), - ("date_part", ("year",), NumericValueExpression), - ("interval", (1, "day"), IntervalValueExpression), - ("date_diff", ("day",), IntegerValueExpression), + ("extract", ("year",), NumericValue), + ("date_part", ("year",), NumericValue), + ("interval", (1, "day"), TemporalValue), + ("date_diff", ("day",), NumericValue), ], ) def test_a_temporal_operation_yields_its_own_type(dialect, timestamp, factory, args, result_cls): @@ -230,32 +234,48 @@ def test_a_temporal_value_does_not_offer_string_operations(dialect): @pytest.mark.parametrize( - "cls", - [TimestampValueExpression, DateValueExpression, TimeValueExpression, - IntervalValueExpression], + "factory", + ["now", "current_date", "current_time", "current_timestamp", "localtimestamp"], ) -def test_each_temporal_type_is_its_own_class(dialect, cls): - """They offer the same operations, so they share a base -- but they are four - different types, and each says so with its own class.""" - from rhosocial.activerecord.backend.expression import FunctionCall - from rhosocial.activerecord.backend.expression.core import TemporalValueExpression +def test_each_temporal_value_is_its_own_node(dialect, factory): + """They offer the same operations, so they share a surface -- but they are + four different types, and each says so with its own class. + + A date and a date-time used to be one class with a label saying which it + was. Now the node is the statement, so this pins that a point in time is + still distinguishable from a span and from the other points by the class + the factory chose. + """ + node = getattr(datetime_functions, factory)(dialect) + assert isinstance(node, TemporalValue) + assert hasattr(node, "date_add") + + +def test_an_interval_is_a_span_and_not_a_point(dialect): + """The distinction the temporal gate asks about, stated by the node. + + ``_is_interval_value`` used to ask about a class nothing ever constructed, + which made the whole gate unreachable. The marker it asks about now is one + the interval node carries. + """ + from rhosocial.activerecord.backend.expression.mixins import IntervalMixin - wrapped = cls(dialect, FunctionCall(dialect, "SOME_FUNC")) - assert isinstance(wrapped, cls) - assert isinstance(wrapped, TemporalValueExpression) - assert hasattr(wrapped, "date_add") + interval = datetime_functions.interval(dialect, 1, "day") + assert isinstance(interval, IntervalMixin) + assert isinstance(interval, TemporalValue) + assert not isinstance(datetime_functions.now(dialect), IntervalMixin) -def test_the_sql_does_not_change_when_a_value_is_wrapped(dialect, timestamp): +def test_the_sql_does_not_change_when_a_type_is_stated(dialect, timestamp): """Typing is a surface decision, never a rendering one.""" from rhosocial.activerecord.backend.expression import FunctionCall call = FunctionCall(dialect, "MY_FUNC", timestamp) - assert TimestampValueExpression(dialect, call).to_sql() == call.to_sql() + assert call.as_datetime().to_sql() == call.to_sql() -def test_an_alias_survives_wrapping(dialect, timestamp): +def test_an_alias_survives_a_stated_type(dialect, timestamp): from rhosocial.activerecord.backend.expression import FunctionCall call = FunctionCall(dialect, "MY_FUNC", timestamp) - assert " AS " in TimestampValueExpression(dialect, call).as_("when").to_sql()[0] + assert " AS " in call.as_datetime().as_("when").to_sql()[0] diff --git a/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py b/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py index 4a4289517..fc3a80c86 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py @@ -165,7 +165,15 @@ def test_string_column_has_like_but_no_arithmetic(): col = build_column(sqlite_dialect(), "name", str) assert isinstance(col, StringColumn) assert hasattr(col, "like") and hasattr(col, "ilike") - assert not hasattr(col, "__add__") + + # Arithmetic is refused explicitly rather than left absent: the operator is + # looked up on the type by the interpreter, so a missing one produces + # CPython's own TypeError instead of the package's message. hasattr is true + # because the method exists to raise, which is why the check calls it. + with pytest.raises(AttributeError): + col + 1 + with pytest.raises(AttributeError): + col * 2 def test_integer_column_has_arithmetic_but_no_like(): @@ -188,8 +196,9 @@ def test_uuid_column_offers_no_uuid_specific_operator(): col = build_column(sqlite_dialect(), "uid", uuid.UUID) assert isinstance(col, UUIDColumn) assert not hasattr(col, "like") - assert not hasattr(col, "__add__") assert not hasattr(col, "json_path") + with pytest.raises(AttributeError): + col + 1 def test_bare_column_offers_no_operation(): diff --git a/tests/rhosocial/activerecord_test/feature/query/test_value_family_inference.py b/tests/rhosocial/activerecord_test/feature/query/test_value_family_inference.py index e572e85db..c3c96ebe1 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_value_family_inference.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_value_family_inference.py @@ -14,47 +14,38 @@ # tests/rhosocial/activerecord_test/feature/query/test_value_family_inference.py import datetime -import decimal import uuid import pytest -from rhosocial.activerecord.backend.expression import Column, FunctionCall, Literal +from rhosocial.activerecord.backend.expression import Column, FunctionCall from rhosocial.activerecord.backend.expression import functions as F -from rhosocial.activerecord.backend.expression import IntegerColumn, NumericColumn -from rhosocial.activerecord.backend.expression.column_types import value_class_of -from rhosocial.activerecord.backend.expression.core import ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - DateValueExpression, - IntegerValueExpression, - IntervalValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - TimeValueExpression, - TimestampValueExpression, - UUIDValueExpression, - XMLValueExpression, +from rhosocial.activerecord.backend.expression.column_types import annotation_for +from rhosocial.activerecord.backend.expression.declared import ( + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, ) -from rhosocial.activerecord.backend.expression import functions as math_functions #: Every value class the lattice holds, for the "declares nothing" assertions. TYPED_VALUE_CLASSES = ( - ArrayValueExpression, - BinaryValueExpression, - BooleanValueExpression, - DateValueExpression, - IntegerValueExpression, - IntervalValueExpression, - JSONValueExpression, - NumericValueExpression, - StringValueExpression, - TimeValueExpression, - TimestampValueExpression, - UUIDValueExpression, - XMLValueExpression, + ArrayAnnotation, + BinaryAnnotation, + BooleanAnnotation, + IntegerAnnotation, + JSONAnnotation, + NumberAnnotation, + TemporalAnnotation, + TextAnnotation, + UUIDAnnotation, + XMLAnnotation, ) @@ -88,12 +79,12 @@ def columns(dialect): # --------------------------------------------------------------------------- -def test_every_column_class_has_a_value_class(columns): +def test_every_column_class_has_an_annotation(columns): """The map is complete, so no column class is silently untyped. There is no registry of family *names* to be complete any more -- a type is a class. But there is a table from column class to value class (see - ``column_types.value_class_of``), and a column class missing from it would + ``column_types.annotation_for``), and a column class missing from it would make every operation over that column quietly untyped, which is the failure this file exists to catch. So it is checked here instead of assumed. """ @@ -111,7 +102,7 @@ def test_every_column_class_has_a_value_class(columns): assert declared, "the typed column classes moved" for name in sorted(declared): column = getattr(CT, name).__new__(getattr(CT, name)) - assert value_class_of(column) is not None, f"{name} has no value class" + assert annotation_for(column) is not None, f"{name} has no annotation" # The base is now an ABC, so there is no "untyped column object" left to # map -- not being able to build one is the stronger form of the same # invariant. The untyped column that *can* be built is the bare ``Column``, @@ -119,16 +110,16 @@ def test_every_column_class_has_a_value_class(columns): assert inspect.isabstract(CT.ColumnBase), "the typed base must not be instantiable" from rhosocial.activerecord.backend.expression import Column as BareColumn - assert value_class_of(BareColumn.__new__(BareColumn)) is None + assert annotation_for(BareColumn.__new__(BareColumn)) is None -def test_every_core_data_type_has_a_value_class_or_is_explicitly_impossible(): +def test_every_core_data_type_has_an_annotation_or_is_explicitly_impossible(): """The DDL-type -> value-class map is closed, so a cast cannot go silently untyped. There is a second completeness question beside the column one above, and it is asked of the *target* side of a cast: ``cast`` answers from the class of - the ``DataType`` it was handed (``type_conversion._type_value_classes()``), + the ``DataType`` it was handed (``type_conversion._type_annotations()``), so a core type missing from that table would make every cast to it come back untyped and the operations its value supports invisible. A type added to the core without a map entry must therefore be a test failure here, not @@ -147,7 +138,7 @@ def test_every_core_data_type_has_a_value_class_or_is_explicitly_impossible(): from rhosocial.activerecord.backend.expression import types as core_types from rhosocial.activerecord.backend.expression.functions.type_conversion import ( - _type_value_classes, + _type_annotations, ) core_prefix = "rhosocial.activerecord.backend.expression.types" @@ -168,7 +159,7 @@ def test_every_core_data_type_has_a_value_class_or_is_explicitly_impossible(): } assert declared, "the core types moved" - mapping = _type_value_classes() + mapping = _type_annotations() # The exception, stated rather than merely skipped. ``CustomType`` is the # escape hatch for a type nobody here has heard of; mapping it to any value # class would offer operations the database may reject. Asserting it stays @@ -184,8 +175,8 @@ def test_every_core_data_type_has_a_value_class_or_is_explicitly_impossible(): if klass is core_types.CustomType: continue assert any(base in mapping for base in klass.__mro__), ( - f"{name} has no value class: add it to " - f"functions/type_conversion._type_value_classes(), or add it to " + f"{name} has no annotation: add it to " + f"functions/type_conversion._type_annotations(), or add it to " f"the deliberate-exception list here with the reason it cannot " f"honestly have one" ) @@ -205,24 +196,24 @@ def test_a_hand_written_column_has_no_family(dialect): @pytest.mark.parametrize( "key, expected", [ - ("string", StringValueExpression), - ("int", IntegerValueExpression), - ("float", NumericValueExpression), - ("bool", BooleanValueExpression), - ("json", JSONValueExpression), - ("bytes", BinaryValueExpression), - ("uuid", UUIDValueExpression), - ("datetime", TimestampValueExpression), + ("string", TextAnnotation), + ("int", IntegerAnnotation), + ("float", NumberAnnotation), + ("bool", BooleanAnnotation), + ("json", JSONAnnotation), + ("bytes", BinaryAnnotation), + ("uuid", UUIDAnnotation), + ("datetime", TemporalAnnotation), ], ) def test_each_annotation_lands_in_its_type(columns, key, expected): - """The annotation picks the column class, and the class picks the value class. + """The annotation picks the column class, and the class picks the annotation. This used to compare against a family *name* read off an attribute. The attribute is gone and the class is the whole of it, so the assertion is the - class an operation over that column now returns. + annotation an operation over that column now returns. """ - assert value_class_of(columns[key]) is expected + assert annotation_for(columns[key]) is expected def test_a_disagreement_yields_no_type(dialect, columns): diff --git a/tests/rhosocial/activerecord_test/feature/query/test_value_type_lattice.py b/tests/rhosocial/activerecord_test/feature/query/test_value_type_lattice.py index 72680c8a7..20c01d49a 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_value_type_lattice.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_value_type_lattice.py @@ -16,7 +16,7 @@ import pytest from rhosocial.activerecord.backend.expression import ( - IntegerValueExpression, + CharLengthFunction, ) @@ -55,9 +55,19 @@ def name(dialect): ids=["length", "ascii", "octet_length", "bit_length", "strpos", "position"], ) def test_number_returning_operation_yields_an_integer(name, method, args, function): - """These read a string and produce a count or a code.""" + """These read a string and produce a count or a code. + + Each is now a node of its own -- ``CharLengthFunction``, + ``AsciiFunction``, ``OctetLengthFunction``, ``BitLengthFunction``, + ``PositionExpression`` -- so what the six share is asserted as the + **surface** rather than as a class: arithmetic is available and the string + surface is not. Asserting ``isinstance(result, IntegerAnnotation)`` would pin + the wrapper these operations used to be returned in, which is exactly what + the per-function classes removed. + """ result = getattr(name, method)(*args) - assert isinstance(result, IntegerValueExpression) + assert hasattr(result, "__add__") + assert not hasattr(result, "upper") assert function in result.to_sql()[0] @@ -80,15 +90,22 @@ def test_the_whole_string_surface_offers_them(name): #: test can call them all the same way. `substr` also takes an optional length #: and `trim` optional characters and a direction; passing the minimum is #: enough to reach the return, which is what is under test here. +#: +#: The placeholders are typed, not a repeated ``"x"``. Positions and counts are +#: integers and text operands are strings, and a call that got them wrong used +#: to pass silently -- a literal carrier accepted any value -- which is how +#: ``substr("x")`` came to mean "substring starting one character in" nowhere. +#: The typed value nodes now refuse it, which is the point of them. _REQUIRED_ARGS = { - "upper": 0, "lower": 0, "trim": 0, "initcap": 0, - "substr": 1, "left": 1, "right": 1, "concat": 1, - "replace": 2, + "upper": [], "lower": [], "trim": [], "initcap": [], + "substr": [1], "left": [1], "right": [1], + "concat": ["x"], + "replace": ["a", "b"], } def _call(name, method): - return getattr(name, method)(*(["x"] * _REQUIRED_ARGS[method])) + return getattr(name, method)(*_REQUIRED_ARGS[method]) @pytest.mark.parametrize( @@ -98,7 +115,7 @@ def test_string_result_keeps_the_string_surface(name, method): """The result is a string and *says* so by offering the next operation. What is asserted is the surface, not one class: most of these return a - StringValueExpression, but a node with its own dialect hook -- TRIM, whose + StringValue, but a node with its own dialect hook -- TRIM, whose standard spelling is a syntax error on SQLite -- is its own class and carries the same mixins, which is what makes ``col.trim().upper()`` legal. """ @@ -147,7 +164,7 @@ def test_number_result_keeps_integer_operations(name): def test_string_then_number(name): """upper() is a string, length() of it is a number.""" result = name.upper().length() - assert isinstance(result, IntegerValueExpression) + assert isinstance(result, CharLengthFunction) assert "LENGTH" in result.to_sql()[0] and "UPPER" in result.to_sql()[0] diff --git a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py index 722cbcf68..1c6c12b5a 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_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, 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( diff --git a/tests/rhosocial/activerecord_test/feature/relation/test_relation_validation.py b/tests/rhosocial/activerecord_test/feature/relation/test_relation_validation.py index c8e98903e..8a81a6e9f 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/test_relation_validation.py +++ b/tests/rhosocial/activerecord_test/feature/relation/test_relation_validation.py @@ -31,10 +31,10 @@ def __init__(self): self.join_clauses = [] self.limit_count = None self.offset_count = None - from rhosocial.activerecord.backend.expression import WildcardExpression + from rhosocial.activerecord.backend.expression import AllColumnsWildcard from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - self.select_columns = [WildcardExpression(SQLiteDialect())] + self.select_columns = [AllColumnsWildcard(SQLiteDialect())] self._explain_enabled = False self._explain_options = None diff --git a/tests/rhosocial/activerecord_test/feature/relation/test_with_modifier.py b/tests/rhosocial/activerecord_test/feature/relation/test_with_modifier.py index 78cd65e34..a8c43ae6f 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/test_with_modifier.py +++ b/tests/rhosocial/activerecord_test/feature/relation/test_with_modifier.py @@ -29,10 +29,10 @@ def __init__(self): self.join_clauses = [] self.limit_count = None self.offset_count = None - from rhosocial.activerecord.backend.expression import WildcardExpression + from rhosocial.activerecord.backend.expression import AllColumnsWildcard from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect - self.select_columns = [WildcardExpression(SQLiteDialect())] + self.select_columns = [AllColumnsWildcard(SQLiteDialect())] self._explain_enabled = False self._explain_options = None