diff --git a/laravel-lsp/src/database.rs b/laravel-lsp/src/database.rs index 58931cd7..79416104 100644 --- a/laravel-lsp/src/database.rs +++ b/laravel-lsp/src/database.rs @@ -334,9 +334,16 @@ impl DatabaseSchemaProvider { /// completion paths against a known schema without a live MySQL / /// Postgres. The cache is the same one `get_schema` reads, so calls /// to `get_tables` / `get_columns_with_types` will see this data - /// immediately. Gated to test builds — no production caller should - /// be poking the cache manually. - #[cfg(test)] + /// immediately. No production caller should be poking the cache manually. + /// + /// Exposed as `#[doc(hidden)] pub` rather than `#[cfg(test)]`: the + /// `main.rs` binary's completion-handler integration tests + /// (`src/tests/query_chain_completion_handler.rs`) need to seed schema + /// from a separate crate, and Cargo does not enable `cfg(test)` on a + /// library consumed as a dependency — a `#[cfg(test)]` seam would be + /// invisible to that bin test crate. Hidden from docs so it stays off the + /// public API surface. + #[doc(hidden)] pub async fn set_test_schema(&self, schema: DatabaseSchema) { *self.schema_cache.write().await = Some(schema); } diff --git a/laravel-lsp/src/tests/mod.rs b/laravel-lsp/src/tests/mod.rs index 2835f11b..3b9fdb51 100644 --- a/laravel-lsp/src/tests/mod.rs +++ b/laravel-lsp/src/tests/mod.rs @@ -28,6 +28,7 @@ mod inertia_handler; mod livewire_component_resolution; mod livewire_tag_navigation_containment; mod loop_variable_resolution; +mod query_chain_completion_handler; mod rename_integration; mod route_binding_resolution; mod route_diagnostics; diff --git a/laravel-lsp/src/tests/query_chain_completion_handler.rs b/laravel-lsp/src/tests/query_chain_completion_handler.rs new file mode 100644 index 00000000..745b5a86 --- /dev/null +++ b/laravel-lsp/src/tests/query_chain_completion_handler.rs @@ -0,0 +1,204 @@ +//! End-to-end coverage for the relationship-hop wiring in the *completion +//! handler* (issue #219, follow-up to #211 / PR #215). +//! +//! The unit tests in `query_chain::eloquent_completion::tests` exercise +//! `apply_relation_method_hops` + `columns_for_collection` directly with a +//! hand-built `ChainContext`, and the diagnostics path is covered end-to-end +//! through `chain_diagnostics`. What was *not* covered is the glue in +//! `main.rs::try_query_chain_completion` that dispatches +//! `apply_relation_method_hops` inside the real `textDocument/completion` +//! flow. A bad re-wire of that block (wrong call order, missing dispatch, lost +//! `effective_model`) would slip past every existing test. +//! +//! These tests drive the real completion entry point with the property-access +//! receiver shape `$user->competitions->where('|')`, mirroring the +//! diagnostics-side end-to-end fixtures added in PR #215. They build a genuine +//! `LaravelLanguageServer` backend, prime its `initialized_root` with a tempdir +//! of `User`/`Competition` model files and its `database_schema` with a seeded +//! provider where `type` lives on `competitions` but not on `users`, then +//! assert the related table's columns are offered. Commenting out the +//! `apply_relation_method_hops` dispatch block in `main.rs` makes both tests +//! fail — they are regression guards for the wiring, not coverage theatre. + +use crate::LaravelLanguageServer; +use laravel_lsp::database::{DatabaseSchema, DatabaseSchemaProvider}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; +use tempfile::TempDir; +use tower_lsp::lsp_types::{Position, Url}; +use tower_lsp::LspService; + +/// A `User` model with a single `competitions` hasMany relation — the issue +/// #211 shape, where the relation's table carries columns the parent's doesn't. +const USER_WITH_COMPETITIONS: &str = r#"hasMany(Competition::class); } +} +"#; + +/// The related `Competition` model. Empty body — its only job is to exist on +/// disk so the relation hop resolves `User::competitions` → `Competition`. +const COMPETITION_MODEL: &str = r#" LaravelLanguageServer { + let (service, _socket) = LspService::new(LaravelLanguageServer::new); + service.inner().clone() +} + +/// Tempdir with `User.php` + `Competition.php` under the standard Laravel +/// `app/Models/` location. Returns the tempdir (hold it to keep the dir alive) +/// and the project root. +fn project_with_models(models: &[(&str, &str)]) -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("tempdir"); + let models_dir = dir.path().join("app").join("Models"); + std::fs::create_dir_all(&models_dir).expect("create models dir"); + for (name, body) in models { + std::fs::write(models_dir.join(format!("{name}.php")), body).expect("write model"); + } + let root = dir.path().to_path_buf(); + (dir, root) +} + +/// Seed a `DatabaseSchemaProvider` directly from in-memory fixtures — no live +/// DB. Uses the `set_test_schema` seam (reachable cross-crate from this bin +/// test, hence `#[doc(hidden)]` rather than `#[cfg(test)]` on the lib side). +async fn provider_with( + root: PathBuf, + tables: &[(&str, &[(&str, &str)])], +) -> DatabaseSchemaProvider { + let mut columns = HashMap::new(); + let mut columns_with_types = HashMap::new(); + let mut table_names = Vec::new(); + for (table, cols) in tables { + table_names.push(table.to_string()); + columns.insert( + table.to_string(), + cols.iter().map(|(n, _)| n.to_string()).collect(), + ); + columns_with_types.insert( + table.to_string(), + cols.iter() + .map(|(n, t)| (n.to_string(), t.to_string())) + .collect(), + ); + } + let schema = DatabaseSchema { + tables: table_names, + columns, + columns_with_types, + cached_at: Instant::now(), + }; + let provider = DatabaseSchemaProvider::new(root); + provider.set_test_schema(schema).await; + provider +} + +/// Build a backend primed for the issue #211 fixtures: `User`/`Competition` +/// models on disk, and a schema where `type` lives on `competitions` but not on +/// `users` (which only carries `email`). The asymmetry is what lets the tests +/// prove the hop advanced `effective_model` from `User` to `Competition`. +async fn competitions_backend() -> (TempDir, LaravelLanguageServer) { + let (dir, root) = project_with_models(&[ + ("User", USER_WITH_COMPETITIONS), + ("Competition", COMPETITION_MODEL), + ]); + let db = provider_with( + root.clone(), + &[ + ("users", &[("id", "int"), ("email", "string")]), + ("competitions", &[("id", "int"), ("type", "string")]), + ], + ) + .await; + + let server = test_server(); + *server.initialized_root.write().await = Some(root); + *server.database_schema.write().await = Some(db); + (dir, server) +} + +/// Build `(content, cursor position)` from a template carrying a single `◊` +/// marker at the desired cursor. The marker is stripped from the returned +/// content; the position is its 0-based line + code-point column, which is what +/// `try_query_chain_completion` expects. +fn cursor_at(template: &str) -> (String, Position) { + const MARK: char = '◊'; + let byte_idx = template + .find(MARK) + .expect("template must contain the ◊ cursor marker"); + let before = &template[..byte_idx]; + let line = before.matches('\n').count() as u32; + let character = before.rsplit('\n').next().unwrap_or(before).chars().count() as u32; + (template.replace(MARK, ""), Position { line, character }) +} + +/// `$user->competitions->where('|')` — `$user` typed as `User` via the `@var` +/// docblock, the cursor inside the (empty) `where` column argument. This is the +/// shape that, after the relation hop, must complete the *competitions* table's +/// columns. +const PROPERTY_RECEIVER_HOP: &str = "competitions->where('◊')->get();\n"; + +/// Run the real completion entry point and return the offered labels. +async fn completion_labels(server: &LaravelLanguageServer, template: &str) -> Vec { + let (content, position) = cursor_at(template); + let uri = Url::from_file_path("/tmp/watson-219-fixture/Controller.php") + .expect("absolute path → file uri"); + let items = server + .try_query_chain_completion(&content, position, &uri) + .await + .expect("completion should resolve a chain-completion result, not None"); + items.into_iter().map(|i| i.label).collect() +} + +#[tokio::test] +async fn property_receiver_hop_offers_related_table_columns() { + // `$user->competitions->where('|')` resolves to a Collection over + // Competition once the relation hop advances `effective_model` from User to + // Competition. The completion must then offer the *competitions* table's + // columns. `type` lives only on `competitions`, so its presence proves the + // hop fired through the real `main.rs` dispatch. With the dispatch block + // commented out, `effective_model` stays `User`, the users table is queried, + // and `type` never appears — this assertion fails, as a regression guard + // should. + let (_dir, server) = competitions_backend().await; + let labels = completion_labels(&server, PROPERTY_RECEIVER_HOP).await; + + assert!( + labels.iter().any(|l| l == "type"), + "completion after the relation hop must offer the competitions column \ + `type`; got {labels:?}" + ); +} + +#[tokio::test] +async fn property_receiver_hop_excludes_parent_only_columns() { + // The mirror assertion: `email` lives only on `users`. Once the hop advances + // `effective_model` to Competition, the users table is no longer the one + // being completed, so `email` must NOT appear. If the + // `apply_relation_method_hops` dispatch block is commented out, the model + // stays `User`, the users table is queried, and `email` leaks into the + // results — this assertion fails, proving it guards the wiring rather than + // just exercising it. + let (_dir, server) = competitions_backend().await; + let labels = completion_labels(&server, PROPERTY_RECEIVER_HOP).await; + + assert!( + !labels.iter().any(|l| l == "email"), + "a column present only on the parent `users` table must not survive the \ + hop to Competition; `email` leaked: {labels:?}" + ); +}