Skip to content

Latest commit

 

History

History
539 lines (449 loc) · 11.1 KB

File metadata and controls

539 lines (449 loc) · 11.1 KB

Extending SQLite

Learn how to extend SQLite with custom functions, aggregate functions, and loadable extensions.

Custom Scalar Functions

Scalar functions operate on individual values and return a single result.

Basic Functions

import { DatabaseSync } from "@photostructure/sqlite";

const db = new DatabaseSync(":memory:");

// Simple function
db.function("double", (x) => x * 2);

// Use in SQL
const result = db.prepare("SELECT double(21) as answer").get();
console.log(result.answer); // 42

// Multiple parameters
db.function("multiply", (a, b) => a * b);
const product = db.prepare("SELECT multiply(6, 7) as result").get();
console.log(product.result); // 42

Function Options

// Deterministic function - same input always produces same output
db.function(
  "hash",
  {
    deterministic: true,
  },
  (value) => {
    const crypto = require("crypto");
    return crypto.createHash("sha256").update(String(value)).digest("hex");
  },
);

// Direct-only function - cannot be used in triggers or views
db.function(
  "random_uuid",
  {
    directOnly: true,
  },
  () => {
    const crypto = require("crypto");
    return crypto.randomUUID();
  },
);

// Variable arguments function
db.function(
  "concat",
  {
    varargs: true,
  },
  (...args) => {
    return args.join("");
  },
);

// Use varargs function
const text = db
  .prepare('SELECT concat("Hello", " ", "World", "!") as msg')
  .get();
console.log(text.msg); // "Hello World!"

Real-World Examples

String Manipulation

// Reverse string function
db.function("reverse", (str) => {
  return str ? str.split("").reverse().join("") : null;
});

// Extract domain from email
db.function("email_domain", (email) => {
  if (!email || !email.includes("@")) return null;
  return email.split("@")[1].toLowerCase();
});

// Slugify function
db.function("slugify", (text) => {
  if (!text) return null;
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, "")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .trim();
});

// Usage
db.exec(`
  CREATE TABLE articles (
    id INTEGER PRIMARY KEY,
    title TEXT,
    slug TEXT GENERATED ALWAYS AS (slugify(title)) STORED
  )
`);

Date and Time

// Parse ISO date to Unix timestamp
db.function("iso_to_timestamp", (isoDate) => {
  const date = new Date(isoDate);
  return Math.floor(date.getTime() / 1000);
});

// Format timestamp as relative time
db.function("time_ago", (timestamp) => {
  const seconds = Math.floor(Date.now() / 1000) - timestamp;

  const intervals = [
    { label: "year", seconds: 31536000 },
    { label: "month", seconds: 2592000 },
    { label: "day", seconds: 86400 },
    { label: "hour", seconds: 3600 },
    { label: "minute", seconds: 60 },
  ];

  for (const interval of intervals) {
    const count = Math.floor(seconds / interval.seconds);
    if (count >= 1) {
      return `${count} ${interval.label}${count > 1 ? "s" : ""} ago`;
    }
  }

  return "just now";
});

// Usage
const posts = db
  .prepare(
    `
  SELECT title, time_ago(created_at) as posted
  FROM posts
  ORDER BY created_at DESC
`,
  )
  .all();

JSON Operations

// Parse JSON and extract field
db.function("json_get", (jsonStr, path) => {
  try {
    const obj = JSON.parse(jsonStr);
    return path.split(".").reduce((o, p) => o?.[p], obj);
  } catch {
    return null;
  }
});

// Check if JSON contains value
db.function("json_contains", (jsonStr, value) => {
  try {
    const obj = JSON.parse(jsonStr);
    return JSON.stringify(obj).includes(value) ? 1 : 0;
  } catch {
    return 0;
  }
});

Custom Aggregate Functions

Aggregate functions process multiple rows and return a single result.

Basic Aggregates

// Custom sum aggregate
db.aggregate("custom_sum", {
  start: 0,
  step: (total, value) => total + (value || 0),
});

// Custom average with null handling
db.aggregate("custom_avg", {
  start: { sum: 0, count: 0 },
  step: (acc, value) => {
    if (value != null) {
      acc.sum += value;
      acc.count += 1;
    }
    return acc;
  },
  result: (acc) => (acc.count > 0 ? acc.sum / acc.count : null),
});

Advanced Aggregates

Statistical Functions

// Standard deviation
db.aggregate("stddev", {
  start: { values: [] },
  step: (acc, value) => {
    if (value != null) acc.values.push(value);
    return acc;
  },
  result: (acc) => {
    if (acc.values.length === 0) return null;

    const mean = acc.values.reduce((a, b) => a + b) / acc.values.length;
    const variance =
      acc.values.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) /
      acc.values.length;

    return Math.sqrt(variance);
  },
});

// Median
db.aggregate("median", {
  start: { values: [] },
  step: (acc, value) => {
    if (value != null) acc.values.push(value);
    return acc;
  },
  result: (acc) => {
    if (acc.values.length === 0) return null;

    acc.values.sort((a, b) => a - b);
    const mid = Math.floor(acc.values.length / 2);

    return acc.values.length % 2 === 0
      ? (acc.values[mid - 1] + acc.values[mid]) / 2
      : acc.values[mid];
  },
});

String Aggregation

// Group concat with custom separator
db.aggregate("group_concat_custom", {
  start: () => ({ values: [], separator: null }),
  step: (acc, value, separator) => {
    if (value != null) acc.values.push(value);
    if (separator != null && acc.separator === null) {
      acc.separator = separator;
    }
    return acc;
  },
  result: (acc) => acc.values.join(acc.separator || ","),
});

// Usage
const tags = db
  .prepare(
    `
  SELECT group_concat_custom(tag, ' | ') as all_tags
  FROM post_tags
  WHERE post_id = ?
`,
  )
  .get(postId);

JSON Aggregation

// Aggregate into JSON array
db.aggregate("json_array_agg", {
  start: [],
  step: (acc, value) => {
    if (value != null) acc.push(value);
    return acc;
  },
  result: (acc) => JSON.stringify(acc),
});

// Aggregate into JSON object
db.aggregate("json_object_agg", {
  start: {},
  step: (acc, key, value) => {
    if (key != null) acc[key] = value;
    return acc;
  },
  result: (acc) => JSON.stringify(acc),
});

// Usage
const userPrefs = db
  .prepare(
    `
  SELECT json_object_agg(key, value) as preferences
  FROM user_settings
  WHERE user_id = ?
`,
  )
  .get(userId);

Window Functions

Aggregate functions can be used as window functions in SQLite. For full window function support with framed windows (using OVER (ORDER BY ...)), you should provide an inverse function that reverses the effect of step:

// Cumulative sum aggregate with inverse for window function support
db.aggregate("cumsum", {
  start: 0,
  step: (sum, value) => sum + (value || 0),
  inverse: (sum, value) => sum - (value || 0), // Required for window functions
});

// Use as window function
const results = db
  .prepare(
    `
  SELECT
    date,
    amount,
    cumsum(amount) OVER (ORDER BY date) as running_total
  FROM transactions
  WHERE account_id = ?
`,
  )
  .all(accountId);

Note: Without the inverse function, the aggregate will still work but may be less efficient for certain window frame types.

Loading Extensions

SQLite supports loadable extensions to add functionality at runtime.

Enabling Extensions

// Must be enabled in constructor
const db = new DatabaseSync("myapp.db", {
  allowExtension: true,
});

// Enable extension loading
db.enableLoadExtension(true);

// Load an extension
db.loadExtension("./extensions/vector.so");

// Disable for security
db.enableLoadExtension(false);

Platform-Specific Extensions

const path = require("path");
const os = require("os");

function loadExtension(db, extensionName) {
  // Determine platform-specific extension
  let ext;
  switch (os.platform()) {
    case "win32":
      ext = ".dll";
      break;
    case "darwin":
      ext = ".dylib";
      break;
    default:
      ext = ".so";
  }

  const extensionPath = path.join(__dirname, "extensions", extensionName + ext);

  db.enableLoadExtension(true);
  try {
    db.loadExtension(extensionPath);
    console.log(`Loaded extension: ${extensionName}`);
  } finally {
    db.enableLoadExtension(false);
  }
}

// Usage
loadExtension(db, "vector"); // Loads vector.so/.dll/.dylib
loadExtension(db, "crypto"); // Loads crypto extension

Common Extensions

Vector Search Extension

// After loading vector extension
db.exec(`
  CREATE VIRTUAL TABLE products_vec USING vec0(
    embedding float[384]
  );
`);

// Insert embeddings
const insertVec = db.prepare(`
  INSERT INTO products_vec(rowid, embedding) VALUES (?, ?)
`);

// Search similar vectors
const similar = db
  .prepare(
    `
  SELECT rowid, distance
  FROM products_vec
  WHERE embedding MATCH ?
  ORDER BY distance
  LIMIT 10
`,
  )
  .all(queryEmbedding);

Full-Text Search (Built-in)

// FTS5 is built into this package
db.exec(`
  CREATE VIRTUAL TABLE articles_fts USING fts5(
    title, 
    content,
    tags,
    tokenize = 'porter unicode61'
  );
`);

// Populate FTS table
db.exec(`
  INSERT INTO articles_fts(title, content, tags)
  SELECT title, content, tags FROM articles;
`);

// Search
const results = db
  .prepare(
    `
  SELECT title, snippet(articles_fts, 1, '<b>', '</b>', '...', 32) as excerpt
  FROM articles_fts
  WHERE articles_fts MATCH ?
  ORDER BY rank
`,
  )
  .all("sqlite NEAR extension");

Best Practices

Error Handling in Functions

db.function("safe_divide", (a, b) => {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
});

// More graceful error handling
db.function("safe_json_parse", (jsonStr) => {
  try {
    return JSON.stringify(JSON.parse(jsonStr));
  } catch (e) {
    return null; // Return null instead of throwing
  }
});

Performance Considerations

// Deterministic functions can be optimized by SQLite
db.function(
  "expensive_calculation",
  {
    deterministic: true, // SQLite can cache results
  },
  (input) => {
    // Complex calculation
    return complexMath(input);
  },
);

// Avoid creating closures in hot paths
const cache = new Map();
db.function("cached_lookup", (key) => {
  if (!cache.has(key)) {
    cache.set(key, expensiveLookup(key));
  }
  return cache.get(key);
});

Testing Custom Functions

// Test helper
function testFunction(db, functionName, testCases) {
  for (const { input, expected } of testCases) {
    const sql = `SELECT ${functionName}(${input.map(() => "?").join(",")}) as result`;
    const result = db.prepare(sql).get(...input);

    if (result.result !== expected) {
      throw new Error(
        `${functionName}(${input.join(", ")}) = ${result.result}, expected ${expected}`,
      );
    }
  }
  console.log(`${functionName} passed all tests`);
}

// Test custom functions
testFunction(db, "double", [
  { input: [5], expected: 10 },
  { input: [0], expected: 0 },
  { input: [-3], expected: -6 },
]);

Next Steps