upkeep-rails 0.2.5-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,136 @@
1
+ # SQLGlot-first vs Arel, and missing semantic bindings
2
+
3
+ ## Outcome
4
+
5
+ A SQL-first decoder is viable enough to pursue. It can use one policy for both
6
+ Active Record-generated SQL and handwritten SQL, while lowering both into a
7
+ small generic dependency graph. The spike handles the cases where the current
8
+ Arel decoder succeeds and cases it intentionally rejects: raw predicates, raw
9
+ joins, correlated `EXISTS`, and CTEs.
10
+
11
+ This is not yet a drop-in replacement. The current Ruby gem exposes only the
12
+ parser-shaped JSON AST. The underlying Rust library has useful semantic APIs,
13
+ but v0.10.12 also has correctness gaps that Upkeep must test and either fix
14
+ upstream or normalize locally.
15
+
16
+ ## Comparison
17
+
18
+ | Concern | Current Arel decoder | SQLGlot-first spike |
19
+ | --- | --- | --- |
20
+ | Hash/Arel predicates | Structural, typed nodes | Parsed from final SQL |
21
+ | Raw predicate/order | Rejected as opaque | Analyzed |
22
+ | Raw join/source | Rejected as opaque | Analyzed |
23
+ | Correlated subquery | Becomes opaque when handwritten | Inner/outer columns and equality edge found |
24
+ | CTE | Depends on Arel shape | Physical CTE sources found; CTE name suppressed |
25
+ | Simple predicate values | Preserves Ruby values/binds | Recovers SQL literal values |
26
+ | Bind type fidelity | Strong | Lost after `to_sql` unless passed separately |
27
+ | Parser dependency | Rails/Arel internals | Native SQLGlot dependency |
28
+ | Decoder policy | Structured vs opaque branches | One SQL policy with explicit confidence/warnings |
29
+
30
+ The generic result is deliberately not a catalog of SQL features:
31
+
32
+ ```text
33
+ physical source ── referenced column
34
+
35
+ ├── equality edge ── other source.column
36
+ └── constant predicate (optional DNF group)
37
+
38
+ query shape: limit / offset / distinct / group / having
39
+ ```
40
+
41
+ SQL constructs belong in the decoder. `DependencyPlan` should only consume the
42
+ graph above plus confidence/coverage. That keeps `Linked` source kinds generic.
43
+
44
+ ## Ruby bindings worth adding
45
+
46
+ The Ruby `sqlglot` gem 0.1.1 currently binds parse, generate, transpile, and
47
+ version. The standalone binding proves these existing `sql-glot-rust` APIs are
48
+ directly useful:
49
+
50
+ 1. `MappingSchema` + `qualify_columns`
51
+ - resolves unqualified columns using Active Record's schema;
52
+ - expands wildcards;
53
+ - makes alias handling less dependent on a Ruby AST walker.
54
+ 2. `build_scope`
55
+ - exposes sources per query scope;
56
+ - separates child subqueries and CTE scopes;
57
+ - exposes all columns, including WHERE/JOIN columns;
58
+ - marks external columns and correlated subqueries.
59
+ 3. `lineage`
60
+ - useful for selected/output values and derived expressions;
61
+ - **not** sufficient for invalidation because filter-only and join-only
62
+ sources are intentionally absent.
63
+
64
+ One JSON-returning `analyze` FFI entry point is preferable to many fine-grained
65
+ calls: parse, qualify, build scopes, and compute requested output lineages in
66
+ Rust, then cross the FFI boundary once.
67
+
68
+ ## Challenging observations
69
+
70
+ - Correlated `EXISTS` works: the inner scope is correlated, includes
71
+ `efforts.milestone_id` and `efforts.status`, and reports
72
+ `milestones.id` as an external column.
73
+ - Output lineage for `SELECT milestones.id ... EXISTS (...)` contains
74
+ `milestones` but not `efforts`. The scope tree contains both. Upkeep should
75
+ derive invalidation inputs from scopes/predicates, not output lineage.
76
+ - Schema qualification resolves unqualified columns and expands selected
77
+ columns.
78
+ - CTE qualification works, and the CTE child scope points at its physical
79
+ table. However, v0.10.12 currently represents the root reference to the CTE
80
+ as `Source::Table("active_cards")`, not `Source::Scope`. This needs an
81
+ upstream fix or a narrowly tested normalizer.
82
+ - Parse failures cross the proposed FFI boundary as structured JSON errors,
83
+ rather than null pointers with lost diagnostics.
84
+
85
+ The earlier Pulse #1802 corpus remains relevant: parsing covers operator
86
+ expressions, functions, nested queries, aliases, and raw SQL more uniformly
87
+ than class-by-class Arel handling. It does not remove the need for semantic
88
+ tests around scope resolution and Rails-emitted dialect details.
89
+
90
+ ## Performance
91
+
92
+ Warm macOS arm64 measurements over 5,000 analyses:
93
+
94
+ | Analyzer | Mean per analysis |
95
+ | --- | ---: |
96
+ | Arel, structured query | 18.3–18.6 µs |
97
+ | SQLGlot, same structured query | 47.7 µs |
98
+ | SQLGlot, raw join + predicate + order | 89.9–90.3 µs |
99
+
100
+ The SQL-first path is about 2.6× slower on the comparable structured query, but
101
+ still below 0.1 ms in this corpus. Schema metadata was cached; without that
102
+ cache, reflection dominated at roughly 0.6 ms.
103
+
104
+ This analysis should be cached by normalized query shape and schema version. It
105
+ should not run once per database change or once per dependent render during
106
+ fan-out. The fan-out cost is then graph matching, not SQL parsing.
107
+
108
+ The standalone release dylib is 3.1 MB on this machine. The complete local Cargo
109
+ release directory is larger (88 MB) but is build output, not shipped runtime.
110
+
111
+ ## Recommendation
112
+
113
+ Proceed with a production-shaped SQL-first prototype behind the existing
114
+ `ActiveRecordQuery.analyze` contract:
115
+
116
+ 1. Add the semantic `analyze` binding to the Ruby gem (preferably upstream).
117
+ 2. Feed it schema data from Active Record's schema cache.
118
+ 3. Lower its result into one generic dependency graph.
119
+ 4. Keep Arel temporarily as a parity oracle and as the source of bind values/type
120
+ metadata, not as a second invalidation policy.
121
+ 5. Run the full query corpus against both decoders and record false-negative,
122
+ conservative, and unsupported results.
123
+ 6. Remove the Arel decoder only after SQL-first parity and performance targets
124
+ are met.
125
+
126
+ The key architectural distinction is “two decoders during migration,” not “two
127
+ policies forever.”
128
+
129
+ ## Source references
130
+
131
+ - [`sql-glot-ruby` v0.1.1 native bindings](https://github.com/AccountAim/sql-glot-ruby/blob/v0.1.1/lib/sqlglot/native.rb)
132
+ - [`sql-glot-rust` v0.10.12 scope analysis](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/scope_analysis.rs)
133
+ - [`sql-glot-rust` v0.10.12 column qualification](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/qualify_columns.rs)
134
+ - [`sql-glot-rust` v0.10.12 lineage](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/lineage.rs)
135
+ - [Active Record 8.1.3 query methods](https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/relation/query_methods.rb)
136
+ - [Arel 8.1.3 bound SQL literals](https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/arel/nodes/bound_sql_literal.rb)
@@ -0,0 +1,169 @@
1
+ # Opaque-query source analysis comparison
2
+
3
+ Date: 2026-07-24
4
+
5
+ ## Decision
6
+
7
+ Use the Ruby `sqlglot` gem as Upkeep's first automatic fallback when Arel
8
+ contains opaque SQL. Keep an Upkeep-owned, scope-aware AST walker and extract
9
+ tables only.
10
+
11
+ Do not add user-selectable query policies. The runtime should follow one
12
+ resolution chain:
13
+
14
+ 1. Use Arel for precise column and predicate coverage.
15
+ 2. Use SQLGlot for conservative named-table coverage.
16
+ 3. Resolve views through adapter schema metadata where practical.
17
+ 4. Refuse the boundary when a source remains hidden or unresolvable.
18
+
19
+ Database-native inspection can improve individual adapters later, but it should
20
+ not be the primary cross-adapter mechanism.
21
+
22
+ ## Corpus
23
+
24
+ The named-source corpus has 12 queries across PostgreSQL, MySQL, and SQLite:
25
+
26
+ - raw predicates and order expressions;
27
+ - raw and schema-qualified joins;
28
+ - correlated `EXISTS` and `IN` subqueries;
29
+ - derived tables;
30
+ - ordinary, shadowing, and recursive CTEs;
31
+ - PostgreSQL full-text search; and
32
+ - MySQL JSON expressions.
33
+
34
+ A separate four-case physical-source corpus tests sources hidden behind views
35
+ and SQL functions.
36
+
37
+ The correctness rule is strict: an approach fails if it omits any physical
38
+ table that could affect the result. Extra tables are conservative but still
39
+ recorded because they increase fanout.
40
+
41
+ ## Results
42
+
43
+ Benchmarks are local single-process measurements on Apple Silicon. They compare
44
+ orders of magnitude, not production latency.
45
+
46
+ | Approach | Named-source correctness | Local cost | Portability | Finding |
47
+ | --- | ---: | ---: | --- | --- |
48
+ | Ruby SQLGlot 0.1.1 | 12/12 | ~67 μs/parse | PostgreSQL, MySQL, SQLite; CRuby native gem | Best overall trade-off |
49
+ | `sqlparser-rs` 0.62.0 | 12/12 | ~21 μs/parse | Multi-dialect Rust library | Fastest, but Upkeep would own a Ruby binding and native release matrix |
50
+ | Python SQLGlot 27.20.0 | 12/12 | ~501 μs/parse | Multi-dialect, separate Python runtime | Correct but operationally inappropriate for a Rails gem |
51
+ | `pg_query` 6.2.2 | 4/4 PostgreSQL | ~104 μs/parse | PostgreSQL and CRuby only | Mature and exact for PostgreSQL, but does not solve adapter portability |
52
+ | PostgreSQL 17.9 `EXPLAIN (FORMAT JSON)` | 4/4 PostgreSQL | ~82 μs/explain | PostgreSQL connection required | Correct baseline; expands views and stable SQL functions |
53
+ | SQLite 3.53 `EXPLAIN QUERY PLAN` text | 5/6 SQLite | ~12 μs/explain | SQLite only | Unsafe: reports aliases instead of physical tables |
54
+ | SQLite authorizer during prepare | 6/6 SQLite | ~15 μs/prepare | SQLite only | Correct baseline and exposed by the existing Ruby `sqlite3` gem |
55
+
56
+ MySQL `EXPLAIN FORMAT=JSON` was not executed because no local MySQL server was
57
+ available. Adding it would still leave Upkeep with three adapter-specific
58
+ implementations and three different result formats.
59
+
60
+ ## Important failures
61
+
62
+ ### Convenience metadata is not a correctness API
63
+
64
+ The Ruby SQLGlot wrapper's `Sqlglot::Query#tables` missed tables inside
65
+ correlated subqueries, `IN` subqueries, and derived tables. Its underlying AST
66
+ contained all sources. The scope-aware recursive extractor in the original
67
+ SQLGlot spike recovered all 12 cases.
68
+
69
+ ### Syntax parsers cannot expand database objects
70
+
71
+ All four parsers return the named view or table function, not the physical
72
+ tables behind it:
73
+
74
+ | Query source | Parser result | Physical dependency |
75
+ | --- | --- | --- |
76
+ | SQLite `published_posts` view | `published_posts` | `posts` |
77
+ | PostgreSQL `active_accounts` view | `active_accounts` | `accounts` |
78
+ | PostgreSQL `account_events(42)` | no table, or the function name | `audit.events` |
79
+
80
+ This is expected: a parser has SQL text but not the connected database schema.
81
+
82
+ ### Query plans are useful but not complete
83
+
84
+ PostgreSQL JSON `EXPLAIN` expanded:
85
+
86
+ - the `active_accounts` view to `accounts`; and
87
+ - an inlineable `STABLE` SQL function to `audit.events`.
88
+
89
+ It did not expose the table read by an otherwise identical `VOLATILE` SQL
90
+ function. The plan contained a function scan with no underlying relation.
91
+ Therefore `EXPLAIN` cannot be treated as a universal proof of physical sources.
92
+
93
+ SQLite's textual plan reported `physical_posts`, a query alias, instead of the
94
+ real `posts` table. Its authorizer callback reported the physical table
95
+ correctly, but installing and restoring a connection-global authorizer safely
96
+ inside Rails requires concurrency and reentrancy work.
97
+
98
+ ## Dependency and packaging comparison
99
+
100
+ - Ruby SQLGlot ships precompiled glibc Linux and macOS gems for x86-64 and
101
+ ARM64. Other platforms require Cargo and Git. The current arm64 macOS gem
102
+ includes a roughly 2.4 MB dylib.
103
+ - `pg_query` compiled a roughly 3.4 MB native extension locally and embeds the
104
+ real PostgreSQL parser.
105
+ - `sqlparser-rs` produced a roughly 7.1 MB standalone release executable in the
106
+ spike. A production choice would require a maintained Ruby extension or FFI
107
+ boundary.
108
+ - Python SQLGlot adds an entire second language runtime to a Rails process or
109
+ requires an out-of-process service.
110
+
111
+ The Ruby SQLGlot wrapper is young. Upkeep should pin its supported version and
112
+ keep contract tests for the AST node shapes it consumes.
113
+
114
+ ## Proposed production contract
115
+
116
+ The fallback analyzer should return one of:
117
+
118
+ ```ruby
119
+ Resolved.new(tables: ["posts", "users"], coverage: :tables)
120
+ Unresolved.new(reason: "table-valued function account_events")
121
+ ```
122
+
123
+ It must never silently convert an empty or partially understood source set into
124
+ the model's primary table. Specifically:
125
+
126
+ - normalize `public.accounts` and `main.posts` to the table names used by
127
+ Active Record change events;
128
+ - exclude CTE and derived-table aliases with query-scope awareness;
129
+ - validate extracted names against tables and views in the connected schema;
130
+ - expand views through adapter metadata or mark them unresolved;
131
+ - detect table-valued functions and other source nodes that do not resolve to
132
+ physical tables;
133
+ - mark every SQLGlot-derived collection non-appendable; and
134
+ - always replay the whole render site for table-coverage dependencies.
135
+
136
+ This leaves refusal as a correctness outcome, not a configurable policy:
137
+ ordinary raw SQL works automatically, while genuinely hidden database behavior
138
+ is rejected rather than guessed.
139
+
140
+ ## Reproduction
141
+
142
+ Each directory is intentionally isolated from production dependencies:
143
+
144
+ ```sh
145
+ # Ruby SQLGlot
146
+ cd docs/spikes/query-source-analysis-comparison/ruby-sqlglot
147
+ mise exec -- ruby -S bundle install
148
+ mise exec -- ruby -rbundler/setup run.rb
149
+
150
+ # pg_query
151
+ cd docs/spikes/query-source-analysis-comparison/pg-query
152
+ mise exec -- ruby -S bundle install
153
+ mise exec -- ruby -rbundler/setup run.rb
154
+
155
+ # Python SQLGlot
156
+ python3 -m venv /tmp/upkeep-python-sqlglot
157
+ /tmp/upkeep-python-sqlglot/bin/pip install -r python-sqlglot/requirements.txt
158
+ /tmp/upkeep-python-sqlglot/bin/python python-sqlglot/run.py
159
+
160
+ # sqlparser-rs
161
+ cd docs/spikes/query-source-analysis-comparison/sqlparser-rs
162
+ mise x rust@stable -- cargo run --release
163
+
164
+ # SQLite native approaches
165
+ python3 docs/spikes/query-source-analysis-comparison/explain/sqlite.py
166
+ ```
167
+
168
+ The PostgreSQL runner expects a clean database through `DATABASE_URL`; it
169
+ creates schemas, tables, a view, and two functions in that database.
@@ -0,0 +1,26 @@
1
+ # SQLGlot-first Active Record analysis spike
2
+
3
+ This spike asks whether `Relation#to_sql` plus database schema metadata can replace
4
+ the Arel decoder. The analyzer never calls `Relation#arel` and produces the same
5
+ small semantic contract Upkeep needs:
6
+
7
+ - physical tables and referenced columns;
8
+ - simple constant predicates in DNF groups;
9
+ - column-equality edges (joins and correlations);
10
+ - limit and appendability shape.
11
+
12
+ Run:
13
+
14
+ ```sh
15
+ mise x ruby@3.4.7 -- ruby sqlglot_active_record_query_test.rb
16
+ mise x ruby@3.4.7 -- ruby benchmark.rb
17
+ ```
18
+
19
+ The important comparison is not “can SQLGlot understand every SQL node?” It is
20
+ whether both structured Active Record and raw SQL lower into this generic contract.
21
+ The tests include raw predicates, raw joins, a correlated `EXISTS`, a CTE inside a
22
+ derived source, and collection-shape modifiers.
23
+
24
+ The analyzer caches schema metadata by connection for the benchmark. A real
25
+ integration should key that cache by Active Record's schema version and clear it
26
+ when Rails clears its schema cache.
@@ -0,0 +1,110 @@
1
+ # SQLGlot query-analysis spike
2
+
3
+ Date: 2026-07-24
4
+
5
+ ## Question
6
+
7
+ Can Upkeep use the `sqlglot` Ruby gem to recover table dependencies from
8
+ Active Record relations whose Arel nodes contain opaque SQL strings?
9
+
10
+ ## Verdict
11
+
12
+ Yes, with one important constraint: use SQLGlot's parsed AST and a small
13
+ Upkeep-owned recursive table extractor. Do not use
14
+ `Sqlglot::Query#tables` as the correctness boundary.
15
+
16
+ The parser accepted every representative PostgreSQL, MySQL, and SQLite query
17
+ in the original corpus. Scope-aware recursive extraction from `Table` AST
18
+ nodes recovered every expected source table. The follow-up corpus derived from
19
+ Pulse #1802 found one important PostgreSQL extension gap: pg_trgm's custom `<%`
20
+ operator does not parse.
21
+
22
+ ## Evidence
23
+
24
+ The corpus covers:
25
+
26
+ - raw predicates and order expressions;
27
+ - raw joins;
28
+ - correlated `EXISTS` subqueries;
29
+ - `IN` subqueries;
30
+ - derived tables in `FROM` and `JOIN`;
31
+ - CTEs;
32
+ - CTEs that shadow a physical table;
33
+ - recursive CTEs;
34
+ - PostgreSQL full-text search;
35
+ - PostgreSQL schema-qualified sources;
36
+ - MySQL JSON expressions; and
37
+ - SQLite quoting and syntax.
38
+
39
+ Results:
40
+
41
+ | Path | Result |
42
+ | --- | --- |
43
+ | SQLGlot parsing | 12/12 query cases parsed |
44
+ | `Sqlglot::Query#tables` | 4 failures in the original 10-case corpus |
45
+ | Scope-aware AST `Table` extraction | 12/12 table sets correct |
46
+ | Invalid SQL | Raised `Sqlglot::ParseError` |
47
+ | Local parse/extract benchmark | about 60 μs/query across 10,000 iterations |
48
+
49
+ The [Pulse #1802 follow-up](PULSE_1802_FINDINGS.md) adds ten parseable,
50
+ production-derived cases covering JSONB operators, nested boolean ranges,
51
+ self-correlated and aggregate subqueries, repeated `EXISTS`, aggregate
52
+ `CASE`, joins, projections, and pg_search expressions. It also preserves the
53
+ realistic pg_search `<%` failure as an expected regression test.
54
+
55
+ The wrapper helper missed physical tables inside correlated subqueries, `IN`
56
+ subqueries, and derived tables. The underlying AST contained those tables in
57
+ regular nested `Table` nodes, so a generic recursive walk recovered them.
58
+
59
+ Run the spike:
60
+
61
+ ```sh
62
+ cd docs/spikes/sqlglot-query-analysis
63
+ mise exec -- ruby -S bundle install
64
+ mise exec -- ruby -rbundler/setup sqlglot_query_analysis_test.rb
65
+ ```
66
+
67
+ ## Proposed Upkeep boundary
68
+
69
+ Keep the existing Arel analyzer as the precise path. When it encounters an
70
+ opaque SQL predicate, order, join, or source:
71
+
72
+ 1. Parse the relation's final SQL using the dialect selected from the Active
73
+ Record adapter.
74
+ 2. Recursively collect physical `Table` nodes.
75
+ 3. Remove CTE names from the collected set.
76
+ 4. Register table-coverage dependencies.
77
+ 5. Disable append, prepend, remove, and member-replace proofs.
78
+ 6. Re-run and update the entire render site when any collected table changes.
79
+
80
+ Only table extraction should ship initially. SQLGlot exposes columns, but
81
+ unqualified columns, aliases, projections, and derived outputs require semantic
82
+ resolution. They are unnecessary for a correct conservative fallback.
83
+
84
+ ## Risks
85
+
86
+ - The Ruby wrapper is new (`0.1.1`). Upkeep needs contract tests for the AST
87
+ node shapes it consumes.
88
+ - Precompiled gems currently cover glibc Linux and macOS on x86-64 and ARM64.
89
+ Other platforms build the Rust library from source and require Cargo and Git.
90
+ - A SQL parser sees named SQL sources, not tables hidden behind database views,
91
+ stored functions, or extensions. Those queries still need a database/schema
92
+ resolver or refusal.
93
+ - PostgreSQL permits extension-defined operators. SQLGlot 0.1.1 rejects
94
+ pg_trgm's `<%` word-similarity operator even though it accepts the surrounding
95
+ pg_search tsearch and ranking expressions.
96
+ - An extracted schema-qualified name must be normalized to the table names used
97
+ by Upkeep change events.
98
+ - Parser success is not proof that every named source corresponds to an
99
+ observed Active Record table. Upkeep must validate extracted names against
100
+ the connected schema before registering them.
101
+
102
+ ## Recommendation
103
+
104
+ Proceed with SQLGlot as an experimental fallback analyzer behind internal code,
105
+ not a user-selectable policy. Keep refusal only as the terminal outcome when
106
+ parsing fails or source names cannot be reconciled with Upkeep's observed
107
+ schema.
108
+
109
+ Before merging production integration, expand the corpus with SQL captured from
110
+ the benchmark applications and run it on PostgreSQL, MySQL, and SQLite CI.
@@ -0,0 +1,95 @@
1
+ # Pulse #1802 SQLGlot edge-case findings
2
+
3
+ Date: 2026-07-24
4
+
5
+ Source: [fetchly/Pulse#1802](https://github.com/fetchly/Pulse/pull/1802)
6
+
7
+ ## Result
8
+
9
+ SQLGlot can eliminate most of the SQL-to-Arel and SQL-to-Ruby rewrites in this
10
+ PR if Upkeep accepts conservative table-level dependencies and replays the
11
+ whole render site.
12
+
13
+ The Ruby SQLGlot 0.1.1 parser plus the scope-aware extractor handled all ten
14
+ parseable Pulse-derived cases. One realistic `pg_search` query remains
15
+ unparseable because SQLGlot does not recognize pg_trgm's custom `<%`
16
+ word-similarity operator.
17
+
18
+ The same minimal `<%` query also fails with Python SQLGlot 27.20.0. PostgreSQL's
19
+ `@@` full-text-search operator, casts, `to_tsquery`, `ts_rank`, and the
20
+ `word_similarity(...)` function all parse; `<%` is the isolated failure.
21
+
22
+ Run the corpus:
23
+
24
+ ```sh
25
+ cd docs/spikes/sqlglot-query-analysis
26
+ mise exec -- ruby -rbundler/setup pulse_1802_edge_cases_test.rb
27
+ ```
28
+
29
+ Observed result:
30
+
31
+ ```text
32
+ 13 runs, 15 assertions, 0 failures, 0 errors
33
+ ```
34
+
35
+ The expected `<%` parse failure is asserted as part of that result.
36
+
37
+ ## PR coverage
38
+
39
+ | PR query shape | Parse / table extraction | Tables recovered | What this means for Pulse |
40
+ | --- | --- | --- | --- |
41
+ | `Assignment.current`, `overlapping`, and `current_and_upcoming` nested date predicates | Full | `assignments` | The string predicates did not need Arel rewrites for conservative reactivity. |
42
+ | `Effort::Filterable.not_overdue` and `not_design_efforts_types` disjunctions | Full | `efforts` | Ordinary `NULL`, comparison, `AND`, and `OR` predicates are routine. |
43
+ | `Project::Staffable#requirements_change_summary` overlapping-range predicate | Full | `project_team_requirements` | This could stay in SQL instead of loading and filtering every requirement in Ruby. |
44
+ | `Effort::Filterable.by_blockers` self-correlated `EXISTS` with an alias | Full | `efforts` | The extractor deduplicates the outer and aliased inner physical table. |
45
+ | Audit JSONB containment `@>` plus `::jsonb` | Full | `audits` | The helper did not need an `Arel::Nodes::InfixOperation` for table dependency recovery. |
46
+ | Audit JSON extraction `-> ... IS NOT NULL` | Full | `audits` | PostgreSQL JSON operators do not block the table-level fallback. |
47
+ | `Effort::Filterable.over_budget` correlated `COALESCE(SUM(...), 0)` scalar subquery | Full | `efforts`, `time_logs` | Both the result table and aggregate input table are recovered. |
48
+ | `Milestone.status_case_sql` with `CASE`, repeated correlated `EXISTS` / `NOT EXISTS`, and `IN` | Full with custom AST walk | `milestones`, `efforts` | The original SQL can be tracked conservatively. `Sqlglot::Query#tables` alone misses `efforts`. |
49
+ | `active_requirements_count` aggregate `SUM(CASE ...)` over a join | Full | `project_team_requirements`, `positions` | It could remain a database aggregate; a change to either table triggers full replay. |
50
+ | Two-column `pluck` across a left join | Full | `assignments`, `teams` | Raw projection strings are irrelevant to table discovery. The Arel projection rewrite is not required by this fallback. |
51
+ | `pg_search` tsearch-only query and trigram ranking function | Full | `projects` | SQLGlot handles the functions, casts, rank expression, and `@@`. |
52
+ | `pg_search` word-similarity predicate using `<%` | **Parse failure** | None | Upkeep must refuse, use a PostgreSQL-specific secondary parser, or add a narrowly tested normalization for custom operators. |
53
+
54
+ ## What “covered” means
55
+
56
+ Coverage here is deliberately conservative. SQLGlot proves which named tables
57
+ can change the result; it does not prove which individual changes alter
58
+ membership, ordering, aggregates, or projected values.
59
+
60
+ For every SQLGlot-derived dependency, Upkeep should therefore:
61
+
62
+ - subscribe to every recovered table;
63
+ - rerun the complete render site on any change to one of those tables; and
64
+ - disable append, prepend, remove, and member-replace optimizations.
65
+
66
+ That is enough to preserve the original SQL for the range filters, JSONB
67
+ predicates, correlated subqueries, status `CASE`, aggregate, and `pluck` cases
68
+ above. It trades precision for correctness without introducing a second
69
+ user-maintained policy.
70
+
71
+ ## What SQLGlot does not address in the PR
72
+
73
+ - The `<%` custom PostgreSQL operator prevents parsing the complete
74
+ `pg_search` relation. Materializing that relation remains necessary unless
75
+ Upkeep adds another safe fallback.
76
+ - SQLGlot cannot discover physical tables hidden behind views or stored
77
+ functions from SQL text alone.
78
+ - The milestone row-target and lazy-frame subscription fixes concern render
79
+ capture and subscription lifetime, not query analysis.
80
+ - Controller runtime installation and cross-controller invalidation are
81
+ runtime concerns, not parser concerns.
82
+ - Parsing an aggregate or `pluck` does not make its result incrementally
83
+ patchable. It only makes full replay safe.
84
+
85
+ ## Recommendation
86
+
87
+ Use SQLGlot automatically after the precise Arel analyzer fails. For this PR,
88
+ that would cover every query rewrite except the full pg_search predicate. Keep
89
+ the scope-aware AST extractor: the convenience `Query#tables` API misses the
90
+ inner `efforts` and `time_logs` sources in the two hardest cases.
91
+
92
+ Treat a SQLGlot parse failure as unresolved. If pg_search is important enough
93
+ to justify broader coverage, the next focused spike should compare
94
+ PostgreSQL's real parser (`pg_query`) against a generic custom-operator
95
+ normalization; the latter must not silently alter source structure.
@@ -0,0 +1,29 @@
1
+ # Missing SQLGlot semantic bindings spike
2
+
3
+ The `sqlglot` Ruby gem 0.1.1 binds only parse, generate, transpile, and version.
4
+ Its underlying `protegrity/sql-glot-rust` v0.10.12 already contains semantic
5
+ APIs that are useful to Upkeep:
6
+
7
+ - `MappingSchema` and `qualify_columns`;
8
+ - `build_scope`, including physical/scoped sources, columns, external columns,
9
+ and correlation;
10
+ - output-column `lineage`.
11
+
12
+ This spike adds one JSON-over-FFI call around those APIs and a small Ruby wrapper.
13
+ It does not fork or modify the upstream dependency.
14
+
15
+ Build and run:
16
+
17
+ ```sh
18
+ cargo build --release
19
+ mise x ruby@3.4.7 -- ruby binding_test.rb
20
+ ```
21
+
22
+ The output-lineage test is intentional: lineage follows a selected value back to
23
+ its source, while Upkeep also needs filter/join/order dependencies. Scope analysis
24
+ contains those columns, so `scope`, not output lineage alone, is the useful base
25
+ for a dependency decoder.
26
+
27
+ Current v0.10.12 caveat: a CTE's child scope is present and correct, but the
28
+ outer source with the CTE's name is still emitted as a table source. The test
29
+ locks this behavior down so an upstream fix is visible.