diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 0b06c1b..7d79783 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -11,4 +11,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 + with: + python-version: 3.12 - uses: pre-commit/action@v3.0.0 diff --git a/bouncer/coderunners.py b/bouncer/coderunners.py index cace3c4..2da9905 100644 --- a/bouncer/coderunners.py +++ b/bouncer/coderunners.py @@ -29,6 +29,8 @@ def from_language(language: str) -> 'CodeRunner': return JsRunner() if language in JavaRunner.supported_standards: return JavaRunner() + if language in SQLiteRunner.supported_standards: + return SQLiteRunner() raise ValueError(f'{language} does not have a compiler yet') def invoke(self, aws_lambda_client, request: SubmissionRequest) -> SubmissionResult: @@ -100,3 +102,12 @@ class JavaRunner(CodeRunner): @property def name(self) -> str: return 'CodeRunnerJava' + + +@dataclass +class SQLiteRunner(CodeRunner): + supported_standards = {'sql', 'sqlite'} + + @property + def name(self) -> str: + return 'CodeRunnerSQLite' diff --git a/coderunners/compilers.py b/coderunners/compilers.py index 3ac6f45..6d87792 100644 --- a/coderunners/compilers.py +++ b/coderunners/compilers.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import ClassVar -from coderunners.executors import Executor, ProcessExecutor +from coderunners.executors import Executor, ProcessExecutor, SQLiteExecutor from coderunners.process import Process from models import RunResult, Status @@ -24,7 +24,7 @@ def find_main_file_path(cls, submission_paths: list[Path], main_file_name: str) @staticmethod def from_language(language: str) -> 'Compiler': - language = language.lower() + language = language.lower().strip() if language in TxtCompiler.supported_standards: return TxtCompiler() if language in CppCompiler.supported_standards: @@ -39,6 +39,8 @@ def from_language(language: str) -> 'Compiler': return JsCompiler(language_standard=language) if language in JavaCompiler.supported_standards: return JavaCompiler() + if language in SQLiteCompiler.supported_standards: + return SQLiteCompiler() raise ValueError(f'{language} does not have a compiler yet') @@ -186,3 +188,20 @@ def compile(self, submission_paths: list[Path]): compile_res = Process(f'cd {self.build_dir} && jar cvf Main.jar *', timeout=10, memory_limit_mb=512).run() print('Compile res:', compile_res) return ProcessExecutor(command=command), compile_res + + +@dataclass +class SQLiteCompiler(Compiler): + supported_standards = {'sql', 'sqlite'} + db_name: str = 'main.db' + + def compile(self, submission_paths: list[Path]): + if len(submission_paths) != 1: + return ProcessExecutor(command='echo "Only one file is allowed"'), RunResult( + status=Status.CE, memory=0, time=0, return_code=0, outputs=None, + errors='Only one file is allowed for SQL submissions', + ) + + script = submission_paths[0].read_text() + executor = SQLiteExecutor(script=script, db_name=self.db_name) + return executor, RunResult(status=Status.OK, memory=0, time=0, return_code=0, outputs=None, errors=None) diff --git a/coderunners/executors.py b/coderunners/executors.py index 9595fcd..89051b4 100644 --- a/coderunners/executors.py +++ b/coderunners/executors.py @@ -1,10 +1,12 @@ +import sqlite3 from abc import ABC, abstractmethod from collections.abc import Iterable from dataclasses import dataclass +from io import StringIO from pathlib import Path from coderunners.process import Process -from models import RunResult, TestCase +from models import RunResult, Status, TestCase class Executor(ABC): @@ -58,3 +60,104 @@ def cleanup(self, test: TestCase) -> None: if (self.ROOT / filename).exists(): print('Removing file at:', self.ROOT / filename) (self.ROOT / filename).unlink() + + +@dataclass +class SQLiteExecutor(Executor): + script: str + db_name: str = 'main.db' + + def __post_init__(self): + self.db = sqlite3.connect(self.db_name) + + def __del__(self): + self.db.close() + + def run(self, test: TestCase, **kwargs) -> RunResult: + """ + self.script is the SQL script that needs to be run on the database + test.input is the initialization SQL script + test.input_files are all the tables that need to be populated + test.target is the expected output of the SQL script (can be empty) + test.target_files are all the tables that need to be populated by the SQL script + """ + import pandas as pd + cursor = self.db.cursor() + + try: + cursor.executescript(test.input) + self.db.commit() + except sqlite3.Error as e: + cursor.close() + return RunResult( + status=Status.RUNTIME_ERROR, memory=0, time=0, return_code=0, outputs=None, + errors=str(e), + ) + + for filename, content in (test.input_files or {}).items(): + # Load the content of the file into a dataframe and then load it into the db (filename) + try: + print('Creating table:', filename) + csv_data = StringIO(content) + df = pd.read_csv(csv_data) + print(df.head()) + df.to_sql(filename, self.db, if_exists='replace', index=False) + print('--- Done ---') + except (sqlite3.Error, pd.errors.ParserError, pd.errors.DatabaseError, ValueError) as e: + cursor.close() + return RunResult( + status=Status.RUNTIME_ERROR, memory=0, time=0, return_code=0, outputs=None, + errors=str(e), + ) + + self.db.commit() + + # Execute the self.script as a single command and get the output + try: + print('Executing script:', self.script) + if self.script.strip().upper().startswith('SELECT'): + res = pd.read_sql_query(self.script, self.db).to_csv(index=False) + else: + cursor.executescript(self.script) + self.db.commit() + res = '' + print('Result:', res) + except (sqlite3.Error, pd.errors.ParserError, pd.errors.DatabaseError, ValueError) as e: + cursor.close() + return RunResult( + status=Status.RUNTIME_ERROR, memory=0, time=0, return_code=0, outputs=None, + errors=str(e), + ) + + # Read output files into the result + try: + r = RunResult( + status=Status.OK, memory=0, time=0, return_code=0, outputs=res, + output_files={ + filename: pd.read_sql_query(f'SELECT * FROM {filename}', self.db).to_csv(index=False) + for filename in (test.target_files or {}).keys() + }) + cursor.close() + return r + except (sqlite3.Error, pd.errors.ParserError, pd.errors.DatabaseError, ValueError) as e: + cursor.close() + return RunResult( + status=Status.RUNTIME_ERROR, memory=0, time=0, return_code=0, outputs=None, + errors=str(e), + ) + + def cleanup(self, test: TestCase) -> None: + """ Drops all the tables in the database """ + cursor = self.db.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + + print(f'Dropping {len(tables)} tables') + for table_name in tables: + print('Dropping table:', table_name[0], end='...') + cursor.execute(f'DROP TABLE {table_name[0]}') + print('Done') + + print('Saving changes') + self.db.commit() + cursor.close() diff --git a/coderunners/lang/sqlite.Dockerfile b/coderunners/lang/sqlite.Dockerfile new file mode 100644 index 0000000..9f93f50 --- /dev/null +++ b/coderunners/lang/sqlite.Dockerfile @@ -0,0 +1,18 @@ +FROM public.ecr.aws/lambda/python:3.12 + +# Initial setup +RUN pip install --upgrade pip +RUN pip install awslambdaric -t "${LAMBDA_TASK_ROOT}" + +# Install dependencies +COPY coderunners/requirements.txt ./ +RUN pip install -r requirements.txt -t "${LAMBDA_TASK_ROOT}" +RUN python -m pip install --upgrade pandas + +# Setup source files +COPY coderunners/*.py ${LAMBDA_TASK_ROOT}/coderunners/ +COPY models.py ${LAMBDA_TASK_ROOT}/ + +# Run the lambda function handler +ENTRYPOINT [ "python", "-m", "awslambdaric" ] +CMD [ "coderunners.app.run_code_lambda" ] diff --git a/coderunners/services.py b/coderunners/services.py index 71d94ce..8c27900 100644 --- a/coderunners/services.py +++ b/coderunners/services.py @@ -90,6 +90,7 @@ def check(self) -> SubmissionResult: self.test_cases[0], time_limit=self.time_limit, memory_limit_mb=self.memory_limit, output_limit_mb=self.output_limit ) + executor.cleanup(self.test_cases[0]) print('Done') # Process all tests @@ -107,7 +108,7 @@ def check(self) -> SubmissionResult: input_files=test.input_files, output_files=r.output_files, target_files=test.target_files, input_assets=test.input_assets, output_assets=r.output_assets, target_assets=test.target_assets, ) if r.status == Status.OK else (r.status, 0, r.message) - print(f'Test {i} res: {r.status} => {r.score}') + print(f'Test {i} res: {r.status} => score {r.score}') # Clean up executor.cleanup(test) @@ -135,8 +136,10 @@ def check(self) -> SubmissionResult: test_results += [ RunResult(status=Status.SKIPPED, memory=0, time=0, return_code=0) ] * (len(self.test_cases) - i - 1) - print('Expected:', test.target, test.target_files) - print('Actual:', test_results[-1].outputs, test_results[-1].output_files) + print('Expected:', test.target) + print('Actual:', test_results[-1].outputs) + print('Expected files:', test.target_files) + print('Actual files:', test_results[-1].output_files) break print('test_results:', test_results) assert len(test_results) == len(self.test_cases) diff --git a/template.yaml b/template.yaml index f432378..cd078d3 100644 --- a/template.yaml +++ b/template.yaml @@ -514,6 +514,7 @@ Resources: DockerTag: js-v1 DockerContext: ./ Dockerfile: coderunners/lang/js.Dockerfile + CodeRunnerJava: Type: AWS::Serverless::Function DependsOn: CodeRunnerMountTarget @@ -534,6 +535,26 @@ Resources: DockerContext: ./ Dockerfile: coderunners/lang/java.Dockerfile + CodeRunnerSQLite: + Type: AWS::Serverless::Function + DependsOn: CodeRunnerMountTarget + Properties: + FunctionName: CodeRunnerSQLite + PackageType: Image + Role: !GetAtt ContestantRole.Arn + VpcConfig: + SecurityGroupIds: + - !GetAtt JudgeVPC.DefaultSecurityGroup + SubnetIds: + - !Ref CodeRunnerPrivateSubnet + FileSystemConfigs: + - Arn: !GetAtt AccessPointResource.Arn + LocalMountPath: '/mnt/efs' + Metadata: + DockerTag: sqlite-v1 + DockerContext: ./ + Dockerfile: coderunners/lang/sqlite.Dockerfile + Outputs: # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function # Find out more about other implicit resources you can reference within SAM @@ -569,6 +590,9 @@ Outputs: CodeRunnerJava: Description: 'AWS Lambda for executing a Java code and getting the outputs' Value: !GetAtt CodeRunnerJava.Arn + CodeRunnerSQLite: + Description: 'AWS Lambda for executing a SQL/SQLite code and getting the results with tables' + Value: !GetAtt CodeRunnerSQLite.Arn SyncS3WithEFSName: Description: 'AWS Lambda for syncing S3 bucket for test cases with EFS' diff --git a/tests/integration/coderunners/test_sql.py b/tests/integration/coderunners/test_sql.py new file mode 100644 index 0000000..8c5a795 --- /dev/null +++ b/tests/integration/coderunners/test_sql.py @@ -0,0 +1,153 @@ +from textwrap import dedent + +from bouncer.coderunners import CodeRunner +from models import Status, SubmissionRequest, TestCase +from tests.integration.config import lambda_client + + +class TestSQLSubmissions: + + def test_echo(self): + test_cases = [ + TestCase( + input=dedent(''' + -- Initialization script goes here + '''), + target=dedent(''' + 'hello world' + hello world + ''').strip()), + ] + request = SubmissionRequest(test_cases=test_cases, return_outputs=True, language='SQL', code={ + 'main.sql': dedent(''' + SELECT 'hello world' + ''').strip(), + }) + res = CodeRunner.from_language(language=request.language).invoke(lambda_client, request=request) + print(res) + assert res.overall.status == Status.OK + assert res.overall.score == 100 + assert len(res.test_results) == 1 + assert res.test_results[0].status == Status.OK + + def test_create_table(self): + test_cases = [ + TestCase( + input=dedent(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + '''), + target=dedent(''' + COUNT(*) + 3 + ''').strip(), + input_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + ''').strip(), + }, + target_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + ''').strip(), + }), + ] + request = SubmissionRequest(test_cases=test_cases, return_outputs=True, language='SQL', code={ + 'main.sql': dedent(''' + SELECT COUNT(*) FROM users; + ''').strip(), + }, comparison_mode='token') + res = CodeRunner.from_language(language=request.language).invoke(lambda_client, request=request) + print(res) + assert res.overall.status == Status.OK + assert res.overall.score == 100 + assert len(res.test_results) == 1 + assert res.test_results[0].status == Status.OK + + def test_insert(self): + test_cases = [ + TestCase( + input=dedent(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + '''), + target='', + input_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + ''').strip(), + }, + target_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + 4,Jack + ''').strip(), + }), + ] + request = SubmissionRequest(test_cases=test_cases, return_outputs=True, language='SQL', code={ + 'main.sql': dedent(''' + INSERT INTO users (id, name) VALUES (4, 'Jack'); + ''').strip(), + }, comparison_mode='token') + res = CodeRunner.from_language(language=request.language).invoke(lambda_client, request=request) + print(res) + assert res.overall.status == Status.OK + assert res.overall.score == 100 + assert len(res.test_results) == 1 + assert res.test_results[0].status == Status.OK + + def test_invalid_query(self): + test_cases = [ + TestCase( + input=dedent(''' + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + '''), + target='', + input_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + ''').strip(), + }, + target_files={ + 'users': dedent(''' + id,name + 1,John + 2,Jane + 3,Martin + ''').strip(), + }), + ] + request = SubmissionRequest(test_cases=test_cases, return_outputs=True, language='SQL', code={ + # Command should result in an error + 'main.sql': dedent(''' + SELECT * FROM random_table; + ''').strip(), + }, comparison_mode='token') + res = CodeRunner.from_language(language=request.language).invoke(lambda_client, request=request) + print(res) + assert res.overall.status == Status.RUNTIME_ERROR + assert res.overall.score == 0 + assert len(res.test_results) == 1 + assert res.test_results[0].status == Status.RUNTIME_ERROR