Skip to content
Open
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
6 changes: 5 additions & 1 deletion cmd/prods/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ func newMigrationFixture(t *testing.T) migrationFixture {
t.Fatal(err)
}
if _, err := db.Exec(`
DROP TABLE public_spec_projection_fields;
DROP TABLE spec_normalization_work_items;
DROP TABLE spec_normalization_work;
DROP TABLE spec_filter_profile_work;
DROP TABLE spec_filter_profiles;
DROP TABLE public_spec_projection_fields;
DROP TABLE public_spec_projection_products;
DROP TABLE spec_numeric_profile_work;
DROP TABLE spec_normalization_selections;
Expand Down
5 changes: 2 additions & 3 deletions internal/catalog/product.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import (
"errors"
"strings"

"golang.org/x/text/cases"
"golang.org/x/text/unicode/norm"
"prods/internal/searchtext"
)

const SearchProjectionVersion = "x-text-v0.25.0-nfc-casefold-fields-v2"
Expand Down Expand Up @@ -276,7 +275,7 @@ func (p Product) DisplayName() string {
}

func FoldSearch(value string) string {
return norm.NFC.String(cases.Fold().String(norm.NFC.String(value)))
return searchtext.Fold(value)
}

func EscapeLike(value string) string {
Expand Down
30 changes: 30 additions & 0 deletions internal/catalog/spec_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package catalog

import "prods/internal/catalogfilter"

type SpecSearchProfile struct {
SpecID string `json:"spec_id"`
SpecSemanticVersion int64 `json:"spec_semantic_version"`
Revision int64 `json:"revision"`
Filterable bool `json:"filterable"`
Profile catalogfilter.Profile `json:"profile"`
}

type SpecNormalizationWork struct {
InitialSeed bool `json:"-"`
ID string `json:"id"`
SpecID string `json:"spec_id"`
Status string `json:"status"`
Cursor string `json:"cursor"`
ProfileRevision int64 `json:"profile_revision"`
SemanticVersion int64 `json:"semantic_version"`
Processed int64 `json:"processed"`
Failed int64 `json:"failed"`
Skipped int64 `json:"skipped"`
Total int64 `json:"total"`
}
type SpecNormalizationWorkItem struct {
SpecValueID string `json:"spec_value_id"`
State string `json:"state"`
Reason string `json:"reason"`
}
15 changes: 15 additions & 0 deletions internal/catalogfilter/clause.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package catalogfilter

import "prods/internal/specnumeric"

type Scope struct {
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
Query string `json:"query,omitempty"`
}
type Clause struct {
Field string `json:"field"`
Operator string `json:"operator"`
Values []string `json:"values,omitempty"`
Numeric *specnumeric.Predicate `json:"numeric,omitempty"`
}
105 changes: 105 additions & 0 deletions internal/catalogfilter/profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package catalogfilter

import (
"fmt"
"slices"
"strings"

"prods/internal/searchtext"
"prods/internal/specnumeric"
)

const TextRule = "unicode-fold-space-1"

type Profile struct {
Version int `json:"version"`
Kind string `json:"kind"`
TextRule string `json:"text_rule,omitempty"`
Delimiter string `json:"delimiter,omitempty"`
Operators []string `json:"operators"`
Numeric *specnumeric.Profile `json:"numeric,omitempty"`
}

func DefaultTextProfile() Profile {
return Profile{Version: 1, Kind: "text", TextRule: TextRule, Operators: []string{"equals", "contains"}}
}
func NumericProfile(p specnumeric.Profile) Profile {
result := Profile{Version: 1, Kind: "numeric", Numeric: &p}
for _, op := range p.AllowedOperators {
result.Operators = append(result.Operators, string(op))
}
return result
}
func ValidateProfile(p Profile) error {
if p.Version != 1 || len(p.Operators) == 0 {
return fmt.Errorf("unsupported search profile version or empty operators")
}
allowed := []string{}
switch p.Kind {
case "text":
allowed = []string{"equals", "contains"}
case "choice", "multi_choice":
allowed = []string{"equals"}
case "numeric":
if p.Numeric == nil {
return fmt.Errorf("numeric profile required")
}
if err := specnumeric.ValidateProfile(*p.Numeric); err != nil {
return err
}
for _, op := range p.Numeric.AllowedOperators {
allowed = append(allowed, string(op))
}
if len(allowed) != len(p.Operators) {
return fmt.Errorf("numeric operators must match their authoritative profile")
}
default:
return fmt.Errorf("unsupported search kind")
}
seen := map[string]bool{}
for _, op := range p.Operators {
if seen[op] || !slices.Contains(allowed, op) {
return fmt.Errorf("unsupported or duplicate search operator")
}
seen[op] = true
}
if p.Kind == "numeric" {
if p.TextRule != "" || p.Delimiter != "" {
return fmt.Errorf("numeric profile cannot declare text rules")
}
} else {
if p.Numeric != nil || p.TextRule != TextRule {
return fmt.Errorf("unsupported text rule")
}
if p.Kind == "multi_choice" {
if strings.TrimSpace(p.Delimiter) == "" || len(p.Delimiter) > 8 {
return fmt.Errorf("explicit literal delimiter required")
}
} else if p.Delimiter != "" {
return fmt.Errorf("delimiter only applies to declared multiple choices")
}
}
return nil
}

func FoldText(value string) string { return searchtext.Fold(strings.Join(strings.Fields(value), " ")) }
func Values(raw string, p Profile) ([]string, error) {
if err := ValidateProfile(p); err != nil {
return nil, err
}
if p.Kind == "numeric" {
return nil, fmt.Errorf("numeric facts require controlled adoption")
}
parts := []string{raw}
if p.Kind == "multi_choice" {
parts = strings.Split(raw, p.Delimiter)
}
var result []string
for _, part := range parts {
value := FoldText(part)
if value != "" && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result, nil
}
13 changes: 12 additions & 1 deletion internal/cataloglisting/listing.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ type CategoryNode struct {
}

func Resolve(views []publishing.PublicView, configuration site.Configuration, request Request) (Result, error) {
return resolve(views, nil, configuration, request)
}

func ResolveSnapshot(snapshot publishing.CatalogSnapshot, request Request) (Result, error) {
return resolve(snapshot.Views, snapshot.ScopeViews, snapshot.Site, request)
}

func resolve(views, columnViews []publishing.PublicView, configuration site.Configuration, request Request) (Result, error) {
if request.Page < 1 {
request.Page = 1
}
Expand All @@ -104,6 +112,9 @@ func Resolve(views []publishing.PublicView, configuration site.Configuration, re
}
profile, hasProfile := configuration.CategoryListingProfiles[request.Scope.ID]
common := commonApplicable(localized)
if columnViews != nil {
common = commonApplicable(columnViews)
}
columns := resolveColumns(request.Scope, profile, hasProfile, common)
sortKey, direction, err := resolveSort(request, profile, hasProfile)
if err != nil {
Expand Down Expand Up @@ -214,7 +225,7 @@ func commonApplicable(views []publishing.PublicView) map[string]publishing.Appli
applicable[spec.ID] = spec
}
for id := range common {
if _, exists := applicable[id]; !exists {
if other, exists := applicable[id]; !exists || other.SemanticVersion != common[id].SemanticVersion {
delete(common, id)
}
}
Expand Down
19 changes: 19 additions & 0 deletions internal/cataloglisting/listing_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cataloglisting

import (
"fmt"
"reflect"
"testing"

Expand All @@ -9,6 +10,24 @@ import (
"prods/internal/site"
)

func TestFilteredSnapshotPaginatesWithScopeColumns(t *testing.T) {
var views []publishing.PublicView
for i := range 27 {
views = append(views, listingView(fmt.Sprintf("p%02d", i), fmt.Sprintf("P%02d", i), "cat", []publishing.ApplicableSpecification{spec("common"), spec("only_filtered")}, nil))
}
scope := append(append([]publishing.PublicView{}, views...), listingView("other", "OTHER", "other", []publishing.ApplicableSpecification{spec("common")}, nil))
snapshot := publishing.CatalogSnapshot{Views: views, ScopeViews: scope, Site: site.DefaultConfiguration()}
got, err := ResolveSnapshot(snapshot, Request{Scope: Scope{Kind: ScopeAll}, Page: 2, PageSize: 24})
if err != nil || got.Total != 27 || len(got.Rows) != 3 || got.Rows[0].ProductID != "p24" {
t.Fatalf("page: %+v %v", got, err)
}
for _, column := range got.Columns {
if column.Key == "spec:only_filtered" {
t.Fatal("filtered subset expanded common fields")
}
}
}

func TestCommonUsesApplicabilityAcrossFullScopeNotCurrentPageValues(t *testing.T) {
input := []publishing.PublicView{
listingView("ldo-1", "LDO-1", "cat_ldo", []publishing.ApplicableSpecification{spec("vin"), spec("vout"), spec("iout"), spec("iq")}, map[string]string{"vin": "3-6 V", "vout": "3.3 V"}),
Expand Down
19 changes: 13 additions & 6 deletions internal/distribution/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"prods/internal/catalogfilter"
"runtime"
"strings"
"sync"
Expand Down Expand Up @@ -70,12 +71,13 @@ type DictionaryEntry struct {
}

type SpecDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
PreferredUnit string `json:"preferred_unit,omitempty"`
Filterable bool `json:"filterable"`
SemanticVersion int64 `json:"semantic_version"`
Status string `json:"status,omitempty"`
SearchProfile *catalogfilter.Profile `json:"search_profile,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
PreferredUnit string `json:"preferred_unit,omitempty"`
Filterable bool `json:"filterable"`
SemanticVersion int64 `json:"semantic_version"`
Status string `json:"status,omitempty"`
}

type SpecSet struct {
Expand Down Expand Up @@ -244,6 +246,11 @@ func validateSampleData(sample SampleData, version string) error {
}
specIDs := make(map[string]struct{}, len(sample.Specs))
for _, spec := range sample.Specs {
if spec.SearchProfile != nil {
if err := catalogfilter.ValidateProfile(*spec.SearchProfile); err != nil {
return fmt.Errorf("sample specification %q search profile: %w", spec.ID, err)
}
}
if !validSampleID(spec.ID) || !validSampleText(spec.Name, 1, 500) ||
!validSampleText(spec.PreferredUnit, 0, 120) || spec.SemanticVersion < 1 ||
(spec.Status != "" && !oneOf(spec.Status, "active", "disabled")) {
Expand Down
Binary file modified internal/distribution/prods-sample-data-v1.json.gz
Binary file not shown.
Loading
Loading