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