pylon_core/schema/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
20pub mod tuple_type;
21
22// ── Deletion policies ──────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub enum DeleteSide {
26 Target,
27 Source,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub enum DeleteAction {
32 Allow,
33 Restrict,
34 DeferredRestrict,
35 DeleteSource,
36 DeleteTarget,
37 DeleteTargetIfOrphan,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct OnDeletePolicy {
42 pub side: DeleteSide,
43 pub action: DeleteAction,
44}
45
46// ── Mutation rewrites ──────────────────────────────────────────────────────────
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct RewriteEntry {
50 /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag).
51 pub on: u8,
52 /// PyQL expression evaluated as the new value.
53 pub handler: String,
54}
55
56// ── Post-commit signal registrations ───────────────────────────────────────────
57
58/// One `on=` registration for a type from the Python-side signal registry —
59/// just the operation bitmask, never the handler itself (the actual
60/// callable stays Python-only and never crosses into this descriptor).
61/// Combined across every handler registered for a type, this drives
62/// whether the DDL emitter attaches a capture trigger to that type's table
63/// at all (see `export::signal_trigger_infos`).
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct SignalEntry {
66 /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag,
67 /// same convention as `RewriteEntry.on`).
68 pub on: u8,
69}
70
71// ── Pointer descriptors ────────────────────────────────────────────────────────
72
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct PropertyDescriptor {
75 pub name: String,
76 /// PostgreSQL column type, e.g. `text`, `int8`, `uuid`. Always a plain
77 /// base type — every read/write/cast/comparison site relies on that, so
78 /// a registered custom scalar's own DOMAIN name lives in `column_type`
79 /// instead, never here.
80 pub pg_type: String,
81 pub nullable: bool,
82 /// SQL expression for the column DEFAULT clause.
83 pub default_sql: Option<String>,
84 /// PyQL expression to be compiled to SQL at DDL-emit time.
85 /// Takes precedence over `default_sql` when both could be set (they won't be).
86 pub default_pyql: Option<String>,
87 pub description: Option<String>,
88 /// Pre-compiled SQL CHECK expressions, e.g. `"price >= 0"`.
89 pub check_constraints: Vec<String>,
90 /// True when a UNIQUE constraint applies to this column alone.
91 pub is_exclusive: bool,
92 /// True when this column is the table primary key.
93 pub is_pk: bool,
94 /// True when the transpiler should reject PyQL updates targeting this pointer.
95 pub is_readonly: bool,
96 pub rewrites: Vec<RewriteEntry>,
97 /// `Some` only for a structural `pylon.Tuple[...]`-typed property — its
98 /// element shape, for decode-time `ShapeNode` building. A *nominal*
99 /// `@pylon.named_tuple`-typed property instead carries its shape via the
100 /// `__nt__:module::Name` `pg_type` marker + `NamedTupleDescriptor.members`.
101 /// An `Array[Tuple[...]]` property carries its *element's* member shape
102 /// here, with the array-ness left to `pg_type`'s own `[]` suffix.
103 pub tuple_members: Option<Vec<TupleMemberDescriptor>>,
104 /// `Some("\"schema\".\"Name\"")` only when this property's scalar type is
105 /// a *registered* custom scalar (see `pylon.scalar(..., name=...)` /
106 /// the `@pylon.scalar` decorator form) — the schema-qualified name of
107 /// the PostgreSQL DOMAIN that scalar compiles to (see
108 /// `export::emit_scalars`). Consulted only for the column's own DDL
109 /// type (`CREATE TABLE` / `ADD COLUMN`); every other use of this
110 /// property (casts, comparisons, wire decode) keeps using `pg_type`'s
111 /// plain base type, so a domain-typed column still round-trips exactly
112 /// like its base type — Postgres enforces the domain's CHECK on writes
113 /// regardless of which type name the read/write path itself uses.
114 pub column_type: Option<String>,
115}
116
117/// A property's `pg_type` with the `__nt__:module::Name` nominal-tuple
118/// marker resolved to the type that column really has — `jsonb`, or
119/// `jsonb[]` for an `Array[SomeNamedTuple]`, which stores one jsonb tuple
120/// per element. Every other `pg_type` passes through untouched.
121pub fn resolved_pg_type(pg_type: &str) -> &str {
122 match pg_type.strip_prefix("__nt__:") {
123 Some(marker) if marker.ends_with("[]") => "jsonb[]",
124 Some(_) => "jsonb",
125 None => pg_type,
126 }
127}
128
129/// The PostgreSQL type a property's column is *declared* with — the one
130/// place that decides it, shared by the DDL emitter and the migration diff
131/// so a column and the diff's idea of that column can't drift apart.
132///
133/// Three kinds of property have a column type that isn't just `pg_type`: a
134/// tuple, which gets a composite type of its own (see `tuple_type`); a
135/// registered custom scalar, which gets its DOMAIN (`column_type`); and a
136/// nominal tuple marker, which `resolved_pg_type` resolves. `owner_module`
137/// is the module of the type declaring the property — where a structural
138/// tuple's composite type lives.
139pub fn column_ddl_type(p: &PropertyDescriptor, owner_module: &str) -> String {
140 if let Some(composite) = tuple_type::property_column_type(p, owner_module) {
141 return composite;
142 }
143 match &p.column_type {
144 Some(domain) => domain.clone(),
145 None => resolved_pg_type(&p.pg_type).to_string(),
146 }
147}
148
149#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
150pub struct LinkDescriptor {
151 pub name: String,
152 /// Qualified name of the target type, e.g. `catalog::Category`.
153 pub target: String,
154 pub nullable: bool,
155 pub description: Option<String>,
156 pub default_pyql: Option<String>,
157 /// True when a UNIQUE constraint applies to this FK column alone.
158 pub is_exclusive: bool,
159 /// True when the transpiler should reject PyQL updates targeting this pointer.
160 pub is_readonly: bool,
161 pub rewrites: Vec<RewriteEntry>,
162 pub on_delete: Vec<OnDeletePolicy>,
163 /// Qualified name of the explicit junction type, if any — when set, this
164 /// link is backed by a junction table (source, target, plus the
165 /// junction's own properties) instead of a `{name}_id` FK column on the
166 /// source table, the same storage MultiLink's own `through` already
167 /// uses, just constrained to at most one row per source.
168 pub through: Option<String>,
169}
170
171impl LinkDescriptor {
172 pub fn is_junction_backed(&self) -> bool {
173 self.through.is_some()
174 }
175}
176
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
178pub struct MultiLinkDescriptor {
179 pub name: String,
180 /// Qualified name of the target type.
181 pub target: String,
182 /// Qualified name of the explicit junction type, if any.
183 pub through: Option<String>,
184 pub nullable: bool,
185 pub description: Option<String>,
186 pub default_pyql: Option<String>,
187 pub on_delete: Vec<OnDeletePolicy>,
188 /// True when a target may be linked from at most one source, which makes
189 /// the backlink single.
190 #[serde(default)]
191 pub is_exclusive: bool,
192}
193
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
195pub struct ComputedDescriptor {
196 pub name: String,
197 /// PyQL expression evaluated at query time.
198 pub expression: String,
199 /// PostgreSQL return type, if known at schema-build time.
200 pub return_type: Option<String>,
201 /// The qualified object type a link-valued computed selects
202 /// (`Computed[Link[Email], "…"]`) — `None` for a scalar computed, whose
203 /// type is `return_type` instead. A consumer that has only the schema to
204 /// go on (the schema browser, an editor) cannot tell the two apart
205 /// otherwise: both carry a bare expression string.
206 #[serde(default)]
207 pub link_target: Option<String>,
208 /// Whether that link-valued computed selects many.
209 #[serde(default)]
210 pub link_multi: bool,
211}
212
213// ── Type-level constructs ──────────────────────────────────────────────────────
214
215// ── Search index ───────────────────────────────────────────────────────────────
216
217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218pub enum SearchBackend {
219 Postgres,
220 OpenSearch,
221 Meilisearch,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225pub enum SearchWeight {
226 A,
227 B,
228 C,
229 D,
230}
231
232impl SearchWeight {
233 pub fn as_str(&self) -> &'static str {
234 match self {
235 SearchWeight::A => "A",
236 SearchWeight::B => "B",
237 SearchWeight::C => "C",
238 SearchWeight::D => "D",
239 }
240 }
241}
242
243#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
244pub struct SearchPointerDescriptor {
245 pub name: String,
246 pub weight: SearchWeight,
247}
248
249#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
250pub struct SearchIndexDescriptor {
251 pub index_name: Option<String>,
252 pub backend: SearchBackend,
253 pub pointers: Vec<SearchPointerDescriptor>,
254}
255
256impl SearchIndexDescriptor {
257 pub fn column_name(&self) -> String {
258 match &self.index_name {
259 None => "__search__".to_string(),
260 Some(name) => format!("__search_{}__", name),
261 }
262 }
263
264 /// Deferred search index name: `"module__table[__name]"` in lowercase.
265 pub fn deferred_index_name(&self, module: &str, type_name: &str) -> String {
266 let base = format!("{}__{}", module, type_name).to_lowercase();
267 match &self.index_name {
268 None => base,
269 Some(n) => format!("{}__{}", base, n.to_lowercase()),
270 }
271 }
272}
273
274/// How a partitioned type's ranges are sized.
275///
276/// Range partitioning on a time column only — the case declarative
277/// partitioning actually pays off for, and the only one that has a sensible
278/// automatic maintenance story (create the next few ranges, drop the ones
279/// past retention). List and hash partitioning need a key set known up
280/// front, which is a different feature, not a parameter of this one.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
282pub enum PartitionInterval {
283 Daily,
284 Weekly,
285 Monthly,
286 Yearly,
287}
288
289impl PartitionInterval {
290 /// The PostgreSQL interval literal for one partition's width.
291 pub fn as_pg_interval(self) -> &'static str {
292 match self {
293 PartitionInterval::Daily => "1 day",
294 PartitionInterval::Weekly => "1 week",
295 PartitionInterval::Monthly => "1 month",
296 PartitionInterval::Yearly => "1 year",
297 }
298 }
299
300 pub fn as_str(self) -> &'static str {
301 match self {
302 PartitionInterval::Daily => "daily",
303 PartitionInterval::Weekly => "weekly",
304 PartitionInterval::Monthly => "monthly",
305 PartitionInterval::Yearly => "yearly",
306 }
307 }
308
309 pub fn parse(s: &str) -> Option<Self> {
310 match s {
311 "daily" => Some(PartitionInterval::Daily),
312 "weekly" => Some(PartitionInterval::Weekly),
313 "monthly" => Some(PartitionInterval::Monthly),
314 "yearly" => Some(PartitionInterval::Yearly),
315 _ => None,
316 }
317 }
318}
319
320/// Declarative range partitioning for one type, maintained by pg_partman.
321///
322/// A type carries at most one of these: a table has exactly one partition
323/// key, so a second declaration isn't a refinement, it's a contradiction.
324/// See `validate_partitions`.
325#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
326pub struct PartitionDescriptor {
327 /// The property partitioned on. Must be a non-nullable date/timestamp
328 /// property of this type — PostgreSQL requires the partition key to be
329 /// part of the primary key and to never be NULL.
330 pub pointer: String,
331 pub interval: PartitionInterval,
332 /// How many future partitions to keep pre-created. A write landing in a
333 /// range that doesn't exist yet fails, so this is the safety margin
334 /// against maintenance falling behind.
335 pub premake: u32,
336 /// Drop partitions older than this many intervals. `None` keeps
337 /// everything — the safe default, since the alternative silently deletes
338 /// data on a schedule.
339 pub retention: Option<u32>,
340}
341
342impl PartitionDescriptor {
343 /// `retention` as a PostgreSQL interval literal, for `part_config`.
344 pub fn retention_interval(&self) -> Option<String> {
345 self.retention.map(|n| match self.interval {
346 PartitionInterval::Daily => format!("{n} days"),
347 PartitionInterval::Weekly => format!("{n} weeks"),
348 PartitionInterval::Monthly => format!("{n} months"),
349 PartitionInterval::Yearly => format!("{n} years"),
350 })
351 }
352}
353
354#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
355pub struct VectorIndexDescriptor {
356 /// `None` = default (bare) index; `Some(name)` = named index.
357 pub index_name: Option<String>,
358 /// Source pointers whose text is concatenated to form the embedding input.
359 pub pointers: Vec<String>,
360 /// Embedding model identifier, e.g. `"mistral-embed"`.
361 pub model: String,
362 /// Distance metric: `"cosine"` | `"euclidean"` | `"inner_product"`.
363 pub metric: String,
364 /// Embedding dimension, e.g. `1024`.
365 pub dimensions: u32,
366}
367
368impl VectorIndexDescriptor {
369 /// PostgreSQL column name for this index's vector column.
370 pub fn column_name(&self) -> String {
371 match &self.index_name {
372 None => "__vector__".to_string(),
373 Some(name) => format!("__vector_{}__", name),
374 }
375 }
376
377 /// pgvector operator class for the configured metric.
378 pub fn ops_class(&self) -> &'static str {
379 match self.metric.as_str() {
380 "euclidean" => "vector_l2_ops",
381 "inner_product" => "vector_ip_ops",
382 _ => "vector_cosine_ops",
383 }
384 }
385}
386
387#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
388pub struct IndexDescriptor {
389 /// Pointer names for a simple or composite index; empty when is_expression=true.
390 pub pointers: Vec<String>,
391 /// PyQL expression for an expression index.
392 pub expression: Option<String>,
393 pub unique: bool,
394 /// PyQL partial-index predicate.
395 pub unless: Option<String>,
396}
397
398#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
399pub struct TriggerDescriptor {
400 /// Bitmask: 1=Insert, 2=Update, 4=Delete.
401 pub on: u8,
402 /// "Before" | "After" | "InsteadOf"
403 pub timing: String,
404 pub handler: String,
405}
406
407#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
408pub enum TypeConstraint {
409 /// Composite UNIQUE INDEX across multiple pointers.
410 Exclusive {
411 pointers: Vec<String>,
412 unless: Option<String>,
413 },
414 /// Arbitrary CHECK constraint expressed as a PyQL boolean expression.
415 Expression { expr: String },
416}
417
418// ── Type descriptor ────────────────────────────────────────────────────────────
419
420#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
421pub struct TypeDescriptor {
422 /// Unqualified name, e.g. `Product`.
423 pub name: String,
424 /// Pylon module, e.g. `catalog`.
425 pub module: String,
426 /// PostgreSQL table (or view) name, e.g. `catalog_product`.
427 pub table: String,
428 /// True for `@pylon.abstract` / `@pylon.interface`.
429 pub abstract_: bool,
430 /// True when a PostgreSQL VIEW is emitted (always true for `@pylon.interface`).
431 pub materialized: bool,
432 pub description: Option<String>,
433 /// Qualified names of abstract (non-materialized) parents.
434 pub parents: Vec<String>,
435 /// Qualified names of interface parents.
436 pub interfaces: Vec<String>,
437 /// Qualified names of the concrete types this one extends, nearest first
438 /// (`BrandAddonBundle` extends `BrandAddon`). Each has a table of its own,
439 /// and reading one of them reads this type's rows too.
440 #[serde(default)]
441 pub bases: Vec<String>,
442 /// Flattened properties (includes inherited from abstract parents).
443 pub properties: Vec<PropertyDescriptor>,
444 /// Flattened links.
445 pub links: Vec<LinkDescriptor>,
446 /// Flattened multi-links.
447 pub multilinks: Vec<MultiLinkDescriptor>,
448 /// Computed (virtual) pointers.
449 pub computed: Vec<ComputedDescriptor>,
450 /// Composite UNIQUE and CHECK constraints (own + inherited from abstract parents).
451 pub constraints: Vec<TypeConstraint>,
452 /// Non-unique indexes (own + inherited from abstract parents).
453 pub indexes: Vec<IndexDescriptor>,
454 /// Declarative range partitioning, at most one per type. `None` for an
455 /// ordinary table.
456 #[serde(default)]
457 pub partition: Option<PartitionDescriptor>,
458 /// Vector (embedding) indexes.
459 pub vector_indexes: Vec<VectorIndexDescriptor>,
460 /// Full-text search indexes.
461 pub search_indexes: Vec<SearchIndexDescriptor>,
462 /// Triggers (own + inherited from abstract parents).
463 pub triggers: Vec<TriggerDescriptor>,
464 /// True for `@pylon.junction` — type is a junction table for a MultiLink.
465 pub junction: bool,
466 /// Post-commit signal registrations from the Python-side registry — one
467 /// entry per distinct `on=` bitmask a handler was registered with (not
468 /// one per handler). Empty unless at least one signal targets this
469 /// type; drives whether a capture trigger gets attached at all.
470 pub signals: Vec<SignalEntry>,
471}
472
473// ── Scalar / enum / global descriptors ────────────────────────────────────────
474
475#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
476pub struct ScalarDescriptor {
477 /// Qualified scalar name, e.g. `default::Email`.
478 pub name: String,
479 pub module: String,
480 /// Pylon base scalar, e.g. `Str`, `Int64`.
481 pub base: String,
482 /// PostgreSQL base type, e.g. `text`, `int8`.
483 pub pg_type: String,
484 /// Pre-compiled SQL CHECK expressions for the DOMAIN constraint.
485 pub check_constraints: Vec<String>,
486 /// True when the scalar extends `pylon.Sequence` — generates a PostgreSQL SEQUENCE.
487 pub is_sequence: bool,
488}
489
490#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
491pub struct EnumDescriptor {
492 pub name: String,
493 pub module: String,
494 pub members: Vec<String>,
495}
496
497/// One member's type within a named-tuple/tuple-shaped value — recursive so a
498/// member can itself be a nested tuple. Drives decode-time shape building
499/// (`ShapeNode`) so a jsonb-backed tuple value decodes with real per-member
500/// types instead of an opaque dict/list.
501#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
502pub enum TupleMemberKind {
503 Scalar {
504 pg_type: String,
505 },
506 Enum {
507 module: String,
508 name: String,
509 },
510 /// A member typed as a registered `@pylon.named_tuple` class.
511 NamedTuple {
512 module: String,
513 name: String,
514 },
515 /// A member typed as a nested structural `pylon.Tuple[...]`.
516 Tuple {
517 members: Vec<TupleMemberDescriptor>,
518 },
519}
520
521#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
522pub struct TupleMemberDescriptor {
523 /// `None` for an unnamed/positional element of a structural tuple.
524 pub name: Option<String>,
525 pub kind: TupleMemberKind,
526}
527
528/// A registered (nominal) `@pylon.named_tuple` type — used both for cast-target
529/// resolution (`<module::Name>expr` → jsonb) and, via `members`, for decoding a
530/// value read back from a column/cast of this type with real per-member types
531/// instead of an opaque dict.
532#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
533pub struct NamedTupleDescriptor {
534 pub name: String,
535 pub module: String,
536 pub members: Vec<TupleMemberDescriptor>,
537}
538
539#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
540pub struct GlobalDescriptor {
541 pub name: String,
542 pub module: String,
543 pub scalar_type: String,
544 pub required: bool,
545 pub default_expr: Option<String>,
546 /// PyQL expression string for computed globals (e.g. `select User filter .id = global current_user_id`).
547 /// When set, the global is computed from this expression at query time rather than injected as a parameter.
548 pub computed_expr: Option<String>,
549}
550
551// ── Function descriptors ────────────────────────────────────────────────────────
552
553#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
554pub struct FunctionParamDescriptor {
555 pub name: String,
556 /// PostgreSQL type string, e.g. `int8`, `text`, `uuid`.
557 pub pg_type: String,
558}
559
560#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
561pub struct FunctionDescriptor {
562 pub name: String,
563 pub module: String,
564 pub params: Vec<FunctionParamDescriptor>,
565 /// For scalar returns: the PG type (e.g. `int8`).
566 /// For object returns: the qualified type name (e.g. `default::Account`).
567 pub return_pg_type: String,
568 pub return_is_object: bool,
569 /// True when the return is `set[T]`.
570 pub return_is_set: bool,
571 /// True when the return type is a polymorphic interface (abstract + materialized).
572 pub return_is_polymorphic: bool,
573 /// "immutable" | "stable" | "volatile"
574 pub volatility: String,
575 /// PyQL expression string (the function body from the docstring).
576 pub body: String,
577}
578
579// ── Alias descriptor ───────────────────────────────────────────────────────────
580
581#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
582pub struct AliasDescriptor {
583 pub name: String,
584 pub module: String,
585 /// PyQL expression string (the alias body).
586 pub expr: String,
587}
588
589// ── Channel descriptor ──────────────────────────────────────────────────────────
590
591/// The shape of a `Channel`'s payload, as declared in the schema DSL.
592#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
593pub enum ChannelPayload {
594 /// A registered object type, by its qualified name (e.g. `default::User`) —
595 /// `notify()` sends the object, `listen()` decodes into that type.
596 Type(String),
597 /// A plain scalar's Postgres type (e.g. `text`, `int8`).
598 Scalar(String),
599 /// An ad hoc named-field payload (`pylon.Object(...)`) — field name to
600 /// scalar Postgres type, in declaration order. No backing table; decodes
601 /// client-side into a `pylon.Object` instance.
602 Object(Vec<(String, String)>),
603}
604
605/// A PostgreSQL pub/sub channel (`NOTIFY`/`LISTEN`) declared in the schema.
606/// Has zero physical DDL footprint — nothing here ever produces a DDL step
607/// in `diff_schema_steps`; its mere presence in a schema is exactly the kind
608/// of content-only change `schema_content_changed` exists to catch.
609#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
610pub struct ChannelDescriptor {
611 pub name: String,
612 pub module: String,
613 /// The actual PostgreSQL NOTIFY/LISTEN channel identifier. Unlike every
614 /// other named construct in this descriptor, Postgres channels have no
615 /// schema namespacing at all — a flat, database-wide identifier — so
616 /// this is already fully disambiguated (module folded in) by the time
617 /// it gets here; see `pylon.schema._channels.wire_name_for_channel`.
618 pub wire_name: String,
619 pub payload: ChannelPayload,
620 pub description: Option<String>,
621}
622
623// ── Top-level schema ───────────────────────────────────────────────────────────
624
625#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
626pub struct SchemaDescriptor {
627 pub types: Vec<TypeDescriptor>,
628 pub scalars: Vec<ScalarDescriptor>,
629 pub enums: Vec<EnumDescriptor>,
630 pub named_tuples: Vec<NamedTupleDescriptor>,
631 pub globals: Vec<GlobalDescriptor>,
632 pub functions: Vec<FunctionDescriptor>,
633 pub aliases: Vec<AliasDescriptor>,
634 pub channels: Vec<ChannelDescriptor>,
635 /// `ir::functions_needing_globals`, which compiles every function body
636 /// and is asked for by every compile that calls one. Filled on first use,
637 /// so a schema must not change once it has been compiled against.
638 #[serde(skip)]
639 pub functions_needing_globals: Derived<std::collections::HashSet<String>>,
640}
641
642/// A value computed from the schema on first use. A clone starts out empty,
643/// so a copy that is then changed never reads the original's value.
644#[derive(Debug)]
645pub struct Derived<T>(std::sync::OnceLock<T>);
646
647impl<T> Derived<T> {
648 pub fn get_or_init(&self, init: impl FnOnce() -> T) -> &T {
649 self.0.get_or_init(init)
650 }
651}
652
653impl<T> Default for Derived<T> {
654 fn default() -> Self {
655 Derived(std::sync::OnceLock::new())
656 }
657}
658
659impl<T> Clone for Derived<T> {
660 fn clone(&self) -> Self {
661 Derived::default()
662 }
663}
664
665impl SchemaDescriptor {
666 /// Find a declared `Channel` by bare or `module::name` reference — the
667 /// single lookup both `notify()` (`ir::compiler::Compiler::resolve_channel`)
668 /// and `pylon-client`'s `Client::listen()` resolve a channel argument
669 /// through, so a schema author's `notify(Foo, ...)` and a client's
670 /// `listen("Foo")` agree on exactly the same name.
671 pub fn find_channel(&self, name: &str) -> Option<&ChannelDescriptor> {
672 self.channels
673 .iter()
674 .find(|c| c.name == name || format!("{}::{}", c.module, c.name) == name)
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn schema_with_one_channel() -> SchemaDescriptor {
683 SchemaDescriptor {
684 channels: vec![ChannelDescriptor {
685 name: "Pings".into(),
686 module: "shop".into(),
687 wire_name: "shop__pings".into(),
688 payload: ChannelPayload::Scalar("text".into()),
689 description: None,
690 }],
691 ..Default::default()
692 }
693 }
694
695 #[test]
696 fn find_channel_matches_bare_name() {
697 let schema = schema_with_one_channel();
698 assert_eq!(
699 schema.find_channel("Pings").map(|c| c.wire_name.as_str()),
700 Some("shop__pings")
701 );
702 }
703
704 #[test]
705 fn find_channel_matches_qualified_name() {
706 let schema = schema_with_one_channel();
707 assert_eq!(
708 schema.find_channel("shop::Pings").map(|c| c.wire_name.as_str()),
709 Some("shop__pings")
710 );
711 }
712
713 #[test]
714 fn find_channel_returns_none_for_unknown_name() {
715 let schema = schema_with_one_channel();
716 assert!(schema.find_channel("NoSuchChannel").is_none());
717 }
718}