Sequel::DuckDB
A Ruby database adapter that enables Sequel to work with DuckDB databases. This gem provides full integration between Sequel's powerful ORM and query building capabilities with DuckDB's high-performance analytical database engine.
Features
- Complete Sequel Integration: Full compatibility with Sequel's Database and Dataset APIs
- Connection Management: Support for both file-based and in-memory DuckDB databases
- Schema Introspection: Automatic discovery of tables, columns, indexes, and constraints
- SQL Generation: DuckDB-optimized SQL generation for all standard operations
- Data Type Mapping: Seamless conversion between Ruby and DuckDB data types
- Transaction Support: Full transaction handling with commit/rollback capabilities
- Error Handling: Comprehensive error mapping to appropriate Sequel exceptions
- Performance Optimized: Leverages DuckDB's columnar storage and parallel processing
Installation
Add this line to your application's Gemfile:
gem 'sequel-duckdb'And then execute:
bundle installOr install it yourself as:
gem install sequel-duckdbQuick Start
Basic Connection
require 'sequel'
# Connect to an in-memory database
db = Sequel.connect('duckdb::memory:')
# Connect to a file database
db = Sequel.connect('duckdb:///path/to/database.duckdb')
# Alternative connection syntax
db = Sequel.connect(
adapter: 'duckdb',
database: '/path/to/database.duckdb'
)Basic Usage
# Create a table
db.create_table :users do
primary_key :id
String :name, null: false
String :email
Integer :age
DateTime :created_at
end
# Insert data
users = db[:users]
users.insert(name: 'John Doe', email: 'john@example.com', age: 30, created_at: Time.now)
users.insert(name: 'Jane Smith', email: 'jane@example.com', age: 25, created_at: Time.now)
# Query data
puts users.where(age: 30).first
# => {:id=>1, :name=>"John Doe", :email=>"john@example.com", :age=>30, :created_at=>...}
puts users.where { age > 25 }.all
# => [{:id=>1, :name=>"John Doe", ...}, ...]
# Update data
users.where(name: 'John Doe').update(age: 31)
# Delete data
users.where(age: 25).deleteSchema Management
sequel-duckdb supports DuckDB's schema functionality for organizing database objects into logical namespaces.
Creating Schemas
# Create a basic schema
db.create_schema(:analytics)
# Create schema with IF NOT EXISTS
db.create_schema(:staging, if_not_exists: true)
# Create or replace schema
db.create_schema(:temp, or_replace: true)Dropping Schemas
# Drop an empty schema
db.drop_schema(:analytics)
# Drop schema with IF EXISTS
db.drop_schema(:staging, if_exists: true)
# Drop schema with all objects using CASCADE
db.drop_schema(:temp, cascade: true)Listing and Checking Schemas
# List all schemas
db.schemas # => [:main, :analytics, :staging]
# Check if schema exists
db.schema_exists?(:analytics) # => trueUsing Schemas with Tables
# Create table in custom schema
db.create_table(Sequel[:analytics][:sales]) do
primary_key :id
String :product
column :amount, "DECIMAL(10,2)"
Date :sale_date
end
# Query tables in custom schema
db.fetch("SELECT * FROM analytics.sales WHERE sale_date > ?", [Date.today - 30]).all
# List tables in specific schema
db.tables(schema: "analytics") # => [:sales, :metrics, ...]Schema Management Limitations
DuckDB has some limitations compared to other databases:
- No Schema Ownership: DuckDB doesn't support schema authorization or ownership
-
No Schema Renaming:
ALTER SCHEMA RENAMEis not supported - View Dependencies: Dependencies for views are not tracked by DuckDB
-
No Database DDL: DuckDB doesn't support
CREATE DATABASEorDROP DATABASEcommands. Instead, databases are created implicitly when you connect to a file path
For attaching additional database files, use raw SQL:
# Attach another database file
db.run("ATTACH 'other.duckdb' AS other")
# Query across attached databases
db.fetch("SELECT * FROM other.schema_name.table_name").all
# Detach database
db.run("DETACH other")Development
After checking out the repo, run bin/setup to install dependencies:
git clone https://github.com/aguynamedryan/sequel-duckdb.git
cd sequel-duckdb
bin/setupRunning Tests
The test suite uses Minitest and includes both unit tests (using Sequel's mock database) and integration tests (using real DuckDB databases):
# Run all tests
bundle exec rake test
# Run specific test file
ruby test/database_test.rb
# Run tests with verbose output
ruby test/all.rb -vDevelopment Console
You can run bin/console for an interactive prompt that will allow you to experiment:
bin/consoleThis will start an IRB session with the gem loaded and a test database available.
Code Quality
The project uses RuboCop for code style enforcement:
# Check code style
bundle exec rubocop
# Auto-fix style issues
bundle exec rubocop -aBuilding and Installing
To install this gem onto your local machine:
bundle exec rake installTo build the gem:
bundle exec rake buildRelease Process
To release a new version:
- Update the version number in
lib/sequel/duckdb/version.rb - Update
CHANGELOG.mdwith the new version details - Run the tests to ensure everything works
- Commit the changes
- Run
bundle exec rake release
This will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/aguynamedryan/sequel-duckdb.
Development Guidelines
- Follow TDD: Write tests before implementing features
- Code Style: Follow the existing RuboCop configuration
- Documentation: Update README and code documentation for new features
- Compatibility: Ensure compatibility with supported Ruby and Sequel versions
- Performance: Consider performance implications, especially for analytical workloads
Reporting Issues
When reporting issues, please include:
- Ruby version
- Sequel version
- DuckDB version
- Operating system
- Minimal code example that reproduces the issue
- Full error message and stack trace
License
The gem is available as open source under the terms of the MIT License.
Acknowledgments
- Jeremy Evans for creating and maintaining Sequel
- The DuckDB team for the excellent database engine and Ruby client
- Contributors to sequel-hexspace and other Sequel adapters for implementation patterns
Connection Options
Connection Strings
# In-memory database (data lost when connection closes)
db = Sequel.connect('duckdb::memory:')
# File database (persistent storage)
db = Sequel.connect('duckdb:///absolute/path/to/database.duckdb')
db = Sequel.connect('duckdb://relative/path/to/database.duckdb')
# With connection options
db = Sequel.connect('duckdb:///path/to/database.duckdb?readonly=true')readonly opens the file with DuckDB's access_mode set to READ_ONLY, so writes
raise Sequel::DatabaseError. All Sequel::Database objects on one file in one
process share one DuckDB instance, so the first one to open the file sets its
settings. Opening the same file again with other settings, such as read-write after
read-only, raises Sequel::DatabaseConnectionError.
Connection Hash
db = Sequel.connect(
adapter: 'duckdb',
database: '/path/to/database.duckdb',
# DuckDB-specific options
readonly: false,
config: {
threads: 4,
memory_limit: '1GB'
}
)Schema Operations
Table Management
# Create table with various column types
db.create_table :products do
primary_key :id
String :name, size: 255, null: false
Text :description
Decimal :price, size: [10, 2]
Integer :stock_quantity
Boolean :active, default: true
Date :release_date
DateTime :created_at
Time :daily_update_time
column :metadata, 'JSON' # DuckDB-specific type
end
# Add columns
db.alter_table :products do
add_column :category_id, Integer
add_index :category_id
end
# Drop table
db.drop_table :productsSchema Introspection
# List all tables
db.tables
# => [:users, :products, :orders]
# Get table schema
db.schema(:users)
# => [[:id, {:type=>:integer, :db_type=>"INTEGER", :primary_key=>true, ...}],
# [:name, {:type=>:string, :db_type=>"VARCHAR", :allow_null=>false, ...}], ...]
# Check if table exists
db.table_exists?(:users)
# => true
# Get indexes
db.indexes(:users)
# => {:users_name_index => {:columns=>[:name], :unique=>false, :primary=>false}}Data Types
Supported Type Mappings
| Ruby Type | DuckDB Type | Notes |
|---|---|---|
| String | VARCHAR/TEXT | Configurable size |
| Integer | INTEGER/BIGINT | Auto-sized based on value |
| Float | REAL/DOUBLE | Precision preserved |
| BigDecimal | DECIMAL/NUMERIC | Precision and scale supported |
| TrueClass/FalseClass | BOOLEAN | Native boolean support |
| Date | DATE | Date-only values |
| Time/DateTime | TIMESTAMP | Full datetime with timezone |
| Time (time-only) | TIME | Time-only values |
| String (binary) | BLOB | Binary data storage |
Type Conversion Examples
# Automatic type conversion
users.insert(
name: 'Alice', # String -> VARCHAR
age: 28, # Integer -> INTEGER
salary: BigDecimal('75000.50'), # BigDecimal -> DECIMAL
active: true, # Boolean -> BOOLEAN
birth_date: Date.new(1995, 5, 15), # Date -> DATE
created_at: Time.now, # Time -> TIMESTAMP
profile_data: '{"key": "value"}' # String -> JSON (if column defined as JSON)
)
# Retrieved data is automatically converted back to Ruby types
user = users.first
user[:birth_date].class # => Date
user[:created_at].class # => Time
user[:active].class # => TrueClassQuery Building
Basic Queries
users = db[:users]
# SELECT with conditions
users.where(active: true)
users.where { age > 25 }
users.where(Sequel.like(:name, 'John%'))
# Ordering and limiting
users.order(:name).limit(10)
users.order(Sequel.desc(:created_at)).first
# Aggregation
users.count
users.avg(:age)
users.group(:department).select(:department, Sequel.count(:id).as(:user_count))Advanced Queries
# JOINs
db[:users]
.join(:orders, user_id: :id)
.select(:users__name, :orders__total)
.where { orders__total > 100 }
# Subqueries
high_value_users = db[:orders]
.group(:user_id)
.having { sum(:total) > 1000 }
.select(:user_id)
db[:users].where(id: high_value_users)
# Window functions (DuckDB-specific optimization)
db[:sales]
.select(
:product_id,
:amount,
Sequel.function(:row_number).over(partition: :product_id, order: :amount).as(:rank)
)
# Common Table Expressions (CTEs)
db.with(:high_spenders,
db[:orders].group(:user_id).having { sum(:total) > 1000 }.select(:user_id)
).from(:high_spenders)
.join(:users, id: :user_id)
.select(:users__name)Transactions
Basic Transactions
db.transaction do
users.insert(name: 'Alice', email: 'alice@example.com')
orders.insert(user_id: users.max(:id), total: 100.00)
# Automatically commits if no exceptions
end
# Manual rollback
db.transaction do
users.insert(name: 'Bob', email: 'bob@example.com')
raise Sequel::Rollback if some_condition
# Transaction will be rolled back
endError Handling
begin
db.transaction do
# Some database operations
users.insert(name: nil) # This will fail due to NOT NULL constraint
end
rescue Sequel::NotNullConstraintViolation => e
puts "Cannot insert user with null name: #{e.message}"
rescue Sequel::DatabaseError => e
puts "Database error: #{e.message}"
endDuckDB-Specific Features
Analytical Queries
# DuckDB excels at analytical workloads
sales_summary = db[:sales]
.select(
:product_category,
Sequel.function(:sum, :amount).as(:total_sales),
Sequel.function(:avg, :amount).as(:avg_sale),
Sequel.function(:count, :id).as(:transaction_count)
)
.group(:product_category)
.order(Sequel.desc(:total_sales))
# Window functions for analytics
monthly_trends = db[:sales]
.select(
:month,
:amount,
Sequel.function(:lag, :amount, 1).over(order: :month).as(:prev_month),
Sequel.function(:sum, :amount).over(order: :month).as(:running_total)
)Performance Optimizations
# Bulk inserts (more efficient than individual inserts)
users_data = [
{name: 'User 1', email: 'user1@example.com'},
{name: 'User 2', email: 'user2@example.com'},
# ... many more records
]
db[:users].multi_insert(users_data)
# Use DuckDB's columnar storage advantages
# Query only needed columns for better performance
db[:large_table].select(:id, :name, :created_at).where(active: true)Exporting with COPY
Database#copy_to runs a DuckDB COPY that writes a dataset (or a SQL string) to a file.
FORMAT comes from the format: option, or else from the file extension. A path with
neither, such as a directory for a partitioned export, raises Sequel::Error.
db.copy_to(db[:users].where(active: true), "/data/users.parquet")
db.copy_to(db[:events], "/data/events", format: :parquet, partition_by: [:year, :month])Dataset#copy_to_sql returns the same COPY statement without running it, for example to
write a plan file:
db[:users].copy_to_sql("/data/users.csv", header: true, delimiter: "|")
# => COPY (SELECT * FROM "users") TO '/data/users.csv' (FORMAT CSV, HEADER, DELIMITER '|')Attaching database files
Database#attach runs a DuckDB ATTACH, and Database#detach runs DETACH DATABASE.
The catalog name as: is required. A name that DuckDB derives from the file name can
equal a schema name inside the file, and then schema.table is ambiguous. Other DuckDB
ATTACH options pass through, for example type: :sqlite.
db.attach("/data/dataset.duckdb", as: :dataset, read_only: true)
db[Sequel[:dataset][:people]].count
db.detach(:dataset, if_exists: true)
db.attach_sql("/data/dataset.duckdb", as: :dataset, read_only: true, if_not_exists: true)
# => ATTACH IF NOT EXISTS '/data/dataset.duckdb' AS "dataset" (READ_ONLY)An attached catalog belongs to the DuckDB database, which all pool connections of a
Sequel::Database share. It lasts until Database#disconnect closes the last
connection. After that, attach it again; if_not_exists: true makes a repeat safe.
search_path is different: DuckDB keeps it per connection. Set it on every new
connection with Sequel's connect_sqls option. Put the writable catalog first, because
CREATE TABLE without a schema goes to the first entry, and a read-only catalog there
makes it fail.
db = Sequel.connect("duckdb:///exports/42/work.duckdb",
connect_sqls: [ "SET search_path = 'work.main'" ])
db.attach("/data/dataset.duckdb", as: :dataset, read_only: true)
db.create_table(:summary) { Integer :n } # created in work.mainLists
Sequel writes a Ruby Array as a parenthesized list, such as (1, 2, 3), for IN
conditions. DuckDB does not read that as a list value. To build a DuckDB list from
expressions, call DuckDB's list_value function:
DB.literal([1, 2, 3])
# => (1, 2, 3)
DB.literal(Sequel.function(:list_value, :a, Sequel[:b] + 1))
# => list_value("a", ("b" + 1))IS DISTINCT FROM
The adapter declares that DuckDB supports IS DISTINCT FROM. Sequel's is_distinct_from
extension then writes it natively. Load the extension on the Database. With only
Sequel.extension :is_distinct_from, the builder exists, but the dataset cannot write it
and raises NoMethodError. Write IS NOT DISTINCT FROM by negating the expression with
~:
DB.extension :is_distinct_from
DB[:t].where(Sequel.is_distinct_from(:a, :b))
# => SELECT * FROM "t" WHERE ("a" IS DISTINCT FROM "b")
DB[:t].where(~Sequel.is_distinct_from(:a, :b))
# => SELECT * FROM "t" WHERE NOT ("a" IS DISTINCT FROM "b")duckdb_ops extension
The duckdb_ops extension adds DuckDB-specific expression builders to Sequel. It is
opt-in. If you do not load it, Sequel generates the same SQL as before. Load it with
either form. Both forms make the builders available globally.
Sequel.extension :duckdb_ops
# or
DB.extension :duckdb_opsSequel.try_cast(expr, type) builds a DuckDB TRY_CAST. It returns NULL when the cast
fails, where CAST raises an error. The type works as in Sequel.cast: a Ruby class
maps to the adapter's type name, and a Symbol or String passes through unchanged.
DB[:raw].select(Sequel.try_cast(:amount, :DOUBLE).as(:amount))
# => SELECT TRY_CAST("amount" AS DOUBLE) AS "amount" FROM "raw"
Sequel.try_cast(:amount, Float)
# => TRY_CAST("amount" AS double precision)DuckDB table functions take named parameters. Sequel.duckdb_named(**params) builds them
for the last argument of Sequel.function. Sequel.duckdb_read_parquet,
Sequel.duckdb_read_csv and Sequel.duckdb_read_json take a path or an array of paths,
plus named parameters. An Array value becomes a DuckDB list. A Hash value becomes a
struct, in which a Symbol is a bare type name and a String is a string literal.
parts = Sequel.duckdb_read_parquet("/data/events/**/*.parquet",
hive_partitioning: true, hive_types: { year: :BIGINT })
DB.from(parts.as(:events)).where(year: 2024)
# => SELECT * FROM read_parquet(['/data/events/**/*.parquet'], hive_partitioning = TRUE,
# hive_types = {'year': BIGINT}) AS "events" WHERE ("year" = 2024)
Sequel.function(:read_json, "/data/a.json", Sequel.duckdb_named(columns: { a: "INTEGER" }))
# => read_json('/data/a.json', columns = {'a': 'INTEGER'})Lambda expressions
Sequel.duckdb_lambda builds a DuckDB lambda expression for list functions such as
list_transform, list_filter and list_reduce. Call those functions through
Sequel.function. The builder has two forms. In the block form, every argument is a
parameter name, and the block gets one identifier for each parameter. In the body form,
the first argument is a parameter name or an Array of names, and the second argument is
the body. The body names the parameters as Symbols or with Sequel[:x].
Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x }
Sequel.duckdb_lambda([:acc, :x], Sequel[:acc] + :x)
# => lambda "acc", "x": ("acc" + "x")
Sequel.function(:list_reduce, :amounts,
Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x },
Sequel.cast(0, :BIGINT))
# => list_reduce("amounts", lambda "acc", "x": ("acc" + "x"), CAST(0 AS BIGINT))DuckDB has no let. To compute a value once and use it more than once, wrap it in a
one-element list, transform it, and take element 1 with Sequel.subscript:
square_plus = Sequel.duckdb_lambda(:v) { |v| (v * v) + v }
Sequel.subscript(Sequel.function(:list_transform, Sequel.function(:list_value, :n), square_plus), 1)
# => (list_transform(list_value("n"), lambda "v": (("v" * "v") + "v")))[1]The builder emits DuckDB's lambda keyword syntax, never the x -> body arrow, which
DuckDB deprecated. It needs a DuckDB version that accepts the lambda keyword. Parameter
names are quoted identifiers, the same as the references to them in the body.
Model Integration
Sequel::Model Usage
class User < Sequel::Model
# Sequel automatically introspects the users table schema
# Associations work normally
one_to_many :orders
# Validations
def validate
super
errors.add(:email, 'must be present') if !email || email.empty?
errors.add(:email, 'must be valid') unless email =~ /@/
end
# Custom methods
def full_name
"#{first_name} #{last_name}"
end
end
class Order < Sequel::Model
many_to_one :user
def total_with_tax(tax_rate = 0.08)
total * (1 + tax_rate)
end
end
# Usage
user = User.create(name: 'John Doe', email: 'john@example.com')
order = user.add_order(total: 99.99, status: 'pending')
# Associations work seamlessly
user.orders.where(status: 'completed').sum(:total)Error Handling
The adapter maps DuckDB errors to appropriate Sequel exception types:
begin
# Various operations that might fail
db[:users].insert(name: nil) # NOT NULL violation
rescue Sequel::NotNullConstraintViolation => e
# Handle null constraint violation
rescue Sequel::UniqueConstraintViolation => e
# Handle unique constraint violation
rescue Sequel::ForeignKeyConstraintViolation => e
# Handle foreign key violation
rescue Sequel::CheckConstraintViolation => e
# Handle check constraint violation
rescue Sequel::DatabaseConnectionError => e
# Handle connection issues
rescue Sequel::DatabaseError => e
# Handle other database errors
endTroubleshooting
Common Issues
Connection Problems
# Issue: Cannot connect to database file
# Solution: Check file path and permissions
begin
db = Sequel.connect('duckdb:///path/to/database.duckdb')
rescue Sequel::DatabaseConnectionError => e
puts "Connection failed: #{e.message}"
# Check if directory exists and is writable
# Ensure DuckDB gem is properly installed
endMemory Issues
# Issue: Out of memory with large datasets
# Solution: Use streaming or limit result sets
db[:large_table].limit(1000).each do |row|
# Process row by row instead of loading all at once
end
# Or use DuckDB's memory configuration
db = Sequel.connect(
adapter: 'duckdb',
database: ':memory:',
config: { memory_limit: '2GB' }
)Performance Issues
# Issue: Slow queries
# Solution: Add appropriate indexes
db.add_index :users, :email
db.add_index :orders, [:user_id, :created_at]
# Use EXPLAIN to analyze query plans
puts db[:users].where(email: 'john@example.com').explainDebugging
# Enable SQL logging to see generated queries
require 'logger'
db.loggers << Logger.new($stdout)
# This will now log all SQL queries
db[:users].where(active: true).all
# Logs: SELECT * FROM users WHERE (active = true)Version Compatibility
- Ruby: 3.3 or higher (CI tests 3.3, 3.4 and 4.0)
- Sequel: 5.0 or higher
- DuckDB C library: 1.5.x and 2.x. Support for 1.5.x ends when DuckDB 1.5.x reaches end of life in March 2027.
-
ruby-duckdb (the
duckdbgem): 1.5.x and 2.x. sequel-duckdb does not depend on it, so addgem "duckdb"to your own Gemfile. ruby-duckdb 2.x builds against library 1.4 or newer, so it works with library 1.5.x.
DuckDB 2.x aborts an open transaction after any failed statement, including a query
on a missing table; 1.5.x does not. table_exists? and schema keep the transaction
on both versions.
Getting Help
- Documentation: Sequel Documentation
- DuckDB Docs: DuckDB Documentation
- Issues: Report bugs on GitHub Issues
- Discussions: Join discussions on GitHub Discussions
Performance Tips
Query Optimization
-
Select only needed columns: DuckDB's columnar storage makes this very efficient
# Good db[:users].select(:id, :name).where(active: true) # Less efficient db[:users].where(active: true) # Selects all columns
-
Use appropriate indexes: Especially for frequently queried columns
db.add_index :users, :email db.add_index :orders, [:user_id, :status]
-
Leverage DuckDB's analytical capabilities: Use window functions and aggregations
# Efficient analytical query db[:sales] .select( :product_id, Sequel.function(:sum, :amount).as(:total), Sequel.function(:rank).over(order: Sequel.desc(:amount)).as(:rank) ) .group(:product_id)
Memory Management
-
Use streaming for large result sets:
db[:large_table].paged_each(rows_per_fetch: 1000) do |row| # Process row by row end
-
Configure DuckDB memory limits:
db = Sequel.connect( adapter: 'duckdb', database: '/path/to/db.duckdb', config: { memory_limit: '4GB', threads: 8 } )
Bulk Operations
-
Use multi_insert for bulk data loading:
# Efficient bulk insert data = 1000.times.map { |i| {name: "User #{i}", email: "user#{i}@example.com"} } db[:users].multi_insert(data)
-
Use transactions for multiple operations:
db.transaction do # Multiple related operations user_id = db[:users].insert(name: 'John', email: 'john@example.com') db[:profiles].insert(user_id: user_id, bio: 'Software developer') db[:preferences].insert(user_id: user_id, theme: 'dark') end
Documentation
Complete API Reference
For comprehensive API documentation including all methods, configuration options, and advanced features, see:
- API_DOCUMENTATION.md - Complete API reference with examples
- docs/DUCKDB_SQL_PATTERNS.md - Detailed SQL generation patterns and syntax
SQL Generation Patterns
The sequel-duckdb adapter generates SQL optimized for DuckDB while maintaining Sequel compatibility. Key patterns include:
- LIKE clauses: Clean syntax without ESCAPE clauses
- ILIKE support: Converted to UPPER() LIKE UPPER() for case-insensitive matching
- Regular expressions: Using DuckDB's regexp_matches() function
- Qualified columns: Standard dot notation (table.column)
- Recursive CTEs: Automatic WITH RECURSIVE detection
- Proper parentheses: Consistent expression grouping
Example SQL patterns:
# LIKE patterns
dataset.where(Sequel.like(:name, "%John%"))
# SQL: SELECT * FROM users WHERE (name LIKE '%John%')
# ILIKE patterns (case-insensitive)
dataset.where(Sequel.ilike(:name, "%john%"))
# SQL: SELECT * FROM users WHERE (UPPER(name) LIKE UPPER('%john%'))
# Regular expressions
dataset.where(name: /^John/)
# SQL: SELECT * FROM users WHERE (regexp_matches(name, '^John'))For complete SQL pattern documentation, see docs/DUCKDB_SQL_PATTERNS.md.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/aguynamedryan/sequel-duckdb.
License
The gem is available as open source under the terms of the MIT License.