Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
25 changes: 0 additions & 25 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
12 changes: 12 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions examples/laravel/app/Models/BakeryOrder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class BakeryOrder extends Model
{
use HasUuids;
}
13 changes: 13 additions & 0 deletions examples/laravel/app/Models/Delivery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;

class Delivery extends Model
{
use HasUlids;

protected $primaryKey = 'tracking_id';
}
22 changes: 22 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,28 @@ public function toArray(): array

\Illuminate\Container\Container::setInstance($previousContainer);

// ─── UUID and ULID primary keys ─────────────────────────────────────────────

$uuidOrder = new \App\Models\BakeryOrder();
$ulidDelivery = new \App\Models\Delivery();
$uuidOrder->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";
Expand Down
3 changes: 3 additions & 0 deletions src/mem_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ fn laravel_meta(l: &LaravelMetadata) -> Sz {
}
z += vs(&l.column_names);
z.add(l.column_sources.capacity() * size_of::<u16>());
if let Some(ids) = &l.unique_ids {
z += vs(ids);
}
for o in [
&l.connection_name,
&l.table_name,
Expand Down
6 changes: 6 additions & 0 deletions src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
/// Whether `$timestamps` is explicitly set on the model.
///
/// - `None` — not declared (inherits the default, which is `true`
Expand Down
10 changes: 8 additions & 2 deletions src/virtual_members/laravel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
46 changes: 46 additions & 0 deletions src/virtual_members/laravel/model_extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &'a class_like::member::ClassLikeMember<'a>>,
content: &str,
) -> Option<Vec<String>> {
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<Item = &'a class_like::member::ClassLikeMember<'a>>,
content: &str,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions src/virtual_members/laravel/unique_ids.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<ClassInfo>>,
) -> 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<Arc<ClassInfo>>,
) -> 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<Arc<ClassInfo>>,
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;
Loading
Loading