diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36306e6..0190425 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -145,7 +145,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -235,7 +235,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -325,7 +325,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -415,7 +415,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -505,7 +505,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -596,7 +596,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -686,7 +686,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -785,7 +785,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | diff --git a/README.md b/README.md index 93eb8a3..261c290 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,14 @@ oldest release carry no gate and are omitted. - **No native arrays, and no JSON *type*.** JSON is reachable through functions (`JSON_VALUE`, `JSON_QUERY`) on 2016+, not through a column type, and `supports_json_type` gates on that. -- **Table partitioning is not implemented.** `add_partition`, - `attach_partition`, `detach_partition`, `list_table_partitioning`, - `range_table_partitioning`, `hash_table_partitioning` and - `subpartitioning` are all `False` at every probed version. A model does - not take a `__partition__` attribute. +- **Table partitioning is metadata-only.** Partition functions, + schemes and aligned indexes are introspectable through + `introspect partitions` / `list_partitions`, but partition *lifecycle* + (`add_partition`, `attach_partition`, `detach_partition`, + `list_table_partitioning`, `range_table_partitioning`, + `hash_table_partitioning`, `subpartitioning`) is not implemented: those + switches are all `False` at every probed version and a model does not take + a `__partition__` attribute. - Also unsupported: `alter_type`, `constraint_enforced`, `create_or_replace_view`, `deferrable_*`, `fetch_with_ties`, `fk_match`, `returning_alias`, `returning_wildcard`, `set_type`, `transaction_mode`. diff --git a/changelog.d/12.added.md b/changelog.d/12.added.md new file mode 100644 index 0000000..ce71150 --- /dev/null +++ b/changelog.d/12.added.md @@ -0,0 +1 @@ +Added SQL Server partitions, user-defined types and constraint introspection. diff --git a/docs/introspection.md b/docs/introspection.md index 1ec6710..dde5ad4 100644 --- a/docs/introspection.md +++ b/docs/introspection.md @@ -12,7 +12,9 @@ The contract those answers fill is core's, and this page does not restate it: th - **Indexes**: name, uniqueness, primary-key flag, type and column order, from `sys.indexes` and `sys.index_columns` - **Foreign keys**: resolved column and table names, update/delete actions - **Views and triggers**: definitions, updatability, firing events, `INSTEAD OF` flag -- **This backend's own families**: schemas, extended properties, procedures, functions and sequences, plus the primary-key read +- **This backend's own families**: schemas, extended properties, procedures, + functions and sequences, plus partitions, user-defined types and + constraints, and the primary-key read - **Operational answers**: unused indexes, per-index counters, instance counters and by-name dependency edges; two families refused - **Data answers**: per-column profiles, value listings and join coverage, all aggregated over the rows @@ -41,10 +43,25 @@ Every question in the introspection contract has a probe on the dialect, and a p | `supports_column_data_profile()` | `True` | What each column of a table holds | | `supports_column_value_sample()` | `True` from 2012 | Which values occur, and how often | | `supports_join_coverage()` | `True` | How much two columns' domains overlap | -| `supports_partition_info()` | `False` | Core's partition family | +| `supports_partition_info()` | `True` from 2008 | Partition functions, schemes and aligned indexes | | `supports_extensions()` | `False` | Installed add-on components | -Five families beyond core's shared set are this backend's own, and each is declared in the mixin that owns it: `supports_extended_property_introspection()` for the `extended-properties` family over `sys.extended_properties`, plus `supports_schema_introspection()`, `supports_procedure_introspection()`, `supports_function_introspection()` and `supports_sequence_introspection()`. `get_primary_key()` is core's method, answered from `list_indexes()`'s `is_primary` flag; the primary-key statement is also what `list_columns()` runs first, so each column's `is_primary_key` is filled. `get_supported_introspection_scopes()` returns database, table, column, index, foreign key, view, trigger and sequence — core's `SCHEMA` scope is not among them, and extended properties and routines have no scope of their own. +Eight families are this backend's own or newly answered, and each is declared +in the mixin that owns it: `supports_extended_property_introspection()` for the +`extended-properties` family over `sys.extended_properties`, plus +`supports_schema_introspection()`, `supports_procedure_introspection()`, +`supports_function_introspection()` and `supports_sequence_introspection()`. +The partition family also has the backend-local spelling +`supports_partition_metadata_introspection()`, and the two new families are +`supports_type_introspection()` -- 2008 (10.0) and up, where +`sys.table_types` arrived -- and `supports_constraint_introspection()`. +`get_primary_key()` is core's method, answered from `list_indexes()`'s +`is_primary` flag; the primary-key statement is also what `list_columns()` +runs first, so each column's `is_primary_key` is filled. +`get_supported_introspection_scopes()` returns database, table, column, index, +foreign key, view, trigger and sequence — core's `SCHEMA` scope is not among +them, and extended properties, routines, partitions, types and constraints +have no scope of their own. A `True` promises only that the query renders and the server accepts it; it says nothing about the shape of the answer or its price, and the price of every family is stated where the family is described. @@ -54,7 +71,12 @@ A `True` promises only that the query renders and the server accepts it; it says `supports_unused_indexes_detection()`, `supports_index_stats()` and `supports_runtime_stats()` are the 2005 (9.0) floor: the three dynamic management views arrived there. `supports_object_dependencies()` is the 2008 (10.0) floor, where `sys.sql_expression_dependencies` arrived. `supports_column_value_sample()` is the 2012 (11.0) floor, because its cap rides `OFFSET 0 ROWS FETCH NEXT n ROWS ONLY`, which the engine parses only from that release — asking the version accessor is a truer answer than a statement that fails to parse. -`supports_partition_info()` and `supports_extensions()` stay `False` by inheritance. Core's partition family has no method on this backend to oblige, and no catalogue is mapped for installable add-on components, so there is no answer behind a `True`. +`supports_partition_info()` answers `True` from SQL Server 2008 (10.0), the +floor the partition mixin already used for table partitioning; the family is +answered from `sys.partition_functions`, `sys.partition_schemes` and the +indexes aligned to a scheme. `supports_extensions()` stays `False` by +inheritance: no catalogue is mapped for installable add-on components, so +there is no answer behind a `True`. ## Basic Usage @@ -100,6 +122,15 @@ for property_ in introspector.list_extended_properties( for routine in introspector.list_functions(): print(routine.name, routine.type_code) + +for partition in introspector.list_partitions("orders"): + print(partition.name, partition.range_direction, partition.boundary_values) + +for type_ in introspector.list_types(): + print(type_.name, type_.kind, type_.base_type) + +for constraint in introspector.list_constraints("orders"): + print(constraint.name, constraint.constraint_type, constraint.definition) ``` `list_extended_properties()` takes either a `major_id` it already has or a `table_name` to resolve — the named form joins `sys.tables` and `sys.schemas`, so a caller needs no first round trip to turn a name into an id. Its `class_name` is checked against the `class_desc` values the engine documents (`OBJECT_OR_COLUMN`, `INDEX`, `DATABASE`, `PARAMETER` and so on), and a schema filter is refused for a class whose `major_id` lives in another id space, because "no rows" there would be a silent contradiction rather than an answer. `value` is `sql_variant` and the driver cannot fetch it, so it is cast to `NVARCHAR(4000)` in the statement. @@ -115,6 +146,9 @@ for routine in introspector.list_functions(): - **Indexes.** `sys.indexes` joined to `sys.index_columns` and `sys.columns`; a heap has no named index row and is excluded, so `index_id` 0 never appears as an index. - **Triggers.** Firing events are not a column of `sys.triggers`; they live one table over, in `sys.trigger_events`, one row per event. The join is a `LEFT JOIN`, one row per event, and the parser folds contiguous rows back into one trigger — so a trigger with no event row is still a trigger, and its definition comes from `OBJECT_DEFINITION`. - **Views.** `INFORMATION_SCHEMA.VIEWS` carries `VIEW_DEFINITION`, which is `NULL` for a view whose text the caller may not read — unlike `OBJECT_DEFINITION()`, which returns the text to a caller holding `VIEW DEFINITION`. Both the list and the single-view question read that one view, so they cannot disagree, and `include_system` is not consulted because the view holds no server-owned row. +- **Partitions.** SQL Server has three levels: `sys.partition_functions` declares the range boundaries, `sys.partition_schemes` maps destinations to filegroups, and an index is aligned when its `data_space_id` is the scheme's. The family is answered with four catalogue questions -- functions, boundaries, schemes and aligned indexes -- and folded in the parser rather than one join, because a single join would multiply boundaries, destinations and indexes into combinations the database does not have. Boundary values are `sql_variant` and cast to text; the direction comes from `boundary_value_on_right`; the aligned index's row and byte counts are its own partitions in `sys.dm_db_partition_stats`, not a sum across indexes that would count a table's rows twice. +- **Types.** `sys.types` supplies alias, table and CLR types; `sys.table_types` adds the object id whose columns a table type declares, so the count is the catalogue's rather than a guess. `TYPE_NAME(system_type_id)` names an alias type's base, and `MS_Description` under `class_desc = 'TYPE'` is the comment. System types are not listed: a model never has to map one back to a declaration. +- **Constraints.** One `sys.objects` row per constraint, with `sys.key_constraints` (PK/UQ), `sys.foreign_keys`/`sys.foreign_key_columns` (F), `sys.check_constraints` (C) and `sys.default_constraints` (D) joined by `object_id`. The check and default constraints are the two no other family answers; the primary-key, unique and foreign-key families are also reachable through the index and foreign-key questions. Each column join is LEFT and matches only the constraint family that owns it, so a primary key does not acquire a foreign key's columns. ## Operational Families @@ -153,9 +187,11 @@ There is no schema filter, and that is deliberate: the counters describe a runni `action` and `referenced_type` stay `None`: this view records no referential action, and no type for the referenced entity beyond the class descriptor. It is a real graph and a partial one — a foreign key is not a row here, and an object bound through dynamic SQL is invisible to it. A reference that leaves the database is not filtered out: the row still names the entity, and its server or database qualifier is not in the projection. Naming an object narrows the answer to its dependants; omitting the name returns the whole graph for the schema. -### The two refusals +### The refusals -`table-stats` and `ddl` are refused by their probes on every release, and the CLI exits **2** with "not supported by this server" without sending a statement. That is a different answer from an empty list: "this server cannot be asked" is not "the table was never read", and an assembled `CREATE TABLE` would be a statement the engine never made. The roundtrip matrix in this repository keeps an explicit pin for `DDLStatementQueryStatement` with this reason. +`table-stats` is refused by its probe on every release, and the CLI exits **2** with "not supported by this server" without sending a statement. That is a different answer from an empty list: "this server cannot be asked" is not "the table was never read". + +DDL extraction is not offered by this backend's CLI at all. `supports_ddl_extraction()` is `False` on every release, so advertising the type would promise only a refusal; the capability, the DDL expression and the formatter stay in the backend, ready for a real implementation. An assembled `CREATE TABLE` would be a statement the engine never made, and the roundtrip matrix in this repository keeps an explicit pin for `DDLStatementQueryStatement` with this reason. Every family — answered or refused — is reached through the same introspector, sync and async alike: @@ -240,7 +276,8 @@ Two limits decide whether an operational or data family can answer at all, and n | `unused-indexes`, `index-stats`, `runtime` | SQL Server 2005 (version 9.0), where the DMVs arrive | probe `False`; the CLI exits 2 without running the query | | `dependencies` | SQL Server 2008 (version 10.0), where `sys.sql_expression_dependencies` arrives | probe `False`; the CLI exits 2 | | `value-samples` | SQL Server 2012 (version 11.0), where `OFFSET ... FETCH` parses | probe `False`; the CLI exits 2 | -| `table-stats`, `ddl` | none — no release answers them | probe `False` on every version; the CLI exits 2 | +| `table-stats` | none — no release answers it | probe `False` on every version; the CLI exits 2 | +| `ddl` | none — no release answers it | probe `False` on every version; not offered by the CLI | | every other family | none established here | — | The floors are asked of the version accessor, not left to a runtime syntax error: a server below one of them is refused at the capability question with "cannot answer", which is a different and truer answer than a query that fails to parse. Every release this repository tests against — 2017, 2019, 2022 and 2025 — is well past all three, so the gates change nothing there and keep an older server from an execution error. @@ -260,6 +297,11 @@ python -m rhosocial.activerecord.backend.impl.sqlserver introspect tables --data python -m rhosocial.activerecord.backend.impl.sqlserver introspect columns orders --database mydb python -m rhosocial.activerecord.backend.impl.sqlserver introspect extended-properties orders --database mydb +# Partition functions, user-defined types and constraint objects +python -m rhosocial.activerecord.backend.impl.sqlserver introspect partitions --database mydb +python -m rhosocial.activerecord.backend.impl.sqlserver introspect types --database mydb +python -m rhosocial.activerecord.backend.impl.sqlserver introspect constraints orders --database mydb + # Operational readings python -m rhosocial.activerecord.backend.impl.sqlserver introspect unused-indexes --database mydb python -m rhosocial.activerecord.backend.impl.sqlserver introspect index-stats --database mydb @@ -271,7 +313,7 @@ python -m rhosocial.activerecord.backend.impl.sqlserver introspect column-profil python -m rhosocial.activerecord.backend.impl.sqlserver introspect value-samples orders \ --column status --limit 20 --database mydb -# The two families this engine does not answer are refused after connecting, exit 2 +# table-stats is refused after connecting, exit 2; ddl is not an offered type python -m rhosocial.activerecord.backend.impl.sqlserver introspect table-stats --database mydb ``` @@ -291,17 +333,21 @@ python -m rhosocial.activerecord.backend.impl.sqlserver introspect table-stats - | `functions` | User-defined functions in `dbo` | not accepted | | `sequences` | Sequences in `dbo` | not accepted | | `schemas` | Schemas and their owners | not accepted | +| `partitions` | Partition functions, schemes and aligned indexes | optional (table) | +| `types` | User-defined alias, table and CLR types | optional (type) | +| `constraints` | Constraint objects, including CHECK and DEFAULT | optional (table) | | `unused-indexes` | Indexes with no recorded read, from 2005 | not accepted | | `table-stats` | Per-table usage counters — refused on every version | not accepted | | `runtime` | Instance performance counters | not accepted | | `index-stats` | Per-index usage counters, from 2005 | not accepted | | `dependencies` | By-name dependency edges, from 2008 | optional (table) | -| `ddl` | The DDL that recreates one object — refused on every version | required (table) | | `column-profiles` | What each column of a table holds | required (table) | | `value-samples` | Most frequent values of one column | required (table), plus `--column` | | `join-coverage` | Overlap of two columns' value domains | not accepted; four flags required | -The nine operational and data families are the set the other CLIs ask for too, and each one names the probe that answers for it here — which is not automatically the same answer it gives elsewhere. `views NAME` is a *view* name and `triggers NAME` is a *table* name: the two are not the same kind of argument, which is why the rule table names both. A name the type cannot use is refused (exit 2) rather than ignored; a missing required name or flag is an incomplete invocation (exit 1); a flag the type does not read is refused (exit 2). A `views` name or an `extended-properties` name that answers nothing is reported as "not found" (exit 1), and for extended properties the table is first checked against the catalogue, because a table with no `MS_Description` and a table that is not there both answer with no rows. `ddl` and `join-coverage` answering `None` — "the engine will not produce a text", "the overlap could not be measured" — also leave with 1 rather than printing a row of nulls a reader would take for a measurement. +The operational and data families are the set the other CLIs ask for too, and each one names the probe that answers for it here — which is not automatically the same answer it gives elsewhere. `views NAME` is a *view* name and `triggers NAME` is a *table* name: the two are not the same kind of argument, which is why the rule table names both. A name the type cannot use is refused (exit 2) rather than ignored; a missing required name or flag is an incomplete invocation (exit 1); a flag the type does not read is refused (exit 2). A `views` name or an `extended-properties` name that answers nothing is reported as "not found" (exit 1), and for extended properties the table is first checked against the catalogue, because a table with no `MS_Description` and a table that is not there both answer with no rows. `join-coverage` answering `None` — "the overlap could not be measured" — also leaves with 1 rather than printing a row of nulls a reader would take for a measurement. + +`ddl` is absent from the table on purpose: `supports_ddl_extraction()` is permanently `False`, so the parser rejects it as an invalid choice (exit 2). The no-result rule for DDL retrieval remains the backend's concern, not the CLI's. `--limit` is forwarded to `sample_column_values()` only when it is given; without it the introspector's own default answers (`VALUE_SAMPLE_LIMIT`, 100), so the number is not written in two places. `--include-system` changes the table listing; the view listing reads `INFORMATION_SCHEMA.VIEWS`, which holds no server-owned row, so the flag has nothing to add there. `column-profiles` writes one note to stderr saying the profiles are aggregated from the rows themselves and paid for with a full scan per column, so a caller piping JSON to a consumer still sees where the numbers came from. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py index 8ce4227..3a92ae2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py @@ -4,7 +4,8 @@ Every type this command offers is answered by ``backend.introspector``. The introspector speaks the core API -- ``list_tables``/``list_views``/ ``list_columns``/... -- plus this backend's own ``list_schemas``, -``list_procedures``, ``list_functions`` and ``list_sequences``; there are no +``list_procedures``, ``list_functions``, ``list_sequences``, +``list_partitions``, ``list_types`` and ``list_constraints``; there are no ``get_tables``/``get_views``/``get_columns`` methods anywhere, and asking for them was why this command used to fail for every type. @@ -19,13 +20,19 @@ than parsed and dropped, and a type added without a rule is a type nobody has decided for. -The nine families the MySQL and PostgreSQL CLIs ask for are offered here -too, each gated on the switch that answers for it. SQL Server answers seven -of the nine from its dynamic management views; two it does not answer at all --- per-table usage counters, which it keeps per *index*, and DDL retrieval, -which the engine has no call for -- so those probes answer ``False`` and the -capability check refuses them with exit 2 rather than letting a rendered -statement fail at execution. +The nine families the MySQL and PostgreSQL CLIs ask for are considered here +too, each gated on the switch that answers for it. SQL Server answers eight +of the nine from its dynamic management views and the rows themselves; the +one it does not answer at all -- per-table usage counters, which it keeps per +*index* -- answers ``False``, and the capability check refuses it with exit 2 +rather than letting a rendered statement fail at execution. + +DDL extraction is not offered by this command at all. The engine has no +per-object call of the kind ``SHOW CREATE TABLE`` is, so +``supports_ddl_extraction()`` is permanently ``False``; advertising a type +whose capability can never be true would promise only a refusal, so ``ddl`` +is not a CLI type. The formatter and the DDL expression stay in the backend, +where a real implementation can reuse them. """ import argparse @@ -61,19 +68,23 @@ "functions": "supports_function_introspection", "sequences": "supports_sequence_introspection", "schemas": "supports_schema_introspection", - # The nine families shared with the MySQL and PostgreSQL CLIs. Each names - # the probe that answers for it here, which is not automatically the same - # answer it gives there: the dependency graph and the runtime listing come - # from the DMVs, the unused-index and index-counter families from the - # index usage DMV, the three data families from the rows themselves, and - # two -- per-table usage counters and DDL retrieval -- are refused by the - # engine's own answer rather than by an absent CLI branch. + "partitions": "supports_partition_metadata_introspection", + "types": "supports_type_introspection", + "constraints": "supports_constraint_introspection", + # The eight of the nine families shared with the MySQL and PostgreSQL + # CLIs that this engine answers. Each names the probe that answers for it + # here, which is not automatically the same answer it gives there: the + # dependency graph and the runtime listing come from the DMVs, the + # unused-index and index-counter families from the index usage DMV, and + # the three data families from the rows themselves. The ninth, + # per-table usage counters, is offered and refused by the engine's own + # answer; ``ddl`` is not offered at all because the capability is + # permanently false. "unused-indexes": "supports_unused_indexes_detection", "table-stats": "supports_table_stats", "runtime": "supports_runtime_stats", "index-stats": "supports_index_stats", "dependencies": "supports_object_dependencies", - "ddl": "supports_ddl_extraction", "column-profiles": "supports_column_data_profile", "value-samples": "supports_column_value_sample", "join-coverage": "supports_join_coverage", @@ -110,15 +121,19 @@ "functions": "forbidden", "sequences": "forbidden", "schemas": "forbidden", - # The nine shared families: the counters and the listings describe the - # database and take no name; the graph narrows to one object and answers - # the whole graph without one; the one-object families ask about a table. + "partitions": "optional", + "types": "optional", + "constraints": "optional", + # The shared families: the counters and the listings describe the database + # and take no name; the graph narrows to one object and answers the whole + # graph without one; the one-object families ask about a table. ``ddl`` is + # absent because the CLI does not offer it: the capability is permanently + # false, so there is no NAME decision to make. "unused-indexes": "forbidden", "table-stats": "forbidden", "runtime": "forbidden", "index-stats": "forbidden", "dependencies": "optional", - "ddl": "required", "column-profiles": "required", "value-samples": "required", "join-coverage": "forbidden", @@ -162,6 +177,9 @@ "list_functions": "User-Defined Functions", "list_sequences": "Sequences", "list_schemas": "Schemas", + "list_partitions": "Partitions", + "list_types": "User-Defined Types", + "list_constraints": "Constraints", "get_database_info": "Database Info", "list_unused_indexes": "Indexes with no recorded use", "list_table_usage_stats": "Table usage counters", @@ -219,6 +237,18 @@ def create_parser(subparsers): # List sequences %(prog)s sequences --database mydb + # Partition functions, schemes and aligned indexes (all, or one table's) + %(prog)s partitions --database mydb + %(prog)s partitions orders --database mydb + + # User-defined alias, table and CLR types (all, or one type) + %(prog)s types --database mydb + %(prog)s types order_note --database mydb + + # Constraint objects, including CHECK and DEFAULT constraints + %(prog)s constraints --database mydb + %(prog)s constraints orders --database mydb + # Operational readings: indexes with no recorded use, per-index counters, # live instance counters, the dependency graph %(prog)s unused-indexes --database mydb @@ -235,11 +265,10 @@ def create_parser(subparsers): %(prog)s join-coverage --left-table orders --left-column customer_id \\ --right-table customers --right-column id --database mydb - # The two families SQL Server does not answer are refused by capability, - # not hidden: table-stats and ddl exit 2 with "not supported by this - # server" once the version is known. + # A family SQL Server cannot answer is refused by capability, not hidden: + # table-stats exits 2 with "not supported by this server" once the version + # is known. %(prog)s table-stats --database mydb - %(prog)s ddl orders --database mydb # Output as JSON %(prog)s tables --database mydb -o json @@ -249,10 +278,10 @@ def create_parser(subparsers): %(prog)s tables NAME per type: - columns, indexes, foreign-keys, extended-properties - a table name, required - ddl, column-profiles, value-samples - a table name, required + columns, indexes, foreign-keys, extended-properties, column-profiles, + value-samples a table name, required + partitions, constraints a table name, optional + types a type name, optional views a view name, optional triggers, dependencies optional narrowing everything else no name: refused with exit 2, never ignored @@ -409,24 +438,6 @@ def _value_sample_arguments(args) -> Dict[str, int]: return {} if limit is None else {"limit": limit} -def _display_ddl(args, provider, statement) -> None: - """Show one object's DDL as the engine handed it back. - - A DDL text is a block, not a field of a row: a bordered cell wraps every - one of its lines and reads worse than the text itself. So the terminal - format prints the engine's own text, and the formats a caller parses keep - the record -- schema, object type, object name, ``native`` and the text -- - which is what ``native`` is for: it says the engine retrieved this rather - than the library assembling it. - """ - record = _serialize_for_output(statement) - if args.output != "table": - provider.display_results([record], title=f"DDL of {args.name}") - return - print(f"-- {record.get('object_type') or 'OBJECT'} {record.get('object_name') or args.name}") - print(record["ddl"]) - - def _require_capability(backend, kind: str) -> None: """Refuse a type the connected server cannot answer. @@ -551,8 +562,22 @@ def _steps(args) -> List[Tuple[str, Dict[str, Any]]]: return [("list_sequences", {"schema": None})] if kind == "schemas": return [("list_schemas", {})] + if kind == "partitions": + return [( + "list_partitions", + {"table_name": args.name, "schema": None}, + )] + if kind == "types": + if args.name: + return [("get_type_info", {"type_name": args.name, "schema": None})] + return [("list_types", {"schema": None})] + if kind == "constraints": + return [( + "list_constraints", + {"table_name": args.name, "schema": None}, + )] - # ---- the nine families shared with the MySQL and PostgreSQL CLIs ----- + # ---- the shared families from the MySQL and PostgreSQL CLIs ---------- # The counters and the listings answer for the whole schema, so none # takes a name; the graph narrows to one object and answers the whole # graph without one; the one-object families ask about a table. @@ -569,11 +594,6 @@ def _steps(args) -> List[Tuple[str, Dict[str, Any]]]: "list_object_dependencies", {"object_name": args.name, "schema": None}, )] - if kind == "ddl": - return [( - "get_ddl_statement", - {"object_name": _name_of(args, kind), "schema": None}, - )] if kind == "column-profiles": return [( "list_column_data_profiles", @@ -638,9 +658,15 @@ def _title_for(method: str, kwargs: Dict[str, Any]) -> str: return f"Dependencies of {kwargs['object_name']}" if method == "list_column_data_profiles": return f"Column profiles of {kwargs.get('table_name', '')}" + if method == "list_partitions" and kwargs.get("table_name"): + return f"Partitions of {kwargs['table_name']}" + if method == "list_constraints" and kwargs.get("table_name"): + return f"Constraints of {kwargs['table_name']}" return _RESULT_TITLES[method] if method == "get_view_info": return f"View {kwargs.get('view_name', '')}" + if method == "get_type_info": + return f"Type {kwargs.get('type_name', '')}" name = kwargs.get("table_name", "") if method == "list_columns": return f"Columns of {name}" @@ -665,6 +691,7 @@ def _title_for(method: str, kwargs: Dict[str, Any]) -> str: #: answer for those ("this schema has no triggers"), not a failed lookup. _NAMED_TYPES: Dict[str, Tuple[str, str]] = { "get_view_info": ("View", "view_name"), + "get_type_info": ("Type", "type_name"), "list_extended_properties": ("Table", "table_name"), } @@ -728,21 +755,14 @@ async def _require_found_async( def _require_measured(method: str, kwargs: Dict[str, Any], value: Any) -> None: """Refuse a ``None`` answer that means "not measured", not zero. - Two of the shared families answer with a single object or nothing: - ``None`` from the DDL question says the engine will not produce a text, - and ``None`` from the coverage question says the overlap could not be - measured. Both are different answers from an empty row and from a measured - zero, so both leave with 1 rather than printing a row of nulls that a - reader would take for a measurement. + ``join-coverage`` answers with a single object or nothing: ``None`` from + the coverage question says the overlap could not be measured, which is a + different answer from an empty row and from a measured zero. It leaves + with 1 rather than printing a row of nulls that a reader would take for a + measurement. """ if value is not None: return - if method == "get_ddl_statement": - print( - f"Error: no DDL available for '{kwargs.get('object_name', '')}'", - file=sys.stderr, - ) - sys.exit(1) if method == "estimate_join_coverage": left = f"{kwargs.get('left_table', '')}.{kwargs.get('left_column', '')}" right = f"{kwargs.get('right_table', '')}.{kwargs.get('right_column', '')}" @@ -827,9 +847,6 @@ def _handle_introspect_sync(args, backend: SQLServerBackend, provider): "(source='aggregate') -- exact, and paid for with a full scan per column.", file=sys.stderr, ) - if method == "get_ddl_statement": - _display_ddl(args, provider, value) - continue sections.extend(_value_sections(method, kwargs, value)) _render(provider, sections) @@ -865,9 +882,6 @@ async def _handle_introspect_async(args, backend, provider): "(source='aggregate') -- exact, and paid for with a full scan per column.", file=sys.stderr, ) - if method == "get_ddl_statement": - _display_ddl(args, provider, value) - continue sections.extend(_value_sections(method, kwargs, value)) _render(provider, sections) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py index 550f040..7445dce 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py @@ -14,7 +14,12 @@ * **routines** -- stored procedures and user-defined functions, which this engine keeps in one relation and separates by ``sys.objects.type``; * **sequences** -- ``sys.sequences``, whose values are ``sql_variant`` and - must be cast to text before the driver can fetch them. + must be cast to text before the driver can fetch them; +* **partitions** -- the partition function, its range boundaries, the + partition scheme over it and the indexes aligned to that scheme; +* **types** -- user-defined alias, table and CLR types in ``sys.types``; +* **constraints** -- the constraint objects in ``sys.objects``, including the + check and default constraints no other family answers. Each class collects all its parameters at construction -- keyword-only, since the question is asked once and never edited -- and names, through @@ -39,11 +44,17 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ + "SQLServerConstraintListQueryStatement", "SQLServerExtendedPropertyListQueryStatement", + "SQLServerPartitionAlignedIndexListQueryStatement", + "SQLServerPartitionBoundaryListQueryStatement", + "SQLServerPartitionFunctionListQueryStatement", + "SQLServerPartitionSchemeListQueryStatement", "SQLServerPrimaryKeyQueryStatement", "SQLServerRoutineListQueryStatement", "SQLServerSchemaListQueryStatement", "SQLServerSequenceListQueryStatement", + "SQLServerTypeListQueryStatement", ] @@ -221,3 +232,163 @@ def format_method(self) -> str: def __repr__(self) -> str: return f"SQLServerSequenceListQueryStatement(schema={self.schema!r})" + + +class SQLServerPartitionFunctionListQueryStatement(IntrospectionExpression): + """Ask for the partition functions of this database, with their parameters. + + Partition functions are database-scoped, not schema-scoped, so the + question carries no schema. One row per parameter is the catalogue's + shape; SQL Server defines one parameter for a table-partitioning + function, and the parser folds the rows into one record per function. + + Args: + dialect: The dialect the statement is bound to. + """ + + KIND_RESERVED = ("SQLServer",) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_partition_function_list_query_statement" + + def __repr__(self) -> str: + return "SQLServerPartitionFunctionListQueryStatement()" + + +class SQLServerPartitionBoundaryListQueryStatement(IntrospectionExpression): + """Ask for the range boundary values of every partition function. + + ``sys.partition_range_values.value`` is ``sql_variant`` and the ODBC + driver cannot fetch it, so the dialect casts it to text in the statement + rather than leaving the driver to refuse the row. + + Args: + dialect: The dialect the statement is bound to. + """ + + KIND_RESERVED = ("SQLServer",) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_partition_boundary_list_query_statement" + + def __repr__(self) -> str: + return "SQLServerPartitionBoundaryListQueryStatement()" + + +class SQLServerPartitionSchemeListQueryStatement(IntrospectionExpression): + """Ask for every partition scheme and its destination filegroups. + + Args: + dialect: The dialect the statement is bound to. + """ + + KIND_RESERVED = ("SQLServer",) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_partition_scheme_list_query_statement" + + def __repr__(self) -> str: + return "SQLServerPartitionSchemeListQueryStatement()" + + +class SQLServerPartitionAlignedIndexListQueryStatement(IntrospectionExpression): + """Ask for the indexes whose ``data_space_id`` is a partition scheme. + + An index aligned to a scheme is partitioned by that scheme's function; + an unaligned index of the same table lives on a filegroup instead and is + not an answer. A table name narrows the answer to one table's aligned + indexes; without it every aligned index in the database is listed. + + Args: + dialect: The dialect the statement is bound to. + table_name: One table's indexes, or ``None`` for every table. + schema: The schema the aligned indexes are resolved in, with or + without ``table_name``. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: Optional[str] = table_name + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_partition_aligned_index_list_query_statement" + + def __repr__(self) -> str: + return ( + f"SQLServerPartitionAlignedIndexListQueryStatement(" + f"table_name={self.table_name!r}, schema={self.schema!r})" + ) + + +class SQLServerTypeListQueryStatement(IntrospectionExpression): + """Ask for the user-defined types of one schema. + + The question lists alias types, table types and CLR types; system types + are not user-defined and are not what a model has to map back. + + Args: + dialect: The dialect the statement is bound to. + schema: The schema to read, or ``None`` for the default schema. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__(self, dialect: "SQLDialectBase", *, schema: Optional[str] = None): + super().__init__(dialect, schema=schema) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_type_list_query_statement" + + def __repr__(self) -> str: + return f"SQLServerTypeListQueryStatement(schema={self.schema!r})" + + +class SQLServerConstraintListQueryStatement(IntrospectionExpression): + """Ask for the constraint objects of one schema, or one table. + + Args: + dialect: The dialect the statement is bound to. + table_name: One table's constraints, or ``None`` for the schema. + schema: The schema to read, or ``None`` for the default schema. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: Optional[str] = table_name + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_constraint_list_query_statement" + + def __repr__(self) -> str: + return ( + f"SQLServerConstraintListQueryStatement(" + f"table_name={self.table_name!r}, schema={self.schema!r})" + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py index 79b1ce3..1c9cacf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py @@ -7,10 +7,15 @@ AsyncSQLServerStatusIntrospector, ) from .types import ( + SQLServerConstraintInfo, SQLServerExtendedPropertyInfo, + SQLServerPartitionIndexInfo, + SQLServerPartitionInfo, + SQLServerPartitionSchemeInfo, SQLServerRoutineInfo, SQLServerSchemaInfo, SQLServerSequenceInfo, + SQLServerTypeInfo, ) __all__ = [ @@ -18,8 +23,13 @@ "AsyncSQLServerIntrospector", "SQLServerStatusIntrospector", "AsyncSQLServerStatusIntrospector", + "SQLServerConstraintInfo", "SQLServerExtendedPropertyInfo", + "SQLServerPartitionIndexInfo", + "SQLServerPartitionInfo", + "SQLServerPartitionSchemeInfo", "SQLServerRoutineInfo", "SQLServerSchemaInfo", "SQLServerSequenceInfo", + "SQLServerTypeInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py index 07a607e..ad4d404 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py @@ -39,20 +39,31 @@ from ..mixins.introspection import DEFAULT_SCHEMA from ..query_nodes import SQLServerNodeBuilderMixin from .types import ( + SQLServerConstraintInfo, SQLServerExtendedPropertyInfo, + SQLServerPartitionIndexInfo, + SQLServerPartitionInfo, + SQLServerPartitionSchemeInfo, SQLServerRoutineInfo, SQLServerSchemaInfo, SQLServerSequenceInfo, + SQLServerTypeInfo, ) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase from ..expression.introspection import ( + SQLServerConstraintListQueryStatement, SQLServerExtendedPropertyListQueryStatement, + SQLServerPartitionAlignedIndexListQueryStatement, + SQLServerPartitionBoundaryListQueryStatement, + SQLServerPartitionFunctionListQueryStatement, + SQLServerPartitionSchemeListQueryStatement, SQLServerPrimaryKeyQueryStatement, SQLServerRoutineListQueryStatement, SQLServerSchemaListQueryStatement, SQLServerSequenceListQueryStatement, + SQLServerTypeListQueryStatement, ) #: ``INFORMATION_SCHEMA.COLUMNS.CHARACTER_MAXIMUM_LENGTH`` reports ``-1`` @@ -233,6 +244,71 @@ def sequence_list_statement( return SQLServerSequenceListQueryStatement(self.dialect, schema=schema) + def partition_function_list_statement( + self, + ) -> "SQLServerPartitionFunctionListQueryStatement": + """Return the statement that lists partition functions and parameters.""" + from ..expression.introspection import ( + SQLServerPartitionFunctionListQueryStatement, + ) + + return SQLServerPartitionFunctionListQueryStatement(self.dialect) + + def partition_boundary_list_statement( + self, + ) -> "SQLServerPartitionBoundaryListQueryStatement": + """Return the statement that lists partition range boundary values.""" + from ..expression.introspection import ( + SQLServerPartitionBoundaryListQueryStatement, + ) + + return SQLServerPartitionBoundaryListQueryStatement(self.dialect) + + def partition_scheme_list_statement( + self, + ) -> "SQLServerPartitionSchemeListQueryStatement": + """Return the statement that lists partition schemes and destinations.""" + from ..expression.introspection import ( + SQLServerPartitionSchemeListQueryStatement, + ) + + return SQLServerPartitionSchemeListQueryStatement(self.dialect) + + def partition_aligned_index_list_statement( + self, *, table_name: Optional[str] = None, schema: Optional[str] = None + ) -> "SQLServerPartitionAlignedIndexListQueryStatement": + """Return the statement that lists indexes aligned to a scheme. + + A table name narrows the answer to one table; a schema narrows it to + one schema, with or without a table name. With neither, every aligned + index in the database is listed. + """ + from ..expression.introspection import ( + SQLServerPartitionAlignedIndexListQueryStatement, + ) + + return SQLServerPartitionAlignedIndexListQueryStatement( + self.dialect, table_name=table_name, schema=schema + ) + + def type_list_statement( + self, *, schema: Optional[str] = None + ) -> "SQLServerTypeListQueryStatement": + """Return the statement that lists one schema's user-defined types.""" + from ..expression.introspection import SQLServerTypeListQueryStatement + + return SQLServerTypeListQueryStatement(self.dialect, schema=schema) + + def constraint_list_statement( + self, *, table_name: Optional[str] = None, schema: Optional[str] = None + ) -> "SQLServerConstraintListQueryStatement": + """Return the statement that lists one schema's or table's constraints.""" + from ..expression.introspection import SQLServerConstraintListQueryStatement + + return SQLServerConstraintListQueryStatement( + self.dialect, table_name=table_name, schema=schema + ) + # ------------------------------------------------------------------ # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ @@ -389,7 +465,9 @@ def parse_columns( default_value=row.get("COLUMN_DEFAULT"), is_primary_key=col_name in pk_set, is_unique=False, - is_auto_increment=bool(row.get("IS_IDENTITY")), + # ``COLUMNPROPERTY(..., 'IsIdentity')`` answers 1/0 or NULL; + # ``_as_flag`` keeps a text "0" from reading as truthy. + is_auto_increment=bool(self._as_flag(row.get("IS_IDENTITY"))), comment=row.get("column_comment"), character_maximum_length=max_len, numeric_precision=precision, @@ -626,6 +704,321 @@ def _as_decimal(value: Any) -> Optional[Decimal]: except (InvalidOperation, ValueError): return None + @staticmethod + def _as_int(value: Any) -> Optional[int]: + """Read a catalogue count back as an int, ``None`` when it is absent.""" + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def parse_partitions( + self, + function_rows: List[Dict[str, Any]], + boundary_rows: List[Dict[str, Any]], + scheme_rows: List[Dict[str, Any]], + index_rows: List[Dict[str, Any]], + table_name: Optional[str] = None, + schema: Optional[str] = None, + ) -> List[SQLServerPartitionInfo]: + """Fold four partition questions into one record per function. + + SQL Server answers partitions at three levels: the function holds the + boundaries, the scheme maps destinations to filegroups, and an index + is aligned to a scheme by sharing its ``data_space_id``. The + statement for each level returns its own row shape, so the fold + happens here rather than in one SQL join whose cartesian product would + multiply boundaries by destinations by indexes. + + When ``table_name`` or ``schema`` is given, only the functions used + by that table's (or schema's) aligned indexes are returned; a function + no index there uses is not that table's (or schema's) partition + function. + """ + functions: Dict[int, SQLServerPartitionInfo] = {} + for row in function_rows: + function_id = int(row.get("function_id") or 0) + if function_id in functions: + continue + functions[function_id] = SQLServerPartitionInfo( + name=row.get("partition_function", ""), + function_id=function_id, + data_type=row.get("data_type"), + base_type=row.get("base_type"), + range_direction=( + "RIGHT" if row.get("boundary_value_on_right") else "LEFT" + ), + fanout=int(row.get("fanout") or 0), + ) + + boundaries: Dict[int, List[Tuple[int, int, str]]] = {} + for row in boundary_rows: + function_id = int(row.get("function_id") or 0) + value = row.get("boundary_value") + if value is None: + continue + boundaries.setdefault(function_id, []).append( + ( + int(row.get("boundary_id") or 0), + int(row.get("parameter_id") or 0), + str(value), + ) + ) + + schemes: Dict[int, Dict[int, Tuple[str, List[Tuple[int, str]]]]] = {} + for row in scheme_rows: + function_id = int(row.get("function_id") or 0) + scheme_id = int(row.get("scheme_id") or 0) + scheme_map = schemes.setdefault(function_id, {}) + if scheme_id not in scheme_map: + scheme_map[scheme_id] = (row.get("scheme_name", ""), []) + filegroup = row.get("filegroup_name") + if filegroup: + scheme_map[scheme_id][1].append( + (int(row.get("destination_id") or 0), filegroup) + ) + + indexes: Dict[int, Dict[Tuple[str, str, str, int], Dict[str, Any]]] = {} + for row in index_rows: + function_id = int(row.get("function_id") or 0) + scheme = row.get("scheme_name") or "" + row_schema = row.get("schema_name") or "" + table = row.get("table_name") or "" + index_id = int(row.get("index_id") or 0) + key = (scheme, row_schema, table, index_id) + entries = indexes.setdefault(function_id, {}) + entry = entries.get(key) + if entry is None: + entry = { + "schema": row_schema, + "table": table, + "scheme": scheme or None, + "name": row.get("index_name"), + "index_id": index_id, + "index_type": row.get("index_type") or "", + "is_unique": bool(row.get("is_unique")), + "is_primary_key": bool(row.get("is_primary_key")), + "columns": [], + "row_count": self._as_int(row.get("row_count")), + "size_bytes": self._as_int(row.get("size_bytes")), + } + entries[key] = entry + column = row.get("partition_column") + if column and column not in entry["columns"]: + entry["columns"].append(column) + + result: List[SQLServerPartitionInfo] = [] + for function_id, record in functions.items(): + boundary_values = tuple( + value for _boundary, _parameter, value in sorted( + boundaries.get(function_id, []) + ) + ) + scheme_infos = tuple( + SQLServerPartitionSchemeInfo( + name=name, + data_space_id=scheme_id, + filegroups=tuple( + filegroup + for _destination, filegroup in sorted(filegroups) + ), + ) + for scheme_id, (name, filegroups) in sorted( + schemes.get(function_id, {}).items() + ) + ) + index_infos = tuple( + SQLServerPartitionIndexInfo( + schema=entry["schema"], + table=entry["table"], + scheme=entry["scheme"], + name=entry["name"], + index_id=entry["index_id"], + index_type=entry["index_type"], + is_unique=entry["is_unique"], + is_primary_key=entry["is_primary_key"], + columns=tuple(entry["columns"]), + row_count=entry["row_count"], + size_bytes=entry["size_bytes"], + ) + for entry in sorted( + indexes.get(function_id, {}).values(), + key=lambda item: ( + item["schema"], + item["table"], + item["index_id"], + ), + ) + ) + if (table_name is not None or schema is not None) and not index_infos: + continue + record.boundary_values = boundary_values + record.schemes = scheme_infos + record.indexes = index_infos + result.append(record) + return sorted(result, key=lambda partition: partition.name) + + def parse_types(self, rows: List[Dict[str, Any]]) -> List[SQLServerTypeInfo]: + """Read ``sys.types`` rows into user-defined type records. + + ``kind`` is derived from the catalogue flags, never from the name: a + table type is ``TABLE_TYPE``, a CLR type ``CLR_TYPE`` and the rest + ``ALIAS_TYPE``. ``column_count`` is meaningful only for a table type; + the subquery answers 0 for an alias type whose ``type_table_object_id`` + is NULL, and that zero is not a count of columns the type has. + """ + types: List[SQLServerTypeInfo] = [] + for row in rows: + is_table_type = bool(row.get("is_table_type")) + is_assembly_type = bool(row.get("is_assembly_type")) + if is_table_type: + kind = "TABLE_TYPE" + elif is_assembly_type: + kind = "CLR_TYPE" + else: + kind = "ALIAS_TYPE" + types.append( + SQLServerTypeInfo( + name=row.get("type_name", ""), + schema=row.get("schema_name"), + kind=kind, + base_type=row.get("base_type"), + max_length=self._as_int(row.get("max_length")), + precision=self._as_int(row.get("precision")), + scale=self._as_int(row.get("scale")), + collation=row.get("collation"), + is_nullable=bool(row.get("is_nullable")), + is_user_defined=bool(row.get("is_user_defined")), + is_table_type=is_table_type, + is_assembly_type=is_assembly_type, + column_count=( + self._as_int(row.get("column_count")) + if is_table_type + else None + ), + default_object_id=int(row.get("default_object_id") or 0), + rule_object_id=int(row.get("rule_object_id") or 0), + comment=row.get("type_comment"), + ) + ) + return types + + def parse_constraints( + self, rows: List[Dict[str, Any]] + ) -> List[SQLServerConstraintInfo]: + """Fold one-row-per-constraint (or per foreign-key column) rows. + + A primary key, unique constraint or foreign key is one row per key + column; a check or default constraint is one row carrying its + expression. The columns and the ordinal that orders them are chosen + by constraint type, so a foreign key's columns never land in the + primary key's list merely because both catalogue views were joined. + """ + records: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + for row in rows: + name = row.get("constraint_name", "") + schema = row.get("schema_name") + table = row.get("table_name") + key = (schema or "", table or "", name) + record = records.get(key) + if record is None: + record = { + "name": name, + "schema": schema, + "table": table, + "constraint_type": (row.get("constraint_type") or "").strip(), + "constraint_type_desc": row.get("constraint_type_desc") or "", + "columns": [], + "referenced_schema": None, + "referenced_table": None, + "referenced_columns": [], + "definition": None, + "is_disabled": None, + "is_not_trusted": None, + "delete_action": None, + "update_action": None, + } + records[key] = record + + constraint_type = (row.get("constraint_type") or "").strip() + ordinal = self._as_int(row.get("fk_ordinal")) + if ordinal is None: + ordinal = self._as_int(row.get("key_ordinal")) + ordinal = ordinal or 0 + + if constraint_type == "F": + column = row.get("fk_column") + referenced = row.get("fk_referenced_column") + record["referenced_schema"] = row.get("referenced_schema") + record["referenced_table"] = row.get("referenced_table") + record["delete_action"] = row.get("on_delete") + record["update_action"] = row.get("on_update") + record["is_disabled"] = self._as_flag(row.get("fk_disabled")) + record["is_not_trusted"] = self._as_flag( + row.get("fk_not_trusted") + ) + elif constraint_type in ("PK", "UQ"): + column = row.get("key_column") + referenced = None + elif constraint_type == "C": + column = row.get("check_column") + referenced = None + record["definition"] = row.get("check_definition") + record["is_disabled"] = self._as_flag(row.get("check_disabled")) + record["is_not_trusted"] = self._as_flag( + row.get("check_not_trusted") + ) + elif constraint_type == "D": + column = row.get("default_column") + referenced = None + record["definition"] = row.get("default_definition") + else: + column = None + referenced = None + + if column and column not in [item[1] for item in record["columns"]]: + record["columns"].append((ordinal, column)) + if referenced and referenced not in [ + item[1] for item in record["referenced_columns"] + ]: + record["referenced_columns"].append((ordinal, referenced)) + + constraints: List[SQLServerConstraintInfo] = [] + for record in records.values(): + record["columns"] = tuple( + column for _ordinal, column in sorted(record["columns"]) + ) + record["referenced_columns"] = tuple( + column + for _ordinal, column in sorted(record["referenced_columns"]) + ) + constraints.append(SQLServerConstraintInfo(**record)) + return sorted( + constraints, + key=lambda constraint: ( + constraint.schema or "", + constraint.table or "", + constraint.name, + ), + ) + + @staticmethod + def _as_flag(value: Any) -> Optional[bool]: + """Read a catalogue bit as a tri-state, ``None`` when absent. + + The driver normally hands back ``0``/``1``, but a text row -- a test + double or a driver conversion -- can hand back ``"0"``, and + ``bool("0")`` is ``True``. The string branch closes that trap before + it can turn an identity or trust flag on. + """ + if value is None: + return None + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes") + return bool(value) + class SQLServerIntrospector( SQLServerIntrospectorMixin, AbstractIntrospector @@ -821,6 +1214,102 @@ def list_sequences( self._cache.set(key, sequences) return sequences + def list_partitions( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ) -> List[SQLServerPartitionInfo]: + """List partition functions, schemes and aligned indexes. + + SQL Server answers partitions at three levels and the question is + rendered as four statements rather than one join, because the + cartesian product of boundaries, destinations and indexes would + describe combinations that do not exist. ``table_name`` narrows the + answer to the functions that table is partitioned by; ``schema`` + narrows the aligned indexes to one schema, with or without a table + name. With neither, every partition function in the database is + reported, including one no table currently uses. + """ + key = self._cache.key( + IntrospectionScope.TABLE, + table_name or "*", + schema=schema, + extra="partitions", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + function_sql, function_params = self.partition_function_list_statement().to_sql() + function_rows = self._executor.execute(function_sql, function_params) + boundary_sql, boundary_params = self.partition_boundary_list_statement().to_sql() + boundary_rows = self._executor.execute(boundary_sql, boundary_params) + scheme_sql, scheme_params = self.partition_scheme_list_statement().to_sql() + scheme_rows = self._executor.execute(scheme_sql, scheme_params) + index_sql, index_params = self.partition_aligned_index_list_statement( + table_name=table_name, schema=schema + ).to_sql() + index_rows = self._executor.execute(index_sql, index_params) + + partitions = self.parse_partitions( + function_rows, boundary_rows, scheme_rows, index_rows, + table_name, schema, + ) + self._cache.set(key, partitions) + return partitions + + def list_types(self, schema: Optional[str] = None) -> List[SQLServerTypeInfo]: + """List one schema's user-defined alias, table and CLR types.""" + key = self._cache.key( + IntrospectionScope.SCHEMA, schema=schema, extra="types" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.type_list_statement(schema=schema).to_sql() + types = self.parse_types(self._executor.execute(sql, params)) + self._cache.set(key, types) + return types + + def get_type_info( + self, type_name: str, schema: Optional[str] = None + ) -> Optional[SQLServerTypeInfo]: + """Return one user-defined type, or ``None`` when it is not there.""" + return next( + (type_ for type_ in self.list_types(schema) if type_.name == type_name), + None, + ) + + def list_constraints( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ) -> List[SQLServerConstraintInfo]: + """List constraint objects, for one table or one schema. + + The families cover primary keys, unique constraints, foreign keys, + check constraints and default constraints. The first three are also + reachable through the index and foreign-key questions; the check and + default constraints are the two no other family answers. + """ + key = self._cache.key( + IntrospectionScope.TABLE, + table_name or "*", + schema=schema, + extra="constraints", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.constraint_list_statement( + table_name=table_name, schema=schema + ).to_sql() + constraints = self.parse_constraints(self._executor.execute(sql, params)) + self._cache.set(key, constraints) + return constraints + class AsyncSQLServerIntrospector( SQLServerIntrospectorMixin, AsyncAbstractIntrospector @@ -996,3 +1485,89 @@ async def list_sequences( self._cache.set(key, sequences) return sequences + async def list_partitions( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ) -> List[SQLServerPartitionInfo]: + """List partition functions, schemes and aligned indexes asynchronously. + + Same question, same fold and same cache key as the synchronous + manager; only the four catalog reads are awaited. + """ + key = self._cache.key( + IntrospectionScope.TABLE, + table_name or "*", + schema=schema, + extra="partitions", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + function_sql, function_params = self.partition_function_list_statement().to_sql() + function_rows = await self._executor.execute(function_sql, function_params) + boundary_sql, boundary_params = self.partition_boundary_list_statement().to_sql() + boundary_rows = await self._executor.execute(boundary_sql, boundary_params) + scheme_sql, scheme_params = self.partition_scheme_list_statement().to_sql() + scheme_rows = await self._executor.execute(scheme_sql, scheme_params) + index_sql, index_params = self.partition_aligned_index_list_statement( + table_name=table_name, schema=schema + ).to_sql() + index_rows = await self._executor.execute(index_sql, index_params) + + partitions = self.parse_partitions( + function_rows, boundary_rows, scheme_rows, index_rows, + table_name, schema, + ) + self._cache.set(key, partitions) + return partitions + + async def list_types( + self, schema: Optional[str] = None + ) -> List[SQLServerTypeInfo]: + """List one schema's user-defined alias, table and CLR types.""" + key = self._cache.key( + IntrospectionScope.SCHEMA, schema=schema, extra="types" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.type_list_statement(schema=schema).to_sql() + types = self.parse_types(await self._executor.execute(sql, params)) + self._cache.set(key, types) + return types + + async def get_type_info( + self, type_name: str, schema: Optional[str] = None + ) -> Optional[SQLServerTypeInfo]: + """Return one user-defined type asynchronously, or ``None``.""" + types = await self.list_types(schema) + return next((type_ for type_ in types if type_.name == type_name), None) + + async def list_constraints( + self, + table_name: Optional[str] = None, + schema: Optional[str] = None, + ) -> List[SQLServerConstraintInfo]: + """List constraint objects asynchronously, for one table or schema.""" + key = self._cache.key( + IntrospectionScope.TABLE, + table_name or "*", + schema=schema, + extra="constraints", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.constraint_list_statement( + table_name=table_name, schema=schema + ).to_sql() + constraints = self.parse_constraints( + await self._executor.execute(sql, params) + ) + self._cache.set(key, constraints) + return constraints + diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py index 2ca33e1..b57f73d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import datetime from decimal import Decimal -from typing import Optional, TYPE_CHECKING +from typing import Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.introspection import SequenceInfo @@ -142,9 +142,142 @@ def as_int(value: Optional[Decimal]) -> Optional[int]: ) +@dataclass +class SQLServerPartitionSchemeInfo: + """One partition scheme, and the filegroups its destinations map to. + + A scheme is a database object of its own: it names one partition function + and maps each of that function's destinations to a filegroup. Two schemes + can share one function, which is why the scheme is a record beside the + function rather than a field of it. + """ + + name: str + data_space_id: int = 0 + filegroups: Tuple[str, ...] = () + + +@dataclass +class SQLServerPartitionIndexInfo: + """One index aligned to a partition scheme. + + ``name`` is ``None`` for a heap, which the catalogue keeps as + ``index_id`` 0 with no named row. ``columns`` is the index's partitioning + columns -- the ones ``sys.index_columns.partition_ordinal`` marks -- not + the whole key. ``row_count`` and ``size_bytes`` are this index's own + figures from ``sys.dm_db_partition_stats``, measured per index and not + summed across indexes: a nonclustered index repeats the table's row count + and adding it to the clustered index's would count every row twice. + """ + + schema: str + table: str + scheme: Optional[str] = None + name: Optional[str] = None + index_id: int = 0 + index_type: str = "" + is_unique: bool = False + is_primary_key: bool = False + columns: Tuple[str, ...] = () + row_count: Optional[int] = None + size_bytes: Optional[int] = None + + +@dataclass +class SQLServerPartitionInfo: + """One partition function, its schemes, boundary values and aligned indexes. + + SQL Server's partition model has three levels: a partition function + declares the range boundaries, a partition scheme maps the resulting + destinations to filegroups, and a table or index is partitioned when its + ``data_space_id`` is that scheme. ``range_direction`` comes from + ``sys.partition_functions.boundary_value_on_right`` -- ``RIGHT`` when the + boundary belongs to the upper range, ``LEFT`` otherwise. + """ + + name: str + function_id: int = 0 + data_type: Optional[str] = None + base_type: Optional[str] = None + range_direction: str = "RIGHT" + fanout: int = 0 + boundary_values: Tuple[str, ...] = () + schemes: Tuple[SQLServerPartitionSchemeInfo, ...] = () + indexes: Tuple[SQLServerPartitionIndexInfo, ...] = () + + +@dataclass +class SQLServerTypeInfo: + """One user-defined type: an alias type, a table type or a CLR type. + + ``kind`` is derived from the catalogue flags rather than guessed from the + name: ``TABLE_TYPE`` when ``is_table_type`` is set, ``CLR_TYPE`` when + ``is_assembly_type`` is set, and ``ALIAS_TYPE`` otherwise. ``base_type`` + is the system type an alias type is declared on, read through + ``sys.types.system_type_id`` and ``TYPE_NAME``. ``max_length`` + is the catalogue's byte length, so an ``nvarchar(20)`` alias reports 40. + ``column_count`` is filled for table types only; the table type's + ``type_table_object_id`` is the object whose columns are counted. + """ + + name: str + schema: Optional[str] = None + kind: str = "ALIAS_TYPE" + base_type: Optional[str] = None + max_length: Optional[int] = None + precision: Optional[int] = None + scale: Optional[int] = None + collation: Optional[str] = None + is_nullable: bool = True + is_user_defined: bool = True + is_table_type: bool = False + is_assembly_type: bool = False + column_count: Optional[int] = None + default_object_id: int = 0 + rule_object_id: int = 0 + comment: Optional[str] = None + + +@dataclass +class SQLServerConstraintInfo: + """One constraint object from ``sys.objects``. + + ``constraint_type`` is the two-character ``sys.objects.type`` spelling: + ``PK`` primary key, ``UQ`` unique, ``F`` foreign key, ``C`` check and + ``D`` default. The primary-key, unique and foreign-key families are also + reachable through the index and foreign-key introspection explicitly; + this record is the uniform view of the constraint object, and the check + and default constraints are the two no other family answers. + + ``definition`` carries the check predicate or the default expression, and + ``is_disabled``/``is_not_trusted`` are the engine's own flags for the + constraint kinds that have them (check and foreign key). + """ + + name: str + schema: Optional[str] = None + table: Optional[str] = None + constraint_type: str = "" + constraint_type_desc: str = "" + columns: Tuple[str, ...] = () + referenced_schema: Optional[str] = None + referenced_table: Optional[str] = None + referenced_columns: Tuple[str, ...] = () + definition: Optional[str] = None + is_disabled: Optional[bool] = None + is_not_trusted: Optional[bool] = None + delete_action: Optional[str] = None + update_action: Optional[str] = None + + __all__ = [ + "SQLServerConstraintInfo", "SQLServerExtendedPropertyInfo", + "SQLServerPartitionIndexInfo", + "SQLServerPartitionInfo", + "SQLServerPartitionSchemeInfo", "SQLServerRoutineInfo", "SQLServerSchemaInfo", "SQLServerSequenceInfo", + "SQLServerTypeInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py index bc5af7c..217ce46 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py @@ -58,11 +58,17 @@ ObjectDependencyQueryStatement, ) from ..expression.introspection import ( + SQLServerConstraintListQueryStatement, SQLServerExtendedPropertyListQueryStatement, + SQLServerPartitionAlignedIndexListQueryStatement, + SQLServerPartitionBoundaryListQueryStatement, + SQLServerPartitionFunctionListQueryStatement, + SQLServerPartitionSchemeListQueryStatement, SQLServerPrimaryKeyQueryStatement, SQLServerRoutineListQueryStatement, SQLServerSchemaListQueryStatement, SQLServerSequenceListQueryStatement, + SQLServerTypeListQueryStatement, ) __all__ = ["DEFAULT_SCHEMA", "SQLServerIntrospectionMixin"] @@ -198,6 +204,38 @@ def supports_table_storage_introspection(self) -> bool: """ return True + def supports_partition_info(self) -> bool: + """``sys.partition_*`` answers partition functions, schemes and indexes. + + This is core's name for the family; the backend-local spelling is + ``supports_partition_metadata_introspection`` on the partition mixin. + Both answer the same engine question, so a caller may gate on either. + """ + return self.version_at_least((10, 0, 0)) + + def supports_type_introspection(self) -> bool: + """``sys.types``/``sys.table_types`` answer user-defined types. + + The family covers alias types, table types and CLR types; system + types are deliberately not listed because a model never has to map + one back to a declaration. The 2008 (10.0) floor is where + ``sys.table_types`` -- and the ``is_table_type`` flag on + ``sys.types`` -- arrived, so a server older than it cannot answer the + question this family asks. + """ + return self.version_at_least((10, 0, 0)) + + def supports_constraint_introspection(self) -> bool: + """``sys.objects`` and the constraint views answer constraint objects. + + A constraint is one object with one row in ``sys.objects``, and the + check and default constraints have no other family: the index family + answers primary and unique keys, the foreign-key family answers + foreign keys, but neither answers the two constraints that carry an + expression. + """ + return True + def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: """The catalogue families this dialect answers, as core names them. @@ -1080,6 +1118,540 @@ def format_sequence_list_query_statement( order_by=[self._col("name", "q")], ) + # ------------------------------------------------------------------ # + # Partitions, user-defined types and constraints + # + # These three families have no core statement class either. Partitions + # are three catalogue questions because SQL Server's model has three + # levels -- function, scheme and the index whose ``data_space_id`` is a + # scheme -- and each is rendered here rather than folded into one join + # that would multiply boundaries by destinations by indexes. + # ------------------------------------------------------------------ # + + def format_partition_function_list_query_statement( + self, expr: "SQLServerPartitionFunctionListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.partition_functions`` with its parameter type. + + ``sys.partition_parameters`` carries the declared type: the + function view itself does not. ``TYPE_NAME`` is asked twice, once for + the user type and once for its system base, so an alias-typed function + keeps both names. The parameter rows are folded into one function + record by the parser. + """ + joined = self._join( + self._relation("partition_functions", "sys", alias="pf"), + self._relation("partition_parameters", "sys", alias="pp"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("function_id", "pf"), self._col("function_id", "pp") + ), + ) + return self._select( + [ + self._col("name", "pf", alias="partition_function"), + self._col("function_id", "pf"), + self._col("type", "pf", alias="function_type"), + self._col("type_desc", "pf", alias="function_type_desc"), + self._col("fanout", "pf"), + self._col("boundary_value_on_right", "pf"), + self._fn( + "TYPE_NAME", self._col("user_type_id", "pp"), alias="data_type" + ), + self._fn( + "TYPE_NAME", self._col("system_type_id", "pp"), alias="base_type" + ), + self._col("max_length", "pp"), + self._col("precision", "pp"), + self._col("scale", "pp"), + self._col("collation_name", "pp", alias="collation"), + self._col("parameter_id", "pp"), + ], + from_=joined, + order_by=[ + self._col("name", "pf"), + self._col("parameter_id", "pp"), + ], + ) + + def format_partition_boundary_list_query_statement( + self, expr: "SQLServerPartitionBoundaryListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.partition_range_values`` with the value cast to text. + + ``value`` is ``sql_variant``; pyodbc raises on that type before a + row reaches the parser, which is why the cast is in the statement. + The cast is wide enough for a boundary on a long character type and + the boundary id orders a function's values. + """ + return self._select( + [ + self._col("function_id", "prv"), + self._col("boundary_id", "prv"), + self._col("parameter_id", "prv"), + self._cast( + self._col("value", "prv"), "NVARCHAR(4000)" + ).as_("boundary_value"), + ], + from_=self._relation("partition_range_values", "sys", alias="prv"), + order_by=[ + self._col("function_id", "prv"), + self._col("boundary_id", "prv"), + self._col("parameter_id", "prv"), + ], + ) + + def format_partition_scheme_list_query_statement( + self, expr: "SQLServerPartitionSchemeListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.partition_schemes`` with each destination's filegroup. + + ``sys.partition_schemes`` inherits ``sys.data_spaces``; its + ``data_space_id`` is the scheme id the indexes point at. + ``sys.destination_data_spaces`` maps one destination to one data + space, and ``sys.data_spaces`` is where that space's filegroup name + lives. The joins are LEFT so a scheme with no destination rows is + still a scheme. + """ + joined = self._join( + self._relation("partition_schemes", "sys", alias="ps"), + self._relation("destination_data_spaces", "sys", alias="dds"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("data_space_id", "ps"), + self._col("partition_scheme_id", "dds"), + ), + ) + joined = self._join( + joined, + self._relation("data_spaces", "sys", alias="ds"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("data_space_id", "dds"), self._col("data_space_id", "ds") + ), + ) + return self._select( + [ + self._col("name", "ps", alias="scheme_name"), + self._col("data_space_id", "ps", alias="scheme_id"), + self._col("function_id", "ps"), + self._col("destination_id", "dds"), + self._col( + "data_space_id", "dds", alias="destination_data_space_id" + ), + self._col("name", "ds", alias="filegroup_name"), + ], + from_=joined, + order_by=[ + self._col("name", "ps"), + self._col("destination_id", "dds"), + ], + ) + + def format_partition_aligned_index_list_query_statement( + self, expr: "SQLServerPartitionAlignedIndexListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the indexes whose ``data_space_id`` is a partition scheme. + + An index is aligned when its ``data_space_id`` is the scheme's -- no + separate flag records it. The partitioning columns are the + ``sys.index_columns`` rows with a non-zero ``partition_ordinal``, and + the row and byte counts are this index's own partitions from + ``sys.dm_db_partition_stats``: measured per index rather than summed + across indexes, because a nonclustered index repeats the table's + rows and a sum would count every row twice. + """ + params = expr.get_params() + table_name = params.get("table_name") + schema = params.get("schema") + + joined = self._join( + self._join( + self._join( + self._join( + self._relation("indexes", "sys", alias="i"), + self._relation("partition_schemes", "sys", alias="ps"), + join_type="JOIN", + condition=self._eq( + self._col("data_space_id", "i"), + self._col("data_space_id", "ps"), + ), + ), + self._relation("objects", "sys", alias="o"), + join_type="JOIN", + condition=self._eq( + self._col("object_id", "i"), self._col("object_id", "o") + ), + ), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "o"), self._col("schema_id", "s") + ), + ), + self._relation("index_columns", "sys", alias="ic"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "i"), self._col("object_id", "ic")), + self._eq(self._col("index_id", "i"), self._col("index_id", "ic")), + self._gt( + self._col("partition_ordinal", "ic"), + self._lit(0, inline=True), + ), + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="c"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "c"), self._col("object_id", "ic")), + self._eq(self._col("column_id", "c"), self._col("column_id", "ic")), + ), + ) + + stats_conditions = self._and( + self._eq(self._col("object_id", "st"), self._col("object_id", "i")), + self._eq(self._col("index_id", "st"), self._col("index_id", "i")), + ) + row_count = self._subquery( + [self._fn("SUM", self._col("row_count", "st"))], + from_=self._relation("dm_db_partition_stats", "sys", alias="st"), + where=stats_conditions, + alias="row_count", + ) + size_bytes = self._mul( + self._subquery( + [self._fn("SUM", self._col("used_page_count", "st"))], + from_=self._relation("dm_db_partition_stats", "sys", alias="st"), + where=stats_conditions, + ), + self._lit(8192, inline=True), + ).as_("size_bytes") + + conditions = [] + if table_name is not None: + conditions.append( + self._eq( + self._col("name", "s"), + self._lit(schema or DEFAULT_SCHEMA), + ) + ) + conditions.append( + self._eq(self._col("name", "o"), self._lit(table_name)) + ) + elif schema is not None: + conditions.append(self._eq(self._col("name", "s"), self._lit(schema))) + + return self._select( + [ + self._col("name", "ps", alias="scheme_name"), + self._col("function_id", "ps"), + self._col("name", "s", alias="schema_name"), + self._col("name", "o", alias="table_name"), + self._col("name", "i", alias="index_name"), + self._col("index_id", "i"), + self._col("type_desc", "i", alias="index_type"), + self._col("is_unique", "i"), + self._col("is_primary_key", "i"), + self._col("name", "c", alias="partition_column"), + self._col("partition_ordinal", "ic"), + row_count, + size_bytes, + ], + from_=joined, + where=self._and(*conditions) if conditions else None, + order_by=[ + self._col("name", "ps"), + self._col("name", "s"), + self._col("name", "o"), + self._col("index_id", "i"), + self._col("partition_ordinal", "ic"), + ], + ) + + def format_type_list_query_statement( + self, expr: "SQLServerTypeListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.types`` for one schema's user-defined types. + + ``sys.table_types`` inherits ``sys.types`` and adds the object id + whose columns a table type declares, so it is a LEFT JOIN keyed on + the shared ``user_type_id``. ``MS_Description`` lives in + ``sys.extended_properties`` under ``class_desc = 'TYPE'`` and + ``major_id = user_type_id``; the join is LEFT so a type without a + description keeps its row. + """ + params = expr.get_params() + target = params.get("schema") or DEFAULT_SCHEMA + + joined = self._join( + self._join( + self._relation("types", "sys", alias="t"), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "t"), self._col("schema_id", "s") + ), + ), + self._relation("table_types", "sys", alias="tt"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("user_type_id", "tt"), self._col("user_type_id", "t") + ), + ) + joined = self._join( + joined, + self._relation("extended_properties", "sys", alias="ep"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("class_desc", "ep"), self._lit("TYPE", inline=True)), + self._eq(self._col("major_id", "ep"), self._col("user_type_id", "t")), + self._eq(self._col("minor_id", "ep"), self._lit(0, inline=True)), + self._eq( + self._col("name", "ep"), + self._lit("MS_Description", inline=True), + ), + ), + ) + return self._select( + [ + self._col("name", "t", alias="type_name"), + self._col("name", "s", alias="schema_name"), + self._col("user_type_id", "t"), + self._col("system_type_id", "t"), + self._fn( + "TYPE_NAME", self._col("system_type_id", "t"), alias="base_type" + ), + self._col("max_length", "t"), + self._col("precision", "t"), + self._col("scale", "t"), + self._col("collation_name", "t", alias="collation"), + self._col("is_nullable", "t"), + self._col("is_user_defined", "t"), + self._col("is_table_type", "t"), + self._col("is_assembly_type", "t"), + self._col("default_object_id", "t"), + self._col("rule_object_id", "t"), + self._col("type_table_object_id", "tt"), + self._subquery( + [self._fn("COUNT", self._row_count_wildcard())], + from_=self._relation("columns", "sys", alias="c"), + where=self._eq( + self._col("object_id", "c"), + self._col("type_table_object_id", "tt"), + ), + alias="column_count", + ), + self._cast(self._col("value", "ep"), "NVARCHAR(4000)").as_( + "type_comment" + ), + ], + from_=joined, + where=self._and( + self._eq(self._col("is_user_defined", "t"), self._lit(1, inline=True)), + self._eq(self._col("name", "s"), self._lit(target)), + ), + order_by=[self._col("name", "t")], + ) + + def format_constraint_list_query_statement( + self, expr: "SQLServerConstraintListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the constraint objects of one table or one schema. + + One ``sys.objects`` row per constraint, with the family's own view + joined by ``object_id``: ``sys.key_constraints`` for ``PK``/``UQ``, + ``sys.foreign_keys``/``sys.foreign_key_columns`` for ``F``, + ``sys.check_constraints`` for ``C`` and ``sys.default_constraints`` + for ``D``. The column joins are LEFT and each matches only the family + that owns the constraint, so a primary key does not acquire a foreign + key's columns merely because both views are present. + """ + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") or DEFAULT_SCHEMA + + joined = self._join( + self._relation("objects", "sys", alias="o"), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "o"), self._col("schema_id", "s") + ), + ) + joined = self._join( + joined, + self._relation("tables", "sys", alias="t"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("parent_object_id", "o"), self._col("object_id", "t") + ), + ) + joined = self._join( + joined, + self._relation("check_constraints", "sys", alias="cc"), + join_type="LEFT JOIN", + condition=self._eq(self._col("object_id", "cc"), self._col("object_id", "o")), + ) + joined = self._join( + joined, + self._relation("default_constraints", "sys", alias="dc"), + join_type="LEFT JOIN", + condition=self._eq(self._col("object_id", "dc"), self._col("object_id", "o")), + ) + joined = self._join( + joined, + self._relation("foreign_keys", "sys", alias="fk"), + join_type="LEFT JOIN", + condition=self._eq(self._col("object_id", "fk"), self._col("object_id", "o")), + ) + joined = self._join( + joined, + self._relation("key_constraints", "sys", alias="kc"), + join_type="LEFT JOIN", + condition=self._eq(self._col("object_id", "kc"), self._col("object_id", "o")), + ) + joined = self._join( + joined, + self._relation("tables", "sys", alias="rt"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("referenced_object_id", "fk"), self._col("object_id", "rt") + ), + ) + joined = self._join( + joined, + self._relation("schemas", "sys", alias="rs"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("schema_id", "rt"), self._col("schema_id", "rs") + ), + ) + joined = self._join( + joined, + self._relation("foreign_key_columns", "sys", alias="fkc"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("constraint_object_id", "fkc"), self._col("object_id", "o") + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="cf"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "cf"), self._col("parent_object_id", "fkc")), + self._eq(self._col("column_id", "cf"), self._col("parent_column_id", "fkc")), + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="cr"), + join_type="LEFT JOIN", + condition=self._and( + self._eq( + self._col("object_id", "cr"), + self._col("referenced_object_id", "fkc"), + ), + self._eq( + self._col("column_id", "cr"), + self._col("referenced_column_id", "fkc"), + ), + ), + ) + joined = self._join( + joined, + self._relation("index_columns", "sys", alias="ic"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "ic"), self._col("parent_object_id", "kc")), + self._eq(self._col("index_id", "ic"), self._col("unique_index_id", "kc")), + self._gt(self._col("key_ordinal", "ic"), self._lit(0, inline=True)), + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="ck"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "ck"), self._col("object_id", "ic")), + self._eq(self._col("column_id", "ck"), self._col("column_id", "ic")), + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="cchk"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "cchk"), self._col("parent_object_id", "cc")), + self._eq(self._col("column_id", "cchk"), self._col("parent_column_id", "cc")), + ), + ) + joined = self._join( + joined, + self._relation("columns", "sys", alias="cd"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "cd"), self._col("parent_object_id", "dc")), + self._eq(self._col("column_id", "cd"), self._col("parent_column_id", "dc")), + ), + ) + + conditions = [ + self._in( + self._col("type", "o"), + ("PK", "UQ", "F", "C", "D"), + ), + self._eq(self._col("is_ms_shipped", "o"), self._lit(0, inline=True)), + self._eq(self._col("name", "s"), self._lit(target)), + ] + if table_name is not None: + conditions.append(self._eq(self._col("name", "t"), self._lit(table_name))) + + return self._select( + [ + self._col("name", "o", alias="constraint_name"), + self._col("name", "s", alias="schema_name"), + self._col("name", "t", alias="table_name"), + self._col("type", "o", alias="constraint_type"), + self._col("type_desc", "o", alias="constraint_type_desc"), + self._col("constraint_column_id", "fkc", alias="fk_ordinal"), + self._col("name", "cf", alias="fk_column"), + self._col("name", "cr", alias="fk_referenced_column"), + self._col("name", "rt", alias="referenced_table"), + self._col("name", "rs", alias="referenced_schema"), + self._col( + "delete_referential_action_desc", "fk", alias="on_delete" + ), + self._col( + "update_referential_action_desc", "fk", alias="on_update" + ), + self._col("unique_index_id", "kc"), + self._col("key_ordinal", "ic"), + self._col("name", "ck", alias="key_column"), + self._col("parent_column_id", "cc", alias="check_column_id"), + self._col("definition", "cc", alias="check_definition"), + self._col("is_disabled", "cc", alias="check_disabled"), + self._col("is_not_trusted", "cc", alias="check_not_trusted"), + self._col("name", "cchk", alias="check_column"), + self._col("parent_column_id", "dc", alias="default_column_id"), + self._col("definition", "dc", alias="default_definition"), + self._col("name", "cd", alias="default_column"), + self._col("is_disabled", "fk", alias="fk_disabled"), + self._col("is_not_trusted", "fk", alias="fk_not_trusted"), + ], + from_=joined, + where=self._and(*conditions), + order_by=[ + self._col("name", "s"), + self._col("name", "t"), + self._col("name", "o"), + self._col("constraint_column_id", "fkc"), + self._col("key_ordinal", "ic"), + ], + ) + # ========== Operational families ========== def supports_unused_indexes_detection(self) -> bool: diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py index 1051468..7a61376 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py @@ -77,6 +77,21 @@ def list_sequences(self, schema=None): def list_schemas(self): return self._record("list_schemas") + def list_partitions(self, table_name=None, schema=None): + return self._record("list_partitions", table_name=table_name, schema=schema) + + def list_types(self, schema=None): + return self._record("list_types", schema=schema) + + def get_type_info(self, type_name, schema=None): + self.calls.append(("get_type_info", {"type_name": type_name, "schema": schema})) + return {"method": "get_type_info", "name": type_name} + + def list_constraints(self, table_name=None, schema=None): + return self._record( + "list_constraints", table_name=table_name, schema=schema + ) + def list_extended_properties( self, major_id=None, @@ -121,18 +136,6 @@ def list_object_dependencies(self, object_name=None, schema=None): "list_object_dependencies", object_name=object_name, schema=schema ) - def get_ddl_statement(self, object_name, schema=None): - self.calls.append( - ("get_ddl_statement", {"object_name": object_name, "schema": schema}) - ) - return { - "schema": "dbo", - "object_type": "TABLE", - "object_name": object_name, - "ddl": "CREATE TABLE [users] ([id] int)", - "native": False, - } - def list_column_data_profiles(self, table_name, schema=None): return self._record("list_column_data_profiles", table_name=table_name, schema=schema) @@ -237,9 +240,6 @@ async def list_index_usage_stats(self, schema=None): async def list_object_dependencies(self, object_name=None, schema=None): return FakeIntrospector.list_object_dependencies(self, object_name, schema) - async def get_ddl_statement(self, object_name, schema=None): - return FakeIntrospector.get_ddl_statement(self, object_name, schema) - async def list_column_data_profiles(self, table_name, schema=None): return FakeIntrospector.list_column_data_profiles(self, table_name, schema) @@ -370,6 +370,18 @@ def test_unknown_type_rejected(self): with pytest.raises(SystemExit): make_args(["introspect", "grants"]) + def test_ddl_is_not_an_offered_choice(self, capsys): + """DDL extraction is permanently unsupported, so the type is absent. + + The parser, not the capability gate, is where this is refused: an + invalid choice is argparse's usage error (exit 2), which is a different + answer from offering a type only to refuse it after connecting. + """ + with pytest.raises(SystemExit) as exc: + make_args(["introspect", "ddl", "users"]) + assert exc.value.code == 2 + assert "invalid choice" in capsys.readouterr().err + def test_missing_database_exits_before_backend_creation(self, capsys): with pytest.raises(SystemExit) as exc: introspect_mod.handle(make_args(["introspect", "tables"])) @@ -389,7 +401,7 @@ class TestDispatchContract: def test_type_maps_to_a_real_method_on_both_managers(self, kind): argv = ["introspect", kind] if kind in ("columns", "indexes", "foreign-keys", "extended-properties", - "ddl", "column-profiles", "value-samples"): + "column-profiles", "value-samples"): argv.append("users") if kind == "value-samples": argv += ["--column", "status"] @@ -427,11 +439,17 @@ class TestHandleDispatch: (["functions"], "list_functions"), (["sequences"], "list_sequences"), (["schemas"], "list_schemas"), - # The nine families shared with the MySQL and PostgreSQL CLIs. - # ``table-stats`` and ``ddl`` are refused by the dialect's own - # answer, so their rows below are what the branch *would* ask: - # the capability gate stops a real run before that, and the - # dedicated test file asserts both halves. + (["partitions"], "list_partitions"), + (["partitions", "orders"], "list_partitions"), + (["types"], "list_types"), + (["types", "order_note"], "get_type_info"), + (["constraints"], "list_constraints"), + (["constraints", "orders"], "list_constraints"), + # The shared families from the MySQL and PostgreSQL CLIs. + # ``table-stats`` is refused by the dialect's own answer, so its + # row below is what the branch *would* ask: the capability gate + # stops a real run before that, and the dedicated test file + # asserts both halves. ``ddl`` is not offered at all. (["unused-indexes"], "list_unused_indexes"), (["table-stats"], "list_table_usage_stats"), (["runtime"], "list_runtime_metrics"), @@ -466,17 +484,6 @@ def test_types_dispatch_and_render_json(self, argv, method, capsys): assert isinstance(payload, list) assert payload[0]["method"] == method - def test_ddl_prints_the_engine_text_and_keeps_the_native_flag(self, capsys): - """A DDL text is a block, so the table format prints it as one; the - machine formats keep the record, ``native`` included.""" - introspect_mod.handle( - make_args(["introspect", "ddl", "users", "--database", "db", "-o", "json"]) - ) - payload = json.loads(capsys.readouterr().out) - assert payload[0]["object_name"] == "users" - assert payload[0]["ddl"] == "CREATE TABLE [users] ([id] int)" - assert payload[0]["native"] is False - def test_join_coverage_receives_the_four_names(self, capsys): introspect_mod.handle( make_args([ @@ -713,12 +720,14 @@ def test_the_table_says_what_each_types_name_means(self): "functions": "forbidden", "sequences": "forbidden", "schemas": "forbidden", + "partitions": "optional", + "types": "optional", + "constraints": "optional", "unused-indexes": "forbidden", "table-stats": "forbidden", "runtime": "forbidden", "index-stats": "forbidden", "dependencies": "optional", - "ddl": "required", "column-profiles": "required", "value-samples": "required", "join-coverage": "forbidden", @@ -739,7 +748,6 @@ def test_the_required_types_are_the_table_scoped_ones(self): "indexes", "foreign-keys", "extended-properties", - "ddl", "column-profiles", "value-samples", } @@ -758,16 +766,15 @@ def test_every_switch_is_declared_by_the_dialect(self, kind, switch): answer = getattr(dialect, switch, None) assert callable(answer), f"{kind} names {switch}, which the dialect lacks" - #: The two families SQL Server does not answer. The CLI offers them all - #: the same -- a type the engine refuses is refused at the capability - #: question with exit 2, not hidden from the parser -- so these are - #: asserted as the dialect's "no" rather than dropped from the table. + #: The family SQL Server does not answer but still offers. The CLI offers + #: it -- a type the engine refuses is refused at the capability question + #: with exit 2, not hidden from the parser -- so this is asserted as the + #: dialect's "no" rather than dropped from the table. UNANSWERED_SWITCHES = { "table-stats": "supports_table_stats", - "ddl": "supports_ddl_extraction", } - def test_the_two_unanswered_families_are_refused_by_the_dialect(self): + def test_the_unanswered_family_is_refused_by_the_dialect(self): from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect dialect = SQLServerDialect(version=(16, 0, 0)) @@ -775,6 +782,15 @@ def test_the_two_unanswered_families_are_refused_by_the_dialect(self): assert introspect_mod._TYPE_CAPABILITIES[kind] == switch assert getattr(dialect, switch)() is False, kind + def test_ddl_capability_stays_false_and_is_not_advertised(self): + """The backend keeps its DDL answer; the CLI simply does not offer it.""" + from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + dialect = SQLServerDialect(version=(16, 0, 0)) + assert dialect.supports_ddl_extraction() is False + assert "ddl" not in introspect_mod._TYPE_CAPABILITIES + assert "ddl" not in introspect_mod.INTROSPECT_TYPES + def test_every_answered_family_answers_true(self): """2022 is the newest release the matrix runs; every family the engine answers must answer there. @@ -842,6 +858,73 @@ def test_every_declared_switch_answers_true(self): assert getattr(dialect, switch)() is True, switch +class TestCoverageFamiliesDispatch: + """The three coverage families reach their introspector methods. + + ``partitions`` and ``constraints`` take an optional table name; ``types`` + takes an optional type name and switches from a list to one record, the + same shape ``views`` uses. + """ + + def test_partitions_asks_for_the_whole_database_or_one_table(self): + assert introspect_mod._steps( + make_args(["introspect", "partitions"]) + ) == [("list_partitions", {"table_name": None, "schema": None})] + assert introspect_mod._steps( + make_args(["introspect", "partitions", "orders"]) + ) == [("list_partitions", {"table_name": "orders", "schema": None})] + + def test_types_lists_or_describes_one_type(self): + assert introspect_mod._steps( + make_args(["introspect", "types"]) + ) == [("list_types", {"schema": None})] + assert introspect_mod._steps( + make_args(["introspect", "types", "order_note"]) + ) == [("get_type_info", {"type_name": "order_note", "schema": None})] + + def test_constraints_asks_for_the_schema_or_one_table(self): + assert introspect_mod._steps( + make_args(["introspect", "constraints"]) + ) == [("list_constraints", {"table_name": None, "schema": None})] + assert introspect_mod._steps( + make_args(["introspect", "constraints", "orders"]) + ) == [("list_constraints", {"table_name": "orders", "schema": None})] + + def test_a_missing_type_is_an_error_not_an_empty_record(self, monkeypatch, capsys): + class _NoSuchType(FakeIntrospector): + def get_type_info(self, type_name, schema=None): + self.calls.append( + ("get_type_info", {"type_name": type_name, "schema": schema}) + ) + return None + + monkeypatch.setattr( + introspect_mod, + "SQLServerBackend", + _tracking_class(FakeBackendBase, "NoSuchTypeBackend", _NoSuchType), + ) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args(["introspect", "types", "missing", "--database", "db"]) + ) + assert exc.value.code == 1 + assert "Type 'missing' not found" in capsys.readouterr().err + + def test_the_three_types_name_their_switches(self): + assert ( + introspect_mod._TYPE_CAPABILITIES["partitions"] + == "supports_partition_metadata_introspection" + ) + assert ( + introspect_mod._TYPE_CAPABILITIES["types"] + == "supports_type_introspection" + ) + assert ( + introspect_mod._TYPE_CAPABILITIES["constraints"] + == "supports_constraint_introspection" + ) + + class TestCapabilityGate: def test_a_switch_that_says_no_refuses_the_type(self, monkeypatch, capsys): cls = _tracking_class(FakeBackendBase, "RefusingBackend") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_introspect_families_cli.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_introspect_families_cli.py index b2e4d9c..2b6894d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_introspect_families_cli.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_introspect_families_cli.py @@ -1,5 +1,5 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_introspect_families_cli.py -"""The nine families the MySQL and PostgreSQL CLIs ask, as this one asks them. +"""The shared families the MySQL and PostgreSQL CLIs ask, as this one asks them. No server is needed here. What is checked is the wiring the CLI owns: that each type reaches its introspector method, that NAME and the type-specific @@ -7,13 +7,15 @@ family the connected server refuses is refused. Whether the answer is right is the introspector's question, and the live tests carry it. -Four of the nine are gated on the server's version because the SQL they +Four of the eight are gated on the server's version because the SQL they render needs an object a release can lack -- the dynamic management views and the pagination clause -- so the floors are asserted on both sides: a server at the floor is not refused, and a server below it is refused before -a statement is rendered rather than failing at execution. Two of the nine -this engine does not answer at all, and those are asserted as the dialect's -``False`` as well. +a statement is rendered rather than failing at execution. One of the nine +this engine does not answer at all, and that is asserted as the dialect's +``False`` as well. The ninth, ``ddl``, is not offered by the CLI: the +capability is permanently false, so the parser refuses it as an invalid +choice. """ import argparse @@ -28,20 +30,20 @@ from rhosocial.activerecord.backend.introspection.types import ( ColumnDataProfile, ColumnValueFrequency, - DDLStatement, JoinCoverage, ) -#: The nine families this change wires, and the switch that gates each. The +#: The eight families this change wires, and the switch that gates each. The #: dialect declares them, so a run against a real server passes the gate; the #: refusal tests below turn the switch off to prove the gate is still read. +#: ``ddl`` is not among them: its capability is permanently false, so the CLI +#: does not advertise the type. NEW_FAMILIES = { "unused-indexes": "supports_unused_indexes_detection", "table-stats": "supports_table_stats", "runtime": "supports_runtime_stats", "index-stats": "supports_index_stats", "dependencies": "supports_object_dependencies", - "ddl": "supports_ddl_extraction", "column-profiles": "supports_column_data_profile", "value-samples": "supports_column_value_sample", "join-coverage": "supports_join_coverage", @@ -127,21 +129,17 @@ def _args(type_: str, name: Optional[str] = None, **overrides) -> argparse.Names class _AnsweringDialect(SQLServerDialect): - """SQL Server's dialect with the two unanswered probes turned on. + """SQL Server's dialect with the one still-offered unanswered probe on. The branch tests drive the dispatch, not the engine's answer, so the - switches the real dialect refuses are answered here. That this is a + switch the real dialect refuses is answered here. That this is a *different* dialect from the one a run meets is the point: - :class:`TestTheTwoUnansweredFamilies` below asserts what the real one - says. + :class:`TestTheUnansweredFamilies` below asserts what the real one says. """ def supports_table_stats(self) -> bool: return True - def supports_ddl_extraction(self) -> bool: - return True - #: The version the stub's ``introspect_and_adapt()`` claims. 2022, the newest #: release the matrix runs and the one every answered family must answer on. @@ -256,49 +254,6 @@ def test_an_unnamed_run_asks_for_the_whole_graph(self, monkeypatch): ] -class TestTheDdlBranch: - """The schema object, not a name, is what a caller parses.""" - - def _statement(self) -> DDLStatement: - return DDLStatement( - schema="dbo", - object_type="PROCEDURE", - object_name="usp_orders", - ddl="CREATE PROCEDURE usp_orders AS BEGIN SET NOCOUNT ON END", - native=False, - ) - - def test_the_table_is_asked_for_by_name(self, monkeypatch): - introspector = _RecordingIntrospector({"get_ddl_statement": self._statement()}) - - _run_sync(monkeypatch, introspector, _args("ddl", "orders")) - - assert introspector.calls == [ - ("get_ddl_statement", (), {"object_name": "orders", "schema": None}) - ] - - def test_the_engine_text_reaches_json_with_its_native_flag( - self, monkeypatch, capsys - ): - introspector = _RecordingIntrospector({"get_ddl_statement": self._statement()}) - - _run_sync(monkeypatch, introspector, _args("ddl", "usp_orders")) - - payload = json.loads(capsys.readouterr().out) - assert payload[0]["object_name"] == "usp_orders" - assert payload[0]["ddl"].startswith("CREATE PROCEDURE") - assert payload[0]["native"] is False - - def test_no_ddl_is_an_error_not_an_empty_listing(self, monkeypatch, capsys): - introspector = _RecordingIntrospector({"get_ddl_statement": None}) - - with pytest.raises(SystemExit) as excinfo: - _run_sync(monkeypatch, introspector, _args("ddl", "orders")) - - assert excinfo.value.code == 1 - assert "no DDL available" in capsys.readouterr().err - - class TestTheColumnProfilesBranch: """The aggregate label must reach the reader; the numbers are not a catalogue.""" @@ -510,7 +465,7 @@ def test_a_flag_the_type_does_not_read_is_refused(self, capsys): assert "takes no --column" in capsys.readouterr().err def test_a_required_name_is_required_for_the_new_families(self, capsys): - for type_ in ("ddl", "column-profiles", "value-samples"): + for type_ in ("column-profiles", "value-samples"): with pytest.raises(SystemExit) as excinfo: introspect_cli._validate_name(_args(type_)) assert excinfo.value.code == 1 @@ -548,12 +503,6 @@ class TestTheAsyncPath: {"name": "orders"}, ((), {"object_name": "orders", "schema": None}), ), - ( - "ddl", - "get_ddl_statement", - {"name": "orders"}, - ((), {"object_name": "orders", "schema": None}), - ), ( "column-profiles", "list_column_data_profiles", @@ -682,22 +631,25 @@ def test_an_unadapted_dialect_refuses_a_gated_family(self): assert getattr(dialect, switch)() is False, type_ -class TestTheTwoUnansweredFamilies: - """The two families this engine does not answer, on any version. +class TestTheUnansweredFamilies: + """What this engine does not answer, on any version. Per-table usage counters: SQL Server's counters are per *index*, and summing them per table would be a derived figure wearing the shape of a - measured one; inserts and deletes are not tracked at all. DDL retrieval: - the engine has no per-object call of the kind MySQL's ``SHOW CREATE - TABLE`` is, so answering would mean assembling a reconstruction and - labelling it native. Both probes answer ``False``, the CLI refuses at the - capability question, and the roundtrip matrix keeps its pins for the two - statement classes with these reasons. + measured one; inserts and deletes are not tracked at all. The probe answers + ``False``, the CLI offers the type and refuses it at the capability + question, and the roundtrip matrix keeps its pin for the statement class + with this reason. + + DDL retrieval is equally unanswered -- the engine has no per-object call + of the kind MySQL's ``SHOW CREATE TABLE`` is -- but it is not offered by + the CLI at all. A type whose capability can never be true would promise + only a refusal, so ``ddl`` is an invalid argparse choice instead. The + backend capability and expression stay, ready for a real implementation. """ UNANSWERED = { "table-stats": "supports_table_stats", - "ddl": "supports_ddl_extraction", } @pytest.mark.parametrize("type_, switch", list(UNANSWERED.items())) @@ -736,4 +688,14 @@ def test_an_empty_per_table_counter_list_would_not_be_an_answer(self): """ dialect = SQLServerDialect(version=SQL_2022) assert dialect.supports_table_stats() is False - assert dialect.supports_ddl_extraction() is False + + def test_ddl_is_not_advertised_by_the_parser(self): + """The backend's ``False`` is unchanged; the CLI no longer offers it.""" + from rhosocial.activerecord.backend.impl.sqlserver.__main__ import parse_args + + assert "ddl" not in introspect_cli._TYPE_CAPABILITIES + assert "ddl" not in introspect_cli._NAME_RULE + assert SQLServerDialect(version=SQL_2022).supports_ddl_extraction() is False + with pytest.raises(SystemExit) as excinfo: + parse_args(["introspect", "ddl", "orders", "--database", "db"]) + assert excinfo.value.code == 2 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_coverage_families.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_coverage_families.py new file mode 100644 index 0000000..c246e00 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_coverage_families.py @@ -0,0 +1,714 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_coverage_families.py +"""Offline tests for the partitions, types and constraints families. + +SQL Server answers these three families from catalog views this backend did +not previously expose. What is asserted here is the whole local path: the +expression class renders through the dialect, the rendered statement keeps +caller values as bind parameters, and the parser folds the catalog's row +shape into the result type's record shape. No server is needed; the live +family coverage is the integration suite's question. + +The identity audit is beside them because it belongs to the same round: the +column listing already asks ``COLUMNPROPERTY(..., 'IsIdentity')``, and the +assertions pin that the value reaches ``IntrospectionColumnInfo`` rather than +being selected and dropped. +""" + +import inspect + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import ( + SQLServerConnectionConfig, +) +from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( + AsyncSQLServerIntrospector, + SQLServerIntrospector, +) + + +def make_introspector(version=(16, 0, 0)) -> SQLServerIntrospector: + config = SQLServerConnectionConfig( + host="localhost", + port=1433, + database="master", + username="sa", + password="", + ) + backend = SQLServerBackend(connection_config=config, version=version) + return SQLServerIntrospector(backend, executor=object()) + + +class TestPartitionStatements: + """The three catalogue questions behind one partition answer.""" + + def test_function_list_reads_the_catalogue_and_parameter_type(self): + sql, params = ( + make_introspector().partition_function_list_statement().to_sql() + ) + assert "[sys].[partition_functions] AS [pf]" in sql + assert "[sys].[partition_parameters] AS [pp]" in sql + assert "TYPE_NAME([pp].[user_type_id]) AS [data_type]" in sql + assert "TYPE_NAME([pp].[system_type_id]) AS [base_type]" in sql + assert "[pf].[boundary_value_on_right]" in sql + assert params == (), "the question names no caller value" + + def test_boundary_value_is_cast_off_the_sql_variant(self): + """``value`` is sql_variant and pyodbc refuses to fetch it (HY106).""" + sql, params = ( + make_introspector().partition_boundary_list_statement().to_sql() + ) + assert ( + "CAST([prv].[value] AS NVARCHAR(4000)) AS [boundary_value]" in sql + ) + assert params == () + + def test_scheme_list_joins_destinations_to_filegroups(self): + sql, params = ( + make_introspector().partition_scheme_list_statement().to_sql() + ) + assert "[sys].[partition_schemes] AS [ps]" in sql + assert "[sys].[destination_data_spaces] AS [dds]" in sql + assert "[sys].[data_spaces] AS [ds]" in sql + assert "[ps].[data_space_id] = [dds].[partition_scheme_id]" in sql + assert params == () + + def test_aligned_indexes_join_on_the_scheme_data_space(self): + """An index is aligned when it shares the scheme's ``data_space_id``.""" + sql, params = ( + make_introspector().partition_aligned_index_list_statement().to_sql() + ) + assert "[sys].[indexes] AS [i]" in sql + assert "[i].[data_space_id] = [ps].[data_space_id]" in sql + assert "[ic].[partition_ordinal] > 0" in sql + assert params == (), "no table named, no caller value carried" + + def test_a_table_name_narrows_the_aligned_indexes(self): + sql, params = ( + make_introspector() + .partition_aligned_index_list_statement( + table_name="orders", schema="sales" + ) + .to_sql() + ) + assert "[s].[name] = ?" in sql + assert "[o].[name] = ?" in sql + assert params == ("sales", "orders") + assert "'sales'" not in sql and "'orders'" not in sql + + def test_a_table_name_defaults_the_schema(self): + sql, params = ( + make_introspector() + .partition_aligned_index_list_statement(table_name="orders") + .to_sql() + ) + assert params == ("dbo", "orders") + + def test_index_storage_is_measured_per_index(self): + """Row and byte counts are this index's own partitions, not a sum.""" + sql, _ = ( + make_introspector().partition_aligned_index_list_statement().to_sql() + ) + assert sql.count("[sys].[dm_db_partition_stats] AS [st]") == 2 + assert "[st].[object_id] = [i].[object_id]" in sql + assert "[st].[index_id] = [i].[index_id]" in sql + assert "* 8192 AS [size_bytes]" in sql + + +class TestParsePartitions: + """The four row shapes fold into one record per partition function.""" + + @staticmethod + def _functions(): + return [ + { + "partition_function": "pf_orders", + "function_id": 11, + "type_desc": "RANGE", + "fanout": 3, + "boundary_value_on_right": 1, + "data_type": "datetime2", + "base_type": "datetime2", + "parameter_id": 1, + } + ] + + @staticmethod + def _boundaries(): + return [ + {"function_id": 11, "boundary_id": 2, "parameter_id": 1, + "boundary_value": "2026-02-01"}, + {"function_id": 11, "boundary_id": 1, "parameter_id": 1, + "boundary_value": "2026-01-01"}, + ] + + @staticmethod + def _schemes(): + return [ + {"scheme_name": "ps_orders", "scheme_id": 21, "function_id": 11, + "destination_id": 2, "filegroup_name": "fg_2"}, + {"scheme_name": "ps_orders", "scheme_id": 21, "function_id": 11, + "destination_id": 1, "filegroup_name": "fg_1"}, + ] + + @staticmethod + def _indexes(): + return [ + { + "scheme_name": "ps_orders", + "function_id": 11, + "schema_name": "dbo", + "table_name": "orders", + "index_name": "PK_orders", + "index_id": 1, + "index_type": "CLUSTERED", + "is_unique": 1, + "is_primary_key": 1, + "partition_column": "created_at", + "partition_ordinal": 1, + "row_count": 120, + "size_bytes": 8192, + } + ] + + def _parse(self, table_name=None): + return make_introspector().parse_partitions( + self._functions(), self._boundaries(), self._schemes(), + self._indexes(), table_name, + ) + + def test_function_level_fields_are_read(self): + partition = self._parse()[0] + assert partition.name == "pf_orders" + assert partition.function_id == 11 + assert partition.fanout == 3 + assert partition.range_direction == "RIGHT" + assert partition.data_type == "datetime2" + + def test_boundary_value_bits_read_left_when_the_flag_is_clear(self): + rows = self._functions() + rows[0]["boundary_value_on_right"] = 0 + partition = make_introspector().parse_partitions( + rows, self._boundaries(), self._schemes(), self._indexes() + )[0] + assert partition.range_direction == "LEFT" + + def test_boundaries_are_ordered_and_folded(self): + assert self._parse()[0].boundary_values == ( + "2026-01-01", "2026-02-01" + ) + + def test_scheme_keeps_its_filegroups(self): + scheme = self._parse()[0].schemes[0] + assert (scheme.name, scheme.data_space_id) == ("ps_orders", 21) + assert scheme.filegroups == ("fg_1", "fg_2") + + def test_aligned_index_keeps_its_partitioning_column_and_storage(self): + index = self._parse()[0].indexes[0] + assert (index.schema, index.table, index.name) == ("dbo", "orders", "PK_orders") + assert index.columns == ("created_at",) + assert (index.row_count, index.size_bytes) == (120, 8192) + + def test_a_table_filter_keeps_only_the_functions_it_uses(self): + functions = self._functions() + [ + {"partition_function": "pf_unused", "function_id": 12, + "fanout": 2, "boundary_value_on_right": 1} + ] + partitions = make_introspector().parse_partitions( + functions, self._boundaries(), self._schemes(), self._indexes(), + table_name="orders", + ) + assert [partition.name for partition in partitions] == ["pf_orders"] + + def test_a_function_no_index_uses_is_kept_without_a_table_filter(self): + functions = self._functions() + [ + {"partition_function": "pf_unused", "function_id": 12, + "fanout": 2, "boundary_value_on_right": 1} + ] + partitions = make_introspector().parse_partitions( + functions, self._boundaries(), self._schemes(), self._indexes() + ) + assert {partition.name for partition in partitions} == { + "pf_orders", "pf_unused" + } + + def test_a_schema_filter_keeps_only_the_functions_used_there(self): + sales_indexes = self._indexes() + sales_indexes[0]["schema_name"] = "sales" + partitions = make_introspector().parse_partitions( + self._functions(), self._boundaries(), self._schemes(), + sales_indexes, schema="sales", + ) + assert [partition.name for partition in partitions] == ["pf_orders"] + assert make_introspector().parse_partitions( + self._functions(), self._boundaries(), self._schemes(), + [], schema="dbo", + ) == [] + + +class TestTypeStatements: + """``sys.types`` with its table-type and description joins.""" + + def test_schema_is_a_bind_parameter(self): + sql, params = make_introspector().type_list_statement(schema="sales").to_sql() + assert "[s].[name] = ?" in sql + assert params == ("sales",) + assert "'sales'" not in sql + + def test_schema_defaults_to_dbo(self): + sql, params = make_introspector().type_list_statement().to_sql() + assert params == ("dbo",) + + def test_only_user_defined_types_are_asked_for(self): + sql, _ = make_introspector().type_list_statement().to_sql() + assert "[t].[is_user_defined] = 1" in sql + + def test_table_types_add_the_object_whose_columns_are_counted(self): + sql, _ = make_introspector().type_list_statement().to_sql() + assert "[sys].[table_types] AS [tt]" in sql + assert "[tt].[type_table_object_id]" in sql + assert "COUNT(*) FROM [sys].[columns] AS [c]" in sql + + def test_description_comes_from_the_type_class(self): + sql, _ = make_introspector().type_list_statement().to_sql() + assert "[ep].[class_desc] = 'TYPE'" in sql + assert "[ep].[major_id] = [t].[user_type_id]" in sql + assert "AS [type_comment]" in sql + + +class TestParseTypes: + def _parse_one(self, **overrides): + row = { + "type_name": "order_note", + "schema_name": "dbo", + "base_type": "nvarchar", + "max_length": 40, + "precision": 0, + "scale": 0, + "collation": "Latin1_General_CI_AS", + "is_nullable": 1, + "is_user_defined": 1, + "is_table_type": 0, + "is_assembly_type": 0, + "default_object_id": 0, + "rule_object_id": 7, + "column_count": 0, + "type_comment": "a note", + } + row.update(overrides) + return make_introspector().parse_types([row])[0] + + def test_an_alias_type_keeps_its_base_and_length(self): + type_ = self._parse_one() + assert type_.kind == "ALIAS_TYPE" + assert type_.base_type == "nvarchar" + assert type_.max_length == 40 + assert type_.rule_object_id == 7 + assert type_.comment == "a note" + assert type_.column_count is None + + def test_a_table_type_is_named_and_counted(self): + type_ = self._parse_one( + type_name="order_line", is_table_type=1, column_count=3 + ) + assert type_.kind == "TABLE_TYPE" + assert type_.is_table_type is True + assert type_.column_count == 3 + + def test_a_clr_type_is_named_as_one(self): + type_ = self._parse_one( + type_name="geo_point", is_assembly_type=1, base_type="geometry" + ) + assert type_.kind == "CLR_TYPE" + assert type_.is_assembly_type is True + + +class TestConstraintStatements: + """One ``sys.objects`` question with each family's view joined.""" + + def test_the_five_constraint_codes_are_the_question(self): + sql, _ = make_introspector().constraint_list_statement().to_sql() + assert "[o].[type] IN ('PK', 'UQ', 'F', 'C', 'D')" in sql + + def test_schema_is_a_bind_parameter_and_table_is_optional(self): + sql, params = ( + make_introspector().constraint_list_statement( + table_name="orders", schema="sales" + ).to_sql() + ) + assert "[s].[name] = ?" in sql + assert "[t].[name] = ?" in sql + assert params == ("sales", "orders") + + def test_no_table_name_asks_for_the_schema(self): + sql, params = make_introspector().constraint_list_statement().to_sql() + assert "[t].[name] = ?" not in sql + assert params == ("dbo",) + + def test_each_family_is_joined_on_its_object_id(self): + sql, _ = make_introspector().constraint_list_statement().to_sql() + for view in ( + "check_constraints", + "default_constraints", + "foreign_keys", + "key_constraints", + ): + assert f"[sys].[{view}] AS " in sql + assert "[cc].[object_id] = [o].[object_id]" in sql + assert "[dc].[object_id] = [o].[object_id]" in sql + assert "[fk].[object_id] = [o].[object_id]" in sql + assert "[kc].[object_id] = [o].[object_id]" in sql + + def test_foreign_key_columns_and_reference_are_joined(self): + sql, _ = make_introspector().constraint_list_statement().to_sql() + assert "[sys].[foreign_key_columns] AS [fkc]" in sql + assert "[sys].[tables] AS [rt]" in sql + assert "[sys].[schemas] AS [rs]" in sql + assert "[fkc].[referenced_column_id]" in sql + + def test_primary_key_columns_come_from_the_unique_index(self): + sql, _ = make_introspector().constraint_list_statement().to_sql() + assert "[ic].[object_id] = [kc].[parent_object_id]" in sql + assert "[ic].[index_id] = [kc].[unique_index_id]" in sql + assert "[ic].[key_ordinal] > 0" in sql + + def test_system_constraints_are_not_in_the_listing(self): + sql, _ = make_introspector().constraint_list_statement().to_sql() + assert "[o].[is_ms_shipped] = 0" in sql + + +class TestParseConstraints: + @staticmethod + def _parse(rows): + return make_introspector().parse_constraints(rows) + + def test_primary_key_columns_are_ordered_by_key_ordinal(self): + rows = [ + {"constraint_name": "pk_orders", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "PK", + "constraint_type_desc": "PRIMARY_KEY_CONSTRAINT", + "key_ordinal": 2, "key_column": "tenant_id"}, + {"constraint_name": "pk_orders", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "PK", + "constraint_type_desc": "PRIMARY_KEY_CONSTRAINT", + "key_ordinal": 1, "key_column": "id"}, + ] + constraint = self._parse(rows)[0] + assert constraint.constraint_type == "PK" + assert constraint.columns == ("id", "tenant_id") + + def test_foreign_key_columns_and_reference_are_folded(self): + rows = [ + {"constraint_name": "fk_order_customer", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "F", + "constraint_type_desc": "FOREIGN_KEY_CONSTRAINT", + "fk_ordinal": 1, "fk_column": "customer_id", + "fk_referenced_column": "id", "referenced_schema": "dbo", + "referenced_table": "customers", "on_delete": "CASCADE", + "on_update": "NO_ACTION", "fk_disabled": 0, "fk_not_trusted": 1}, + ] + constraint = self._parse(rows)[0] + assert constraint.columns == ("customer_id",) + assert constraint.referenced_columns == ("id",) + assert (constraint.referenced_schema, constraint.referenced_table) == ( + "dbo", "customers", + ) + assert (constraint.delete_action, constraint.update_action) == ( + "CASCADE", "NO_ACTION", + ) + assert constraint.is_disabled is False + assert constraint.is_not_trusted is True + + def test_check_constraint_carries_its_expression_and_flags(self): + rows = [ + {"constraint_name": "ck_orders_total", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "C", + "constraint_type_desc": "CHECK_CONSTRAINT", + "check_column": "total", "check_column_id": 3, + "check_definition": "([total] > (0))", "check_disabled": 1, + "check_not_trusted": 1}, + ] + constraint = self._parse(rows)[0] + assert constraint.constraint_type == "C" + assert constraint.columns == ("total",) + assert constraint.definition == "([total] > (0))" + assert constraint.is_disabled is True + assert constraint.is_not_trusted is True + + def test_default_constraint_carries_its_expression(self): + rows = [ + {"constraint_name": "df_orders_status", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "D", + "constraint_type_desc": "DEFAULT_CONSTRAINT", + "default_column": "status", "default_column_id": 4, + "default_definition": "('pending')"}, + ] + constraint = self._parse(rows)[0] + assert constraint.constraint_type == "D" + assert constraint.columns == ("status",) + assert constraint.definition == "('pending')" + assert constraint.is_disabled is None + + def test_a_primary_key_does_not_acquire_the_foreign_key_columns(self): + rows = [ + {"constraint_name": "pk_orders", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "PK", + "key_ordinal": 1, "key_column": "id", + "fk_ordinal": None, "fk_column": None}, + ] + constraint = self._parse(rows)[0] + assert constraint.columns == ("id",) + assert constraint.referenced_columns == () + + def test_constraints_are_ordered_by_schema_table_and_name(self): + rows = [ + {"constraint_name": "z", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "C"}, + {"constraint_name": "a", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "D"}, + ] + assert [c.name for c in self._parse(rows)] == ["a", "z"] + + +class TestAsyncParity: + """The asynchronous manager answers the same three questions.""" + + @pytest.mark.parametrize( + ("sync_method", "async_method"), + [ + ("list_partitions", "list_partitions"), + ("list_types", "list_types"), + ("get_type_info", "get_type_info"), + ("list_constraints", "list_constraints"), + ], + ) + def test_the_async_class_has_the_same_method_with_the_same_parameters( + self, sync_method, async_method + ): + sync_parameters = inspect.signature( + getattr(SQLServerIntrospector, sync_method) + ).parameters + async_parameters = inspect.signature( + getattr(AsyncSQLServerIntrospector, async_method) + ).parameters + assert list(sync_parameters) == list(async_parameters) + assert inspect.iscoroutinefunction( + getattr(AsyncSQLServerIntrospector, async_method) + ) + + def test_the_async_parsers_are_the_same_functions(self): + for name in ("parse_partitions", "parse_types", "parse_constraints"): + sync_parser = getattr(SQLServerIntrospector, name) + async_parser = getattr(AsyncSQLServerIntrospector, name) + assert sync_parser is async_parser + + +class _AsyncExecutor: + """Answer each catalogue question with the rows the test declared.""" + + def __init__(self) -> None: + self.calls = [] + + async def execute(self, sql, params): + self.calls.append((sql, params)) + return _rows_for(sql) + + +class _SyncExecutor: + """The synchronous shape of :class:`_AsyncExecutor`.""" + + def __init__(self) -> None: + self.calls = [] + + def execute(self, sql, params): + self.calls.append((sql, params)) + return _rows_for(sql) + + +def _rows_for(sql): + if "partition_functions" in sql: + return [_function_row()] + if "partition_range_values" in sql: + return [_boundary_row()] + if "key_constraints" in sql: + return [_constraint_row()] + if "index_columns" in sql: + return [_index_row()] + if "partition_schemes" in sql: + return [_scheme_row()] + if "table_types" in sql: + return [_type_row()] + return [] + + +def _function_row(): + return { + "partition_function": "pf_orders", "function_id": 11, "fanout": 2, + "boundary_value_on_right": 1, "data_type": "datetime2", + "base_type": "datetime2", + } + + +def _boundary_row(): + return { + "function_id": 11, "boundary_id": 1, "parameter_id": 1, + "boundary_value": "2026-01-01", + } + + +def _scheme_row(): + return { + "scheme_name": "ps_orders", "scheme_id": 21, "function_id": 11, + "destination_id": 1, "filegroup_name": "fg_1", + } + + +def _index_row(): + return { + "scheme_name": "ps_orders", "function_id": 11, "schema_name": "dbo", + "table_name": "orders", "index_name": "PK_orders", "index_id": 1, + "index_type": "CLUSTERED", "is_unique": 1, "is_primary_key": 1, + "partition_column": "created_at", "partition_ordinal": 1, + "row_count": 2, "size_bytes": 8192, + } + + +def _type_row(): + return { + "type_name": "order_note", "schema_name": "dbo", "base_type": "nvarchar", + "max_length": 40, "is_nullable": 1, "is_user_defined": 1, + "is_table_type": 0, "is_assembly_type": 0, + } + + +def _constraint_row(): + return { + "constraint_name": "ck_orders_total", "schema_name": "dbo", + "table_name": "orders", "constraint_type": "C", + "constraint_type_desc": "CHECK_CONSTRAINT", "check_column": "total", + "check_definition": "([total] > (0))", "check_disabled": 0, + "check_not_trusted": 0, + } + + +class TestAsyncExecution: + """The await path reaches the same parsers and result types.""" + + def _introspector(self): + return AsyncSQLServerIntrospector( + make_introspector()._backend, executor=_AsyncExecutor() + ) + + @pytest.mark.asyncio + async def test_list_partitions_executes_all_four_questions(self): + introspector = self._introspector() + partitions = await introspector.list_partitions("orders") + assert [partition.name for partition in partitions] == ["pf_orders"] + assert len(introspector._executor.calls) == 4 + last_params = introspector._executor.calls[-1][1] + assert last_params == ("dbo", "orders") + + @pytest.mark.asyncio + async def test_list_types_and_get_type_info_answer(self): + introspector = self._introspector() + types = await introspector.list_types() + assert types[0].name == "order_note" + assert await introspector.get_type_info("order_note") == types[0] + assert await introspector.get_type_info("missing") is None + + @pytest.mark.asyncio + async def test_list_constraints_answers(self): + introspector = self._introspector() + constraints = await introspector.list_constraints("orders") + assert constraints[0].constraint_type == "C" + + +class TestSyncExecution: + """The synchronous manager reaches the same parsers and result types.""" + + def _introspector(self): + return SQLServerIntrospector( + make_introspector()._backend, executor=_SyncExecutor() + ) + + def test_list_partitions_executes_all_four_questions(self): + introspector = self._introspector() + partitions = introspector.list_partitions("orders") + assert [partition.name for partition in partitions] == ["pf_orders"] + assert len(introspector._executor.calls) == 4 + assert introspector._executor.calls[-1][1] == ("dbo", "orders") + + def test_list_types_and_get_type_info_answer(self): + introspector = self._introspector() + types = introspector.list_types() + assert types[0].name == "order_note" + assert introspector.get_type_info("order_note") == types[0] + assert introspector.get_type_info("missing") is None + + def test_list_constraints_answers(self): + introspector = self._introspector() + constraints = introspector.list_constraints("orders") + assert constraints[0].constraint_type == "C" + + +class TestIdentityAudit: + """IDENTITY columns must report ``is_auto_increment``.""" + + @staticmethod + def _parse_one(**catalog): + row = { + "COLUMN_NAME": "id", + "DATA_TYPE": "int", + "ORDINAL_POSITION": 1, + "IS_NULLABLE": "NO", + **catalog, + } + return make_introspector().parse_columns([row], "t", "dbo")[0] + + def test_identity_flag_becomes_auto_increment(self): + column = self._parse_one(IS_IDENTITY=1) + assert column.is_auto_increment is True + + def test_a_non_identity_column_is_not_auto_increment(self): + assert self._parse_one(IS_IDENTITY=0).is_auto_increment is False + assert self._parse_one().is_auto_increment is False + + def test_a_text_flag_does_not_read_zero_as_truthy(self): + """``bool("0")`` is ``True``; the flag is parsed, not coerced.""" + assert self._parse_one(IS_IDENTITY="0").is_auto_increment is False + assert self._parse_one(IS_IDENTITY="1").is_auto_increment is True + + def test_the_column_question_projects_the_identity_property(self): + """The flag is selected by ``COLUMNPROPERTY``, not inferred later.""" + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + ) + + sql, _ = ColumnInfoQueryStatement( + make_introspector()._backend.dialect, "users", schema="dbo" + ).to_sql() + assert "COLUMNPROPERTY" in sql + assert "IsIdentity" in sql + assert "IS_IDENTITY" in sql + + +class TestCapabilitySwitches: + def test_the_three_local_switches_answer_true(self): + dialect = make_introspector(version=(16, 0, 0))._backend.dialect + assert dialect.supports_partition_info() is True + assert dialect.supports_partition_metadata_introspection() is True + assert dialect.supports_type_introspection() is True + assert dialect.supports_constraint_introspection() is True + + def test_partition_metadata_follows_the_partitioning_floor(self): + dialect = make_introspector(version=(9, 0, 0))._backend.dialect + assert dialect.supports_partition_info() is False + assert dialect.supports_partition_metadata_introspection() is False + + def test_type_introspection_follows_the_table_type_floor(self): + """``sys.table_types`` and ``is_table_type`` arrived with 2008.""" + dialect = make_introspector(version=(9, 0, 0))._backend.dialect + assert dialect.supports_type_introspection() is False + newer = make_introspector(version=(10, 0, 0))._backend.dialect + assert newer.supports_type_introspection() is True