Contents
- PLAN: View Inlining for Stream Tables
- Table of Contents
- 1. Problem Statement
- 2. Design Overview
- 3. Architecture Decision: Why Parse-Tree Rewrite
- 4. Detailed Design
- 5. Implementation Steps
- Step 1: Implement rewrite_views_inline() in parser.rs
- Step 2: Export from dvm/mod.rs
- Step 3: Wire into the rewrite chain in api.rs
- Step 4: Reject materialized views
- Step 5: Add foreign table rejection
- Step 6: Add view DDL tracking to hooks
- Step 7: Store original query in catalog
- Step 8: Add view dependency tracking
- Step 9: Write tests
- 6. Edge Cases & Constraints
- 6.1 Nested views (view → view → table)
- 6.2 Views with CTEs
- 6.3 Views with set operations
- 6.4 Views with SECURITY DEFINER
- 6.5 Views with column renaming
- 6.6 Views in non-public schemas
- 6.7 Views with * (wildcard) expansion
- 6.8 Views in the fromClause of set operations
- 6.9 Recursive CTEs referencing views
- 6.10 Performance consideration
- 7. DDL Hook Integration
- 8. Catalog Impact
- 9. Testing Plan
- 10. Risk Assessment
- 11. Future Work
- Appendix A: Affected Files Summary
- Appendix B: Execution Order
PLAN: View Inlining for Stream Tables
Status: Implemented
Date: 2026-02-25
Branch: main
Resolves: GAP_SQL_PHASE_6 G2.1 (P0 — views as sources in DIFFERENTIAL mode)
Related: GAP_SQL_PHASE_6 G2.2 (materialized views — handled separately)
Effort: 8–12 hours (implementation + tests)
Table of Contents
- Problem Statement
- Design Overview
- Architecture Decision: Why Parse-Tree Rewrite
- Detailed Design
- Implementation Steps
- Edge Cases & Constraints
- DDL Hook Integration
- Catalog Impact
- Testing Plan
- Risk Assessment
- Future Work
1. Problem Statement
When a user creates a stream table whose defining query references a view, pg_trickle silently fails to track changes in DIFFERENTIAL mode:
CREATE VIEW active_orders AS
SELECT * FROM orders WHERE status = 'active';
-- Stream table referencing the view:
SELECT pgtrickle.create('order_summary', 'DIFFERENTIAL',
'SELECT customer_id, COUNT(*) FROM active_orders GROUP BY customer_id');
What happens today:
resolve_table_oid()resolvesactive_ordersto an OID (views have OIDs)parse_from_item()createsOpTree::Scan { table_oid, ... }for the viewextract_source_relations()classifies the view assource_type = "VIEW"- CDC trigger loop at
api.rs:274skips views:if source_type == "TABLE" - No triggers are created → changes to
ordersare invisible → the stream table becomes permanently stale after initial population
There is no error, no warning. The user sees a healthy stream table that never updates. This is P0 — silent data staleness.
Current CDC trigger skip behavior
// api.rs lines 273-277
for (source_oid, source_type) in &source_relids {
if source_type == "TABLE" {
setup_cdc_for_source(*source_oid, pgt_id, &change_schema)?;
}
}
2. Design Overview
View inlining transparently replaces view references in the defining query with the view’s underlying SELECT definition as a subquery. After inlining, the query references only base tables, so CDC triggers land on the correct targets.
-- Before inlining:
SELECT customer_id, COUNT(*) FROM active_orders GROUP BY customer_id
-- After inlining:
SELECT customer_id, COUNT(*)
FROM (SELECT orders.order_id, orders.customer_id, orders.status, orders.amount
FROM orders
WHERE status = 'active') AS active_orders
GROUP BY customer_id
This is implemented as auto-rewrite pass #0 — the first rewrite in the existing chain, before DISTINCT ON, GROUPING SETS, etc. This ensures:
- View definitions containing DISTINCT ON, GROUPING SETS, etc. get further rewritten by the downstream passes
extract_source_relations()sees only base tables (source_type = "TABLE")- CDC triggers are created on the actual base tables
- The DVM parser builds
OpTree::Scannodes for base tables with real PKs
Data flow after this change
┌── rewrite_views_inline() ← NEW (pass #0)
│
User SQL ──────────>├── rewrite_distinct_on()
├── rewrite_grouping_sets()
├── rewrite_scalar_subquery_in_where()
├── rewrite_sublinks_in_or()
├── rewrite_multi_partition_windows()
│
├── validate_defining_query()
├── reject_limit_offset()
├── reject_unsupported_constructs()
├── parse_defining_query_full()
│
├── extract_source_relations() ← sees base tables only
├── setup_cdc_for_source() ← triggers on base tables
└── catalog insert
3. Architecture Decision: Why Parse-Tree Rewrite
Rejected alternatives
| Approach | Description | Why rejected |
|---|---|---|
| Reject views (G2.1 Option A) | Return error when view detected | Unnecessarily restrictive; views are a common SQL abstraction |
| Auto-downgrade to FULL (G2.1 Option C) | Detect view, switch to FULL mode | Performance penalty; doesn’t solve the underlying problem |
| OpTree-level expansion | Expand at parse_from_item() time |
Too late — extract_source_relations() runs on the original query string, not the OpTree. Also complicates the DVM tree. |
| Regex string replacement | s/view_name/(view_def)/ |
Fragile — doesn’t handle schema qualification, aliases, quoting, nested views |
Chosen: Parse-tree-level SQL rewrite (Option B+)
Follows the exact pattern of the 5 existing rewrites:
- Call
pg_sys::raw_parser()on the query string - Walk the
SelectStmt.fromClauselooking forRangeVarnodes - For each
RangeVar, resolve OID → checkrelkindinpg_class - If
relkind = 'v': get definition viapg_get_viewdef(oid, true) - Replace the
RangeVarwith(view_definition) AS aliasin the deparsed SQL - Return the rewritten SQL string
Why this is robust:
- View definitions from
pg_get_viewdef()use fully-qualified table names - PostgreSQL’s own pretty-printer handles all SQL syntax correctly
- Column aliases are preserved through the subquery alias
- Nested views are handled by iterating until fixpoint
- All existing deparse infrastructure (
deparse_select_stmt_to_sql,deparse_from_item_to_sql) is reusable
4. Detailed Design
4.1 Core function: rewrite_views_inline()
// src/dvm/parser.rs
/// Auto-rewrite pass #0: Replace view references with inline subqueries.
///
/// For each RangeVar in the FROM clause that resolves to a PostgreSQL view
/// (relkind='v'), replaces it with `(view_definition) AS alias`.
/// Handles nested views by iterating until no views remain (fixpoint).
///
/// Materialized views (relkind='m') are NOT inlined — their semantics
/// differ (stale snapshot vs live query). They are rejected later.
pub fn rewrite_views_inline(query: &str) -> Result<String, PgTrickleError> {
let mut current = query.to_string();
let max_depth = 10; // Guard against pathological nesting
for depth in 0..max_depth {
let rewritten = rewrite_views_inline_once(¤t)?;
if rewritten == current {
return Ok(current); // Fixpoint reached — no more views
}
current = rewritten;
}
Err(PgTrickleError::QueryParseError(format!(
"View inlining exceeded maximum nesting depth of {}. \
This may indicate circular view dependencies.",
max_depth
)))
}
4.2 Single-pass rewrite: rewrite_views_inline_once()
Algorithm for one pass:
- Parse the query with
raw_parser() - Extract the
SelectStmt - Handle set operations: if
op != SETOP_NONE, recurse intolarg/rarg - Walk the
fromClauselist - For each node:
- If
RangeVar: resolve OID → check relkind → if ‘v’, get view def - If
JoinExpr: recurse intolargandrarg - If
RangeSubselect: inspect innerSelectStmtfor view refs
- If
- If any views found, deparse the modified query back to SQL
- If no views found, return the original string unchanged
4.3 View definition retrieval
SELECT pg_get_viewdef(oid, true) -- pretty-print = true
pg_get_viewdef(oid, true) returns the view’s stored query with:
- Fully-qualified table names (schema.table)
- Explicit column lists (no * expansion in the definition itself,
though the stored definition preserves the original form)
- Cleaned-up formatting
4.4 Alias preservation
Critical for correctness: the view’s alias (or implicit name) must be preserved so column references in the outer query still resolve.
-- Input: SELECT v.x FROM my_view AS v WHERE v.y > 5
-- Output: SELECT v.x FROM (SELECT ...) AS v WHERE v.y > 5
-- Input: SELECT my_view.x FROM my_view (implicit alias = view name)
-- Output: SELECT my_view.x FROM (SELECT ...) AS my_view
Rules:
1. If rv.alias is non-NULL: use the explicit alias
2. If rv.alias is NULL: use rv.relname (the view name) as alias
4.5 Column alias handling
Special case: SELECT * FROM my_view. PostgreSQL expands * to the view’s
output columns. After inlining, the subquery’s output columns must match
the view’s column names.
pg_get_viewdef() returns the view definition with the view’s output column
names. For example:
CREATE VIEW v(a, b) AS SELECT x, y FROM t;
-- pg_get_viewdef returns: SELECT t.x AS a, t.y AS b FROM t
However, pg_get_viewdef does NOT always alias columns. It returns the original
definition as-is. So if the view was CREATE VIEW v AS SELECT x, y FROM t,
it returns SELECT x, y FROM t — meaning the subquery alias columns are
x and y, not renamed.
This is correct because PostgreSQL creates the view’s column list from the SELECT output. The original names are preserved through the subquery alias.
4.6 CTE interaction
If the defining query has CTEs that reference views, those are handled
automatically: the rewrite walks the full SelectStmt including its
withClause CTE bodies.
If a view definition itself contains CTEs, that’s fine — the inline
subquery becomes (WITH ... SELECT ... FROM ...) AS alias, which
PostgreSQL 14+ supports (CTEs in subqueries).
For PostgreSQL versions before 14 where CTEs in subqueries aren’t supported: pg_trickle targets PG 18, so this is not a concern.
4.7 Handling views in JOINs
Views can appear on either side of a JOIN:
SELECT t.x, v.y FROM table1 t JOIN my_view v ON t.id = v.id
The rewrite walks JoinExpr.larg and JoinExpr.rarg recursively, so
views in any join position are inlined.
4.8 LATERAL interaction
SELECT t.*, l.* FROM table1 t, LATERAL (SELECT * FROM my_view WHERE my_view.id = t.id) AS l
The view reference inside the LATERAL subquery is within a RangeSubselect
node. The rewrite recurses into subselect bodies, so this works. The outer
column reference (t.id) remains valid because the subquery structure is
preserved.
However: if the view itself contains a LATERAL reference that depends on outer columns, inlining is still correct because the view definition is placed as-is inside the subquery wrapper.
5. Implementation Steps
Step 1: Implement rewrite_views_inline() in parser.rs
File: src/dvm/parser.rs
Location: Before rewrite_distinct_on() (around line 1854)
Effort: 3–4 hours
Functions to create:
| Function | Purpose |
|---|---|
rewrite_views_inline(query) |
Outer loop — iterates until fixpoint |
rewrite_views_inline_once(query) |
Single pass — parse, walk, replace, deparse |
resolve_relkind(schema, table) |
SPI lookup: SELECT relkind FROM pg_class ... |
get_view_definition(schema, table) |
SPI call: pg_get_viewdef(oid, true) |
The deparse step reuses the existing deparse_select_stmt_to_sql(),
deparse_from_item_to_sql(), and related functions. The key modification
is that when a RangeVar is identified as a view, instead of deparsing it
as "schema"."view_name", it’s deparsed as (view_definition) AS alias.
Implementation approach — deparse with substitution:
Rather than mutating the parse tree in-place (which is complex with raw C pointers), the implementation will:
- Walk the
fromClauseto identify which RangeVars are views - Build a substitution map:
(schema, name) → (view_sql, alias) - Deparse the full query using modified deparse functions that check the substitution map when processing RangeVar nodes
This avoids any unsafe mutation of the parse tree while reusing the
existing deparse infrastructure.
Step 2: Export from dvm/mod.rs
File: src/dvm/mod.rs
Change: Add rewrite_views_inline to the pub use parser::{ ... } export list
Effort: 1 minute
pub use parser::{
parse_defining_query, parse_defining_query_full, reject_limit_offset,
reject_unsupported_constructs, rewrite_distinct_on, rewrite_grouping_sets,
rewrite_multi_partition_windows, rewrite_scalar_subquery_in_where,
rewrite_sublinks_in_or, rewrite_views_inline, // ← NEW
...
};
Step 3: Wire into the rewrite chain in api.rs
File: src/api.rs
Location: create_stream_table_impl(), before rewrite_distinct_on()
(line 68)
Effort: 15 minutes
// ── View inlining auto-rewrite ─────────────────────────────────
// Views are replaced with their underlying SELECT definition as
// inline subqueries. This ensures CDC triggers land on base tables
// and the DVM parser sees real table scans with PKs. Must run first
// so view definitions get further rewritten by downstream passes.
let query = &crate::dvm::rewrite_views_inline(query)?;
// ── DISTINCT ON auto-rewrite ───────────────────────────────────
let query = &crate::dvm::rewrite_distinct_on(query)?;
// ... rest of chain unchanged ...
Step 4: Reject materialized views
File: src/api.rs or src/dvm/parser.rs
Location: In the view inlining function, when relkind = 'm' is detected
Effort: 30 minutes
When a RangeVar resolves to a materialized view (relkind = 'm'), the
rewrite should not inline it — instead, it should return an error:
if relkind == "m" {
return Err(PgTrickleError::UnsupportedOperator(format!(
"Materialized view '{}' cannot be used as a source in DIFFERENTIAL mode. \
Materialized views are stale snapshots — CDC triggers cannot track \
REFRESH MATERIALIZED VIEW. Use the underlying query directly, or \
switch to FULL refresh mode.",
view_name
)));
}
For FULL mode, materialized views are fine (no CDC needed) — but since the rewrite runs before mode checking, we need to either:
(a) Pass the refresh mode into the rewrite function, or
(b) Only reject in DIFFERENTIAL mode during the later validation phase
Recommended: Option (b) — keep the rewrite idempotent and mode-unaware. Add a separate validation step after the rewrite chain that checks if any materialized views were found that couldn’t be inlined:
// After rewrites, before parse_defining_query_full():
if refresh_mode == RefreshMode::Differential {
crate::dvm::reject_materialized_views(query)?;
}
Step 5: Add foreign table rejection
File: Same as Step 4
Effort: 15 minutes
Also reject relkind = 'f' (foreign tables) with a clear message:
if relkind == "f" {
return Err(PgTrickleError::UnsupportedOperator(format!(
"Foreign table '{}' cannot be used as a source in DIFFERENTIAL mode. \
Row-level triggers cannot be created on foreign tables. \
Use FULL refresh mode instead.",
table_name
)));
}
Step 6: Add view DDL tracking to hooks
File: src/hooks.rs
Location: handle_ddl_command() match block
Effort: 1–2 hours
Add a case for view DDL:
// ── View DDL ──────────────────────────────────────────────────
("view", "CREATE VIEW") | ("view", "ALTER VIEW") => {
handle_view_change(cmd);
}
The handle_view_change() function needs to:
- Resolve the view OID from
cmd.objid - Get all base tables the view depends on (walk
pg_depend) - For each base table, find stream tables that depend on it
- Mark affected stream tables for reinit (
needs_reinit = true)
This handles CREATE OR REPLACE VIEW which changes a view definition.
After reinit, the view inlining rewrite runs again with the new definition.
Important: The hook also needs to track view drops. When a view is dropped, any stream table whose original defining query referenced that view can no longer be re-created. This is handled in Step 8.
Step 7: Store original query in catalog
File: src/lib.rs (schema), src/catalog.rs, src/api.rs
Effort: 1–2 hours
Add an original_query column to preserve the user’s original SQL:
ALTER TABLE pgtrickle.pgt_stream_tables
ADD COLUMN original_query TEXT;
Schema change in src/lib.rs:
CREATE TABLE IF NOT EXISTS pgtrickle.pgt_stream_tables (
...
defining_query TEXT NOT NULL, -- Rewritten (post-inlining)
original_query TEXT, -- Original user SQL (pre-inlining)
...
);
Why both: The defining_query is used by the refresh engine (needs
the expanded form). The original_query is needed for:
- Reinit after view definition change: Re-run the rewrite pipeline on the original query to pick up the new view definition
- User introspection:
pgtrickle.info()shows what the user wrote - ALTER stream table: If we ever support changing the defining query
In api.rs:
// Store both the original and rewritten query
StreamTableMeta::insert(
pgt_relid, &table_name, &schema,
query, // defining_query (rewritten)
Some(original), // original_query (user's input)
schedule_str, refresh_mode,
)?;
Step 8: Add view dependency tracking
File: src/api.rs (dependency registration), src/catalog.rs
Effort: 1 hour
Even though the rewritten query only references base tables, we need to track which views were inlined so that:
- View DDL changes trigger reinit (Step 6)
- View drops are detected and reported
Approach: After inlining, call extract_source_relations on both
the original and rewritten queries. Register base table dependencies from
the rewritten query (for CDC), and register view “soft dependencies” from
the original-only relations (for DDL tracking):
let rewritten_sources = extract_source_relations(rewritten_query)?;
let original_sources = extract_source_relations(original_query)?;
// Views appear in original but not rewritten (they were inlined)
let view_sources: Vec<_> = original_sources.iter()
.filter(|(_, stype)| stype == "VIEW")
.collect();
// Register base table dependencies (from rewritten query)
for (source_oid, source_type) in &rewritten_sources { ... }
// Register view soft-dependencies (for DDL tracking only)
for (view_oid, _) in &view_sources {
StDependency::insert_with_snapshot(
pgt_id, *view_oid, "VIEW", None, None, None,
)?;
}
The view dependency rows enable find_downstream_pgt_ids() to find
affected stream tables when a view is modified.
Step 9: Write tests
Effort: 2–3 hours
See Section 9: Testing Plan for details.
6. Edge Cases & Constraints
6.1 Nested views (view → view → table)
Handled by the fixpoint loop in rewrite_views_inline(). Each pass
inlines one level of views. A view referencing another view becomes a
query referencing a view after the first pass, which gets inlined in
the second pass. Limited to 10 iterations to prevent runaway loops
(PostgreSQL prevents circular view dependencies, so this is a safety
net, not a real concern).
Example:
CREATE VIEW v1 AS SELECT * FROM base_table WHERE x > 0;
CREATE VIEW v2 AS SELECT * FROM v1 WHERE y < 100;
-- User's query:
SELECT COUNT(*) FROM v2
-- After pass 1:
SELECT COUNT(*) FROM (SELECT * FROM v1 WHERE y < 100) AS v2
-- After pass 2:
SELECT COUNT(*) FROM (SELECT * FROM (SELECT * FROM base_table WHERE x > 0) AS v1 WHERE y < 100) AS v2
6.2 Views with CTEs
pg_get_viewdef() returns the full CTE definition. Example:
CREATE VIEW v AS WITH cte AS (SELECT ...) SELECT * FROM cte;
-- pg_get_viewdef returns: WITH cte AS (SELECT ...) SELECT cte.col FROM cte
When inlined: FROM (WITH cte AS (...) SELECT ...) AS v. PostgreSQL 14+
supports CTEs inside subqueries. Since pg_trickle targets PG 18, this works.
6.3 Views with set operations
CREATE VIEW v AS SELECT * FROM t1 UNION ALL SELECT * FROM t2;
-- pg_get_viewdef returns: SELECT t1.col FROM t1 UNION ALL SELECT t2.col FROM t2
When inlined: FROM (SELECT ... UNION ALL SELECT ...) AS v. Valid SQL.
6.4 Views with SECURITY DEFINER
pg_get_viewdef() returns the query regardless of security settings.
However, after inlining, the query executes with the current user’s
permissions, not the view definer’s. This could cause permission errors
if the user lacks direct access to base tables.
Handling: If the user can SELECT through the view but not directly
from the base tables, the validate_defining_query() step (which runs
LIMIT 0 on the rewritten query) will catch the permission error and
report it clearly. No special handling needed — the error message will be:
ERROR: permission denied for table <base_table>
HINT: The view '<view_name>' was expanded inline. You need SELECT privilege
on the underlying tables, or use FULL refresh mode.
We should wrap the validation error to add this hint.
6.5 Views with column renaming
CREATE VIEW v(a, b) AS SELECT x, y FROM t;
pg_get_viewdef(oid, true) returns SELECT t.x AS a, t.y AS b FROM t,
which correctly maps the view’s column names. No special handling needed.
6.6 Views in non-public schemas
pg_get_viewdef() returns schema-qualified references:
CREATE VIEW myschema.v AS SELECT * FROM myschema.t;
-- Returns: SELECT t.id, t.name FROM myschema.t
The rewrite resolves the view’s schema from the RangeVar.schemaname
or defaults to search_path resolution. No special handling needed.
6.7 Views with * (wildcard) expansion
The user’s defining query may use SELECT * FROM my_view. After inlining:
-- Before: SELECT * FROM my_view
-- After: SELECT * FROM (SELECT t.col1, t.col2, t.col3 FROM t) AS my_view
The wildcard * in the outer query resolves to the subquery’s output
columns. pg_get_viewdef returns explicit column lists, so this works.
6.8 Views in the fromClause of set operations
If the user writes:
SELECT * FROM v1 UNION ALL SELECT * FROM v2
The rewrite must handle set operations by recursing into larg and rarg
of the SelectStmt when op != SETOP_NONE. Each arm may reference views.
6.9 Recursive CTEs referencing views
WITH RECURSIVE r AS (
SELECT * FROM my_view WHERE id = 1 -- base case
UNION ALL
SELECT v.* FROM my_view v JOIN r ON v.parent = r.id -- recursive
)
SELECT * FROM r;
The view inlining walks CTE bodies via the withClause, so views inside
CTEs are inlined. The recursive self-reference (r) is not a view and
won’t be touched.
6.10 Performance consideration
View inlining adds SPI calls (relkind lookup + pg_get_viewdef) at
stream table creation time only. There is zero runtime overhead during
refresh — the stored defining_query is already expanded.
For deeply nested views (e.g., 5 levels), the fixpoint loop runs 5+1
iterations, each calling raw_parser(). This is negligible (< 10ms).
7. DDL Hook Integration
7.1 View creation / replacement
When CREATE OR REPLACE VIEW changes a view that was inlined into a
stream table, the stream table’s stored defining_query becomes stale.
Detection mechanism:
- Hook fires for
("view", "CREATE VIEW") - Look up the view OID in
pgt_dependencies(source_type = ‘VIEW’) - If matches found: mark those stream tables as
needs_reinit = true - On next scheduled refresh, the reinit process:
- Reads
original_queryfrom catalog - Re-runs the full rewrite pipeline (including view inlining)
- Rebuilds CDC triggers for the (possibly different) base tables
- Repopulates the stream table
- Reads
7.2 View drop
When a view is dropped, any stream table that referenced it is broken.
Detection mechanism:
- Extend
pg_trickle_on_sql_drop()to also handleobject_type == "view" - Look up the dropped OID in
pgt_dependencies - If matches found: mark as
needs_reinit = truewith error status
The reinit will fail (view no longer exists) and the stream table enters
ERROR status with a clear message. The user must ALTER the defining
query or drop the stream table.
7.3 Base table DDL (existing functionality)
The existing handle_alter_table() hook already handles DDL on base
tables. After view inlining, base tables are registered as dependencies,
so ALTER TABLE on a base table correctly triggers the existing
column-change detection and reinit logic.
7.4 Hook code structure
// hooks.rs — additions to handle_ddl_command()
("view", tag) if tag == "CREATE VIEW" || tag == "ALTER VIEW" => {
handle_view_change(cmd);
}
// hooks.rs — new function
fn handle_view_change(cmd: &DdlCommand) {
let identity = cmd.object_identity.as_deref().unwrap_or("unknown");
// Find STs that depend on this view
let affected = match find_downstream_pgt_ids(cmd.objid) {
Ok(ids) => ids,
Err(e) => {
pgrx::warning!(
"pg_trickle_ddl_tracker: failed to find dependents of view {}: {}",
identity, e
);
return;
}
};
if affected.is_empty() {
return;
}
pgrx::info!(
"pg_trickle: view {} changed, marking {} stream table(s) for reinit",
identity, affected.len()
);
for pgt_id in &affected {
if let Err(e) = StreamTableMeta::mark_needs_reinit(*pgt_id) {
pgrx::warning!("pg_trickle: failed to mark ST {} for reinit: {}", pgt_id, e);
}
}
}
And in the drop handler:
// hooks.rs — extend pg_trickle_on_sql_drop()
for obj in &dropped {
match obj.object_type.as_str() {
"table" => handle_dropped_table(obj),
"view" => handle_dropped_view(obj), // NEW
_ => {}
}
}
8. Catalog Impact
8.1 Schema migration
Add original_query column:
-- Extension upgrade SQL (0.1.x → 0.2.0 or similar)
ALTER TABLE pgtrickle.pgt_stream_tables
ADD COLUMN IF NOT EXISTS original_query TEXT;
-- Backfill: for existing STs, original = defining (no views were inlined)
UPDATE pgtrickle.pgt_stream_tables
SET original_query = defining_query
WHERE original_query IS NULL;
8.2 Catalog struct update
// catalog.rs — StreamTableMeta
pub struct StreamTableMeta {
// ... existing fields ...
pub original_query: Option<String>, // NEW
}
8.3 Dependency table — no schema change needed
The pgt_dependencies table already supports source_type = 'VIEW'.
View soft-dependencies use existing infrastructure.
8.4 Info view update
The pgtrickle.stream_tables view should expose original_query:
CREATE OR REPLACE VIEW pgtrickle.stream_tables AS
SELECT
st.pgt_id,
st.pgt_schema || '.' || st.pgt_name AS name,
st.defining_query,
st.original_query, -- NEW
...
FROM pgtrickle.pgt_stream_tables st;
9. Testing Plan
9.1 Unit tests (src/dvm/parser.rs)
| Test | Description |
|---|---|
test_rewrite_views_inline_simple_view |
Single view → base table expansion |
test_rewrite_views_inline_no_views |
Query with only tables → unchanged |
test_rewrite_views_inline_aliased_view |
FROM v AS alias preserves alias |
test_rewrite_views_inline_unaliased_view |
FROM v uses view name as alias |
test_rewrite_views_inline_nested_views |
v2 → v1 → t, both levels inlined |
test_rewrite_views_inline_view_in_join |
t1 JOIN v ON ... |
test_rewrite_views_inline_view_both_sides |
v1 JOIN v2 ON ... |
test_rewrite_views_inline_view_with_cte |
View definition contains CTE |
test_rewrite_views_inline_view_with_union |
View definition is UNION ALL |
test_rewrite_views_inline_schema_qualified |
myschema.my_view |
test_rewrite_views_inline_matview_untouched |
Materialized view not inlined |
test_rewrite_views_inline_mixed |
Table + view + subquery in FROM |
test_rewrite_views_inline_depth_limit |
Exceeds max depth → clear error |
test_rewrite_views_inline_column_aliases |
CREATE VIEW v(a,b) AS ... |
Note: Unit tests require a PG backend (SPI access for relkind lookup
and pg_get_viewdef). These will likely be #[pg_test] tests.
9.2 E2E tests (tests/e2e_view_tests.rs — new file)
| Test | Description |
|---|---|
test_view_inline_diff_basic |
Create view, create DIFF ST referencing it, INSERT into base → verify refresh captures change |
test_view_inline_diff_update_delete |
INSERT, UPDATE, DELETE through base table → all captured |
test_view_inline_diff_with_filter |
View has WHERE clause, verify filter semantics preserved |
test_view_inline_diff_with_aggregation |
SELECT COUNT(*) FROM my_view GROUP BY ... |
test_view_inline_diff_with_join |
View joined with table |
test_view_inline_diff_two_views |
Both sources are views |
test_view_inline_nested_view |
View referencing another view |
test_view_inline_full_mode |
FULL mode with view — should work without inlining too |
test_view_inline_matview_rejected |
Materialized view → clear error in DIFF |
test_view_inline_foreign_table_rejected |
Foreign table → clear error in DIFF |
test_view_inline_view_replaced |
CREATE OR REPLACE VIEW → reinit triggered |
test_view_inline_view_dropped |
DROP VIEW → error status |
test_view_inline_security_definer |
View with SECURITY DEFINER owner |
test_view_inline_truncate_base |
TRUNCATE on base table through view → captured |
test_view_inline_column_renamed |
View renames columns: v(a,b) |
9.3 Integration tests (if needed)
May not need dedicated integration tests — the E2E tests cover the full flow. If SECURITY DEFINER testing requires multi-role setup, it belongs in E2E.
10. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| View definition syntax not fully deparsed | Low | High (incorrect SQL) | Use pg_get_viewdef() which is PostgreSQL’s own pretty-printer; heavily battle-tested |
| SECURITY DEFINER permission change | Medium | Medium (unexpected errors) | Clear error message with HINT; validate_defining_query catches this |
| Deeply nested views cause slow creation | Very low | Low (one-time cost) | Depth limit of 10; each iteration is < 2ms |
| View with INSTEAD OF triggers | Low | Low (semantic difference) | INSTEAD OF triggers on views are irrelevant for the SELECT definition |
| Set-returning view definitions | Low | Low | PostgreSQL doesn’t allow SRFs in view definitions that break subquery wrapping |
| Regression in existing non-view queries | Low | High | Rewrite returns input unchanged when no views found; extensive existing test suite |
| Catalog migration breaks upgrade | Low | Medium | Use ADD COLUMN IF NOT EXISTS + backfill |
11. Future Work
11.1 Materialized view live expansion (P3)
A future enhancement could offer LIVE mode for materialized views:
inline the matview’s definition like a regular view, ignoring the snapshot
semantics. This would require a user opt-in flag.
11.2 View metadata in pgtrickle.info()
Display inlined views and their definitions in the monitoring output:
SELECT * FROM pgtrickle.info('my_st');
-- Output includes:
-- original_query: SELECT ... FROM my_view
-- defining_query: SELECT ... FROM (SELECT ... FROM base_table) AS my_view
-- inlined_views: [my_view → public.base_table]
11.3 Selective view re-expansion on reinit
When a view definition changes and reinit triggers, only re-expand the changed view instead of re-running the full pipeline. This is an optimization for stream tables with many view dependencies.
11.4 View dependency graph visualization
Extend the DAG visualization to show which views were inlined and their relationships to base tables.
Appendix A: Affected Files Summary
| File | Changes |
|---|---|
src/dvm/parser.rs |
New: rewrite_views_inline(), rewrite_views_inline_once(), resolve_relkind(), get_view_definition(), reject_materialized_views() |
src/dvm/mod.rs |
Export: rewrite_views_inline, reject_materialized_views |
src/api.rs |
Wire rewrite pass #0; store original_query; register view deps |
src/hooks.rs |
Add view DDL handling: handle_view_change(), handle_dropped_view() |
src/lib.rs |
Schema: add original_query column |
src/catalog.rs |
StreamTableMeta: add original_query field; update insert/select |
tests/e2e_view_tests.rs |
New E2E test file: 15 tests |
Appendix B: Execution Order
Session 1 (4h): Steps 1-3 — core rewrite + wiring + basic unit tests
Session 2 (3h): Steps 4-5 — matview/foreign table rejection + Step 7 catalog
Session 3 (2h): Steps 6, 8 — DDL hooks + view dep tracking
Session 4 (3h): Step 9 — full E2E test suite
Total: ~12 hours (includes testing)