Getting Started

View Source

This guide will walk you through your first steps with Lotus, from creating your first query to understanding the results.

Prerequisites

Before starting, make sure you have:

  • Completed the Installation guide
  • A running Elixir application with Ecto and Lotus configured
  • Some data in your database to query

Your First Query

Creating a Saved Query

Let's create and save a simple query:

# Create a new query
{:ok, query} = Lotus.create_query(%{
  name: "Count Users",
  query: %{
    sql: "SELECT COUNT(*) as user_count FROM users"
  }
})

IO.inspect(query)
# %Lotus.Storage.Query{
#   id: 1,
#   name: "Count Users",
#   query: %{"sql" => "SELECT COUNT(*) as user_count FROM users"},
#   inserted_at: ~N[2024-01-15 10:30:00],
#   updated_at: ~N[2024-01-15 10:30:00]
# }

Running the Query

Now let's execute our saved query:

# Execute the saved query
{:ok, result} = Lotus.run_query(query)

IO.inspect(result)
# %Lotus.QueryResult{
#   columns: ["user_count"],
#   rows: [[42]],
#   num_rows: 1
# }

Accessing Results

The QueryResult struct contains all the information about your query execution:

# Get the column names
result.columns
# ["user_count"]

# Get the data rows
result.rows
# [[42]]

# Get the number of rows returned
result.num_rows
# 1

# The QueryResult struct contains:
# - columns: list of column names
# - rows: list of result rows
# - num_rows: total count of returned rows

Ad-hoc Queries

Sometimes you want to run a query without saving it first:

# Run SQL directly
{:ok, result} = Lotus.run_sql(
  "SELECT name, email FROM users WHERE active = $1 LIMIT $2",
  [true, 10]
)

IO.inspect(result.columns)
# ["name", "email"]

IO.inspect(result.rows)
# [
#   ["Alice Johnson", "alice@example.com"],
#   ["Bob Smith", "bob@example.com"],
#   ...
# ]

Working with Multiple Data Repositories

If you have configured multiple data repositories, you can execute queries against specific databases:

# Execute against a specific repository by name
{:ok, result} = Lotus.run_sql(
  "SELECT COUNT(*) FROM page_views WHERE date = $1",
  [Date.utc_today()],
  repo: "analytics"
)

# Execute against a repository module directly
{:ok, result} = Lotus.run_sql(
  "SELECT SUM(amount) FROM transactions",
  [],
  repo: MyApp.SqliteRepo
)

# List all available data repositories
repo_names = Lotus.list_data_repo_names()
IO.inspect(repo_names)
# ["main", "analytics", "sqlite_data"]

Storing Queries with Specific Data Repositories

You can store queries with a specific data repository, so they automatically execute against the correct database:

# Create a query that will run against the analytics database
{:ok, analytics_query} = Lotus.create_query(%{
  name: "Daily Page Views",
  query: %{
    sql: "SELECT COUNT(*) FROM page_views WHERE date = $1",
    params: [Date.utc_today()]
  },
  data_repo: "analytics"
})

# Create a query for the main database
{:ok, user_query} = Lotus.create_query(%{
  name: "Active Users",
  query: %{sql: "SELECT COUNT(*) FROM users WHERE active = true"},
  data_repo: "main"
})

# Execute queries - they automatically use their stored data_repo
{:ok, analytics_result} = Lotus.run_query(analytics_query)
{:ok, user_result} = Lotus.run_query(user_query)

Runtime Repository Override

You can override the stored data repository at execution time:

# Query was saved with data_repo: "analytics"
{:ok, query} = Lotus.create_query(%{
  name: "User Count",
  query: %{sql: "SELECT COUNT(*) FROM users"},
  data_repo: "analytics"
})

# Execute against the stored repository
{:ok, result} = Lotus.run_query(query)

# Override at runtime to use a different repository
{:ok, result} = Lotus.run_query(query, repo: "main")

Fallback Behavior

If you don't specify a data_repo when creating a query, it will use the default repository when executed:

# Query without specific data_repo
{:ok, query} = Lotus.create_query(%{
  name: "Generic Query",
  query: %{sql: "SELECT 1"}
  # No data_repo specified
})

# Will use the default configured repository
{:ok, result} = Lotus.run_query(query)

Managing Saved Queries

Listing All Queries

# Get all saved queries
queries = Lotus.list_queries()

Enum.each(queries, fn query ->
  IO.puts("#{query.id}: #{query.name}")
end)
# 1: Count Users
# 2: Active Users Report
# 3: Monthly Sales Summary

Finding a Specific Query

# Get a query by ID
query = Lotus.get_query!(1)
IO.puts(query.name)
# "Count Users"

Updating a Query

# Update an existing query
{:ok, updated_query} = Lotus.update_query(query, %{
  name: "Total User Count",
  query: %{
    sql: "SELECT COUNT(*) as total_users FROM users WHERE deleted_at IS NULL"
  }
})

IO.puts(updated_query.name)
# "Total User Count"

Deleting a Query

# Delete a query
{:ok, _deleted_query} = Lotus.delete_query(query)

# Verify it's gone
try do
  Lotus.get_query!(query.id)
rescue
  Ecto.NoResultsError -> IO.puts("Query deleted successfully")
end

PostgreSQL Schema Resolution with search_path

When working with PostgreSQL databases that use multiple schemas, you can use search_path to resolve unqualified table names. This is especially useful for multi-tenant applications or when you have separate schemas for reporting, analytics, or different environments.

Understanding search_path

PostgreSQL's search_path determines which schemas are searched when you reference an unqualified table name like users instead of reporting.users. For example:

# Without search_path - must fully qualify table names
{:error, reason} = Lotus.run_sql("SELECT * FROM customers")
# "SQL error: relation \"customers\" does not exist"

# With search_path - finds reporting.customers automatically
{:ok, result} = Lotus.run_sql(
  "SELECT * FROM customers", 
  [], 
  search_path: "reporting, public"
)

Stored Queries with search_path

You can save a search_path with your queries to make them automatically resolve against the correct schemas:

# Create a query that looks in reporting schema first, then public
{:ok, query} = Lotus.create_query(%{
  name: "Customer Report",
  query: %{
    sql: "SELECT COUNT(*) FROM customers WHERE active = true",
    params: []
  },
  search_path: "reporting, public",
  data_repo: "postgres"
})

# Execute - automatically uses the stored search_path
{:ok, result} = Lotus.run_query(query)
# Finds reporting.customers without needing to qualify the table name

Runtime search_path Override

You can override or provide a search_path at runtime:

# Override stored search_path
{:ok, result} = Lotus.run_query(query, search_path: "analytics, public")

# Provide search_path for ad-hoc queries
{:ok, result} = Lotus.run_sql(
  "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id",
  [],
  repo: "postgres",
  search_path: "reporting, public"
)

Multi-Schema Scenarios

Here are common patterns for using search_path:

Multi-Tenant with Schema-per-Tenant

# Query template that works across tenant schemas
{:ok, tenant_query} = Lotus.create_query(%{
  name: "Tenant User Count",
  query: %{sql: "SELECT COUNT(*) FROM users WHERE active = $1"},
  data_repo: "postgres"
})

# Execute for different tenants by overriding search_path
{:ok, tenant_a_result} = Lotus.run_query(tenant_query, [true], search_path: "tenant_123, public")
{:ok, tenant_b_result} = Lotus.run_query(tenant_query, [true], search_path: "tenant_456, public") 

Reporting and Analytics Schemas

# Create queries that work across different schema contexts
{:ok, report_query} = Lotus.create_query(%{
  name: "Monthly Revenue",
  query: %{
    sql: """
    SELECT 
      DATE_TRUNC('month', created_at) as month,
      SUM(amount) as revenue
    FROM orders 
    WHERE created_at >= $1 
    GROUP BY 1 
    ORDER BY 1
    """
  },
  search_path: "reporting, public",
  data_repo: "postgres"
})

# Use the same query structure for different contexts
{:ok, prod_data} = Lotus.run_query(report_query, [~D[2024-01-01]])
{:ok, staging_data} = Lotus.run_query(report_query, [~D[2024-01-01]], search_path: "staging, public")

Mixed Schema Access

# Query that needs tables from multiple schemas in search order
{:ok, complex_query} = Lotus.create_query(%{
  name: "User Activity Summary", 
  query: %{
    sql: """
    SELECT 
      u.name,
      COUNT(e.id) as event_count,
      MAX(s.last_login) as last_seen
    FROM users u
    LEFT JOIN events e ON u.id = e.user_id  -- from analytics schema
    LEFT JOIN sessions s ON u.id = s.user_id  -- from public schema
    GROUP BY u.id, u.name
    """
  },
  search_path: "public, analytics",  # users in public, events in analytics
  data_repo: "postgres"
})

search_path Validation

Lotus validates search_path values to prevent injection attacks:

# Valid search_path values
{:ok, query} = Lotus.create_query(%{
  name: "Valid Query",
  query: %{sql: "SELECT 1"},
  search_path: "reporting"  # single schema
})

{:ok, query} = Lotus.create_query(%{
  name: "Valid Query",  
  query: %{sql: "SELECT 1"},
  search_path: "schema1, schema_2, public"  # multiple schemas
})

# Invalid search_path - validation error
{:error, changeset} = Lotus.create_query(%{
  name: "Invalid Query",
  query: %{sql: "SELECT 1"}, 
  search_path: "invalid-name, 123schema"  # hyphens and leading numbers not allowed
})

errors_on(changeset)
# %{search_path: ["must be a comma-separated list of valid schema identifiers (letters, numbers, underscores only)"]}

search_path with Other Databases

For non-PostgreSQL databases, search_path is safely ignored:

# SQLite ignores search_path without error
{:ok, result} = Lotus.run_sql(
  "SELECT COUNT(*) FROM products",
  [],
  repo: "sqlite",
  search_path: "ignored_value"  # Has no effect but doesn't cause errors
)

Safety and Scoping

Lotus implements search_path safely:

  • Uses SET LOCAL search_path to scope changes to the current transaction only
  • Changes don't leak to other queries or database sessions
  • The same search_path is used for both preflight authorization and query execution
  • Schema identifiers are validated to prevent injection attacks

Working with Parameters

Lotus supports parameterized queries for safety and reusability:

# Create a parameterized query
{:ok, query} = Lotus.create_query(%{
  name: "Users by Status",
  query: %{
    sql: "SELECT id, name, email FROM users WHERE status = $1 AND created_at > $2",
    params: ["active", ~D[2024-01-01]]
  }
})

# Run with the stored parameters
{:ok, result} = Lotus.run_query(query)

Error Handling

Lotus provides clear error messages for common issues:

# Invalid SQL
{:error, reason} = Lotus.run_sql("SELCT * FROM users")  # typo in SELECT
IO.inspect(reason)
# "SQL syntax error: syntax error at or near \"SELCT\""

# Attempting destructive operation
{:error, reason} = Lotus.run_sql("DROP TABLE users")
IO.inspect(reason)
# "Only read-only queries are allowed"

# Query timeout
{:error, reason} = Lotus.run_sql(
  "SELECT pg_sleep(10)",
  [],
  timeout: 1000  # 1 second timeout
)
IO.inspect(reason)
# "SQL error: canceling statement due to user request"

# Table visibility restriction
{:error, reason} = Lotus.run_sql("SELECT * FROM schema_migrations")
IO.inspect(reason)
# "Query touches blocked table(s): schema_migrations"

Configuration Options

You can customize query execution with options:

# Set a custom timeout
{:ok, result} = Lotus.run_query(query, timeout: 30_000)

# Use a search_path for schema resolution
{:ok, result} = Lotus.run_query(query, search_path: "reporting, public")

# Combine multiple options
{:ok, result} = Lotus.run_query(query, [
  timeout: 30_000,
  search_path: "reporting, public",
  statement_timeout_ms: 25_000
])

Best Practices

1. Use Descriptive Names

# Good
Lotus.create_query(%{
  name: "Monthly Active Users Report",
  query: %{sql: "..."}
})

# Avoid
Lotus.create_query(%{
  name: "Query 1",
  query: %{sql: "..."}
})

2. Always Use Parameters for Dynamic Values

# Good - safe from SQL injection
Lotus.run_sql(
  "SELECT * FROM users WHERE status = $1",
  [user_status]
)

# Avoid - vulnerable to SQL injection
Lotus.run_sql("SELECT * FROM users WHERE status = '#{user_status}'")

3. Handle Errors Gracefully

case Lotus.run_query(query) do
  {:ok, result} ->
    process_results(result)

  {:error, reason} ->
    Logger.error("Query failed: #{inspect(reason)}")
    {:error, "Unable to generate report"}
end

Next Steps

Now that you understand the basics, explore: