From dcf534a0fef9e0ba4e85a27b50b7e8078913363e Mon Sep 17 00:00:00 2001 From: "Denis V." Date: Wed, 8 Apr 2026 16:04:23 +0300 Subject: [PATCH] add support Apache AGE --- .env_example | 10 +- README.md | 14 +- main.py | 408 +++++++++++++++++++++++++++++---------------------- 3 files changed, 253 insertions(+), 179 deletions(-) diff --git a/.env_example b/.env_example index 0b69a40..138b591 100644 --- a/.env_example +++ b/.env_example @@ -7,4 +7,12 @@ POSTGRES_PORT = 5432 NEO_ACTIVE=false NEO_USER=neo4j NEO_PASS=pass -NEO_HOST=neo4j://localhost:7687 \ No newline at end of file +NEO_HOST=neo4j://localhost:7687 + +AGE_ACTIVE=false +AGE_USER=postgres +AGE_PASS=pass +AGE_HOST=localhost +AGE_PORT=5432 +AGE_DB=university +AGE_GRAPH_NAME=movie_graph \ No newline at end of file diff --git a/README.md b/README.md index 939c817..8a20ad9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ ### Пример приложения для работы с БД -Небольшое десктоп‑приложение на PyQt5 для работы с PostgreSQL и Neo4j. +Небольшое десктоп-приложение на PyQt5 для работы с PostgreSQL, Neo4j и Apache AGE. ### Стек - PyQt5 — графический интерфейс - PostgreSQL (psycopg2) — реляционная БД для пользователей - Neo4j — графовая БД для фильмов +- Apache AGE — графовая модель поверх PostgreSQL для фильмов - python-dotenv — загрузка параметров подключения из файла `.env` ### Скриншоты @@ -24,6 +25,7 @@ - Python 3.10+ - Установленный PostgreSQL и доступ к серверу БД - Установленный Neo4j и загруженный обучающий граф фильмов (Movie Graph example) +- Для Apache AGE: установленное расширение `age` в PostgreSQL и доступ к базе, где оно включено ### Настройка окружения @@ -43,6 +45,14 @@ NEO_ACTIVE=false # включить Neo4j-часть приложения, NEO_USER=neo4j NEO_PASS=your_password NEO_HOST=neo4j://localhost:7687 + +AGE_ACTIVE=false # включить Apache AGE, установив true +AGE_USER=postgres +AGE_PASS=your_password +AGE_HOST=localhost +AGE_PORT=5432 +AGE_DB=university +AGE_GRAPH_NAME=movie_graph ``` 2. Создайте и активируйте виртуальное окружение: @@ -77,4 +87,4 @@ pip install -r requirements.txt py main.py ``` -Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их ещё нет), выполнит запрос к Neo4j (если активно) и отобразит результаты в двух окнах. \ No newline at end of file +Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их еще нет), выполнит запрос к Neo4j (если активно) и/или к Apache AGE (если активно), а затем отобразит результаты в отдельных окнах. \ No newline at end of file diff --git a/main.py b/main.py index b41af49..4d217f5 100644 --- a/main.py +++ b/main.py @@ -1,245 +1,301 @@ +import os +import sys + import psycopg2 +from dotenv import load_dotenv +from neo4j import GraphDatabase from psycopg2 import sql from psycopg2.errors import DuplicateDatabase -from neo4j import GraphDatabase -from PyQt5.QtWidgets import QApplication, QMainWindow, QGridLayout, QWidget, QTableWidget, QTableWidgetItem, \ - QHeaderView, QVBoxLayout, QLabel from PyQt5.QtCore import QSize, Qt +from PyQt5.QtWidgets import QApplication, QGridLayout, QHeaderView, QMainWindow, QTableWidget, QTableWidgetItem, QWidget -import os -from dotenv import load_dotenv load_dotenv() -NEO_USER = os.getenv('NEO_USER') -NEO_PASS = os.getenv('NEO_PASS') -NEO_HOST = os.getenv('NEO_HOST') +POSTGRES_USER = os.getenv("POSTGRES_USER") +POSTGRES_DB = os.getenv("POSTGRES_DB") +POSTGRES_PASS = os.getenv("POSTGRES_PASS") +POSTGRES_HOST = os.getenv("POSTGRES_HOST") +POSTGRES_PORT = os.getenv("POSTGRES_PORT") -POSTGRES_USER = os.getenv('POSTGRES_USER') -POSTGRES_DB = os.getenv('POSTGRES_DB') -POSTGRES_PASS = os.getenv('POSTGRES_PASS') -POSTGRES_HOST = os.getenv('POSTGRES_HOST') -POSTGRES_PORT = os.getenv('POSTGRES_PORT') +NEO_ACTIVE = os.getenv("NEO_ACTIVE", "false").lower() == "true" +NEO_USER = os.getenv("NEO_USER") +NEO_PASS = os.getenv("NEO_PASS") +NEO_HOST = os.getenv("NEO_HOST") -def connect(config): - """ Connect to the PostgreSQL database server """ +AGE_ACTIVE = os.getenv("AGE_ACTIVE", "false").lower() == "true" +AGE_USER = os.getenv("AGE_USER", POSTGRES_USER) +AGE_PASS = os.getenv("AGE_PASS", POSTGRES_PASS) +AGE_HOST = os.getenv("AGE_HOST", POSTGRES_HOST) +AGE_PORT = os.getenv("AGE_PORT", POSTGRES_PORT) +AGE_DB = os.getenv("AGE_DB", POSTGRES_DB) +AGE_GRAPH_NAME = os.getenv("AGE_GRAPH_NAME", "movie_graph") + +USER_SEED_DATA = [ + (1, "Ivan", 15), + (2, "Igor", 22), + (3, "Alex", 16), + (4, "Anna", 40), + (5, "Inna", 30), +] + +def connect_postgres(dbname): + conn = psycopg2.connect( + dbname=dbname, + user=POSTGRES_USER, + password=POSTGRES_PASS, + host=POSTGRES_HOST, + port=POSTGRES_PORT, + ) + conn.autocommit = True + return conn + + +def create_database(admin_conn, database_name): try: - # connecting to the PostgreSQL server - with psycopg2.connect(**config) as conn: - print('Connected to the PostgreSQL server.') - return conn + with admin_conn.cursor() as cur: + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name))) + print(f"Database '{database_name}' created.") + except DuplicateDatabase: + print(f"Database '{database_name}' already exists.") + + +def create_users_table(conn): + try: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY, + name VARCHAR(255), + age INT + ) + """ + ) + print("Users table is ready.") except (psycopg2.DatabaseError, Exception) as error: print(error) -def createUsersDB(config): - """ Connect to the PostgreSQL database server """ +def select_users(conn): try: - # connecting to the PostgreSQL server - conn.autocommit = True - cur = conn.cursor() - cur.execute(sql.SQL("CREATE DATABASE {}").format( - sql.Identifier('university')) - ) - print('db created') - except DuplicateDatabase: - print('db already exists') - pass - - -def createuUiversityDB(config): - """ Connect to the PostgreSQL database server """ - try: - # connecting to the PostgreSQL server - conn.autocommit = True - cur = conn.cursor() - cur.execute(sql.SQL("CREATE DATABASE {}").format( - sql.Identifier('university')) - ) - print('db created') - except DuplicateDatabase: - print('db already exists') - pass - - -def createUsersTable(config): - """ Connect to the PostgreSQL database server """ - try: - # connecting to the PostgreSQL server - conn.autocommit = True - cur = conn.cursor() - cur.execute(sql.SQL("CREATE TABLE IF NOT EXISTS users(id int, name varchar(255), age int)")) - print('table created') - except (psycopg2.DatabaseError, Exception) as error: - print(error) - - -def selectUsers(config): - try: - cur = conn.cursor() - cur.execute(sql.SQL("SELECT * FROM users")) - return cur.fetchall() + with conn.cursor() as cur: + cur.execute("SELECT id, name, age FROM users ORDER BY id") + return cur.fetchall() except (psycopg2.DatabaseError, Exception) as error: print(error) return [] -def insertUsersData(config, values): - """ Connect to the PostgreSQL database server """ +def insert_users_data(conn, values): try: - conn.autocommit = True - cur = conn.cursor() + existing_users = select_users(conn) + if existing_users: + print("Users are already seeded.") + return - users = selectUsers(conn) - - if len(users) == 0: - args = ','.join(cur.mogrify("(%s,%s,%s)", i).decode('utf-8') - for i in values) - - # executing the sql statement - cur.execute("INSERT INTO users VALUES " + (args)) - print('inserted') - else: - print('already inserted') - print(users) + with conn.cursor() as cur: + args = ",".join(cur.mogrify("(%s,%s,%s)", value).decode("utf-8") for value in values) + cur.execute("INSERT INTO users (id, name, age) VALUES " + args) + print("Users inserted.") except (psycopg2.DatabaseError, Exception) as error: print(error) +def fetch_neo_movies(): + uri = NEO_HOST + auth = (NEO_USER, NEO_PASS) + + with GraphDatabase.driver(uri, auth=auth) as driver: + records, _, _ = driver.execute_query( + """ + MATCH (movie:Movie) + WHERE movie.released >= 1990 AND movie.released < 2000 + RETURN movie.title AS title + ORDER BY title + """ + ) + return [record["title"] for record in records] + + +def create_age_movies(cur): + cur.execute("CREATE EXTENSION IF NOT EXISTS age;") + cur.execute("LOAD 'age';") + cur.execute('SET search_path = ag_catalog, "$user", public;') + + cur.execute("SELECT 1 FROM ag_catalog.ag_graph WHERE name = %s", (AGE_GRAPH_NAME,)) + if cur.fetchone() is None: + cur.execute("SELECT * FROM ag_catalog.create_graph(%s)", (AGE_GRAPH_NAME,)) + + cur.execute( + sql.SQL("SELECT * FROM cypher({}, $$ {} $$) AS (seeded agtype)").format( + sql.Literal(AGE_GRAPH_NAME), + sql.SQL(''' + MERGE (matrix:Movie {title: 'The Matrix', released: 1999}) + MERGE (apollo:Movie {title: 'Apollo 13', released: 1995}) + MERGE (toy_story:Movie {title: 'Toy Story', released: 1995}) + RETURN matrix.title AS seeded + '''), + ) + ) + + +def select_age_movies(cur): + cur.execute( + sql.SQL("SELECT * FROM cypher({}, $$ {} $$) AS (title agtype)").format( + sql.Literal(AGE_GRAPH_NAME), + sql.SQL(''' + MATCH (movie:Movie) + WHERE movie.released >= 1990 AND movie.released < 2000 + RETURN movie.title AS title + ORDER BY title + '''), + ) + ) + return [str(row[0]).strip('"') for row in cur.fetchall()] + + +def load_users(): + admin_conn = connect_postgres("postgres") + try: + create_database(admin_conn, POSTGRES_DB) + finally: + admin_conn.close() + + app_conn = connect_postgres(POSTGRES_DB) + try: + create_users_table(app_conn) + insert_users_data(app_conn, USER_SEED_DATA) + return select_users(app_conn) + finally: + app_conn.close() + + class MainWindow(QMainWindow): - # Override class constructor def __init__(self): - # You must call the super class method - QMainWindow.__init__(self) + super().__init__() - self.setMinimumSize(QSize(640, 480)) # Set sizes - self.setWindowTitle("postgre") # Set the window title - central_widget = QWidget(self) # Create a central widget - self.setCentralWidget(central_widget) # Install the central widget + self.setMinimumSize(QSize(640, 480)) + self.setWindowTitle("postgres") - grid_layout = QGridLayout(self) # Create QGridLayout - central_widget.setLayout(grid_layout) # Set this layout in central widget + central_widget = QWidget(self) + self.setCentralWidget(central_widget) - self.table = QTableWidget(self) # Create a table - self.table.setColumnCount(3) # Set three columns - self.table.setRowCount(1) # and one row + grid_layout = QGridLayout(self) + central_widget.setLayout(grid_layout) + + self.table = QTableWidget(self) + self.table.setColumnCount(3) + self.table.setRowCount(0) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - # Set the table headers self.table.setHorizontalHeaderLabels(["ID", "Name", "Age"]) - - # Set the tooltips to headings - self.table.horizontalHeaderItem(0).setToolTip("ID ") - self.table.horizontalHeaderItem(1).setToolTip("Name ") - self.table.horizontalHeaderItem(2).setToolTip("Age ") - - # Set the alignment to the headers + self.table.horizontalHeaderItem(0).setToolTip("ID") + self.table.horizontalHeaderItem(1).setToolTip("Name") + self.table.horizontalHeaderItem(2).setToolTip("Age") self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(1).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(2).setTextAlignment(Qt.AlignHCenter) - # Do the resize of the columns by content - self.table.resizeColumnsToContents() - - grid_layout.addWidget(self.table, 0, 0) # Adding the table to the grid + grid_layout.addWidget(self.table, 0, 0) def load_data(self, users_data): - for i, user in enumerate(users_data): - row_number = self.table.rowCount()-1 - if len(users_data) != self.table.rowCount(): - self.table.insertRow(row_number) + self.table.setRowCount(len(users_data)) + for row_number, user in enumerate(users_data): self.table.setItem(row_number, 0, QTableWidgetItem(str(user[0]))) self.table.setItem(row_number, 1, QTableWidgetItem(str(user[1]))) self.table.setItem(row_number, 2, QTableWidgetItem(str(user[2]))) + self.table.resizeColumnsToContents() -class NeoWindow(QWidget): - def __init__(self, parent_window): +class GraphWindow(QWidget): + def __init__(self, title, parent_window): super().__init__() - self.setWindowTitle("neo4j") - self.resize(300, 250) + self.setWindowTitle(title) - grid_layout = QGridLayout(self) # Create QGridLayout - self.setLayout(grid_layout) # Set this layout in central widget + grid_layout = QGridLayout(self) + self.setLayout(grid_layout) self.table = QTableWidget(self) self.table.setColumnCount(1) - self.table.setRowCount(1) - + self.table.setRowCount(0) self.table.setHorizontalHeaderLabels(["Info"]) - - # Set the tooltips to headings self.table.horizontalHeaderItem(0).setToolTip("Info") - - # Set the alignment to the headers self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter) - - grid_layout.addWidget(self.table, 0, 0) # Adding the table to the grid - - # Position the window to the right of the parent window + grid_layout.addWidget(self.table, 0, 0) self.position_relative_to_parent(parent_window) def position_relative_to_parent(self, parent_window): - # Get the geometry of the parent window parent_frame = parent_window.frameGeometry() + self.move(parent_frame.x() + parent_frame.width() + 5, parent_frame.y()) - # Calculate new position (right of parent window with a small gap) - new_x = parent_frame.x() + parent_frame.width() + 5 - new_y = parent_frame.y() - - # Move the window to the new position - self.move(new_x, new_y) - - def load_data(self, neo_data): - for i, node_data in enumerate(neo_data): - row_number = self.table.rowCount()-1 - if len(neo_data) != self.table.rowCount(): - self.table.insertRow(row_number) - self.table.setItem(row_number, 0, QTableWidgetItem(str(node_data[0]["title"]))) - + def load_data(self, rows): + self.table.setRowCount(len(rows)) + for row_number, item in enumerate(rows): + self.table.setItem(row_number, 0, QTableWidgetItem(str(item))) self.table.resizeColumnsToContents() + self.table.resizeRowsToContents() + + margins = self.layout().contentsMargins() + width = ( + self.table.verticalHeader().width() + + self.table.columnWidth(0) + + self.table.frameWidth() * 2 + + margins.left() + + margins.right() + + 24 + ) + height = ( + self.table.horizontalHeader().height() + + sum(self.table.rowHeight(row) for row in range(self.table.rowCount())) + + self.table.frameWidth() * 2 + + margins.top() + + margins.bottom() + + 24 + ) + + self.resize(min(max(width, 220), 700), min(max(height, 120), 500)) + if __name__ == "__main__": - import sys - - conn = psycopg2.connect( - "user={0} password={1} host={2} port={3}".format( - POSTGRES_USER, - POSTGRES_PASS, - POSTGRES_HOST, - POSTGRES_PORT, - ) - ) - - createuUiversityDB(conn) - - conn = psycopg2.connect("user={0} dbname={1} password={2} host={3} port={4}".format(POSTGRES_USER, POSTGRES_DB, - POSTGRES_PASS, POSTGRES_HOST, POSTGRES_PORT)) - - createUsersTable(conn) - - values = [(1, 'Ivan', 15), (2, 'Igor', 22), (3, 'Alex', 16), (4, 'Anna', 40), (5, 'Inna', 30)] - - insertUsersData(conn, values) - - users = selectUsers(conn) + users = load_users() app = QApplication(sys.argv) - mw = MainWindow() - mw.load_data(users) - mw.show() + main_window = MainWindow() + main_window.load_data(users) + main_window.show() - if os.getenv('NEO_ACTIVE') == 'true': - URI = NEO_HOST - AUTH = (NEO_USER, NEO_PASS) + graph_windows = [] - with GraphDatabase.driver(URI, auth=AUTH) as driver: - records, summary, keys = driver.execute_query( - "MATCH (nineties:Movie) WHERE nineties.released >= 1990 AND nineties.released < 2000 RETURN nineties" - ) + if NEO_ACTIVE: + try: + neo_window = GraphWindow("neo4j", main_window) + neo_window.load_data(fetch_neo_movies()) + neo_window.show() + graph_windows.append(neo_window) + except Exception as error: + print(f"Neo4j load failed: {error}") - neo_window = NeoWindow(mw) - neo_window.load_data(records) - neo_window.show() + if AGE_ACTIVE: + try: + with psycopg2.connect( + dbname=AGE_DB, + user=AGE_USER, + password=AGE_PASS, + host=AGE_HOST, + port=AGE_PORT, + ) as age_conn: + age_conn.autocommit = True + + with age_conn.cursor() as cur: + create_age_movies(cur) + age_movies = select_age_movies(cur) + + age_window = GraphWindow("apache age", main_window) + age_window.load_data(age_movies) + age_window.show() + graph_windows.append(age_window) + except Exception as error: + print(f"Apache AGE load failed: {error}") sys.exit(app.exec()) \ No newline at end of file