Przeglądaj źródła

Implimentations

Alexey Kim 2 lat temu
rodzic
commit
5a31f7ef41

+ 8 - 0
cache/key.go

@@ -0,0 +1,8 @@
+package cache
+
+import "time"
+
+type Key interface {
+	TTL() time.Duration
+	String() string
+}

+ 49 - 0
cache/sql.go

@@ -0,0 +1,49 @@
+package cache
+
+import (
+	"fmt"
+	"hash/fnv"
+	"strconv"
+	"time"
+)
+
+type SqlKey struct {
+	format string
+	ttl    time.Duration
+	table  string
+	clause string
+	args   []any
+}
+
+func NewSQLKey(tName string, t time.Duration, clause string, args ...any) *SqlKey {
+	h := fnv.New32a()
+	_, _ = h.Write([]byte(tName))
+
+	return &SqlKey{
+		format: fmt.Sprintf("sql_%s_%s", strconv.Itoa(int(h.Sum32())), "%v"),
+		ttl:    t,
+		table:  tName,
+		clause: clause,
+		args:   args,
+	}
+}
+
+func (k *SqlKey) Table() string {
+	return k.table
+}
+
+func (k *SqlKey) Clause() string {
+	return k.clause
+}
+
+func (k *SqlKey) Args() []any {
+	return k.args
+}
+
+func (k *SqlKey) TTL() time.Duration {
+	return k.ttl
+}
+
+func (k *SqlKey) String() string {
+	return fmt.Sprintf(k.format, k.args...)
+}

+ 63 - 0
cmd/main.go

@@ -0,0 +1,63 @@
+package main
+
+import (
+	"context"
+	"github.com/99designs/gqlgen/graphql/handler"
+	"github.com/gshopify/service-wrapper/config"
+	"github.com/gshopify/service-wrapper/server"
+	"github.com/spf13/pflag"
+	"gshopper.com/gshopify/products/graphql"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+)
+
+var (
+	signals  = make(chan os.Signal, 1)
+	fPort    = pflag.IntP("port", "p", 80, "exposing port")
+	fTimeout = pflag.DurationP("timeout", "t", 15*time.Second, "timeout duration")
+	fDebug   = pflag.Bool("debug", false, "debug mode")
+)
+
+func init() {
+	pflag.Parse()
+
+	config.Instance()
+	config.PrintBanner()
+}
+
+func main() {
+	opts, err := server.NewDefaultOpts(*fPort, *fTimeout)
+	if err != nil {
+		panic(err)
+	}
+
+	resolver, err := graphql.New(context.Background(), *fDebug)
+	if err != nil {
+		panic(err)
+	}
+
+	srv := server.NewServer(handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{
+		Resolvers: resolver,
+	})), opts)
+
+	go func() {
+		if err := srv.ListenAndServe(); err != nil {
+			log.Println(err)
+		}
+	}()
+
+	signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
+	<-signals
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+	defer cancel()
+
+	_ = srv.Shutdown(ctx)
+
+	log.Println("shutting down")
+	os.Exit(0)
+}

+ 245 - 0
db/clickhouse.go

@@ -0,0 +1,245 @@
+package db
+
+import (
+	"context"
+	"fmt"
+	"github.com/gshopify/service-wrapper/config"
+	"github.com/gshopify/service-wrapper/db"
+	"github.com/gshopify/service-wrapper/model"
+	"github.com/jellydator/ttlcache/v3"
+	"github.com/mailru/dbr"
+	_ "github.com/mailru/go-clickhouse"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"gshopper.com/gshopify/products/relation"
+	"net/url"
+	"strings"
+	"sync"
+)
+
+type clickhouse struct {
+	ctx     context.Context
+	config  *db.Config
+	session *dbr.Session
+	cache   *ttlcache.Cache[string, any]
+}
+
+func New(ctx context.Context, forceDebug bool) (Database, error) {
+	r := &clickhouse{
+		ctx:    ctx,
+		config: db.New(),
+		cache: ttlcache.New[string, any](
+			ttlcache.WithTTL[string, any](cacheTimeout),
+			ttlcache.WithCapacity[string, any](cacheCapacity),
+		),
+	}
+
+	if err := config.Instance().Load(ctx, r.config); err != nil {
+		return nil, err
+	}
+
+	if forceDebug {
+		r.config.Params.Debug = true
+	}
+
+	//goland:noinspection HttpUrlsUsage
+	source, err := url.Parse(fmt.Sprintf("http://%s:%s@%s:%d/%s",
+		url.QueryEscape(r.config.Username),
+		url.QueryEscape(r.config.Password),
+		r.config.Host,
+		r.config.Port,
+		r.config.Database))
+	if err != nil {
+		return nil, err
+	}
+
+	kv := make(url.Values)
+	kv.Set("timeout", fmt.Sprintf("%ds", r.config.Params.Timeout))
+	kv.Set("read_timeout", fmt.Sprintf("%ds", r.config.Params.ReadTimeout))
+	kv.Set("write_timeout", fmt.Sprintf("%ds", r.config.Params.WriteTimeout))
+	kv.Set("debug", fmt.Sprintf("%v", r.config.Params.Debug))
+	source.RawQuery = kv.Encode()
+
+	con, err := dbr.Open("clickhouse", source.String(), nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not establish Clickhouse session: %v", err)
+	}
+
+	r.session = con.NewSessionContext(ctx, nil)
+
+	if err = r.session.Ping(); err != nil {
+		return nil, err
+	}
+
+	go r.cache.Start()
+	return r, nil
+}
+
+func (db *clickhouse) ProductCollections(ln model.LanguageCode, id string) ([]*generated.Collection, error) {
+	var (
+		collections []*generated.Collection
+		key         = productCollectionKey("product.id=?", id)
+		l           = ttlcache.LoaderFunc[string, any](
+			func(ttl *ttlcache.Cache[string, any], _ string) *ttlcache.Item[string, any] {
+				var o []relation.Collection
+
+				rows, err := db.session.SelectBySql("SELECT "+
+					ln.SqlFieldSelection("title")+", "+ln.SqlFieldSelection("description")+", `id`, `handle`, `thumbnail`, "+
+					"`created_at`, `updated_at`, `deleted_at` "+
+					"FROM `product_collection` "+
+					"ARRAY JOIN (SELECT `collection_ids` FROM `product` WHERE `id` = ?) AS cid "+
+					"WHERE `id` = cid "+
+					"ORDER BY `created_at` ASC;", key.Args()...).
+					Load(&o)
+				if rows < 1 || err != nil {
+					return nil
+				}
+
+				return ttl.Set(key.String(), o, key.TTL())
+			})
+	)
+
+	p := db.cache.Get(key.String(), ttlcache.WithLoader[string, any](l))
+	if p == nil {
+		return nil, fmt.Errorf("not found")
+	}
+
+	for _, row := range p.Value().([]relation.Collection) {
+		collections = append(collections, row.As())
+	}
+
+	return collections, nil
+}
+
+func (db *clickhouse) Product(ln model.LanguageCode, handle *string, id *string) (*generated.Product, error) {
+	var (
+		clause = strings.Builder{}
+		vars   = []any{relation.ProductStatusPublished}
+	)
+
+	clause.WriteString("status=?")
+
+	if id != nil {
+		clause.WriteString(" AND id=?")
+		vars = append(vars, *id)
+	}
+
+	if handle != nil {
+		clause.WriteString(" AND handle=?")
+		vars = append(vars, *handle)
+	}
+
+	var (
+		key = productKey(clause.String(), vars...)
+		l   = ttlcache.LoaderFunc[string, any](
+			func(ttl *ttlcache.Cache[string, any], _ string) *ttlcache.Item[string, any] {
+				o := relation.Product{}
+				rows, err := db.session.
+					Select(productSelection(ln)...).
+					From(key.Table()).
+					Where(key.Clause(), key.Args()...).
+					OrderBy("created_at").
+					Limit(1).
+					Load(&o)
+				if rows < 1 || err != nil {
+					return nil
+				}
+
+				return ttl.Set(key.String(), o, key.TTL())
+			})
+	)
+
+	p := db.cache.Get(key.String(), ttlcache.WithLoader[string, any](l))
+	if p == nil {
+		return nil, fmt.Errorf("not found")
+	}
+
+	product := p.Value().(relation.Product)
+	return product.As(), nil
+}
+
+func (db *clickhouse) ProductOptions(ln model.LanguageCode, id string) ([]*generated.ProductOption, error) {
+	var options []*generated.ProductOption
+	var o []relation.ProductOption
+	_, err := db.session.
+		Select(
+			"id",
+			"product_id",
+			"created_at", "updated_at", "deleted_at",
+			ln.SqlFieldSelection("name"),
+			ln.SqlArraySelection("values")).
+		From("product_option").
+		Where("product_id=?", id).
+		OrderBy("created_at").
+		Load(&o)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, v := range o {
+		options = append(options, v.As())
+	}
+
+	return options, nil
+}
+
+func (db *clickhouse) CollectionProducts(ln model.LanguageCode, id string) ([]*generated.Product, error) {
+	var (
+		products []*generated.Product
+		key      = productKey("has(collection_ids, ?)", id)
+		l        = ttlcache.LoaderFunc[string, any](
+			func(ttl *ttlcache.Cache[string, any], _ string) *ttlcache.Item[string, any] {
+				var o []relation.Product
+				rows, err := db.session.
+					Select(productSelection(ln)...).
+					From(key.Table()).
+					Where(key.Clause(), key.Args()...).
+					OrderBy("created_at").
+					Load(&o)
+				if rows < 1 || err != nil {
+					return nil
+				}
+
+				return ttl.Set(key.String(), o, key.TTL())
+			},
+		)
+	)
+
+	p := db.cache.Get(key.String(), ttlcache.WithLoader[string, any](l))
+	if p == nil {
+		return nil, fmt.Errorf("not found")
+	}
+
+	for _, row := range p.Value().([]relation.Product) {
+		products = append(products, row.As())
+	}
+
+	return products, nil
+}
+
+func (db *clickhouse) Ping() error {
+	return db.session.Ping()
+}
+
+func (db *clickhouse) Close() error {
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		defer wg.Done()
+
+		if db.cache != nil {
+			db.cache.DeleteAll()
+			db.cache.Stop()
+		}
+	}()
+
+	go func() {
+		defer wg.Done()
+
+		if db.session != nil {
+			_ = db.session.Close()
+		}
+	}()
+
+	return nil
+}

+ 22 - 0
db/database.go

@@ -0,0 +1,22 @@
+package db
+
+import (
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"time"
+)
+
+const (
+	cacheTimeout  = time.Hour
+	cacheCapacity = 4096
+)
+
+type Database interface {
+	Ping() error
+	Close() error
+
+	Product(ln model.LanguageCode, handle *string, id *string) (*generated.Product, error)
+	ProductCollections(ln model.LanguageCode, id string) ([]*generated.Collection, error)
+	ProductOptions(ln model.LanguageCode, id string) ([]*generated.ProductOption, error)
+	CollectionProducts(ln model.LanguageCode, id string) ([]*generated.Product, error)
+}

+ 37 - 0
db/product.go

@@ -0,0 +1,37 @@
+package db
+
+import (
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/cache"
+	"time"
+)
+
+var (
+	productKey = func(clause string, args ...any) *cache.SqlKey {
+		return cache.NewSQLKey(
+			"product",
+			time.Minute,
+			clause,
+			args...,
+		)
+	}
+	productSelection = func(ln model.LanguageCode) []string {
+		return append([]string{
+			"id", "profile_id", "collection_ids", "type_id", "handle", "thumbnail", "is_giftcard", "discountable",
+			"hs_code", "mid_code", "weight", "length", "height", "width", "origin_country", "vendor", "material",
+			"created_at", "updated_at", "deleted_at", "tags", "status",
+		},
+			ln.SqlFieldSelection("title"),
+			ln.SqlFieldSelection("subtitle"),
+			ln.SqlFieldSelection("description"),
+		)
+	}
+	productCollectionKey = func(clause string, args ...any) *cache.SqlKey {
+		return cache.NewSQLKey(
+			"product_collection",
+			3*time.Minute,
+			clause,
+			args...,
+		)
+	}
+)

+ 53 - 0
graphql/helper/collection.go

@@ -0,0 +1,53 @@
+package helper
+
+import (
+	"fmt"
+	"github.com/gshopify/service-wrapper/fun"
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/graphql/generated"
+)
+
+func NewCollectionConnection(src []*generated.Collection,
+	after *string, before *string, first *int, last *int, reverse *bool) (*generated.CollectionConnection, error) {
+	var (
+		connection = &generated.CollectionConnection{}
+		err        error
+	)
+
+	if len(src) == 0 {
+		return connection, nil
+	}
+
+	if reverse != nil && *reverse {
+		src = fun.Reverse(src)
+	}
+
+	connection.Nodes = src[:]
+	connection.Nodes = fun.First(connection.Nodes, first)
+	connection.Nodes = fun.Last(connection.Nodes, last)
+
+	if connection.Nodes, err = fun.WithCursor(connection.Nodes, after, model.GidCollection, true); err != nil {
+		return nil, fmt.Errorf("illegal cursor: %s", *after)
+	}
+
+	if connection.Nodes, err = fun.WithCursor(connection.Nodes, before, model.GidCollection, false); err != nil {
+		return nil, fmt.Errorf("illegal cursor: %s", *before)
+	}
+
+	connection.PageInfo = NewPageInfo(connection.Nodes, model.GidCollection, src[0], src[len(src)-1])
+
+	// Edges
+	for i := range connection.Nodes {
+		e := &generated.CollectionEdge{
+			Node: connection.Nodes[i],
+		}
+
+		if c, err := model.NewSimpleCursor(e.Node.ID, model.GidCollection); err == nil {
+			e.Cursor = *c.String()
+		}
+
+		connection.Edges = append(connection.Edges, e)
+	}
+
+	return connection, nil
+}

+ 30 - 0
graphql/helper/page_info.go

@@ -0,0 +1,30 @@
+package helper
+
+import (
+	"github.com/gshopify/service-wrapper/interfaces"
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/graphql/generated"
+)
+
+func NewPageInfo[T interfaces.Node](src []T, namespace model.Gid, first T, last T) *generated.PageInfo {
+	var (
+		size = len(src)
+		info = &generated.PageInfo{}
+	)
+
+	if size == 0 {
+		return info
+	}
+
+	if c, err := model.NewSimpleCursor(src[0].GetID(), namespace); err == nil {
+		info.StartCursor = c.String()
+	}
+
+	if c, err := model.NewSimpleCursor(src[size-1].GetID(), namespace); err == nil {
+		info.EndCursor = c.String()
+	}
+
+	info.HasPreviousPage = first.GetID() != src[0].GetID()
+	info.HasNextPage = info.StartCursor == info.EndCursor || last.GetID() != src[size-1].GetID()
+	return info
+}

+ 55 - 0
graphql/helper/product.go

@@ -0,0 +1,55 @@
+package helper
+
+import (
+	"fmt"
+	"github.com/gshopify/service-wrapper/fun"
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/graphql/generated"
+)
+
+func NewProductConnection(src []*generated.Product,
+	after *string, before *string, first *int, last *int, reverse *bool) (*generated.ProductConnection, error) {
+	var (
+		connection = &generated.ProductConnection{}
+		err        error
+	)
+
+	if len(src) == 0 {
+		return connection, nil
+	}
+
+	if reverse != nil && *reverse {
+		src = fun.Reverse(src)
+	}
+
+	connection.Nodes = src[:]
+	connection.Nodes = fun.First(connection.Nodes, first)
+	connection.Nodes = fun.Last(connection.Nodes, last)
+
+	connection.Nodes, err = fun.WithCursor(connection.Nodes, after, model.GidProduct, true)
+	if err != nil {
+		return nil, fmt.Errorf("illegal cursor: %s", *after)
+	}
+
+	connection.Nodes, err = fun.WithCursor(connection.Nodes, before, model.GidProduct, false)
+	if err != nil {
+		return nil, fmt.Errorf("illegal cursor: %s", *before)
+	}
+
+	connection.PageInfo = NewPageInfo(connection.Nodes, model.GidProduct, src[0], src[len(src)-1])
+
+	// Edges
+	for i := range connection.Nodes {
+		e := &generated.ProductEdge{
+			Node: connection.Nodes[i],
+		}
+
+		if c, err := model.NewSimpleCursor(e.Node.ID, model.GidCollection); err == nil {
+			e.Cursor = *c.String()
+		}
+
+		connection.Edges = append(connection.Edges, e)
+	}
+
+	return connection, nil
+}

+ 45 - 0
graphql/query_collections.go

@@ -0,0 +1,45 @@
+package graphql
+
+import (
+	"context"
+	"github.com/gshopify/service-wrapper/fun"
+	"github.com/gshopify/service-wrapper/model"
+	"github.com/gshopify/service-wrapper/server/middleware"
+	"github.com/microcosm-cc/bluemonday"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"gshopper.com/gshopify/products/graphql/helper"
+)
+
+func (r *queryResolver) Collections(ctx context.Context, after *string, before *string, first *int, last *int, query *string, reverse *bool, sort *generated.CollectionSortKeys) (*generated.CollectionConnection, error) {
+	panic("not implemented")
+}
+
+func (r *productResolver) Collections(ctx context.Context, product *generated.Product,
+	after *string, before *string, first *int, last *int, reverse *bool) (*generated.CollectionConnection, error) {
+	var (
+		inContext   *middleware.GShopifyContext
+		collections []*generated.Collection
+		err         error
+	)
+
+	if inContext, err = middleware.InContext(ctx); err != nil {
+		return nil, err
+	}
+
+	rawId, err := model.ParseId(model.GidProduct, product.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	if collections, err = r.db.ProductCollections(inContext.Language, rawId); err != nil {
+		return nil, err
+	}
+
+	return helper.NewCollectionConnection(collections, after, before, first, last, reverse)
+}
+
+func (r *collectionResolver) Description(_ context.Context, collection *generated.Collection, truncateAt *int) (string, error) {
+	return fun.TruncateAt(
+		bluemonday.StrictPolicy().Sanitize(string(collection.DescriptionHTML)),
+		truncateAt)
+}

+ 103 - 0
graphql/query_product.go

@@ -0,0 +1,103 @@
+package graphql
+
+import (
+	"context"
+	"fmt"
+	"github.com/gshopify/service-wrapper/fun"
+	"github.com/gshopify/service-wrapper/model"
+	"github.com/gshopify/service-wrapper/server/middleware"
+	"github.com/microcosm-cc/bluemonday"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"gshopper.com/gshopify/products/graphql/helper"
+)
+
+func (r *queryResolver) Product(ctx context.Context, handle *string, id *string) (*generated.Product, error) {
+	var (
+		inContext *middleware.GShopifyContext
+		product   *generated.Product
+		err       error
+	)
+
+	if inContext, err = middleware.InContext(ctx); err != nil {
+		return nil, err
+	}
+
+	if id == nil && handle == nil {
+		return nil, fmt.Errorf("at least one argument must be specified")
+	}
+
+	if id != nil {
+		if s, err := model.ParseId(model.GidProduct, *id); err != nil {
+			return nil, err
+		} else {
+			*id = s
+		}
+	}
+
+	if product, err = r.db.Product(inContext.Language, handle, id); err != nil {
+		return nil, err
+	}
+
+	return product, nil
+}
+
+func (r *productResolver) Description(_ context.Context, product *generated.Product, truncateAt *int) (string, error) {
+	return fun.TruncateAt(
+		bluemonday.StrictPolicy().Sanitize(string(product.DescriptionHTML)),
+		truncateAt)
+}
+
+// Products retrieves Product list by collection
+// TODO: implement filters
+// TODO: implement sort
+func (r *collectionResolver) Products(ctx context.Context, collection *generated.Collection,
+	after *string, before *string, filters []*generated.ProductFilter, first *int, last *int, reverse *bool, sort *generated.ProductCollectionSortKeys) (*generated.ProductConnection, error) {
+	var (
+		inContext *middleware.GShopifyContext
+		products  []*generated.Product
+		err       error
+	)
+
+	if inContext, err = middleware.InContext(ctx); err != nil {
+		return nil, err
+	}
+
+	rawId, err := model.ParseId(model.GidCollection, collection.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	if products, err = r.db.CollectionProducts(inContext.Language, rawId); err != nil {
+		return nil, err
+	}
+
+	return helper.NewProductConnection(products, after, before, first, last, reverse)
+}
+
+func (r *productResolver) Options(ctx context.Context, product *generated.Product, first *int) ([]*generated.ProductOption, error) {
+	var (
+		inContext *middleware.GShopifyContext
+		options   []*generated.ProductOption
+		err       error
+	)
+
+	if inContext, err = middleware.InContext(ctx); err != nil {
+		return nil, err
+	}
+
+	rawId, err := model.ParseId(model.GidProduct, product.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	if options, err = r.db.ProductOptions(inContext.Language, rawId); err != nil {
+		return nil, err
+	}
+
+	return fun.First(options, first), nil
+}
+
+func (r *productResolver) Variants(ctx context.Context, product *generated.Product,
+	first *int, after *string, last *int, before *string, reverse *bool, sortKey *generated.ProductVariantSortKeys) (*generated.ProductVariantConnection, error) {
+	panic("not implemented")
+}

+ 41 - 0
relation/collection.go

@@ -0,0 +1,41 @@
+package relation
+
+import (
+	"github.com/gshopify/service-wrapper/model"
+	"github.com/gshopify/service-wrapper/scalar"
+	"github.com/mailru/dbr"
+	"github.com/microcosm-cc/bluemonday"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"time"
+)
+
+type Collection struct {
+	Id          string         `db:"id"`
+	Title       string         `db:"title"`
+	Description string         `db:"description"`
+	Handle      dbr.NullString `db:"handle"`
+	Thumbnail   dbr.NullString `db:"thumbnail"`
+	CreatedAt   time.Time      `db:"created_at"`
+	UpdatedAt   time.Time      `db:"updated_at"`
+	DeletedAt   *time.Time     `db:"deleted_at"`
+}
+
+func (c *Collection) As() *generated.Collection {
+	description := bluemonday.StrictPolicy().Sanitize(c.Description)
+	return &generated.Collection{
+		Description:     description,
+		DescriptionHTML: scalar.Html(c.Description),
+		Handle:          c.Handle.String,
+		ID:              model.NewId(model.GidCollection, c.Id),
+		Image:           nil, //TODO:
+		Metafield:       nil,
+		Metafields:      nil,
+		OnlineStoreURL:  nil, //TODO:
+		Seo: &generated.Seo{
+			Description: &description,
+			Title:       &c.Title,
+		},
+		Title:     c.Title,
+		UpdatedAt: scalar.NewDateTimeFrom(c.UpdatedAt),
+	}
+}

+ 75 - 0
relation/product.go

@@ -0,0 +1,75 @@
+package relation
+
+import (
+	"github.com/gshopify/service-wrapper/model"
+	"github.com/gshopify/service-wrapper/scalar"
+	"github.com/mailru/dbr"
+	"github.com/microcosm-cc/bluemonday"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"time"
+)
+
+type Product struct {
+	Id            string         `db:"id"`
+	Title         string         `db:"title"`
+	Subtitle      dbr.NullString `db:"subtitle"`
+	Description   dbr.NullString `db:"description"`
+	Handle        dbr.NullString `db:"handle"`
+	IsGiftcard    bool           `db:"is_giftcard"`
+	Thumbnail     dbr.NullString `db:"thumbnail"`
+	ProfileId     string         `db:"profile_id"`
+	Weight        dbr.NullInt64  `db:"weight"`
+	Length        dbr.NullInt64  `db:"length"`
+	Height        dbr.NullInt64  `db:"height"`
+	Width         dbr.NullInt64  `db:"width"`
+	HsCode        dbr.NullString `db:"hs_code"`
+	OriginCountry dbr.NullString `db:"origin_country"`
+	Vendor        dbr.NullString `db:"vendor"`
+	MidCode       dbr.NullString `db:"mid_code"`
+	Material      dbr.NullString `db:"material"`
+	CreatedAt     time.Time      `db:"created_at"`
+	UpdatedAt     time.Time      `db:"updated_at"`
+	DeletedAt     *time.Time     `db:"deleted_at"`
+	CollectionIds []string       `db:"collection_ids"`
+	TypeId        dbr.NullString `db:"type_id"`
+	Discountable  bool           `db:"discountable"`
+	Status        string         `db:"status"`
+	Tags          []string       `db:"tags"`
+	ExternalId    dbr.NullString `db:"external_id"`
+}
+
+func (p *Product) As() *generated.Product {
+	description := bluemonday.StrictPolicy().Sanitize(p.Description.String)
+	return &generated.Product{
+		AvailableForSale:    false,
+		CompareAtPriceRange: nil,
+		CreatedAt:           scalar.NewDateTimeFrom(p.CreatedAt),
+		Description:         description,
+		DescriptionHTML:     scalar.Html(p.Description.String),
+		FeaturedImage:       nil,
+		Handle:              p.Handle.String,
+		ID:                  model.NewId(model.GidProduct, p.Id),
+		Images:              nil,
+		IsGiftCard:          p.IsGiftcard,
+		Media:               nil,
+		Metafield:           nil,
+		Metafields:          nil,
+		OnlineStoreURL:      nil,
+		PriceRange:          nil,
+		ProductType:         p.TypeId.String,
+		PublishedAt:         scalar.NewDateTimeFrom(p.CreatedAt),
+		RequiresSellingPlan: false,
+		SellingPlanGroups:   nil,
+		Seo: &generated.Seo{
+			Description: &description,
+			Title:       &p.Title,
+		},
+		Tags:                     p.Tags,
+		Title:                    p.Title,
+		TotalInventory:           nil,
+		UpdatedAt:                scalar.NewDateTimeFrom(p.UpdatedAt),
+		VariantBySelectedOptions: nil,
+		Variants:                 "",
+		Vendor:                   p.Vendor.String,
+	}
+}

+ 25 - 0
relation/product_option.go

@@ -0,0 +1,25 @@
+package relation
+
+import (
+	"github.com/gshopify/service-wrapper/model"
+	"gshopper.com/gshopify/products/graphql/generated"
+	"time"
+)
+
+type ProductOption struct {
+	Id        string     `db:"id"`
+	Name      string     `db:"name"`
+	Values    []string   `db:"values"`
+	ProductId string     `db:"product_id"`
+	CreatedAt time.Time  `db:"created_at"`
+	UpdatedAt time.Time  `db:"updated_at"`
+	DeletedAt *time.Time `db:"deleted_at"`
+}
+
+func (opt *ProductOption) As() *generated.ProductOption {
+	return &generated.ProductOption{
+		ID:     model.NewId(model.GidOption, opt.Id),
+		Name:   opt.Name,
+		Values: opt.Values,
+	}
+}

+ 10 - 0
relation/product_status.go

@@ -0,0 +1,10 @@
+package relation
+
+type ProductStatus string
+
+const (
+	ProductStatusDraft     ProductStatus = "draft"
+	ProductStatusProposed  ProductStatus = "proposed"
+	ProductStatusPublished ProductStatus = "published"
+	ProductStatusRejected  ProductStatus = "rejected"
+)