Back to ER Diagram
Database & Data Access

Database Functions & LINQ Data Access

PostgreSQL trigger functions, document number generation, and LINQ-based data access patterns using Entity Framework Core.

Trigger Functions
Doc Number Generator
Audit Logging
LINQ Queries

Development Approach

LINQ-Heavy Development

ProKure follows a LINQ-first approach using Entity Framework Core. All business logic resides in the C# application layer, with LINQ queries handling data operations. Database functions are limited to automatic triggers and utility functions only.

Primary: LINQ + EF Core DB: Triggers Only

Application Layer

  • All business logic in C#
  • LINQ queries via EF Core
  • Repository pattern
  • Service layer for operations

Database Triggers

  • update_updated_at_column()
  • log_data_change()
  • Auto timestamp management
  • Audit trail capture

Utility Functions

  • get_next_document_number()
  • Configurable prefixes
  • Auto-reset (YEARLY/MONTHLY)
  • Thread-safe sequences

PostgreSQL Trigger Functions

1. Auto-Update Timestamp Trigger

Automatically updates the updated_at column whenever a row is modified. This runs at database level so it works regardless of how the data is modified.

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Applied to tables via triggers:
CREATE TRIGGER trg_users_updated_at
    BEFORE UPDATE ON admin.users
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER trg_purchase_requests_updated_at
    BEFORE UPDATE ON pr.purchase_requests
    FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

-- Applied to all 166 tables that have updated_at column

2. Audit Log Trigger Function

Captures INSERT, UPDATE, and DELETE operations automatically, storing old/new data and changed columns in JSONB format for compliance and debugging.

CREATE OR REPLACE FUNCTION audit.log_data_change()
RETURNS TRIGGER AS $$
DECLARE
    v_old_data JSONB;
    v_new_data JSONB;
    v_changed_columns TEXT[];
    v_user_id UUID;
BEGIN
    -- Get current user from session variable (set by application)
    BEGIN
        v_user_id := current_setting('app.current_user_id')::UUID;
    EXCEPTION WHEN OTHERS THEN
        v_user_id := NULL;
    END;

    IF TG_OP = 'INSERT' THEN
        v_new_data := to_jsonb(NEW);
        INSERT INTO audit.data_change_logs (
            schema_name, table_name, record_id, operation,
            new_data, changed_by, transaction_id
        ) VALUES (
            TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id, TG_OP,
            v_new_data, v_user_id, txid_current()
        );
        RETURN NEW;

    ELSIF TG_OP = 'UPDATE' THEN
        v_old_data := to_jsonb(OLD);
        v_new_data := to_jsonb(NEW);

        -- Get list of changed columns
        SELECT array_agg(key) INTO v_changed_columns
        FROM (
            SELECT key FROM jsonb_each(v_old_data)
            WHERE v_old_data->key IS DISTINCT FROM v_new_data->key
        ) changed;

        IF v_changed_columns IS NOT NULL THEN
            INSERT INTO audit.data_change_logs (
                schema_name, table_name, record_id, operation,
                old_data, new_data, changed_columns, changed_by
            ) VALUES (
                TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id, TG_OP,
                v_old_data, v_new_data, v_changed_columns, v_user_id
            );
        END IF;
        RETURN NEW;

    ELSIF TG_OP = 'DELETE' THEN
        v_old_data := to_jsonb(OLD);
        INSERT INTO audit.data_change_logs (...);
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

Application Responsibility

The application must set the session user before operations: SET LOCAL app.current_user_id = 'uuid-here'; for proper audit attribution.

Document Number Generation

The system.get_next_document_number() function generates sequential document numbers with configurable formats, prefixes, and auto-reset capabilities. Called from C# service layer.

C# Service
calls function
Lock Sequence
FOR UPDATE
Check Reset
YEARLY/MONTHLY
Increment
current + 1
Return
PR-2026-00001
CREATE OR REPLACE FUNCTION system.get_next_document_number(
    p_company_id UUID,
    p_document_type VARCHAR(50)
) RETURNS VARCHAR(50) AS $$
DECLARE
    v_seq RECORD;
    v_next_number BIGINT;
    v_doc_number VARCHAR(50);
BEGIN
    -- Get and lock sequence configuration (thread-safe)
    SELECT * INTO v_seq
    FROM system.document_sequences
    WHERE company_id = p_company_id
      AND document_type = p_document_type
      AND is_active = true
    FOR UPDATE;

    -- Check if reset is needed (YEARLY, MONTHLY, DAILY)
    IF v_seq.reset_frequency = 'YEARLY'
       AND EXTRACT(YEAR FROM CURRENT_DATE) != EXTRACT(YEAR FROM v_seq.last_reset_date) THEN
        v_seq.current_number := v_seq.start_number - v_seq.increment_by;
        UPDATE system.document_sequences SET last_reset_date = CURRENT_DATE
        WHERE company_id = p_company_id AND document_type = p_document_type;
    END IF;

    -- Increment and build document number
    v_next_number := v_seq.current_number + v_seq.increment_by;

    UPDATE system.document_sequences
    SET current_number = v_next_number
    WHERE company_id = p_company_id AND document_type = p_document_type;

    v_doc_number := v_seq.prefix || '-' || TO_CHAR(CURRENT_DATE, 'YYYY') ||
                    '-' || LPAD(v_next_number::TEXT, v_seq.number_length, '0');

    RETURN v_doc_number;  -- e.g., "PR-2026-00001"
END;
$$ LANGUAGE plpgsql;

Document Types & Format Examples

Document Type Prefix Format Example
Purchase Requisition PR PR-YYYY-NNNNN PR-2026-00001
Request for Quotation RFQ RFQ-YYYY-NNNNN RFQ-2026-00042
Purchase Order PO PO-YYYY-NNNNN PO-2026-00156
Goods Receipt Note GRN GRN-YYYY-MM-NNNNN GRN-2026-01-00023
Reverse Auction RA RA-YYYY-NNNNN RA-2026-00089
Vendor Invoice INV INV-YYYY-NNNNN INV-2026-00512
Payment PAY PAY-YYYY-NNNNN PAY-2026-00078

LINQ Data Access Patterns

All data operations use LINQ queries through Entity Framework Core. Below are the common patterns used throughout the ProKure application.

1. Basic LINQ Queries

// PurchaseRequestService.cs
public class PurchaseRequestService : IPurchaseRequestService
{
    private readonly ProKureDbContext _context;

    public PurchaseRequestService(ProKureDbContext context)
    {
        _context = context;
    }

    // Get single PR with related data
    public async Task<PurchaseRequest?> GetByIdAsync(Guid id)
    {
        return await _context.PurchaseRequests
            .Include(pr => pr.Items)
            .Include(pr => pr.Approvals)
            .Include(pr => pr.RequestedBy)
            .Include(pr => pr.Department)
            .FirstOrDefaultAsync(pr => pr.Id == id);
    }

    // Filtered list with pagination
    public async Task<PagedResult<PurchaseRequestDto>> GetListAsync(
        Guid companyId, string? searchText, int page, int pageSize)
    {
        var query = _context.PurchaseRequests
            .Where(pr => pr.CompanyId == companyId && !pr.IsDeleted);

        if (!string.IsNullOrEmpty(searchText))
        {
            query = query.Where(pr =>
                pr.PrNumber.Contains(searchText) ||
                pr.RequestedBy.FirstName.Contains(searchText));
        }

        var totalCount = await query.CountAsync();

        var items = await query
            .OrderByDescending(pr => pr.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(pr => new PurchaseRequestDto
            {
                Id = pr.Id,
                PrNumber = pr.PrNumber,
                Status = pr.Status,
                TotalAmount = pr.TotalAmount,
                RequestedByName = pr.RequestedBy.FirstName + " " + pr.RequestedBy.LastName,
                CreatedAt = pr.CreatedAt
            })
            .ToListAsync();

        return new PagedResult<PurchaseRequestDto>(items, totalCount, page, pageSize);
    }
}

2. Complex Joins & Projections

// RfqService.cs - Complex query with multiple joins
public async Task<List<BidComparisonDto>> GetBidComparisonAsync(Guid rfqId)
{
    return await (
        from rfqItem in _context.RfqItems
        join bidItem in _context.VendorBidItems on rfqItem.Id equals bidItem.RfqItemId
        join bid in _context.VendorBids on bidItem.BidId equals bid.Id
        join vendor in _context.Vendors on bid.VendorId equals vendor.Id
        join product in _context.Products on rfqItem.ProductId equals product.Id
        where rfqItem.RfqId == rfqId
        orderby rfqItem.LineNumber, bidItem.UnitPrice
        select new BidComparisonDto
        {
            LineNumber = rfqItem.LineNumber,
            ProductCode = product.ProductCode,
            ProductName = product.ProductName,
            RequiredQty = rfqItem.Quantity,
            VendorName = vendor.VendorName,
            UnitPrice = bidItem.UnitPrice,
            TotalPrice = bidItem.UnitPrice * rfqItem.Quantity,
            DeliveryDays = bidItem.DeliveryDays,
            Remarks = bidItem.Remarks
        }
    ).ToListAsync();
}

// Alternative using method syntax with GroupJoin
public async Task<List<VendorWithRatingDto>> GetVendorsWithRatingsAsync(Guid categoryId)
{
    return await _context.Vendors
        .Where(v => v.VendorCategories.Any(vc => vc.CategoryId == categoryId))
        .Select(v => new VendorWithRatingDto
        {
            VendorId = v.Id,
            VendorCode = v.VendorCode,
            VendorName = v.VendorName,
            // Subquery for latest rating
            LatestRating = v.VendorRatings
                .OrderByDescending(r => r.RatedAt)
                .Select(r => r.CompositeScore)
                .FirstOrDefault(),
            Classification = v.VendorRatings
                .OrderByDescending(r => r.RatedAt)
                .Select(r => r.Classification)
                .FirstOrDefault()
        })
        .ToListAsync();
}

3. Create/Update Operations with Transactions

// PurchaseOrderService.cs - Transaction example
public async Task<PurchaseOrder> CreateFromRfqAsync(CreatePoFromRfqDto dto)
{
    await using var transaction = await _context.Database.BeginTransactionAsync();

    try
    {
        // Generate document number via DB function
        var poNumber = await _context.Database
            .SqlQuery<string>($"SELECT system.get_next_document_number({dto.CompanyId}, 'PO')")
            .FirstAsync();

        // Create PO header
        var purchaseOrder = new PurchaseOrder
        {
            Id = Guid.NewGuid(),
            PoNumber = poNumber,
            PoDate = DateTime.UtcNow,
            CompanyId = dto.CompanyId,
            VendorId = dto.VendorId,
            RfqId = dto.RfqId,
            Status = PoStatus.Draft,
            CreatedBy = dto.CreatedBy
        };

        _context.PurchaseOrders.Add(purchaseOrder);

        // Create PO items from awarded bid items
        var bidItems = await _context.VendorBidItems
            .Include(bi => bi.RfqItem)
            .Where(bi => dto.AwardedBidItemIds.Contains(bi.Id))
            .ToListAsync();

        var lineNumber = 1;
        foreach (var bidItem in bidItems)
        {
            _context.PoItems.Add(new PoItem
            {
                Id = Guid.NewGuid(),
                PoId = purchaseOrder.Id,
                LineNumber = lineNumber++,
                ProductId = bidItem.RfqItem.ProductId,
                Quantity = bidItem.Quantity,
                UnitPrice = bidItem.UnitPrice,
                RfqItemId = bidItem.RfqItemId
            });
        }

        // Update RFQ status
        var rfq = await _context.Rfqs.FindAsync(dto.RfqId);
        rfq!.Status = RfqStatus.Awarded;

        await _context.SaveChangesAsync();
        await transaction.CommitAsync();

        return purchaseOrder;
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Entity Framework Core Configuration

DbContext Setup with Schema Mapping

// ProKureDbContext.cs
public class ProKureDbContext : DbContext
{
    // Admin schema
    public DbSet<User> Users { get; set; }
    public DbSet<Role> Roles { get; set; }
    public DbSet<Permission> Permissions { get; set; }

    // Organization schema
    public DbSet<Company> Companies { get; set; }
    public DbSet<Department> Departments { get; set; }
    public DbSet<Plant> Plants { get; set; }

    // Purchase Requisition schema
    public DbSet<PurchaseRequest> PurchaseRequests { get; set; }
    public DbSet<PrItem> PrItems { get; set; }

    // RFQ schema
    public DbSet<Rfq> Rfqs { get; set; }
    public DbSet<VendorBid> VendorBids { get; set; }
    public DbSet<ReverseAuction> ReverseAuctions { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Schema mappings
        modelBuilder.Entity<User>().ToTable("users", "admin");
        modelBuilder.Entity<Role>().ToTable("roles", "admin");
        modelBuilder.Entity<Company>().ToTable("companies", "org");
        modelBuilder.Entity<PurchaseRequest>().ToTable("purchase_requests", "pr");
        modelBuilder.Entity<Rfq>().ToTable("rfqs", "rfq");
        modelBuilder.Entity<ReverseAuction>().ToTable("reverse_auctions", "rfq");

        // JSONB column mapping
        modelBuilder.Entity<Company>()
            .Property(c => c.Address)
            .HasColumnType("jsonb");

        modelBuilder.Entity<PurchaseRequest>()
            .Property(pr => pr.CustomData)
            .HasColumnType("jsonb");

        // Soft delete query filter
        modelBuilder.Entity<User>().HasQueryFilter(u => !u.IsDeleted);
        modelBuilder.Entity<Vendor>().HasQueryFilter(v => !v.IsDeleted);
        modelBuilder.Entity<PurchaseRequest>().HasQueryFilter(pr => !pr.IsDeleted);

        // Relationships
        modelBuilder.Entity<PurchaseRequest>()
            .HasMany(pr => pr.Items)
            .WithOne(i => i.PurchaseRequest)
            .HasForeignKey(i => i.PrId)
            .OnDelete(DeleteBehavior.Cascade);

        // Apply all configurations from assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProKureDbContext).Assembly);
    }
}

Best Practices

LINQ Queries

  • Use AsNoTracking() for reads
  • Project with Select()
  • Avoid N+1 with Include()
  • Filter before ToList()

Performance

  • Pagination on all lists
  • Use compiled queries
  • Index foreign keys
  • Batch SaveChanges

Security

  • Parameterized via LINQ
  • Filter by CompanyId
  • Soft delete pattern
  • Audit via triggers

Architecture

  • Repository pattern
  • Service layer for logic
  • DTOs for API transfer
  • Transactions for multi-op

Why LINQ Over Stored Procedures?

Advantages of LINQ Approach

  • Type-safe queries with compile-time checking
  • Easier refactoring and IDE support
  • Business logic stays in one place (C#)
  • Better unit testing capabilities
  • No SQL injection when using LINQ properly
  • Version control friendly (code vs DB scripts)
  • Easier debugging with breakpoints

When DB Functions Are Used

  • Triggers: Auto-timestamps (updated_at)
  • Triggers: Audit logging (change capture)
  • Functions: Document number generation
  • Reason: Must work regardless of access path
  • Reason: Thread-safe sequence generation
  • Reason: Cannot be bypassed by direct DB access