feat: create m:n relationship convenience command (C4, ADR-0045)

`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.

Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).

Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
  candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
  mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
  pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.

Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-06-10 14:26:33 +00:00
parent e598008ecf
commit 8bd43ccadf
28 changed files with 1273 additions and 26 deletions
+418
View File
@@ -0,0 +1,418 @@
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
//!
//! Covers parse, junction generation (columns / compound PK / two
//! enforced FKs), the `as <name>` override, a compound-PK parent,
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
//! parent guard.
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.expect("db");
(project, db, dir)
}
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence_and_undo(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
true,
)
.expect("db");
(project, db, dir)
}
/// A parent table `(id serial PK, label text)` — the `label` gives an
/// insertable non-PK column (a serial-PK-only table has nothing to put
/// in a short-form INSERT).
async fn serial_pk_table(db: &Database, name: &str) {
db.create_table(
name.to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap_or_else(|e| panic!("create {name}: {e}"));
}
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
async fn add_row(db: &Database, table: &str, label: &str) {
db.insert(
table.to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text(label.to_string())],
None,
)
.await
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
}
// ---- parse layer -----------------------------------------------
#[test]
fn parses_to_create_m2n_relationship() {
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
Command::CreateM2nRelationship { t1, t2, name } => {
assert_eq!(t1, "Students");
assert_eq!(t2, "Courses");
assert_eq!(name, None);
}
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
#[test]
fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses")
{
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
// ---- junction generation ---------------------------------------
#[test]
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("create m:n");
// Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!(
cols,
vec![("Students_id", true), ("Courses_id", true)],
"expected two FK columns forming the compound PK"
);
// Two outbound relationships (one per parent).
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
// FK enforcement: a junction row needs existing parents.
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.expect("valid link");
// Duplicate link refused by the compound PK.
let dup = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await;
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
// A link to a non-existent parent is refused by the FK.
let orphan = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
None,
)
.await;
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
});
}
#[test]
fn as_name_overrides_the_junction_table_name() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("Enrollments".to_string()),
None,
)
.await
.expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
});
}
#[test]
fn compound_parent_pk_contributes_one_fk_column_each() {
let (_p, db, _d) = open();
rt().block_on(async {
// Sections has a 2-column PK (course_id, term).
db.create_table(
"Sections".to_string(),
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
vec!["course_id".to_string(), "term".to_string()],
None,
)
.await
.unwrap();
serial_pk_table(&db, "Students").await;
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK.
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
});
}
#[test]
fn deleting_a_parent_cascades_to_the_junction() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
});
}
#[test]
fn create_m2n_is_one_undo_step() {
let (_p, db, _d) = open_with_undo();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
// A real source makes the command undoable (a source-less call is
// treated as an internal, non-undoable op).
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
// One undo removes the junction table AND both relationships.
db.undo().await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string(), None).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
});
}
// ---- guards ----------------------------------------------------
#[test]
fn self_referential_m2n_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Users").await;
let err = db
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await
.expect_err("self m:n must be refused");
assert!(format!("{err}").contains("two different tables"), "got: {err}");
});
}
#[test]
fn missing_parent_table_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
let err = db
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
.await
.expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table).
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
});
}
#[test]
fn junction_name_collision_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("first m:n");
// A second identical m:n collides on the auto-name `Students_Courses`.
let err = db
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect_err("a junction-name collision must be refused");
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
});
}
// ---- the junction is a normal table ----------------------------
#[test]
fn the_junction_can_be_renamed() {
// C4 requirement text: "an auto-named junction table the user can
// rename." It is a normal table, so `rename table` works.
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
});
}
#[test]
fn junction_survives_save_and_rebuild() {
// Persistence round-trip: the junction + both relationships are
// reconstructed from project.yaml after the .db is discarded.
let dir = tempfile::tempdir().expect("tempdir");
let project_path = {
let project = project::open_or_create(None, Some(dir.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
});
}
#[test]
fn as_an_internal_name_is_refused() {
// The junction must be a real, listable table — an `as __rdbms_*`
// name would be filtered out of `list_tables` (a hidden orphan).
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
let err = db
.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("__rdbms_evil".to_string()),
None,
)
.await
.expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}");
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
});
}
#[test]
fn pk_less_parent_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
// A PK-less table via the advanced SQL path.
db.sql_create_table(
"Loose".to_string(),
vec![ColumnSpec::new("a", Type::Int)],
vec![],
vec![],
vec![],
vec![],
false,
None,
)
.await
.unwrap();
let err = db
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
.await
.expect_err("a PK-less parent must be refused");
assert!(format!("{err}").contains("no primary key"), "got: {err}");
});
}
+1
View File
@@ -19,6 +19,7 @@ mod iteration4a_rebuild_command;
mod iteration4b_lifecycle_commands;
mod iteration5_export_import;
mod iteration6_resume_history;
mod m2n;
mod parse_error_pedagogy;
mod project_lifecycle;
mod replay_command;
+73
View File
@@ -0,0 +1,73 @@
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
//! completion candidates, ambient hint, highlighting, and parse state —
//! at each stage, so a regression in any of those surfaces is caught.
use crate::typing_surface::*;
use rdbms_playground::input_render::InputState;
#[test]
fn after_create_offers_table_and_m2n() {
let schema = schema_multi_table();
let a = assess_at_end("create ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
// `create` branches to `table` (create table) or the `m:n` composite.
assert_candidate_present(&a, &["table", "m:n"]);
crate::snap!("after_create", a);
}
#[test]
fn m2n_relationship_keyword_sequence_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["from"]);
crate::snap!("after_relationship_keyword", a);
}
#[test]
fn after_from_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_from", a);
}
#[test]
fn after_to_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_to", a);
}
#[test]
fn complete_create_m2n_parses() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("complete", a);
}
#[test]
fn create_m2n_with_as_name_parses() {
let schema = schema_multi_table();
let a = assess_at_end(
"create m:n relationship from Customers to Orders as CustomerOrders",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("with_as_name", a);
}
#[test]
fn after_as_keyword_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("after_as", a);
}
+2
View File
@@ -35,6 +35,7 @@ pub mod create_table;
pub mod drop_column;
pub mod drop_relationship;
pub mod add_relationship;
pub mod create_m2n;
pub mod index_ops;
pub mod constraints;
pub mod rename_change_column;
@@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
RenameColumn { .. } => "RenameColumn".into(),
ChangeColumnType { .. } => "ChangeColumnType".into(),
AddRelationship { .. } => "AddRelationship".into(),
CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
DropRelationship { .. } => "DropRelationship".into(),
AddIndex { .. } => "AddIndex".into(),
DropIndex { .. } => "DropIndex".into(),
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 72
description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as ",
cursor: 52,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 16
description: "input=\"create \" cursor=7"
expression: "& a"
---
Assessment {
input: "create ",
cursor: 7,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
7,
7,
),
partial_prefix: "",
candidates: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 34
description: "input=\"create m:n relationship from \" cursor=29"
expression: "& a"
---
Assessment {
input: "create m:n relationship from ",
cursor: 29,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
29,
29,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 43
description: "input=\"create m:n relationship from Customers to \" cursor=42"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to ",
cursor: 42,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
42,
42,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 52
description: "input=\"create m:n relationship from Customers to Orders\" cursor=48"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders",
cursor: 48,
state: Valid,
hint: Some(
Prose(
"Submit with Enter",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 64
description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as CustomerOrders",
cursor: 66,
state: Valid,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,42 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 25
description: "input=\"create m:n relationship \" cursor=24"
expression: "& a"
---
Assessment {
input: "create m:n relationship ",
cursor: 24,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
24,
24,
),
partial_prefix: "",
candidates: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -1,5 +1,6 @@
---
source: tests/typing_surface/create_table.rs
assertion_line: 13
description: "input=\"create \" cursor=7"
expression: "& a"
---
@@ -13,6 +14,11 @@ Assessment {
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
@@ -30,6 +36,11 @@ Assessment {
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
@@ -1,5 +1,6 @@
---
source: tests/typing_surface/create_table.rs
assertion_line: 48
description: "input=\"create table Customers with \" cursor=28"
expression: "& a"
---
@@ -13,7 +14,7 @@ Assessment {
Candidate {
text: "pk",
kind: Keyword,
mode: Both,
mode: Simple,
},
],
selected: None,
@@ -30,7 +31,7 @@ Assessment {
Candidate {
text: "pk",
kind: Keyword,
mode: Both,
mode: Simple,
},
],
},