diff --git a/app/assets/images/sparql/schema.png b/app/assets/images/sparql/schema.png
new file mode 100644
index 0000000000..5638fcdb6b
Binary files /dev/null and b/app/assets/images/sparql/schema.png differ
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 0d927c5214..6a06a2df14 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -71,3 +71,4 @@
//= require institution-ror-typeahead
//= require fair_data_station
//= require DataTables-2.3.1/datatables.min
+//= require sparql
diff --git a/app/assets/javascripts/sparql.js b/app/assets/javascripts/sparql.js
new file mode 100644
index 0000000000..efc9759767
--- /dev/null
+++ b/app/assets/javascripts/sparql.js
@@ -0,0 +1,151 @@
+document.addEventListener('DOMContentLoaded', function() {
+ // Get DOM elements
+ const clearBtn = document.getElementById('clear-query');
+ const queryTextarea = document.getElementById('sparql_query');
+
+ if (clearBtn && queryTextarea) {
+ clearBtn.addEventListener('click', function() {
+ queryTextarea.value = '';
+ queryTextarea.focus();
+ });
+ }
+
+
+ // Use example query buttons
+ const useQueryButtons = document.querySelectorAll('.use-query');
+ useQueryButtons.forEach(button => {
+ button.addEventListener('click', function() {
+ const queryText = this.dataset.query;
+ if (queryTextarea) {
+ queryTextarea.value = queryText;
+ queryTextarea.focus();
+
+ // Scroll to the query form
+ queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+ });
+
+ // Auto-resize textarea
+ if (queryTextarea) {
+ function autoResize() {
+ queryTextarea.style.height = 'auto';
+ queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';
+ }
+
+ queryTextarea.addEventListener('input', autoResize);
+ autoResize(); // Initial resize
+ }
+
+ // Handle clicks on URI links to execute DESCRIBE queries
+ document.addEventListener('click', function(e) {
+ if (e.target.classList.contains('external-link')) {
+ e.preventDefault(); // Prevent default link behavior
+
+ const uri = e.target.href;
+ const describeQuery = `DESCRIBE <${uri}>`;
+
+ if (queryTextarea) {
+ queryTextarea.value = describeQuery;
+ queryTextarea.focus();
+
+ // Auto-resize after setting the query
+ queryTextarea.style.height = 'auto';
+ queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';
+
+ // Scroll to the query form
+ queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+ // Auto-execute the DESCRIBE query
+ const form = queryTextarea.closest('form');
+ if (form) {
+ form.submit();
+ }
+ }
+ }
+ });
+
+ // Handle example query dropdown
+ const exampleQuerySelect = document.getElementById('example-queries');
+
+ if (exampleQuerySelect && queryTextarea) {
+ exampleQuerySelect.addEventListener('change', function() {
+ const selectedOption = this.options[this.selectedIndex];
+
+ if (selectedOption.value) {
+ const rawQuery = selectedOption.dataset.query;
+
+ // Decode HTML entities
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = rawQuery;
+ const query = tempDiv.textContent || tempDiv.innerText || '';
+
+ // Set the decoded query in the textarea
+ queryTextarea.value = query;
+ queryTextarea.focus();
+
+ // Auto-resize after setting the query
+ queryTextarea.style.height = 'auto';
+ queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';
+
+ // Reset dropdown to default
+ this.selectedIndex = 0;
+
+ // Scroll to the query form
+ queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+ }
+
+ // Example query filter functionality
+ const queryFilter = document.getElementById('query-filter');
+ const queriesContainer = document.getElementById('example-queries-container');
+ const noQueriesMessage = document.getElementById('no-queries-message');
+
+ if (queryFilter && queriesContainer) {
+ queryFilter.addEventListener('input', function() {
+ const filterText = this.value.toLowerCase().trim();
+ const exampleQueries = queriesContainer.querySelectorAll('.example-query');
+ let visibleCount = 0;
+
+ exampleQueries.forEach(function(query) {
+ const title = query.querySelector('.query-title strong');
+ const description = query.querySelector('.text-muted');
+ const queryCode = query.querySelector('.query-code');
+
+ const titleText = title ? title.textContent.toLowerCase() : '';
+ const descriptionText = description ? description.textContent.toLowerCase() : '';
+ const codeText = queryCode ? queryCode.textContent.toLowerCase() : '';
+
+ const matchesFilter = filterText === '' ||
+ titleText.includes(filterText) ||
+ descriptionText.includes(filterText) ||
+ codeText.includes(filterText);
+
+ if (matchesFilter) {
+ query.parentElement.style.display = '';
+ visibleCount++;
+ } else {
+ query.parentElement.style.display = 'none';
+ }
+ });
+
+ // Show/hide "no results" message
+ if (noQueriesMessage) {
+ if (visibleCount === 0 && filterText !== '') {
+ noQueriesMessage.style.display = 'block';
+ } else {
+ noQueriesMessage.style.display = 'none';
+ }
+ }
+ });
+
+ // Clear filter when escape key is pressed
+ queryFilter.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') {
+ this.value = '';
+ this.dispatchEvent(new Event('input'));
+ }
+ });
+ }
+});
\ No newline at end of file
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 7138293595..20beee2ff2 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -42,4 +42,5 @@
*= require linked_extended_metadata
*= require ror-widget
*= require DataTables-2.3.1/datatables.min
+*= require sparql
*/
diff --git a/app/assets/stylesheets/sparql.scss b/app/assets/stylesheets/sparql.scss
new file mode 100644
index 0000000000..8e0e08737d
--- /dev/null
+++ b/app/assets/stylesheets/sparql.scss
@@ -0,0 +1,122 @@
+.sparql-interface {
+ .sparql-textarea {
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ background-color: #f8f9fa;
+ border: 2px solid #dee2e6;
+
+ &:focus {
+ border-color: #007bff;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+ }
+ }
+
+ .sparql-results {
+ margin-top: 2rem;
+
+ .table-responsive {
+ max-width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch; // Smooth scrolling on mobile
+ }
+
+ table {
+ font-size: 12px;
+ min-width: 100%;
+ white-space: nowrap; // Prevent cell content from wrapping
+
+ th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+ min-width: 150px; // Minimum column width
+ }
+
+ td {
+ min-width: 150px; // Minimum column width
+ }
+
+ .external-link {
+ word-break: break-all;
+ max-width: 300px; // Limit URI column width
+ display: inline-block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .code-block {
+ max-height: 500px;
+ overflow-y: auto;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+
+ pre {
+ background: #f8f9fa;
+ border: none;
+ border-radius: 0;
+ margin: 0;
+ padding: 1rem;
+ font-size: 11px;
+ white-space: pre-wrap;
+ }
+ }
+ }
+
+ .sparql-examples {
+ margin-top: 2rem;
+
+ .example-query-filter {
+ margin-bottom: 1.5rem;
+
+ .input-group {
+ max-width: 400px;
+ }
+ }
+
+ .example-query {
+ background: #f8f9fa;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+
+ .query-title {
+ margin-bottom: 0.5rem;
+
+ .label {
+ margin-left: 0.5rem;
+ }
+ }
+
+ .query-code {
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 3px;
+ padding: 0.75rem;
+ font-size: 11px;
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
+ margin: 0.5rem 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+
+ .use-query {
+ margin-top: 0.5rem;
+ }
+ }
+ }
+
+ .sparql-format-select {
+ width: auto;
+ display: inline-block;
+ }
+
+ .form-actions {
+ margin-top: 25px;
+
+ .btn + .btn {
+ margin-left: 0.5rem;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/controllers/sparql_controller.rb b/app/controllers/sparql_controller.rb
new file mode 100644
index 0000000000..73d703a7f8
--- /dev/null
+++ b/app/controllers/sparql_controller.rb
@@ -0,0 +1,101 @@
+require 'sparql/client'
+require 'net/http'
+
+class SparqlController < ApplicationController
+ layout 'application'
+
+ before_action :rdf_repository_configured?
+
+ def index
+ # Main SPARQL interface page
+ flash.now[:error] = 'SPARQL endpoint is configured, but not currently available.' unless rdf_repository_available?
+
+ @example_queries = load_example_queries
+
+ respond_to(&:html)
+ end
+
+ def query
+ @sparql_query = params[:sparql_query] || ''
+
+ if rdf_repository_available?
+ begin
+ @results = execute_sparql_query(@sparql_query)
+ rescue StandardError => e
+ @error = e.message
+ Rails.logger.error("SPARQL Query Error: #{e.message}")
+ end
+ else
+ @error = 'SPARQL endpoint is configured, but not currently available.'
+ flash[:error] = @error
+ end
+
+ status = @error ? :unprocessable_entity : nil # can't use :success but is the default if nil
+ @results ||= []
+
+ respond_to do |format|
+ format.html do
+ @example_queries = load_example_queries
+ render :index, status: status
+ end
+ format.json { render json: { 'results': @results, 'error': @error }.compact, status: status }
+ format.xml { render xml: @results.to_xml, status: status }
+ end
+ end
+
+ private
+
+ def execute_sparql_query(query)
+ public_graph = Seek::Rdf::RdfRepository.instance.get_configuration.public_graph
+ endpoint = Seek::Rdf::RdfRepository.instance.get_configuration.uri
+ sparql_client = SPARQL::Client.new(endpoint, graph: public_graph)
+ results = sparql_client.query(query)
+ convert_sparql_results(results)
+ end
+
+ def convert_sparql_results(results)
+ return [] if results.nil?
+
+ # Handle empty collections
+ return [] if results.respond_to?(:empty?) && results.empty?
+
+ # Handle different result formats
+ if results.respond_to?(:map)
+ results.map do |solution|
+ if solution.respond_to?(:to_h)
+ solution.to_h.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v }
+ elsif solution.respond_to?(:bindings)
+ solution.bindings.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v }
+ else
+ solution.to_s
+ end
+ end
+ else
+ # This handles ASK queries (boolean) and any other single values
+ [{ 'result' => results.to_s }]
+ end
+ end
+
+ def rdf_repository_available?
+ Seek::Rdf::RdfRepository.instance.available?
+ end
+
+ def rdf_repository_configured?
+ unless Seek::Rdf::RdfRepository.instance&.configured?
+ flash[:error] = 'SPARQL endpoint is not configured.'
+ redirect_to main_app.root_path
+ end
+ end
+
+ def load_example_queries
+ queries_file = Rails.root.join('config', 'sparql_queries.yml')
+ if File.exist?(queries_file)
+ YAML.safe_load(File.read(queries_file)) || {}
+ else
+ {}
+ end
+ rescue StandardError => e
+ Rails.logger.error("Failed to load SPARQL queries: #{e.message}")
+ {}
+ end
+end
diff --git a/app/helpers/sparql_helper.rb b/app/helpers/sparql_helper.rb
new file mode 100644
index 0000000000..ef2ded9f32
--- /dev/null
+++ b/app/helpers/sparql_helper.rb
@@ -0,0 +1,11 @@
+module SparqlHelper
+
+ def sparql_results_panel_title
+ " Query Results (#{@results.length} results)"
+ end
+
+ def sparql_examples_panel_title
+ ' Example Queries'
+ end
+
+end
\ No newline at end of file
diff --git a/app/views/layouts/navbar/_navbar.html.erb b/app/views/layouts/navbar/_navbar.html.erb
index c5d82e32c8..d2d41b0063 100644
--- a/app/views/layouts/navbar/_navbar.html.erb
+++ b/app/views/layouts/navbar/_navbar.html.erb
@@ -25,6 +25,9 @@
<%= render :partial => 'layouts/navbar/search_bar' if Seek::Config.solr_enabled %>
+ <% if defined?(Seek::Rdf::RdfRepository) && Seek::Rdf::RdfRepository.instance.configured? %>
+ <%= link_to 'SPARQL', sparql_index_path %>
+ <% end %>
<%= render :partial => "layouts/navbar/about_menu"%>
<%= render :partial => "layouts/navbar/help_menu" if Seek::Config.documentation_enabled %>
<% if logged_in_and_registered? %>
diff --git a/app/views/sparql/_example_queries.html.erb b/app/views/sparql/_example_queries.html.erb
new file mode 100644
index 0000000000..c4f91c8dff
--- /dev/null
+++ b/app/views/sparql/_example_queries.html.erb
@@ -0,0 +1,26 @@
+<% if @example_queries.present? %>
+ <% @example_queries.each_slice(2) do |queries_pair| %>
+
+ <% queries_pair.each do |query_key, query_data| %>
+
+
+
+ <%= query_data['title'] %>
+ Example
+
+ <% if query_data['description'].present? %>
+
<%= query_data['description'] %>
+ <% end %>
+
<%= h(query_data['query'].strip) %>
+
Use this query
+
+
+ <% end %>
+
+ <% end %>
+<% else %>
+
+
+ No example queries available.
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/sparql/_results.html.erb b/app/views/sparql/_results.html.erb
new file mode 100644
index 0000000000..28415a12da
--- /dev/null
+++ b/app/views/sparql/_results.html.erb
@@ -0,0 +1,5 @@
+<% if results.present? %>
+ <%= render partial: "results_table", locals: { results: results } %>
+<% else %>
+ No results found.
+<% end %>
\ No newline at end of file
diff --git a/app/views/sparql/_results_table.html.erb b/app/views/sparql/_results_table.html.erb
new file mode 100644
index 0000000000..9b1bb7ac3f
--- /dev/null
+++ b/app/views/sparql/_results_table.html.erb
@@ -0,0 +1,33 @@
+<% if results.present? && results.first.is_a?(Hash) %>
+
+
+
+
+ <% results.first.keys.each do |column| %>
+ <%= column.to_s.humanize %>
+ <% end %>
+
+
+
+ <% results.each do |row| %>
+
+ <% row.values.each do |value| %>
+
+ <% if value.to_s.start_with?('http') %>
+ <%= link_to value, value, class: 'external-link' %>
+ <% else %>
+ <%= value %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+<% else %>
+
+
+ No tabular data to display.
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/sparql/_sparql_examples.html.erb b/app/views/sparql/_sparql_examples.html.erb
new file mode 100644
index 0000000000..ef4250f4ba
--- /dev/null
+++ b/app/views/sparql/_sparql_examples.html.erb
@@ -0,0 +1,25 @@
+
+
+ <%= folding_panel(sparql_examples_panel_title, true, class: 'panel-info') do %>
+
+
+
+ <%= render partial: "example_queries" %>
+
+
+
+
+ No example queries match your search.
+
+
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/sparql/index.html.erb b/app/views/sparql/index.html.erb
new file mode 100644
index 0000000000..06861697dc
--- /dev/null
+++ b/app/views/sparql/index.html.erb
@@ -0,0 +1,93 @@
+<% content_for :title, "SPARQL Query Interface" %>
+
+
+
+
+ <%= form_with url: query_sparql_index_path, method: :post, local: true, class: "sparql-form" do |form| %>
+
+
+
+ <%= form.label :sparql_query, "SPARQL Query:", class: "control-label" %>
+ <%= form.text_area :sparql_query, value: @sparql_query,
+ class: "form-control sparql-textarea",
+ rows: 12,
+ placeholder: "Enter your SPARQL query here...\n\nExample:\nSELECT ?subject ?predicate ?object\nWHERE {\n ?subject ?predicate ?object .\n}\nLIMIT 10" %>
+
+
+
+
+
+
+
+ <%= form.label :format, "Output Format:", class: "control-label" %>
+ <%= form.select :format,
+ [['Table', 'html'], ['JSON', 'json'], ['XML', 'xml']],
+ { selected: @format },
+ { class: "form-control sparql-format-select" } %>
+
+
+
+
+ <%= form.submit "Execute Query", class: "btn btn-primary" %>
+ Clear
+
+
+
+ <% end %>
+
+ <% if @error %>
+
+
Query Error:
+
<%= @error %>
+
+ <% end %>
+
+ <% if @results && @results.any? %>
+
+
+ <%= panel(sparql_results_panel_title, class:'panel-success') do %>
+ <%= render partial: "results", locals: { results: @results, format: @format } %>
+ <% end %>
+
+ <% elsif @results && @results.empty? && @error.nil? %>
+
+
+ No results found for your query.
+
+ <% end %>
+
+ <%= render partial: 'sparql_examples' %>
+
+
+
+
+
+
+
+
+ <%= image_tag('sparql/schema.png', alt: 'SEEK Data Schema', class: 'img-responsive', style: 'max-width: 100%; height: auto;') %>
+
+
+
+
+
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 62cef507fc..6b6a8ed40d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -838,6 +838,13 @@
resources :projects, :people, :programmes, :samples, :assays, :studies, :investigations, :data_files, :sops, :publications, :collections
end
+ ### SPARQL ###
+ resources :sparql, only: [:index] do
+ collection do
+ post :query
+ end
+ end
+
### MISC MATCHES ###
get '/search/' => 'search#index', as: :search
get '/search/save' => 'search#save', as: :save_search
diff --git a/config/sparql_queries.yml b/config/sparql_queries.yml
new file mode 100644
index 0000000000..8302aa6f15
--- /dev/null
+++ b/config/sparql_queries.yml
@@ -0,0 +1,109 @@
+---
+# SPARQL Example Queries
+# Format:
+# query_name:
+# title: "Human readable title"
+# description: "Optional description"
+# query: |
+# SPARQL query here
+
+basic_exploration:
+ title: "List all classes"
+ description: "Find all RDF classes used in the dataset"
+ query: |
+ SELECT DISTINCT ?class
+ WHERE {
+ [] a ?class
+ }
+ LIMIT 20
+
+basic_triples:
+ title: "Sample triples"
+ description: "Show basic subject-predicate-object patterns"
+ query: |
+ SELECT ?subject ?predicate ?object
+ WHERE {
+ ?subject ?predicate ?object .
+ }
+ LIMIT 10
+
+properties:
+ title: "List all properties"
+ description: "Find all predicates/properties used"
+ query: |
+ SELECT DISTINCT ?property
+ WHERE {
+ [] ?property []
+ }
+ LIMIT 20
+
+count_by_type:
+ title: "Count entities by type"
+ description: "Count how many entities exist for each class"
+ query: |
+ SELECT ?type (COUNT(?entity) as ?count)
+ WHERE {
+ ?entity a ?type
+ }
+ GROUP BY ?type
+ ORDER BY DESC(?count)
+
+describe_example:
+ title: "Describe a resource"
+ description: "Get all information about a specific resource (replace with actual URI)"
+ query: |
+ DESCRIBE
+
+organisms:
+ title: "Find organisms"
+ description: "Look for organism-related data"
+ query: |
+ SELECT DISTINCT ?organism ?label
+ WHERE {
+ ?organism a ?type .
+ FILTER(CONTAINS(LCASE(STR(?type)), "organism") ||
+ CONTAINS(LCASE(STR(?type)), "species") ||
+ CONTAINS(LCASE(STR(?type)), "taxon"))
+ OPTIONAL { ?organism rdfs:label ?label }
+ }
+ LIMIT 10
+
+projects:
+ title: "Find projects"
+ description: "Look for project-related data"
+ query: |
+ SELECT DISTINCT ?project ?title
+ WHERE {
+ ?project a ?type .
+ FILTER(CONTAINS(LCASE(STR(?type)), "project"))
+ OPTIONAL { ?project dc:title ?title }
+ OPTIONAL { ?project dcterms:title ?title }
+ OPTIONAL { ?project rdfs:label ?title }
+ }
+ LIMIT 10
+
+studies:
+ title: "Find studies"
+ description: "Look for study-related data"
+ query: |
+ SELECT DISTINCT ?study ?title
+ WHERE {
+ ?study a ?type .
+ FILTER(CONTAINS(LCASE(STR(?type)), "study"))
+ OPTIONAL { ?study dc:title ?title }
+ OPTIONAL { ?study dcterms:title ?title }
+ OPTIONAL { ?study rdfs:label ?title }
+ }
+ LIMIT 10
+
+graph_exploration:
+ title: "List named graphs"
+ description: "Show all named graphs in the dataset"
+ query: |
+ SELECT DISTINCT ?graph
+ WHERE {
+ GRAPH ?graph {
+ ?s ?p ?o
+ }
+ }
+ LIMIT 10
\ No newline at end of file
diff --git a/lib/seek/rdf/rdf_repository.rb b/lib/seek/rdf/rdf_repository.rb
index 70fdd9103d..bcd7821d84 100644
--- a/lib/seek/rdf/rdf_repository.rb
+++ b/lib/seek/rdf/rdf_repository.rb
@@ -86,6 +86,15 @@ def configured?
File.exist?(config_path) && enabled_for_environment?
end
+ # whether the endpoint is available and responds to requests, even if configured
+ def available?
+ select('ask where {?s ?p ?o}')
+ true
+ rescue StandardError => e
+ Rails.logger.error("Error trying a simple query: #{e.message}")
+ false
+ end
+
# provides the URI's of any items related to the item - discovered by querying the triple store to find both:
# ?predicate
# or
diff --git a/test/functional/sparql_controller_test.rb b/test/functional/sparql_controller_test.rb
new file mode 100644
index 0000000000..d6b59ef855
--- /dev/null
+++ b/test/functional/sparql_controller_test.rb
@@ -0,0 +1,28 @@
+require 'test_helper'
+require 'minitest/mock'
+
+class SparqlControllerTest < ActionController::TestCase
+
+ # these tests cover the case where the sparql endpoint isn't configured. For cases where the triple store is available
+ # please see the test/integration/sparql_controller_test
+
+ test 'index' do
+ Seek::Rdf::RdfRepository.instance.stub(:configured?, ->(){ false }) do
+ refute Seek::Rdf::VirtuosoRepository.instance.configured?
+ get :index
+ assert_redirected_to :root
+ assert_equal 'SPARQL endpoint is not configured.', flash[:error]
+ end
+ end
+
+ test 'post sparql to index' do
+ Seek::Rdf::RdfRepository.instance.stub(:configured?, ->(){ false }) do
+ refute Seek::Rdf::VirtuosoRepository.instance.configured?
+ query = 'ask where {?s ?p ?o}'
+ post :index, params: { sparql_query: query, format: 'json' }
+ assert_redirected_to :root
+ assert_equal 'SPARQL endpoint is not configured.', flash[:error]
+ end
+ end
+
+end
\ No newline at end of file
diff --git a/test/integration/sparql_controller_test.rb b/test/integration/sparql_controller_test.rb
new file mode 100644
index 0000000000..0e6d935e2d
--- /dev/null
+++ b/test/integration/sparql_controller_test.rb
@@ -0,0 +1,261 @@
+require 'test_helper'
+require 'minitest/mock'
+
+class SparqlControllerTest < ActionDispatch::IntegrationTest
+
+ def setup
+ @repository = Seek::Rdf::RdfRepository.instance
+ skip('these tests need a configured triple store setup') unless @repository.configured?
+ @private_graph = RDF::URI.new @repository.get_configuration.private_graph
+ @public_graph = RDF::URI.new @repository.get_configuration.public_graph
+
+ VCR.configure do |c|
+ c.allow_http_connections_when_no_cassette = true
+ end
+
+ end
+ def teardown
+ return unless @repository.configured?
+ q = @repository.query.delete(%i[s p o]).graph(@private_graph).where(%i[s p o])
+ @repository.delete(q)
+
+ q = @repository.query.delete(%i[s p o]).graph(@public_graph).where(%i[s p o])
+ @repository.delete(q)
+ VCR.configure do |c|
+ c.allow_http_connections_when_no_cassette = false
+ end
+ end
+
+
+ test 'get index' do
+ path = sparql_index_path
+ get path
+ assert_response :success
+ assert_select '#content .container-fluid' do
+ assert_select 'div#error_flash', count: 0
+ assert_select 'div.sparql-interface' do
+ assert_select 'form[action=?][method=?]', query_sparql_index_path, 'post' do
+ assert_select 'textarea.sparql-textarea'
+ end
+ assert_select 'div.sparql-examples div.panel'
+ end
+ end
+ end
+
+ test 'post sparql query and json response' do
+ path = query_sparql_index_path
+ create_some_triples
+ query = 'SELECT ?datafile ?title
+ WHERE {
+ ?datafile a .
+ ?datafile "public data file" .
+ ?datafile ?title .
+ }'
+
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :success
+ json = JSON.parse(@response.body)
+
+ assert_equal 1, json['results'].length
+
+ query = 'SELECT ?datafile ?title
+ WHERE {
+ ?datafile a .
+ ?datafile "private data file" .
+ ?datafile ?title .
+ }'
+
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :success
+ json = JSON.parse(@response.body)
+
+ assert_empty json['results']
+ assert_nil json['error']
+ end
+
+ test 'post sparql query and html response' do
+ path = query_sparql_index_path
+ create_some_triples
+ query = 'SELECT ?datafile ?title
+ WHERE {
+ ?datafile a .
+ ?datafile "public data file" .
+ ?datafile ?title .
+ }'
+
+ post path, params: { sparql_query: query }
+ assert_response :success
+ assert_select 'div#query-error', count: 0
+
+ assert_select 'div.sparql-results table' do
+ assert_select 'tbody tr', count: 1
+ assert_select 'thead th', count: 2
+ assert_select 'td', text: 'public data file', count: 1
+ end
+ end
+
+ test 'demonstrate that the default public graph can be worked around' do
+ path = query_sparql_index_path
+ create_some_triples
+ query = 'SELECT ?datafile ?title ?graph
+ WHERE {
+ GRAPH ?graph {
+ ?datafile a .
+ ?datafile "private data file" .
+ ?datafile ?title .
+ }
+ }'
+
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :success
+ json = JSON.parse(@response.body)
+
+ assert_equal 1, json['results'].length
+ assert_equal 'private data file', json['results'].first['title']
+ assert_equal 'seek-testing:private', json['results'].first['graph']
+ assert_nil json['error']
+ end
+
+ test 'post invalid sparql' do
+ path = query_sparql_index_path
+ create_some_triples
+
+ query = 'SEECT ?datafile ?invalid ?graph
+ WHERE {
+ GRAPH ?graph {
+ ?datafile a .
+ ?datafile "public data file" .
+ ?datafile ?title .
+ }
+ }'
+
+ post path, params: { sparql_query: query }
+ assert_response :unprocessable_entity
+ assert_nil flash[:error] # a query error is shown in the results box
+
+ assert_select 'div#query-error.alert-danger' do
+ assert_select 'h4', text:/Query Error/
+ assert_select 'pre', text:/SPARQL compiler, line 3: syntax error at 'SEECT'/
+ end
+
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :unprocessable_entity
+ json = JSON.parse(@response.body)
+
+ assert_match /SPARQL compiler, line 3: syntax error at 'SEECT'/, json['error']
+
+ end
+
+ test 'handle single result e.g ask' do
+ path = query_sparql_index_path
+ create_some_triples
+ query = 'ask where {?s ?p ?o}'
+
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :success
+ json = JSON.parse(@response.body)
+ expected = {'result' => 'true'}
+ assert_equal expected, json['results'].first
+
+ post path, params: { sparql_query: query }
+ assert_response :success
+ assert_select 'div.sparql-results table' do
+ assert_select 'tbody tr', count: 1
+ assert_select 'tbody td', text:'true', count: 1
+ assert_select 'thead th', count: 1
+ assert_select 'thead th', text:'Result', count: 1
+ end
+
+ end
+
+ test 'respond with json when using content negotiation' do
+ path = query_sparql_index_path
+ create_some_triples
+ query = 'ask where {?s ?p ?o}'
+
+ post path, params: { sparql_query: query }, headers: { 'Accept' => 'application/json' }
+ assert_response :success
+ json = JSON.parse(@response.body)
+ expected = {'result' => 'true'}
+ assert_equal expected, json['results'].first
+ end
+
+ test 'cannot insert with sparql query' do
+ id = (DataFile.last&.id || 0) + 1 #get a non existing id
+ graph = @repository.get_configuration.public_graph
+ count = all_triples_count
+ query = "INSERT DATA INTO GRAPH <#{graph}> {
+ 'some description' .
+ }"
+
+ path = query_sparql_index_path
+ post path, params: { sparql_query: query, format: 'json' }
+
+ #should probably be a different response (not authorized) when fixed
+ assert_response :unprocessable_entity
+ assert_equal count, all_triples_count
+ json = JSON.parse(@response.body)
+ assert_match /SECURITY: No permission to execute procedure/, json['error']
+ end
+
+ test 'cannot delete with sparql query' do
+ create_some_triples
+ id = DataFile.last.id
+ graph = @repository.get_configuration.public_graph
+ count = all_triples_count
+
+ query = "DELETE FROM <#{graph}> {
+ ?p ?o .
+ } WHERE {
+ ?p ?o .
+ }"
+
+ path = query_sparql_index_path
+ post path, params: { sparql_query: query, format: 'json' }
+
+ assert_response :unprocessable_entity
+ assert_equal count, all_triples_count
+ json = JSON.parse(@response.body)
+ assert_match /SECURITY: No permission to execute procedure/, json['error']
+ end
+
+ test 'repository not available' do
+ path = query_sparql_index_path
+ Seek::Rdf::RdfRepository.instance.stub(:available?, ->(){ false }) do
+ get sparql_index_path
+ assert_response :success
+ assert_equal 'SPARQL endpoint is configured, but not currently available.', flash[:error]
+ assert_select 'div#error_flash', text:/SPARQL endpoint is configured, but not currently available/
+
+ query = 'ask where {?s ?p ?o}'
+ post path, params: { sparql_query: query }
+ assert_response :unprocessable_entity
+ assert_equal 'SPARQL endpoint is configured, but not currently available.', flash[:error]
+ assert_select 'div#error_flash', text:/SPARQL endpoint is configured, but not currently available/
+ assert_empty assigns(:results)
+
+ query = 'ask where {?s ?p ?o}'
+ post path, params: { sparql_query: query, format: 'json' }
+ assert_response :unprocessable_entity
+ assert_empty assigns(:results)
+ json = JSON.parse(@response.body)
+ assert_match /SPARQL endpoint is configured, but not currently available/, json['error']
+ end
+ end
+
+ private
+
+ def create_some_triples
+ private_df = FactoryBot.create(:max_data_file, title:'private data file')
+ private_df.send_rdf_to_repository
+
+ public_df = FactoryBot.create(:max_data_file, title:'public data file', policy: FactoryBot.create(:public_policy))
+ public_df.send_rdf_to_repository
+ end
+
+ def all_triples_count
+ q = @repository.query.select.where(%i[s p o])
+ @repository.select(q).count
+ end
+
+end
\ No newline at end of file