diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c534ed..5e9c440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] - 2026-04-30 +### Added +- Added `ProjectService` with `GetProject` method to retrieve a project data +- Added `GetProjectByPurlName(purlName, purlType)` method in `ProjectModel` to look up a single project by PURL name and type +- Added `Project` type for the public, decoded project response, with nullable source columns (`source_mine_id`, `source_purl_name`, `source_vendor`, `source_component`) exposed as pointers +- Added `ErrProjectNotFound` sentinel error in `ProjectService` +- Wired `ProjectService` into `scanoss.Client` + +### Changed +- Extended `ProjectModel.Project` struct with `MineID`, `PurlType`, `Vendor`, `SourceMineName`, `SourcePurlType`, `SourceRepositoryURL` +- **Breaking:** Renamed `ProjectModel.GetProjectByPurlName(purlName, mineID)` to `ProjectModel.GetProjectByPurlNameAndMineID(purlName, mineID)` + ## [0.9.0] - 2026-04-01 ### Added - Added `GetSPDXLicenseDetails` method in `LicenseModel` to retrieve SPDX license details by ID from the `spdx_license_data` table @@ -107,4 +119,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [0.6.0]: https://github.com/scanoss/go-models/compare/v0.5.1...v0.6.0 [0.7.0]: https://github.com/scanoss/go-models/compare/v0.6.0...v0.7.0 [0.8.0]: https://github.com/scanoss/go-models/compare/v0.7.0...v0.8.0 -[0.9.0]: https://github.com/scanoss/go-models/compare/v0.8.0...v0.9.0 \ No newline at end of file +[0.9.0]: https://github.com/scanoss/go-models/compare/v0.8.0...v0.9.0 +[0.10.0]: https://github.com/scanoss/go-models/compare/v0.9.0...v0.10.0 \ No newline at end of file diff --git a/internal/testutils/mock/mines.sql b/internal/testutils/mock/mines.sql index ad6a411..c43c0f3 100644 --- a/internal/testutils/mock/mines.sql +++ b/internal/testutils/mock/mines.sql @@ -2,7 +2,8 @@ DROP TABLE IF EXISTS mines; CREATE TABLE mines ( id INTEGER PRIMARY KEY, mine_name TEXT DEFAULT '', - purl_type TEXT DEFAULT '' + purl_type TEXT DEFAULT '', + repository_url TEXT DEFAULT '' ); INSERT INTO mines (id, mine_name, purl_type) VALUES (0, 'maven.org', 'maven'); INSERT INTO mines (id, mine_name, purl_type) VALUES (1, 'rubygems.org', 'gem'); diff --git a/pkg/models/projects.go b/pkg/models/projects.go index b7c8b1a..0b356a4 100644 --- a/pkg/models/projects.go +++ b/pkg/models/projects.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2018-2022 SCANOSS.COM + * Copyright (C) 2018-2026 SCANOSS.COM * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -20,6 +20,7 @@ package models import ( "context" + "database/sql" "errors" "fmt" @@ -32,14 +33,24 @@ type ProjectModel struct { } type Project struct { - PurlName string `db:"purl_name"` - Component string `db:"component"` - License string `db:"license"` - LicenseID string `db:"license_id"` - IsSpdx bool `db:"is_spdx"` - GitLicense string `db:"g_license"` - GitLicenseID string `db:"g_license_id"` - GitIsSpdx bool `db:"g_is_spdx"` + MineID int32 `db:"mine_id"` + PurlName string `db:"purl_name"` + PurlType string `db:"purl_type"` + Vendor string `db:"vendor"` + Component string `db:"component"` + License string `db:"license"` + LicenseID string `db:"license_id"` + IsSpdx bool `db:"is_spdx"` + GitLicense string `db:"g_license"` + GitLicenseID string `db:"g_license_id"` + GitIsSpdx bool `db:"g_is_spdx"` + SourceMineID *int32 `db:"source_mine_id"` + SourcePurlName *string `db:"source_purl_name"` + SourceVendor *string `db:"source_vendor"` + SourceComponent *string `db:"source_component"` + SourceMineName string `db:"source_mine_name"` + SourcePurlType string `db:"source_purl_type"` + SourceRepositoryURL string `db:"source_repository_url"` } // NewProjectModel creates a new instance of the Project Model. @@ -60,12 +71,27 @@ func (m *ProjectModel) GetProjectsByPurlName(ctx context.Context, purlName strin } var allProjects []Project err := m.db.SelectContext(ctx, &allProjects, - "SELECT purl_name, component,"+ - " l.license_name AS license, l.spdx_id AS license_id, l.is_spdx AS is_spdx,"+ - " g.license_name AS g_license, g.spdx_id AS g_license_id, g.is_spdx AS g_is_spdx"+ + "SELECT p.mine_id, p.purl_name,"+ + " COALESCE(m.purl_type, '') AS purl_type,"+ + " COALESCE(p.vendor, '') AS vendor,"+ + " COALESCE(p.component, '') AS component,"+ + " COALESCE(l.license_name, '') AS license,"+ + " COALESCE(l.spdx_id, '') AS license_id,"+ + " COALESCE(l.is_spdx, false) AS is_spdx,"+ + " COALESCE(g.license_name, '') AS g_license,"+ + " COALESCE(g.spdx_id, '') AS g_license_id,"+ + " COALESCE(g.is_spdx, false) AS g_is_spdx,"+ + " p.source_mine_id,"+ + " p.source_purl_name,"+ + " p.source_vendor,"+ + " p.source_component,"+ + " COALESCE(sm.mine_name, '') AS source_mine_name,"+ + " COALESCE(sm.purl_type, '') AS source_purl_type,"+ + " COALESCE(sm.repository_url, '') AS source_repository_url"+ " FROM projects p"+ - " LEFT JOIN mines m ON p.mine_id = m.id"+ - " LEFT JOIN licenses l ON p.license_id = l.id"+ + " LEFT JOIN mines m ON p.mine_id = m.id"+ + " LEFT JOIN mines sm ON p.source_mine_id = sm.id"+ + " LEFT JOIN licenses l ON p.license_id = l.id"+ " LEFT JOIN licenses g ON p.git_license_id = g.id"+ " WHERE m.purl_type = $1 AND p.purl_name = $2", purlType, purlName) @@ -76,8 +102,8 @@ func (m *ProjectModel) GetProjectsByPurlName(ctx context.Context, purlName strin return allProjects, nil } -// GetProjectByPurlName searches the projects' table for details about a Purl Name and Mine ID. -func (m *ProjectModel) GetProjectByPurlName(ctx context.Context, purlName string, mineID int32) (Project, error) { +// GetProjectByPurlNameAndMineID searches the projects' table for details about a Purl Name and Mine ID. +func (m *ProjectModel) GetProjectByPurlNameAndMineID(ctx context.Context, purlName string, mineID int32) (Project, error) { s := ctxzap.Extract(ctx).Sugar() if len(purlName) == 0 { s.Error("Please specify a valid Purl Name to query") @@ -88,13 +114,29 @@ func (m *ProjectModel) GetProjectByPurlName(ctx context.Context, purlName string return Project{}, errors.New("please specify a valid Mine ID to query") } rows, err := m.db.QueryxContext(ctx, - "SELECT purl_name, component,"+ - " l.license_name AS license, l.spdx_id AS license_id, l.is_spdx AS is_spdx,"+ - " g.license_name AS g_license, g.spdx_id AS g_license_id, g.is_spdx AS g_is_spdx"+ + "SELECT p.mine_id, p.purl_name,"+ + " COALESCE(m.purl_type, '') AS purl_type,"+ + " COALESCE(p.vendor, '') AS vendor,"+ + " COALESCE(p.component, '') AS component,"+ + " COALESCE(l.license_name, '') AS license,"+ + " COALESCE(l.spdx_id, '') AS license_id,"+ + " COALESCE(l.is_spdx, false) AS is_spdx,"+ + " COALESCE(g.license_name, '') AS g_license,"+ + " COALESCE(g.spdx_id, '') AS g_license_id,"+ + " COALESCE(g.is_spdx, false) AS g_is_spdx,"+ + " p.source_mine_id,"+ + " p.source_purl_name,"+ + " p.source_vendor,"+ + " p.source_component,"+ + " COALESCE(sm.mine_name, '') AS source_mine_name,"+ + " COALESCE(sm.purl_type, '') AS source_purl_type,"+ + " COALESCE(sm.repository_url, '') AS source_repository_url"+ " FROM projects p"+ - " LEFT JOIN licenses l ON p.license_id = l.id"+ + " LEFT JOIN mines m ON p.mine_id = m.id"+ + " LEFT JOIN mines sm ON p.source_mine_id = sm.id"+ + " LEFT JOIN licenses l ON p.license_id = l.id"+ " LEFT JOIN licenses g ON p.git_license_id = g.id"+ - " WHERE purl_name = $1 AND mine_id = $2", + " WHERE p.purl_name = $1 AND p.mine_id = $2", purlName, mineID) defer func() { @@ -121,3 +163,53 @@ func (m *ProjectModel) GetProjectByPurlName(ctx context.Context, purlName string } return project, nil } + +// GetProjectByPurlName searches the projects' table for a single project matching +// the given Purl Name and Purl Type (resolved via the mines join). Returns +// sql.ErrNoRows when no match exists. +func (m *ProjectModel) GetProjectByPurlName(ctx context.Context, purlName string, purlType string) (Project, error) { + s := ctxzap.Extract(ctx).Sugar() + if len(purlName) == 0 { + s.Error("Please specify a valid Purl Name to query") + return Project{}, errors.New("please specify a valid Purl Name to query") + } + if len(purlType) == 0 { + s.Error("Please specify a valid Purl Type to query") + return Project{}, errors.New("please specify a valid Purl Type to query") + } + var project Project + err := m.db.GetContext(ctx, &project, + "SELECT p.mine_id, p.purl_name,"+ + " COALESCE(m.purl_type, '') AS purl_type,"+ + " COALESCE(p.vendor, '') AS vendor,"+ + " COALESCE(p.component, '') AS component,"+ + " COALESCE(l.license_name, '') AS license,"+ + " COALESCE(l.spdx_id, '') AS license_id,"+ + " COALESCE(l.is_spdx, false) AS is_spdx,"+ + " COALESCE(g.license_name, '') AS g_license,"+ + " COALESCE(g.spdx_id, '') AS g_license_id,"+ + " COALESCE(g.is_spdx, false) AS g_is_spdx,"+ + " p.source_mine_id,"+ + " p.source_purl_name,"+ + " p.source_vendor,"+ + " p.source_component,"+ + " COALESCE(sm.mine_name, '') AS source_mine_name,"+ + " COALESCE(sm.purl_type, '') AS source_purl_type,"+ + " COALESCE(sm.repository_url, '') AS source_repository_url"+ + " FROM projects p"+ + " LEFT JOIN mines m ON p.mine_id = m.id"+ + " LEFT JOIN mines sm ON p.source_mine_id = sm.id"+ + " LEFT JOIN licenses l ON p.license_id = l.id"+ + " LEFT JOIN licenses g ON p.git_license_id = g.id"+ + " WHERE m.purl_type = $1 AND p.purl_name = $2"+ + " LIMIT 1", + purlType, purlName) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Project{}, err + } + s.Errorf("Failed to query projects table for %v, %v: %v", purlName, purlType, err) + return Project{}, fmt.Errorf("failed to query the projects table: %v", err) + } + return project, nil +} diff --git a/pkg/models/projects_test.go b/pkg/models/projects_test.go index 5a5f710..b4963db 100644 --- a/pkg/models/projects_test.go +++ b/pkg/models/projects_test.go @@ -71,30 +71,30 @@ func TestProjectsSearch(t *testing.T) { purlName = "tablestyle" var mineId int32 = 1 fmt.Printf("Searching for project: %v - %v\n", purlName, mineId) - project, err := projectsModel.GetProjectByPurlName(ctx, "tablestyle", mineId) + project, err := projectsModel.GetProjectByPurlNameAndMineID(ctx, "tablestyle", mineId) if err != nil { - t.Errorf("projects.GetProjectByPurlName() error = %+v", err) + t.Errorf("projects.GetProjectByPurlNameAndMineID() error = %+v", err) } if len(project.PurlName) == 0 { - t.Errorf("projects.GetProjectByPurlName() No project returned from query") + t.Errorf("projects.GetProjectByPurlNameAndMineID() No project returned from query") } else { fmt.Printf("Project: %v\n", project) } purlName = "" mineId = -1 fmt.Printf("Searching for project list: %v - %v\n", purlName, purlType) - _, err = projectsModel.GetProjectByPurlName(ctx, purlName, mineId) + _, err = projectsModel.GetProjectByPurlNameAndMineID(ctx, purlName, mineId) if err == nil { - t.Errorf("projects.GetProjectByPurlName() error = did not get an error") + t.Errorf("projects.GetProjectByPurlNameAndMineID() error = did not get an error") } else { fmt.Printf("Got expected error = %v\n", err) } purlName = "NONEXISTENT" mineId = -1 fmt.Printf("Searching for project list: %v - %v\n", purlName, purlType) - _, err = projectsModel.GetProjectByPurlName(ctx, purlName, mineId) + _, err = projectsModel.GetProjectByPurlNameAndMineID(ctx, purlName, mineId) if err == nil { - t.Errorf("projects.GetProjectByPurlName() error = did not get an error") + t.Errorf("projects.GetProjectByPurlNameAndMineID() error = did not get an error") } else { fmt.Printf("Got expected error = %v\n", err) } @@ -118,9 +118,9 @@ func TestProjectsSearchBadSql(t *testing.T) { } else { fmt.Printf("Got expected error = %v\n", err) } - _, err = projectsModel.GetProjectByPurlName(ctx, "rubbish", 2) + _, err = projectsModel.GetProjectByPurlNameAndMineID(ctx, "rubbish", 2) if err == nil { - t.Errorf("projects.GetProjectByPurlName() error = did not get an error") + t.Errorf("projects.GetProjectByPurlNameAndMineID() error = did not get an error") } else { fmt.Printf("Got expected error = %v\n", err) } diff --git a/pkg/scanoss/client.go b/pkg/scanoss/client.go index 02396b6..864c382 100644 --- a/pkg/scanoss/client.go +++ b/pkg/scanoss/client.go @@ -26,6 +26,7 @@ import ( type Client struct { Models *models.Models Component *services.ComponentService + Project *services.ProjectService } // New creates a SCANOSS Model Client. @@ -34,9 +35,11 @@ func New(db *sqlx.DB) *Client { // Initialize services component := services.NewComponentService(m) + project := services.NewProjectService(m) return &Client{ Models: m, Component: component, + Project: project, } } diff --git a/pkg/services/project.go b/pkg/services/project.go new file mode 100644 index 0000000..b1b2e9b --- /dev/null +++ b/pkg/services/project.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2018-2026 SCANOSS.COM + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/scanoss/go-models/pkg/models" + "github.com/scanoss/go-models/pkg/types" + purlutils "github.com/scanoss/go-purl-helper/pkg" +) + +// ErrProjectNotFound is returned when no project row is found for the given PURL. +var ErrProjectNotFound = errors.New("project not found") + +// ProjectService exposes project-table lookups. +type ProjectService struct { + models *models.Models +} + +// NewProjectService creates a new ProjectService instance. +func NewProjectService(models *models.Models) *ProjectService { + return &ProjectService{models: models} +} + +// GetProject retrieves a project row from the projects table for the given +// PURL, joining mines (for purl_type and source-mine details) and licenses. +// It is intended as a fallback when no resolved component is available. +// Returns ErrProjectNotFound if no project matches the (purl_name, purl_type) +// pair. When multiple projects match, the first row is returned. +func (ps *ProjectService) GetProject(ctx context.Context, purl string) (types.Project, error) { + if len(purl) == 0 { + return types.Project{}, errors.New("please specify a valid purl to query") + } + packageURL, err := purlutils.PurlFromString(purl) + if err != nil { + return types.Project{}, fmt.Errorf("failed to parse purl: %w", err) + } + purlName, err := purlutils.PurlNameFromString(purl) + if err != nil { + return types.Project{}, fmt.Errorf("failed to extract purl name: %w", err) + } + row, err := ps.models.Projects.GetProjectByPurlName(ctx, purlName, packageURL.Type) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return types.Project{}, ErrProjectNotFound + } + return types.Project{}, err + } + return types.Project{ + MineID: row.MineID, + PurlName: row.PurlName, + PurlType: row.PurlType, + Vendor: row.Vendor, + Component: row.Component, + License: row.License, + LicenseID: row.LicenseID, + SourceMineID: row.SourceMineID, + SourcePurlName: row.SourcePurlName, + SourceVendor: row.SourceVendor, + SourceComponent: row.SourceComponent, + SourceMineName: row.SourceMineName, + SourcePurlType: row.SourcePurlType, + SourceRepositoryURL: row.SourceRepositoryURL, + }, nil +} diff --git a/pkg/types/types.go b/pkg/types/types.go index bb71fe3..b21f009 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later /* - * Copyright (C) 2018-2023 SCANOSS.COM + * Copyright (C) 2018-2026 SCANOSS.COM * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -51,3 +51,44 @@ type ComponentVersionsResponse struct { // Versions is the list of all available versions for the component. Versions []string `json:"versions"` } + +// Project represents a row from the projects table joined with mines and +// licenses. It is the public, decoded form of the project lookup used as a +// fallback when no resolved component is available. +// +// Nullable columns (source_mine_id, source_purl_name, source_vendor, +// source_component) are exposed as pointers so callers can distinguish +// "not set" from "empty value". The source_mine_* fields are derived from +// a LEFT JOIN on mines using source_mine_id; they are empty strings when +// no source mine is linked. +type Project struct { + // MineID is the id of the mine the project was sourced from. + MineID int32 `json:"mine_id"` + // PurlName is the PURL name (e.g., "lodash" or "github.com/foo/bar"). + PurlName string `json:"purl_name"` + // PurlType is the PURL type from the joined mines row (e.g., "npm"). + PurlType string `json:"purl_type"` + // Vendor is the project vendor/namespace as recorded in the projects row. + Vendor string `json:"vendor"` + // Component is the project component name as recorded in the projects row. + Component string `json:"component"` + // License is the SPDX license name from the joined licenses row. + License string `json:"license,omitempty"` + // LicenseID is the SPDX license id from the joined licenses row. + LicenseID string `json:"license_id,omitempty"` + // SourceMineID is the id of the source mine the project was sourced from. + // Nil when the project has no linked source mine. + SourceMineID *int32 `json:"source_mine_id,omitempty"` + // SourcePurlName is the PURL name on the source mine. Nil when not set. + SourcePurlName *string `json:"source_purl_name,omitempty"` + // SourceVendor is the vendor/namespace on the source mine. Nil when not set. + SourceVendor *string `json:"source_vendor,omitempty"` + // SourceComponent is the component name on the source mine. Nil when not set. + SourceComponent *string `json:"source_component,omitempty"` + // SourceMineName is the human-readable name of the source mine. + SourceMineName string `json:"source_mine_name,omitempty"` + // SourcePurlType is the PURL type of the source mine. + SourcePurlType string `json:"source_purl_type,omitempty"` + // SourceRepositoryURL is the canonical repository URL exposed by the source mine. + SourceRepositoryURL string `json:"source_repository_url,omitempty"` +}