Skip to content
Closed
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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
[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
1 change: 1 addition & 0 deletions internal/testutils/mock/all_urls.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand Down
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
193 changes: 174 additions & 19 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,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.
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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() {
Expand All @@ -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
}
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,
}
}
38 changes: 38 additions & 0 deletions pkg/services/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading