-
Notifications
You must be signed in to change notification settings - Fork 55
Sparql interface #2303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Sparql interface #2303
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
4a52dff
Add comprehensive SPARQL query interface
jjkoehorst 0069b0f
style sheet for sparql requirement added
jjkoehorst ec1c59b
cleaning up based on feedback
jjkoehorst 138425f
Made some further changes...
jjkoehorst 8e085d0
fixed an issue where the original implementation of DESCRIBE was igno…
jjkoehorst 85c008d
some "legacy" code where it would listen to /sparql/query while thats…
jjkoehorst c2590e6
added a filter function in case we add many more example queries (whi…
jjkoehorst 7fef4cf
a integration test to show the index page is working
stuzart 4dd5a3a
tidy how it checks the configuration and connection
stuzart d46c85d
an integration test performing a sparql query
stuzart f1dbadc
update the sparql query test to be easier to test, and also in future…
stuzart efb2022
test that check you cannot insert and delete triples with a sparql qu…
stuzart 4e81c2e
remove test trace
stuzart 68f86c4
a test for the html response
stuzart 5152d34
an initial test for an invalid sparql query. response codes and json …
stuzart 5c00316
Added a schema image of the demo dataset. To be updated in the future…
jjkoehorst 4f90eb4
simplified example view to work on the sparql_queries.yml file.
jjkoehorst d8b2cf5
Example query section is now collapsed by default to reduce space
jjkoehorst 39654e3
add a before_action to block access to sparql controller if not confi…
stuzart b380fd8
fix to always use unauthorized access to endpoint, to prevent insert,…
stuzart 0dd3883
sorted out some of the error reporting and response codes. 422 seemed…
stuzart 40a94d4
add test for the case where there is a single result
stuzart 372c66a
test for when the repo is configured, but for some reason not available
stuzart ea49fb0
don't default the results, and only set if a query. Also only allow X…
stuzart 3e96aa9
split the query into its own action
stuzart c24c077
fixes and refactoring for the use of the format param, including a te…
stuzart 4068bb5
use the bootstrap_helper for the panels and put the examples in its o…
stuzart 56a8ae9
resolve copilot comments
stuzart b88ebcf
don't show the flash[:error] when there is just a query error
stuzart 8c5938d
default to restricting to the public graph, but added a test to show …
stuzart 2cb682c
test updates
stuzart 3b629d2
update on the schema
jjkoehorst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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')); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| require 'sparql/client' | ||
| require 'net/http' | ||
|
|
||
| class SparqlController < ApplicationController | ||
| layout 'application' | ||
|
|
||
| before_action :rdf_repository_configured? | ||
|
|
||
| def index | ||
stuzart marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| # 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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.