pylon_core/query/mod.rs
1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// https://opensource.org/licenses/MIT
11// https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20use lru::LruCache;
21use std::collections::hash_map::DefaultHasher;
22use std::hash::{Hash, Hasher};
23use std::num::NonZeroUsize;
24use std::sync::{Arc, OnceLock, RwLock};
25
26use crate::analyze::ShapePathAlias;
27use crate::error::PyQLError;
28use crate::schema::SchemaDescriptor;
29use crate::{analyze, ir, parse, sql};
30
31const CACHE_CAPACITY: usize = 1024;
32
33/// Number of independently-locked cache shards.
34///
35/// `LruCache::get` has to reorder the recency list, so it needs a *write*
36/// lock even on a hit — meaning a single map serializes every compile in the
37/// process behind one lock. Sharding by key hash keeps LRU semantics while
38/// cutting that contention by this factor. Must be a power of two, since
39/// shard selection masks the low bits of the hash.
40const CACHE_SHARDS: usize = 16;
41
42/// One cached compilation, stored behind an `Arc` so a hit hands out a
43/// pointer rather than deep-cloning the SQL string, parameter names, tag
44/// list and entire shape tree (measured at 0.94 µs of a 1.05 µs cache hit).
45///
46/// The key is kept alongside the value and re-checked on every hit: lookup
47/// is by hash alone (so it costs no allocation), and a hash collision must
48/// read as a miss, never as "here is some other query's SQL."
49struct CacheEntry {
50 query: String,
51 config: ir::SessionConfig,
52 compiled: Arc<CompiledQuery>,
53}
54
55// Keyed by (query text, session config) — not query text alone. Compilation
56// success/shape/SQL can depend on the config (e.g. an INSERT assigning `id`
57// compiles under allow_user_specified_id=true and errors otherwise), so two
58// requests for the same query text under different configs must never share
59// a cache entry.
60type CacheShard = RwLock<LruCache<u64, CacheEntry>>;
61
62static QUERY_CACHE: OnceLock<Vec<CacheShard>> = OnceLock::new();
63
64fn query_cache() -> &'static [CacheShard] {
65 QUERY_CACHE.get_or_init(|| {
66 let per_shard = NonZeroUsize::new(CACHE_CAPACITY / CACHE_SHARDS).unwrap();
67 (0..CACHE_SHARDS)
68 .map(|_| RwLock::new(LruCache::new(per_shard)))
69 .collect()
70 })
71}
72
73fn cache_key_hash(query: &str, config: &ir::SessionConfig) -> u64 {
74 let mut hasher = DefaultHasher::new();
75 query.hash(&mut hasher);
76 config.hash(&mut hasher);
77 hasher.finish()
78}
79
80/// Discard all cached compiled queries. Call when the schema is reloaded.
81pub fn clear_query_cache() {
82 if let Some(shards) = QUERY_CACHE.get() {
83 for shard in shards {
84 shard.write().unwrap().clear();
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Cardinality {
91 Required,
92 Optional,
93 Many,
94}
95
96/// Internal tree describing one position in the query output shape.
97/// Opaque to Python — only the Rust deserializer inspects it.
98#[derive(Debug, Clone)]
99pub enum ShapeNode {
100 /// Leaf value; native PG type.
101 Scalar { name: String, position: usize },
102 /// The `result` column IS the value — not wrapped in ROW(). Used for array literals
103 /// where an array is returned as its own top-level column.
104 RawScalar,
105 /// Like RawScalar but the value is a decoded JSON object (from a <json> cast).
106 /// REPL displays it as `Json("...")`.
107 JsonScalar,
108 /// Object shape.
109 /// `type_name = Some(s)` → named schema type decoded to a registered dataclass.
110 /// `type_name = None` → free type decoded to Pylon's generic Object dataclass.
111 /// `name` is the pointer name within the parent (empty string for the root).
112 Object {
113 name: String,
114 type_name: Option<String>,
115 position: usize,
116 cardinality: Cardinality,
117 pointers: Vec<ShapeNode>,
118 /// True when `pointers[0]` is an `id` the compiler added to a shape
119 /// that did not select one — see `IrScalarPointer::implicit_id`. Every
120 /// decoder hands it to the caller like any other property; only JSON
121 /// output drops it.
122 has_implicit_id: bool,
123 },
124 /// `record[]` column decoded to a Python list.
125 Array {
126 name: String,
127 position: usize,
128 element: Box<ShapeNode>,
129 },
130 /// Anonymous positional tuple decoded to a Python tuple. No type name — no registry lookup.
131 /// A composite tuple row, read by indexing rather than out of jsonb.
132 /// `names` is set for a named tuple that had to be emitted as a composite
133 /// because an element holds an object -- jsonb has no member kind for one
134 /// (see `JsonMemberKind`) -- and decides whether this hydrates to a plain
135 /// tuple or a `NamedTupleValue`.
136 Tuple {
137 position: usize,
138 elements: Vec<ShapeNode>,
139 names: Option<Vec<String>>,
140 },
141 /// Named tuple decoded from jsonb. When `type_name` is Some, hydrated to the registered class.
142 /// `members` carries the full per-member decode plan when statically known (a registered
143 /// NamedTupleDescriptor's members, a structural `pylon.Tuple[...]` property's tuple_members,
144 /// or a `<tuple<...>>`/nominal cast's own target type) — `None` falls back to decoding the
145 /// raw jsonb value generically (dict/list, no per-member typing).
146 NamedTuple {
147 name: String,
148 position: usize,
149 type_name: Option<String>,
150 members: Option<Vec<JsonMember>>,
151 /// True when this value came from `{ x := 1.0 }` (curly-brace free-
152 /// object syntax) rather than `(x := 1.0)` (paren tuple syntax) —
153 /// decoded identically either way (both are jsonb), but the
154 /// frontend's own value-shape-tag tree (pylon/query.py's
155 /// shape_value_tags) uses this to tell the Python API layer to
156 /// describe it as an "object" rather than a "namedTuple", so
157 /// JsonTree renders an expandable `Object {x: 1.0}` instead of a
158 /// non-expandable `(x := 1.0)` tuple literal.
159 is_free_object: bool,
160 },
161 /// Enum value arrived as text; hydrated to the Python enum class keyed by `enum_type`.
162 Enum {
163 name: String,
164 position: usize,
165 /// Pylon-qualified name, e.g. `default::Gender`.
166 enum_type: String,
167 },
168 /// Result of a `vector::search` statement.
169 /// The outer `result` tuple has three slots:
170 /// 0 → NULL (virtual type, no registry lookup)
171 /// `object_position` → the object sub-tuple (decoded as a Pylon object)
172 /// `distance_position` → the distance scalar (float64)
173 VectorSearch {
174 object_position: usize,
175 distance_position: usize,
176 object_node: Box<ShapeNode>,
177 },
178 /// Result of a `fts::search` statement.
179 /// Outer tuple layout mirrors `VectorSearch`: pos 0 = NULL, pos 1 = object, pos 2 = score.
180 FtsSearch {
181 object_position: usize,
182 rank_position: usize,
183 object_node: Box<ShapeNode>,
184 },
185 /// Result of a `group` statement: each row is a free object with key/grouping/elements.
186 Group {
187 /// One ShapeNode per grouping key (carries name, position, and type).
188 /// Positions are 1-based in the outer tuple (pos 0 is the NULL type slot).
189 key_nodes: Vec<ShapeNode>,
190 /// Position of the `ARRAY[key_names...]::text[]` in the outer tuple.
191 grouping_position: usize,
192 /// Position of the `array_agg(elements)` in the outer tuple.
193 elements_position: usize,
194 /// Shape node for each element in the elements array.
195 element: Box<ShapeNode>,
196 },
197}
198
199/// One member's decode plan within a jsonb-backed tuple value
200/// (`ShapeNode::NamedTuple.members`) — recursive so a member can itself be a
201/// nested tuple.
202#[derive(Debug, Clone)]
203pub struct JsonMember {
204 /// `None` for a positional/unnamed element of a structural tuple
205 /// (the value is a jsonb array); `Some` for a named member (a jsonb
206 /// object key) — either a nominal named-tuple field or a named
207 /// structural element.
208 pub key: Option<String>,
209 pub kind: JsonMemberKind,
210}
211
212#[derive(Debug, Clone)]
213pub enum JsonMemberKind {
214 /// Plain scalar — the jsonb value's own native JSON type is already
215 /// correct (number/string/bool), used as-is.
216 Scalar,
217 /// A `decimal` member. jsonb has one number type and cannot say which
218 /// of its numbers were written as decimals, so the declaration does:
219 /// the digits travel as they were written (see
220 /// `DecodedValue::JsonNumber`) and this is what tells a reader to build
221 /// a decimal from them rather than a float.
222 Decimal,
223 /// Value arrived as a jsonb string; hydrate to the Python enum class
224 /// keyed by `enum_type` (Pylon-qualified name, e.g. `default::Gender`).
225 Enum { enum_type: String },
226 /// Nested tuple member — recurse. `type_name` hydrates to a registered
227 /// dataclass when present (nominal); `None` decodes to a plain tuple
228 /// (all-positional members) or a dynamically-built dataclass (named,
229 /// unregistered structural).
230 Tuple {
231 type_name: Option<String>,
232 members: Vec<JsonMember>,
233 },
234}
235
236/// Opaque handle to the output shape of a compiled query.
237#[derive(Debug, Clone)]
238pub struct ShapeDescriptor {
239 pub root: ShapeNode,
240}
241
242/// Typed query parameter value produced during compilation.
243#[derive(Debug, Clone)]
244pub enum QueryParam {
245 Null,
246 Bool(bool),
247 Int(i64),
248 Float(f64),
249 Text(String),
250 Bytes(Vec<u8>),
251 Uuid([u8; 16]),
252}
253
254/// Pre-execution inference plan — set when the query requires an external model call
255/// before the SQL can be executed. Python switches on the variant.
256#[derive(Debug, Clone)]
257pub enum InferencePlan {
258 /// `fts::search` with a remote backend (OpenSearch or Meilisearch).
259 /// Python fetches (id, score) pairs from the backend, then injects them as
260 /// `__deferred_ids__` / `__deferred_scores__` params and runs `sql` against Postgres.
261 Search {
262 /// Remote backend identifier: `"opensearch"` or `"meilisearch"`.
263 backend: String,
264 /// Remote index name.
265 index_name: String,
266 /// Name of the user's query-text param; empty string when an inline literal.
267 query_param_name: String,
268 /// Inline literal query text.
269 query_literal: Option<String>,
270 /// Requested result size (limit), if known at compile time.
271 size: Option<usize>,
272 },
273 /// `vector::search(TypeName, query := $text)` text overload.
274 /// Python embeds the text via the configured model provider, then injects the
275 /// resulting vector as `__deferred_vec__` and runs `sql` against Postgres.
276 Embedding {
277 /// Embedding model identifier from the schema, e.g. `"mistral-embed"`.
278 model_name: String,
279 /// Qualified type name for provider lookup, e.g. `"default::Product"`.
280 type_name: String,
281 /// Vector index name for provider lookup (`None` = default index).
282 index_name: Option<String>,
283 /// Name of the user's `query :=` param; empty string when an inline literal.
284 query_param_name: String,
285 /// Inline literal query text.
286 query_literal: Option<String>,
287 },
288}
289
290/// The tuple type a parameter is cast to — `<tuple<…>>$p`, a nominal
291/// `<module::Point>$p`, or an array of either.
292///
293/// A tuple value travels as jsonb, and a named member is a *key* in it, so a
294/// client holding the value positionally (a Python tuple, a named-tuple
295/// instance) needs the member names to build what the cast asks for. The
296/// cast is the only place those names exist, so compilation records them
297/// here rather than leaving the client to guess from the value's own shape.
298#[derive(Debug, Clone)]
299pub struct ParamTupleType {
300 /// `<array<tuple<…>>>` — `members` then describes one element.
301 pub is_array: bool,
302 /// The registered name for a nominal named tuple; `None` for a
303 /// structural `tuple<…>`.
304 pub type_name: Option<String>,
305 pub members: Vec<JsonMember>,
306}
307
308/// The output of a successful PyQL compilation.
309/// Immutable and safe to cache and reuse across requests.
310#[derive(Debug, Clone)]
311pub struct CompiledQuery {
312 /// PostgreSQL SQL string ready for execution.
313 pub sql: String,
314 /// Ordered parameter names matching $1, $2, … in the SQL.
315 /// The client uses this to map kwargs to positional arguments.
316 pub param_names: Vec<String>,
317 /// Positionally matching `param_names`: the tuple type each parameter is
318 /// cast to, where it is cast to one (see `ParamTupleType`).
319 pub param_tuple_types: Vec<Option<ParamTupleType>>,
320 /// Typed bound parameters — populated at execution time, empty after compilation.
321 pub params: Vec<QueryParam>,
322 /// Opaque shape handle — consumed by the Rust deserializer.
323 pub shape: ShapeDescriptor,
324 /// Non-fatal warnings produced during compilation.
325 pub warnings: Vec<String>,
326 /// Set when the query requires a pre-execution model call.
327 pub inference_plan: Option<InferencePlan>,
328 /// Every schema-qualified table (`"schema.table"`) this statement reads
329 /// from or writes to — see `ir::tags::collect_tags`. For a SELECT, the
330 /// set of tags to cache this result under; for an INSERT/UPDATE/DELETE,
331 /// the set of tags a cache layer must invalidate after the write commits.
332 pub tags: Vec<String>,
333 /// True when executing this statement writes to any of `tags`. Lets a
334 /// cache layer act on the invalidation duty described above without
335 /// re-parsing the SQL to guess whether a write happened.
336 pub mutates: bool,
337 /// `Some` only for `analyze <query>` — the shape-path↔SQL-alias map an
338 /// `analyze` execution needs to correlate Postgres's `EXPLAIN` plan
339 /// nodes back to the query's own shape (see `analyze` module). `None`
340 /// for every other query, which doesn't pay for this extra shape walk.
341 pub analyze_paths: Option<Vec<ShapePathAlias>>,
342 /// This query's stable shape id — see `crate::shape_id` and `shape_id()`.
343 /// Computed once at compile time; fill it with `derive_shape_id` if you
344 /// ever build a `CompiledQuery` outside `compile_uncached`.
345 pub shape_id: Arc<str>,
346}
347
348/// The shape id for a given SQL string and result shape — the derivation
349/// `compile_uncached` uses to populate `CompiledQuery::shape_id`, exposed so
350/// anything else constructing a `CompiledQuery` by hand can fill that field
351/// consistently rather than inventing its own value.
352pub fn derive_shape_id(sql: &str, shape: &ShapeDescriptor) -> Arc<str> {
353 crate::shape_id::query_shape_id(sql, &format!("{shape:?}")).into()
354}
355
356impl CompiledQuery {
357 /// This query's stable shape id — see `crate::shape_id`.
358 ///
359 /// Independent of the values bound to it, which is what makes it safe as
360 /// a metric label: a query run a million times with different parameters
361 /// reports one label value, not a million.
362 ///
363 /// Precomputed rather than derived per call. Every execution reports this
364 /// label, and deriving it meant `Debug`-formatting the entire shape tree
365 /// into a throwaway `String` and hashing it — measured at 3.65 µs per
366 /// execution on a 20-field shape, all of it repeated work, since a
367 /// `CompiledQuery` is immutable.
368 pub fn shape_id(&self) -> Arc<str> {
369 self.shape_id.clone()
370 }
371
372 /// A stable hash of this query's SQL, for callers that need to key a
373 /// cache entry on the statement without moving the SQL text itself
374 /// around. Same derivation as `shape_id`, minus the shape.
375 pub fn sql_id(&self) -> &str {
376 // The shape id already covers the SQL — a query's shape can't change
377 // without its SQL changing — so a second hash would be redundant.
378 &self.shape_id
379 }
380}
381
382/// A coarse bucket for a failed query, for use as a metric label.
383///
384/// Deliberately coarse: an unbounded label (a raw error message, a SQLSTATE,
385/// a constraint name) is exactly the thing that blows up a metrics backend's
386/// cardinality. Anything finer belongs on a span or in a log line.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum ErrorClass {
389 /// A constraint the write violated — unique, foreign key, check, not-null.
390 ConstraintViolation,
391 /// Serialization failure or deadlock: the caller can retry.
392 Contention,
393 /// Statement or lock timeout.
394 Timeout,
395 /// Couldn't reach or stay connected to the database.
396 Connection,
397 /// The query never reached the database — bad PyQL, unknown type/pointer.
398 Compile,
399 Other,
400}
401
402impl ErrorClass {
403 /// The label value. `&'static str` so it can't accidentally become
404 /// unbounded.
405 pub fn as_label(self) -> &'static str {
406 match self {
407 ErrorClass::ConstraintViolation => "constraint_violation",
408 ErrorClass::Contention => "contention",
409 ErrorClass::Timeout => "timeout",
410 ErrorClass::Connection => "connection",
411 ErrorClass::Compile => "compile",
412 ErrorClass::Other => "other",
413 }
414 }
415
416 /// Buckets a SQLSTATE by its two-character class, which is how the
417 /// standard already groups them — so a code this was never written
418 /// against still lands somewhere sensible instead of in `Other`.
419 pub fn from_sqlstate(code: &str) -> Self {
420 match code {
421 "40001" => ErrorClass::Contention,
422 "40P01" => ErrorClass::Contention,
423 "57014" => ErrorClass::Timeout,
424 "55P03" => ErrorClass::Timeout,
425 _ => match code.get(..2) {
426 // 23 = integrity constraint violation.
427 Some("23") => ErrorClass::ConstraintViolation,
428 // 08 = connection exception.
429 Some("08") => ErrorClass::Connection,
430 // 40 = transaction rollback.
431 Some("40") => ErrorClass::Contention,
432 _ => ErrorClass::Other,
433 },
434 }
435 }
436}
437
438/// Whether a query succeeded, and if not, how it failed.
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum Outcome {
441 Ok,
442 Error(ErrorClass),
443}
444
445impl Outcome {
446 pub fn as_label(self) -> &'static str {
447 match self {
448 Outcome::Ok => "success",
449 Outcome::Error(_) => "error",
450 }
451 }
452}
453
454/// Timing and result facts about one query execution.
455///
456/// Returned alongside the result rather than reported from inside the
457/// execution path, so the caller decides what to do with it — record a
458/// metric, attach it to a span, log it, or ignore it. Keeping the decision
459/// out here is what lets the same execution path serve an instrumented
460/// server and an uninstrumented script.
461#[derive(Debug, Clone)]
462pub struct ExecutionMetadata {
463 /// PyQL → SQL compilation. Zero on a compile-cache hit, which is itself
464 /// the signal that the cache is working.
465 pub compile_duration: std::time::Duration,
466 /// Time in the database, from handing over the SQL to having the rows.
467 pub execute_duration: std::time::Duration,
468 /// See `CompiledQuery::shape_id`.
469 pub query_shape_id: String,
470 /// `None` for a statement that returns no rows, which is distinct from
471 /// `Some(0)` — a query that ran and matched nothing.
472 pub rows_returned: Option<u64>,
473 pub outcome: Outcome,
474}
475
476impl ExecutionMetadata {
477 /// Compile plus execute — what a caller timing "the query" means.
478 pub fn total_duration(&self) -> std::time::Duration {
479 self.compile_duration + self.execute_duration
480 }
481}
482
483/// Compile a PyQL expression string in the context of a named type to a bare SQL
484/// expression suitable for use in an UPDATE SET clause.
485///
486/// Pointer references (`.name`) are emitted without a table alias because UPDATE
487/// SET expressions reference the current row directly. Query parameters (`$name`)
488/// are rejected — fill expressions must be literal values or pointer references.
489pub fn compile_fill_expr(
490 type_name: &str,
491 expr_str: &str,
492 schema: &SchemaDescriptor,
493) -> Result<String, crate::error::PyQLError> {
494 let expr_ast = parse::parse_expr(expr_str)?;
495 let (ir_expr, params) = ir::compile_expr_unaliased(&expr_ast, type_name, schema)?;
496 if !params.is_empty() {
497 return Err(crate::error::PyQLError::Syntax(crate::error::PyQLSyntaxError {
498 message: "fill expressions may not contain query parameters".into(),
499 position: crate::error::Position { line: 0, col: 0 },
500 }));
501 }
502 Ok(sql::emit_expr(&ir_expr))
503}
504
505/// Compile a schema `Trigger`'s `handler` PyQL statement (e.g. `insert Note
506/// { note := __new__.name }`) to a full SQL statement, for embedding in the
507/// generated plpgsql trigger function body — see `ir::compile_trigger_handler`
508/// for the `__new__`/`__old__` row-context binding rules. `on_mask` is
509/// Pylon's `On` bitmask (1=Insert, 2=Update, 4=Delete), matching
510/// `TriggerDescriptor::on`. Query parameters (`$name`) are rejected, same
511/// rule as `compile_fill_expr` — a trigger handler has no caller to supply
512/// them.
513pub fn compile_trigger_handler(
514 handler: &str,
515 type_name: &str,
516 on_mask: u8,
517 schema: &SchemaDescriptor,
518) -> Result<String, crate::error::PyQLError> {
519 let ir_out = ir::compile_trigger_handler(handler, type_name, on_mask, schema)?;
520 if !ir_out.params.is_empty() {
521 return Err(crate::error::PyQLError::Syntax(crate::error::PyQLSyntaxError {
522 message: "trigger handlers may not contain query parameters".into(),
523 position: crate::error::Position { line: 0, col: 0 },
524 }));
525 }
526 Ok(sql::emit(&ir_out).sql)
527}
528
529/// Compile a PyQL query string to SQL against `schema`, using default
530/// session config (see `ir::SessionConfig`) — for schema-time/test callers
531/// with no live client-supplied config. `compile_with_config` is the real
532/// entry a query request uses.
533///
534/// Results are cached in a process-global LRU (capacity 1024). Call
535/// `clear_query_cache()` when the schema is reloaded to avoid stale entries.
536/// Synchronous — compilation is CPU-bound; async lives at the DB execution layer.
537/// Raises `PyQLError` on any grammar, type, or resolution failure.
538pub fn compile(query: &str, schema: &SchemaDescriptor) -> Result<Arc<CompiledQuery>, PyQLError> {
539 compile_with_config(query, schema, &ir::SessionConfig::default())
540}
541
542/// Every statement of a script, compiled in the order written.
543///
544/// A single statement comes back as a one-element list, and takes the cached
545/// path — so this is safe to call for any query, not only ones with semicolons
546/// in them.
547pub fn compile_script(
548 query: &str,
549 schema: &SchemaDescriptor,
550 config: &ir::SessionConfig,
551) -> Result<Vec<Arc<CompiledQuery>>, PyQLError> {
552 let statements = parse::parse_script(query)?;
553 if statements.len() == 1 {
554 return Ok(vec![compile_with_config(query, schema, config)?]);
555 }
556 statements
557 .iter()
558 .map(|ast| compile_ast(ast, schema, config).map(Arc::new))
559 .collect()
560}
561
562/// Like `compile`, but honors a caller-supplied `SessionConfig` for this query.
563///
564/// Returns an `Arc` — callers share one immutable compilation rather than
565/// each getting a deep copy of it.
566pub fn compile_with_config(
567 query: &str,
568 schema: &SchemaDescriptor,
569 config: &ir::SessionConfig,
570) -> Result<Arc<CompiledQuery>, PyQLError> {
571 let hash = cache_key_hash(query, config);
572 let shard = &query_cache()[(hash as usize) % CACHE_SHARDS];
573 {
574 let mut cache = shard.write().unwrap();
575 if let Some(entry) = cache.get(&hash) {
576 // Verify, don't assume: a 64-bit collision is vanishingly rare
577 // but handing back the wrong query's SQL would be silent and
578 // catastrophic, so a mismatch falls through to a real compile.
579 if entry.query == query && &entry.config == config {
580 return Ok(entry.compiled.clone());
581 }
582 }
583 }
584 let compiled = Arc::new(compile_uncached(query, schema, config)?);
585 shard.write().unwrap().put(
586 hash,
587 CacheEntry {
588 query: query.to_string(),
589 config: config.clone(),
590 compiled: compiled.clone(),
591 },
592 );
593 Ok(compiled)
594}
595
596fn compile_uncached(
597 query: &str,
598 schema: &SchemaDescriptor,
599 config: &ir::SessionConfig,
600) -> Result<CompiledQuery, PyQLError> {
601 let ast = parse::parse(query)?;
602 compile_ast(&ast, schema, config)
603}
604
605/// Compile one already-parsed statement. Shared by the single-statement path
606/// and by `compile_script`, which has several of them and no source text to
607/// hand back to the parser per statement.
608fn compile_ast(
609 ast: &parse::Stmt,
610 schema: &SchemaDescriptor,
611 config: &ir::SessionConfig,
612) -> Result<CompiledQuery, PyQLError> {
613 let is_analyze = matches!(ast, parse::Stmt::Analyze(_));
614 let ir_out = ir::compile_with_config(ast, schema, config)?;
615 // `analyze`'s own shape-path walk is skipped for every other query — no
616 // reason to pay for it when nothing will read `analyze_paths`.
617 let analyze_paths = is_analyze.then(|| {
618 let mut paths = analyze::collect_shape_path_aliases(&ir_out.stmt);
619 // The root marker has no IR pointer of its own to carry it (see
620 // `root_marker_offset`'s own doc comment) — filled in here from the
621 // original AST, still in scope at this point.
622 if let Some(root) = paths.iter_mut().find(|p| p.path == "root") {
623 root.marker_offset = analyze::root_marker_offset(ast);
624 }
625 paths
626 });
627 let tags = ir::tags::collect_tags(&ir_out);
628 let mutates = stmt_mutates(&ir_out.stmt) || ir_out.ctes.iter().any(|c| stmt_mutates(&c.stmt));
629 let sql_out = sql::emit(&ir_out);
630 let shape_id = derive_shape_id(&sql_out.sql, &sql_out.shape);
631 Ok(CompiledQuery {
632 sql: sql_out.sql,
633 param_names: ir_out.params,
634 param_tuple_types: ir_out.param_tuple_types,
635 params: Vec::new(),
636 shape: sql_out.shape,
637 warnings: ir_out.warnings,
638 inference_plan: sql_out.inference_plan,
639 tags,
640 mutates,
641 analyze_paths,
642 shape_id,
643 })
644}
645
646/// Whether executing this statement writes to any of its `tags`.
647///
648/// Not just the outermost node: a `FOR` body, a `WITH` binding
649/// (`with c := (insert Company {...}) select c`), and a select over a DML
650/// source (`select (insert Person {...}) { id }`) all write while presenting
651/// as something else.
652fn stmt_mutates(stmt: &ir::IrStmt) -> bool {
653 match stmt {
654 ir::IrStmt::Insert(_) | ir::IrStmt::Update(_) | ir::IrStmt::Delete(_) => true,
655 ir::IrStmt::For(f) => stmt_mutates(&f.body),
656 ir::IrStmt::Select(sel) => sel.dml_source.as_deref().is_some_and(stmt_mutates),
657 _ => false,
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::schema::{PropertyDescriptor, TypeDescriptor};
665
666 fn make_schema() -> SchemaDescriptor {
667 SchemaDescriptor {
668 types: vec![TypeDescriptor {
669 name: "Person".into(),
670 module: "default".into(),
671 table: "person".into(),
672 abstract_: false,
673 materialized: false,
674 description: None,
675 parents: vec![],
676 interfaces: vec![],
677 bases: vec![],
678 properties: vec![PropertyDescriptor {
679 name: "id".into(),
680 pg_type: "uuid".into(),
681 nullable: false,
682 default_sql: Some("uuidv7()".into()),
683 default_pyql: None,
684 description: None,
685 check_constraints: vec![],
686 is_exclusive: true,
687 is_pk: true,
688 is_readonly: true,
689 rewrites: vec![],
690 tuple_members: None,
691 column_type: None,
692 }],
693 links: vec![],
694 multilinks: vec![],
695 computed: vec![],
696 constraints: vec![],
697 indexes: vec![],
698 partition: None,
699 vector_indexes: vec![],
700 search_indexes: vec![],
701 triggers: vec![],
702 junction: false,
703 signals: vec![],
704 }],
705 scalars: vec![],
706 enums: vec![],
707 named_tuples: vec![],
708 globals: vec![],
709 functions: vec![],
710 aliases: vec![],
711 channels: vec![],
712 ..Default::default()
713 }
714 }
715
716 #[test]
717 fn test_analyze_paths_is_none_for_a_plain_query() {
718 let schema = make_schema();
719 let compiled = compile("select Person { id }", &schema).unwrap();
720 assert!(compiled.analyze_paths.is_none());
721 }
722
723 #[test]
724 fn test_analyze_paths_is_populated_for_an_analyze_query() {
725 let schema = make_schema();
726 let query = "analyze select Person { id }";
727 let compiled = compile(query, &schema).unwrap();
728 let paths = compiled
729 .analyze_paths
730 .as_ref()
731 .expect("analyze query should populate analyze_paths");
732 assert_eq!(paths.len(), 1);
733 assert_eq!(paths[0].path, "root");
734 let offset = paths[0].marker_offset.expect("root path should carry a marker offset");
735 assert_eq!(&query[offset..offset + "Person".len()], "Person");
736 }
737}