diff --git a/README.md b/README.md index 1ae410e..5c4a766 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Repository href format: npm support queries the `npm_package` table. Each row is one package version (typically one tarball). Pulp stores only `name` and `version` in the database; rich metadata (`description`, `license`, `dependencies`, etc.) lives in the tarball's `package.json` and is not persisted by pulp_npm. -- **`NpmPackageList`** — lists packages in the latest repository version, grouped by `name`, with all versions and `latest_versions` (most recent `pulp_created` per version). Supports optional `Search` prefix filter on `name` (including scoped names like `@scope/pkg`). Pagination is done in SQL. +- **`NpmPackageList`** — lists packages in the latest repository version, grouped by `name`, with all versions and `latest_versions` (most recent `pulp_created` per version). Supports optional `Search` prefix filter on the npm scope (text before `/`) or unscoped package name (text after `/`), similar to Maven `group_id` / `artifact_id` search. Unscoped names match against the full name. Pagination is done in SQL. - **`NpmBuildList`** — lists builds (`name` + `version` pairs) in the latest repository version, optionally filtered by `name` and `version`. Pagination is done in SQL. - **`NpmPackageGet`** — returns tarball info (`relative_path`, `filename`, `sha256`, `size`) and timestamps for a given `name` and `version`, plus all other versions available in the repository. Returns `ErrNpmPackageNotFound` when the package version is not in the repository. - **`NpmPackageVersionsGet`** — returns tarball info for every version of a given `name`, each entry matching `NpmPackageGet` for that version. Returns `ErrNpmPackageNotFound` when the package is not in the repository. diff --git a/internal/test/integration/npm_test.go b/internal/test/integration/npm_test.go index abc152e..7f1fc2e 100644 --- a/internal/test/integration/npm_test.go +++ b/internal/test/integration/npm_test.go @@ -344,7 +344,7 @@ func TestNpmScopedSuite(t *testing.T) { } func (n *NpmScopedSuite) TestNpmScopedPackage() { - listResponse, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "@types/"}, tangy.PageOptions{ + listResponse, err := n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "@types"}, tangy.PageOptions{ Offset: 0, Limit: 10, }) @@ -354,6 +354,15 @@ func (n *NpmScopedSuite) TestNpmScopedPackage() { assert.Equal(n.T(), testNpmScopedPackageName, listResponse.Results[0].Name) assert.Contains(n.T(), listResponse.Results[0].Versions, testNpmScopedVersion) + listResponse, err = n.tangy.NpmPackageList(context.Background(), n.repositoryHref, tangy.NpmPackageListFilters{Search: "is-odd"}, tangy.PageOptions{ + Offset: 0, + Limit: 10, + }) + require.NoError(n.T(), err) + require.NotEmpty(n.T(), listResponse.Results) + assert.Equal(n.T(), 1, listResponse.Total) + assert.Equal(n.T(), testNpmScopedPackageName, listResponse.Results[0].Name) + detail, err := n.tangy.NpmPackageGet( context.Background(), n.repositoryHref, diff --git a/pkg/tangy/npm.go b/pkg/tangy/npm.go index 9d12fb8..11d1aad 100644 --- a/pkg/tangy/npm.go +++ b/pkg/tangy/npm.go @@ -122,7 +122,7 @@ func (t *tangyImpl) NpmPackageList(ctx context.Context, repositoryHref string, f searchFilter := "" if filterOpts.Search != "" { args["searchFilter"] = filterOpts.Search - searchFilter = ` AND rp.name ILIKE CONCAT(@searchFilter::text, '%')` + searchFilter = npmPackageListSearchFilter() } innerUnion, err := contentIdsInVersions(ctx, conn, repoVerMap, &args) if err != nil { @@ -525,6 +525,45 @@ func assembleNpmPackageListFromRows(rows []npmPackageVersionRow) []NpmPackageLis return append(results, current) } +// npmPackageListSearchFilter returns SQL that prefix-matches the search term against +// the npm scope (text before the first '/') and the unscoped package name (text after +// the first '/'), similar to MavenPackageList matching group_id OR artifact_id. +// Unscoped packages only have a scope segment (the full name). +func npmPackageListSearchFilter() string { + return ` AND ( + split_part(rp.name, '/', 1) ILIKE CONCAT(@searchFilter::text, '%') + OR ( + POSITION('/' IN rp.name) > 0 + AND split_part(rp.name, '/', 2) ILIKE CONCAT(@searchFilter::text, '%') + ) + )` +} + +// splitNpmPackageName splits a package name into scope and unscoped name segments. +func splitNpmPackageName(name string) (scope, unscopedName string, scoped bool) { + if i := strings.Index(name, "/"); i >= 0 { + return name[:i], name[i+1:], true + } + return name, "", false +} + +// npmPackageMatchesSearch mirrors npmPackageListSearchFilter for unit tests. +func npmPackageMatchesSearch(name, search string) bool { + if search == "" { + return true + } + + searchLower := strings.ToLower(search) + scope, unscopedName, scoped := splitNpmPackageName(name) + if strings.HasPrefix(strings.ToLower(scope), searchLower) { + return true + } + if scoped && strings.HasPrefix(strings.ToLower(unscopedName), searchLower) { + return true + } + return false +} + // parseNpmRepositoryHref extracts the repository UUID from an npm repository href. // Example: /api/pulp/default/api/v3/repositories/npm/npm/018c1c95-4281-76eb-b277-842cbad524f4/ func parseNpmRepositoryHref(href string) (string, error) { diff --git a/pkg/tangy/npm_test.go b/pkg/tangy/npm_test.go index d7d5bab..1b60ed4 100644 --- a/pkg/tangy/npm_test.go +++ b/pkg/tangy/npm_test.go @@ -165,6 +165,73 @@ func TestParseNpmLatestVersionsJSON(t *testing.T) { require.Error(t, err) } +func TestNpmPackageMatchesSearch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pkg string + search string + want bool + }{ + { + name: "unscoped package name prefix", + pkg: "is-odd", + search: "is-odd", + want: true, + }, + { + name: "unscoped package name shorter prefix", + pkg: "is-odd", + search: "is", + want: true, + }, + { + name: "scoped package scope prefix", + pkg: "@types/is-odd", + search: "@types", + want: true, + }, + { + name: "scoped package unscoped name prefix", + pkg: "@types/is-odd", + search: "is-odd", + want: true, + }, + { + name: "scoped package unscoped name shorter prefix", + pkg: "@types/is-odd", + search: "is", + want: true, + }, + { + name: "scoped package full name prefix does not match unscoped segment only", + pkg: "@types/is-odd", + search: "@types/", + want: false, + }, + { + name: "scoped package search does not match unrelated scope", + pkg: "@types/is-odd", + search: "lodash", + want: false, + }, + { + name: "empty search matches everything", + pkg: "@types/is-odd", + search: "", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, npmPackageMatchesSearch(tt.pkg, tt.search)) + }) + } +} + func TestMockTangyNpmPackageList(t *testing.T) { t.Parallel()