Skip to main content

hawdb_sql/pgq/create/
mod.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Read-only creation binding. Catalog publication and source snapshot ownership
16//! belong to the caller, independently of query binding and execution.
17
18mod expression;
19mod source;
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt;
23
24use hawdb_sql_syntax::{
25    CreatePropertyGraph, ElementExposure, ElementLabel, Identifier, PropertyExposure,
26    QualifiedName, Span,
27};
28
29use super::{PgqDataType, PropertyGraphElementSchema, PropertyGraphSchema};
30use crate::SqlDataType;
31use expression::BoundExpression;
32
33/// A coherent, read-only source metadata snapshot for one creation binding.
34/// The caller resolves search paths and owns its lifetime; no rows are read.
35pub trait PgqSourceCatalog {
36    fn source_table(&self, name: &[String]) -> Option<&PgqSourceTableSchema>;
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PgqSourceTableSchema {
41    /// Canonical decoded identity, also used by foreign-key targets.
42    pub name: Vec<String>,
43    /// Source order is retained for ALL COLUMNS expansion.
44    pub columns: Vec<PgqSourceColumnSchema>,
45    pub primary_key: Vec<String>,
46    pub foreign_keys: Vec<PgqSourceForeignKeySchema>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PgqSourceColumnSchema {
51    pub name: String,
52    pub data_type: SqlDataType,
53    pub nullable: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PgqSourceForeignKeySchema {
58    pub columns: Vec<String>,
59    pub referenced_table: Vec<String>,
60    pub referenced_columns: Vec<String>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum PgqCreateBindErrorCode {
66    InvalidSyntaxTree,
67    InvalidSourceSchema,
68    UnknownTable,
69    UnknownColumn,
70    DuplicateAlias,
71    MissingKey,
72    InvalidKey,
73    UnknownVertex,
74    MissingForeignKey,
75    AmbiguousForeignKey,
76    DuplicateLabel,
77    MissingPropertyName,
78    DuplicateProperty,
79    LabelMismatch,
80    PropertyTypeMismatch,
81    PropertyExpressionMismatch,
82    UnsupportedExpression,
83    InvalidLiteral,
84    TypeMismatch,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PgqCreateBindError {
89    pub code: PgqCreateBindErrorCode,
90    pub span: Span,
91    pub message: String,
92}
93
94impl fmt::Display for PgqCreateBindError {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(
97            formatter,
98            "SQL/PGQ creation binding error {:?} at bytes {}..{}: {}",
99            self.code, self.span.start, self.span.end, self.message
100        )
101    }
102}
103
104impl std::error::Error for PgqCreateBindError {}
105
106type Result<T> = std::result::Result<T, PgqCreateBindError>;
107use PgqCreateBindErrorCode as Code;
108
109/// Validate a parsed creation against a source snapshot and return its schema.
110///
111/// `input` must be the text from which `create` was parsed. This operation neither
112/// installs the schema nor changes query routing, storage, or catalog state.
113pub fn bind_postgres_create_property_graph(
114    input: &str,
115    create: &CreatePropertyGraph,
116    catalog: &dyn PgqSourceCatalog,
117) -> Result<PropertyGraphSchema> {
118    let binder = Binder {
119        input,
120        enclosing: create.span,
121    };
122    binder.text(create.span)?;
123    let mut graph = PropertyGraphSchema::new(binder.name(&create.name)?);
124    let mut aliases = BTreeSet::new();
125    let mut vertices = BTreeMap::new();
126    let mut elements = Vec::new();
127    for vertex in &create.vertex_tables {
128        binder.text(vertex.span)?;
129        let source = binder.source(catalog, &vertex.table)?;
130        let alias = binder.alias(source, vertex.alias.as_ref(), vertex.span)?;
131        binder.unique_alias(&mut aliases, &alias, vertex.span)?;
132        binder.key(source, &vertex.key, vertex.span)?;
133        vertices.insert(alias.clone(), source);
134        elements.push((true, source, alias, vertex.exposure.as_ref(), vertex.span));
135    }
136    for edge in &create.edge_tables {
137        binder.text(edge.span)?;
138        let source = binder.source(catalog, &edge.table)?;
139        let alias = binder.alias(source, edge.alias.as_ref(), edge.span)?;
140        binder.unique_alias(&mut aliases, &alias, edge.span)?;
141        binder.key(source, &edge.key, edge.span)?;
142        binder.endpoint(source, &edge.source, &vertices)?;
143        binder.endpoint(source, &edge.destination, &vertices)?;
144        elements.push((false, source, alias, edge.exposure.as_ref(), edge.span));
145    }
146    let mut labels = BTreeMap::new();
147    let mut property_types = BTreeMap::new();
148    for (vertex, source, alias, exposure, span) in elements {
149        let bound = binder.exposure(source, &alias, exposure, span)?;
150        for (label, properties) in bound {
151            let mut schema = PropertyGraphElementSchema::default();
152            for (name, expression) in properties {
153                if property_types
154                    .get(&name)
155                    .is_some_and(|previous| *previous != expression.data_type)
156                {
157                    return Err(binder.error(
158                        Code::PropertyTypeMismatch,
159                        span,
160                        format!("property {name} has inconsistent scalar types"),
161                    ));
162                }
163                property_types.insert(name.clone(), expression.data_type);
164                schema.properties.insert(name, expression.data_type);
165            }
166            if labels
167                .get(&label)
168                .is_some_and(|previous| previous != &schema)
169            {
170                return Err(binder.error(
171                    Code::LabelMismatch,
172                    span,
173                    format!("label {label} has inconsistent property sets"),
174                ));
175            }
176            labels.insert(label.clone(), schema.clone());
177            if vertex {
178                graph.add_vertex_label(label, schema);
179            } else {
180                graph.add_edge_label(label, schema);
181            }
182        }
183    }
184    Ok(graph)
185}
186
187struct Binder<'a> {
188    input: &'a str,
189    enclosing: Span,
190}
191
192impl Binder<'_> {
193    fn error(&self, code: Code, span: Span, message: impl Into<String>) -> PgqCreateBindError {
194        let span = if self.input.get(span.start..span.end).is_some() {
195            span
196        } else if self
197            .input
198            .get(self.enclosing.start..self.enclosing.end)
199            .is_some()
200        {
201            self.enclosing
202        } else {
203            Span::default()
204        };
205        PgqCreateBindError {
206            code,
207            span,
208            message: message.into(),
209        }
210    }
211
212    fn text(&self, span: Span) -> Result<&str> {
213        self.input
214            .get(span.start..span.end)
215            .ok_or_else(|| self.error(Code::InvalidSyntaxTree, span, "invalid source span"))
216    }
217
218    fn identifier(&self, identifier: &Identifier) -> Result<String> {
219        let text = self.text(identifier.span)?;
220        let decoded = if identifier.quoted {
221            text.strip_prefix('"')
222                .and_then(|text| text.strip_suffix('"'))
223                .filter(|text| !text.replace("\"\"", "").contains('"'))
224                .map(|text| text.replace("\"\"", "\""))
225        } else {
226            let mut chars = text.chars();
227            chars
228                .next()
229                .filter(|c| *c == '_' || c.is_alphabetic())
230                .filter(|_| chars.all(|c| c == '_' || c == '$' || c.is_alphanumeric()))
231                .map(|_| text.to_ascii_lowercase())
232        };
233        decoded
234            .filter(|name| !name.is_empty() && !name.contains('\0'))
235            .ok_or_else(|| {
236                self.error(
237                    Code::InvalidSyntaxTree,
238                    identifier.span,
239                    "invalid identifier",
240                )
241            })
242    }
243
244    fn name(&self, name: &QualifiedName) -> Result<Vec<String>> {
245        self.text(name.span)?;
246        if name.parts.is_empty() {
247            return Err(self.error(Code::InvalidSyntaxTree, name.span, "empty qualified name"));
248        }
249        name.parts
250            .iter()
251            .map(|part| self.identifier(part))
252            .collect()
253    }
254
255    fn unique_alias(&self, aliases: &mut BTreeSet<String>, alias: &str, span: Span) -> Result<()> {
256        if !aliases.insert(alias.to_owned()) {
257            return Err(self.error(
258                Code::DuplicateAlias,
259                span,
260                format!("duplicate element alias {alias}"),
261            ));
262        }
263        Ok(())
264    }
265
266    fn exposure(
267        &self,
268        source: &PgqSourceTableSchema,
269        alias: &str,
270        exposure: Option<&ElementExposure>,
271        span: Span,
272    ) -> Result<BTreeMap<String, BTreeMap<String, BoundExpression>>> {
273        let Some(exposure) = exposure else {
274            return Ok(BTreeMap::from([(
275                alias.to_owned(),
276                self.properties(source, &PropertyExposure::AllColumns, span)?,
277            )]));
278        };
279        self.text(exposure.span)?;
280        if exposure.labels.is_empty() {
281            return Err(self.error(
282                Code::InvalidSyntaxTree,
283                exposure.span,
284                "empty label exposure",
285            ));
286        }
287        let mut labels = BTreeMap::new();
288        let mut expressions = BTreeMap::new();
289        for label in &exposure.labels {
290            self.text(label.span)?;
291            let name = match &label.label {
292                ElementLabel::Implicit | ElementLabel::Default => alias.to_owned(),
293                ElementLabel::Named(name) => self.identifier(name)?,
294            };
295            if labels.contains_key(&name) {
296                return Err(self.error(
297                    Code::DuplicateLabel,
298                    label.span,
299                    format!("duplicate label {name}"),
300                ));
301            }
302            let properties = self.properties(source, &label.properties, label.span)?;
303            for (property, expression) in &properties {
304                if let Some(previous) = expressions.get(property)
305                    && previous != expression
306                {
307                    return Err(self.error(
308                        Code::PropertyExpressionMismatch,
309                        label.span,
310                        format!("property {property} uses different expressions on one element"),
311                    ));
312                }
313                expressions.insert(property.clone(), expression.clone());
314            }
315            labels.insert(name, properties);
316        }
317        Ok(labels)
318    }
319
320    fn properties(
321        &self,
322        source: &PgqSourceTableSchema,
323        exposure: &PropertyExposure,
324        span: Span,
325    ) -> Result<BTreeMap<String, BoundExpression>> {
326        let mut properties = BTreeMap::new();
327        match exposure {
328            PropertyExposure::AllColumns => {
329                for (index, column) in source.columns.iter().enumerate() {
330                    properties.insert(
331                        column.name.clone(),
332                        BoundExpression::column(
333                            index,
334                            scalar_type(column.data_type).ok_or_else(|| {
335                                self.error(
336                                    Code::UnsupportedExpression,
337                                    span,
338                                    "VECTOR graph properties are not supported",
339                                )
340                            })?,
341                        ),
342                    );
343                }
344            }
345            PropertyExposure::NoProperties => {}
346            PropertyExposure::Expressions(expressions) => {
347                if expressions.is_empty() {
348                    return Err(self.error(
349                        Code::InvalidSyntaxTree,
350                        span,
351                        "empty property expression list",
352                    ));
353                }
354                for property in expressions {
355                    self.text(property.span)?;
356                    let mut expression = self.expression(source, &property.expression, 0)?;
357                    let name = match &property.alias {
358                        Some(alias) => self.identifier(alias)?,
359                        None => expression
360                            .implicit_column_name(source, &property.expression)
361                            .ok_or_else(|| {
362                                self.error(
363                                    Code::MissingPropertyName,
364                                    property.span,
365                                    "non-column property requires AS",
366                                )
367                            })?,
368                    };
369                    self.resolve_unknown(&mut expression, PgqDataType::String, property.span)?;
370                    if properties.insert(name.clone(), expression).is_some() {
371                        return Err(self.error(
372                            Code::DuplicateProperty,
373                            property.span,
374                            format!("duplicate property {name}"),
375                        ));
376                    }
377                }
378            }
379        }
380        Ok(properties)
381    }
382}
383
384fn scalar_type(data_type: SqlDataType) -> Option<PgqDataType> {
385    Some(match data_type {
386        SqlDataType::Boolean => PgqDataType::Boolean,
387        SqlDataType::BigInt => PgqDataType::Int64,
388        SqlDataType::DoublePrecision => PgqDataType::Float64,
389        SqlDataType::Text => PgqDataType::String,
390        SqlDataType::Bytea => PgqDataType::Binary,
391        SqlDataType::Uuid => PgqDataType::Uuid,
392        SqlDataType::Vector(_) => return None,
393    })
394}
395
396#[cfg(test)]
397mod tests;