Responds to PR #5 review comment: consolidate on common/errors.ErrNotFound instead of having two separate error types (domain.PlainError and common/errors.SimpleError) for the same sentinel. - Remove domain.ErrNotFound and domain.PlainError type - Keep domain.ErrNotImplemented as alias to common/errors.ErrNotImplemented - Update sqlite_organization_repository to return cerrors.ErrNotFound - Update sqlite_learning_category_repository to return cerrors.ErrNotFound - Update server.go handleError to check only errors.ErrNotFound - Update test to assert errors.ErrNotFound - Remove unused domain import from server.go
331 lines
8.3 KiB
Go
331 lines
8.3 KiB
Go
package adapters
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
cerrors "git.loyso.art/frx/kurious/internal/common/errors"
|
|
"git.loyso.art/frx/kurious/internal/common/xslices"
|
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
var (
|
|
organizationColumns = []string{
|
|
"id",
|
|
"external_id",
|
|
"alias",
|
|
"name",
|
|
"site",
|
|
"logo",
|
|
"created_at",
|
|
"updated_at",
|
|
"deleted_at",
|
|
}
|
|
|
|
organizationColumnsStr = joinColumns(organizationColumns)
|
|
organizationColumnsArgsStr = namedArgColumns(organizationColumns)
|
|
)
|
|
|
|
type organizationStatDB struct {
|
|
ID string `db:"id"`
|
|
ExternalID sql.NullString `db:"external_id"`
|
|
Name string `db:"name"`
|
|
CoursesCount uint64 `db:"courses_count"`
|
|
}
|
|
|
|
func (s organizationStatDB) AsDomain() domain.OrganizationStat {
|
|
return domain.OrganizationStat{
|
|
ID: s.ID,
|
|
ExternalID: nullStringAsDomain(s.ExternalID),
|
|
Name: s.Name,
|
|
CoursesCount: s.CoursesCount,
|
|
}
|
|
}
|
|
|
|
type organizationDB struct {
|
|
ID string `db:"id"`
|
|
ExternalID sql.NullString `db:"external_id"`
|
|
Alias string `db:"alias"`
|
|
Name string `db:"name"`
|
|
Site string `db:"site"`
|
|
Logo string `db:"logo"`
|
|
CreatedAt time.Time `db:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at"`
|
|
DeletedAt sql.NullTime `db:"deleted_at"`
|
|
}
|
|
|
|
func (o organizationDB) AsDomain() domain.Organization {
|
|
return domain.Organization{
|
|
ID: o.ID,
|
|
ExternalID: nullStringAsDomain(o.ExternalID),
|
|
Alias: o.Alias,
|
|
Name: o.Name,
|
|
Site: o.Site,
|
|
LogoLink: o.Logo,
|
|
CreatedAt: o.CreatedAt,
|
|
UpdatedAt: o.UpdatedAt,
|
|
DeletedAt: nullTimeAsDomain(o.DeletedAt),
|
|
}
|
|
}
|
|
|
|
func (o *organizationDB) FromDomain(in domain.Organization) {
|
|
*o = organizationDB{
|
|
ID: in.ID,
|
|
ExternalID: nullableValueAsString(in.ExternalID),
|
|
Alias: in.Alias,
|
|
Name: in.Name,
|
|
Site: in.Site,
|
|
Logo: in.LogoLink,
|
|
CreatedAt: in.CreatedAt,
|
|
UpdatedAt: in.UpdatedAt,
|
|
DeletedAt: nullableValueAsTime(in.DeletedAt),
|
|
}
|
|
}
|
|
|
|
func (c *sqliteConnection) Organization() domain.OrganizationRepository {
|
|
return &sqliteOrganizationRepository{
|
|
db: c.db,
|
|
log: c.log.With("repository", "organization"),
|
|
}
|
|
}
|
|
|
|
type sqliteOrganizationRepository struct {
|
|
db *sqlx.DB
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (r *sqliteOrganizationRepository) ListStats(
|
|
ctx context.Context,
|
|
params domain.ListOrganizationsParams,
|
|
) (out []domain.OrganizationStat, err error) {
|
|
const queryTemplate = `WITH cte as (
|
|
SELECT learning_type, course_thematic, organization_id, count(id) as courses_count
|
|
FROM courses
|
|
WHERE 1=1 {whereSuffix}
|
|
GROUP BY learning_type, course_thematic, organization_id
|
|
) SELECT o.id as id, o.external_id as external_id, o.name as name, cte.courses_count as courses_count
|
|
FROM cte
|
|
INNER JOIN organizations o ON o.id = cte.organization_id`
|
|
|
|
whereSuffix := ""
|
|
|
|
args := make([]any, 0, 3)
|
|
if params.LearningTypeID != "" {
|
|
whereSuffix += " AND learning_type = ?"
|
|
args = append(args, params.LearningTypeID)
|
|
}
|
|
if params.CourseThematicID != "" {
|
|
whereSuffix += " AND course_thematic = ?"
|
|
args = append(args, params.CourseThematicID)
|
|
}
|
|
|
|
query := strings.Replace(queryTemplate, "{whereSuffix}", whereSuffix, 1)
|
|
ctx, span := dbTracer.Start(
|
|
ctx, "list_stats courses",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
r.mergeAttributes(
|
|
dbOperationAttr.String("SELECT"),
|
|
dbStatementAttr.String(query),
|
|
)...,
|
|
),
|
|
)
|
|
defer func() {
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
}
|
|
span.End()
|
|
}()
|
|
|
|
var stats []organizationStatDB
|
|
err = r.db.SelectContext(ctx, &stats, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("executing query: %w", err)
|
|
}
|
|
|
|
return xslices.Map(stats, asDomainFunc), nil
|
|
}
|
|
|
|
func (r *sqliteOrganizationRepository) List(ctx context.Context, params domain.ListOrganizationsParams) (out []domain.Organization, err error) {
|
|
const queryTemplate = `SELECT %s FROM organizations WHERE 1=1`
|
|
query := fmt.Sprintf(queryTemplate, organizationColumnsStr)
|
|
|
|
args := make([]any, 0, len(params.IDs))
|
|
if len(params.IDs) > 0 {
|
|
args = append(
|
|
args,
|
|
xslices.Map(params.IDs, func(t string) any { return t })...,
|
|
)
|
|
queryParam := strings.TrimSuffix(strings.Repeat("?,", len(args)), ",")
|
|
query += " AND id IN (" + queryParam + ")"
|
|
}
|
|
|
|
ctx, span := dbTracer.Start(
|
|
ctx, "list courses.organizations",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
r.mergeAttributes(
|
|
dbOperationAttr.String("SELECT"),
|
|
dbStatementAttr.String(query),
|
|
)...,
|
|
),
|
|
)
|
|
defer func() {
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
}
|
|
span.End()
|
|
}()
|
|
|
|
organizations := make([]organizationDB, 0, 1<<8)
|
|
err = r.db.SelectContext(ctx, &organizations, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("executing query: %w", err)
|
|
}
|
|
|
|
return xslices.Map(organizations, asDomainFunc), nil
|
|
}
|
|
|
|
func (r *sqliteOrganizationRepository) Get(ctx context.Context, params domain.GetOrganizationParams) (out domain.Organization, err error) {
|
|
const queryTemplate = "SELECT %s FROM organizations WHERE 1=1"
|
|
query := fmt.Sprintf(queryTemplate, organizationColumnsStr)
|
|
args := make([]any, 0, 2)
|
|
if params.ID.Valid() {
|
|
args = append(args, params.ID.Value())
|
|
query += " AND id = ?"
|
|
}
|
|
if params.ExternalID.Valid() {
|
|
args = append(args, params.ExternalID.Value())
|
|
query += " AND external_id = ?"
|
|
}
|
|
|
|
ctx, span := dbTracer.Start(
|
|
ctx, "get courses.organizations",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
r.mergeAttributes(
|
|
dbOperationAttr.String("SELECT"),
|
|
dbStatementAttr.String(query),
|
|
)...,
|
|
),
|
|
)
|
|
defer func() {
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
}
|
|
span.End()
|
|
}()
|
|
|
|
var orgdb organizationDB
|
|
err = r.db.GetContext(ctx, &orgdb, query, args...)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return out, cerrors.ErrNotFound
|
|
}
|
|
return out, fmt.Errorf("executing query: %w", err)
|
|
}
|
|
|
|
return orgdb.AsDomain(), nil
|
|
}
|
|
|
|
func (r *sqliteOrganizationRepository) Create(ctx context.Context, params domain.CreateOrganizationParams) (out domain.Organization, err error) {
|
|
const queryTemplate = `INSERT INTO organizations (%[1]s) VALUES (%[2]s) RETURNING %[1]s`
|
|
query := fmt.Sprintf(queryTemplate, organizationColumnsStr, organizationColumnsArgsStr)
|
|
|
|
ctx, span := dbTracer.Start(
|
|
ctx, "create courses.organizations",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
r.mergeAttributes(
|
|
dbOperationAttr.String("INSERT"),
|
|
dbStatementAttr.String(query),
|
|
)...,
|
|
),
|
|
)
|
|
defer func() {
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
}
|
|
span.End()
|
|
}()
|
|
|
|
stmt, err := r.db.PrepareNamedContext(ctx, query)
|
|
if err != nil {
|
|
return out, fmt.Errorf("preparing statement: %w", err)
|
|
}
|
|
|
|
var orgdb organizationDB
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
err = stmt.GetContext(ctx, &orgdb, organizationDB{
|
|
ID: params.ID,
|
|
ExternalID: nullableValueAsString(params.ExternalID),
|
|
Alias: params.Alias,
|
|
Name: params.Name,
|
|
Site: params.Site,
|
|
Logo: params.LogoLink,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
DeletedAt: sql.NullTime{},
|
|
})
|
|
if err != nil {
|
|
return out, fmt.Errorf("executing query: %w", err)
|
|
}
|
|
|
|
return orgdb.AsDomain(), nil
|
|
}
|
|
|
|
func (r *sqliteOrganizationRepository) Delete(ctx context.Context, id string) (err error) {
|
|
const query = `DELETE FROM organizations WHERE id = ?`
|
|
|
|
ctx, span := dbTracer.Start(
|
|
ctx, "delete courses.organizations",
|
|
trace.WithSpanKind(trace.SpanKindClient),
|
|
trace.WithAttributes(
|
|
r.mergeAttributes(
|
|
dbOperationAttr.String("DELETE"),
|
|
dbStatementAttr.String(query),
|
|
)...,
|
|
),
|
|
)
|
|
defer func() {
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
}
|
|
span.End()
|
|
}()
|
|
|
|
result, err := r.db.ExecContext(ctx, query, id)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return cerrors.ErrNotFound
|
|
}
|
|
return fmt.Errorf("executing query: %w", err)
|
|
}
|
|
|
|
affected, _ := result.RowsAffected()
|
|
if affected == 0 {
|
|
return cerrors.ErrNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *sqliteOrganizationRepository) mergeAttributes(custom ...attribute.KeyValue) []attribute.KeyValue {
|
|
outbase := append(
|
|
getSqliteBaseAttributes(),
|
|
dbTableAttr.String("organizaitons"),
|
|
)
|
|
|
|
return append(outbase, custom...)
|
|
}
|