Type Affinity in SQLite WHERE Clause Comparisons

Column Affinity and Comparison Semantics in SQLite Queries

Issue Overview: Type Mismatch in Foreign Key Column Filtering

The core issue revolves around unexpected query results when filtering on a foreign key column using string literals versus integer values. Consider a schema with two tables:

CREATE TABLE a(id INTEGER PRIMARY KEY NOT NULL, x);
CREATE TABLE b(id INTEGER PRIMARY KEY NOT NULL, y, fkx, FOREIGN KEY(fkx) REFERENCES a(id));

After inserting test data:

INSERT INTO a (x) VALUES("hello");
INSERT INTO b (y, fkx) VALUES("y is here", 1);

Queries against b.id succeed with both numeric and text literals:

SELECT * FROM b WHERE id = 1;    -- Returns row
SELECT * FROM b WHERE id = "1";  -- Returns row

But equivalent queries against b.fkx show divergent behavior:

SELECT * FROM b WHERE fkx = 1;    -- Returns row
SELECT * FROM b WHERE fkx = "1";  -- Empty result

This discrepancy stems from SQLite’s type affinity system and its interaction with column declaration types. The b.id column declared as INTEGER PRIMARY KEY has integer affinity, triggering automatic type conversion during comparisons. The b.fkx column declared without explicit type specification has no affinity, resulting in strict type-based comparisons.

Three fundamental concepts explain this behavior:

  1. Storage Classes: SQLite uses dynamic typing with five storage classes (NULL, INTEGER, REAL, TEXT, BLOB)
  2. Type Affinity: Columns’ preferred storage class determined by declaration syntax
  3. Comparison Rules: Governed by storage class hierarchy and affinity conversions

When comparing values, SQLite follows this priority order:

  1. NULL
  2. INTEGER/REAL
  3. TEXT
  4. BLOB

Values with different storage classes compare unequal unless affinity conversions occur first. The foreign key constraint in this scenario only enforces referential integrity – it does not influence column affinity or comparison behavior.

Root Causes: Type Affinity Mismatch and Declaration Ambiguity

The empty result from WHERE fkx = "1" arises from multiple interacting factors:

1. Column Declaration Syntax

  • b.id is declared with INTEGER PRIMARY KEY, establishing integer affinity
  • b.fkx lacks type specification, defaulting to numeric affinity through declaration heuristic:
    • Column name "fkx" doesn’t match any type names (INT, TEXT, etc.)
    • Falls under "NUMERIC" affinity category per SQLite rules

2. Affinity-Driven Type Conversion
For columns with integer affinity (b.id):

  • Comparison values are converted to INTEGER before evaluation
  • "1" becomes integer 1 through affinity conversion

For numeric-affinity columns (b.fkx):

  • Attempts to convert both operands to INTEGER/REAL if possible
  • But stored TEXT values retain their type unless explicitly converted

3. Storage Class Preservation
The foreign key column fkx contains integer values due to referencing a.id (INTEGER PRIMARY KEY). However, without explicit type declaration:

  • SQLite stores values using most appropriate storage class
  • Inserted as INTEGER (1) but could accept TEXT values
  • Comparison uses storage class not logical value

4. Foreign Key Constraints
While foreign keys enforce referential integrity between tables:

  • They do not coerce data types
  • The referenced column’s type affinity doesn’t propagate
  • Value storage class remains independent in child table

5. String Literal Handling
Using double-quoted values ("1") creates TEXT values:

  • With integer affinity columns: Converted to INTEGER
  • With numeric affinity columns: Treated as TEXT unless convertible

The critical difference emerges because b.fkx stores INTEGER values but allows TEXT storage. When comparing INTEGER (storage class 1) with TEXT (storage class 3), they’re considered distinct unless type conversion occurs first.

Resolution Strategies: Type Consistency and Schema Design

1. Explicit Column Typing
Modify the foreign key declaration to specify INTEGER type:

CREATE TABLE b(
  id INTEGER PRIMARY KEY NOT NULL, 
  y, 
  fkx INTEGER,  -- Explicit type declaration
  FOREIGN KEY(fkx) REFERENCES a(id)
);

This establishes integer affinity for fkx, enabling automatic conversion of comparison values.

2. Parameter Binding with Correct Types
When using Go’s database/sql package:

// Incorrect: Passing ID as string
db.Query("SELECT * FROM b WHERE fkx = ?", "1")

// Correct: Use integer type
db.Query("SELECT * FROM b WHERE fkx = ?", 1)

Go’s type system requires explicit conversion if source data is string-based:

idStr := "1"
id, err := strconv.Atoi(idStr)
// Handle error
db.Query("SELECT * FROM b WHERE fkx = ?", id)

3. CAST Operations in Queries
Explicitly convert comparison values:

SELECT * FROM b WHERE fkx = CAST("1" AS INTEGER);

4. Consistent Literal Formatting
Use SQL-standard string literals with single quotes:

SELECT * FROM b WHERE fkx = '1';  -- Still problematic without conversion

But combine with CAST for reliability:

SELECT * FROM b WHERE fkx = CAST('1' AS INTEGER);

5. Schema Migration for Existing Data
For existing tables with mixed types:

-- Create new table with proper typing
CREATE TABLE b_new(
  id INTEGER PRIMARY KEY,
  y TEXT,
  fkx INTEGER,
  FOREIGN KEY(fkx) REFERENCES a(id)
);

-- Copy data with type conversion
INSERT INTO b_new SELECT id, y, CAST(fkx AS INTEGER) FROM b;

-- Replace old table
DROP TABLE b;
ALTER TABLE b_new RENAME TO b;

6. Type Checking Constraints
Add CHECK constraints to enforce data types:

CREATE TABLE b(
  id INTEGER PRIMARY KEY,
  y TEXT,
  fkx INTEGER CHECK(TYPEOF(fkx) = 'integer'),
  FOREIGN KEY(fkx) REFERENCES a(id)
);

7. Diagnostic Queries
Investigate storage classes using TYPEOF():

SELECT fkx, TYPEOF(fkx) FROM b;

8. SQLite Configuration Options
Modify type conversion behavior (not recommended):

PRAGMA legacy_column_type = 1;  -- Changes type affinity rules

9. Application-Level Sanitization
Implement input validation in Go code:

func sanitizeID(input string) (int, error) {
    if id, err := strconv.Atoi(input); err == nil {
        return id, nil
    }
    return 0, fmt.Errorf("invalid integer ID")
}

10. Index Considerations
Type mismatches impact index usage:

EXPLAIN QUERY PLAN SELECT * FROM b WHERE fkx = "1";
-- May show full table scan if type conversion prevents index use

Create properly typed indexes:

CREATE INDEX b_fkx_idx ON b(fkx);

11. Foreign Key Verification
Ensure data integrity with:

PRAGMA foreign_key_check;

12. SQLite Version-Specific Behaviors
Test across versions (3.37+ recommended) as type handling evolves:

SELECT sqlite_version();

13. ORM Mapping Considerations
In Go structs, use int types instead of string for IDs:

type B struct {
    ID  int    `db:"id"`
    Y   string `db:"y"`
    Fkx int    `db:"fkx"`  // Match SQLite integer type
}

14. Prepared Statement Advantages
Use parameterized queries to avoid string conversion:

stmt, err := db.Prepare("SELECT * FROM b WHERE fkx = ?")
// Handle error
rows, err := stmt.Query(1)  // Integer parameter

15. JSON Handling Implications
When returning results to JSON APIs:

type BJSON struct {
    ID  string `json:"id"`  // String representation
    Fkx string `json:"fkx"`
}

// Convert database types during marshaling
func (b B) ToJSON() BJSON {
    return BJSON{
        ID:  strconv.Itoa(b.ID),
        Fkx: strconv.Itoa(b.Fkx),
    }
}

16. Testing Strategies
Implement comprehensive type tests:

func TestForeignKeyType(t *testing.T) {
    db := initTestDB()
    _, err := db.Exec("INSERT INTO b (y, fkx) VALUES (?, ?)", "test", "1")
    if err == nil {
        t.Error("Expected error when inserting string into integer column")
    }
}

17. Documentation Practices
Annotate schema with comments:

CREATE TABLE b(
  id INTEGER PRIMARY KEY NOT NULL, -- Always integer
  y TEXT,                         -- Free-form text
  fkx INTEGER,                    -- References a.id (INTEGER)
  FOREIGN KEY(fkx) REFERENCES a(id)
);

18. Migration Rollback Plans
Use transactional schema changes:

BEGIN;
CREATE TABLE new_table(...);
INSERT INTO new_table ...;
DROP TABLE old_table;
ALTER TABLE new_table RENAME TO old_table;
COMMIT;

19. Monitoring and Logging
Enable SQLite trace to see type conversions:

sql.Open("sqlite3", "file:test.db?_trace=1")

20. Cross-Database Considerations
Understand differences with other DBs:

  • PostgreSQL: Strict type checking
  • MySQL: Silent type conversions
  • SQLite: Affinity-based conversions

This comprehensive approach addresses the immediate type comparison issue while establishing robust practices for SQLite schema design and application integration. Proper type handling ensures reliable query results, optimal performance, and maintainable codebases across the application stack.

Related Guides

Leave a Reply

Your email address will not be published. Required fields are marked *