Files
kurious/internal/kurious/adapters/sqlite_organization_repository.go
Aleksandr Trushkin 9088caf600 fix: unify ErrNotFound — remove domain.ErrNotFound, use errors.ErrNotFound everywhere
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
2026-06-28 07:56:13 +00:00

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...)
}