From 0dd5301c99f23756d7ec9c3383e77dff1c863b73 Mon Sep 17 00:00:00 2001 From: YoEight Date: Sun, 17 May 2026 01:57:35 -0400 Subject: [PATCH] feat: support open record type --- src/arena.rs | 37 ++++++++++---- src/tests/analysis.rs | 25 ++++++++++ src/tests/resources/project_event_decls.eql | 2 + ..._tests__analysis__project_event_decls.snap | 48 +++++++++++++++++++ src/typing/analysis.rs | 40 ++++------------ src/typing/mod.rs | 5 +- 6 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 src/tests/resources/project_event_decls.eql create mode 100644 src/tests/snapshots/eventql_parser__tests__analysis__project_event_decls.snap diff --git a/src/arena.rs b/src/arena.rs index 4f70836..72efd0f 100644 --- a/src/arena.rs +++ b/src/arena.rs @@ -213,10 +213,13 @@ impl TypeArena { /// Allocates a fresh copy of a type. For records, this clones the record definition. pub fn alloc_type(&mut self, tpe: Type) -> Type { if let Type::Record(rec) = tpe { - let key = Record(self.records.len()); + let key = Record { + id: self.records.len(), + open: rec.open, + }; // TODO: technically, a deep-clone is needed here, where properties that point to // records should also be allocated as well. - self.records.push(self.records[rec.0].clone()); + self.records.push(self.records[rec.id].clone()); return Type::Record(key); } @@ -231,7 +234,21 @@ impl TypeArena { /// Allocates a new record type from a map of field names to types. pub fn alloc_record(&mut self, record: FxHashMap) -> Record { - let key = Record(self.records.len()); + let key = Record { + id: self.records.len(), + open: false, + }; + self.records.push(record); + key + } + + /// Allocates a new open record type from a map of field names to types. An open record type is + /// a record type that doesn't fail typechecking if we try to access a field that isn't defined yet. + pub fn alloc_open_record(&mut self, record: FxHashMap) -> Record { + let key = Record { + id: self.records.len(), + open: true, + }; self.records.push(record); key } @@ -259,7 +276,7 @@ impl TypeArena { /// Returns the field map for the given record. pub fn get_record(&self, key: Record) -> &FxHashMap { - &self.records[key.0] + &self.records[key.id] } /// Returns the argument type slice for the given [`ArgsRef`]. @@ -279,7 +296,7 @@ impl TypeArena { /// Returns the type of a field in the given record, or `None` if the field doesn't exist. pub fn record_get(&self, record: Record, field: StrRef) -> Option { - self.records[record.0].get(&field).copied() + self.records[record.id].get(&field).copied() } /// Checks whether two records have the exact same set of field names. @@ -304,19 +321,19 @@ impl TypeArena { true } - /// Creates an empty record type. - pub fn instantiate_record(&mut self) -> Record { - self.alloc_record(FxHashMap::default()) + /// Creates an empty open record type. + pub fn instantiate_open_record(&mut self) -> Record { + self.alloc_open_record(FxHashMap::default()) } /// Sets the type of a field in the given record, inserting or updating as needed. pub fn record_set(&mut self, record: Record, field: StrRef, value: Type) { - self.records[record.0].insert(field, value); + self.records[record.id].insert(field, value); } /// Returns the number of fields in the given record. pub fn record_len(&self, record: Record) -> usize { - self.records[record.0].len() + self.records[record.id].len() } } diff --git a/src/tests/analysis.rs b/src/tests/analysis.rs index faf27a4..74a0dd2 100644 --- a/src/tests/analysis.rs +++ b/src/tests/analysis.rs @@ -448,3 +448,28 @@ fn test_ids_in_group_by_should_pass() { .map(|q| q.view(&session.arena)) })); } + +#[test] +fn test_project_event_decls() { + let mut builder = Session::builder().use_stdlib(); + + builder + .declare_type() + .define_record() + .prop("id", Type::String) + .prop("name", Type::String) + .prop("version", Type::String) + .prop("summary", Type::String) + .prop("schema", Type::Unspecified) + .for_data_source("command_decls"); + + let mut session = builder.build(); + + let query = session.parse(include_str!("./resources/project_event_decls.eql")); + + insta::assert_yaml_snapshot!(query.and_then(|q| { + session + .run_static_analysis(q) + .map(|q| q.view(&session.arena)) + })); +} diff --git a/src/tests/resources/project_event_decls.eql b/src/tests/resources/project_event_decls.eql new file mode 100644 index 0000000..23bc605 --- /dev/null +++ b/src/tests/resources/project_event_decls.eql @@ -0,0 +1,2 @@ +FROM c IN command_decls +PROJECT INTO c.schema.properties \ No newline at end of file diff --git a/src/tests/snapshots/eventql_parser__tests__analysis__project_event_decls.snap b/src/tests/snapshots/eventql_parser__tests__analysis__project_event_decls.snap new file mode 100644 index 0000000..91b038f --- /dev/null +++ b/src/tests/snapshots/eventql_parser__tests__analysis__project_event_decls.snap @@ -0,0 +1,48 @@ +--- +source: src/tests/analysis.rs +expression: "query.and_then(|q|\n{ session.run_static_analysis(q).map(|q| q.view(&session.arena)) })" +--- +Ok: + attrs: + pos: + line: 1 + col: 1 + sources: + - binding: + name: c + pos: + line: 1 + col: 6 + kind: + Name: command_decls + predicate: ~ + group_by: ~ + order_by: ~ + limit: ~ + projection: + attrs: + pos: + line: 2 + col: 14 + value: + Access: + target: + attrs: + pos: + line: 2 + col: 14 + value: + Access: + target: + attrs: + pos: + line: 2 + col: 14 + value: + Id: c + field: schema + field: properties + distinct: false + meta: + project: Unspecified + aggregate: false diff --git a/src/typing/analysis.rs b/src/typing/analysis.rs index e7b5052..fdef1a9 100644 --- a/src/typing/analysis.rs +++ b/src/typing/analysis.rs @@ -1136,8 +1136,6 @@ impl<'a> Analysis<'a> { ) -> AnalysisResult { struct State { depth: u8, - /// When true means we are into dynamically type object. - dynamic: bool, definition: Def, } @@ -1145,7 +1143,6 @@ impl<'a> Analysis<'a> { fn new(definition: Def) -> Self { Self { depth: 0, - dynamic: false, definition, } } @@ -1183,7 +1180,7 @@ impl<'a> Analysis<'a> { } } else if let Some(tpe) = scope.get_mut(id) { if matches!(tpe, Type::Unspecified) { - let record = arena.types.instantiate_record(); + let record = arena.types.instantiate_open_record(); *tpe = Type::Record(record); Ok(State::new(Def::User { @@ -1217,23 +1214,12 @@ impl<'a> Analysis<'a> { } } Value::Access(access) => { - let mut state = go(scope, arena, sys, access.target)?; - - // TODO - we should consider make that field and depth configurable. - let is_data_field = - state.depth == 0 && arena.strings.get(access.field) == "data"; - - // TODO - we should consider make that behavior configurable. - // the `data` property is where the JSON payload is located, which means - // we should be lax if a property is not defined yet. - if !state.dynamic && is_data_field { - state.dynamic = true; - } + let state = go(scope, arena, sys, access.target)?; match state.definition { Def::User { parent, tpe } => { - if matches!(tpe, Type::Unspecified) && state.dynamic { - let record = arena.types.instantiate_record(); + if matches!(tpe, Type::Unspecified) { + let record = arena.types.instantiate_open_record(); arena .types .record_set(record, access.field, Type::Unspecified); @@ -1256,7 +1242,6 @@ impl<'a> Analysis<'a> { }, tpe: Type::Unspecified, }, - ..state }); } else if let Type::Record(record) = tpe { return if let Some(tpe) = @@ -1271,11 +1256,9 @@ impl<'a> Analysis<'a> { }, tpe, }, - ..state }) } else { - // TODO - that test seems useless because it can't be the data field and not be dynamic - if state.dynamic || is_data_field { + if record.open { arena.types.record_set( record, access.field, @@ -1290,7 +1273,6 @@ impl<'a> Analysis<'a> { }, tpe: Type::Unspecified, }, - ..state }); } @@ -1310,11 +1292,10 @@ impl<'a> Analysis<'a> { } Def::System(tpe) => { - if matches!(tpe, Type::Unspecified) && state.dynamic { + if matches!(tpe, Type::Unspecified) { return Ok(State { depth: state.depth + 1, definition: Def::System(Type::Unspecified), - ..state }); } @@ -1323,7 +1304,6 @@ impl<'a> Analysis<'a> { return Ok(State { depth: state.depth + 1, definition: Def::System(field), - ..state }); } @@ -1511,8 +1491,8 @@ impl Arena { } (Type::Record(a), Type::Record(b)) if self.types.records_have_same_keys(a, b) => { - let mut map_a = mem::take(&mut self.types.records[a.0]); - let mut map_b = mem::take(&mut self.types.records[b.0]); + let mut map_a = mem::take(&mut self.types.records[a.id]); + let mut map_b = mem::take(&mut self.types.records[b.id]); for (bk, bv) in map_b.iter_mut() { let av = map_a.get_mut(bk).unwrap(); @@ -1522,8 +1502,8 @@ impl Arena { *bv = new_tpe; } - self.types.records[a.0] = map_a; - self.types.records[b.0] = map_b; + self.types.records[a.id] = map_a; + self.types.records[b.id] = map_b; Ok(Type::Record(a)) } diff --git a/src/typing/mod.rs b/src/typing/mod.rs index 1e8b67b..4d9f67c 100644 --- a/src/typing/mod.rs +++ b/src/typing/mod.rs @@ -8,7 +8,10 @@ pub struct TypeRef(pub(crate) usize); /// A reference to a record definition stored in the [`TypeArena`](crate::arena::TypeArena). #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] -pub struct Record(pub(crate) usize); +pub struct Record { + pub(crate) id: usize, + pub(crate) open: bool, +} /// A reference to a function argument type list stored in the [`TypeArena`](crate::arena::TypeArena). #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)]