Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ Gemfile.lock
/spec/dummy_rails/tmp
/spec/dummy_rails/db/*.sqlite3
/spec/dummy_rails/db/*.sqlite3-*
vendor/
.bundle/
4 changes: 2 additions & 2 deletions lib/tiny_admin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def configure(&block)
block&.call(settings) || settings
end

def configure_from_file(file)
settings.reset!
def configure_from_file(file, reset: true)
settings.reset! if reset
config = YAML.load_file(file, symbolize_names: true)
config.each do |key, value|
settings[key] = value
Expand Down
76 changes: 76 additions & 0 deletions lib/tiny_admin/actions/csv_export.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

require "csv"

module TinyAdmin
module Actions
# CsvExport is a collection action that streams all matching records as a
# CSV file attachment. It honours the same field/attribute config as the
# Index action and applies any active filters, but skips pagination so the
# full dataset is returned.
#
# Register it as a collection action in your resource section:
#
# collection_actions:
# - csv_export: TinyAdmin::Actions::CsvExport
#
# The action respects the resource's +index.attributes+ config for the
# columns to export. If no attributes are configured, all repository fields
# are exported.
#
# Use the +max_export_limit+ option to cap the number of rows returned (default: 10_000).
# Set it to nil to disable the cap (not recommended for large datasets).
class CsvExport < BasicAction
DEFAULT_MAX_EXPORT_LIMIT = 10_000

def call(app:, context:, options:)
repository = context.repository
fields_options = attribute_options(options[:attributes])
fields = repository.fields(options: fields_options)
filters = prepare_filters(fields, context.request.params, options)
records = fetch_all_records(repository, filters, options)

csv_content = build_csv(records, fields, repository, fields_options)
set_csv_response_headers(app, context.slug)
app.render(inline: csv_content)
end

private

def fetch_all_records(repository, filters, options)
limit = options.key?(:max_export_limit) ? options[:max_export_limit] : DEFAULT_MAX_EXPORT_LIMIT
# When limit is nil, fetch all records (use with caution on large datasets).
effective_limit = limit || repository.list(page: 1, limit: 1).last
records, = repository.list(page: 1, limit: effective_limit, filters: filters, sort: options[:sort])
records
end

def set_csv_response_headers(app, slug)
filename = "#{slug}-#{Time.now.strftime('%Y%m%d%H%M%S')}.csv"
app.response["Content-Type"] = "text/csv; charset=utf-8"
app.response["Content-Disposition"] = "attachment; filename=\"#{filename}\""
end

def prepare_filters(fields, params, options)
filter_config = (options[:filters] || []).map { _1.is_a?(Hash) ? _1 : { field: _1 } }
filter_map = filter_config.to_h { |f| [f[:field], f] }
values = params["q"] || {}
fields.each_with_object({}) do |(name, field), result|
result[field] = { value: values[name], filter: filter_map[name] } if filter_map.key?(name)
end
end

def build_csv(records, fields, repository, fields_options)
CSV.generate(headers: true) do |csv|
csv << fields.values.map { |f| f.options[:header] || f.title }

records.each do |record|
attrs = repository.index_record_attrs(record, fields: fields_options)
row = fields.keys.map { |key| attrs[key]&.to_s }
csv << row
end
end
end
end
end
end
25 changes: 23 additions & 2 deletions lib/tiny_admin/actions/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@ def call(app:, context:, options:)
evaluate_options(options)
fields = repository.fields(options: fields_options)
filters = prepare_filters(fields)
records, count = repository.list(page: current_page, limit: pagination, filters: filters, sort: options[:sort])
sort = merge_sort(options[:sort], fields)
records, count = repository.list(page: current_page, limit: pagination, filters: filters, sort: sort)
attributes = {
actions: context.actions,
fields: fields,
filters: filters,
links: options[:links],
prepare_record: ->(record) { repository.index_record_attrs(record, fields: fields_options) },
records: records,
show_link: options.fetch(:show_link, true),
slug: context.slug,
sort_params: @sort_params,
title: repository.index_title,
widgets: options[:widgets]
}
Expand All @@ -46,7 +49,25 @@ def evaluate_options(options)
@repository = context.repository
@pagination = options[:pagination] || 10
@current_page = (params["p"] || 1).to_i
@query_string = params_to_s(params.except("p"))
@query_string = params_to_s(params.except("p", "sort"))
@sort_params = params["sort"]
end

# Merge user-supplied sort params (from query string) with the configured
# sort defaults. Only fields that are actually returned by the repository
# are accepted to prevent arbitrary column injection.
def merge_sort(configured_sort, fields)
raw = params["sort"]
return configured_sort unless raw.is_a?(Hash)

allowed = fields.keys.map(&:to_s)
dynamic = raw.each_with_object([]) do |(field, dir), list|
next unless allowed.include?(field.to_s)

direction = dir.to_s.downcase == "desc" ? "DESC" : "ASC"
list << "#{field} #{direction}"
end
dynamic.any? ? dynamic : configured_sort
end

def prepare_filters(fields)
Expand Down
3 changes: 3 additions & 0 deletions lib/tiny_admin/basic_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ def authentication_plugin
plugin :render, engine: "html"
plugin :sessions, secret: ENV.fetch("TINY_ADMIN_SECRET") { SecureRandom.hex(64) }

# NOTE: The authentication plugin is applied at class-load time. Ensure
# TinyAdmin.configure / TinyAdmin.configure_from_file are called before
# BasicApp (or its subclass Router) is first referenced.
plugin authentication_plugin, TinyAdmin.settings.authentication

not_found { prepare_page(TinyAdmin.settings.page_not_found).call }
Expand Down
38 changes: 31 additions & 7 deletions lib/tiny_admin/plugins/active_record_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ def collection
def list(page: 1, limit: 10, sort: nil, filters: nil)
query = sort ? collection.order(sort) : collection
query = apply_filters(query, filters) if filters
page_offset = page.positive? ? (page - 1) * limit : 0
records = query.offset(page_offset).limit(limit).to_a
records = query.offset(page_offset(page, limit)).limit(limit).to_a
[records, query.count]
end

Expand All @@ -57,16 +56,41 @@ def apply_filters(query, filters)
next if value.nil? || value == ""

query =
case field.type
when :string
value = ActiveRecord::Base.sanitize_sql_like(value.strip)
query.where("#{field.name} LIKE ?", "%#{value}%")
if value.is_a?(Hash)
apply_hash_filter(query, field, value)
elsif value.is_a?(Array)
non_empty = value.reject { |v| v.to_s.empty? }
next if non_empty.empty?

query.where(field.name => non_empty)
else
query.where(field.name => value)
apply_scalar_filter(query, field, value)
end
end
query
end

private

# Handle range filters: { "gte" => min, "lte" => max }
def apply_hash_filter(query, field, value)
gte = value["gte"] || value[:gte]
lte = value["lte"] || value[:lte]
query = query.where("#{field.name} >= ?", gte) if gte.present?
query = query.where("#{field.name} <= ?", lte) if lte.present?
query
end

# Handle scalar (single-value) filters.
def apply_scalar_filter(query, field, value)
case field.type
when :string
sanitized = ActiveRecord::Base.sanitize_sql_like(value.strip)
query.where("#{field.name} LIKE ?", "%#{sanitized}%")
else
query.where(field.name => value)
end
end
end
end
end
45 changes: 45 additions & 0 deletions lib/tiny_admin/plugins/base_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@

module TinyAdmin
module Plugins
# BaseRepository is the contract that every repository plugin must satisfy.
#
# Required methods (must be overridden in subclasses):
#
# fields(options: nil) -> Hash<String, TinyAdmin::Field>
# Return a hash mapping field name strings to TinyAdmin::Field instances.
# When +options+ is provided it should be a hash of field_name => config
# and only those fields should be returned.
#
# index_record_attrs(record, fields: nil) -> Hash<String, Object>
# Return a hash of attribute values for the given record suitable for
# display in the index (collection) view. When +fields+ is nil, return
# all attributes; otherwise only the specified fields.
#
# show_record_attrs(record, fields: nil) -> Hash<String, Object>
# Same as index_record_attrs but used in the detail (show) view.
#
# index_title -> String
# Return the human-readable title for the collection page.
#
# show_title(record) -> String
# Return the human-readable title for the detail page of the given record.
#
# find(reference) -> Object
# Find and return the record identified by +reference+ (usually a primary
# key string from the URL). Raise BaseRepository::RecordNotFound when no
# record is found.
#
# collection -> Enumerable
# Return a "base scope" representing all records of the resource.
#
# list(page: 1, limit: 10, sort: nil, filters: nil) -> [Array, Integer]
# Return a two-element array: the page of records and the total count.
# +sort+ may be nil, a String/Array accepted by the underlying ORM, or any
# structure the concrete repository understands.
# +filters+ is a Hash<TinyAdmin::Field, { value: Object, filter: Hash }>
# as built by Actions::Index#prepare_filters.
#
class BaseRepository
class RecordNotFound < StandardError
end
Expand All @@ -11,6 +49,13 @@ class RecordNotFound < StandardError
def initialize(model)
@model = model
end

protected

# Shared helper: compute the zero-based offset for a given page / limit.
def page_offset(page, limit)
page.positive? ? (page - 1) * limit : 0
end
end
end
end
119 changes: 119 additions & 0 deletions lib/tiny_admin/plugins/sequel_repository.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# frozen_string_literal: true

module TinyAdmin
module Plugins
# SequelRepository implements the BaseRepository contract for Sequel datasets.
#
# Usage in config:
#
# sections:
# - slug: posts
# name: Posts
# type: resource
# model: Post # a Sequel::Model subclass
# repository: TinyAdmin::Plugins::SequelRepository
#
# Requires the +sequel+ gem.
class SequelRepository < BaseRepository
def index_record_attrs(record, fields: nil)
return record.values.transform_values(&:to_s) unless fields

fields.to_h { [_1, record.send(_1)] }
end

def index_title
title = model.to_s
title.respond_to?(:pluralize) ? title.pluralize : title
end

# Build Field objects from the model's schema.
def fields(options: nil)
schema_types = model.db_schema.to_h { |col, info| [col.to_s, sequel_type_to_sym(info[:type])] }

if options
options.to_h do |name, field_options|
[name, TinyAdmin::Field.create_field(name: name, type: schema_types[name.to_s], options: field_options)]
end
else
schema_types.to_h do |name, type|
[name, TinyAdmin::Field.create_field(name: name, type: type)]
end
end
end

alias show_record_attrs index_record_attrs

def show_title(record)
"#{model} ##{record.pk}"
end

def find(reference)
model[reference] || raise(BaseRepository::RecordNotFound, "#{model} with pk=#{reference} not found")
end

def collection
model.dataset
end

def list(page: 1, limit: 10, sort: nil, filters: nil)
query = sort ? collection.order(*Array(sort).map { Sequel.lit(_1) }) : collection
query = apply_filters(query, filters) if filters
records = query.offset(page_offset(page, limit)).limit(limit).all
[records, query.count]
end

def apply_filters(query, filters)
filters.reduce(query) do |q, (field, filter)|
apply_single_filter(q, field, filter)
end
end

private

def apply_single_filter(query, field, filter)
value = filter&.dig(:value)
return query if value.nil? || value == ""

if value.is_a?(Hash)
apply_hash_filter(query, field, value)
elsif value.is_a?(Array)
non_empty = value.reject { |v| v.to_s.empty? }
non_empty.any? ? query.where(Sequel[field.name.to_sym] => non_empty) : query
else
apply_scalar_filter(query, field, value)
end
end

# Map Sequel schema type symbols to the TinyAdmin field type convention.
def sequel_type_to_sym(type)
case type
when :integer, :bigint, :smallint then :integer
when :boolean then :boolean
when :date then :date
when :datetime, :timestamp then :datetime
when :float, :decimal, :numeric then :float
else :string
end
end

def apply_hash_filter(query, field, value)
gte = value["gte"] || value[:gte]
lte = value["lte"] || value[:lte]
col = Sequel[field.name.to_sym]
query = query.where(col >= gte) if gte && gte != ""
query = query.where(col <= lte) if lte && lte != ""
query
end

def apply_scalar_filter(query, field, value)
col = Sequel[field.name.to_sym]
case field.type
when :string
query.where(Sequel.ilike(col, "%#{value.strip}%"))
else
query.where(col => value)
end
end
end
end
end
Loading