Menangani nilai desimal dan mata uang

Driver mssql-python memetakan tipe data desimal, numerik, dan uang SQL Server ke Python decimal.Decimal untuk perhitungan keuangan dan ilmiah yang akurat. SQL Server menyediakan jenis numerik yang tepat berikut:

Jenis SQL Jenis Python Presisi Kasus Penggunaan
desimal(p,s) decimal.Decimal 1-38 digit Perhitungan yang tepat
numerik(p,s) decimal.Decimal 1-38 angka Sama dengan desimal
uang decimal.Decimal 19 digit, 4 desimal Mata uang
smallmoney decimal.Decimal 10 digit, 4 desimal Mata uang kecil
float float sekitar 15 digit Perkiraan
real float ~7 digit Perkiraan

Python Tipe desimal

Menerima nilai desimal

Kolom desimal mengembalikan objek Pythondecimal.Decimal:

from decimal import Decimal
import mssql_python

conn = mssql_python.connect(connection_string)
cursor = conn.cursor()

cursor.execute(
    "SELECT SubTotal, TaxAmt, TotalDue "
    "FROM Sales.SalesOrderHeader WHERE SalesOrderID = 43659"
)
row = cursor.fetchone()

print(type(row.SubTotal))  # <class 'decimal.Decimal'>
print(row.SubTotal)        # Decimal('20565.6206')
print(row.TaxAmt)          # Decimal('1971.5149')
print(row.TotalDue)        # Decimal('23153.2339')

Mengirim nilai desimal

Lewati Decimal objek untuk penyisipan yang tepat:

from decimal import Decimal

cursor.execute("""
    CREATE TABLE #ProductDemo (
        Name NVARCHAR(50),
        Price DECIMAL(10,2),
        Cost DECIMAL(10,2)
    )
""")
cursor.execute("""
    INSERT INTO #ProductDemo (Name, Price, Cost)
    VALUES (%(name)s, %(price)s, %(cost)s)
""", {
    "name": "Widget",
    "price": Decimal("199.99"),
    "cost": Decimal("87.50")
})
conn.commit()

Hindari float untuk data keuangan

Tipe float memiliki masalah presisi sehingga tidak cocok untuk perhitungan keuangan. Selalu gunakan Decimal untuk hasil yang akurat:

from decimal import Decimal

# Bad: float has precision issues
price = 0.1 + 0.2  # 0.30000000000000004

# Good: Decimal is exact
price = Decimal("0.1") + Decimal("0.2")  # Decimal('0.3')

# Use string constructor for literal values
correct = Decimal("19.99")  # Exact
avoid = Decimal(19.99)      # Might introduce float imprecision

Jenis uang

Bekerja dengan kolom uang

Mengambil dan memperbarui kolom uang SQL Server menggunakan objek Python Decimal:

from decimal import Decimal

# Create and populate a temp table with a money column
cursor.execute("""
    CREATE TABLE #Accounts (
        ID INT,
        AccountID NVARCHAR(20),
        Balance MONEY
    )
""")
cursor.execute(
    "INSERT INTO #Accounts (ID, AccountID, Balance) VALUES (1, %(acct)s, %(bal)s)",
    {"acct": "ACCT-001", "bal": Decimal("1234.5678")}
)
conn.commit()

cursor.execute("SELECT AccountID, Balance FROM #Accounts WHERE ID = 1")
row = cursor.fetchone()

print(row.Balance)         # Decimal('1234.5678')
print(type(row.Balance))   # <class 'decimal.Decimal'>

# Money arithmetic
cursor.execute("""
    UPDATE #Accounts 
    SET Balance = Balance + %(amount)s 
    WHERE ID = %(id)s
""", {"amount": Decimal("100.00"), "id": 1})
conn.commit()

Pemformatan uang

Format nilai desimal sebagai string mata uang dengan simbol dan pemisah koma:

from decimal import Decimal

def format_currency(value: Decimal, symbol: str = "$") -> str:
    """Format decimal as currency string."""
    return f"{symbol}{value:,.2f}"

cursor.execute("SELECT TotalDue FROM Sales.SalesOrderHeader")
for row in cursor:
    print(format_currency(row.TotalDue))  # $1,234.56

Presisi dan skala desimal

Pahami presisi dan skala

  • Presisi (p): Jumlah total digit
  • Skala (s): Jumlah digit setelah tanda desimal

Misalnya, decimal(10, 2) dapat menyimpan nilai dari -999999999.99 hingga 99999999.99, sedangkan decimal(5, 4) dapat menyimpan nilai dari -9.9999 hingga 9.9999.

Tentukan presisi pada parameter

Driver mssql-python menyimpulkan presisi dan skala dari nilai Desimal secara otomatis:

from decimal import Decimal

# Create a temp table for the measurement value
cursor.execute("CREATE TABLE #Measurements (Value DECIMAL(18,6))")

# The driver infers precision from the Decimal value
value = Decimal("123.456789")
cursor.execute("INSERT INTO #Measurements (Value) VALUES (%(v)s)", {"v": value})
conn.commit()

Kontrol pembulatan

Gunakan metode quantize() untuk membulatkan nilai desimal ke skala tertentu:

from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN

price = Decimal("19.999")

# Round to 2 decimal places
rounded = price.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(rounded)  # Decimal('20.00')

# Round down (truncate)
truncated = price.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
print(truncated)  # Decimal('19.99')

Perhitungan keuangan

Aritmatika yang aman

Terapkan perhitungan keuangan menggunakan Desimal dengan pembulatan yang tepat:

from decimal import Decimal, ROUND_HALF_UP

def calculate_total(price: Decimal, quantity: int, tax_rate: Decimal) -> Decimal:
    """Calculate order total with tax."""
    subtotal = price * quantity
    tax = (subtotal * tax_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    return subtotal + tax

cursor.execute("SELECT ListPrice FROM Production.Product WHERE ProductID = 1")
price = cursor.fetchval()

total = calculate_total(
    price=price,
    quantity=3,
    tax_rate=Decimal("0.0825")  # 8.25% tax
)

cursor.execute("""
    CREATE TABLE #OrderCalc (
        ProductID INT, Quantity INT, Total DECIMAL(10,2), Tax DECIMAL(10,2)
    )
""")
cursor.execute("""
    INSERT INTO #OrderCalc (ProductID, Quantity, Total, Tax)
    VALUES (%(prod)s, %(qty)s, %(total)s, %(tax)s)
""", {"prod": 1, "qty": 3, "total": total, "tax": total * Decimal("0.0825")})

Perhitungan persentase

Hitung diskon dan penyesuaian berbasis persentase:

from decimal import Decimal

def calculate_discount(price: Decimal, discount_percent: Decimal) -> Decimal:
    """Calculate discounted price."""
    discount = (price * discount_percent / 100).quantize(Decimal("0.01"))
    return price - discount

original = Decimal("99.99")
discounted = calculate_discount(original, Decimal("15"))  # 15% off
print(f"Original: ${original}, After discount: ${discounted}")

Membagi jumlah

Bagi jumlah total secara merata di antara beberapa pihak, tangani sisanya:

from decimal import Decimal, ROUND_DOWN

def split_amount(total: Decimal, ways: int) -> list[Decimal]:
    """Split amount evenly, handling remainder."""
    per_person = (total / ways).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
    remainder = total - (per_person * ways)
    
    amounts = [per_person] * ways
    # Add remainder to first person
    amounts[0] += remainder
    return amounts

total = Decimal("100.00")
split = split_amount(total, 3)
print(split)  # [Decimal('33.34'), Decimal('33.33'), Decimal('33.33')]
print(sum(split))  # Decimal('100.00') - always equals original

Operasi agregasi

SUM dan AVG dengan desimal

Fungsi agregasi SQL Server mengembalikan jenis desimal untuk presisi:

# SUM preserves decimal type
cursor.execute("SELECT SUM(TotalDue) AS TotalSales FROM Sales.SalesOrderHeader")
total_sales = cursor.fetchval()
print(type(total_sales))  # <class 'decimal.Decimal'>

# AVG might return more decimal places
cursor.execute("SELECT AVG(ListPrice) AS AvgPrice FROM Production.Product")
avg_price = cursor.fetchval()
# Round to desired precision
avg_price = avg_price.quantize(Decimal("0.01"))

Menangani NULL dalam agregasi

Fungsi agregasi SQL Server dapat dikembalikan None ketika tidak ada baris yang cocok dengan kueri:

cursor.execute("SELECT SUM(TotalDue) FROM Sales.SalesOrderHeader WHERE CustomerID = 0")
total_discount = cursor.fetchval()

# SUM returns NULL if no rows match
if total_discount is None:
    total_discount = Decimal("0.00")

Konverter output untuk bilangan desimal

Konverter keluaran menggunakan jenis Python yang ditampilkan di cursor.description sebagai kunci, bukan bilangan bulat tipe SQL ODBC. Daftarkan konverter dengan decimal.Decimal sehingga berfungsi untuk kolom decimal, numeric, money, dan smallmoney, yang semuanya dipetakan ke decimal.Decimal.

Konversi ke float (saat presisi tidak penting)

Untuk kompatibilitas dengan pustaka yang mengharapkan nilai float, tentukan konverter output:

import mssql_python
from decimal import Decimal

def decimal_to_float(value):
    """Convert Decimal to float."""
    if value is None:
        return None
    return float(value)  # value is already a Decimal object

conn = mssql_python.connect(connection_string)

# Convert decimals to float for compatibility with libraries that expect float
conn.add_output_converter(Decimal, decimal_to_float)

cursor = conn.cursor()
cursor.execute("SELECT ListPrice FROM Production.Product WHERE ProductID = 1")
row = cursor.fetchone()
print(type(row.ListPrice))  # <class 'float'>

Konverter pemformatan uang khusus

Tentukan konverter output kustom untuk secara otomatis memformat nilai Desimal sebagai string mata uang:

from decimal import Decimal

def money_to_string(value):
    """Convert Decimal to formatted string."""
    if value is None:
        return None
    return f"${value:,.2f}"  # value is already a Decimal object

conn.add_output_converter(Decimal, money_to_string)

cursor = conn.cursor()
cursor.execute("CREATE TABLE #Accounts (Balance DECIMAL(19,4))")
cursor.execute(
    "INSERT INTO #Accounts (Balance) VALUES (%(bal)s)",
    {"bal": Decimal("1234.56")}
)
conn.commit()

cursor.execute("SELECT Balance FROM #Accounts")
for row in cursor:
    print(row.Balance)  # "$1,234.56"

Pola umum

Konversi mata uang

Konversi jumlah moneter antar mata uang menggunakan nilai tukar:

from decimal import Decimal

def convert_currency(amount: Decimal, rate: Decimal) -> Decimal:
    """Convert currency at given exchange rate."""
    return (amount * rate).quantize(Decimal("0.01"))

usd_amount = Decimal("100.00")
eur_rate = Decimal("0.92")
eur_amount = convert_currency(usd_amount, eur_rate)
print(f"${usd_amount} = €{eur_amount}")

Pembaruan saldo dengan transaksi

Gunakan transaksi dengan petunjuk kunci yang sesuai untuk memastikan transfer dana atom dan mencegah kehilangan update:

from decimal import Decimal

def transfer_funds(conn, from_account: int, to_account: int, amount: Decimal):
    """Transfer funds between accounts atomically."""
    cursor = conn.cursor()
    conn.autocommit = False
    
    try:
        balances = {}
        for account_id in sorted((from_account, to_account)):
            cursor.execute(
                "SELECT Balance FROM #Accounts WITH (UPDLOCK) WHERE ID = %(id)s",
                {"id": account_id}
            )
            row = cursor.fetchone()
            if row is None:
                raise ValueError(f"Account {account_id} not found")
            balances[account_id] = row.Balance
        
        if balances[from_account] < amount:
            raise ValueError("Insufficient funds")
        
        # Debit source
        cursor.execute("""
            UPDATE #Accounts SET Balance = Balance - %(amount)s
            WHERE ID = %(id)s
        """, {"amount": amount, "id": from_account})
        
        # Credit destination
        cursor.execute("""
            UPDATE #Accounts SET Balance = Balance + %(amount)s
            WHERE ID = %(id)s
        """, {"amount": amount, "id": to_account})
        
        conn.commit()
        
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.autocommit = True

# Set up demo accounts
cursor = conn.cursor()
cursor.execute("CREATE TABLE #Accounts (ID INT, Balance MONEY)")
cursor.executemany(
    "INSERT INTO #Accounts (ID, Balance) VALUES (%(id)s, %(bal)s)",
    [{"id": 1, "bal": Decimal("1000.00")}, {"id": 2, "bal": Decimal("500.00")}]
)
conn.commit()

# Usage
transfer_funds(conn, from_account=1, to_account=2, amount=Decimal("500.00"))

Transfer mengunci kedua baris sejak awal, dimulai dari ID terendah, dengan menjalankan SELECT ... WITH (UPDLOCK) terpisah untuk setiap akun berdasarkan urutan yang diurutkan. UPDLOCK mencegah pola pembaruan yang hilang di mana dua transaksi membaca saldo yang sama dan menerapkan pembaruan berdasarkan data kedaluwarsa. Menerapkan penguncian melalui pernyataan-pernyataan terpisah dalam urutan terurut inilah yang menjamin urutan perolehan kunci, sehingga transfer berlawanan arah antara dua akun yang sama tidak memperoleh kunci dalam urutan terbalik dan mengalami deadlock.

Penyisipan massal dengan desimal

Sisipkan beberapa baris dengan nilai Desimal menggunakan executemany():

from decimal import Decimal

cursor.execute("""
    CREATE TABLE #BulkProducts (
        Name NVARCHAR(50),
        Price DECIMAL(10,2),
        Cost DECIMAL(10,2)
    )
""")

products = [
    {"name": "Widget A", "price": Decimal("19.99"), "cost": Decimal("8.50")},
    {"name": "Widget B", "price": Decimal("29.99"), "cost": Decimal("12.75")},
    {"name": "Widget C", "price": Decimal("39.99"), "cost": Decimal("18.00")},
]

cursor.executemany("""
    INSERT INTO #BulkProducts (Name, Price, Cost)
    VALUES (%(name)s, %(price)s, %(cost)s)
""", products)
conn.commit()

Praktik terbaik

Selalu gunakan Desimal untuk matematika keuangan

Jangan pernah menggunakan float untuk perhitungan keuangan; selalu gunakan tipe Desimal untuk presisi:

# Wrong: float precision issues
price = 0.10
quantity = 3
total = price * quantity  # 0.30000000000000004

# Correct: Decimal is exact
price = Decimal("0.10")
quantity = 3
total = price * quantity  # Decimal('0.30')

Gunakan konstruktor string

Buat nilai Desimal dari string untuk menghindari ketidaktepatan float:

# Good: exact value
amount = Decimal("123.45")

# Risky: might inherit float imprecision
amount = Decimal(123.45)  # Could be 123.4500000000000028...

Selalu bulatkan sebelum ditampilkan atau disimpan

Bulatkan nilai desimal ke skala yang sesuai sebelum ditampilkan atau disimpan ke basis data:

from decimal import Decimal, ROUND_HALF_UP

calculated = Decimal("123.456789")
display = calculated.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

Tetapkan konteks desimal jika diperlukan

Presisi konteks desimal memengaruhi hasil perhitungan, bukan nilai yang diurai dari string atau dibaca dari database. Kueri selalu mengembalikan nilai atau money atau decimal dengan skala tersimpan penuhnya, terlepas dari getcontext().prec. Konteks hanya berlaku saat Anda melakukan komputasi dengannya. Untuk melihat efek presisi mulai berlaku, lakukan operasi seperti pembagian.

from decimal import Decimal, getcontext

# A money value read from SQL Server arrives at its full stored scale,
# regardless of the context precision.
cursor.execute(
    "SELECT SubTotal FROM Sales.SalesOrderHeader WHERE SalesOrderID = 43659"
)
subtotal = cursor.fetchone().SubTotal
print(subtotal)  # Decimal('20565.6206')

# The context precision governs the calculation, not the fetched value.
# Split the subtotal into three equal installments.
getcontext().prec = 10
print(subtotal / 3)  # 6855.206867 (10 significant digits)

getcontext().prec = 28  # Reset to default
print(subtotal / 3)  # 6855.206866666666666666666667 (28 significant digits)