Implement the following plan:

# Optimize Footprint JSON Queries

## Context

Migration 08 consolidated 8 metric columns (`flops_any_avg`, `mem_bw_avg`, etc.) into a single `footprint TEXT` JSON column. All queries now use `JSON_EXTRACT(footprint, '$.field')` for filtering (WHERE) and sorting (ORDER BY), which forces **full table scans** — there are zero indexes on footprint expressions despite ~80 indexes on regular columns.

**Bug found**: `jobQuery.go:66` checks `JSON_VALID(meta_data)` instead of `JSON_VALID(footprint)` when sorting by footprint fields.

## Approach: SQLite Expression Indexes

Expression indexes (`CREATE INDEX ... ON job(json_extract(footprint, '$.field'))`) are the optimal solution:
- No schema/table changes needed (unlike generated columns or denormalization which require table rebuilds)
- Can be added/dropped independently per metric
- SQLite uses them for both WHERE and ORDER BY when the expression matches exactly
- Handles variable metric sets gracefully (missing keys = NULL, skipped in index)

### Critical requirement
SQLite matches expressions **textually**. The `json_extract` call in queries must match the index definition exactly. Current code uses `JSON_EXTRACT(footprint, "$.field")` — must normalize to `json_extract(footprint, '$.field')`.

## Changes

### 1. New migration `11_add-footprint-indexes.up.sql`

**Standalone expression indexes** (8 — for WHERE and ORDER BY):
```sql
CREATE INDEX IF NOT EXISTS jobs_fp_<metric> ON job (json_extract(footprint, '$.<metric>'));
```
For: `flops_any_avg`, `mem_bw_avg`, `mem_used_max`, `cpu_load_avg`, `net_bw_avg`, `net_data_vol_total`, `file_bw_avg`, `file_data_vol_total`

**Composite indexes with cluster** (4 — for the most common filter+sort combo):
```sql
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_<metric> ON job (cluster, json_extract(footprint, '$.<metric>'));
```
For: `cpu_load_avg`, `flops_any_avg`, `mem_bw_avg`, `mem_used_max`

Down migration drops all 12 indexes.

### 2. Update `internal/repository/migration.go`
- `Version` 10 → 11

### 3. Update `internal/repository/jobQuery.go`

**`buildFloatJSONCondition`** (lines 338-349):
- Remove `JSON_VALID(footprint)` — prevents index use, redundant (json_extract returns NULL on invalid JSON)
- Normalize expression to lowercase `json_extract` with single-quoted path

**ORDER BY** (lines 64-75):
- Fix bug: remove `JSON_VALID(meta_data)` (wrong column!)
- Normalize expression to `json_extract(footprint, '$.%s')`

### 4. Update `internal/repository/stats.go`

**`jobsMetricStatisticsHistogram`** (lines 923-933):
- Remove `JSON_VALID(footprint)` from WHERE
- Normalize `json_extract` expressions to single-quoted paths

## Expected Impact

- **Filtering**: O(n) full scan → O(log n) index seek
- **Sorting**: Eliminates temp B-tree sort step entirely
- **Filter+Sort on cluster**: Single composite index satisfies both
- **Write cost**: ~12-24 microseconds added per INSERT (negligible for HPC job rates)

## Verification

1. Run `go test ./internal/repository/...`
2. With a test DB, run `EXPLAIN QUERY PLAN` on footprint queries to confirm `USING INDEX jobs_fp_*`
3. Build: `go build ./cmd/cc-backend`

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/c4d819fa-a4bd-4377-908d-a2b097a87a68.jsonl


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

// 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 10: Current version
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 10

//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(meta_data)")
    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).Query()
    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).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→		states := make([]string, len(filter.State))
   201→		for i, val := range filter.State {
   202→			states[i] = string(val)
   203→		}
   204→		query = query.Where(sq.Eq{"job.job_state": states})
   205→	}
   206→	if filter.Shared != nil {
   207→		query = query.Where("job.shared = ?", *filter.Shared)
   208→	}
   209→	if filter.Project != nil {
   210→		query = buildStringCondition("job.project", filter.Project, query)
   211→	}
   212→	if filter.User != nil {
   213→		query = buildStringCondition("job.hpc_user", filter.User, query)
   214→	}
   215→	if filter.NumNodes != nil {
   216→		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
   217→	}
   218→	if filter.NumAccelerators != nil {
   219→		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
   220→	}
   221→	if filter.NumHWThreads != nil {
   222→		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
   223→	}
   224→	if filter.ArrayJobID != nil {
   225→		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
   226→	}
   227→	if filter.StartTime != nil {
   228→		query = buildTimeCondition("job.start_time", filter.StartTime, query)
   229→	}
   230→	if filter.Duration != nil {
   231→		query = buildIntCondition("job.duration", filter.Duration, query)
   232→	}
   233→	if filter.Energy != nil {
   234→		query = buildFloatCondition("job.energy", filter.Energy, query)
   235→	}
   236→	// Indices on Tag Table
   237→	if filter.Tags != nil {
   238→		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
   239→		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
   240→	}
   241→	// No explicit Indices
   242→	if filter.JobID != nil {
   243→		query = buildStringCondition("job.job_id", filter.JobID, query)
   244→	}
   245→	// Queries Within JSONs
   246→	if filter.MetricStats != nil {
   247→		for _, ms := range filter.MetricStats {
   248→			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
   249→		}
   250→	}
   251→	if filter.Node != nil {
   252→		query = buildResourceJSONCondition("hostname", filter.Node, query)
   253→	}
   254→	if filter.JobName != nil {
   255→		query = buildMetaJSONCondition("jobName", filter.JobName, query)
   256→	}
   257→	if filter.Schedule != nil {
   258→		interactiveJobname := "interactive"
   259→		switch *filter.Schedule {
   260→		case "interactive":
   261→			iFilter := model.StringInput{Eq: &interactiveJobname}
   262→			query = buildMetaJSONCondition("jobName", &iFilter, query)
   263→		case "batch":
   264→			sFilter := model.StringInput{Neq: &interactiveJobname}
   265→			query = buildMetaJSONCondition("jobName", &sFilter, query)
   266→		}
   267→	}
   268→
   269→	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
   270→	if filter.MinRunningFor != nil {
   271→		now := time.Now().Unix()
   272→		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
   273→		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
   274→		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
   275→	}
   276→	return query
   277→}
   278→
   279→// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
   280→func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
   281→	if cond.From != 1 && cond.To != 0 {
   282→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   283→	} else if cond.From != 1 && cond.To == 0 {
   284→		return query.Where(field+" >= ?", cond.From)
   285→	} else if cond.From == 1 && cond.To != 0 {
   286→		return query.Where(field+" <= ?", cond.To)
   287→	} else {
   288→		return query
   289→	}
   290→}
   291→
   292→// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
   293→func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   294→	if cond.From != 1.0 && cond.To != 0.0 {
   295→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   296→	} else if cond.From != 1.0 && cond.To == 0.0 {
   297→		return query.Where(field+" >= ?", cond.From)
   298→	} else if cond.From == 1.0 && cond.To != 0.0 {
   299→		return query.Where(field+" <= ?", cond.To)
   300→	} else {
   301→		return query
   302→	}
   303→}
   304→
   305→// buildTimeCondition creates time range filters supporting absolute timestamps,
   306→// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
   307→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   308→func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
   309→	if cond.From != nil && cond.To != nil {
   310→		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
   311→	} else if cond.From != nil {
   312→		return query.Where(field+" >= ?", cond.From.Unix())
   313→	} else if cond.To != nil {
   314→		return query.Where(field+" <= ?", cond.To.Unix())
   315→	} else if cond.Range != "" {
   316→		now := time.Now().Unix()
   317→		var then int64
   318→		switch cond.Range {
   319→		case "last6h":
   320→			then = now - (60 * 60 * 6)
   321→		case "last24h":
   322→			then = now - (60 * 60 * 24)
   323→		case "last7d":
   324→			then = now - (60 * 60 * 24 * 7)
   325→		case "last30d":
   326→			then = now - (60 * 60 * 24 * 30)
   327→		default:
   328→			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
   329→			return query
   330→		}
   331→		return query.Where(field+" >= ?", then)
   332→	} else {
   333→		return query
   334→	}
   335→}
   336→
   337→// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
   338→func buildFloatJSONCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   339→	query = query.Where("JSON_VALID(footprint)")
   340→	if cond.From != 1.0 && cond.To != 0.0 {
   341→		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") BETWEEN ? AND ?", cond.From, cond.To)
   342→	} else if cond.From != 1.0 && cond.To == 0.0 {
   343→		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") >= ?", cond.From)
   344→	} else if cond.From == 1.0 && cond.To != 0.0 {
   345→		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") <= ?", cond.To)
   346→	} else {
   347→		return query
   348→	}
   349→}
   350→
   351→// buildStringCondition creates filters for string fields supporting equality,
   352→// inequality, prefix, suffix, substring, and IN list matching.
   353→func buildStringCondition(field string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   354→	if cond.Eq != nil {
   355→		return query.Where(field+" = ?", *cond.Eq)
   356→	}
   357→	if cond.Neq != nil {
   358→		return query.Where(field+" != ?", *cond.Neq)
   359→	}
   360→	if cond.StartsWith != nil {
   361→		return query.Where(field+" LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   362→	}
   363→	if cond.EndsWith != nil {
   364→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   365→	}
   366→	if cond.Contains != nil {
   367→		return query.Where(field+" LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   368→	}
   369→	if cond.In != nil {
   370→		queryElements := make([]string, len(cond.In))
   371→		copy(queryElements, cond.In)
   372→		return query.Where(sq.Or{sq.Eq{field: queryElements}})
   373→	}
   374→	return query
   375→}
   376→
   377→// buildMetaJSONCondition creates filters on fields within the meta_data JSON column.
   378→func buildMetaJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   379→	query = query.Where("JSON_VALID(meta_data)")
   380→	if cond.Eq != nil {
   381→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") = ?", *cond.Eq)
   382→	}
   383→	if cond.Neq != nil {
   384→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") != ?", *cond.Neq)
   385→	}
   386→	if cond.StartsWith != nil {
   387→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint(*cond.StartsWith, "%"))
   388→	}
   389→	if cond.EndsWith != nil {
   390→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.EndsWith))
   391→	}
   392→	if cond.Contains != nil {
   393→		return query.Where("JSON_EXTRACT(meta_data, \"$."+jsonField+"\") LIKE ?", fmt.Sprint("%", *cond.Contains, "%"))
   394→	}
   395→	return query
   396→}
   397→
   398→// buildResourceJSONCondition creates filters on fields within the resources JSON array column.
   399→// Uses json_each to search within array elements.
   400→func buildResourceJSONCondition(jsonField string, cond *model.StringInput, query sq.SelectBuilder) sq.SelectBuilder {
   401→	query = query.Where("JSON_VALID(resources)")
   402→	if cond.Eq != nil {
   403→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") = ?)", *cond.Eq)
   404→	}
   405→	if cond.Neq != nil { // Currently Unused
   406→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") != ?)", *cond.Neq)
   407→	}
   408→	if cond.StartsWith != nil { // Currently Unused
   409→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\")) LIKE ?)", fmt.Sprint(*cond.StartsWith, "%"))
   410→	}
   411→	if cond.EndsWith != nil { // Currently Unused
   412→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.EndsWith))
   413→	}
   414→	if cond.Contains != nil {
   415→		return query.Where("EXISTS (SELECT 1 FROM json_each(job.resources) WHERE json_extract(value, \"$."+jsonField+"\") LIKE ?)", fmt.Sprint("%", *cond.Contains, "%"))
   416→	}
   417→	return query
   418→}
   419→
   420→var (
   421→	matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
   422→	matchAllCap   = regexp.MustCompile("([a-z0-9])([A-Z])")
   423→)
   424→
   425→// toSnakeCase converts camelCase strings to snake_case for SQL column names.
   426→// Includes security checks to prevent SQL injection attempts.
   427→// Panics if potentially dangerous characters are detected.
   428→func toSnakeCase(str string) string {
   429→	for _, c := range str {
   430→		if c == '\'' || c == '\\' || c == '"' || c == ';' || c == '-' || c == ' ' {
   431→			cclog.Panicf("toSnakeCase: potentially dangerous character detected in input: %q", str)
   432→		}
   433→	}
   434→
   435→	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
   436→	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
   437→	return strings.ToLower(snake)
   438→}
   439→

// 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(meta_data)")
			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).Query()
	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).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 {
		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(field 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, \"$."+field+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") <= ?", 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)
}


   900→				if m.Name == metric {
   901→					if m.Peak > peak {
   902→						peak = m.Peak
   903→					}
   904→					if unit == "" {
   905→						unit = m.Unit.Prefix + m.Unit.Base
   906→					}
   907→					if footprintStat == "" {
   908→						footprintStat = m.Footprint
   909→					}
   910→				}
   911→			}
   912→		}
   913→	}
   914→
   915→	// Construct SQL histogram bins using normalized values.
   916→	// Algorithm based on: https://jereze.com/code/sql-histogram/ (modified)
   917→	start := time.Now()
   918→
   919→	// Bin calculation formula:
   920→	//   bin_number = CAST( (value / peak) * num_bins AS INTEGER ) + 1
   921→	// Special case: value == peak would create bin N+1, so we test for equality
   922→	// and multiply peak by 0.999999999 to force it into bin N.
   923→	binQuery := fmt.Sprintf(`CAST(
   924→		((case when json_extract(footprint, "$.%s") = %f then %f*0.999999999 else json_extract(footprint, "$.%s") end) / %f)
   925→		* %v as INTEGER )`,
   926→		(metric + "_" + footprintStat), peak, peak, (metric + "_" + footprintStat), peak, *bins)
   927→
   928→	mainQuery := sq.Select(
   929→		fmt.Sprintf(`%s + 1 as bin`, binQuery),
   930→		`count(*) as count`,
   931→	).From("job").Where(
   932→		"JSON_VALID(footprint)",
   933→	).Where(fmt.Sprintf(`json_extract(footprint, "$.%s") is not null and json_extract(footprint, "$.%s") <= %f`, (metric + "_" + footprintStat), (metric + "_" + footprintStat), peak))
   934→
   935→	mainQuery, qerr := SecurityCheck(ctx, mainQuery)
   936→	if qerr != nil {
   937→		return nil, qerr
   938→	}
   939→
   940→	for _, f := range filters {
   941→		mainQuery = BuildWhereClause(f, mainQuery)
   942→	}
   943→
   944→	mainQuery = mainQuery.GroupBy("bin").OrderBy("bin")
   945→
   946→	rows, err := mainQuery.RunWith(r.DB).Query()
   947→	if err != nil {
   948→		cclog.Errorf("Error while running mainQuery: %s", err)
   949→		return nil, err
   950→	}
   951→
   952→	// Pre-initialize bins with calculated min/max ranges.
   953→	// Example: peak=1000, bins=10 -> bin 1=[0,100), bin 2=[100,200), ..., bin 10=[900,1000]
   954→	points := make([]*model.MetricHistoPoint, 0)
   955→	binStep := int(peak) / *bins
   956→	for i := 1; i <= *bins; i++ {
   957→		binMin := (binStep * (i - 1))
   958→		binMax := (binStep * i)
   959→		epoint := model.MetricHistoPoint{Bin: &i, Count: 0, Min: &binMin, Max: &binMax}

				if m.Name == metric {
					if m.Peak > peak {
						peak = m.Peak
					}
					if unit == "" {
						unit = m.Unit.Prefix + m.Unit.Base
					}
					if footprintStat == "" {
						footprintStat = m.Footprint
					}
				}
			}
		}
	}

	// Construct SQL histogram bins using normalized values.
	// Algorithm based on: https://jereze.com/code/sql-histogram/ (modified)
	start := time.Now()

	// Bin calculation formula:
	//   bin_number = CAST( (value / peak) * num_bins AS INTEGER ) + 1
	// Special case: value == peak would create bin N+1, so we test for equality
	// and multiply peak by 0.999999999 to force it into bin N.
	binQuery := fmt.Sprintf(`CAST(
		((case when json_extract(footprint, "$.%s") = %f then %f*0.999999999 else json_extract(footprint, "$.%s") end) / %f)
		* %v as INTEGER )`,
		(metric + "_" + footprintStat), peak, peak, (metric + "_" + footprintStat), peak, *bins)

	mainQuery := sq.Select(
		fmt.Sprintf(`%s + 1 as bin`, binQuery),
		`count(*) as count`,
	).From("job").Where(
		"JSON_VALID(footprint)",
	).Where(fmt.Sprintf(`json_extract(footprint, "$.%s") is not null and json_extract(footprint, "$.%s") <= %f`, (metric + "_" + footprintStat), (metric + "_" + footprintStat), peak))

	mainQuery, qerr := SecurityCheck(ctx, mainQuery)
	if qerr != nil {
		return nil, qerr
	}

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

	mainQuery = mainQuery.GroupBy("bin").OrderBy("bin")

	rows, err := mainQuery.RunWith(r.DB).Query()
	if err != nil {
		cclog.Errorf("Error while running mainQuery: %s", err)
		return nil, err
	}

	// Pre-initialize bins with calculated min/max ranges.
	// Example: peak=1000, bins=10 -> bin 1=[0,100), bin 2=[100,200), ..., bin 10=[900,1000]
	points := make([]*model.MetricHistoPoint, 0)
	binStep := int(peak) / *bins
	for i := 1; i <= *bins; i++ {
		binMin := (binStep * (i - 1))
		binMax := (binStep * i)
		epoint := model.MetricHistoPoint{Bin: &i, Count: 0, Min: &binMin, Max: &binMax}


Bash

ls /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/migrations/sqlite3/ | sort

List existing migrations


01_init-schema.down.sql
01_init-schema.up.sql
02_add-index.down.sql
02_add-index.up.sql
03_add-userprojects.down.sql
03_add-userprojects.up.sql
04_add-constraints.down.sql
04_add-constraints.up.sql
05_extend-tags.down.sql
05_extend-tags.up.sql
06_change-config.down.sql
06_change-config.up.sql
07_fix-tag-id.down.sql
07_fix-tag-id.up.sql
08_add-footprint.down.sql
08_add-footprint.up.sql
09_add-job-cache.down.sql
09_add-job-cache.up.sql
10_node-table.down.sql
10_node-table.up.sql


Now I have all the context. Let me implement all the changes.


Write

-- Expression indexes on footprint JSON fields for WHERE and ORDER BY optimization.
-- SQLite matches expressions textually, so queries must use exactly:
--   json_extract(footprint, '$.field')

-- Standalone expression indexes (for filtering and sorting)
CREATE INDEX IF NOT EXISTS jobs_fp_flops_any_avg ON job (json_extract(footprint, '$.flops_any_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_mem_bw_avg ON job (json_extract(footprint, '$.mem_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_mem_used_max ON job (json_extract(footprint, '$.mem_used_max'));
CREATE INDEX IF NOT EXISTS jobs_fp_cpu_load_avg ON job (json_extract(footprint, '$.cpu_load_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_net_bw_avg ON job (json_extract(footprint, '$.net_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_net_data_vol_total ON job (json_extract(footprint, '$.net_data_vol_total'));
CREATE INDEX IF NOT EXISTS jobs_fp_file_bw_avg ON job (json_extract(footprint, '$.file_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_file_data_vol_total ON job (json_extract(footprint, '$.file_data_vol_total'));

-- Composite indexes with cluster (for common filter+sort combinations)
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_cpu_load_avg ON job (cluster, json_extract(footprint, '$.cpu_load_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_flops_any_avg ON job (cluster, json_extract(footprint, '$.flops_any_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_mem_bw_avg ON job (cluster, json_extract(footprint, '$.mem_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_mem_used_max ON job (cluster, json_extract(footprint, '$.mem_used_max'));


File created successfully at: /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/migrations/sqlite3/11_add-footprint-indexes.up.sql

-- Expression indexes on footprint JSON fields for WHERE and ORDER BY optimization.
-- SQLite matches expressions textually, so queries must use exactly:
--   json_extract(footprint, '$.field')

-- Standalone expression indexes (for filtering and sorting)
CREATE INDEX IF NOT EXISTS jobs_fp_flops_any_avg ON job (json_extract(footprint, '$.flops_any_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_mem_bw_avg ON job (json_extract(footprint, '$.mem_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_mem_used_max ON job (json_extract(footprint, '$.mem_used_max'));
CREATE INDEX IF NOT EXISTS jobs_fp_cpu_load_avg ON job (json_extract(footprint, '$.cpu_load_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_net_bw_avg ON job (json_extract(footprint, '$.net_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_net_data_vol_total ON job (json_extract(footprint, '$.net_data_vol_total'));
CREATE INDEX IF NOT EXISTS jobs_fp_file_bw_avg ON job (json_extract(footprint, '$.file_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_fp_file_data_vol_total ON job (json_extract(footprint, '$.file_data_vol_total'));

-- Composite indexes with cluster (for common filter+sort combinations)
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_cpu_load_avg ON job (cluster, json_extract(footprint, '$.cpu_load_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_flops_any_avg ON job (cluster, json_extract(footprint, '$.flops_any_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_mem_bw_avg ON job (cluster, json_extract(footprint, '$.mem_bw_avg'));
CREATE INDEX IF NOT EXISTS jobs_cluster_fp_mem_used_max ON job (cluster, json_extract(footprint, '$.mem_used_max'));


-- Drop standalone expression indexes
DROP INDEX IF EXISTS jobs_fp_flops_any_avg;
DROP INDEX IF EXISTS jobs_fp_mem_bw_avg;
DROP INDEX IF EXISTS jobs_fp_mem_used_max;
DROP INDEX IF EXISTS jobs_fp_cpu_load_avg;
DROP INDEX IF EXISTS jobs_fp_net_bw_avg;
DROP INDEX IF EXISTS jobs_fp_net_data_vol_total;
DROP INDEX IF EXISTS jobs_fp_file_bw_avg;
DROP INDEX IF EXISTS jobs_fp_file_data_vol_total;

-- Drop composite indexes
DROP INDEX IF EXISTS jobs_cluster_fp_cpu_load_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_flops_any_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_mem_bw_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_mem_used_max;


File created successfully at: /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/migrations/sqlite3/11_add-footprint-indexes.down.sql

-- Drop standalone expression indexes
DROP INDEX IF EXISTS jobs_fp_flops_any_avg;
DROP INDEX IF EXISTS jobs_fp_mem_bw_avg;
DROP INDEX IF EXISTS jobs_fp_mem_used_max;
DROP INDEX IF EXISTS jobs_fp_cpu_load_avg;
DROP INDEX IF EXISTS jobs_fp_net_bw_avg;
DROP INDEX IF EXISTS jobs_fp_net_data_vol_total;
DROP INDEX IF EXISTS jobs_fp_file_bw_avg;
DROP INDEX IF EXISTS jobs_fp_file_data_vol_total;

-- Drop composite indexes
DROP INDEX IF EXISTS jobs_cluster_fp_cpu_load_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_flops_any_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_mem_bw_avg;
DROP INDEX IF EXISTS jobs_cluster_fp_mem_used_max;


Edit

// Version history:
//   - Version 10: Current version
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 10

// Version history:
//   - Version 11: Add expression indexes on footprint JSON fields
//   - Version 10: Previous version
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 11


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/migration.go has been updated successfully.

// Version history:
//   - Version 11: Add expression indexes on footprint JSON fields
//   - Version 10: Previous version
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 11

// 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 10: Current version
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 10

//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
}

+//   - Version 11: Add expression indexes on footprint JSON fields


		// Order by footprint JSON field values
			query = query.Where("JSON_VALID(meta_data)")
			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))

		// Order by footprint JSON field values
			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))


The file /Users/jan/prg/ClusterCockpit/cc-backend/internal/repository/jobQuery.go has been updated successfully.

		// Order by footprint JSON field values
			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))

// 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(meta_data)")
			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).Query()
	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).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 {
		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(field 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, \"$."+field+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") <= ?", 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)
}

-      query = query.Where("JSON_VALID(meta_data)")

-        query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") ASC", field))

+        query = query.OrderBy(fmt.Sprintf("json_extract(footprint, '$.%s') ASC", field))

-        query = query.OrderBy(fmt.Sprintf("JSON_EXTRACT(footprint, \"$.%s\") DESC", field))

+        query = query.OrderBy(fmt.Sprintf("json_extract(footprint, '$.%s') DESC", field))


func buildFloatJSONCondition(field 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, \"$."+field+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+field+"\") <= ?", cond.To)
	} else {
		return query
	}
}

func buildFloatJSONCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 1.0 && cond.To != 0.0 {
		return query.Where("json_extract(footprint, '$."+field+"') BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("json_extract(footprint, '$."+field+"') >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("json_extract(footprint, '$."+field+"') <= ?", cond.To)
	} else {
		return query
	}
}