From 2053256fca325c38c250ae2a2658d8218e4310df Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 13:44:41 +0600 Subject: [PATCH] feat(laravel): infer string keys for UUID and ULID models Recognize HasUuids and HasUlids through composed traits and parent models when resolving implicit primary keys. Preserve existing property precedence and custom key names. Add provider and LSP regression tests, Laravel demos with runtime assertions, and changelog documentation. Remove the completed backlog item. --- docs/CHANGELOG.md | 1 + docs/todo.md | 1 - docs/todo/laravel.md | 25 -- examples/laravel/app/Demo.php | 12 + examples/laravel/app/Models/BakeryOrder.php | 11 + examples/laravel/app/Models/Delivery.php | 13 + examples/laravel/assertions.php | 22 ++ src/mem_audit.rs | 3 + src/types/mod.rs | 6 + src/virtual_members/laravel/mod.rs | 10 +- .../laravel/model_extraction.rs | 46 ++++ src/virtual_members/laravel/unique_ids.rs | 102 +++++++ .../laravel/unique_ids_tests.rs | 249 ++++++++++++++++++ tests/integration/laravel_unique_ids.rs | 218 +++++++++++++++ tests/integration/main.rs | 1 + 15 files changed, 692 insertions(+), 28 deletions(-) create mode 100644 examples/laravel/app/Models/BakeryOrder.php create mode 100644 examples/laravel/app/Models/Delivery.php create mode 100644 src/virtual_members/laravel/unique_ids.rs create mode 100644 src/virtual_members/laravel/unique_ids_tests.rs create mode 100644 tests/integration/laravel_unique_ids.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c014f86ab..27786adad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Directories reached through a symlink are indexed with the rest of the project.** A project that keeps its framework or a shared library outside the repository and links it into the tree (`kdhelp -> ../kdhelp`, say) now resolves the symbols there like any other project code. Indexed paths keep the symlink spelling, so a file reached through the link is the same file the editor opened, and a tree is indexed once however many links lead to it. Changes another tool writes inside a linked directory are picked up as well on editors that can watch a path outside the project; where they cannot, such a change needs a window reload, while a file open in the editor always re-parses as it is edited. Contributed by @liudashuang. Closes #383. +- **UUID and ULID model keys resolve as strings.** Models using Laravel's `HasUuids` or `HasUlids` traits now expose their primary keys as `string` in completion, hover, and type checking, including traits inherited from parent models or composed through other traits. Custom primary-key names are respected, and a model whose `uniqueIds()` generates a different column keeps its integer key. Contributed by @shuvroroy. - **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. - **Qualified names can be converted to imports in one action.** Invoke the refactoring on an absolute or relative qualified class, function, or constant to add the matching `use`, `use function`, or `use const` declaration and shorten every equivalent usage in the file. When the natural short name is already imported from elsewhere, the new import receives a namespace-derived alias instead. A companion action on the same cursor position does the whole namespace at once, importing every qualified class, function, and constant it contains and aliasing the ones whose short names collide. Contributed by @calebdw. diff --git a/docs/todo.md b/docs/todo.md index 11d725cb6..a18c69c9c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -173,7 +173,6 @@ unlikely to move the needle for most users. | L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium | | L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Medium | | L3 | `$dates` array (deprecated) | Low-Medium | Medium | -| L12 | [`HasUuids` / `HasUlids` trait — `$id` typed as `string`](todo/laravel.md#l12-hasuuids-hasulids-trait-id-typed-as-string) | Low-Medium | Medium | | L44 | [Sibling resource registrations and degenerate resource names](todo/laravel.md#l44-sibling-resource-registrations-and-degenerate-resource-names) | Low-Medium | Medium | | L50 | ["Create route" quick-fix for an unresolved route name](todo/laravel.md#l50-create-route-quick-fix-for-an-unresolved-route-name) | Low-Medium | Medium | | L47 | [Morph aliases in `*_type` column comparisons](todo/laravel.md#l47-morph-aliases-in-_type-column-comparisons) | Low-Medium | Medium-High | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index c0308c620..57570c63e 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -354,31 +354,6 @@ methods, or document this as a known limitation. --- -#### L12. `HasUuids` / `HasUlids` trait — `$id` typed as `string` - -**Impact: Low-Medium · Complexity: Medium** - -Models that use `Illuminate\Database\Eloquent\Concerns\HasUuids` or -`HasUlids` have their primary key (`$id` by default) typed as -`string` instead of `int`. Currently PHPantom does not inspect these -traits, so `$model->id` resolves to `int` (from the default Model -stub) instead of `string`. - -Larastan's `bug-2188.php` tests this: `assertType('string', $uuidModel->id)`. - -**Where to change:** In `LaravelModelProvider::provide`, after -synthesizing other virtual properties, check whether the model's -`used_traits` (recursively, including parent traits) contains -`HasUuids` or `HasUlids`. If so, synthesize a virtual `id` property -typed as `string` (or override the existing one). The trait also -overrides `getKeyType()` to return `'string'` and -`getIncrementing()` to return `false`, but for virtual property -purposes just the `id` type is the main gap. - -Alternatively, if the stubs for these traits include `@property` -tags or a typed `$id` override, the PHPDoc provider may handle it -automatically once the traits are loaded. - #### L47. Morph aliases in `*_type` column comparisons **Impact: Low-Medium · Complexity: Medium-High** diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index d90eb2237..68d2f1d31 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -16,10 +16,12 @@ use App\Http\Requests\UpdateBakeryRequest; use App\Models\Baker; use App\Models\Bakery; +use App\Models\BakeryOrder; use App\Models\BlogAuthor; use App\Models\BlogPost; use App\Models\Customer; use App\Models\Danish; +use App\Models\Delivery; use App\Models\Loaf; use App\Models\PostCollection; use App\Models\Review; @@ -55,6 +57,16 @@ class Demo { + // Try: hover or complete the primary keys. HasUuids and HasUlids + // make them strings without a $keyType override or @property tag. + public function uniqueIdentifiers(BakeryOrder $order, Delivery $delivery): string + { + $orderId = $order->id; // HasUuids → string + $trackingId = $delivery->tracking_id; // HasUlids, custom primary key → string + + return $orderId . ':' . $trackingId; + } + // ── Eloquent Virtual Properties ───────────────────────────────────────── // Alphabetical — every property a through w should appear in order. // Trigger completion on `$bakery->` and scan the list. diff --git a/examples/laravel/app/Models/BakeryOrder.php b/examples/laravel/app/Models/BakeryOrder.php new file mode 100644 index 000000000..6e9aada14 --- /dev/null +++ b/examples/laravel/app/Models/BakeryOrder.php @@ -0,0 +1,11 @@ +setUniqueIds(); +$ulidDelivery->setUniqueIds(); + +check('HasUuids generates a string id', is_string($uuidOrder->id)); +check('HasUuids generates a valid UUID', \Illuminate\Support\Str::isUuid($uuidOrder->id)); +check('HasUuids overrides the default key type', $uuidOrder->getKeyType() === 'string'); +check('HasUuids disables incrementing', $uuidOrder->getIncrementing() === false); +check('HasUlids respects a custom primary key', $ulidDelivery->getKeyName() === 'tracking_id'); +check('HasUlids generates a string key', is_string($ulidDelivery->tracking_id)); +check('HasUlids generates a valid ULID', \Illuminate\Support\Str::isUlid($ulidDelivery->tracking_id)); +check('HasUlids overrides the default key type', $ulidDelivery->getKeyType() === 'string'); +check('HasUlids disables incrementing', $ulidDelivery->getIncrementing() === false); +check( + 'Unique identifier demo reads both string keys', + (new \App\Demo())->uniqueIdentifiers($uuidOrder, $ulidDelivery) + === $uuidOrder->id . ':' . $ulidDelivery->tracking_id +); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 82b55c1c9..e9a2ea415 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -494,6 +494,9 @@ fn laravel_meta(l: &LaravelMetadata) -> Sz { } z += vs(&l.column_names); z.add(l.column_sources.capacity() * size_of::()); + if let Some(ids) = &l.unique_ids { + z += vs(ids); + } for o in [ &l.connection_name, &l.table_name, diff --git a/src/types/mod.rs b/src/types/mod.rs index 5ef337115..e340b04af 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1652,6 +1652,12 @@ pub struct LaravelMetadata { /// and cannot be resolved statically, so no implicit primary key /// property is synthesized. pub has_get_key_name_method: bool, + /// Columns returned by the model's own `uniqueIds()` override. + /// + /// - `None` — not declared, or not a literal list of strings. + /// - `Some(["uuid"])` — `HasUuids`/`HasUlids` generate these columns; + /// the primary key is only a string when it is listed. + pub unique_ids: Option>, /// Whether `$timestamps` is explicitly set on the model. /// /// - `None` — not declared (inherits the default, which is `true` diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 66f8618ab..917e9336d 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -76,7 +76,9 @@ //! - **Implicit primary key.** Every model exposes a primary key column //! (`id` by default, respecting `$primaryKey`/`$keyType` overrides) //! even when no schema or cast entry describes it, unless the model -//! overrides `getKeyName()`. +//! overrides `getKeyName()`. `HasUuids` and `HasUlids` make the key a +//! string, including when inherited or composed through other traits, +//! unless a `uniqueIds()` override leaves the key out. //! //! - **Timestamp properties.** `created_at`/`updated_at` (or their //! configured names) are added as the configured Laravel date class, @@ -136,6 +138,7 @@ mod scopes; mod storage; mod string_keys; mod trans_keys; +mod unique_ids; pub(crate) mod validated_shape; pub(crate) mod validation_rules; mod view_data; @@ -860,7 +863,10 @@ impl VirtualMemberProvider for LaravelModelProvider { if !laravel.has_get_key_name_method { let primary_key = laravel.primary_key.as_deref().unwrap_or("id"); if seen_props.insert(primary_key.to_string()) { - let php_type = if laravel.key_type.as_deref() == Some("string") { + let php_type = if laravel.key_type.as_deref() == Some("string") + || (unique_ids::uses_unique_string_ids(class, class_loader) + && unique_ids::unique_ids_include(class, primary_key, class_loader)) + { PhpType::string() } else { PhpType::int() diff --git a/src/virtual_members/laravel/model_extraction.rs b/src/virtual_members/laravel/model_extraction.rs index 6a2bf301e..1583d4a29 100644 --- a/src/virtual_members/laravel/model_extraction.rs +++ b/src/virtual_members/laravel/model_extraction.rs @@ -894,6 +894,46 @@ fn extract_facade_accessor<'a>( (!name.is_empty()).then(|| FacadeAccessor::Class(atom(name))) } +/// Extract the columns a model's own `uniqueIds()` override returns. +/// +/// Only a `return [...]` of string literals is statically knowable. Any +/// other shape (`[$this->getKeyName(), 'uuid']`, a spread of +/// `parent::uniqueIds()`, a computed value) yields `None`, as does a +/// model that does not declare the method. +fn extract_unique_ids<'a>( + members: impl Iterator>, + content: &str, +) -> Option> { + let method = members.into_iter().find_map(|member| match member { + class_like::member::ClassLikeMember::Method(method) + if bytes_to_str(method.name.value).eq_ignore_ascii_case("uniqueIds") => + { + Some(method) + } + _ => None, + })?; + let class_like::method::MethodBody::Concrete(block) = &method.body else { + return None; + }; + let value = block.statements.iter().find_map(|stmt| match stmt { + Statement::Return(ret) => ret.value, + _ => None, + })?; + let elements = match value { + Expression::Array(arr) => &arr.elements, + Expression::LegacyArray(arr) => &arr.elements, + _ => return None, + }; + elements + .iter() + .map(|element| match element { + ArrayElement::Value(v) => super::helpers::extract_string_literal(v.value, content) + .map(|(text, _, _)| text.to_string()), + _ => None, + }) + .collect() +} + fn extract_string_property<'a>( members: impl Iterator>, content: &str, @@ -1090,6 +1130,11 @@ pub(crate) fn extract_laravel_metadata<'a>( let has_get_key_name_method = methods .iter() .any(|m| m.name.eq_ignore_ascii_case("getKeyName")); + let unique_ids = methods + .iter() + .any(|m| m.name.eq_ignore_ascii_case("uniqueIds")) + .then(|| extract_unique_ids(class.members.iter(), content)) + .flatten(); let dates_definitions = extract_dates_definitions(class.members.iter(), content); @@ -1128,6 +1173,7 @@ pub(crate) fn extract_laravel_metadata<'a>( primary_key, key_type, has_get_key_name_method, + unique_ids, timestamps, created_at_name, updated_at_name, diff --git a/src/virtual_members/laravel/unique_ids.rs b/src/virtual_members/laravel/unique_ids.rs new file mode 100644 index 000000000..ba549c9d2 --- /dev/null +++ b/src/virtual_members/laravel/unique_ids.rs @@ -0,0 +1,102 @@ +//! Primary-key types supplied by Laravel's UUID and ULID traits. + +use std::sync::Arc; + +use crate::atom::AtomSet; +use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH, MAX_TRAIT_DEPTH}; + +use super::ELOQUENT_MODEL_FQN; + +/// Whether a model uses `HasUuids` or `HasUlids`, including through +/// composed traits and parent models. Base resolution retains only the +/// leaf class's trait names, so inspect the original declarations too. +pub(super) fn uses_unique_string_ids( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + let mut visited = AtomSet::default(); + if traits_use_unique_string_ids(class, class_loader, &mut visited, 0) { + return true; + } + + let mut parent_name = class.parent_class; + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(name) = parent_name else { + break; + }; + // The framework base model has no UUID/ULID trait. Avoid walking + // its internal concerns for every ordinary application model. + if name.eq_ignore_ascii_case(ELOQUENT_MODEL_FQN) { + break; + } + let Some(parent) = class_loader(&name) else { + break; + }; + if traits_use_unique_string_ids(&parent, class_loader, &mut visited, 0) { + return true; + } + parent_name = parent.parent_class; + } + false +} + +/// Whether the traits generate `key`, mirroring their `getKeyType()` +/// check of `in_array($this->getKeyName(), $this->uniqueIds())`. The +/// nearest `uniqueIds()` override decides; without a statically known +/// one, the traits' own `uniqueIds()` returns the primary key. +pub(super) fn unique_ids_include( + class: &ClassInfo, + key: &str, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + if let Some(ids) = class.laravel().and_then(|l| l.unique_ids.as_ref()) { + return ids.iter().any(|id| id == key); + } + let mut parent_name = class.parent_class; + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(name) = parent_name else { + break; + }; + if name.eq_ignore_ascii_case(ELOQUENT_MODEL_FQN) { + break; + } + let Some(parent) = class_loader(&name) else { + break; + }; + if let Some(ids) = parent.laravel().and_then(|l| l.unique_ids.as_ref()) { + return ids.iter().any(|id| id == key); + } + parent_name = parent.parent_class; + } + true +} + +fn traits_use_unique_string_ids( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, + visited: &mut AtomSet, + depth: u32, +) -> bool { + if depth >= MAX_TRAIT_DEPTH { + return false; + } + for name in &class.used_traits { + let fqn = name.trim_start_matches('\\'); + if fqn.eq_ignore_ascii_case("Illuminate\\Database\\Eloquent\\Concerns\\HasUuids") + || fqn.eq_ignore_ascii_case("Illuminate\\Database\\Eloquent\\Concerns\\HasUlids") + { + return true; + } + if visited.insert(*name) + && let Some(trait_class) = class_loader(name) + && traits_use_unique_string_ids(&trait_class, class_loader, visited, depth + 1) + { + return true; + } + } + false +} + +#[cfg(test)] +#[path = "unique_ids_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/unique_ids_tests.rs b/src/virtual_members/laravel/unique_ids_tests.rs new file mode 100644 index 000000000..b48ba3065 --- /dev/null +++ b/src/virtual_members/laravel/unique_ids_tests.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::atom::atom; +use crate::php_type::PhpType; +use crate::test_fixtures::{make_class, make_method, no_loader}; +use crate::types::{ClassLikeKind, PropertySource, Visibility}; +use crate::virtual_members::laravel::LaravelModelProvider; +use crate::virtual_members::laravel::database_schema::{SchemaIndex, parse_schema_dump}; +use crate::virtual_members::{VirtualMemberProvider, VirtualMembers, new_resolved_class_cache}; + +const UUIDS: &str = "Illuminate\\Database\\Eloquent\\Concerns\\HasUuids"; +const ULIDS: &str = "Illuminate\\Database\\Eloquent\\Concerns\\HasUlids"; + +fn model(traits: &[&str]) -> ClassInfo { + let mut class = make_class("App\\Models\\User"); + class.parent_class = Some(atom(ELOQUENT_MODEL_FQN)); + class.used_traits = traits.iter().map(|name| atom(name)).collect(); + class.laravel_mut(); + class +} + +fn assert_key(members: &VirtualMembers, name: &str, expected: &str) { + let keys: Vec<_> = members + .properties + .iter() + .filter(|p| p.name == name) + .collect(); + assert_eq!(keys.len(), 1, "expected one {name} property"); + assert_eq!(keys[0].type_hint_str().as_deref(), Some(expected)); + assert_eq!(keys[0].visibility, Visibility::Public); + assert!(!keys[0].is_static); +} + +#[test] +fn unique_ids_type_primary_keys_as_strings() { + for trait_name in [ + UUIDS, + ULIDS, + "\\Illuminate\\Database\\Eloquent\\Concerns\\hasuuids", + ] { + let mut class = model(&[trait_name]); + // The traits override the framework's default $keyType = 'int'. + class.laravel_mut().key_type = Some("int".into()); + assert!(uses_unique_string_ids(&class, &no_loader)); + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "string", + ); + + class.laravel_mut().primary_key = Some("identifier".into()); + let members = LaravelModelProvider.provide(&class, &no_loader, None); + assert_key(&members, "identifier", "string"); + assert!(members.properties.iter().all(|p| p.name != "id")); + } +} + +#[test] +fn unique_ids_follow_parent_models_and_composed_traits() { + for trait_name in [UUIDS, ULIDS] { + let mut nested = make_class("App\\Concerns\\Identifiers"); + nested.kind = ClassLikeKind::Trait; + nested.used_traits = vec![atom(trait_name)]; + let nested = Arc::new(nested); + let mut wrapper = make_class("App\\Concerns\\ModelTraits"); + wrapper.kind = ClassLikeKind::Trait; + wrapper.used_traits = vec![atom("App\\Concerns\\Missing"), nested.name]; + let wrapper = Arc::new(wrapper); + let mut parent = model(&[&wrapper.name]); + parent.name = atom("App\\Models\\BaseModel"); + let parent = Arc::new(parent); + let mut middle = model(&[]); + middle.name = atom("App\\Models\\Intermediate"); + middle.parent_class = Some(parent.name); + let middle = Arc::new(middle); + let loader = |name: &str| { + [&nested, &wrapper, &parent, &middle] + .into_iter() + .find(|class| class.name == name) + .map(Arc::clone) + }; + let mut class = model(&[]); + class.parent_class = Some(middle.name); + assert_key( + &LaravelModelProvider.provide(&class, &loader, None), + "id", + "string", + ); + class.parent_class = Some(atom(ELOQUENT_MODEL_FQN)); + class.used_traits = vec![wrapper.name]; + assert_key( + &LaravelModelProvider.provide(&class, &loader, None), + "id", + "string", + ); + } +} + +#[test] +fn unique_ids_do_not_match_unrelated_trait_names() { + let class = model(&["HasUuids", "App\\HasUuids", "App\\HasUlids"]); + assert!(!uses_unique_string_ids(&class, &no_loader)); + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "int", + ); + let mut plain = make_class("PlainClass"); + plain.used_traits = vec![atom(UUIDS)]; + assert!(!LaravelModelProvider.applies_to(&plain, &no_loader)); +} + +#[test] +fn unique_ids_preserve_explicit_attribute_types() { + for trait_name in [UUIDS, ULIDS] { + let mut class = model(&[trait_name]); + class.laravel_mut().casts_definitions = vec![("id".into(), "integer".into())]; + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "int", + ); + class.laravel_mut().casts_definitions.clear(); + class.laravel_mut().attributes_definitions = vec![("id".into(), PhpType::null())]; + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "null", + ); + class.laravel_mut().attributes_definitions.clear(); + class + .methods + .push(Arc::new(make_method("getIdAttribute", Some("int")))); + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "int", + ); + } +} + +#[test] +fn unique_ids_preserve_schema_metadata_and_dynamic_key_names() { + let mut class = model(&[UUIDS]); + let cache = new_resolved_class_cache(); + cache.write().set_schema_index(SchemaIndex::from_tables( + Some("primary".into()), + parse_schema_dump("primary", "CREATE TABLE users (id varchar(36) NOT NULL);"), + )); + let members = LaravelModelProvider.provide(&class, &no_loader, Some(&cache)); + assert_key(&members, "id", "string"); + assert!(matches!( + members + .properties + .iter() + .find(|p| p.name == "id") + .unwrap() + .source, + Some(PropertySource::DatabaseColumn { .. }) + )); + class.laravel_mut().has_get_key_name_method = true; + let members = LaravelModelProvider.provide(&class, &no_loader, None); + assert!(members.properties.iter().all(|p| p.name != "id")); +} + +#[test] +fn unique_ids_handle_missing_parents_and_trait_cycles() { + let mut class = model(&[]); + class.parent_class = None; + assert!(!uses_unique_string_ids(&class, &no_loader)); + class.parent_class = Some(atom("Missing")); + assert!(!uses_unique_string_ids(&class, &no_loader)); + + let mut cycle = make_class("CyclicTrait"); + cycle.used_traits = vec![cycle.name]; + let cycle = Arc::new(cycle); + let loader = |name: &str| (name == cycle.name).then(|| Arc::clone(&cycle)); + class.used_traits = vec![cycle.name, cycle.name]; + assert!(!uses_unique_string_ids(&class, &loader)); + class.used_traits.push(atom(ULIDS)); + assert!(uses_unique_string_ids(&class, &loader)); +} + +#[test] +fn unique_ids_bound_inheritance_and_trait_depth() { + let mut parent = make_class("CyclicParent"); + parent.parent_class = Some(parent.name); + let parent = Arc::new(parent); + let mut class = model(&[]); + class.parent_class = Some(parent.name); + let loads = std::cell::Cell::new(0); + assert!(!uses_unique_string_ids(&class, &|_| { + loads.set(loads.get() + 1); + Some(Arc::clone(&parent)) + })); + assert_eq!(loads.get(), MAX_INHERITANCE_DEPTH); + + let traits: Vec<_> = (0..MAX_TRAIT_DEPTH) + .map(|i| { + let mut class = make_class(&format!("Trait{i}")); + class.used_traits = vec![atom(&format!("Trait{}", i + 1))]; + Arc::new(class) + }) + .collect(); + let loader = |name: &str| { + traits + .iter() + .find(|class| class.name == name) + .map(Arc::clone) + }; + class.parent_class = None; + class.used_traits = vec![traits[0].name]; + assert!(!uses_unique_string_ids(&class, &loader)); +} + +#[test] +fn unique_ids_overrides_decide_whether_the_primary_key_is_generated() { + let mut class = model(&[UUIDS]); + class.laravel_mut().unique_ids = Some(vec!["uuid".into()]); + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "int", + ); + class.laravel_mut().unique_ids = Some(vec!["uuid".into(), "id".into()]); + assert_key( + &LaravelModelProvider.provide(&class, &no_loader, None), + "id", + "string", + ); + + let mut parent = model(&[UUIDS]); + parent.name = atom("App\\Models\\BaseModel"); + parent.laravel_mut().unique_ids = Some(vec!["uuid".into()]); + let parent = Arc::new(parent); + let loader = |name: &str| (name == parent.name).then(|| Arc::clone(&parent)); + let mut child = model(&[]); + child.parent_class = Some(parent.name); + assert_key( + &LaravelModelProvider.provide(&child, &loader, None), + "id", + "int", + ); + child.laravel_mut().unique_ids = Some(vec!["id".into()]); + assert_key( + &LaravelModelProvider.provide(&child, &loader, None), + "id", + "string", + ); +} diff --git a/tests/integration/laravel_unique_ids.rs b/tests/integration/laravel_unique_ids.rs new file mode 100644 index 000000000..262c75ecd --- /dev/null +++ b/tests/integration/laravel_unique_ids.rs @@ -0,0 +1,218 @@ +use crate::common::{create_psr4_workspace, open_php}; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER: &str = r#"{ + "autoload": { "psr-4": { + "App\\": "src/", + "Illuminate\\Database\\Eloquent\\": "vendor/illuminate/Eloquent/" + }} +}"#; + +const MODEL: &str = r#"getKeyName(), 'uuid']; } +}", + "id", + "string", + "int", + ), + ( + r"class Example extends BaseModel { public int $id = 1; }", + "id", + "int", + "string", + ), + ] { + let model = format!( + "{key}; }}\nfunction rejects(Example $model): {wrong} {{ return $model->{key}; }}\n$model = new Example();\n$model->{key};\n" + ); + let uri = Url::from_file_path(dir.path().join("src/usage.php")).unwrap(); + open_php(&backend, &uri, &content).await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), &content, &mut diagnostics); + assert_eq!(diagnostics.len(), 1, "{declaration}: {diagnostics:?}"); + assert_eq!(diagnostics[0].range.start.line, 3); + assert_eq!( + diagnostics[0].code, + Some(NumberOrString::String("type_mismatch_return".into())) + ); + assert_eq!( + diagnostics[0].message, + format!("Return type {expected} is incompatible with declared return type {wrong}") + ); + + let position = TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position::new(5, 9), + }; + let hover = backend + .hover(HoverParams { + text_document_position_params: position.clone(), + work_done_progress_params: Default::default(), + }) + .await + .unwrap() + .expect("primary-key hover"); + let HoverContents::Markup(hover) = hover.contents else { + panic!("expected markup hover") + }; + assert!( + hover.value.contains(&format!("`{expected}`")) + || hover.value.contains(&format!("{expected} ${key}")), + "{declaration}: {}", + hover.value + ); + + let completion = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + position: Position::new(5, 8), + ..position + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + context: None, + }) + .await + .unwrap() + .expect("model completion"); + let items = match completion { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + let keys: Vec<_> = items.iter().filter(|item| item.label == key).collect(); + assert_eq!(keys.len(), 1, "{declaration}: {items:?}"); + assert!( + keys[0] + .detail + .as_deref() + .unwrap_or_default() + .contains(expected), + "{:?}", + keys[0] + ); + } +} + +#[tokio::test] +async fn unique_ids_refresh_when_an_inherited_trait_changes() { + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("vendor/illuminate/Eloquent/Model.php", MODEL), + ("vendor/illuminate/Eloquent/Concerns/HasUlids.php", ULIDS), + ("src/Concerns/Identified.php", WRAPPER), + ("src/Models/BaseModel.php", PARENT), + ( + "src/Models/Example.php", + "id; }\n"; + open_php(&backend, &uri, content).await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), content, &mut diagnostics); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + + let trait_uri = Url::from_file_path(dir.path().join("src/Concerns/Identified.php")).unwrap(); + open_php( + &backend, + &trait_uri, + "