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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
[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
3 changes: 2 additions & 1 deletion internal/testutils/mock/mines.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
134 changes: 113 additions & 21 deletions pkg/models/projects.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +20,7 @@ package models

import (
"context"
"database/sql"
"errors"
"fmt"

Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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() {
Expand All @@ -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
}
18 changes: 9 additions & 9 deletions pkg/models/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/scanoss/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
type Client struct {
Models *models.Models
Component *services.ComponentService
Project *services.ProjectService
}

// New creates a SCANOSS Model Client.
Expand All @@ -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,
}
}
83 changes: 83 additions & 0 deletions pkg/services/project.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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
}
Loading
Loading