From 53b038dc788893c026305d0d75ae47def9ef23c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 22:14:31 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Plan=202:=20schema=20=E2=80=94=20the=20data?= =?UTF-8?q?base=20configuration,=20DDL=20and=20the=20entity=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- docs/plans/2-schema.md | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/plans/2-schema.md diff --git a/docs/plans/2-schema.md b/docs/plans/2-schema.md new file mode 100644 index 0000000..3a03a38 --- /dev/null +++ b/docs/plans/2-schema.md @@ -0,0 +1,61 @@ +# Plan 2 — schema: the database configuration, DDL and the entity loader + +## Goal + +Idea 1 of the README end to end: a Cedar schema, with its `@sql_*` annotations, becomes a +`DatabaseConfiguration` (tables, columns, primary keys, tags tables, the hierarchy table and the +`__entity_id`/`__entity_type` interface columns), the configuration renders as Postgres DDL, and Cedar +entities load as rows of those tables — so that later phases have data to query. + +## Design + +- **Identifiers** (`src/ident.rs`): `SQLIdentifier` (non-empty, at most 63 bytes, no NUL; `Display` is the + double-quoted form) and `quoted_literal` (single quotes doubled; NUL rejected, since Postgres `text` cannot + hold it). +- **Model** (`src/config.rs`): `SQLType {Text, BigInt, Bool, Jsonb, Set, Custom}`, `ColumnConfiguration` + (type, nullable, unique, custom attributes, foreign key, generated expression), `TableConfiguration` + (entity type, primary keys, columns in definition order, the tags table, the entity id column, the + attribute-to-column map), `DatabaseConfiguration` (tables in entity-type order, the hierarchy table and the + interface column names, `emit_foreign_keys`, `hierarchy_closed`). `SQLType::for_cedar_type` maps `Bool`, + `Long`, `String`, a single entity type (a `Text` id with a foreign key) and, per the decision recorded in the + README, `Set` and `Record` to `Jsonb`; extension, union, unspecified and never types are `Unsupported`, as are + entity types with additional attributes. +- **The builder**: entity types sorted by name; the table is `@sql_table` or the fully-qualified type name; + one column per attribute (`@sql_column`, `@sql_unique`, `@sql_custom_attrs`), nullable iff optional; the + custom columns, primary keys and entity id column of `@sql_custom_config` (JSON, `entity_type` must be + `null`); the tags table is `@sql_tags_table` or `_tags`. Conflicts are errors naming the annotation + that resolves them: two attributes on one column, a custom column on an attribute column, two entity types on + one table, a tags table on an entity table. The interface columns and the hierarchy table take the default + name unless any table's column (or any table) uses it, then the first free numeric suffix from 2 — the + README's `__entity_id2`/`cedar_entity_hierarchy2` rule. `@sql_entity_id_column` must name a non-nullable + text column that is unique or the primary key; the interface id column is then generated from it, otherwise + it is the physical primary key. The type column is always generated from the type name. +- **DDL** (`src/ddl.rs`, `src/dialect.rs`): `create_tables` renders every entity table, tags table + (`entity_id, tag, value`, key `(entity_id, tag)`) and the hierarchy table, then the foreign keys as + `ALTER TABLE … DEFERRABLE INITIALLY DEFERRED` so table order and reference cycles do not matter and rows load + in any order; constraint names over 63 bytes are shortened with a hash. `drop_tables` is the inverse. The + `Dialect` trait (Postgres only for now) owns the type names, the generated-column clause and the literal forms. +- **Loader** (`src/load.rs`): `entities_to_sql` renders one `INSERT` per entity, per ancestor (the hierarchy + table gets the transitive closure Cedar entities already carry) and per tag; action entities are returned + separately for partial evaluation. `canonical_json` is the JSON encoding of compound values: sets as arrays + (deduplicated by Cedar's own set semantics), records as `{"r": {…}}`, entity references as + `{"e": {"t": type, "i": id}}`; equality of these is defined order-insensitively by later phases, so no + element ordering is relied on. Undeclared attributes, missing required attributes, tags on tagless types, + unknown types and NUL characters are errors; residual (unknown) attribute values are `Unsupported`. + +## Files + +`Cargo.toml`, `src/{ident,config,annotations,ddl,dialect,load,error}.rs`, `tests/{config,ddl_golden,load_pg}.rs`, +`tests/schemas/*.cedarschema`, `tests/golden/*.sql`, `tests/entities/kitchen_sink.json`, `docs/plans/2-schema.md`. + +## Verification + +`cargo fmt --all --check`, `cargo clippy --all-targets --all-features -- -D warnings`, +`cargo test --all-features` with `CEDAR_SQL_PG_URL` set: the README's two schemas and a kitchen-sink schema +against golden DDL (`UPDATE_GOLDEN=1` regenerates) that also executes on Postgres, the configuration rules and +error cases, and a load round trip that reads the rows, hierarchy and tags back and checks the deferred +foreign keys with `SET CONSTRAINTS ALL IMMEDIATE`. + +## History + +New; the second `cedar-sql` branch. From 340fe795cdbb7c5f701e40c49db5688d16f8e828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 22:14:31 +0300 Subject: [PATCH 2/3] Map a Cedar schema to tables, render DDL, and load entities as rows `SQLIdentifier`, the `DatabaseConfiguration` model and its builder from the validator schema and the `@sql_*` annotations (read from the schema fragment), Postgres DDL with deferred foreign keys, and the entity loader with the canonical JSON encoding of sets, records and entity references. Tests: golden DDL for the README's schemas executed on Postgres, the naming and conflict rules, and a load round trip. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- Cargo.toml | 3 +- src/annotations.rs | 218 +++++++- src/config.rs | 595 ++++++++++++++++++++- src/ddl.rs | 162 +++++- src/dialect.rs | 53 +- src/error.rs | 19 + src/ident.rs | 113 +++- src/load.rs | 213 +++++++- tests/config.rs | 205 +++++++ tests/ddl_golden.rs | 56 ++ tests/entities/kitchen_sink.json | 21 + tests/golden/kitchen_sink.sql | 47 ++ tests/golden/readme_annotated.sql | 39 ++ tests/golden/readme_simple.sql | 26 + tests/load_pg.rs | 137 +++++ tests/schemas/kitchen_sink.cedarschema | 21 + tests/schemas/readme_annotated.cedarschema | 41 ++ tests/schemas/readme_simple.cedarschema | 19 + 18 files changed, 1963 insertions(+), 25 deletions(-) create mode 100644 tests/config.rs create mode 100644 tests/ddl_golden.rs create mode 100644 tests/entities/kitchen_sink.json create mode 100644 tests/golden/kitchen_sink.sql create mode 100644 tests/golden/readme_annotated.sql create mode 100644 tests/golden/readme_simple.sql create mode 100644 tests/load_pg.rs create mode 100644 tests/schemas/kitchen_sink.cedarschema create mode 100644 tests/schemas/readme_annotated.cedarschema create mode 100644 tests/schemas/readme_simple.cedarschema diff --git a/Cargo.toml b/Cargo.toml index 8a3c2d1..d2467b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,9 @@ repository = "https://github.com/luxas/cedar-sql" [dependencies] cedar-policy = { version = "4.12.0", features = ["tpe"] } cedar-policy-core = { version = "4.12.0", features = ["tpe"] } -indexmap = "2" +indexmap = { version = "2", features = ["serde"] } postgres = { version = "0.19", features = ["with-serde_json-1"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" smol_str = "0.3" thiserror = "2" diff --git a/src/annotations.rs b/src/annotations.rs index ceee341..67ad1eb 100644 --- a/src/annotations.rs +++ b/src/annotations.rs @@ -1,7 +1,215 @@ //! Reading the `@sql_*` schema annotations. //! -//! Plan 2 adds the entity-type annotations (`@sql_table`, `@sql_tags_table`, -//! `@sql_primary_key`, `@sql_custom_config`, `@sql_entity_id_column`) and the -//! attribute annotations (`@sql_column`, `@sql_unique`, `@sql_custom_attrs`), -//! read from the schema fragment since annotations do not survive into the -//! validator schema. +//! Annotations live on the schema fragment and do not survive into the +//! validator schema, so the schema text is parsed again as a fragment (the +//! pattern of `cedar-policy-symcc`'s `@semantics` collector). Entity types +//! carry `@sql_table`, `@sql_tags_table`, `@sql_primary_key`, +//! `@sql_entity_id_column` and `@sql_custom_config`; attributes carry +//! `@sql_column`, `@sql_unique` and `@sql_custom_attrs`. Attribute annotations +//! are read from the entity type's record shape, or from the common type the +//! shape names. + +use std::collections::HashMap; +use std::str::FromStr; + +use cedar_policy_core::ast::EntityType; +use cedar_policy_core::est::Annotations; +use cedar_policy_core::extensions::Extensions; +use cedar_policy_core::validator::RawName; +use cedar_policy_core::validator::json_schema::{ + EntityTypeKind, Fragment, NamespaceDefinition, RecordType, Type, TypeVariant, +}; +use smol_str::SmolStr; + +use crate::config::CustomConfig; +use crate::ident::SQLIdentifier; +use crate::{Error, Result}; + +/// `@sql_table("users")`: the table name. +pub const TABLE: &str = "sql_table"; +/// `@sql_tags_table("users_tags")`: the tags table name. +pub const TAGS_TABLE: &str = "sql_tags_table"; +/// `@sql_primary_key("id")`: the primary key column. +pub const PRIMARY_KEY: &str = "sql_primary_key"; +/// `@sql_entity_id_column("id")`: the column holding the entity id. +pub const ENTITY_ID_COLUMN: &str = "sql_entity_id_column"; +/// `@sql_custom_config("{ … }")`: custom columns and table modifiers as JSON. +pub const CUSTOM_CONFIG: &str = "sql_custom_config"; +/// `@sql_column("first_name")`: the column name of an attribute. +pub const COLUMN: &str = "sql_column"; +/// `@sql_unique("true")`: whether the attribute's column is unique. +pub const UNIQUE: &str = "sql_unique"; +/// `@sql_custom_attrs("DEFAULT TRUE")`: raw SQL appended to the column. +pub const CUSTOM_ATTRS: &str = "sql_custom_attrs"; + +/// The `@sql_*` annotations of a schema. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SqlAnnotations { + /// By fully-qualified entity type. + pub entity_types: HashMap, +} + +/// The annotations of one entity type and its attributes. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EntityTypeAnnotations { + /// `@sql_table`. + pub table: Option, + /// `@sql_tags_table`. + pub tags_table: Option, + /// `@sql_primary_key`. + pub primary_key: Option, + /// `@sql_entity_id_column`. + pub entity_id_column: Option, + /// `@sql_custom_config`. + pub custom_config: Option, + /// The attributes' annotations, by attribute name. + pub attributes: HashMap, +} + +/// The annotations of one attribute. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AttributeAnnotations { + /// `@sql_column`. + pub column: Option, + /// `@sql_unique`. + pub unique: bool, + /// `@sql_custom_attrs`. + pub custom_attrs: Option, +} + +/// Collects the annotations of a schema in the Cedar schema syntax. +pub fn from_cedarschema_str(src: &str) -> Result { + let (fragment, _warnings) = + Fragment::::from_cedarschema_str(src, Extensions::all_available()) + .map_err(|e| Error::Schema(e.to_string()))?; + collect(&fragment) +} + +/// Collects the annotations of a schema in the JSON syntax. +pub fn from_json_str(src: &str) -> Result { + let fragment = + Fragment::::from_json_str(src).map_err(|e| Error::Schema(e.to_string()))?; + collect(&fragment) +} + +/// Collects the annotations of a parsed fragment. +pub fn collect(fragment: &Fragment) -> Result { + let mut out = SqlAnnotations::default(); + for (namespace, def) in &fragment.0 { + for (id, entity_type) in &def.entity_types { + let name = match namespace { + Some(ns) => format!("{ns}::{id}"), + None => id.to_string(), + }; + let ety = EntityType::from_str(&name).map_err(|e| { + Error::Schema(format!("the entity type name {name} does not parse: {e}")) + })?; + let on = format!("entity type {ety}"); + let a = &entity_type.annotations; + let mut ann = EntityTypeAnnotations { + table: identifier(a, TABLE, &on)?, + tags_table: identifier(a, TAGS_TABLE, &on)?, + primary_key: identifier(a, PRIMARY_KEY, &on)?, + entity_id_column: identifier(a, ENTITY_ID_COLUMN, &on)?, + custom_config: None, + attributes: HashMap::new(), + }; + if let Some(json) = value(a, CUSTOM_CONFIG) { + ann.custom_config = + Some(serde_json::from_str(json).map_err(|e| Error::Annotation { + annotation: CUSTOM_CONFIG, + on: on.clone(), + message: e.to_string(), + })?); + } + if let EntityTypeKind::Standard(standard) = &entity_type.kind + && let Some(record) = resolve_record(&standard.shape.0, def, fragment) + { + for (attr, attr_ty) in &record.attributes { + let on = format!("attribute {attr} of {ety}"); + let a = &attr_ty.annotations; + let attr_ann = AttributeAnnotations { + column: identifier(a, COLUMN, &on)?, + unique: boolean(a, UNIQUE, &on)?, + custom_attrs: value(a, CUSTOM_ATTRS).map(str::to_owned), + }; + if attr_ann != AttributeAnnotations::default() { + ann.attributes.insert(attr.clone(), attr_ann); + } + } + } + if ann != EntityTypeAnnotations::default() { + out.entity_types.insert(ety, ann); + } + } + } + Ok(out) +} + +/// The record type an entity shape denotes: the record itself, or the record +/// behind a common type name (same namespace first, then the empty one). +fn resolve_record<'f>( + ty: &'f Type, + def: &'f NamespaceDefinition, + fragment: &'f Fragment, +) -> Option<&'f RecordType> { + match ty { + Type::Type { + ty: TypeVariant::Record(record), + .. + } => Some(record), + Type::CommonTypeRef { type_name, .. } + | Type::Type { + ty: TypeVariant::EntityOrCommon { type_name }, + .. + } => { + let wanted = type_name.to_string(); + let empty = fragment.0.get(&None); + [Some(def), empty] + .into_iter() + .flatten() + .flat_map(|d| d.common_types.iter()) + .find(|(id, _)| id.to_string() == wanted) + .and_then(|(_, common)| resolve_record(&common.ty, def, fragment)) + } + Type::Type { .. } => None, + } +} + +/// The annotation's value; a bare `@key` (no value) counts as the empty string. +fn value<'a>(annotations: &'a Annotations, key: &str) -> Option<&'a str> { + annotations + .0 + .iter() + .find(|(id, _)| id.as_ref() == key) + .map(|(_, a)| a.as_ref().map_or("", |a| a.val.as_str())) +} + +fn identifier( + annotations: &Annotations, + key: &'static str, + on: &str, +) -> Result> { + value(annotations, key) + .map(|v| { + SQLIdentifier::new(v).map_err(|e| Error::Annotation { + annotation: key, + on: on.to_owned(), + message: e.to_string(), + }) + }) + .transpose() +} + +fn boolean(annotations: &Annotations, key: &'static str, on: &str) -> Result { + match value(annotations, key) { + None => Ok(false), + Some("true") => Ok(true), + Some("false") => Ok(false), + Some(other) => Err(Error::Annotation { + annotation: key, + on: on.to_owned(), + message: format!("expected \"true\" or \"false\", got {other:?}"), + }), + } +} diff --git a/src/config.rs b/src/config.rs index 618a5c0..468abe7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,594 @@ //! The database, table and column model a Cedar schema maps to. //! -//! Plan 2 adds `DatabaseConfiguration`, `TableConfiguration`, -//! `ColumnConfiguration` and `SQLType` as described in the README, together -//! with the globally unique interface-column and hierarchy-table naming rule. +//! [`DatabaseConfiguration::from_schema`] applies the defaults of the README +//! (one table per entity type named after it, one column per attribute, a +//! `
_tags` table per entity type with tags, one hierarchy table, and the +//! `__entity_id`/`__entity_type` interface columns every entity table +//! exposes), and [`DatabaseConfiguration::from_schema_with_annotations`] +//! applies the `@sql_*` annotations collected by [`crate::annotations`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use cedar_policy::Schema; +use cedar_policy_core::ast::EntityType; +use cedar_policy_core::validator::types::{EntityKind, OpenTag, Type}; +use cedar_policy_core::validator::{ValidatorEntityType, ValidatorSchema}; +use indexmap::{IndexMap, IndexSet}; +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; + +use crate::annotations::{EntityTypeAnnotations, SqlAnnotations}; +use crate::ident::SQLIdentifier; +use crate::{Error, Result}; + +/// The default name of the interface column holding the entity id. +pub const DEFAULT_ENTITY_ID_COLUMN: &str = "__entity_id"; +/// The default name of the interface column holding the entity type. +pub const DEFAULT_ENTITY_TYPE_COLUMN: &str = "__entity_type"; +/// The default name of the entity hierarchy table. +pub const DEFAULT_HIERARCHY_TABLE: &str = "cedar_entity_hierarchy"; +/// The suffix of a default tags table name. +pub const TAGS_TABLE_SUFFIX: &str = "_tags"; +/// The tags table column holding the tagged entity's id. +pub const TAGS_ENTITY_ID_COLUMN: &str = "entity_id"; +/// The tags table column holding the tag. +pub const TAGS_TAG_COLUMN: &str = "tag"; +/// The tags table column holding the tag's value. +pub const TAGS_VALUE_COLUMN: &str = "value"; +/// The hierarchy table columns, in order. +pub const HIERARCHY_COLUMNS: [&str; 4] = [ + "descendant_type", + "descendant_id", + "ancestor_type", + "ancestor_id", +]; + +/// A column type. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SQLType { + /// Cedar `String`, and entity references (the referenced entity's id). + Text, + /// Cedar `Long`. + BigInt, + /// Cedar `Bool`. + Bool, + /// Cedar `Set` and `Record`, in the canonical JSON encoding of + /// [`crate::load::canonical_json`]. + Jsonb, + /// An array column; not used by the Cedar mapping (sets are [`SQLType::Jsonb`]). + Set(Box), + /// A type this crate does not interpret, for custom columns. + Custom(String), +} + +impl SQLType { + /// The column type for a Cedar attribute (or tag) type, and the entity + /// type the column references when it is an entity reference. + pub fn for_cedar_type(ty: &Type) -> Result<(SQLType, Option)> { + Ok(match ty { + Type::Bool(_) => (SQLType::Bool, None), + Type::Long => (SQLType::BigInt, None), + Type::String => (SQLType::Text, None), + Type::Entity(EntityKind::Entity(lub)) => match lub.get_single_entity() { + Some(ety) => (SQLType::Text, Some(ety.clone())), + None => return Err(Error::Unsupported("attributes of a union entity type")), + }, + Type::Entity(EntityKind::AnyEntity) => { + return Err(Error::Unsupported( + "attributes of an unspecified entity type", + )); + } + Type::Set { .. } | Type::Record { .. } => (SQLType::Jsonb, None), + Type::ExtensionType { .. } => return Err(Error::Unsupported("extension types")), + Type::Never => return Err(Error::Unsupported("attributes of the never type")), + }) + } +} + +/// A foreign key target. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ForeignKey { + /// The referenced table. + pub table: SQLIdentifier, + /// The referenced column (the table's entity id column). + pub column: SQLIdentifier, +} + +/// One column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColumnConfiguration { + /// The column type. + pub ty: SQLType, + /// Whether the column may be `NULL` (an optional Cedar attribute). + pub nullable: bool, + /// Whether the column carries a `UNIQUE` constraint. + pub unique: bool, + /// Raw SQL appended to the column definition (`@sql_custom_attrs`). + pub custom_attrs: Option, + /// The foreign key the column carries, if it is an entity reference. + pub references: Option, + /// The expression of a stored generated column. + pub generated: Option, +} + +impl ColumnConfiguration { + fn new(ty: SQLType) -> Self { + Self { + ty, + nullable: false, + unique: false, + custom_attrs: None, + references: None, + generated: None, + } + } +} + +/// The tags table of an entity type with tags: columns +/// [`TAGS_ENTITY_ID_COLUMN`], [`TAGS_TAG_COLUMN`] and [`TAGS_VALUE_COLUMN`], +/// with the first two as the primary key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagsTableConfiguration { + /// The table name. + pub table: SQLIdentifier, + /// The value column. + pub value: ColumnConfiguration, +} + +/// One entity table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TableConfiguration { + /// The entity type the table stores. + pub entity_type: EntityType, + /// The primary key columns. + pub primary_keys: IndexSet, + /// The columns, in definition order. + pub columns: IndexMap, + /// The tags table, when the entity type has tags. + pub tags: Option, + /// The column holding the entity id, which entity lookups join on. It is + /// the interface column itself unless `@sql_entity_id_column` (or the + /// custom configuration) named another column, in which case the + /// interface column is generated from it. + pub entity_id_column: SQLIdentifier, + /// Which column each Cedar attribute is stored in. + pub attribute_columns: IndexMap, +} + +/// The `@sql_custom_config` JSON: custom columns and table modifiers. +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CustomConfig { + /// Must be absent or `null`; the entity type comes from the schema. + #[serde(default)] + pub entity_type: Option<()>, + /// The primary key columns, when not the entity id column. + #[serde(default)] + pub primary_keys: Vec, + /// The column holding the entity id. + #[serde(default)] + pub entity_id_column: Option, + /// Additional columns. + #[serde(default)] + pub columns: IndexMap, +} + +/// One custom column of a [`CustomConfig`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CustomColumn { + /// The column type. + pub ty: SQLType, + /// Whether the column may be `NULL`. + #[serde(default)] + pub nullable: bool, + /// Whether the column carries a `UNIQUE` constraint. + #[serde(default)] + pub unique: bool, + /// Raw SQL appended to the column definition. + #[serde(default)] + pub custom_attrs: Option, +} + +/// The whole database. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DatabaseConfiguration { + /// The entity tables by name, in entity type order. + pub tables: IndexMap, + /// The hierarchy table: [`HIERARCHY_COLUMNS`], all forming the primary key. + pub entity_hierarchy_table: SQLIdentifier, + /// The interface column every entity table exposes for the entity id. + pub entity_id_column: SQLIdentifier, + /// The interface column every entity table exposes for the entity type. + pub entity_type_column: SQLIdentifier, + /// Whether the DDL declares the foreign keys. Cedar data may reference + /// entities that do not exist, so the differential tests turn this off. + pub emit_foreign_keys: bool, + /// Whether the hierarchy table holds the transitive closure, in which + /// case `in` needs no recursion. + pub hierarchy_closed: bool, + entity_tables: HashMap, +} + +impl DatabaseConfiguration { + /// The default mapping of `schema`, without annotations. + pub fn from_schema(schema: &Schema) -> Result { + Self::from_schema_with_annotations(schema, &SqlAnnotations::default()) + } + + /// The mapping of `schema` under its `@sql_*` annotations. + pub fn from_schema_with_annotations( + schema: &Schema, + annotations: &SqlAnnotations, + ) -> Result { + Builder::new(schema.as_ref(), annotations).build() + } + + /// Parses a schema in the Cedar schema syntax, collecting its annotations. + pub fn from_cedarschema_str(src: &str) -> Result<(Self, Schema)> { + let (schema, _warnings) = + Schema::from_cedarschema_str(src).map_err(|e| Error::Schema(e.to_string()))?; + let annotations = crate::annotations::from_cedarschema_str(src)?; + Ok(( + Self::from_schema_with_annotations(&schema, &annotations)?, + schema, + )) + } + + /// Parses a schema in the JSON syntax, collecting its annotations. + pub fn from_json_str(src: &str) -> Result<(Self, Schema)> { + let schema = Schema::from_json_str(src).map_err(|e| Error::Schema(e.to_string()))?; + let annotations = crate::annotations::from_json_str(src)?; + Ok(( + Self::from_schema_with_annotations(&schema, &annotations)?, + schema, + )) + } + + /// The table storing `ety`, if any (action types have none). + pub fn table_for(&self, ety: &EntityType) -> Option<(&SQLIdentifier, &TableConfiguration)> { + let name = self.entity_tables.get(ety)?; + Some((name, self.tables.get(name)?)) + } + + /// The column storing attribute `attr` of `ety`. + pub fn column_for(&self, ety: &EntityType, attr: &str) -> Option<&SQLIdentifier> { + self.table_for(ety)?.1.attribute_columns.get(attr) + } +} + +/// A table under construction. +struct Draft { + name: SQLIdentifier, + entity_type: EntityType, + columns: IndexMap, + attribute_columns: IndexMap, + /// Entity references, resolved to foreign keys once every table is named. + references: Vec<(SQLIdentifier, EntityType)>, + entity_id_target: Option, + primary_keys: Vec, + tags: Option<(SQLIdentifier, SQLType, Option)>, +} + +struct Builder<'a> { + schema: &'a ValidatorSchema, + annotations: &'a SqlAnnotations, +} + +impl<'a> Builder<'a> { + fn new(schema: &'a ValidatorSchema, annotations: &'a SqlAnnotations) -> Self { + Self { + schema, + annotations, + } + } + + fn build(self) -> Result { + let mut types: Vec<&ValidatorEntityType> = self.schema.entity_types().collect(); + types.sort_by_key(|t| t.name().to_string()); + let empty = EntityTypeAnnotations::default(); + let drafts = types + .iter() + .map(|vet| { + let ann = self + .annotations + .entity_types + .get(vet.name()) + .unwrap_or(&empty); + self.draft(vet, ann) + }) + .collect::>>()?; + + // Table names must be distinct, tags tables included. + let mut table_names: BTreeMap<&SQLIdentifier, String> = BTreeMap::new(); + for draft in &drafts { + let owner = format!("entity type {}", draft.entity_type); + if let Some(previous) = table_names.insert(&draft.name, owner.clone()) { + return Err(Error::Schema(format!( + "table {} would store both {previous} and {owner}; use @sql_table", + draft.name + ))); + } + } + for draft in &drafts { + if let Some((tags, _, _)) = &draft.tags { + let owner = format!("the tags of entity type {}", draft.entity_type); + if let Some(previous) = table_names.insert(tags, owner.clone()) { + return Err(Error::Schema(format!( + "table {tags} would store both {previous} and {owner}; use @sql_tags_table" + ))); + } + } + } + let entity_tables: HashMap = drafts + .iter() + .map(|d| (d.entity_type.clone(), d.name.clone())) + .collect(); + + // The interface columns and the hierarchy table get globally unique + // names: the default, or the default with the first free suffix >= 2. + let column_names: BTreeSet<&str> = drafts + .iter() + .flat_map(|d| d.columns.keys().map(SQLIdentifier::as_str)) + .collect(); + let entity_id_column = unique_name(DEFAULT_ENTITY_ID_COLUMN, &column_names)?; + let entity_type_column = unique_name(DEFAULT_ENTITY_TYPE_COLUMN, &column_names)?; + let taken_tables: BTreeSet<&str> = table_names.keys().map(|n| n.as_str()).collect(); + let entity_hierarchy_table = unique_name(DEFAULT_HIERARCHY_TABLE, &taken_tables)?; + + let mut tables = IndexMap::new(); + for draft in drafts { + let table = self.finish( + draft, + &entity_id_column, + &entity_type_column, + &entity_tables, + &tables, + )?; + tables.insert(table.0, table.1); + } + // Foreign keys to tables finished later than the referencing table. + let targets: HashMap = tables + .iter() + .map(|(name, t)| (name.clone(), t.entity_id_column.clone())) + .collect(); + for table in tables.values_mut() { + for column in table.columns.values_mut() { + if let Some(fk) = &mut column.references { + fk.column = targets[&fk.table].clone(); + } + } + if let Some(tags) = &mut table.tags + && let Some(fk) = &mut tags.value.references + { + fk.column = targets[&fk.table].clone(); + } + } + Ok(DatabaseConfiguration { + tables, + entity_hierarchy_table, + entity_id_column, + entity_type_column, + emit_foreign_keys: true, + hierarchy_closed: false, + entity_tables, + }) + } + + fn draft(&self, vet: &ValidatorEntityType, ann: &EntityTypeAnnotations) -> Result { + let ety = vet.name().clone(); + let name = match &ann.table { + Some(name) => name.clone(), + None => SQLIdentifier::new(ety.to_string()).map_err(|_| { + Error::Schema(format!( + "the entity type name {ety} is not a valid table name; use @sql_table" + )) + })?, + }; + if vet.open_attributes() == OpenTag::OpenAttributes { + return Err(Error::Unsupported( + "entity types with additional attributes", + )); + } + let mut columns: IndexMap = IndexMap::new(); + let mut attribute_columns = IndexMap::new(); + let mut references = Vec::new(); + for (attr, attr_ty) in vet.attributes().iter() { + let attr_ann = ann.attributes.get(attr); + let column = match attr_ann.and_then(|a| a.column.clone()) { + Some(column) => column, + None => SQLIdentifier::new(attr.as_str()).map_err(|_| { + Error::Schema(format!( + "attribute {attr} of {ety} is not a valid column name; use @sql_column" + )) + })?, + }; + if let Some(other) = attribute_columns + .iter() + .find(|(_, c): &(&SmolStr, &SQLIdentifier)| **c == column) + { + return Err(Error::Schema(format!( + "attributes {} and {attr} of {ety} both map to column {column}", + other.0 + ))); + } + let (ty, reference) = SQLType::for_cedar_type(&attr_ty.attr_type)?; + let mut config = ColumnConfiguration::new(ty); + config.nullable = !attr_ty.is_required; + if let Some(a) = attr_ann { + config.unique = a.unique; + config.custom_attrs = a.custom_attrs.clone(); + } + if let Some(target) = reference { + references.push((column.clone(), target)); + } + columns.insert(column.clone(), config); + attribute_columns.insert(attr.clone(), column); + } + let mut entity_id_target = ann.entity_id_column.clone(); + let mut primary_keys: Vec = ann.primary_key.iter().cloned().collect(); + if let Some(custom) = &ann.custom_config { + for (column, custom_column) in &custom.columns { + if columns.contains_key(column) { + return Err(Error::Schema(format!( + "custom column {column} of {ety} collides with an attribute column" + ))); + } + columns.insert( + column.clone(), + ColumnConfiguration { + ty: custom_column.ty.clone(), + nullable: custom_column.nullable, + unique: custom_column.unique, + custom_attrs: custom_column.custom_attrs.clone(), + references: None, + generated: None, + }, + ); + } + if entity_id_target.is_none() { + entity_id_target = custom.entity_id_column.clone(); + } + if primary_keys.is_empty() { + primary_keys = custom.primary_keys.clone(); + } + } + let tags = match vet.tag_type() { + None => None, + Some(tag_ty) => { + let (ty, reference) = SQLType::for_cedar_type(tag_ty)?; + let table = match &ann.tags_table { + Some(table) => table.clone(), + None => name.with_suffix(TAGS_TABLE_SUFFIX).map_err(|_| { + Error::Schema(format!( + "the tags table name for {ety} is too long; use @sql_tags_table" + )) + })?, + }; + Some((table, ty, reference)) + } + }; + Ok(Draft { + name, + entity_type: ety, + columns, + attribute_columns, + references, + entity_id_target, + primary_keys, + tags, + }) + } + + fn finish( + &self, + draft: Draft, + entity_id_column: &SQLIdentifier, + entity_type_column: &SQLIdentifier, + entity_tables: &HashMap, + _finished: &IndexMap, + ) -> Result<(SQLIdentifier, TableConfiguration)> { + let ety = draft.entity_type; + let mut columns = IndexMap::new(); + let entity_id = match &draft.entity_id_target { + None => { + columns.insert( + entity_id_column.clone(), + ColumnConfiguration::new(SQLType::Text), + ); + entity_id_column.clone() + } + Some(target) => { + let Some(column) = draft.columns.get(target) else { + return Err(Error::Schema(format!( + "the entity id column {target} of {ety} does not exist" + ))); + }; + if column.ty != SQLType::Text || column.nullable { + return Err(Error::Schema(format!( + "the entity id column {target} of {ety} must be a non-nullable text column" + ))); + } + let is_key = + column.unique || draft.primary_keys.as_slice() == std::slice::from_ref(target); + if !is_key { + return Err(Error::Schema(format!( + "the entity id column {target} of {ety} must be unique or the primary key" + ))); + } + let mut generated = ColumnConfiguration::new(SQLType::Text); + generated.generated = Some(target.to_string()); + columns.insert(entity_id_column.clone(), generated); + target.clone() + } + }; + let mut type_column = ColumnConfiguration::new(SQLType::Text); + type_column.generated = Some(crate::ident::quoted_literal(&ety.to_string())?); + columns.insert(entity_type_column.clone(), type_column); + for (name, mut column) in draft.columns { + if let Some((_, target)) = draft.references.iter().find(|(c, _)| *c == name) { + let Some(table) = entity_tables.get(target) else { + return Err(Error::Unsupported( + "attributes referencing action entity types", + )); + }; + column.references = Some(ForeignKey { + table: table.clone(), + column: entity_id_column.clone(), // fixed up by `build` + }); + } + columns.insert(name, column); + } + let primary_keys: IndexSet = if draft.primary_keys.is_empty() { + IndexSet::from([entity_id.clone()]) + } else { + for key in &draft.primary_keys { + if !columns.contains_key(key) { + return Err(Error::Schema(format!( + "the primary key column {key} of {ety} does not exist" + ))); + } + } + draft.primary_keys.into_iter().collect() + }; + let tags = match draft.tags { + None => None, + Some((table, ty, reference)) => { + let mut value = ColumnConfiguration::new(ty); + if let Some(target) = reference { + let Some(table) = entity_tables.get(&target) else { + return Err(Error::Unsupported("tags referencing action entity types")); + }; + value.references = Some(ForeignKey { + table: table.clone(), + column: entity_id_column.clone(), // fixed up by `build` + }); + } + Some(TagsTableConfiguration { table, value }) + } + }; + Ok(( + draft.name, + TableConfiguration { + entity_type: ety, + primary_keys, + columns, + tags, + entity_id_column: entity_id, + attribute_columns: draft.attribute_columns, + }, + )) + } +} + +/// `default` unless taken, else `default2`, `default3`, … (the README's rule). +fn unique_name(default: &str, taken: &BTreeSet<&str>) -> Result { + if !taken.contains(default) { + return SQLIdentifier::new(default); + } + (2u32..) + .map(|n| format!("{default}{n}")) + .find(|candidate| !taken.contains(candidate.as_str())) + .map(SQLIdentifier::new) + .expect("an unbounded sequence of candidates has a free one") +} diff --git a/src/ddl.rs b/src/ddl.rs index 7b1b4a8..21693ae 100644 --- a/src/ddl.rs +++ b/src/ddl.rs @@ -1,5 +1,157 @@ -//! Rendering a [`crate::config::DatabaseConfiguration`] as DDL. -//! -//! Plan 2 adds `create_tables` and `drop_tables`: the entity tables, the tags -//! tables, the hierarchy table, and the foreign keys as trailing `ALTER TABLE` -//! statements so table order does not matter. +//! Rendering a [`DatabaseConfiguration`] as DDL. + +use crate::Result; +use crate::config::ColumnConfiguration; +use crate::config::{ + DatabaseConfiguration, HIERARCHY_COLUMNS, TAGS_ENTITY_ID_COLUMN, TAGS_TAG_COLUMN, + TAGS_VALUE_COLUMN, TableConfiguration, +}; +use crate::dialect::Dialect; +use crate::ident::{MAX_IDENTIFIER_BYTES, SQLIdentifier}; + +/// The statements creating every table of `config`: the entity tables, their +/// tags tables, the hierarchy table, and then the foreign keys as +/// `ALTER TABLE` statements (when `config.emit_foreign_keys`), so that table +/// order and reference cycles do not matter. The foreign keys are deferred +/// to the end of the transaction, so rows may be loaded in any order. +pub fn create_tables(config: &DatabaseConfiguration, dialect: &dyn Dialect) -> Result> { + let mut statements = Vec::new(); + let mut foreign_keys = Vec::new(); + for (name, table) in &config.tables { + statements.push(create_entity_table(name, table, dialect)); + for (column, cc) in &table.columns { + if let Some(fk) = &cc.references { + foreign_keys.push(alter_foreign_key(name, column, &fk.table, &fk.column)?); + } + } + if let Some(tags) = &table.tags { + let value = &tags.value; + let value_ty = dialect.render_type(&value.ty); + statements.push(format!( + "CREATE TABLE {} (\n \"{TAGS_ENTITY_ID_COLUMN}\" TEXT NOT NULL,\n \"{TAGS_TAG_COLUMN}\" TEXT NOT NULL,\n \"{TAGS_VALUE_COLUMN}\" {value_ty} NOT NULL,\n PRIMARY KEY (\"{TAGS_ENTITY_ID_COLUMN}\", \"{TAGS_TAG_COLUMN}\")\n)", + tags.table + )); + let entity_id = SQLIdentifier::new(TAGS_ENTITY_ID_COLUMN)?; + foreign_keys.push(alter_foreign_key( + &tags.table, + &entity_id, + name, + &table.entity_id_column, + )?); + if let Some(fk) = &value.references { + let value_column = SQLIdentifier::new(TAGS_VALUE_COLUMN)?; + foreign_keys.push(alter_foreign_key( + &tags.table, + &value_column, + &fk.table, + &fk.column, + )?); + } + } + } + let hierarchy_columns = HIERARCHY_COLUMNS + .iter() + .map(|c| format!(" \"{c}\" TEXT NOT NULL,\n")) + .collect::(); + let hierarchy_key = HIERARCHY_COLUMNS + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "); + statements.push(format!( + "CREATE TABLE {} (\n{hierarchy_columns} PRIMARY KEY ({hierarchy_key})\n)", + config.entity_hierarchy_table + )); + if config.emit_foreign_keys { + statements.extend(foreign_keys); + } + Ok(statements) +} + +/// The statements dropping every table of `config`, if they exist. +pub fn drop_tables(config: &DatabaseConfiguration) -> Vec { + let mut names = vec![config.entity_hierarchy_table.clone()]; + for (name, table) in config.tables.iter().rev() { + if let Some(tags) = &table.tags { + names.push(tags.table.clone()); + } + names.push(name.clone()); + } + names + .into_iter() + .map(|name| format!("DROP TABLE IF EXISTS {name} CASCADE")) + .collect() +} + +fn create_entity_table( + name: &SQLIdentifier, + table: &TableConfiguration, + dialect: &dyn Dialect, +) -> String { + let mut lines: Vec = table + .columns + .iter() + .map(|(column, cc)| format!(" {column} {}", column_definition(cc, dialect))) + .collect(); + let keys = table + .primary_keys + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + lines.push(format!(" PRIMARY KEY ({keys})")); + format!("CREATE TABLE {name} (\n{}\n)", lines.join(",\n")) +} + +fn column_definition(cc: &ColumnConfiguration, dialect: &dyn Dialect) -> String { + let mut parts = vec![dialect.render_type(&cc.ty)]; + if let Some(expr) = &cc.generated { + parts.push(dialect.generated_column(expr)); + } else if !cc.nullable { + parts.push("NOT NULL".into()); + } + if cc.unique { + parts.push("UNIQUE".into()); + } + if let Some(custom) = &cc.custom_attrs { + parts.push(custom.clone()); + } + parts.join(" ") +} + +fn alter_foreign_key( + table: &SQLIdentifier, + column: &SQLIdentifier, + target_table: &SQLIdentifier, + target_column: &SQLIdentifier, +) -> Result { + let constraint = constraint_name(&format!("{}_{}_fkey", table.as_str(), column.as_str()))?; + // Deferred, so that rows may reference rows loaded later in the same + // transaction (`User.friend: User`, or two types referencing each other). + Ok(format!( + "ALTER TABLE {table} ADD CONSTRAINT {constraint} FOREIGN KEY ({column}) REFERENCES {target_table} ({target_column}) DEFERRABLE INITIALLY DEFERRED" + )) +} + +/// `name`, shortened to the identifier limit with a hash of the whole name +/// when it is too long, so that distinct long names stay distinct. +fn constraint_name(name: &str) -> Result { + if name.len() <= MAX_IDENTIFIER_BYTES { + return SQLIdentifier::new(name); + } + let hash = format!("{:016x}", fxhash(name)); + let keep = MAX_IDENTIFIER_BYTES - hash.len() - 1; + let mut prefix = name.to_owned(); + while !prefix.is_char_boundary(keep) || prefix.len() > keep { + prefix.pop(); + } + SQLIdentifier::new(format!("{prefix}_{hash}")) +} + +/// A small stable hash (FNV-1a), so constraint names do not depend on the +/// standard library's hasher. +fn fxhash(s: &str) -> u64 { + s.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }) +} diff --git a/src/dialect.rs b/src/dialect.rs index d63ae91..76f409b 100644 --- a/src/dialect.rs +++ b/src/dialect.rs @@ -1,4 +1,51 @@ //! The SQL dialect differences. -//! -//! Plan 2 adds the `Dialect` trait with the Postgres implementation; Plan 8 -//! adds SQLite (Turso). + +use crate::config::SQLType; + +/// What differs between databases in the SQL this crate emits. +pub trait Dialect { + /// The dialect's name, for messages. + fn name(&self) -> &'static str; + /// The column type for `ty`. + fn render_type(&self, ty: &SQLType) -> String; + /// The column clause for a stored column computed from `expr`. + fn generated_column(&self, expr: &str) -> String; + /// A JSON literal from its serialized text (already single-quoted). + fn json_literal(&self, quoted: &str) -> String; + /// A 64-bit integer literal. `i64::MIN` needs care: as a bare literal + /// Postgres parses it as the negation of a too-large number. + fn bigint_literal(&self, n: i64) -> String; +} + +/// Postgres 18. +#[derive(Debug, Clone, Copy, Default)] +pub struct Postgres; + +impl Dialect for Postgres { + fn name(&self) -> &'static str { + "postgres" + } + + fn render_type(&self, ty: &SQLType) -> String { + match ty { + SQLType::Text => "TEXT".into(), + SQLType::BigInt => "BIGINT".into(), + SQLType::Bool => "BOOLEAN".into(), + SQLType::Jsonb => "JSONB".into(), + SQLType::Set(element) => format!("{}[]", self.render_type(element)), + SQLType::Custom(custom) => custom.clone(), + } + } + + fn generated_column(&self, expr: &str) -> String { + format!("GENERATED ALWAYS AS ({expr}) STORED") + } + + fn json_literal(&self, quoted: &str) -> String { + format!("{quoted}::jsonb") + } + + fn bigint_literal(&self, n: i64) -> String { + format!("'{n}'::bigint") + } +} diff --git a/src/error.rs b/src/error.rs index e5a8652..2db60e2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,6 +11,25 @@ pub enum Error { /// treat this as a benign skip, so the string names the construct. #[error("unsupported: {0}")] Unsupported(&'static str), + /// A string is not a valid SQL identifier. + #[error("invalid SQL identifier {0:?}: {1}")] + Identifier(String, &'static str), + /// The Cedar schema cannot be mapped to a database configuration. + #[error("cannot map the schema to SQL: {0}")] + Schema(String), + /// An `@sql_*` annotation is malformed. + #[error("invalid @{annotation} annotation on {on}: {message}")] + Annotation { + /// The annotation key, without the `@`. + annotation: &'static str, + /// The declaration the annotation is on. + on: String, + /// What is wrong with it. + message: String, + }, + /// The entities cannot be turned into rows of the configured tables. + #[error("cannot load entities: {0}")] + Load(String), /// The database returned an error. #[error("database error: {0}")] Database(#[from] postgres::Error), diff --git a/src/ident.rs b/src/ident.rs index 7810629..e13bae6 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -1,5 +1,108 @@ -//! SQL identifiers. -//! -//! Plan 2 adds `SQLIdentifier`: a validated identifier of at most 63 bytes whose -//! `Display` emits the double-quoted form with `"` doubled, plus -//! `quoted_literal` for single-quoted string literals. +//! SQL identifiers and literals. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// The longest identifier Postgres keeps intact (`NAMEDATALEN - 1`). +pub const MAX_IDENTIFIER_BYTES: usize = 63; + +/// A validated SQL identifier: non-empty, at most [`MAX_IDENTIFIER_BYTES`] +/// bytes, without NUL characters. `Display` renders the double-quoted form, +/// so any identifier — a Cedar type name such as `App::User` included — is +/// safe to splice into a statement. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SQLIdentifier(String); + +impl SQLIdentifier { + /// Validates `name` as an identifier. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(Error::Identifier(name, "empty")); + } + if name.len() > MAX_IDENTIFIER_BYTES { + return Err(Error::Identifier(name, "longer than 63 bytes")); + } + if name.contains('\0') { + return Err(Error::Identifier(name, "contains a NUL character")); + } + Ok(Self(name)) + } + + /// The identifier's text, unquoted. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// `name` followed by `suffix`, e.g. for the `__entity_id2` naming rule or + /// a `_tags` table. + pub fn with_suffix(&self, suffix: &str) -> Result { + Self::new(format!("{}{suffix}", self.0)) + } +} + +impl std::borrow::Borrow for SQLIdentifier { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SQLIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "\"{}\"", self.0.replace('"', "\"\"")) + } +} + +impl TryFrom for SQLIdentifier { + type Error = Error; + + fn try_from(name: String) -> Result { + Self::new(name) + } +} + +impl From for String { + fn from(ident: SQLIdentifier) -> String { + ident.0 + } +} + +/// `s` as a single-quoted SQL string literal, with `'` doubled. Backslashes +/// are literal (Postgres `standard_conforming_strings`, SQLite always). +/// +/// # Errors +/// +/// When `s` contains a NUL character, which Postgres `text` cannot store. +pub fn quoted_literal(s: &str) -> Result { + if s.contains('\0') { + return Err(Error::Load(format!( + "the string {s:?} contains a NUL character, which Postgres cannot store" + ))); + } + Ok(format!("'{}'", s.replace('\'', "''"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoting() { + let id = SQLIdentifier::new("App::\"User\"").unwrap(); + assert_eq!(id.to_string(), "\"App::\"\"User\"\"\""); + assert_eq!(quoted_literal("it's").unwrap(), "'it''s'"); + assert!(quoted_literal("a\0b").is_err()); + } + + #[test] + fn limits() { + assert!(SQLIdentifier::new("").is_err()); + assert!(SQLIdentifier::new("x".repeat(63)).is_ok()); + assert!(SQLIdentifier::new("x".repeat(64)).is_err()); + assert!(SQLIdentifier::new("a\0b").is_err()); + } +} diff --git a/src/load.rs b/src/load.rs index 0b73312..d81836d 100644 --- a/src/load.rs +++ b/src/load.rs @@ -1,5 +1,212 @@ //! Turning Cedar entities into rows. //! -//! Plan 2 adds the canonical JSON encoding of compound values (sets -//! deduplicated and sorted, records and entity references wrapped), the tag -//! rows and the ancestor rows, and separates the action entities out for TPE. +//! [`entities_to_sql`] renders one `INSERT` per entity into its type's table, +//! one per ancestor into the hierarchy table (Cedar entities carry their +//! transitive ancestors, so the table holds the closure), and one per tag +//! into the tags table. Action entities have no table; they are returned +//! separately so that partial evaluation can be given them. +//! +//! Compound values use the canonical JSON encoding of [`canonical_json`]: +//! a set is an array of its (deduplicated) elements, a record is +//! `{"r": {…}}`, an entity reference is `{"e": {"t": type, "i": id}}`, and +//! primitives are JSON primitives. The wrappers keep records, entities and +//! sets apart whatever the record's keys are. + +use cedar_policy::{Entities, Schema}; +use cedar_policy_core::ast::{ + Entity, EntityType, EntityUID, Literal, PartialValue, Value, ValueKind, +}; +use cedar_policy_core::validator::ValidatorSchema; +use serde_json::json; + +use crate::config::{ + DatabaseConfiguration, HIERARCHY_COLUMNS, SQLType, TAGS_ENTITY_ID_COLUMN, TAGS_TAG_COLUMN, + TAGS_VALUE_COLUMN, +}; +use crate::dialect::Dialect; +use crate::ident::quoted_literal; +use crate::{Error, Result}; + +/// The statements loading a set of entities, and the action entities. +#[derive(Debug, Default)] +pub struct Load { + /// `INSERT` statements, in an order that satisfies the foreign keys only + /// when the referenced entities exist (Cedar allows dangling references). + pub statements: Vec, + /// The action entities, which have no table. + pub actions: Vec, +} + +/// Renders `entities` as rows of the tables of `config`. +pub fn entities_to_sql( + entities: &Entities, + schema: &Schema, + config: &DatabaseConfiguration, + dialect: &dyn Dialect, +) -> Result { + let vschema: &ValidatorSchema = schema.as_ref(); + let mut load = Load::default(); + let mut ordered: Vec<&Entity> = entities.as_ref().iter().collect(); + ordered.sort_by_key(|e| e.uid().to_string()); + for entity in ordered { + let uid = entity.uid(); + let ety = uid.entity_type(); + if ety.is_action() { + load.actions.push(entity.clone()); + continue; + } + let Some((table_name, table)) = config.table_for(ety) else { + return Err(Error::Load(format!("no table stores entity type {ety}"))); + }; + let Some(vet) = vschema.get_entity_type(ety) else { + return Err(Error::Load(format!("the schema has no entity type {ety}"))); + }; + let eid = quoted_literal(uid.eid().as_ref())?; + let mut columns = vec![table.entity_id_column.to_string()]; + let mut values = vec![eid.clone()]; + for (attr, column) in &table.attribute_columns { + if *column == table.entity_id_column { + // The id column is also exposed as an attribute; the values must agree. + match entity.get(attr) { + Some(PartialValue::Value(v)) if literal(v, &SQLType::Text, dialect)? == eid => { + } + _ => { + return Err(Error::Load(format!( + "attribute {attr} of {uid} must equal the entity id, since it is the entity id column" + ))); + } + } + continue; + } + let cc = &table.columns[column]; + let rendered = match entity.get(attr) { + None if cc.nullable => "NULL".to_owned(), + None => { + return Err(Error::Load(format!( + "required attribute {attr} of {uid} is missing" + ))); + } + Some(PartialValue::Value(v)) => literal(v, &cc.ty, dialect)?, + Some(PartialValue::Residual(_)) => { + return Err(Error::Unsupported("entities with unknown attribute values")); + } + }; + columns.push(column.to_string()); + values.push(rendered); + } + for (attr, _) in entity.attrs() { + if vet.attr(attr).is_none() { + return Err(Error::Load(format!( + "attribute {attr} of {uid} is not declared in the schema" + ))); + } + } + load.statements.push(format!( + "INSERT INTO {table_name} ({}) VALUES ({})", + columns.join(", "), + values.join(", ") + )); + let mut ancestors: Vec<&EntityUID> = entity.ancestors().collect(); + ancestors.sort_by_key(|a| a.to_string()); + for ancestor in ancestors { + load.statements.push(format!( + "INSERT INTO {} ({}) VALUES ({}, {eid}, {}, {})", + config.entity_hierarchy_table, + HIERARCHY_COLUMNS + .iter() + .map(|c| format!("\"{c}\"")) + .collect::>() + .join(", "), + quoted_literal(&ety.to_string())?, + quoted_literal(&ancestor.entity_type().to_string())?, + quoted_literal(ancestor.eid().as_ref())?, + )); + } + let mut tags: Vec<(&str, &PartialValue)> = + entity.tags().map(|(k, v)| (k.as_str(), v)).collect(); + tags.sort_by_key(|(k, _)| *k); + for (tag, value) in tags { + let Some(tags_table) = &table.tags else { + return Err(Error::Load(format!( + "{uid} has tag {tag}, but its type declares no tags" + ))); + }; + let PartialValue::Value(value) = value else { + return Err(Error::Unsupported("entities with unknown tag values")); + }; + load.statements.push(format!( + "INSERT INTO {} (\"{TAGS_ENTITY_ID_COLUMN}\", \"{TAGS_TAG_COLUMN}\", \"{TAGS_VALUE_COLUMN}\") VALUES ({eid}, {}, {})", + tags_table.table, + quoted_literal(tag)?, + literal(value, &tags_table.value.ty, dialect)?, + )); + } + } + Ok(load) +} + +/// `value` as a SQL literal of column type `ty`. +pub fn literal(value: &Value, ty: &SQLType, dialect: &dyn Dialect) -> Result { + let mismatch = || { + Error::Load(format!( + "the value {value} cannot be stored in a column of type {ty:?}" + )) + }; + match (ty, value.value_kind()) { + (SQLType::Text, ValueKind::Lit(Literal::String(s))) => quoted_literal(s), + (SQLType::Text, ValueKind::Lit(Literal::EntityUID(uid))) => { + quoted_literal(uid.eid().as_ref()) + } + (SQLType::BigInt, ValueKind::Lit(Literal::Long(n))) => Ok(dialect.bigint_literal(*n)), + (SQLType::Bool, ValueKind::Lit(Literal::Bool(b))) => { + Ok(if *b { "TRUE" } else { "FALSE" }.to_owned()) + } + (SQLType::Jsonb, ValueKind::Set(_) | ValueKind::Record(_)) => { + let json = canonical_json(value)?; + Ok(dialect.json_literal("ed_literal(&json.to_string())?)) + } + (SQLType::Set(_) | SQLType::Custom(_), _) => Err(Error::Unsupported( + "loading values into array or custom columns", + )), + (_, ValueKind::ExtensionValue(_)) => Err(Error::Unsupported("extension values")), + _ => Err(mismatch()), + } +} + +/// The canonical JSON encoding of a Cedar value (see the module docs). +pub fn canonical_json(value: &Value) -> Result { + Ok(match value.value_kind() { + ValueKind::Lit(Literal::Bool(b)) => json!(b), + ValueKind::Lit(Literal::Long(n)) => json!(n), + ValueKind::Lit(Literal::String(s)) => { + if s.contains('\0') { + return Err(Error::Load(format!( + "the string {s:?} contains a NUL character, which Postgres cannot store" + ))); + } + json!(s.as_str()) + } + ValueKind::Lit(Literal::EntityUID(uid)) => entity_json(uid), + ValueKind::Set(set) => { + serde_json::Value::Array(set.iter().map(canonical_json).collect::>>()?) + } + ValueKind::Record(record) => { + let fields = record + .iter() + .map(|(k, v)| Ok((k.to_string(), canonical_json(v)?))) + .collect::>>()?; + json!({ "r": fields }) + } + ValueKind::ExtensionValue(_) => return Err(Error::Unsupported("extension values")), + }) +} + +/// The canonical JSON encoding of an entity reference. +pub fn entity_json(uid: &EntityUID) -> serde_json::Value { + json!({ "e": { "t": uid.entity_type().to_string(), "i": uid.eid().as_ref() } }) +} + +/// The entity type of a canonical entity reference, for messages and tests. +pub fn entity_type_of_json(value: &serde_json::Value) -> Option { + value.get("e")?.get("t")?.as_str()?.parse().ok() +} diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..2b6378e --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,205 @@ +//! The schema-to-configuration mapping: names, conflicts and validation. + +use cedar_sql::Error; +use cedar_sql::config::{DatabaseConfiguration, SQLType}; + +fn config(src: &str) -> Result { + DatabaseConfiguration::from_cedarschema_str(src).map(|(c, _)| c) +} + +fn err(src: &str) -> String { + match config(src) { + Ok(c) => panic!("expected an error, got {c:#?}"), + Err(e) => e.to_string(), + } +} + +#[test] +fn defaults() { + let c = config( + r#" + namespace App { + entity Group; + entity User in [Group] = { name: String, boss?: User, tags: Set, r: { a: Bool } } tags String; + action view appliesTo { principal: [User], resource: [Group] }; + }"#, + ) + .unwrap(); + assert_eq!(c.entity_id_column.as_str(), "__entity_id"); + assert_eq!(c.entity_type_column.as_str(), "__entity_type"); + assert_eq!(c.entity_hierarchy_table.as_str(), "cedar_entity_hierarchy"); + let names: Vec<&str> = c.tables.keys().map(|k| k.as_str()).collect(); + assert_eq!(names, ["App::Group", "App::User"]); + let user = &c.tables["App::User"]; + let columns: Vec<(&str, &SQLType, bool)> = user + .columns + .iter() + .map(|(k, v)| (k.as_str(), &v.ty, v.nullable)) + .collect(); + assert_eq!( + columns, + [ + ("__entity_id", &SQLType::Text, false), + ("__entity_type", &SQLType::Text, false), + ("boss", &SQLType::Text, true), + ("name", &SQLType::Text, false), + ("r", &SQLType::Jsonb, false), + ("tags", &SQLType::Jsonb, false), + ] + ); + assert_eq!( + user.columns["boss"] + .references + .as_ref() + .unwrap() + .table + .as_str(), + "App::User" + ); + assert_eq!( + user.columns["boss"] + .references + .as_ref() + .unwrap() + .column + .as_str(), + "__entity_id" + ); + assert_eq!(user.tags.as_ref().unwrap().table.as_str(), "App::User_tags"); + assert_eq!(user.tags.as_ref().unwrap().value.ty, SQLType::Text); + assert!(user.columns["__entity_type"].generated.as_deref() == Some("'App::User'")); + let ety = "App::User".parse().unwrap(); + assert_eq!(c.column_for(&ety, "name").unwrap().as_str(), "name"); + assert!(c.table_for(&"App::Action".parse().unwrap()).is_none()); +} + +#[test] +fn interface_suffixes() { + let c = config( + r#" + entity cedar_entity_hierarchy = { __entity_id: Long, __entity_type: String }; + entity Other = { __entity_id2: Bool, __entity_type2: Bool, __entity_type3: Bool }; + action a appliesTo { principal: [Other], resource: [Other] }; + "#, + ) + .unwrap(); + assert_eq!(c.entity_id_column.as_str(), "__entity_id3"); + assert_eq!(c.entity_type_column.as_str(), "__entity_type4"); + assert_eq!(c.entity_hierarchy_table.as_str(), "cedar_entity_hierarchy2"); +} + +#[test] +fn column_conflicts() { + let e = err(r#" + entity User = { @sql_column("name") fullName: String, @sql_column("name") firstName: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("both map to column \"name\""), "{e}"); + let e = err(r#" + @sql_custom_config("{\"columns\": {\"name\": {\"ty\": \"Text\"}}}") + entity User = { name: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("collides with an attribute column"), "{e}"); +} + +#[test] +fn table_conflicts() { + let e = err(r#" + @sql_table("t") entity A; + @sql_table("t") entity B; + action a appliesTo { principal: [A], resource: [B] }; + "#); + assert!(e.contains("use @sql_table"), "{e}"); + let e = err(r#" + entity foo tags String; + entity foo_tags; + action a appliesTo { principal: [foo], resource: [foo_tags] }; + "#); + assert!(e.contains("use @sql_tags_table"), "{e}"); +} + +#[test] +fn entity_id_column_rules() { + let e = err(r#" + @sql_entity_id_column("id") entity User = { id: Long }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("non-nullable text column"), "{e}"); + let e = err(r#" + @sql_entity_id_column("id") entity User = { id: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("unique or the primary key"), "{e}"); + let c = config( + r#" + @sql_entity_id_column("id") @sql_primary_key("id") entity User = { id: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#, + ) + .unwrap(); + let user = &c.tables["User"]; + assert_eq!(user.entity_id_column.as_str(), "id"); + assert_eq!( + user.columns["__entity_id"].generated.as_deref(), + Some("\"id\"") + ); + assert_eq!( + user.primary_keys + .iter() + .map(|k| k.as_str()) + .collect::>(), + ["id"] + ); + let e = err(r#" + @sql_entity_id_column("nope") entity User = { id: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("does not exist"), "{e}"); +} + +#[test] +fn bad_annotations() { + let e = err(r#" + entity User = { @sql_unique("yes") id: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!( + e.contains("@sql_unique") && e.contains("attribute id of User"), + "{e}" + ); + let e = err(r#" + @sql_custom_config("{\"entity_type\": \"User\"}") entity User; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("@sql_custom_config"), "{e}"); + let e = err(r#" + @sql_table("") entity User; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("@sql_table") && e.contains("empty"), "{e}"); +} + +#[test] +fn unsupported() { + let e = err(r#" + entity User = { ip: ipaddr }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert_eq!(e, "unsupported: extension types"); + let e = err(r#" + entity User = { a: Action }; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert_eq!(e, "unsupported: attributes referencing action entity types"); +} + +#[test] +fn json_schema_annotations() { + let (c, _) = DatabaseConfiguration::from_json_str( + r#"{"": {"entityTypes": {"User": {"annotations": {"sql_table": "users"}, "shape": {"type": "Record", "attributes": {"n": {"type": "String", "annotations": {"sql_column": "name"}}}}}}, "actions": {"a": {"appliesTo": {"principalTypes": ["User"], "resourceTypes": ["User"]}}}}}"#, + ) + .unwrap(); + assert!(c.tables.contains_key("users")); + assert_eq!(c.tables["users"].attribute_columns["n"].as_str(), "name"); +} diff --git a/tests/ddl_golden.rs b/tests/ddl_golden.rs new file mode 100644 index 0000000..99f58cd --- /dev/null +++ b/tests/ddl_golden.rs @@ -0,0 +1,56 @@ +//! The DDL of the README's schemas, against golden files (regenerate with +//! `UPDATE_GOLDEN=1`), and executed on Postgres. + +use cedar_sql::backend::Backend; +use cedar_sql::config::DatabaseConfiguration; +use cedar_sql::ddl::{create_tables, drop_tables}; +use cedar_sql::dialect::Postgres; +use cedar_sql::testing::SharedPostgres; + +fn check(name: &str) -> Vec { + let src = std::fs::read_to_string(format!("tests/schemas/{name}.cedarschema")).unwrap(); + let (config, _schema) = DatabaseConfiguration::from_cedarschema_str(&src).unwrap(); + let statements = create_tables(&config, &Postgres).unwrap(); + let rendered = statements + .iter() + .map(|s| format!("{s};\n")) + .collect::(); + let golden = format!("tests/golden/{name}.sql"); + if std::env::var_os("UPDATE_GOLDEN").is_some() { + std::fs::write(&golden, &rendered).unwrap(); + } + let expected = std::fs::read_to_string(&golden) + .unwrap_or_else(|e| panic!("{golden}: {e}; run with UPDATE_GOLDEN=1")); + assert_eq!( + rendered, expected, + "{golden} differs; run with UPDATE_GOLDEN=1 to update" + ); + + // The DDL runs, and so does its drop. + let mut db = SharedPostgres::get().unwrap().connect().unwrap(); + db.begin().unwrap(); + for statement in &statements { + db.execute_batch(statement) + .unwrap_or_else(|e| panic!("{statement}\n{e}")); + } + for statement in drop_tables(&config) { + db.execute_batch(&statement).unwrap(); + } + db.rollback().unwrap(); + statements +} + +#[test] +fn readme_simple() { + check("readme_simple"); +} + +#[test] +fn readme_annotated() { + check("readme_annotated"); +} + +#[test] +fn kitchen_sink() { + check("kitchen_sink"); +} diff --git a/tests/entities/kitchen_sink.json b/tests/entities/kitchen_sink.json new file mode 100644 index 0000000..682691a --- /dev/null +++ b/tests/entities/kitchen_sink.json @@ -0,0 +1,21 @@ +[ + {"uid": {"type": "Group", "id": "admins"}, "attrs": {}, "parents": []}, + {"uid": {"type": "User", "id": "alice"}, + "attrs": { + "name": "Alice", "age": 30, "admin": true, + "groups": ["b", "a", "a"], + "profile": {"city": "Zürich", "pets": [3, 1, 2], "boss": {"__entity": {"type": "User", "id": "bob"}}}, + "friend": {"__entity": {"type": "User", "id": "bob"}}, + "friends": [{"__entity": {"type": "User", "id": "bob"}}] + }, + "parents": [{"type": "Group", "id": "admins"}], + "tags": {"k": "v", "it's": "q'"}}, + {"uid": {"type": "User", "id": "bob"}, + "attrs": {"name": "Bob", "admin": false, "groups": [], "profile": {"city": "", "pets": []}, "friends": []}, + "parents": []}, + {"uid": {"type": "Doc", "id": "d1"}, + "attrs": {"owner": {"__entity": {"type": "User", "id": "alice"}}}, + "parents": [{"type": "Group", "id": "admins"}], + "tags": {"reviewer": {"__entity": {"type": "User", "id": "bob"}}}}, + {"uid": {"type": "Action", "id": "view"}, "attrs": {}, "parents": []} +] diff --git a/tests/golden/kitchen_sink.sql b/tests/golden/kitchen_sink.sql new file mode 100644 index 0000000..5fbbc87 --- /dev/null +++ b/tests/golden/kitchen_sink.sql @@ -0,0 +1,47 @@ +CREATE TABLE "Doc" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('Doc') STORED, + "owner" TEXT NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "Doc_tags" ( + "entity_id" TEXT NOT NULL, + "tag" TEXT NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY ("entity_id", "tag") +); +CREATE TABLE "Group" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('Group') STORED, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "User" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('User') STORED, + "admin" BOOLEAN NOT NULL, + "age" BIGINT, + "friend" TEXT, + "friends" JSONB NOT NULL, + "groups" JSONB NOT NULL, + "name" TEXT NOT NULL, + "profile" JSONB NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "User_tags" ( + "entity_id" TEXT NOT NULL, + "tag" TEXT NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY ("entity_id", "tag") +); +CREATE TABLE "cedar_entity_hierarchy" ( + "descendant_type" TEXT NOT NULL, + "descendant_id" TEXT NOT NULL, + "ancestor_type" TEXT NOT NULL, + "ancestor_id" TEXT NOT NULL, + PRIMARY KEY ("descendant_type", "descendant_id", "ancestor_type", "ancestor_id") +); +ALTER TABLE "Doc" ADD CONSTRAINT "Doc_owner_fkey" FOREIGN KEY ("owner") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "Doc_tags" ADD CONSTRAINT "Doc_tags_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "Doc" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "Doc_tags" ADD CONSTRAINT "Doc_tags_value_fkey" FOREIGN KEY ("value") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "User" ADD CONSTRAINT "User_friend_fkey" FOREIGN KEY ("friend") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "User_tags" ADD CONSTRAINT "User_tags_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; diff --git a/tests/golden/readme_annotated.sql b/tests/golden/readme_annotated.sql new file mode 100644 index 0000000..cc73df9 --- /dev/null +++ b/tests/golden/readme_annotated.sql @@ -0,0 +1,39 @@ +CREATE TABLE "App::User" ( + "__entity_id3" TEXT GENERATED ALWAYS AS ("user_id") STORED, + "__entity_type2" TEXT GENERATED ALWAYS AS ('App::User') STORED, + "__entity_id2" TEXT NOT NULL, + "custom_config" JSONB NOT NULL, + "user_id" TEXT NOT NULL UNIQUE, + PRIMARY KEY ("user_id") +); +CREATE TABLE "App::User_tags" ( + "entity_id" TEXT NOT NULL, + "tag" TEXT NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY ("entity_id", "tag") +); +CREATE TABLE "usertags" ( + "__entity_id3" TEXT GENERATED ALWAYS AS ("custom_eid") STORED, + "__entity_type2" TEXT GENERATED ALWAYS AS ('App::UserTag') STORED, + "categories" JSONB, + "enabled" BOOLEAN NOT NULL DEFAULT TRUE, + "custom_pk" BIGINT NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY, + "custom_eid" TEXT NOT NULL UNIQUE DEFAULT uuidv7()::text, + PRIMARY KEY ("custom_pk") +); +CREATE TABLE "cedar_entity_hierarchy" ( + "__entity_id3" TEXT NOT NULL, + "__entity_type2" TEXT GENERATED ALWAYS AS ('cedar_entity_hierarchy') STORED, + "__entity_id" BIGINT NOT NULL, + "__entity_type" TEXT NOT NULL, + PRIMARY KEY ("__entity_id3") +); +CREATE TABLE "cedar_entity_hierarchy2" ( + "descendant_type" TEXT NOT NULL, + "descendant_id" TEXT NOT NULL, + "ancestor_type" TEXT NOT NULL, + "ancestor_id" TEXT NOT NULL, + PRIMARY KEY ("descendant_type", "descendant_id", "ancestor_type", "ancestor_id") +); +ALTER TABLE "App::User_tags" ADD CONSTRAINT "App::User_tags_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "App::User" ("user_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "App::User_tags" ADD CONSTRAINT "App::User_tags_value_fkey" FOREIGN KEY ("value") REFERENCES "usertags" ("custom_eid") DEFERRABLE INITIALLY DEFERRED; diff --git a/tests/golden/readme_simple.sql b/tests/golden/readme_simple.sql new file mode 100644 index 0000000..5a33555 --- /dev/null +++ b/tests/golden/readme_simple.sql @@ -0,0 +1,26 @@ +CREATE TABLE "documents" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('Document') STORED, + "parent" TEXT NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "folders" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('Folder') STORED, + "confidential" BOOLEAN NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "users" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT GENERATED ALWAYS AS ('User') STORED, + "firstName" TEXT NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "cedar_entity_hierarchy" ( + "descendant_type" TEXT NOT NULL, + "descendant_id" TEXT NOT NULL, + "ancestor_type" TEXT NOT NULL, + "ancestor_id" TEXT NOT NULL, + PRIMARY KEY ("descendant_type", "descendant_id", "ancestor_type", "ancestor_id") +); +ALTER TABLE "documents" ADD CONSTRAINT "documents_parent_fkey" FOREIGN KEY ("parent") REFERENCES "folders" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; diff --git a/tests/load_pg.rs b/tests/load_pg.rs new file mode 100644 index 0000000..48de0f4 --- /dev/null +++ b/tests/load_pg.rs @@ -0,0 +1,137 @@ +//! Loading entities into the generated tables and reading them back. + +use std::collections::HashSet; + +use cedar_policy::{Entities, Entity, EntityId, EntityUid}; +use cedar_sql::backend::{Backend, SqlValue}; +use cedar_sql::config::DatabaseConfiguration; +use cedar_sql::ddl::create_tables; +use cedar_sql::dialect::Postgres; +use cedar_sql::load::entities_to_sql; +use cedar_sql::testing::SharedPostgres; +use serde_json::json; + +fn text(s: &str) -> SqlValue { + SqlValue::Text(s.into()) +} + +#[test] +fn round_trip() { + let src = std::fs::read_to_string("tests/schemas/kitchen_sink.cedarschema").unwrap(); + let (config, schema) = DatabaseConfiguration::from_cedarschema_str(&src).unwrap(); + let json = std::fs::read_to_string("tests/entities/kitchen_sink.json").unwrap(); + let entities = Entities::from_json_str(&json, Some(&schema)).unwrap(); + let load = entities_to_sql(&entities, &schema, &config, &Postgres).unwrap(); + assert_eq!(load.actions.len(), 1); + assert_eq!(load.actions[0].uid().to_string(), "Action::\"view\""); + + let mut db = SharedPostgres::get().unwrap().connect().unwrap(); + db.begin().unwrap(); + for statement in create_tables(&config, &Postgres).unwrap() { + db.execute_batch(&statement).unwrap(); + } + for statement in &load.statements { + db.execute_batch(statement) + .unwrap_or_else(|e| panic!("{statement}\n{e}")); + } + // The deferred foreign keys hold. + db.execute_batch("SET CONSTRAINTS ALL IMMEDIATE").unwrap(); + + let users = db + .query( + "SELECT \"__entity_id\", \"__entity_type\", \"name\", \"age\", \"admin\", \"groups\", \"profile\", \"friend\", \"friends\" FROM \"User\" ORDER BY 1", + ) + .unwrap(); + let bob = json!({"e": {"t": "User", "i": "bob"}}); + assert_eq!( + users, + vec![ + vec![ + text("alice"), + text("User"), + text("Alice"), + SqlValue::Long(30), + SqlValue::Bool(true), + SqlValue::Json(json!(["a", "b"])), + SqlValue::Json(json!({"r": {"boss": bob, "city": "Zürich", "pets": [1, 2, 3]}})), + text("bob"), + SqlValue::Json(json!([bob])), + ], + vec![ + text("bob"), + text("User"), + text("Bob"), + SqlValue::Null, + SqlValue::Bool(false), + SqlValue::Json(json!([])), + SqlValue::Json(json!({"r": {"city": "", "pets": []}})), + SqlValue::Null, + SqlValue::Json(json!([])), + ], + ] + ); + let hierarchy = db + .query("SELECT * FROM \"cedar_entity_hierarchy\" ORDER BY 1, 2") + .unwrap(); + assert_eq!( + hierarchy, + vec![ + vec![text("Doc"), text("d1"), text("Group"), text("admins")], + vec![text("User"), text("alice"), text("Group"), text("admins")], + ] + ); + let tags = db + .query("SELECT \"entity_id\", \"tag\", \"value\" FROM \"User_tags\" ORDER BY 2") + .unwrap(); + assert_eq!( + tags, + vec![ + vec![text("alice"), text("it's"), text("q'")], + vec![text("alice"), text("k"), text("v")], + ] + ); + let doc_tags = db + .query("SELECT \"entity_id\", \"tag\", \"value\" FROM \"Doc_tags\"") + .unwrap(); + assert_eq!( + doc_tags, + vec![vec![text("d1"), text("reviewer"), text("bob")]] + ); + db.rollback().unwrap(); +} + +#[test] +fn rejects_bad_entities() { + let src = std::fs::read_to_string("tests/schemas/kitchen_sink.cedarschema").unwrap(); + let (config, schema) = DatabaseConfiguration::from_cedarschema_str(&src).unwrap(); + let case = |entities: &str| { + let entities = Entities::from_json_str(entities, None).unwrap(); + entities_to_sql(&entities, &schema, &config, &Postgres) + .expect_err("an error") + .to_string() + }; + assert!( + case(r#"[{"uid": {"type": "Group", "id": "g"}, "attrs": {"extra": 1}, "parents": []}]"#) + .contains("not declared in the schema") + ); + assert!( + case(r#"[{"uid": {"type": "User", "id": "u"}, "attrs": {"name": "x"}, "parents": []}]"#) + .contains("required attribute") + ); + assert!( + case(r#"[{"uid": {"type": "Group", "id": "g"}, "attrs": {}, "parents": [], "tags": {"t": 1}}]"#) + .contains("declares no tags") + ); + assert!( + case(r#"[{"uid": {"type": "Nope", "id": "g"}, "attrs": {}, "parents": []}]"#) + .contains("no table stores") + ); + // A NUL character, which Postgres cannot store, is rejected up front. + let nul = Entity::new_no_attrs( + EntityUid::from_type_name_and_id("Group".parse().unwrap(), EntityId::new("a\0b")), + HashSet::new(), + ); + let entities = Entities::from_entities([nul], None).unwrap(); + let e = entities_to_sql(&entities, &schema, &config, &Postgres).expect_err("an error"); + assert!(e.to_string().contains("NUL"), "{e}"); +} diff --git a/tests/schemas/kitchen_sink.cedarschema b/tests/schemas/kitchen_sink.cedarschema new file mode 100644 index 0000000..38aee79 --- /dev/null +++ b/tests/schemas/kitchen_sink.cedarschema @@ -0,0 +1,21 @@ +entity Group; + +entity User in [Group] = { + name: String, + age?: Long, + admin: Bool, + groups: Set, + profile: { city: String, pets: Set, boss?: User }, + friend?: User, + friends: Set, +} tags String; + +entity Doc in [Group] = { + owner: User, +} tags User; + +action "view" appliesTo { + principal: [User], + resource: [Doc], + context: { ip: String } +}; diff --git a/tests/schemas/readme_annotated.cedarschema b/tests/schemas/readme_annotated.cedarschema new file mode 100644 index 0000000..e27a8e2 --- /dev/null +++ b/tests/schemas/readme_annotated.cedarschema @@ -0,0 +1,41 @@ +// Forces the entity hierarchy table to add a "2" suffix. +entity cedar_entity_hierarchy = { + // Forces a non-"__entity_id" table interface. + __entity_id: Long, + // Forces a non-"__entity_type" table interface. + __entity_type: String, +}; + +namespace App { + @sql_entity_id_column("user_id") + entity User = { + @sql_unique("true") + user_id: String, + + // Stored as a jsonb column + @sql_column("custom_config") + config: Config, + + // Forces a non-"__entity_id2" table interface. + __entity_id2: String, + } tags UserTag; + + type Config = { + a: Bool, + b: String, + }; + + @sql_table("usertags") + @sql_custom_config("{\"primary_keys\": [\"custom_pk\"], \"entity_id_column\": \"custom_eid\", \"columns\": {\"custom_pk\": {\"ty\": \"BigInt\", \"unique\": true, \"custom_attrs\": \"GENERATED ALWAYS AS IDENTITY\"}, \"custom_eid\": {\"ty\": \"Text\", \"unique\": true, \"custom_attrs\": \"DEFAULT uuidv7()::text\"}}}") + entity UserTag = { + categories?: Set, + + @sql_custom_attrs("DEFAULT TRUE") + enabled: Bool, + }; + + action "get" appliesTo { + principal: [User], + resource: [UserTag] + }; +} diff --git a/tests/schemas/readme_simple.cedarschema b/tests/schemas/readme_simple.cedarschema new file mode 100644 index 0000000..5e26dd9 --- /dev/null +++ b/tests/schemas/readme_simple.cedarschema @@ -0,0 +1,19 @@ +@sql_table("users") +entity User = { + firstName: String +}; + +@sql_table("folders") +entity Folder = { + confidential: Bool +}; + +@sql_table("documents") +entity Document in [Folder] = { + parent: Folder +}; + +action "get" appliesTo { + principal: [User], + resource: [Document] +}; From a8f0f920725e8586e3814d103c2718e0453b76d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 22:29:03 +0300 Subject: [PATCH 3/3] Fix the schema mapping and loader per review Shorten long constraint names at a char boundary (the previous loop hung on multibyte names), keep `__entity_id` unique when another column is the primary key, check the entity type of references and tag values when loading, resolve qualified common type names for attribute annotations, add the column an undefined `@sql_entity_id_column` names, and reject conflicting annotation and custom-config values. Tests for each. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- README.md | 2 + docs/followups/2-schema.md | 36 ++++++++++ src/annotations.rs | 23 +++++-- src/config.rs | 54 ++++++++++----- src/ddl.rs | 32 ++------- src/ident.rs | 39 +++++++++++ src/load.rs | 50 ++++++++++---- tests/config.rs | 99 ++++++++++++++++++++++++++- tests/ddl_golden.rs | 20 ++++++ tests/entities/readme_annotated.json | 9 +++ tests/golden/kitchen_sink.sql | 6 +- tests/golden/pk_and_refs.sql | 37 ++++++++++ tests/golden/readme_annotated.sql | 10 +-- tests/golden/readme_simple.sql | 6 +- tests/load_pg.rs | 94 +++++++++++++++++++++++++ tests/schemas/pk_and_refs.cedarschema | 15 ++++ 16 files changed, 456 insertions(+), 76 deletions(-) create mode 100644 docs/followups/2-schema.md create mode 100644 tests/entities/readme_annotated.json create mode 100644 tests/golden/pk_and_refs.sql create mode 100644 tests/schemas/pk_and_refs.cedarschema diff --git a/README.md b/README.md index 9688b60..fafe5e1 100644 --- a/README.md +++ b/README.md @@ -744,6 +744,8 @@ Deviations from the text above that were decided during implementation: - Sets and records are stored as canonical JSONB (sets deduplicated and sorted, records and entity references wrapped) rather than `[]` arrays, so that JSONB equality is Cedar equality for every nested shape. - The hierarchy table may hold the transitive closure instead of the direct edges (the recursive CTE tolerates both). +- The `@sql_entity_id_column` target may be the table's (sole) primary key instead of being declared unique. +- Foreign keys are `DEFERRABLE INITIALLY DEFERRED`, so rows load in any order within a transaction. The crate expects a checkout of [`cedar-woodpecker`] (a fork of `cedar`) next to this repository, as `cedar-spec` does. Tests need a Postgres: set `CEDAR_SQL_PG_URL`, or leave it unset to have an embedded Postgres downloaded and diff --git a/docs/followups/2-schema.md b/docs/followups/2-schema.md new file mode 100644 index 0000000..d586e47 --- /dev/null +++ b/docs/followups/2-schema.md @@ -0,0 +1,36 @@ +# Follow-ups for branch 2 — schema (review fixes) + +## PR description + +Idea 1 of the README: a Cedar schema with its `@sql_*` annotations becomes a `DatabaseConfiguration`, renders +as Postgres DDL, and Cedar entities load as rows (`docs/plans/2-schema.md`). Tests run the README's schemas' +DDL on Postgres and read loaded rows back. + +## What this branch contains + +`SQLIdentifier` and literal quoting; the configuration model and its builder from the validator schema and +the annotations; DDL with deferred foreign keys; the loader with the canonical JSON encoding of compound values. + +## Review findings (fixed here) + +- Constraint-name shortening looped forever on long names with multibyte characters; now `ident::shortened` + cuts at a char boundary and appends a hash (shared with the query compiler's generated names). +- With `@sql_primary_key` naming another column, the `__entity_id` column was not unique, so foreign keys to it + failed; it is now `UNIQUE` whenever it is not the primary key. +- The loader stored entity references of the wrong type, and strings in reference columns, silently; `literal` + now checks the referenced entity type (`ForeignKey::entity_type`) for attributes and tag values. +- Attribute annotations behind a qualified or cross-namespace common type name were dropped; `resolve_record` + resolves `Ns::Type` in the named namespace. +- `@sql_entity_id_column` naming an undefined column now adds a `TEXT NOT NULL UNIQUE` column, as the README says. +- Conflicting `@sql_entity_id_column`/`@sql_primary_key` and `@sql_custom_config` values are errors; record keys + are NUL-checked; generated interface columns are `NOT NULL`. + +## Divergences from the README + +Recorded in the README's "Status and phases": canonical JSONB for sets and records, the closure in the +hierarchy table, the primary key accepted as the entity id column, deferred foreign keys. + +## Suggested follow-ups + +- Arrays as a fast path for flat scalar sets (Plan 8 or later). +- A `CHECK`-style validation query for the canonical form of loaded JSON. diff --git a/src/annotations.rs b/src/annotations.rs index 67ad1eb..f68669b 100644 --- a/src/annotations.rs +++ b/src/annotations.rs @@ -147,7 +147,9 @@ pub fn collect(fragment: &Fragment) -> Result { } /// The record type an entity shape denotes: the record itself, or the record -/// behind a common type name (same namespace first, then the empty one). +/// behind a common type name. An unqualified name is looked up in the +/// declaring namespace, then in the empty one; a qualified name in the +/// namespace it names. fn resolve_record<'f>( ty: &'f Type, def: &'f NamespaceDefinition, @@ -163,13 +165,24 @@ fn resolve_record<'f>( ty: TypeVariant::EntityOrCommon { type_name }, .. } => { - let wanted = type_name.to_string(); - let empty = fragment.0.get(&None); - [Some(def), empty] + let full = type_name.to_string(); + let (namespaces, id): (Vec>>, &str) = + match full.rsplit_once("::") { + Some((ns, id)) => { + let named = fragment + .0 + .iter() + .find(|(k, _)| k.as_ref().is_some_and(|k| k.to_string() == ns)) + .map(|(_, d)| d); + (vec![named], id) + } + None => (vec![Some(def), fragment.0.get(&None)], full.as_str()), + }; + namespaces .into_iter() .flatten() .flat_map(|d| d.common_types.iter()) - .find(|(id, _)| id.to_string() == wanted) + .find(|(k, _)| k.to_string() == id) .and_then(|(_, common)| resolve_record(&common.ty, def, fragment)) } Type::Type { .. } => None, diff --git a/src/config.rs b/src/config.rs index 468abe7..b4f8934 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,6 +92,8 @@ pub struct ForeignKey { pub table: SQLIdentifier, /// The referenced column (the table's entity id column). pub column: SQLIdentifier, + /// The referenced entity type. + pub entity_type: EntityType, } /// One column. @@ -343,7 +345,6 @@ impl<'a> Builder<'a> { &entity_id_column, &entity_type_column, &entity_tables, - &tables, )?; tables.insert(table.0, table.1); } @@ -446,8 +447,22 @@ impl<'a> Builder<'a> { }, ); } - if entity_id_target.is_none() { - entity_id_target = custom.entity_id_column.clone(); + match (&entity_id_target, &custom.entity_id_column) { + (Some(a), Some(b)) if a != b => { + return Err(Error::Schema(format!( + "@sql_entity_id_column and @sql_custom_config of {ety} name different entity id columns" + ))); + } + (None, Some(b)) => entity_id_target = Some(b.clone()), + _ => {} + } + if !primary_keys.is_empty() + && !custom.primary_keys.is_empty() + && primary_keys != custom.primary_keys + { + return Err(Error::Schema(format!( + "@sql_primary_key and @sql_custom_config of {ety} name different primary keys" + ))); } if primary_keys.is_empty() { primary_keys = custom.primary_keys.clone(); @@ -482,28 +497,30 @@ impl<'a> Builder<'a> { fn finish( &self, - draft: Draft, + mut draft: Draft, entity_id_column: &SQLIdentifier, entity_type_column: &SQLIdentifier, entity_tables: &HashMap, - _finished: &IndexMap, ) -> Result<(SQLIdentifier, TableConfiguration)> { - let ety = draft.entity_type; + let ety = draft.entity_type.clone(); let mut columns = IndexMap::new(); let entity_id = match &draft.entity_id_target { None => { - columns.insert( - entity_id_column.clone(), - ColumnConfiguration::new(SQLType::Text), - ); + let mut column = ColumnConfiguration::new(SQLType::Text); + // Unique in its own right when another column is the primary key. + column.unique = !draft.primary_keys.is_empty() + && draft.primary_keys.as_slice() != std::slice::from_ref(entity_id_column); + columns.insert(entity_id_column.clone(), column); entity_id_column.clone() } Some(target) => { - let Some(column) = draft.columns.get(target) else { - return Err(Error::Schema(format!( - "the entity id column {target} of {ety} does not exist" - ))); - }; + if !draft.columns.contains_key(target) { + // An otherwise undefined name adds a column (the README's rule). + let mut added = ColumnConfiguration::new(SQLType::Text); + added.unique = true; + draft.columns.insert(target.clone(), added); + } + let column = &draft.columns[target]; if column.ty != SQLType::Text || column.nullable { return Err(Error::Schema(format!( "the entity id column {target} of {ety} must be a non-nullable text column" @@ -525,8 +542,9 @@ impl<'a> Builder<'a> { let mut type_column = ColumnConfiguration::new(SQLType::Text); type_column.generated = Some(crate::ident::quoted_literal(&ety.to_string())?); columns.insert(entity_type_column.clone(), type_column); - for (name, mut column) in draft.columns { - if let Some((_, target)) = draft.references.iter().find(|(c, _)| *c == name) { + let references = std::mem::take(&mut draft.references); + for (name, mut column) in std::mem::take(&mut draft.columns) { + if let Some((_, target)) = references.iter().find(|(c, _)| *c == name) { let Some(table) = entity_tables.get(target) else { return Err(Error::Unsupported( "attributes referencing action entity types", @@ -535,6 +553,7 @@ impl<'a> Builder<'a> { column.references = Some(ForeignKey { table: table.clone(), column: entity_id_column.clone(), // fixed up by `build` + entity_type: target.clone(), }); } columns.insert(name, column); @@ -562,6 +581,7 @@ impl<'a> Builder<'a> { value.references = Some(ForeignKey { table: table.clone(), column: entity_id_column.clone(), // fixed up by `build` + entity_type: target.clone(), }); } Some(TagsTableConfiguration { table, value }) diff --git a/src/ddl.rs b/src/ddl.rs index 21693ae..7b4694b 100644 --- a/src/ddl.rs +++ b/src/ddl.rs @@ -7,7 +7,7 @@ use crate::config::{ TAGS_VALUE_COLUMN, TableConfiguration, }; use crate::dialect::Dialect; -use crate::ident::{MAX_IDENTIFIER_BYTES, SQLIdentifier}; +use crate::ident::{SQLIdentifier, shortened}; /// The statements creating every table of `config`: the entity tables, their /// tags tables, the hierarchy table, and then the foreign keys as @@ -105,10 +105,11 @@ fn create_entity_table( fn column_definition(cc: &ColumnConfiguration, dialect: &dyn Dialect) -> String { let mut parts = vec![dialect.render_type(&cc.ty)]; + if !cc.nullable { + parts.push("NOT NULL".into()); + } if let Some(expr) = &cc.generated { parts.push(dialect.generated_column(expr)); - } else if !cc.nullable { - parts.push("NOT NULL".into()); } if cc.unique { parts.push("UNIQUE".into()); @@ -125,33 +126,10 @@ fn alter_foreign_key( target_table: &SQLIdentifier, target_column: &SQLIdentifier, ) -> Result { - let constraint = constraint_name(&format!("{}_{}_fkey", table.as_str(), column.as_str()))?; + let constraint = shortened(&format!("{}_{}_fkey", table.as_str(), column.as_str())); // Deferred, so that rows may reference rows loaded later in the same // transaction (`User.friend: User`, or two types referencing each other). Ok(format!( "ALTER TABLE {table} ADD CONSTRAINT {constraint} FOREIGN KEY ({column}) REFERENCES {target_table} ({target_column}) DEFERRABLE INITIALLY DEFERRED" )) } - -/// `name`, shortened to the identifier limit with a hash of the whole name -/// when it is too long, so that distinct long names stay distinct. -fn constraint_name(name: &str) -> Result { - if name.len() <= MAX_IDENTIFIER_BYTES { - return SQLIdentifier::new(name); - } - let hash = format!("{:016x}", fxhash(name)); - let keep = MAX_IDENTIFIER_BYTES - hash.len() - 1; - let mut prefix = name.to_owned(); - while !prefix.is_char_boundary(keep) || prefix.len() > keep { - prefix.pop(); - } - SQLIdentifier::new(format!("{prefix}_{hash}")) -} - -/// A small stable hash (FNV-1a), so constraint names do not depend on the -/// standard library's hasher. -fn fxhash(s: &str) -> u64 { - s.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { - (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) - }) -} diff --git a/src/ident.rs b/src/ident.rs index e13bae6..49bb244 100644 --- a/src/ident.rs +++ b/src/ident.rs @@ -71,6 +71,31 @@ impl From for String { } } +/// `raw` as an identifier, shortened with a hash of the whole name when it is +/// longer than [`MAX_IDENTIFIER_BYTES`] (Postgres would otherwise truncate it +/// silently, so that two long names could collide). NUL characters are +/// dropped. +pub fn shortened(raw: &str) -> SQLIdentifier { + let raw = raw.replace('\0', ""); + if !raw.is_empty() && raw.len() <= MAX_IDENTIFIER_BYTES { + return SQLIdentifier::new(raw).expect("validated"); + } + let hash = format!("{:016x}", fnv1a(&raw)); + let mut cut = (MAX_IDENTIFIER_BYTES - hash.len() - 1).min(raw.len()); + while !raw.is_char_boundary(cut) { + cut -= 1; + } + SQLIdentifier::new(format!("{}_{hash}", &raw[..cut])).expect("within the limit") +} + +/// A small stable hash (FNV-1a), so generated names do not depend on the +/// standard library's hasher. +fn fnv1a(s: &str) -> u64 { + s.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }) +} + /// `s` as a single-quoted SQL string literal, with `'` doubled. Backslashes /// are literal (Postgres `standard_conforming_strings`, SQLite always). /// @@ -98,6 +123,20 @@ mod tests { assert!(quoted_literal("a\0b").is_err()); } + #[test] + fn shortening() { + let long = format!("{}{}", "a".repeat(45), "ä".repeat(8)); + let short = shortened(&format!("{long}_owner_fkey")); + assert!(short.as_str().len() <= MAX_IDENTIFIER_BYTES, "{short}"); + assert!(short.as_str().starts_with("aaaa")); + assert_ne!( + shortened(&format!("{long}_a_fkey")), + shortened(&format!("{long}_b_fkey")) + ); + assert_eq!(shortened("x").as_str(), "x"); + assert_eq!(shortened("").as_str().len(), 17); + } + #[test] fn limits() { assert!(SQLIdentifier::new("").is_err()); diff --git a/src/load.rs b/src/load.rs index d81836d..804e00c 100644 --- a/src/load.rs +++ b/src/load.rs @@ -20,8 +20,8 @@ use cedar_policy_core::validator::ValidatorSchema; use serde_json::json; use crate::config::{ - DatabaseConfiguration, HIERARCHY_COLUMNS, SQLType, TAGS_ENTITY_ID_COLUMN, TAGS_TAG_COLUMN, - TAGS_VALUE_COLUMN, + ColumnConfiguration, DatabaseConfiguration, HIERARCHY_COLUMNS, SQLType, TAGS_ENTITY_ID_COLUMN, + TAGS_TAG_COLUMN, TAGS_VALUE_COLUMN, }; use crate::dialect::Dialect; use crate::ident::quoted_literal; @@ -68,8 +68,8 @@ pub fn entities_to_sql( if *column == table.entity_id_column { // The id column is also exposed as an attribute; the values must agree. match entity.get(attr) { - Some(PartialValue::Value(v)) if literal(v, &SQLType::Text, dialect)? == eid => { - } + Some(PartialValue::Value(v)) if matches!(v.value_kind(), ValueKind::Lit(Literal::String(s)) if s.as_str() == uid.eid().as_ref()) => + {} _ => { return Err(Error::Load(format!( "attribute {attr} of {uid} must equal the entity id, since it is the entity id column" @@ -86,7 +86,7 @@ pub fn entities_to_sql( "required attribute {attr} of {uid} is missing" ))); } - Some(PartialValue::Value(v)) => literal(v, &cc.ty, dialect)?, + Some(PartialValue::Value(v)) => literal(v, cc, dialect)?, Some(PartialValue::Residual(_)) => { return Err(Error::Unsupported("entities with unknown attribute values")); } @@ -138,25 +138,40 @@ pub fn entities_to_sql( "INSERT INTO {} (\"{TAGS_ENTITY_ID_COLUMN}\", \"{TAGS_TAG_COLUMN}\", \"{TAGS_VALUE_COLUMN}\") VALUES ({eid}, {}, {})", tags_table.table, quoted_literal(tag)?, - literal(value, &tags_table.value.ty, dialect)?, + literal(value, &tags_table.value, dialect)?, )); } } Ok(load) } -/// `value` as a SQL literal of column type `ty`. -pub fn literal(value: &Value, ty: &SQLType, dialect: &dyn Dialect) -> Result { +/// `value` as a SQL literal for `column`: strings and entity references of +/// the referenced type in text columns, integers, booleans, and the canonical +/// JSON of sets and records. +pub fn literal( + value: &Value, + column: &ColumnConfiguration, + dialect: &dyn Dialect, +) -> Result { let mismatch = || { Error::Load(format!( - "the value {value} cannot be stored in a column of type {ty:?}" + "the value {value} cannot be stored in a column of type {:?}{}", + column.ty, + column + .references + .as_ref() + .map(|fk| format!(" referencing {}", fk.entity_type)) + .unwrap_or_default() )) }; - match (ty, value.value_kind()) { - (SQLType::Text, ValueKind::Lit(Literal::String(s))) => quoted_literal(s), - (SQLType::Text, ValueKind::Lit(Literal::EntityUID(uid))) => { - quoted_literal(uid.eid().as_ref()) + match (&column.ty, value.value_kind()) { + (SQLType::Text, ValueKind::Lit(Literal::String(s))) if column.references.is_none() => { + quoted_literal(s) } + (SQLType::Text, ValueKind::Lit(Literal::EntityUID(uid))) => match &column.references { + Some(fk) if fk.entity_type == *uid.entity_type() => quoted_literal(uid.eid().as_ref()), + _ => Err(mismatch()), + }, (SQLType::BigInt, ValueKind::Lit(Literal::Long(n))) => Ok(dialect.bigint_literal(*n)), (SQLType::Bool, ValueKind::Lit(Literal::Bool(b))) => { Ok(if *b { "TRUE" } else { "FALSE" }.to_owned()) @@ -193,7 +208,14 @@ pub fn canonical_json(value: &Value) -> Result { ValueKind::Record(record) => { let fields = record .iter() - .map(|(k, v)| Ok((k.to_string(), canonical_json(v)?))) + .map(|(k, v)| { + if k.contains('\0') { + return Err(Error::Load(format!( + "the record key {k:?} contains a NUL character, which Postgres cannot store" + ))); + } + Ok((k.to_string(), canonical_json(v)?)) + }) .collect::>>()?; json!({ "r": fields }) } diff --git a/tests/config.rs b/tests/config.rs index 2b6378e..32c206d 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -151,11 +151,75 @@ fn entity_id_column_rules() { .collect::>(), ["id"] ); +} + +#[test] +fn primary_key_elsewhere_keeps_the_id_unique() { + let c = config( + r#" + @sql_primary_key("id") entity User = { id: String }; + entity Doc = { owner: User }; + action a appliesTo { principal: [User], resource: [Doc] }; + "#, + ) + .unwrap(); + let user = &c.tables["User"]; + assert!(user.columns["__entity_id"].unique); + assert_eq!( + user.primary_keys + .iter() + .map(|k| k.as_str()) + .collect::>(), + ["id"] + ); + let owner = c.tables["Doc"].columns["owner"] + .references + .as_ref() + .unwrap(); + assert_eq!( + (owner.table.as_str(), owner.column.as_str()), + ("User", "__entity_id") + ); + assert_eq!(owner.entity_type.to_string(), "User"); + // The default table's id column is the primary key and not separately unique. + assert!(!c.tables["Doc"].columns["__entity_id"].unique); +} + +#[test] +fn undefined_entity_id_column_is_added() { + let c = config( + r#" + @sql_entity_id_column("external_id") entity User = { name: String }; + action a appliesTo { principal: [User], resource: [User] }; + "#, + ) + .unwrap(); + let user = &c.tables["User"]; + let added = &user.columns["external_id"]; + assert!(added.unique && !added.nullable && added.ty == SQLType::Text); + assert_eq!(user.entity_id_column.as_str(), "external_id"); + assert_eq!( + user.columns["__entity_id"].generated.as_deref(), + Some("\"external_id\"") + ); +} + +#[test] +fn annotation_and_custom_config_conflicts() { + let e = err(r#" + @sql_entity_id_column("a") + @sql_custom_config("{\"entity_id_column\": \"b\", \"columns\": {\"a\": {\"ty\": \"Text\", \"unique\": true}, \"b\": {\"ty\": \"Text\", \"unique\": true}}}") + entity User; + action a appliesTo { principal: [User], resource: [User] }; + "#); + assert!(e.contains("different entity id columns"), "{e}"); let e = err(r#" - @sql_entity_id_column("nope") entity User = { id: String }; + @sql_primary_key("a") + @sql_custom_config("{\"primary_keys\": [\"b\"], \"columns\": {\"a\": {\"ty\": \"Text\"}, \"b\": {\"ty\": \"Text\"}}}") + entity User; action a appliesTo { principal: [User], resource: [User] }; "#); - assert!(e.contains("does not exist"), "{e}"); + assert!(e.contains("different primary keys"), "{e}"); } #[test] @@ -194,6 +258,37 @@ fn unsupported() { assert_eq!(e, "unsupported: attributes referencing action entity types"); } +#[test] +fn json_common_type_annotations() { + let (c, _) = DatabaseConfiguration::from_json_str( + r#"{ + "Lib": {"entityTypes": {}, "actions": {}, "commonTypes": {"Shape": {"type": "Record", "attributes": {"name": {"type": "String", "annotations": {"sql_column": "lib_name"}}}}}}, + "App": { + "commonTypes": {"Shape": {"type": "Record", "attributes": {"name": {"type": "String", "annotations": {"sql_column": "n"}}}}}, + "entityTypes": { + "Plain": {"shape": {"type": "Shape"}}, + "Qualified": {"shape": {"type": "App::Shape"}}, + "Cross": {"shape": {"type": "Lib::Shape"}} + }, + "actions": {"a": {"appliesTo": {"principalTypes": ["Plain"], "resourceTypes": ["Qualified", "Cross"]}}} + } + }"#, + ) + .unwrap(); + assert_eq!( + c.tables["App::Plain"].attribute_columns["name"].as_str(), + "n" + ); + assert_eq!( + c.tables["App::Qualified"].attribute_columns["name"].as_str(), + "n" + ); + assert_eq!( + c.tables["App::Cross"].attribute_columns["name"].as_str(), + "lib_name" + ); +} + #[test] fn json_schema_annotations() { let (c, _) = DatabaseConfiguration::from_json_str( diff --git a/tests/ddl_golden.rs b/tests/ddl_golden.rs index 99f58cd..583147d 100644 --- a/tests/ddl_golden.rs +++ b/tests/ddl_golden.rs @@ -54,3 +54,23 @@ fn readme_annotated() { fn kitchen_sink() { check("kitchen_sink"); } + +#[test] +fn pk_and_refs() { + let statements = check("pk_and_refs"); + // The shortened constraint names are within the limit and distinct. + let names: Vec<&str> = statements + .iter() + .filter_map(|s| s.split("ADD CONSTRAINT \"").nth(1)) + .map(|s| s.split('"').next().unwrap()) + .collect(); + assert_eq!(names.len(), 4); + assert!(names.iter().all(|n| n.len() <= 63), "{names:?}"); + assert_eq!( + names + .iter() + .collect::>() + .len(), + 4 + ); +} diff --git a/tests/entities/readme_annotated.json b/tests/entities/readme_annotated.json new file mode 100644 index 0000000..13217fa --- /dev/null +++ b/tests/entities/readme_annotated.json @@ -0,0 +1,9 @@ +[ + {"uid": {"type": "App::User", "id": "u1"}, + "attrs": {"user_id": "u1", "config": {"a": true, "b": "x"}, "__entity_id2": "other"}, + "parents": [], + "tags": {"role": {"__entity": {"type": "App::UserTag", "id": "t1"}}}}, + {"uid": {"type": "App::UserTag", "id": "t1"}, "attrs": {"categories": ["b", "a"], "enabled": false}, "parents": []}, + {"uid": {"type": "App::UserTag", "id": "t2"}, "attrs": {"enabled": true}, "parents": []}, + {"uid": {"type": "cedar_entity_hierarchy", "id": "h"}, "attrs": {"__entity_id": 5, "__entity_type": "s"}, "parents": []} +] diff --git a/tests/golden/kitchen_sink.sql b/tests/golden/kitchen_sink.sql index 5fbbc87..6cc747e 100644 --- a/tests/golden/kitchen_sink.sql +++ b/tests/golden/kitchen_sink.sql @@ -1,6 +1,6 @@ CREATE TABLE "Doc" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('Doc') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Doc') STORED, "owner" TEXT NOT NULL, PRIMARY KEY ("__entity_id") ); @@ -12,12 +12,12 @@ CREATE TABLE "Doc_tags" ( ); CREATE TABLE "Group" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('Group') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Group') STORED, PRIMARY KEY ("__entity_id") ); CREATE TABLE "User" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('User') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('User') STORED, "admin" BOOLEAN NOT NULL, "age" BIGINT, "friend" TEXT, diff --git a/tests/golden/pk_and_refs.sql b/tests/golden/pk_and_refs.sql new file mode 100644 index 0000000..e9ad565 --- /dev/null +++ b/tests/golden/pk_and_refs.sql @@ -0,0 +1,37 @@ +CREATE TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääääää" ( + "__entity_id" TEXT NOT NULL, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Doc') STORED, + "owner" TEXT NOT NULL, + "team" TEXT NOT NULL, + PRIMARY KEY ("__entity_id") +); +CREATE TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääää_tags" ( + "entity_id" TEXT NOT NULL, + "tag" TEXT NOT NULL, + "value" TEXT NOT NULL, + PRIMARY KEY ("entity_id", "tag") +); +CREATE TABLE "Team" ( + "__entity_id" TEXT NOT NULL GENERATED ALWAYS AS ("external_id") STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Team') STORED, + "name" TEXT NOT NULL, + "external_id" TEXT NOT NULL UNIQUE, + PRIMARY KEY ("external_id") +); +CREATE TABLE "User" ( + "__entity_id" TEXT NOT NULL UNIQUE, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('User') STORED, + "id" TEXT NOT NULL, + PRIMARY KEY ("id") +); +CREATE TABLE "cedar_entity_hierarchy" ( + "descendant_type" TEXT NOT NULL, + "descendant_id" TEXT NOT NULL, + "ancestor_type" TEXT NOT NULL, + "ancestor_id" TEXT NOT NULL, + PRIMARY KEY ("descendant_type", "descendant_id", "ancestor_type", "ancestor_id") +); +ALTER TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääääää" ADD CONSTRAINT "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_f207ae3e3534e7d4" FOREIGN KEY ("owner") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääääää" ADD CONSTRAINT "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_49a8c18fc7e9392c" FOREIGN KEY ("team") REFERENCES "Team" ("external_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääää_tags" ADD CONSTRAINT "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaä_13084d4062a315cf" FOREIGN KEY ("entity_id") REFERENCES "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääääää" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääää_tags" ADD CONSTRAINT "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaä_37068e80ddbc8e6f" FOREIGN KEY ("value") REFERENCES "User" ("__entity_id") DEFERRABLE INITIALLY DEFERRED; diff --git a/tests/golden/readme_annotated.sql b/tests/golden/readme_annotated.sql index cc73df9..c52c114 100644 --- a/tests/golden/readme_annotated.sql +++ b/tests/golden/readme_annotated.sql @@ -1,6 +1,6 @@ CREATE TABLE "App::User" ( - "__entity_id3" TEXT GENERATED ALWAYS AS ("user_id") STORED, - "__entity_type2" TEXT GENERATED ALWAYS AS ('App::User') STORED, + "__entity_id3" TEXT NOT NULL GENERATED ALWAYS AS ("user_id") STORED, + "__entity_type2" TEXT NOT NULL GENERATED ALWAYS AS ('App::User') STORED, "__entity_id2" TEXT NOT NULL, "custom_config" JSONB NOT NULL, "user_id" TEXT NOT NULL UNIQUE, @@ -13,8 +13,8 @@ CREATE TABLE "App::User_tags" ( PRIMARY KEY ("entity_id", "tag") ); CREATE TABLE "usertags" ( - "__entity_id3" TEXT GENERATED ALWAYS AS ("custom_eid") STORED, - "__entity_type2" TEXT GENERATED ALWAYS AS ('App::UserTag') STORED, + "__entity_id3" TEXT NOT NULL GENERATED ALWAYS AS ("custom_eid") STORED, + "__entity_type2" TEXT NOT NULL GENERATED ALWAYS AS ('App::UserTag') STORED, "categories" JSONB, "enabled" BOOLEAN NOT NULL DEFAULT TRUE, "custom_pk" BIGINT NOT NULL UNIQUE GENERATED ALWAYS AS IDENTITY, @@ -23,7 +23,7 @@ CREATE TABLE "usertags" ( ); CREATE TABLE "cedar_entity_hierarchy" ( "__entity_id3" TEXT NOT NULL, - "__entity_type2" TEXT GENERATED ALWAYS AS ('cedar_entity_hierarchy') STORED, + "__entity_type2" TEXT NOT NULL GENERATED ALWAYS AS ('cedar_entity_hierarchy') STORED, "__entity_id" BIGINT NOT NULL, "__entity_type" TEXT NOT NULL, PRIMARY KEY ("__entity_id3") diff --git a/tests/golden/readme_simple.sql b/tests/golden/readme_simple.sql index 5a33555..f738930 100644 --- a/tests/golden/readme_simple.sql +++ b/tests/golden/readme_simple.sql @@ -1,18 +1,18 @@ CREATE TABLE "documents" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('Document') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Document') STORED, "parent" TEXT NOT NULL, PRIMARY KEY ("__entity_id") ); CREATE TABLE "folders" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('Folder') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('Folder') STORED, "confidential" BOOLEAN NOT NULL, PRIMARY KEY ("__entity_id") ); CREATE TABLE "users" ( "__entity_id" TEXT NOT NULL, - "__entity_type" TEXT GENERATED ALWAYS AS ('User') STORED, + "__entity_type" TEXT NOT NULL GENERATED ALWAYS AS ('User') STORED, "firstName" TEXT NOT NULL, PRIMARY KEY ("__entity_id") ); diff --git a/tests/load_pg.rs b/tests/load_pg.rs index 48de0f4..2d7ca7a 100644 --- a/tests/load_pg.rs +++ b/tests/load_pg.rs @@ -100,6 +100,80 @@ fn round_trip() { db.rollback().unwrap(); } +#[test] +fn annotated_round_trip() { + let src = std::fs::read_to_string("tests/schemas/readme_annotated.cedarschema").unwrap(); + let (config, schema) = DatabaseConfiguration::from_cedarschema_str(&src).unwrap(); + let json = std::fs::read_to_string("tests/entities/readme_annotated.json").unwrap(); + let entities = Entities::from_json_str(&json, Some(&schema)).unwrap(); + let load = entities_to_sql(&entities, &schema, &config, &Postgres).unwrap(); + let mut db = SharedPostgres::get().unwrap().connect().unwrap(); + db.begin().unwrap(); + for statement in create_tables(&config, &Postgres).unwrap() { + db.execute_batch(&statement).unwrap(); + } + for statement in &load.statements { + db.execute_batch(statement) + .unwrap_or_else(|e| panic!("{statement}\n{e}")); + } + db.execute_batch("SET CONSTRAINTS ALL IMMEDIATE").unwrap(); + let users = db + .query("SELECT \"__entity_id3\", \"__entity_type2\", \"user_id\", \"custom_config\", \"__entity_id2\" FROM \"App::User\"") + .unwrap(); + assert_eq!( + users, + vec![vec![ + text("u1"), + text("App::User"), + text("u1"), + SqlValue::Json(json!({"r": {"a": true, "b": "x"}})), + text("other"), + ]] + ); + let tags = db + .query("SELECT \"entity_id\", \"tag\", \"value\" FROM \"App::User_tags\"") + .unwrap(); + assert_eq!(tags, vec![vec![text("u1"), text("role"), text("t1")]]); + let usertags = db + .query("SELECT \"__entity_id3\", \"custom_pk\", \"custom_eid\", \"categories\", \"enabled\" FROM \"usertags\" ORDER BY 2") + .unwrap(); + assert_eq!( + usertags, + vec![ + vec![ + text("t1"), + SqlValue::Long(1), + text("t1"), + SqlValue::Json(json!(["a", "b"])), + SqlValue::Bool(false) + ], + vec![ + text("t2"), + SqlValue::Long(2), + text("t2"), + SqlValue::Null, + SqlValue::Bool(true) + ], + ] + ); + let hierarchy_entities = db + .query("SELECT \"__entity_id3\", \"__entity_id\", \"__entity_type\" FROM \"cedar_entity_hierarchy\"") + .unwrap(); + assert_eq!( + hierarchy_entities, + vec![vec![text("h"), SqlValue::Long(5), text("s")]] + ); + // The id column exposed as an attribute must agree with the entity id. + let bad = Entities::from_json_str( + r#"[{"uid": {"type": "App::User", "id": "u9"}, "attrs": {"user_id": "u1", "config": {"a": true, "b": "x"}, "__entity_id2": "o"}, "parents": []}]"#, + None, + ) + .unwrap(); + let e = entities_to_sql(&bad, &schema, &config, &Postgres).expect_err("an error"); + assert!(e.to_string().contains("must equal the entity id"), "{e}"); + db.rollback().unwrap(); +} + #[test] fn rejects_bad_entities() { let src = std::fs::read_to_string("tests/schemas/kitchen_sink.cedarschema").unwrap(); @@ -126,6 +200,26 @@ fn rejects_bad_entities() { case(r#"[{"uid": {"type": "Nope", "id": "g"}, "attrs": {}, "parents": []}]"#) .contains("no table stores") ); + // Entity references must be of the referenced type; strings and references + // do not mix. + assert!( + case(r#"[{"uid": {"type": "Doc", "id": "d"}, "attrs": {"owner": {"__entity": {"type": "Group", "id": "g"}}}, "parents": []}]"#) + .contains("referencing User") + ); + assert!( + case( + r#"[{"uid": {"type": "Doc", "id": "d"}, "attrs": {"owner": "alice"}, "parents": []}]"# + ) + .contains("referencing User") + ); + assert!( + case(r#"[{"uid": {"type": "Group", "id": "g"}, "attrs": {}, "parents": [], "tags": {}}, {"uid": {"type": "User", "id": "u"}, "attrs": {"name": {"__entity": {"type": "User", "id": "x"}}, "admin": true, "groups": [], "profile": {"city": "", "pets": []}, "friends": []}, "parents": []}]"#) + .contains("cannot be stored in a column of type Text") + ); + assert!( + case(r#"[{"uid": {"type": "Doc", "id": "d"}, "attrs": {"owner": {"__entity": {"type": "User", "id": "u"}}}, "parents": [], "tags": {"t": {"__entity": {"type": "Group", "id": "g"}}}}]"#) + .contains("referencing User") + ); // A NUL character, which Postgres cannot store, is rejected up front. let nul = Entity::new_no_attrs( EntityUid::from_type_name_and_id("Group".parse().unwrap(), EntityId::new("a\0b")), diff --git a/tests/schemas/pk_and_refs.cedarschema b/tests/schemas/pk_and_refs.cedarschema new file mode 100644 index 0000000..3fdf33b --- /dev/null +++ b/tests/schemas/pk_and_refs.cedarschema @@ -0,0 +1,15 @@ +// The primary key is another column: the interface id column must stay unique +// for the foreign keys that reference it. +@sql_primary_key("id") +entity User = { id: String }; + +// A new, otherwise undefined entity id column. +@sql_entity_id_column("external_id") +entity Team = { name: String }; + +// A long table name with multibyte characters, whose constraint names need shortening. +@sql_table("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääääää") +@sql_tags_table("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaääääää_tags") +entity Doc = { owner: User, team: Team } tags User; + +action view appliesTo { principal: [User], resource: [Doc] };