8000 feat(spec)!: return a statement's result as a rows type by colinhacks · Pull Request #184 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/scripts/install-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ try {
const command = join(project, "node_modules", ".bin", windows ? "reylo.cmd" : "reylo");
const compiled = run(command, ["compile", "user.sql"], { cwd: project, env }).trim();
console.log(compiled);
const descriptor = JSON.parse(compiled) as { file?: string; columns?: { name: string }[]; cardinality?: string };
if (descriptor.file !== "user.sql" || descriptor.columns?.map((c) => c.name).join() !== "id,name" || descriptor.cardinality !== "at_most_one") throw new Error("reylo compile did not type user.sql");
const descriptor = JSON.parse(compiled) as { file?: string; result?: { columns: { name: string }[]; cardinality: string } };
if (descriptor.file !== "user.sql" || descriptor.result?.columns.map((c) => c.name).join() !== "id,name" || descriptor.result?.cardinality !== "at_most_one") throw new Error("reylo compile did not type user.sql");
// The playground's server, which the launcher runs in JavaScript.
if (!run(command, ["play", "--help"], { cwd: project }).startsWith("Usage: reylo play")) throw new Error("reylo play --help printed no usage");
const ran = run(process.execPath, ["--import", "reylo/register", "main.ts"], { cwd: project, env }).trim();
Expand Down
5 changes: 2 additions & 3 deletions engine/cli/src/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use crate::project::{self, Project};
use reylo_engine_core::api::{utf16_offset, Analysis};
use reylo_engine_core::dialect::Dialect;
use reylo_engine_core::types::{Cardinality, DialectVersion, Diagnostic, Kind, StatementDescriptor};
use reylo_engine_core::types::{Cardinality, DialectVersion, Diagnostic, Kind, RowsType, StatementDescriptor};
use reylo_engine_native::Failure;
use serde_json::{Map, Value};
use std::collections::HashMap;
Expand Down Expand Up @@ -175,8 +175,7 @@ async fn compile_all(dialect: &str, url: String, given: bool, base: &Path, proje
kind: None,
comment: None,
params: Vec::new(),
columns: Vec::new(),
cardinality: Cardinality::Many,
result: RowsType { cardinality: Cardinality::Many, columns: Vec::new(), key: None, ordered: false }.into(),
diagnostics: vec![Diagnostic { message, start: 0, end: utf16_offset(&input.sql, input.sql.len()), code: None }],
},
};
Expand Down
5 changes: 2 additions & 3 deletions engine/core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
use crate::database::Database;
use crate::dialect::Dialect;
use crate::lex;
use crate::types::{self, Cardinality, DialectVersion, StatementDescriptor, StatementKind, Type};
use crate::types::{self, Cardinality, DialectVersion, RowsType, StatementDescriptor, StatementKind, Type};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand Down Expand Up @@ -266,8 +266,7 @@ impl Analysis {
kind: self.kind,
comment: self.comment.clone(),
params: self.params.iter().map(Param::descriptor).collect(),
columns: self.columns.iter().map(Column::descriptor).collect(),
cardinality: self.cardinality,
result: RowsType { cardinality: self.cardinality, columns: self.columns.iter().map(Column::descriptor).collect(), key: self.key.clone(), ordered: self.ordered }.into(),
diagnostics: self.diagnostics.iter().map(|d| {
let start = utf16_offset(sql, d.start);
types::Diagnostic { message: d.message.clone(), start, end: utf16_offset(sql, d.start + d.length).max(start), code: d.server_code.clone() }
Expand Down
2 changes: 2 additions & 0 deletions engine/core/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ impl Type {
let entry = json!({"type": "object", "properties": {"key": m.key.json_schema(opts), "value": if m.value.nullable { nullable(value) } else { value }}, "required": ["key", "value"]});
json!({"type": "array", "items": entry})
}
// A relation nested in a value, as a list of its rows.
Kind::Rows(_) => json!({"type": "array", "items": {"type": "object"}}),
// A tagged union as a client returns one: its tag and its value, `{ tag, value }`.
Kind::TaggedUnion(t) => json!({"anyOf": t.alternatives.iter().map(|a| {
let value = a.ty.json_schema(opts);
Expand Down
103 changes: 95 additions & 8 deletions engine/core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub enum Kind {
/// One of several alternatives, each named by the tag every value carries: DuckDB's `UNION`.
/// Appended, as a new kind is, so the order of the kinds never changes.
TaggedUnion(TaggedUnionType),
/// Rows of columns: what a statement returns, and a relation nested in a value.
Rows(RowsType),
}

/// Bounds on an ordered kind's finite values, each a value in its kind's canonical text. A side
Expand Down Expand Up @@ -536,6 +538,52 @@ pub struct Param {
/// [`StatementDescriptor`] names in `$schema`: the `$id` of `spec/typedefs.schema.json`.
pub const SCHEMA: &str = "https://reylo.dev/typedefs/0.1.0.json";

/// Rows of columns: what a statement returns, and a relation nested in a value, such as SQL's
/// `MULTISET` or an Oracle nested table.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RowsType {
pub cardinality: Cardinality,
pub columns: Vec<Column>,
/// Columns, by name, no two rows agree on all of, a NULL equal to another NULL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<Vec<String>>,
/// The order of the rows is total, the same on every run.
#[serde(default, skip_serializing_if = "is_false")]
pub ordered: bool,
}

/// What a statement returns: a [`RowsType`], with what every type may carry. It reads and writes as a [`Type`] of kind `rows`, and refuses any other kind, as the format does.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "Type", into = "Type")]
pub struct ResultRows {
pub rows: RowsType,
pub name: Option<String>,
pub comment: Option<String>,
}

impl From<RowsType> for ResultRows {
fn from(rows: RowsType) -> Self {
ResultRows { rows, name: None, comment: None }
}
}

impl TryFrom<Type> for ResultRows {
type Error = String;

fn try_from(ty: Type) -> Result<Self, String> {
match ty.kind {
Kind::Rows(rows) => Ok(ResultRows { rows, name: ty.name, comment: ty.comment }),
other => Err(format!("a statement's result is of kind rows, not {}", other.name())),
}
}
}

impl From<ResultRows> for Type {
fn from(result: ResultRows) -> Self {
Type { kind: Kind::Rows(result.rows), name: result.name, comment: result.comment }
}
}

/// What introspecting one statement returns.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatementDescriptor {
Expand All @@ -553,8 +601,8 @@ pub struct StatementDescriptor {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub params: Vec<Param>,
pub columns: Vec<Column>,
pub cardinality: Cardinality,
/// What the statement returns.
pub result: ResultRows,
pub diagnostics: Vec<Diagnostic>,
}

Expand Down Expand Up @@ -681,6 +729,7 @@ impl Kind {
Kind::Any => 23,
Kind::Unknown => 24,
Kind::TaggedUnion(_) => 25,
Kind::Rows(_) => 26,
}
}

Expand Down Expand Up @@ -713,6 +762,7 @@ impl Kind {
Kind::Any => "any",
Kind::Unknown => "unknown",
Kind::TaggedUnion(_) => "tagged_union",
Kind::Rows(_) => "rows",
}
}
}
Expand Down Expand Up @@ -895,6 +945,7 @@ impl Type {
Kind::Json(JsonType { content: Some(c), .. }) => c.ty.collect_names(out),
Kind::Union(u) => u.members.iter().for_each(|m| m.collect_names(out)),
Kind::TaggedUnion(t) => t.alternatives.iter().for_each(|a| a.ty.collect_names(out)),
Kind::Rows(r) => r.columns.iter().for_each(|c| c.ty.collect_names(out)),
Kind::Map(m) => {
m.key.collect_names(out);
m.value.ty.collect_names(out);
Expand All @@ -918,6 +969,7 @@ impl Type {
Kind::Json(JsonType { content: Some(c), schema }) => Kind::Json(JsonType { content: Some(Box::new(position(*c))), schema }),
Kind::Union(u) => Kind::Union(UnionType { members: u.members.into_iter().map(|m| m.renamed(rename)).collect() }),
Kind::TaggedUnion(t) => Kind::TaggedUnion(TaggedUnionType { alternatives: t.alternatives.into_iter().map(|a| Alternative { ty: a.ty.renamed(rename), ..a }).collect() }),
Kind::Rows(r) => Kind::Rows(RowsType { columns: r.columns.into_iter().map(|c| Column { ty: c.ty.renamed(rename), ..c }).collect(), ..r }),
Kind::Map(m) => Kind::Map(MapType { key: Box::new(m.key.renamed(rename)), value: Box::new(position(*m.value)), ..m }),
Kind::Range(r) => Kind::Range(RangeType { subtype: Box::new(r.subtype.renamed(rename)), ..r }),
Kind::Multirange(r) => Kind::Multirange(RangeType { subtype: Box::new(r.subtype.renamed(rename)), ..r }),
Expand Down Expand Up @@ -1019,6 +1071,10 @@ impl Type {
let alternatives: Vec<String> = t.alternatives.iter().map(|a| format!("{}: {}", a.tag, position_short(&a.position()))).collect();
format!("{}<{}>", self.name.as_deref().unwrap_or("tagged"), alternatives.join(" | "))
}
Kind::Rows(r) => {
let columns: Vec<String> = r.columns.iter().map(|c| format!("{}: {}", c.name, position_short(&Position::new(c.ty.clone(), c.nullable)))).collect();
format!("{}<{}>{{ {} }}", self.kind.name(), r.cardinality.as_str(), columns.join(", "))
}
Kind::Set(set) => format!("{}{{{}}}", label(), position_short(&set.element)),
Kind::Map(m) => format!("{}({} => {})", label(), m.key.short(), position_short(&m.value)),
Kind::Range(r) | Kind::Multirange(r) if self.name.is_none() => format!("{}({})", self.kind.name(), r.subtype.short()),
Expand Down Expand Up @@ -1141,6 +1197,19 @@ impl Type {
m.check_at(&format!("{at}|{i}"))?;
}
}
Kind::Rows(r) => {
for c in &r.columns {
c.ty.check_at(&format!("{at}.{}", c.name))?;
}
if let Some(key) = &r.key {
if r.cardinality != Cardinality::Many {
return fail("a key is only for rows that can be more than one".into());
}
if let Some(k) = key.iter().find(|k| !r.columns.iter().any(|c| &c.name == *k)) {
return fail(format!("key column {k:?} is not a column"));
}
}
}
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
Kind::TaggedUnion(t) => {
let mut tags = std::collections::HashSet::new();
for a in &t.alternatives {
Expand Down Expand Up @@ -1304,6 +1373,21 @@ mod tests {
refused(json!({"kind": "decimal", "min_scale": 3, "max_scale": 2}));
refused(json!({"kind": "opaque"}));
refused(json!({"kind": "array", "element": {"type": {"kind": "float", "exponent_bits": 0, "mantissa_bits": 3}}}));
let id = json!({"name": "id", "type": {"kind": "integer", "bits": 32}});
refused(json!({"kind": "rows", "cardinality": "many", "columns": [id], "key": ["missing"]}));
refused(json!({"kind": "rows", "cardinality": "at_most_one", "columns": [id], "key": ["id"]}));
refused(json!({"kind": "rows", "cardinality": "one", "columns": [id], "key": ["id"]}));
serde_json::from_value::<Type>(json!({"kind": "rows", "cardinality": "many", "columns": [id], "key": ["id"]})).unwrap().check().unwrap();
}

#[test]
fn a_statements_result_is_rows_and_nothing_else() {
let result = |result: Value| serde_json::from_value::<StatementDescriptor>(json!({"$schema": SCHEMA, "dialect": {"name": "postgres", "version": "18"}, "sql": "select 1", "params": [], "result": result, "diagnostics": []}));
assert!(result(json!({"kind": "integer", "bits": 32})).is_err());
let rows = json!({"kind": "rows", "cardinality": "one", "columns": []});
let read = result(rows.clone()).unwrap();
assert_eq!(read.result.rows.cardinality, Cardinality::One);
assert_eq!(serde_json::to_value(&read).unwrap()["result"], rows);
}

#[test]
Expand All @@ -1322,12 +1406,15 @@ mod tests {
"kind": "select",
"comment": "One user, by id.",
"params": [{"name": "id", "type": {"kind": "integer", "bits": 64, "name": "int8"}}],
"columns": [
{"name": "id", "type": {"kind": "integer", "bits": 64, "name": "int8"}},
{"name": "email", "type": {"kind": "text", "size": {"max": 255}, "name": "varchar"}},
{"name": "bio", "nullable": true, "type": {"kind": "text", "name": "text"}, "comment": "Shown on the profile page"}
],
"cardinality": "at_most_one",
"result": {
"kind": "rows",
"cardinality": "at_most_one",
"columns": [
{"name": "id", "type": {"kind": "integer", "bits": 64, "name": "int8"}},
{"name": "email", "type": {"kind": "text", "size": {"max": 255}, "name": "varchar"}},
{"name": "bio", "nullable": true, "type": {"kind": "text", "name": "text"}, "comment": "Shown on the profile page"}
]
},
"diagnostics": []
});
let d: StatementDescriptor = serde_json::from_value(value.clone()).unwrap();
Expand Down
49 changes: 26 additions & 23 deletions site/content/sections/08c-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,33 @@ Reylo Type Defs is in preview, at version 0.1.0. Until 1.0.0, any release may ch
"params": [
{ "name": "id", "type": { "kind": "integer", "bits": 32, "name": "int4" } }
],
"columns": [
{
"name": "id",
"type": { "kind": "integer", "bits": 32, "name": "int4" }
},
{
"name": "name",
"type": { "kind": "text", "name": "text" }
},
{
"name": "email",
"type": { "kind": "text", "name": "text" },
"nullable": true
},
{
"name": "role",
"type": {
"kind": "text",
"enum": ["admin", "member"],
"name": "public.user_role"
"result": {
"kind": "rows",
"cardinality": "at_most_one",
"columns": [
{
"name": "id",
"type": { "kind": "integer", "bits": 32, "name": "int4" }
},
{
"name": "name",
"type": { "kind": "text", "name": "text" }
},
{
"name": "email",
"type": { "kind": "text", "name": "text" },
"nullable": true
},
{
"name": "role",
"type": {
"kind": "text",
"enum": ["admin", "member"],
"name": "public.user_role"
}
}
}
],
"cardinality": "at_most_one",
]
},
"diagnostics": []
}
```
Expand Down
71 changes: 54 additions & 17 deletions site/public/typedefs/0.1.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
},
{
"$ref": "#/$defs/TaggedUnionType"
},
{
"$ref": "#/$defs/RowsType"
}
]
},
Expand Down Expand Up @@ -1140,6 +1143,53 @@
],
"unevaluatedProperties": false
},
"RowsType": {
"description": "Rows of columns: what a statement returns, and a relation nested in a value, such as SQL's `MULTISET` or an Oracle nested table. A statement that returns one value is still a row of one column, which keeps the column's name.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/Base"
}
],
"properties": {
"kind": {
"const": "rows"
},
"cardinality": {
"description": "How many rows: exactly one, at most one, any number, or none (a write without `RETURNING`).",
"enum": [
"one",
"at_most_one",
"many",
"none"
]
},
"columns": {
"description": "In order; empty where there are no rows.",
"type": "array",
"items": {
"$ref": "#/$defs/Column"
}
},
"key": {
"description": "Columns, by name, no two rows agree on all of, a NULL equal to another NULL: `[\"id\"]`. Only for rows that can be more than one, and absent where none is known.",
"type": "array",
"items": {
"type": "string"
}
},
"ordered": {
"description": "The order of the rows is total, the same on every run: an `ORDER BY` no two rows tie on.",
"const": true
}
},
"required": [
"kind",
"cardinality",
"columns"
],
"unevaluatedProperties": false
},
"StatementDescriptor": {
"description": "What introspecting one statement returns. A tool may write keys of its own beside these, as `reylo compile` writes the file a statement was read from.",
"type": "object",
Expand Down Expand Up @@ -1176,21 +1226,9 @@
"$ref": "#/$defs/Param"
}
},
"columns": {
"description": "In order; empty when the statement returns no rows.",
"type": "array",
"items": {
"$ref": "#/$defs/Column"
}
},
"cardinality": {
"description": "How many rows the statement returns: exactly one, at most one, any number, or none (a write without `RETURNING`).",
"enum": [
"one",
"at_most_one",
"many",
"none"
]
"result": {
"description": "What the statement returns.",
"$ref": "#/$defs/RowsType"
},
"diagnostics": {
"description": "What the database or the introspection reports against the statement, each at its place, so an editor can underline it. A statement that does not parse has a diagnostic and no columns.",
Expand All @@ -1205,8 +1243,7 @@
"dialect",
"sql",
"params",
"columns",
"cardinality",
"result",
"diagnostics"
]
},
Expand Down
Loading
Loading
0