Skip to content

feat(analytics): saved-query export/import — promote experiment queries dev->prod (#2259) - #2260

Closed
cyberantonz wants to merge 3 commits into
constructorfabric:mainfrom
cyberantonz:pres/2259-saved-query-export-import
Closed

feat(analytics): saved-query export/import — promote experiment queries dev->prod (#2259)#2260
cyberantonz wants to merge 3 commits into
constructorfabric:mainfrom
cyberantonz:pres/2259-saved-query-export-import

Conversation

@cyberantonz

@cyberantonz cyberantonz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What

Adds saved-query export/import to the analytics service — two thin endpoints over the existing saved-query CRUD (#1965):

  • GET /v1/queries/export -> the calling tenant's saved queries as a portable JSON document ({ queries: [{ name, description, sql }] }), carrying no id, tenant, or timestamps.
  • POST /v1/queries/import -> bulk-creates from that document; returns { imported, skipped }.

Why

A tier-3 experiment is built on a dev stand: a bespoke frontend plus new saved queries authored against that stand's data. The frontend promotes to production by PR into src/frontend, but the saved queries it depends on are otherwise stranded on the dev stand's database. Export/import lets those queries travel dev -> prod alongside the frontend.

Mechanism

Portable by construction — no SQL rewriting:

  • SQL is contract-relative (silver.* / gold), identical across stands.
  • Tenant is session-injected at run time (the {tenant} parameter), never stored. Import drops any source id/tenant, generates a fresh id, and binds the importing session's tenant, so an import can never write cross-tenant.
  • Import re-validates every SQL through the single-SELECT gate (validate_single_select) — one bad statement rejects the whole document as a 400.
  • Collision policy: a row whose name already exists for the tenant is skipped, never overwritten, so a re-import is idempotent.

No schema change — reuses the existing saved_queries table.

Out of scope

  • Curated/product queries auto-seeded to every stand (definitions-as-data, semantic epic [epic] Semantic Layer #2213), not a bespoke query seeder.
  • A/B rules, traffic routing, variant assignment.

Specs & tests

  • Extends the presentation PRD/DESIGN (export/import FR + the two endpoints under API Contracts); cfs validate is 0 errors per artifact.
  • Regenerates the analytics OpenAPI contract; drift gate green.
  • Rust unit tests for the collision/dedup policy; stand endpoint-contract tests for export 200, import 201 + idempotent re-import, and import re-gating a non-read SQL, plus the coverage-gate wiring for the two new operations.

Closes #2259
Part of #1803

Summary by CodeRabbit

  • New Features
    • Added authenticated endpoints to export saved queries as portable JSON and import them into another tenant.
    • Imported queries receive fresh identifiers and are revalidated for safe SQL.
    • Duplicate or same-name queries are skipped without creating conflicts.
    • Import responses report the number of imported and skipped queries.
  • Documentation
    • Updated API documentation, requirements, and acceptance criteria for saved-query portability.

…es across stands (constructorfabric#2259)

Two thin endpoints over the saved-query CRUD so a promoted tier-3 experiment
arrives with the queries it depends on, instead of leaving them stranded on the
dev stand's database:

- GET  /v1/queries/export  dumps the tenant's queries as portable JSON
  ({name, description, sql} only — no id, tenant, or timestamps).
- POST /v1/queries/import  bulk-creates from that document, re-validating each
  SQL through the single-SELECT gate, dropping any source id/tenant and
  re-homing each row to the importing session's tenant with a fresh id.

Portable by construction: the SQL is contract-relative and the tenant is
session-injected at run time, so no rewriting is needed. Same-name collisions
are skipped (never overwritten), so a re-import is idempotent; the response
reports imported vs skipped counts. No schema change — reuses saved_queries.

Extends the presentation PRD/DESIGN (export/import FR + the two endpoints under
API Contracts), regenerates the analytics OpenAPI contract, and adds stand
endpoint-contract tests plus the coverage-gate wiring for the two operations.

Closes constructorfabric#2259
Part of constructorfabric#1803

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz
cyberantonz requested a review from a team as a code owner August 6, 2026 05:26
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cyberantonz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f742e9b8-b57c-4a7d-9918-ba0de673a1db

📥 Commits

Reviewing files that changed from the base of the PR and between 47bc952 and 18efddd.

📒 Files selected for processing (7)
  • docs/components/backend/analytics/openapi.json
  • docs/domain/presentation-layer/specs/DESIGN.md
  • docs/domain/presentation-layer/specs/PRD.md
  • src/backend/services/analytics/src/api/saved_queries.rs
  • src/backend/services/analytics/src/domain/saved_query.rs
  • tests/stand/api/analytics/test_queries.py
  • tests/stand/api/schemas/analytics.py
📝 Walkthrough

Walkthrough

Adds portable saved-query export and import contracts and authenticated endpoints. Imports revalidate SQL, re-home queries to the importing tenant with fresh IDs, skip name collisions, and return imported and skipped counts.

Changes

Saved-query transfer

Layer / File(s) Summary
Portable contracts and API schemas
src/backend/services/analytics/src/domain/saved_query.rs, docs/components/backend/analytics/openapi.json, docs/domain/presentation-layer/specs/*, tests/stand/api/schemas/*
Defines portable saved-query records, export envelopes, import counts, OpenAPI contracts, documentation, and schema validation models.
Route registration and contract coverage
src/backend/services/analytics/src/api/mod.rs, src/backend/services/analytics/src/api/openapi_tests.rs, tests/stand/api/operations.py, tests/lib/insight_stand/coverage.py, tests/stand/api/analytics/test_request_contracts.py
Registers authenticated export and import routes and updates route, status, operation, and request-body coverage.
Export and import behavior
src/backend/services/analytics/src/api/saved_queries.rs
Exports tenant queries without identifiers. Imports validate all SQL, skip existing and duplicate names, create fresh tenant-owned rows, and return counts.
Behavior and integration validation
src/backend/services/analytics/src/api/saved_queries.rs, tests/stand/api/analytics/test_queries.py
Tests export shape, collision handling, idempotent imports, invalid SQL rejection, and cleanup helpers.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant saved_queries
  participant saved_queries_table
  participant validate_single_select
  Client->>saved_queries: GET /v1/queries/export
  saved_queries->>saved_queries_table: Load tenant queries
  saved_queries_table-->>saved_queries: Tenant saved queries
  saved_queries-->>Client: SavedQueryExport
  Client->>saved_queries: POST /v1/queries/import
  saved_queries->>validate_single_select: Validate each SQL statement
  validate_single_select-->>saved_queries: Validation result
  saved_queries->>saved_queries_table: Insert accepted queries for session tenant
  saved_queries_table-->>saved_queries: Insert result
  saved_queries-->>Client: ImportResponse
Loading

Possibly related PRs

Suggested reviewers: ktursunov

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the analytics saved-query export/import feature and its promotion use case.
Linked Issues check ✅ Passed The implementation satisfies issue #2259: portable export/import, SQL revalidation, tenant rehoming, fresh IDs, collision skipping, documentation, and tests.
Out of Scope Changes check ✅ Passed The changes remain within issue #2259 and cover implementation, API contracts, documentation, tests, and coverage wiring.
Docstring Coverage ✅ Passed Docstring coverage is 89.29% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (5)
tests/stand/api/analytics/test_queries.py (2)

339-346: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Test the all-or-nothing import contract.

The document contains only an invalid query. It cannot show whether a valid query before the invalid query was persisted. Add a unique valid query before DROP TABLE metrics, then assert that neither name exists after the 400 response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/stand/api/analytics/test_queries.py` around lines 339 - 346, Add a
uniquely named valid query before the invalid DROP TABLE metrics entry in the
import document, then update the post-response assertions to verify that neither
the valid query name nor the existing invalid query name appears in
_ids_by_name(api) after the 400 response, preserving the all-or-nothing contract
check.

292-296: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert description in both transfer directions.

The portable contract includes description. The export test checks only name and SQL. The import test checks only the imported name. A regression that drops description passes both tests.

  • tests/stand/api/analytics/test_queries.py#L292-L296: Assert the exported query description equals scratch_saved_query.description.
  • tests/stand/api/analytics/test_queries.py#L307-L319: Read the imported list item and assert its description equals "imported by the stand suite".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/stand/api/analytics/test_queries.py` around lines 292 - 296, Extend the
export assertion around the SavedQueryExport response at
tests/stand/api/analytics/test_queries.py:292-296 to verify the exported query
description matches scratch_saved_query.description. Also update the import
assertions at tests/stand/api/analytics/test_queries.py:307-319 to read the
imported list item and verify its description is "imported by the stand suite".
src/backend/services/analytics/src/api/saved_queries.rs (1)

463-490: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use one table-driven collision test.

The three tests exercise one partitioning rule with different inputs. Put the cases in one table-driven loop and add assertion messages that identify the case.

As per coding guidelines: “Make tests read as specifications: use table-driven loops with per-case assertion messages.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/saved_queries.rs` around lines 463 -
490, Replace the three separate tests around select_new_queries with one
table-driven test containing the existing collision, duplicate, and
distinct-name cases. Iterate over named cases, preserve each expected fresh-name
and skipped-count assertion, and include the case name in assertion messages so
failures identify the scenario.

Source: Coding guidelines

src/backend/services/analytics/src/api/mod.rs (1)

226-228: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove delivery-context comments from the route table.

The route names, DTOs, and handlers already express the behavior. Keep issue numbers and promotion scope in the PR and design documentation.

As per coding guidelines, “Use comments only when code cannot express the reason” and “Do not use module headers, issue numbers, or phase/scope notes in source comments.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/mod.rs` around lines 226 - 228, Remove
the delivery-context comment block above the export/import route definitions,
including the issue number and promotion-scope notes; leave the route names,
DTOs, and handlers unchanged.

Source: Coding guidelines

src/backend/services/analytics/src/domain/saved_query.rs (1)

83-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove service-level documentation comments.

These comments describe DTO fields and endpoint behavior in a service crate. Keep this content in the API contract or design documentation instead.

As per coding guidelines, “Use /// documentation comments only on exported items in shared library crates; … Do not add documentation comments to binaries or services.”

Also applies to: 97-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/saved_query.rs` around lines 83 -
88, Remove the service-level `///` documentation comments associated with the
saved-query export/import record, including the block near the record definition
and the additional block around lines 97–105. Leave the underlying types and
implementation unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/components/backend/analytics/openapi.json`:
- Around line 2122-2225: Add 415 Unsupported Media Type and 422 Unprocessable
Entity responses to the POST operation identified by operationId
analytics_api.queries.import, using the existing application/problem+json
Problem schema pattern. Preserve the current 400 response and accurately
document the actual text/plain and off-schema JSON failure statuses.

In `@docs/domain/presentation-layer/specs/PRD.md`:
- Line 213: Update the saved-query export/import requirement to use the
implemented document envelope with a top-level queries property containing the
query objects, matching the OpenAPI contract. Preserve the existing fields and
import/export behavior while replacing the top-level array representation.

In `@src/backend/services/analytics/src/api/saved_queries.rs`:
- Around line 176-192: Update the saved-query import flow around
load_query_names and Entity::insert_many to enforce uniqueness on
(insight_tenant_id, name), use conflict-ignore insertion, and count rows
rejected by the conflict as skipped. Preserve the existing fresh/skipped
classification while ensuring concurrent imports do not return a 500 for
duplicate names.
- Around line 159-199: Refactor import_saved_queries so it only extracts the
tenant and request data, calls a focused application/domain import operation,
and maps its result or error to the HTTP response. Move SQL validation,
existing-name loading, duplicate/collision selection, row construction,
persistence, and import-specific error mapping from import_saved_queries into
that operation, preserving the current all-or-nothing validation, skip behavior,
and ImportResponse fields.
- Line 140: Remove redundant commentary from
src/backend/services/analytics/src/api/saved_queries.rs at lines 140, 166-175,
201-218, 237-238, 403-404, 463-475, and 484: delete the module header,
import-flow narration, private helper and loader documentation, private
error-helper documentation, and test comments that duplicate test names or
intent. Keep code behavior unchanged and retain documentation only for exported
items or comments expressing non-obvious rationale.

In `@src/backend/services/analytics/src/domain/saved_query.rs`:
- Around line 100-102: Bound SavedQueryExport.queries at the route boundary for
both import and export: enforce documented item-count and payload-size limits
before deserialization or persistence, reject oversized imports, and cap or
paginate exports so responses cannot exceed the limits. Define and reuse symbols
such as MAX_SAVED_QUERY_TRANSFER_ITEMS and the payload-size limit across the
relevant handlers.

---

Nitpick comments:
In `@src/backend/services/analytics/src/api/mod.rs`:
- Around line 226-228: Remove the delivery-context comment block above the
export/import route definitions, including the issue number and promotion-scope
notes; leave the route names, DTOs, and handlers unchanged.

In `@src/backend/services/analytics/src/api/saved_queries.rs`:
- Around line 463-490: Replace the three separate tests around
select_new_queries with one table-driven test containing the existing collision,
duplicate, and distinct-name cases. Iterate over named cases, preserve each
expected fresh-name and skipped-count assertion, and include the case name in
assertion messages so failures identify the scenario.

In `@src/backend/services/analytics/src/domain/saved_query.rs`:
- Around line 83-88: Remove the service-level `///` documentation comments
associated with the saved-query export/import record, including the block near
the record definition and the additional block around lines 97–105. Leave the
underlying types and implementation unchanged.

In `@tests/stand/api/analytics/test_queries.py`:
- Around line 339-346: Add a uniquely named valid query before the invalid DROP
TABLE metrics entry in the import document, then update the post-response
assertions to verify that neither the valid query name nor the existing invalid
query name appears in _ids_by_name(api) after the 400 response, preserving the
all-or-nothing contract check.
- Around line 292-296: Extend the export assertion around the SavedQueryExport
response at tests/stand/api/analytics/test_queries.py:292-296 to verify the
exported query description matches scratch_saved_query.description. Also update
the import assertions at tests/stand/api/analytics/test_queries.py:307-319 to
read the imported list item and verify its description is "imported by the stand
suite".
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f6cf336-432c-44ec-984d-6461ae8f575b

📥 Commits

Reviewing files that changed from the base of the PR and between a008c56 and 47bc952.

📒 Files selected for processing (13)
  • docs/components/backend/analytics/openapi.json
  • docs/domain/presentation-layer/specs/DESIGN.md
  • docs/domain/presentation-layer/specs/PRD.md
  • src/backend/services/analytics/src/api/mod.rs
  • src/backend/services/analytics/src/api/openapi_tests.rs
  • src/backend/services/analytics/src/api/saved_queries.rs
  • src/backend/services/analytics/src/domain/saved_query.rs
  • tests/lib/insight_stand/coverage.py
  • tests/stand/api/analytics/test_queries.py
  • tests/stand/api/analytics/test_request_contracts.py
  • tests/stand/api/operations.py
  • tests/stand/api/schemas/__init__.py
  • tests/stand/api/schemas/analytics.py

Comment on lines +2122 to +2225
"/v1/queries/import": {
"post": {
"operationId": "analytics_api.queries.import",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SavedQueryExport"
}
}
},
"description": "Portable saved-query document to bulk-create",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportResponse"
}
}
},
"description": "Imported and skipped counts"
},
"400": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Not Found"
},
"409": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Conflict"
},
"429": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Too Many Requests"
},
"500": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"bearerAuth": []
}
],
"summary": "Import saved queries"
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document the actual import failure responses.

POST /v1/queries/import returns 415 for text/plain and 422 for off-schema JSON. This contract declares 400 but omits both actual responses. Clients generated from this OpenAPI document cannot rely on the documented error contract.

Add the 415 and 422 responses, or map extractor failures to the documented status code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/components/backend/analytics/openapi.json` around lines 2122 - 2225, Add
415 Unsupported Media Type and 422 Unprocessable Entity responses to the POST
operation identified by operationId analytics_api.queries.import, using the
existing application/problem+json Problem schema pattern. Preserve the current
400 response and accurately document the actual text/plain and off-schema JSON
failure statuses.

Comment thread docs/domain/presentation-layer/specs/PRD.md Outdated
Ok(StatusCode::NO_CONTENT)
}

// ── Export / Import ─────────────────────────────────────────

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove commentary that duplicates code or test intent.

Keep source comments only where code cannot express the reason. Remove private /// documentation comments from this service. Use test names to state the collision and import rules.

  • src/backend/services/analytics/src/api/saved_queries.rs#L140-L140: Remove the module-style source header.
  • src/backend/services/analytics/src/api/saved_queries.rs#L166-L175: Remove the import-flow narration.
  • src/backend/services/analytics/src/api/saved_queries.rs#L201-L218: Remove private helper documentation comments.
  • src/backend/services/analytics/src/api/saved_queries.rs#L237-L238: Remove the private loader documentation comment.
  • src/backend/services/analytics/src/api/saved_queries.rs#L403-L404: Remove the private error-helper documentation comment.
  • src/backend/services/analytics/src/api/saved_queries.rs#L463-L475: Remove test documentation comments that restate test names.
  • src/backend/services/analytics/src/api/saved_queries.rs#L484-L484: Remove the test documentation comment that restates test intent.

As per coding guidelines: “Use comments only when code cannot express the reason” and “Use /// documentation comments only on exported items in shared library crates.”

📍 Affects 1 file
  • src/backend/services/analytics/src/api/saved_queries.rs#L140-L140 (this comment)
  • src/backend/services/analytics/src/api/saved_queries.rs#L166-L175
  • src/backend/services/analytics/src/api/saved_queries.rs#L201-L218
  • src/backend/services/analytics/src/api/saved_queries.rs#L237-L238
  • src/backend/services/analytics/src/api/saved_queries.rs#L403-L404
  • src/backend/services/analytics/src/api/saved_queries.rs#L463-L475
  • src/backend/services/analytics/src/api/saved_queries.rs#L484-L484
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/saved_queries.rs` at line 140, Remove
redundant commentary from
src/backend/services/analytics/src/api/saved_queries.rs at lines 140, 166-175,
201-218, 237-238, 403-404, 463-475, and 484: delete the module header,
import-flow narration, private helper and loader documentation, private
error-helper documentation, and test comments that duplicate test names or
intent. Keep code behavior unchanged and retain documentation only for exported
items or comments expressing non-obvious rationale.

Source: Coding guidelines

Comment on lines +159 to +199
pub async fn import_saved_queries(
Extension(state): Extension<Arc<AppState>>,
Extension(ctx): Extension<SecurityContext>,
Json(doc): Json<SavedQueryExport>,
) -> Result<impl IntoResponse, CanonicalError> {
let tenant_id = ctx.subject_tenant_id();

// Re-gate every SQL before writing anything: one bad statement rejects the
// whole document, so an import is all-valid-or-nothing.
for query in &doc.queries {
validate_single_select(&query.sql).map_err(|e| invalid_import_sql(&query.name, e))?;
}

// Names already taken for this tenant are skipped, never overwritten; the
// pure partition also absorbs duplicate names inside the document, so a
// re-import is idempotent. Source id/tenant are dropped — every kept row is
// re-homed to the importing session's tenant with a fresh id.
let existing = load_query_names(&state, tenant_id).await?;
let (fresh, skipped) = select_new_queries(existing, doc.queries);

let rows: Vec<saved_queries::ActiveModel> = fresh
.into_iter()
.map(|query| new_query_row(tenant_id, query))
.collect();

let imported = rows.len();
if !rows.is_empty() {
saved_queries::Entity::insert_many(rows)
.exec(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to import saved queries");
CanonicalError::internal("failed to import saved queries").create()
})?;
}

Ok((
StatusCode::CREATED,
Json(ImportResponse { imported, skipped }),
))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move import policy out of the API handler.

import_saved_queries performs validation, collision policy, row construction, persistence, and response mapping. Keep the handler to extraction, one application call, and HTTP response mapping. Put the import use case and its error mapping in a focused application or domain module.

As per coding guidelines: “Keep API handlers to an orchestration skeleton of extract → validate → domain call → map → respond, with approximately 30 lines maximum; keep business logic and serialization formats out of the API layer.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/saved_queries.rs` around lines 159 -
199, Refactor import_saved_queries so it only extracts the tenant and request
data, calls a focused application/domain import operation, and maps its result
or error to the HTTP response. Move SQL validation, existing-name loading,
duplicate/collision selection, row construction, persistence, and
import-specific error mapping from import_saved_queries into that operation,
preserving the current all-or-nothing validation, skip behavior, and
ImportResponse fields.

Source: Coding guidelines

Comment on lines +176 to +192
let existing = load_query_names(&state, tenant_id).await?;
let (fresh, skipped) = select_new_queries(existing, doc.queries);

let rows: Vec<saved_queries::ActiveModel> = fresh
.into_iter()
.map(|query| new_query_row(tenant_id, query))
.collect();

let imported = rows.len();
if !rows.is_empty() {
saved_queries::Entity::insert_many(rows)
.exec(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to import saved queries");
CanonicalError::internal("failed to import saved queries").create()
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make name collision handling atomic.

Two imports can both load the same free name, classify it as fresh, and then insert it. Without a database uniqueness constraint, this creates duplicates. With a uniqueness constraint, the later request returns a 500 instead of reporting a skipped name.

Enforce uniqueness on (insight_tenant_id, name) and use a conflict-ignore insert. Include conflict-skipped rows in skipped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/saved_queries.rs` around lines 176 -
192, Update the saved-query import flow around load_query_names and
Entity::insert_many to enforce uniqueness on (insight_tenant_id, name), use
conflict-ignore insertion, and count rows rejected by the conflict as skipped.
Preserve the existing fresh/skipped classification while ensuring concurrent
imports do not return a 500 for duplicate names.

Comment on lines +100 to +102
pub struct SavedQueryExport {
pub queries: Vec<PortableSavedQuery>,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound saved-query transfer size.

queries is unbounded for both import input and export output. The handlers deserialize or collect the full document before persistence or response serialization. A tenant can cause excessive memory use and an oversized database batch.

Add documented limits such as MAX_SAVED_QUERY_TRANSFER_ITEMS and a payload-size limit at the route boundary. Reject oversized imports. Cap or paginate exports.

As per coding guidelines, “Bound every unbounded resource at the edge, including concurrent requests, response sizes, and queue depths; a missing bound is a bug.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/saved_query.rs` around lines 100 -
102, Bound SavedQueryExport.queries at the route boundary for both import and
export: enforce documented item-count and payload-size limits before
deserialization or persistence, reject oversized imports, and cap or paginate
exports so responses cannot exceed the limits. Define and reuse symbols such as
MAX_SAVED_QUERY_TRANSFER_ITEMS and the payload-size limit across the relevant
handlers.

Source: Coding guidelines

…sion semantics (constructorfabric#2259)

Address review feedback on the saved-query export/import PR:

- Bound import input at the route boundary: reject a document over
  MAX_IMPORT_QUERIES with a 400 before any load or write, so one request
  cannot force an unbounded batch (src/backend/CLAUDE.md: bound every
  unbounded thing at the edge).
- Align the PRD FR with the implemented `{ "queries": [...] }` document
  envelope (was a top-level array), matching the OpenAPI contract and DESIGN.
- Document in DESIGN that the name-collision skip is best-effort: the surface
  carries no (insight_tenant_id, name) uniqueness invariant — create already
  permits duplicate names — so concurrent imports race exactly as concurrent
  creates do, and export is bounded by construction and never truncates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — went through all six. Addressed in a006d64:

Applied

  • Bound import size (major): import now rejects a document over MAX_IMPORT_QUERIES (1000) with a 400 on the queries field, at the route boundary before any load or write. Export is bounded by construction (only the tenant's own analyst-authored queries; never truncated), so I did not cap it — silently dropping queries would defeat a promotion tool.
  • PRD envelope (minor): PRD FR now uses { "queries": [...] } to match the OpenAPI contract and DESIGN.

Documented rather than changed

  • Atomic collision handling (major): the saved-query surface has no (insight_tenant_id, name) uniqueness invariant — create already permits duplicate names — and adding one is a schema change, which is explicitly out of scope for this PR. The name-skip is therefore best-effort: two concurrent imports of the same new name race exactly as two concurrent creates do, and it is never a cross-tenant concern. DESIGN now states this explicitly instead of implying atomicity.

Skipped, with reasons

  • Document 415/422 on import (minor): openapi.json is generated from the handlers via .standard_errors and drift-gated in .github/workflows/openapi-specs.yml; hand-editing it fails the gate. The 415/422 under-declaration is a known, surface-wide condition (backend(analytics): legacy body endpoints return non-canonical errors + 422 (plain axum::Json, not CanonicalJson) #1670) shared by every analytics route (e.g. POST /v1/queries), not specific to import — the stand suite already pins the real 415/422 behaviour. Fixing it on one route by hand-editing generated output would be both inconsistent and immediately reverted by the gate.
  • Move import policy to a domain module (major): the sibling handlers in this file (create/update/run) all keep their validate-build-persist logic inline; the pure pieces (select_new_queries, new_query_row) are already extracted. A standalone domain module for import alone would make the file inconsistent for no correctness gain.
  • Remove commentary / /// docs (major): these match the file's established style — the pre-existing private helpers and tests in this same module (parse_json_each_row, missing_param_name, classify_run_error, and every test) already use /// docs and explanatory // "why" comments. Selectively stripping only the new ones would make the module inconsistent.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline a006d64f
git show --format=fuller --find-renames --find-copies -- \
  src/backend/services/analytics/src/api/saved_queries.rs \
  src/backend/services/analytics/src/domain/saved_query.rs \
  docs/domain/presentation-layer/specs/PRD.md \
  docs/domain/presentation-layer/specs/DESIGN.md \
  .github/workflows/openapi-specs.yml \
  docs/components/backend/analytics/openapi.json \
  a006d64f

printf '\n--- Relevant symbols and documented statements ---\n'
rg -n -C 4 \
  'MAX_IMPORT_QUERIES|import_saved_queries|select_new_queries|new_query_row|best-effort|atomic|queries' \
  src/backend/services/analytics/src/api/saved_queries.rs \
  docs/domain/presentation-layer/specs/PRD.md \
  docs/domain/presentation-layer/specs/DESIGN.md

printf '\n--- OpenAPI generation workflow references ---\n'
rg -n -C 3 'openapi|generate|drift|standard_errors' \
  .github/workflows/openapi-specs.yml \
  src/backend/services/analytics/src/api \
  -g '*.rs'

Length of output: 109186


@cyberantonz Verified a006d64f.

  • MAX_IMPORT_QUERIES limits imports to 1,000 queries before validation, database reads, or writes. The 400 field violation on queries has unit coverage.
  • The PRD now uses the { "queries": [...] } document envelope.
  • The DESIGN now states that same-tenant name skipping is best-effort. This matches the existing CRUD behavior, which permits duplicate names.
  • The OpenAPI drift gate confirms that the committed document is generated from the route definitions. The 415/422 limitation is not specific to this endpoint.
  • Keeping the import flow inline and retaining the local documentation style is consistent with the adjacent saved-query handlers.

I consider the six findings resolved or withdrawn as applicable. 🐇


✏️ Learnings added
Learnt from: cyberantonz
URL: https://github.com/constructorfabric/insight/pull/2260

Timestamp: 2026-08-06T05:37:56.643Z
Learning: In `src/backend/services/analytics/src/api/saved_queries.rs`, saved-query CRUD permits duplicate names because `saved_queries` has no `(insight_tenant_id, name)` uniqueness invariant. Import name-collision skipping is therefore best-effort under concurrent requests. The DESIGN must not describe this behavior as atomic.

Learnt from: cyberantonz
URL: https://github.com/constructorfabric/insight/pull/2260

Timestamp: 2026-08-06T05:37:56.643Z
Learning: The committed Analytics OpenAPI document at `docs/components/backend/analytics/openapi.json` is generated from the route definitions by the offline `analytics openapi` command. `.github/workflows/openapi-specs.yml` drift-checks the generated document. Do not hand-edit isolated OpenAPI response declarations that the generator does not produce; the known 415/422 declaration gap is tracked as `#1670` and affects the wider analytics API surface.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

…solves (constructorfabric#2259)

The promoted tier-3 frontend calls each saved query by id
(`/v1/queries/{id}/run`) — there is no run-by-name path — so a query that
lands under a fresh id on the target stand is unreachable from the FE that was
promoted alongside it. Dropping the source id (the earlier design) broke
exactly the use case this feature exists for.

Import now PRESERVES the source id and re-homes only the tenant; export carries
the id. Cross-tenant safety is unchanged — it comes from the tenant-scoped
reads (`find`/`list`/`run` filter `insight_tenant_id`), not from rewriting the
id. Collision/idempotency is keyed on id, not name (names are not unique): an
id already present in the table is skipped, checked globally over the incoming
ids because the id is the sole primary key, so an already-taken id cannot be
re-homed and skipping it (rather than colliding on insert) keeps a re-import
idempotent. DESIGN notes the single-target-tenant consequence and the
best-effort concurrency edge.

Specs, OpenAPI, generated models, and stand tests updated to match; the import
test now asserts the query lands under its own id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Anton Zelenov <antonz@constructor.tech>
@cyberantonz

Copy link
Copy Markdown
Contributor Author

Closing — the design moved. Migrating saved queries doesn't integrate anything into the product FE; the FE renders metrics (by metric_key), so the real path is query→custom metric. The schema already scaffolds it (source_kind='custom_observation_sql', origin='custom', and the YAML reconcile's disable-missing is scoped to origin='builtin' so custom rows survive). Reworking as custom-metric CRUD + export/import (keyed on metric_key) + a /metrics FE surface, with custom_observation_sql wired into the compiler so a metric can carry multi-table custom SQL. Continues in the same branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[pres] Saved-query export/import — promote experiment queries dev->prod

2 participants