Implement the following plan:

# Usage Dashboard Query Optimization

## Change 1: Partial indexes for running jobs

- **New `internal/repository/migrations/sqlite3/14_running-partial-indexes.up.sql`** — partial covering indexes `WHERE job_state = 'running'` for user/project/subcluster groupings. Only running jobs in B-tree (tiny vs millions).
- **New `internal/repository/migrations/sqlite3/14_running-partial-indexes.down.sql`** — drop them.
- **`internal/repository/migration.go:30`** — `Version` 13 → 14.
- **`internal/repository/jobQuery.go:200-202`** — inline literal state value (`fmt.Sprintf("job.job_state = '%s'", singleStat)`) so SQLite matches partial indexes. Safe: values from validated GraphQL enum.

## Change 2: Request-scoped cache for grouped stats (6 SQL → 2)

- **`internal/graph/stats_cache.go`** (new) — define `statsGroupCache` type (wraps `sync.Map`), context key, `getOrCompute` method using `sync.Once` per cache entry.
- **`cmd/cc-backend/server.go:87-88`** — add `graphQLServer.AroundOperations` middleware to inject empty cache into context per request.
- **`internal/graph/schema.resolvers.go:671-678`** — in `JobsStatistics`, when `groupBy != nil`: check cache for (filter+groupBy) key → on miss, call `JobsStatsGrouped` with nil sortBy/page (all results), cache them → sort+slice in Go per caller's sortBy/page.

## Change 3: Parallelize histogram queries

- **`internal/repository/stats.go:643-668`** — replace 4 sequential histogram calls in `AddHistograms` with `errgroup.WithContext`. Import `golang.org/x/sync/errgroup`. Each goroutine writes distinct struct field.

## Change 4: Frontend uses consolidated results

- **`web/frontend/src/status/dashdetails/UsageDash.svelte`** — replace 6-alias topStatsQuery with 2 aliases (USER, PROJECT) requesting all fields, no sort/page. Add 6 `$derived` vars to sort+slice top-10. Update template refs.

## Verification
```bash
cd web/frontend && npm run build && cd ../.. && go build ./... && go test ./internal/repository/...
```

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/jan/.claude/projects/-Users-jan-prg-ClusterCockpit-cc-backend/3643a384-f90b-4f89-9ba0-2e5861a4674c.jsonl


Let me read the key files first.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"database/sql"
    10→	"embed"
    11→	"fmt"
    12→
    13→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    14→	"github.com/golang-migrate/migrate/v4"
    15→	"github.com/golang-migrate/migrate/v4/database/sqlite3"
    16→	"github.com/golang-migrate/migrate/v4/source/iofs"
    17→)
    18→
    19→// Version is the current database schema version required by this version of cc-backend.
    20→// When the database schema changes, this version is incremented and a new migration file
    21→// is added to internal/repository/migrations/sqlite3/.
    22→//
    23→// Version history:
    24→//   - Version 13: Add covering indexes for status/dashboard queries (cluster, job_state, ...)
    25→//   - Version 12: Add covering index for stats queries (cluster, start_time, hpc_user, ...)
    26→//   - Version 11: Optimize job table indexes (reduce from ~78 to 48)
    27→//   - Version 10: Node table
    28→//
    29→// Migration files are embedded at build time from the migrations directory.
    30→const Version uint = 13
    31→
    32→//go:embed migrations/*
    33→var migrationFiles embed.FS
    34→
    35→// checkDBVersion verifies that the database schema version matches the expected version.
    36→// This is called automatically during Connect() to ensure schema compatibility.
    37→//
    38→// Returns an error if:
    39→//   - Database version is older than expected (needs migration)
    40→//   - Database version is newer than expected (needs app upgrade)
    41→//   - Database is in a dirty state (failed migration)
    42→//
    43→// A "dirty" database indicates a migration was started but not completed successfully.
    44→// This requires manual intervention to fix the database and force the version.
    45→func checkDBVersion(db *sql.DB) error {
    46→	driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
    47→	if err != nil {
    48→		return err
    49→	}
    50→	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
    51→	if err != nil {
    52→		return err
    53→	}
    54→
    55→	m, err := migrate.NewWithInstance("iofs", d, "sqlite3", driver)
    56→	if err != nil {
    57→		return err
    58→	}
    59→
    60→	v, dirty, err := m.Version()
    61→	if err != nil {
    62→		if err == migrate.ErrNilVersion {
    63→			cclog.Warn("Legacy database without version or missing database file!")
    64→		} else {
    65→			return err
    66→		}
    67→	}
    68→
    69→	if v < Version {
    70→		return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
    71→	} else if v > Version {
    72→		return fmt.Errorf("unsupported database version %d, need %d.\nPlease refer to documentation how to downgrade db with external migrate tool", v, Version)
    73→	}
    74→
    75→	if dirty {
    76→		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
    77→	}
    78→
    79→	return nil
    80→}
    81→
    82→// getMigrateInstance creates a new migration instance for the given database file.
    83→// This is used internally by MigrateDB, RevertDB, and ForceDB.
    84→func getMigrateInstance(db string) (m *migrate.Migrate, err error) {
    85→	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
    86→	if err != nil {
    87→		return nil, err
    88→	}
    89→
    90→	m, err = migrate.NewWithSourceInstance("iofs", d, fmt.Sprintf("sqlite3://%s?_foreign_keys=on", db))
    91→	if err != nil {
    92→		return nil, err
    93→	}
    94→
    95→	return m, nil
    96→}
    97→
    98→// MigrateDB applies all pending database migrations to bring the schema up to date.
    99→// This should be run with the -migrate-db flag before starting the application
   100→// after upgrading to a new version that requires schema changes.
   101→//
   102→// Process:
   103→//  1. Checks current database version
   104→//  2. Applies all migrations from current version to target Version
   105→//  3. Updates schema_migrations table to track applied migrations
   106→//
   107→// Important:
   108→//   - Always backup your database before running migrations
   109→//   - Migrations are irreversible without manual intervention
   110→//   - If a migration fails, the database is marked "dirty" and requires manual fix
   111→//
   112→// Usage:
   113→//
   114→//	cc-backend -migrate-db
   115→func MigrateDB(db string) error {
   116→	m, err := getMigrateInstance(db)
   117→	if err != nil {
   118→		return err
   119→	}
   120→
   121→	v, dirty, err := m.Version()
   122→	if err != nil {
   123→		if err == migrate.ErrNilVersion {
   124→			cclog.Info("Legacy database without version or missing database file!")
   125→		} else {
   126→			return err
   127→		}
   128→	}
   129→
   130→	if v < Version {
   131→		cclog.Infof("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
   132→	}
   133→
   134→	if dirty {
   135→		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
   136→	}
   137→
   138→	if err := m.Up(); err != nil {
   139→		if err == migrate.ErrNoChange {
   140→			cclog.Info("DB already up to date!")
   141→		} else {
   142→			return err
   143→		}
   144→	}
   145→
   146→	m.Close()
   147→	return nil
   148→}
   149→
   150→// RevertDB rolls back the database schema to the previous version (Version - 1).
   151→// This is primarily used for testing or emergency rollback scenarios.
   152→//
   153→// Warning:
   154→//   - This may cause data loss if newer schema added columns/tables
   155→//   - Always backup before reverting
   156→//   - Not all migrations are safely reversible
   157→//
   158→// Usage:
   159→//
   160→//	cc-backend -revert-db
   161→func RevertDB(db string) error {
   162→	m, err := getMigrateInstance(db)
   163→	if err != nil {
   164→		return err
   165→	}
   166→
   167→	if err := m.Migrate(Version - 1); err != nil {
   168→		if err == migrate.ErrNoChange {
   169→			cclog.Info("DB already up to date!")
   170→		} else {
   171→			return err
   172→		}
   173→	}
   174→
   175→	m.Close()
   176→	return nil
   177→}
   178→
   179→// ForceDB forces the database schema version to the current Version without running migrations.
   180→// This is only used to recover from failed migrations that left the database in a "dirty" state.
   181→//
   182→// When to use:
   183→//   - After manually fixing a failed migration
   184→//   - When you've manually applied schema changes and need to update the version marker
   185→//
   186→// Warning:
   187→//   - This does NOT apply any schema changes
   188→//   - Only use after manually verifying the schema is correct
   189→//   - Improper use can cause schema/version mismatch
   190→//
   191→// Usage:
   192→//
   193→//	cc-backend -force-db
   194→func ForceDB(db string) error {
   195→	m, err := getMigrateInstance(db)
   196→	if err != nil {
   197→		return err
   198→	}
   199→
   200→	if err := m.Force(int(Version)); err != nil {
   201→		return err
   202→	}
   203→
   204→	m.Close()
   205→	return nil
   206→}
   207→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package repository

import (
	"database/sql"
	"embed"
	"fmt"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite3"
	"github.com/golang-migrate/migrate/v4/source/iofs"
)

// Version is the current database schema version required by this version of cc-backend.
// When the database schema changes, this version is incremented and a new migration file
// is added to internal/repository/migrations/sqlite3/.
//
// Version history:
//   - Version 13: Add covering indexes for status/dashboard queries (cluster, job_state, ...)
//   - Version 12: Add covering index for stats queries (cluster, start_time, hpc_user, ...)
//   - Version 11: Optimize job table indexes (reduce from ~78 to 48)
//   - Version 10: Node table
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 13

//go:embed migrations/*
var migrationFiles embed.FS

// checkDBVersion verifies that the database schema version matches the expected version.
// This is called automatically during Connect() to ensure schema compatibility.
//
// Returns an error if:
//   - Database version is older than expected (needs migration)
//   - Database version is newer than expected (needs app upgrade)
//   - Database is in a dirty state (failed migration)
//
// A "dirty" database indicates a migration was started but not completed successfully.
// This requires manual intervention to fix the database and force the version.
func checkDBVersion(db *sql.DB) error {
	driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
	if err != nil {
		return err
	}
	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
	if err != nil {
		return err
	}

	m, err := migrate.NewWithInstance("iofs", d, "sqlite3", driver)
	if err != nil {
		return err
	}

	v, dirty, err := m.Version()
	if err != nil {
		if err == migrate.ErrNilVersion {
			cclog.Warn("Legacy database without version or missing database file!")
		} else {
			return err
		}
	}

	if v < Version {
		return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
	} else if v > Version {
		return fmt.Errorf("unsupported database version %d, need %d.\nPlease refer to documentation how to downgrade db with external migrate tool", v, Version)
	}

	if dirty {
		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
	}

	return nil
}

// getMigrateInstance creates a new migration instance for the given database file.
// This is used internally by MigrateDB, RevertDB, and ForceDB.
func getMigrateInstance(db string) (m *migrate.Migrate, err error) {
	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
	if err != nil {
		return nil, err
	}

	m, err = migrate.NewWithSourceInstance("iofs", d, fmt.Sprintf("sqlite3://%s?_foreign_keys=on", db))
	if err != nil {
		return nil, err
	}

	return m, nil
}

// MigrateDB applies all pending database migrations to bring the schema up to date.
// This should be run with the -migrate-db flag before starting the application
// after upgrading to a new version that requires schema changes.
//
// Process:
//  1. Checks current database version
//  2. Applies all migrations from current version to target Version
//  3. Updates schema_migrations table to track applied migrations
//
// Important:
//   - Always backup your database before running migrations
//   - Migrations are irreversible without manual intervention
//   - If a migration fails, the database is marked "dirty" and requires manual fix
//
// Usage:
//
//	cc-backend -migrate-db
func MigrateDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	v, dirty, err := m.Version()
	if err != nil {
		if err == migrate.ErrNilVersion {
			cclog.Info("Legacy database without version or missing database file!")
		} else {
			return err
		}
	}

	if v < Version {
		cclog.Infof("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
	}

	if dirty {
		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
	}

	if err := m.Up(); err != nil {
		if err == migrate.ErrNoChange {
			cclog.Info("DB already up to date!")
		} else {
			return err
		}
	}

	m.Close()
	return nil
}

// RevertDB rolls back the database schema to the previous version (Version - 1).
// This is primarily used for testing or emergency rollback scenarios.
//
// Warning:
//   - This may cause data loss if newer schema added columns/tables
//   - Always backup before reverting
//   - Not all migrations are safely reversible
//
// Usage:
//
//	cc-backend -revert-db
func RevertDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	if err := m.Migrate(Version - 1); err != nil {
		if err == migrate.ErrNoChange {
			cclog.Info("DB already up to date!")
		} else {
			return err
		}
	}

	m.Close()
	return nil
}

// ForceDB forces the database schema version to the current Version without running migrations.
// This is only used to recover from failed migrations that left the database in a "dirty" state.
//
// When to use:
//   - After manually fixing a failed migration
//   - When you've manually applied schema changes and need to update the version marker
//
// Warning:
//   - This does NOT apply any schema changes
//   - Only use after manually verifying the schema is correct
//   - Improper use can cause schema/version mismatch
//
// Usage:
//
//	cc-backend -force-db
func ForceDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	if err := m.Force(int(Version)); err != nil {
		return err
	}

	m.Close()
	return nil
}


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package repository provides job query functionality with filtering, pagination,
     7→// and security controls. This file contains the main query builders and security
     8→// checks for job retrieval operations.
     9→package repository
    10→
    11→import (
    12→	"context"
    13→	"errors"
    14→	"fmt"
    15→	"regexp"
    16→	"strings"
    17→	"time"
    18→
    19→	"github.com/ClusterCockpit/cc-backend/internal/config"
    20→	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
    21→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    22→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    23→	sq "github.com/Masterminds/squirrel"
    24→)
    25→
    26→const (
    27→	// Default initial capacity for job result slices
    28→	defaultJobsCapacity = 50
    29→)
    30→
    31→// QueryJobs retrieves jobs from the database with optional filtering, pagination,
    32→// and sorting. Security controls are automatically applied based on the user context.
    33→//
    34→// Parameters:
    35→//   - ctx: Context containing user authentication information
    36→//   - filters: Optional job filters (cluster, state, user, time ranges, etc.)
    37→//   - page: Optional pagination parameters (page number and items per page)
    38→//   - order: Optional sorting specification (column or footprint field)
    39→//
    40→// Returns a slice of jobs matching the criteria, or an error if the query fails.
    41→// The function enforces role-based access control through SecurityCheck.
    42→func (r *JobRepository) QueryJobs(
    43→	ctx context.Context,
    44→	filters []*model.JobFilter,
    45→	page *model.PageRequest,
    46→	order *model.OrderByInput,
    47→) ([]*schema.Job, error) {
    48→	query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
    49→	if qerr != nil {
    50→		return nil, qerr
    51→	}
    52→
    53→	if order != nil {
    54→		field := toSnakeCase(order.Field)
    55→		if order.Type == "col" {
    56→			switch order.Order {
    57→			case model.SortDirectionEnumAsc:
    58→				query = query.OrderBy(fmt.Sprintf("job.%s ASC", field))
    59→			case model.SortDirectionEnumDesc:
    60→				query = query.OrderBy(fmt.Sprintf("job.%s DESC", field))
    61→			default:
    62→				return nil, errors.New("invalid sorting order for column")
    63→			}
    64→		} else {
    65→			// Order by footprint JSON field values
    66→			query = query.Where("JSON_VALID(footprint)")
    67→			switch order.Order {
    68→			case model.SortDirectionEnumAsc:
    69→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))
    70→			case model.SortDirectionEnumDesc:
    71→				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))
    72→			default:
    73→				return nil, errors.New("invalid sorting order for footprint")
    74→			}
    75→		}
    76→	}
    77→
    78→	if page != nil && page.ItemsPerPage != -1 {
    79→		limit := uint64(page.ItemsPerPage)
    80→		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
    81→	}
    82→
    83→	for _, f := range filters {
    84→		query = BuildWhereClause(f, query)
    85→	}
    86→
    87→	rows, err := query.RunWith(r.stmtCache).QueryContext(ctx)
    88→	if err != nil {
    89→		queryString, queryVars, _ := query.ToSql()
    90→		return nil, fmt.Errorf("query failed [%s] %v: %w", queryString, queryVars, err)
    91→	}
    92→	defer rows.Close()
    93→
    94→	jobs := make([]*schema.Job, 0, defaultJobsCapacity)
    95→	for rows.Next() {
    96→		job, err := scanJob(rows)
    97→		if err != nil {
    98→			cclog.Warnf("Error scanning job row: %v", err)
    99→			return nil, fmt.Errorf("failed to scan job row: %w", err)
   100→		}
   101→		jobs = append(jobs, job)
   102→	}
   103→
   104→	if err := rows.Err(); err != nil {
   105→		return nil, fmt.Errorf("error iterating job rows: %w", err)
   106→	}
   107→
   108→	return jobs, nil
   109→}
   110→
   111→// CountJobs returns the total number of jobs matching the given filters.
   112→// Security controls are automatically applied based on the user context.
   113→// Uses DISTINCT count to handle tag filters correctly (jobs may appear multiple
   114→// times when joined with the tag table).
   115→func (r *JobRepository) CountJobs(
   116→	ctx context.Context,
   117→	filters []*model.JobFilter,
   118→) (int, error) {
   119→	query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
   120→	if qerr != nil {
   121→		return 0, qerr
   122→	}
   123→
   124→	for _, f := range filters {
   125→		query = BuildWhereClause(f, query)
   126→	}
   127→
   128→	var count int
   129→	if err := query.RunWith(r.DB).QueryRowContext(ctx).Scan(&count); err != nil {
   130→		return 0, fmt.Errorf("failed to count jobs: %w", err)
   131→	}
   132→
   133→	return count, nil
   134→}
   135→
   136→// SecurityCheckWithUser applies role-based access control filters to a job query
   137→// based on the provided user's roles and permissions.
   138→//
   139→// Access rules by role:
   140→//   - API role (exclusive): Full access to all jobs
   141→//   - Admin/Support roles: Full access to all jobs
   142→//   - Manager role: Access to jobs in managed projects plus own jobs
   143→//   - User role: Access only to own jobs
   144→//
   145→// Returns an error if the user is nil or has no recognized roles.
   146→func SecurityCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   147→	if user == nil {
   148→		var qnil sq.SelectBuilder
   149→		return qnil, fmt.Errorf("user context is nil")
   150→	}
   151→
   152→	switch {
   153→	case len(user.Roles) == 1 && user.HasRole(schema.RoleAPI):
   154→		return query, nil
   155→	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}):
   156→		return query, nil
   157→	case user.HasRole(schema.RoleManager):
   158→		if len(user.Projects) != 0 {
   159→			return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.hpc_user": user.Username}}), nil
   160→		}
   161→		cclog.Debugf("Manager '%s' has no assigned projects, restricting to personal jobs", user.Username)
   162→		return query.Where("job.hpc_user = ?", user.Username), nil
   163→	case user.HasRole(schema.RoleUser):
   164→		return query.Where("job.hpc_user = ?", user.Username), nil
   165→	default:
   166→		var qnil sq.SelectBuilder
   167→		return qnil, fmt.Errorf("user has no or unknown roles")
   168→	}
   169→}
   170→
   171→// SecurityCheck extracts the user from the context and applies role-based access
   172→// control filters to the query. This is a convenience wrapper around SecurityCheckWithUser.
   173→func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
   174→	user := GetUserFromContext(ctx)
   175→	return SecurityCheckWithUser(user, query)
   176→}
   177→
   178→// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
   179→// them to the query. Supports filtering by job properties (cluster, state, user),
   180→// time ranges, resource usage, tags, and JSON field searches in meta_data,
   181→// footprint, and resources columns.
   182→func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
   183→	// Primary Key
   184→	if filter.DbID != nil {
   185→		dbIDs := make([]string, len(filter.DbID))
   186→		copy(dbIDs, filter.DbID)
   187→		query = query.Where(sq.Eq{"job.id": dbIDs})
   188→	}
   189→	// Explicit indices
   190→	if filter.Cluster != nil {
   191→		query = buildStringCondition("job.cluster", filter.Cluster, query)
   192→	}
   193→	if filter.SubCluster != nil {
   194→		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
   195→	}
   196→	if filter.Partition != nil {
   197→		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
   198→	}
   199→	if filter.State != nil {
   200→		if len(filter.State) == 1 {
   201→			singleStat := string(filter.State[0])
   202→			query = query.Where("job.job_state = ?", singleStat)
   203→		} else {
   204→			states := make([]string, len(filter.State))
   205→			for i, val := range filter.State {
   206→				states[i] = string(val)
   207→			}
   208→			query = query.Where(sq.Eq{"job.job_state": states})
   209→		}
   210→	}
   211→	if filter.Shared != nil {
   212→		query = query.Where("job.shared = ?", *filter.Shared)
   213→	}
   214→	if filter.Project != nil {
   215→		query = buildStringCondition("job.project", filter.Project, query)
   216→	}
   217→	if filter.User != nil {
   218→		query = buildStringCondition("job.hpc_user", filter.User, query)
   219→	}
   220→	if filter.NumNodes != nil {
   221→		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
   222→	}
   223→	if filter.NumAccelerators != nil {
   224→		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
   225→	}
   226→	if filter.NumHWThreads != nil {
   227→		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
   228→	}
   229→	if filter.ArrayJobID != nil {
   230→		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
   231→	}
   232→	if filter.StartTime != nil {
   233→		query = buildTimeCondition("job.start_time", filter.StartTime, query)
   234→	}
   235→	if filter.Duration != nil {
   236→		query = buildIntCondition("job.duration", filter.Duration, query)
   237→	}
   238→	if filter.Energy != nil {
   239→		query = buildFloatCondition("job.energy", filter.Energy, query)
   240→	}
   241→	// Indices on Tag Table
   242→	if filter.Tags != nil {
   243→		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
   244→		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
   245→	}
   246→	// No explicit Indices
   247→	if filter.JobID != nil {
   248→		query = buildStringCondition("job.job_id", filter.JobID, query)
   249→	}
   250→	// Queries Within JSONs
   251→	if filter.MetricStats != nil {
   252→		for _, ms := range filter.MetricStats {
   253→			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
   254→		}
   255→	}
   256→	if filter.Node != nil {
   257→		query = buildResourceJSONCondition("hostname", filter.Node, query)
   258→	}
   259→	if filter.JobName != nil {
   260→		query = buildMetaJSONCondition("jobName", filter.JobName, query)
   261→	}
   262→	if filter.Schedule != nil {
   263→		interactiveJobname := "interactive"
   264→		switch *filter.Schedule {
   265→		case "interactive":
   266→			iFilter := model.StringInput{Eq: &interactiveJobname}
   267→			query = buildMetaJSONCondition("jobName", &iFilter, query)
   268→		case "batch":
   269→			sFilter := model.StringInput{Neq: &interactiveJobname}
   270→			query = buildMetaJSONCondition("jobName", &sFilter, query)
   271→		}
   272→	}
   273→
   274→	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
   275→	if filter.MinRunningFor != nil {
   276→		now := time.Now().Unix()
   277→		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
   278→		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
   279→		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
   280→	}
   281→	return query
   282→}
   283→
   284→// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
   285→func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
   286→	if cond.From != 1 && cond.To != 0 {
   287→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   288→	} else if cond.From != 1 && cond.To == 0 {
   289→		return query.Where(field+" >= ?", cond.From)
   290→	} else if cond.From == 1 && cond.To != 0 {
   291→		return query.Where(field+" <= ?", cond.To)
   292→	} else {
   293→		return query
   294→	}
   295→}
   296→
   297→// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
   298→func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   299→	if cond.From != 1.0 && cond.To != 0.0 {
   300→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   301→	} else if cond.From != 1.0 && cond.To == 0.0 {
   302→		return query.Where(field+" >= ?", cond.From)
   303→	} else if cond.From == 1.0 && cond.To != 0.0 {
   304→		return query.Where(field+" <= ?", cond.To)
   305→	} else {
   306→		return query
   307→	}
   308→}
   309→
   310→// buildTimeCondition creates time range filters supporting absolute timestamps,
   311→// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
   312→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   313→func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
   314→	if cond.From != nil && cond.To != nil {
   315→		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
   316→	} else if cond.From != nil {
   317→		return query.Where(field+" >= ?", cond.From.Unix())
   318→	} else if cond.To != nil {
   319→		return query.Where(field+" <= ?", cond.To.Unix())
   320→	} else if cond.Range != "" {
   321→		now := time.Now().Unix()
   322→		var then int64
   323→		switch cond.Range {
   324→		case "last6h":
   325→			then = now - (60 * 60 * 6)
   326→		case "last24h":
   327→			then = now - (60 * 60 * 24)
   328→		case "last7d":
   329→			then = now - (60 * 60 * 24 * 7)
   330→		case "last30d":
   331→			then = now - (60 * 60 * 24 * 30)
   332→		default:
   333→			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
   334→			return query
   335→		}
   336→		return query.Where(field+" >= ?", then)
   337→	} else {
   338→		return query
   339→	}
   340→}
   341→
   342→// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
   343→func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   344→	query = query.Where("JSON_VALID(footprint)")
   345→	if cond.From != 1.0 && cond.To != 0.0 {
   346→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
   347→	} else if cond.From != 1.0 && cond.To == 0.0 {
   348→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
   349→	} else if cond.From == 1.0 && cond.To != 0.0 {
   350→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
   351→	} else {
   352→		return query
   353→	}
   354→}
   355→
   356→// buildStringCondition creates filters for string fields supporting equality,
   357→// inequality, prefix, suffix, substring, and IN list matching.
   358→func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   359→	if cond.Eq != nil {
   360→		return query.Where(field+" = ?", *cond.Eq)
   361→	}
   362→	if cond.Neq != nil {
   363→		return query.Where(field+" != ?", *cond.Neq)
   364→	}
   365→	if cond.StartsWith != nil {
   366→		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   367→	}
   368→	if cond.EndsWith != nil {
   369→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   370→	}
   371→	if cond.Contains != nil {
   372→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   373→	}
   374→	if cond.In != nil {
   375→		queryElements := make([]string, len(cond.In))
   376→		copy(queryElements, cond.In)
   377→		return query.Where(sq.Or{sq.Eq{field: queryElements}})
   378→	}
   379→	return query
   380→}
   381→
   382→// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
   383→func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   384→	query = query.Where("JSON_VALID(meta_data)")
   385→	if cond.Eq != nil {
   386→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
   387→	}
   388→	if cond.Neq != nil {
   389→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
   390→	}
   391→	if cond.StartsWith != nil {
   392→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   393→	}
   394→	if cond.EndsWith != nil {
   395→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   396→	}
   397→	if cond.Contains != nil {
   398→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   399→	}
   400→	return query
   401→}
   402→
   403→// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
   404→// Uses json_each to search within array elements.
   405→func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   406→	query = query.Where("JSON_VALID(resources)")
   407→	if cond.Eq != nil {
   408→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
   409→	}
   410→	if cond.Neq != nil { // Currently Unused
   411→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
   412→	}
   413→	if cond.StartsWith != nil { // Currently Unused
   414→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
   415→	}
   416→	if cond.EndsWith != nil { // Currently Unused
   417→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
   418→	}
   419→	if cond.Contains != nil {
   420→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
   421→	}
   422→	return query
   423→}
   424→
   425→var (
   426→	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
   427→	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
   428→)
   429→
   430→// toSnakeCase converts camelCase strings to snake_case for SQL column names.
   431→// Includes security checks to prevent SQL injection attempts.
   432→// Panics if potentially dangerous characters are detected.
   433→func toSnakeCase(str string) string {
   434→	for _, c := range str {
   435→		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
   436→			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
   437→		}
   438→	}
   439→
   440→	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
   441→	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
   442→	return strings.ToLower(snake)
   443→}
   444→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package repository provides job query functionality with filtering, pagination,
// and security controls. This file contains the main query builders and security
// checks for job retrieval operations.
package repository

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/ClusterCockpit/cc-backend/internal/config"
	"github.com/ClusterCockpit/cc-backend/internal/graph/model"
	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/ClusterCockpit/cc-lib/v2/schema"
	sq "github.com/Masterminds/squirrel"
)

const (
	// Default initial capacity for job result slices
	defaultJobsCapacity = 50
)

// QueryJobs retrieves jobs from the database with optional filtering, pagination,
// and sorting. Security controls are automatically applied based on the user context.
//
// Parameters:
//   - ctx: Context containing user authentication information
//   - filters: Optional job filters (cluster, state, user, time ranges, etc.)
//   - page: Optional pagination parameters (page number and items per page)
//   - order: Optional sorting specification (column or footprint field)
//
// Returns a slice of jobs matching the criteria, or an error if the query fails.
// The function enforces role-based access control through SecurityCheck.
func (r *JobRepository) QueryJobs(
	ctx context.Context,
	filters []*model.JobFilter,
	page *model.PageRequest,
	order *model.OrderByInput,
) ([]*schema.Job, error) {
	query, qerr := SecurityCheck(ctx, sq.Select(jobColumns...).From("job"))
	if qerr != nil {
		return nil, qerr
	}

	if order != nil {
		field := toSnakeCase(order.Field)
		if order.Type == "col" {
			switch order.Order {
			case model.SortDirectionEnumAsc:
				query = query.OrderBy(fmt.Sprintf("job.%s ASC", field))
			case model.SortDirectionEnumDesc:
				query = query.OrderBy(fmt.Sprintf("job.%s DESC", field))
			default:
				return nil, errors.New("invalid sorting order for column")
			}
		} else {
			// Order by footprint JSON field values
			query = query.Where("JSON_VALID(footprint)")
			switch order.Order {
			case model.SortDirectionEnumAsc:
				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))
			case model.SortDirectionEnumDesc:
				query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))
			default:
				return nil, errors.New("invalid sorting order for footprint")
			}
		}
	}

	if page != nil && page.ItemsPerPage != -1 {
		limit := uint64(page.ItemsPerPage)
		query = query.Offset((uint64(page.Page) - 1) * limit).Limit(limit)
	}

	for _, f := range filters {
		query = BuildWhereClause(f, query)
	}

	rows, err := query.RunWith(r.stmtCache).QueryContext(ctx)
	if err != nil {
		queryString, queryVars, _ := query.ToSql()
		return nil, fmt.Errorf("query failed [%s] %v: %w", queryString, queryVars, err)
	}
	defer rows.Close()

	jobs := make([]*schema.Job, 0, defaultJobsCapacity)
	for rows.Next() {
		job, err := scanJob(rows)
		if err != nil {
			cclog.Warnf("Error scanning job row: %v", err)
			return nil, fmt.Errorf("failed to scan job row: %w", err)
		}
		jobs = append(jobs, job)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("error iterating job rows: %w", err)
	}

	return jobs, nil
}

// CountJobs returns the total number of jobs matching the given filters.
// Security controls are automatically applied based on the user context.
// Uses DISTINCT count to handle tag filters correctly (jobs may appear multiple
// times when joined with the tag table).
func (r *JobRepository) CountJobs(
	ctx context.Context,
	filters []*model.JobFilter,
) (int, error) {
	query, qerr := SecurityCheck(ctx, sq.Select("count(DISTINCT job.id)").From("job"))
	if qerr != nil {
		return 0, qerr
	}

	for _, f := range filters {
		query = BuildWhereClause(f, query)
	}

	var count int
	if err := query.RunWith(r.DB).QueryRowContext(ctx).Scan(&count); err != nil {
		return 0, fmt.Errorf("failed to count jobs: %w", err)
	}

	return count, nil
}

// SecurityCheckWithUser applies role-based access control filters to a job query
// based on the provided user's roles and permissions.
//
// Access rules by role:
//   - API role (exclusive): Full access to all jobs
//   - Admin/Support roles: Full access to all jobs
//   - Manager role: Access to jobs in managed projects plus own jobs
//   - User role: Access only to own jobs
//
// Returns an error if the user is nil or has no recognized roles.
func SecurityCheckWithUser(user *schema.User, query sq.SelectBuilder) (sq.SelectBuilder, error) {
	if user == nil {
		var qnil sq.SelectBuilder
		return qnil, fmt.Errorf("user context is nil")
	}

	switch {
	case len(user.Roles) == 1 && user.HasRole(schema.RoleAPI):
		return query, nil
	case user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}):
		return query, nil
	case user.HasRole(schema.RoleManager):
		if len(user.Projects) != 0 {
			return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.hpc_user": user.Username}}), nil
		}
		cclog.Debugf("Manager '%s' has no assigned projects, restricting to personal jobs", user.Username)
		return query.Where("job.hpc_user = ?", user.Username), nil
	case user.HasRole(schema.RoleUser):
		return query.Where("job.hpc_user = ?", user.Username), nil
	default:
		var qnil sq.SelectBuilder
		return qnil, fmt.Errorf("user has no or unknown roles")
	}
}

// SecurityCheck extracts the user from the context and applies role-based access
// control filters to the query. This is a convenience wrapper around SecurityCheckWithUser.
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
	user := GetUserFromContext(ctx)
	return SecurityCheckWithUser(user, query)
}

// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
// them to the query. Supports filtering by job properties (cluster, state, user),
// time ranges, resource usage, tags, and JSON field searches in meta_data,
// footprint, and resources columns.
func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
	// Primary Key
	if filter.DbID != nil {
		dbIDs := make([]string, len(filter.DbID))
		copy(dbIDs, filter.DbID)
		query = query.Where(sq.Eq{"job.id": dbIDs})
	}
	// Explicit indices
	if filter.Cluster != nil {
		query = buildStringCondition("job.cluster", filter.Cluster, query)
	}
	if filter.SubCluster != nil {
		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
	}
	if filter.Partition != nil {
		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
	}
	if filter.State != nil {
		if len(filter.State) == 1 {
			singleStat := string(filter.State[0])
			query = query.Where("job.job_state = ?", singleStat)
		} else {
			states := make([]string, len(filter.State))
			for i, val := range filter.State {
				states[i] = string(val)
			}
			query = query.Where(sq.Eq{"job.job_state": states})
		}
	}
	if filter.Shared != nil {
		query = query.Where("job.shared = ?", *filter.Shared)
	}
	if filter.Project != nil {
		query = buildStringCondition("job.project", filter.Project, query)
	}
	if filter.User != nil {
		query = buildStringCondition("job.hpc_user", filter.User, query)
	}
	if filter.NumNodes != nil {
		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
	}
	if filter.NumAccelerators != nil {
		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
	}
	if filter.NumHWThreads != nil {
		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
	}
	if filter.ArrayJobID != nil {
		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
	}
	if filter.StartTime != nil {
		query = buildTimeCondition("job.start_time", filter.StartTime, query)
	}
	if filter.Duration != nil {
		query = buildIntCondition("job.duration", filter.Duration, query)
	}
	if filter.Energy != nil {
		query = buildFloatCondition("job.energy", filter.Energy, query)
	}
	// Indices on Tag Table
	if filter.Tags != nil {
		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
	}
	// No explicit Indices
	if filter.JobID != nil {
		query = buildStringCondition("job.job_id", filter.JobID, query)
	}
	// Queries Within JSONs
	if filter.MetricStats != nil {
		for _, ms := range filter.MetricStats {
			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
		}
	}
	if filter.Node != nil {
		query = buildResourceJSONCondition("hostname", filter.Node, query)
	}
	if filter.JobName != nil {
		query = buildMetaJSONCondition("jobName", filter.JobName, query)
	}
	if filter.Schedule != nil {
		interactiveJobname := "interactive"
		switch *filter.Schedule {
		case "interactive":
			iFilter := model.StringInput{Eq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &iFilter, query)
		case "batch":
			sFilter := model.StringInput{Neq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &sFilter, query)
		}
	}

	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
	if filter.MinRunningFor != nil {
		now := time.Now().Unix()
		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
	}
	return query
}

// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 1 && cond.To != 0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1 && cond.To == 0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.From == 1 && cond.To != 0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 1.0 && cond.To != 0.0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildTimeCondition creates time range filters supporting absolute timestamps,
// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != nil && cond.To != nil {
		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
	} else if cond.From != nil {
		return query.Where(field+" >= ?", cond.From.Unix())
	} else if cond.To != nil {
		return query.Where(field+" <= ?", cond.To.Unix())
	} else if cond.Range != "" {
		now := time.Now().Unix()
		var then int64
		switch cond.Range {
		case "last6h":
			then = now - (60 * 60 * 6)
		case "last24h":
			then = now - (60 * 60 * 24)
		case "last7d":
			then = now - (60 * 60 * 24 * 7)
		case "last30d":
			then = now - (60 * 60 * 24 * 30)
		default:
			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
			return query
		}
		return query.Where(field+" >= ?", then)
	} else {
		return query
	}
}

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")
	if cond.From != 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
	} else {
		return query
	}
}

// buildStringCondition creates filters for string fields supporting equality,
// inequality, prefix, suffix, substring, and IN list matching.
func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.Eq != nil {
		return query.Where(field+" = ?", *cond.Eq)
	}
	if cond.Neq != nil {
		return query.Where(field+" != ?", *cond.Neq)
	}
	if cond.StartsWith != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
	}
	if cond.In != nil {
		queryElements := make([]string, len(cond.In))
		copy(queryElements, cond.In)
		return query.Where(sq.Or{sq.Eq{field: queryElements}})
	}
	return query
}

// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(meta_data)")
	if cond.Eq != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
	}
	if cond.Neq != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
	}
	if cond.StartsWith != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
	}
	return query
}

// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
// Uses json_each to search within array elements.
func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(resources)")
	if cond.Eq != nil {
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
	}
	if cond.Neq != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
	}
	if cond.StartsWith != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
	}
	if cond.EndsWith != nil { // Currently Unused
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
	}
	if cond.Contains != nil {
		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
	}
	return query
}

var (
	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
)

// toSnakeCase converts camelCase strings to snake_case for SQL column names.
// Includes security checks to prevent SQL injection attempts.
// Panics if potentially dangerous characters are detected.
func toSnakeCase(str string) string {
	for _, c := range str {
		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
		}
	}

	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
	return strings.ToLower(snake)
}