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",
#   statement: "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.Result{
#   columns: ["user_count"],
#   rows: [[42]],
#   num_rows: 1
# }

Accessing Results

The Result 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 Result 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

Lotus supports PostgreSQL, MySQL, and SQLite databases. 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.MySQLRepo
)

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

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",
  statement: "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",
  statement: "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")

Default Repository Behavior

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

# Configuration with default_repo
config :lotus,
  default_repo: "main",
  data_repos: %{
    "main" => MyApp.Repo,
    "analytics" => MyApp.AnalyticsRepo
  }

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

# Will use the "main" repository (from default_repo config)
{: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",
  statement: "SELECT COUNT(*) FROM users WHERE active = {{is_active}}",
  variables: [
    %{name: "is_active", type: :text, label: "Is Active", default: "true"}
  ],
  data_repo: "postgres"
})

# Execute for different tenants by overriding search_path
{:ok, tenant_a_result} = Lotus.run_query(tenant_query, search_path: "tenant_123, public")
{:ok, tenant_b_result} = Lotus.run_query(tenant_query, 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",
  statement: "SELECT 1",
  search_path: "reporting"  # single schema
})

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

# Invalid search_path - validation error
{:error, changeset} = Lotus.create_query(%{
  name: "Invalid Query",
  statement: "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 identifiers"]}

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 Smart Variables

Lotus supports smart variable substitution using {{var}} placeholders for safety and reusability:

# Create a query with smart variables
{:ok, query} = Lotus.create_query(%{
  name: "Users by Status",
  statement: "SELECT id, name, email FROM users WHERE status = {{status}} AND created_at > {{created_date}}",
  variables: [
    %{name: "status", type: :text, label: "User Status", default: "active"},
    %{name: "created_date", type: :date, label: "Created After", default: "2024-01-01"}
  ]
})

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

# Override variables at runtime
{:ok, result} = Lotus.run_query(query, vars: %{
  "status" => "pending",
  "created_date" => "2024-06-01"
})

Variable Types and Widgets

Variables can be configured with different types and UI widgets to create better user interfaces:

# Example with different variable types and widgets
attrs = %{
  name: "Active Users",
  statement: "SELECT * FROM users WHERE org_id = {{org_id}} AND created_at >= {{since}} AND status = {{status}}",
  variables: [
    # Number input with default
    %{name: "org_id", type: :number, label: "Organization ID", default: "1"},
    
    # Date input
    %{name: "since", type: :date, label: "Created Since"},
    
    # Static dropdown with predefined options
    %{
      name: "status", 
      type: :text, 
      widget: :select, 
      label: "Status",
      static_options: ["active", "inactive", "pending"]
    }
  ]
}

q = Lotus.Storage.Query.new(attrs) |> Repo.insert!()

# Use to_sql_params for parameterized queries
Lotus.Storage.Query.to_sql_params(q, %{"since" => "2024-01-01"})
# => {"SELECT * FROM users WHERE org_id = $1 AND created_at >= $2 AND status = $3", [1, ~D[2024-01-01], "active"]}

Dynamic Dropdown Options

For select widgets, you can populate options dynamically using options_query:

# Dynamic dropdown populated from database
%{
  name: "org_id",
  type: :number,
  widget: :select,
  label: "Organization",
  options_query: "SELECT id, name FROM orgs ORDER BY name"
}

The options_query should return two columns:

  • First column: the value to be used in the query
  • Second column: the label to display to users

Variable Features

  • Safe substitution: Variables are converted to database-specific placeholders with automatic type casting ($1::integer for PostgreSQL, CAST(? AS SIGNED) for MySQL, ? for SQLite)
  • Structured variables: Define variables with type, label, and default values for better UI integration
  • Type support: Supports text, number, integer, date, datetime, time, boolean, and json types with automatic database casting
  • Widget controls: Specify input or select widgets for UI rendering
  • Static options: Use static_options for predefined dropdown choices
  • Dynamic options: Use options_query to populate dropdowns from database queries
  • Default values: Provide fallback values in variable definitions
  • Runtime override: Pass vars: option to override defaults
  • Multiple occurrences: The same variable can appear multiple times and will be bound correctly
  • Type safety: Variables are passed as parameters, preventing SQL injection

Variable Type Casting

Lotus automatically generates type-specific SQL placeholders based on your variable types, ensuring proper data handling across different databases:

PostgreSQL Type Casting

  • :integer$1::integer
  • :number$1::numeric
  • :date$1::date
  • :datetime$1::timestamp
  • :time$1::time
  • :boolean$1::boolean
  • :json$1::jsonb
  • :text (default) → $1

MySQL Type Casting

  • :integerCAST(? AS SIGNED)
  • :numberCAST(? AS DECIMAL)
  • :dateCAST(? AS DATE)
  • :datetimeCAST(? AS DATETIME)
  • :timeCAST(? AS TIME)
  • :booleanCAST(? AS UNSIGNED)
  • :jsonCAST(? AS JSON)
  • :text (default) → ?

SQLite

SQLite uses untyped ? placeholders for all variable types, as it handles type conversion automatically.

This type casting ensures that your data is properly handled by the database engine and can prevent runtime type errors.

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

Using Lotus Web

If you prefer a visual interface or need to provide query access to non-technical users, consider setting up Lotus Web. It provides a beautiful web interface that mounts directly in your Phoenix application:

# In your router
import Lotus.Web.Router

scope "/", MyAppWeb do
  pipe_through [:browser, :require_authenticated_user]
  
  lotus_dashboard "/lotus"
end

With Lotus Web, you get:

  • A SQL editor with syntax highlighting
  • Visual query management and organization
  • Interactive schema exploration
  • Real-time result visualization
  • All without leaving your application

See the installation guide for detailed setup instructions.

Next Steps

Now that you understand the basics, explore: