diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c534ed..dbe0ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] - 2026-04-30 +### Added +- Added `GetSourcePurl` method in `ComponentService` to retrieve the source-mine row used to build a source PURL for a component +- Added `GetSourcePurl` method in `ProjectModel` to query the `projects`/`mines` join for the source PURL row +- Added `SourcePurlRow` struct in `ProjectModel` for the raw join row +- Added `SourcePurl` type for the public, decoded source PURL response +- Added `ErrSourcePurlNotFound` sentinel error in `ComponentService` +- Added `is_mined` column to the `all_urls` mock schema +- Added `ProjectService` with `GetProject` method to retrieve a project data +- Added `GetProjectByPurl` method in `ProjectModel` +- 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` + ## [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 +124,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/all_urls.sql b/internal/testutils/mock/all_urls.sql index 2b85681..79a7d41 100644 --- a/internal/testutils/mock/all_urls.sql +++ b/internal/testutils/mock/all_urls.sql @@ -13,6 +13,7 @@ CREATE TABLE all_urls version_id integer, license_id integer, purl_name text, + is_mined boolean default true, primary key (package_hash, url, url_hash) ); 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..981a15e 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,36 @@ 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"` +} + +// SourcePurlRow is the raw row returned by the source-PURL lookup query: +// a join across projects and mines that exposes the source-mine fields +// needed to assemble a source PURL. +type SourcePurlRow struct { + SourceMineID int32 `db:"source_mine_id"` + SourcePurlName string `db:"source_purl_name"` + SourceVendor string `db:"source_vendor"` + MineName string `db:"mine_name"` + PurlType string `db:"purl_type"` + RepositoryURL string `db:"repository_url"` } // NewProjectModel creates a new instance of the Project Model. @@ -60,12 +83,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,6 +114,57 @@ func (m *ProjectModel) GetProjectsByPurlName(ctx context.Context, purlName strin return allProjects, nil } +// GetSourcePurl looks up the source-mine row used to build a source PURL +// for a component identified by (purlName, purlType). It returns an empty +// row (and nil error) when no match exists. +func (m *ProjectModel) GetSourcePurl(ctx context.Context, purlName string, purlType string) (SourcePurlRow, error) { + s := ctxzap.Extract(ctx).Sugar() + if len(purlName) == 0 { + s.Error("Please specify a valid Purl Name to query") + return SourcePurlRow{}, 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 SourcePurlRow{}, errors.New("please specify a valid Purl Type to query") + } + rows, err := m.db.QueryxContext(ctx, + "SELECT p.source_mine_id,"+ + " COALESCE(p.source_purl_name, '') AS source_purl_name,"+ + " COALESCE(p.source_vendor, '') AS source_vendor,"+ + " COALESCE(sm.mine_name, '') AS mine_name,"+ + " COALESCE(sm.purl_type, '') AS purl_type,"+ + " COALESCE(sm.repository_url, '') AS repository_url"+ + " FROM projects p"+ + " INNER JOIN mines m ON p.mine_id = m.id"+ + " INNER JOIN mines sm ON p.source_mine_id = sm.id"+ + " WHERE p.mine_id IS NOT NULL"+ + " AND p.source_mine_id IS NOT NULL"+ + " AND p.purl_name = $1"+ + " AND m.purl_type = $2"+ + " LIMIT 1", + purlName, purlType) + defer func() { + if rows != nil { + closeErr := rows.Close() + if closeErr != nil { + s.Warnf("Problem closing Rows: %v", closeErr) + } + } + }() + if err != nil { + s.Errorf("Failed to query source purl for %v, %v: %v", purlName, purlType, err) + return SourcePurlRow{}, fmt.Errorf("failed to query the projects table: %v", err) + } + var row SourcePurlRow + if rows.Next() { + if errStruct := rows.StructScan(&row); errStruct != nil { + s.Errorf("Failed to parse source purl row for %v, %v: %v", purlName, purlType, errStruct) + return SourcePurlRow{}, fmt.Errorf("failed to parse source purl row: %v", errStruct) + } + } + return row, 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) { s := ctxzap.Extract(ctx).Sugar() @@ -88,13 +177,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 +226,53 @@ func (m *ProjectModel) GetProjectByPurlName(ctx context.Context, purlName string } return project, nil } + +// GetProjectByPurl 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) GetProjectByPurl(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/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/component.go b/pkg/services/component.go index 0569f48..1f92562 100644 --- a/pkg/services/component.go +++ b/pkg/services/component.go @@ -34,6 +34,10 @@ var ErrComponentNotFound = errors.New("component not found") // ErrVersionNotFound is returned when a component exists but no version could be determined. var ErrVersionNotFound = errors.New("version not found") +// ErrSourcePurlNotFound is returned when no source PURL row is found for the +// given PURL. +var ErrSourcePurlNotFound = errors.New("source purl not found") + // ComponentService orchestrates component lookup logic using extracted business logic. type ComponentService struct { models *models.Models @@ -171,6 +175,40 @@ func (cs *ComponentService) GetComponentVersions(ctx context.Context, purl strin }, nil } +// GetSourcePurl retrieves the source-mine row used to build a source PURL +// for the given component PURL. The returned data is the raw source-mine +// fields (type, vendor, name, repository URL); callers are responsible for +// assembling the final PURL string. Returns ErrSourcePurlNotFound if no +// source PURL exists for the component. +func (cs *ComponentService) GetSourcePurl(ctx context.Context, purl string) (types.SourcePurl, error) { + if len(purl) == 0 { + return types.SourcePurl{}, errors.New("please specify a valid purl to query") + } + packageURL, err := purlutils.PurlFromString(purl) + if err != nil { + return types.SourcePurl{}, fmt.Errorf("failed to parse purl: %w", err) + } + purlName, err := purlutils.PurlNameFromString(purl) + if err != nil { + return types.SourcePurl{}, fmt.Errorf("failed to extract purl name: %w", err) + } + row, err := cs.models.Projects.GetSourcePurl(ctx, purlName, packageURL.Type) + if err != nil { + return types.SourcePurl{}, err + } + if row.SourcePurlName == "" { + return types.SourcePurl{}, ErrSourcePurlNotFound + } + return types.SourcePurl{ + SourceMineID: row.SourceMineID, + SourcePurlName: row.SourcePurlName, + SourceVendor: row.SourceVendor, + MineName: row.MineName, + PurlType: row.PurlType, + RepositoryURL: row.RepositoryURL, + }, nil +} + // pickOneUrl takes the potential matching component/versions and selects the most appropriate one. func (cs *ComponentService) pickOneUrl(ctx context.Context, allUrls []models.AllURL, purlName, purlType, purlReq string) (models.AllURL, error) { diff --git a/pkg/services/project.go b/pkg/services/project.go new file mode 100644 index 0000000..a6843ac --- /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.GetProjectByPurl(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..f5f23ae 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,62 @@ 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"` +} + +// SourcePurl represents the raw source-mine data used to build a source PURL +// for a component. It is the public, decoded form of the projects/mines join +// row used by the source PURL lookup. +type SourcePurl struct { + // SourceMineID is the id of the source mine the component was sourced from. + SourceMineID int32 `json:"source_mine_id"` + // SourcePurlName is the PURL name on the source mine. + SourcePurlName string `json:"source_purl_name"` + // SourceVendor is the namespace/vendor on the source mine (may be empty). + SourceVendor string `json:"source_vendor"` + // MineName is the human-readable name of the source mine. + MineName string `json:"mine_name"` + // PurlType is the PURL type of the source mine (e.g., "github"). + PurlType string `json:"purl_type"` + // RepositoryURL is the canonical repository URL exposed by the source mine. + RepositoryURL string `json:"repository_url"` +}