add support Apache AGE

This commit is contained in:
2026-04-08 16:04:23 +03:00
parent dee23c76f7
commit dcf534a0fe
3 changed files with 253 additions and 179 deletions
+8
View File
@@ -8,3 +8,11 @@ NEO_ACTIVE=false
NEO_USER=neo4j NEO_USER=neo4j
NEO_PASS=pass NEO_PASS=pass
NEO_HOST=neo4j://localhost:7687 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
+12 -2
View File
@@ -1,12 +1,13 @@
### Пример приложения для работы с БД ### Пример приложения для работы с БД
Небольшое десктопприложение на PyQt5 для работы с PostgreSQL и Neo4j. Небольшое десктоп-приложение на PyQt5 для работы с PostgreSQL, Neo4j и Apache AGE.
### Стек ### Стек
- PyQt5 — графический интерфейс - PyQt5 — графический интерфейс
- PostgreSQL (psycopg2) — реляционная БД для пользователей - PostgreSQL (psycopg2) — реляционная БД для пользователей
- Neo4j — графовая БД для фильмов - Neo4j — графовая БД для фильмов
- Apache AGE — графовая модель поверх PostgreSQL для фильмов
- python-dotenv — загрузка параметров подключения из файла `.env` - python-dotenv — загрузка параметров подключения из файла `.env`
### Скриншоты ### Скриншоты
@@ -24,6 +25,7 @@
- Python 3.10+ - Python 3.10+
- Установленный PostgreSQL и доступ к серверу БД - Установленный PostgreSQL и доступ к серверу БД
- Установленный Neo4j и загруженный обучающий граф фильмов (Movie Graph example) - Установленный Neo4j и загруженный обучающий граф фильмов (Movie Graph example)
- Для Apache AGE: установленное расширение `age` в PostgreSQL и доступ к базе, где оно включено
### Настройка окружения ### Настройка окружения
@@ -43,6 +45,14 @@ NEO_ACTIVE=false # включить Neo4j-часть приложения,
NEO_USER=neo4j NEO_USER=neo4j
NEO_PASS=your_password NEO_PASS=your_password
NEO_HOST=neo4j://localhost:7687 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. Создайте и активируйте виртуальное окружение: 2. Создайте и активируйте виртуальное окружение:
@@ -77,4 +87,4 @@ pip install -r requirements.txt
py main.py py main.py
``` ```
Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их ещё нет), выполнит запрос к Neo4j (если активно) и отобразит результаты в двух окнах. Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их еще нет), выполнит запрос к Neo4j (если активно) и/или к Apache AGE (если активно), а затем отобразит результаты в отдельных окнах.
+232 -176
View File
@@ -1,245 +1,301 @@
import os
import sys
import psycopg2 import psycopg2
from dotenv import load_dotenv
from neo4j import GraphDatabase
from psycopg2 import sql from psycopg2 import sql
from psycopg2.errors import DuplicateDatabase 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.QtCore import QSize, Qt
from PyQt5.QtWidgets import QApplication, QGridLayout, QHeaderView, QMainWindow, QTableWidget, QTableWidgetItem, QWidget
import os
from dotenv import load_dotenv
load_dotenv() load_dotenv()
NEO_USER = os.getenv('NEO_USER') POSTGRES_USER = os.getenv("POSTGRES_USER")
NEO_PASS = os.getenv('NEO_PASS') POSTGRES_DB = os.getenv("POSTGRES_DB")
NEO_HOST = os.getenv('NEO_HOST') POSTGRES_PASS = os.getenv("POSTGRES_PASS")
POSTGRES_HOST = os.getenv("POSTGRES_HOST")
POSTGRES_PORT = os.getenv("POSTGRES_PORT")
POSTGRES_USER = os.getenv('POSTGRES_USER') NEO_ACTIVE = os.getenv("NEO_ACTIVE", "false").lower() == "true"
POSTGRES_DB = os.getenv('POSTGRES_DB') NEO_USER = os.getenv("NEO_USER")
POSTGRES_PASS = os.getenv('POSTGRES_PASS') NEO_PASS = os.getenv("NEO_PASS")
POSTGRES_HOST = os.getenv('POSTGRES_HOST') NEO_HOST = os.getenv("NEO_HOST")
POSTGRES_PORT = os.getenv('POSTGRES_PORT')
def connect(config): AGE_ACTIVE = os.getenv("AGE_ACTIVE", "false").lower() == "true"
""" Connect to the PostgreSQL database server """ 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: try:
# connecting to the PostgreSQL server with admin_conn.cursor() as cur:
with psycopg2.connect(**config) as conn: cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name)))
print('Connected to the PostgreSQL server.') print(f"Database '{database_name}' created.")
return conn 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: except (psycopg2.DatabaseError, Exception) as error:
print(error) print(error)
def createUsersDB(config): def select_users(conn):
""" Connect to the PostgreSQL database server """
try: try:
# connecting to the PostgreSQL server with conn.cursor() as cur:
conn.autocommit = True cur.execute("SELECT id, name, age FROM users ORDER BY id")
cur = conn.cursor() return cur.fetchall()
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()
except (psycopg2.DatabaseError, Exception) as error: except (psycopg2.DatabaseError, Exception) as error:
print(error) print(error)
return [] return []
def insertUsersData(config, values): def insert_users_data(conn, values):
""" Connect to the PostgreSQL database server """
try: try:
conn.autocommit = True existing_users = select_users(conn)
cur = conn.cursor() if existing_users:
print("Users are already seeded.")
return
users = selectUsers(conn) with conn.cursor() as cur:
args = ",".join(cur.mogrify("(%s,%s,%s)", value).decode("utf-8") for value in values)
if len(users) == 0: cur.execute("INSERT INTO users (id, name, age) VALUES " + args)
args = ','.join(cur.mogrify("(%s,%s,%s)", i).decode('utf-8') print("Users inserted.")
for i in values)
# executing the sql statement
cur.execute("INSERT INTO users VALUES " + (args))
print('inserted')
else:
print('already inserted')
print(users)
except (psycopg2.DatabaseError, Exception) as error: except (psycopg2.DatabaseError, Exception) as error:
print(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): class MainWindow(QMainWindow):
# Override class constructor
def __init__(self): def __init__(self):
# You must call the super class method super().__init__()
QMainWindow.__init__(self)
self.setMinimumSize(QSize(640, 480)) # Set sizes self.setMinimumSize(QSize(640, 480))
self.setWindowTitle("postgre") # Set the window title self.setWindowTitle("postgres")
central_widget = QWidget(self) # Create a central widget
self.setCentralWidget(central_widget) # Install the central widget
grid_layout = QGridLayout(self) # Create QGridLayout central_widget = QWidget(self)
central_widget.setLayout(grid_layout) # Set this layout in central widget self.setCentralWidget(central_widget)
self.table = QTableWidget(self) # Create a table grid_layout = QGridLayout(self)
self.table.setColumnCount(3) # Set three columns central_widget.setLayout(grid_layout)
self.table.setRowCount(1) # and one row
self.table = QTableWidget(self)
self.table.setColumnCount(3)
self.table.setRowCount(0)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
# Set the table headers
self.table.setHorizontalHeaderLabels(["ID", "Name", "Age"]) self.table.setHorizontalHeaderLabels(["ID", "Name", "Age"])
self.table.horizontalHeaderItem(0).setToolTip("ID")
# Set the tooltips to headings self.table.horizontalHeaderItem(1).setToolTip("Name")
self.table.horizontalHeaderItem(0).setToolTip("ID ") self.table.horizontalHeaderItem(2).setToolTip("Age")
self.table.horizontalHeaderItem(1).setToolTip("Name ")
self.table.horizontalHeaderItem(2).setToolTip("Age ")
# Set the alignment to the headers
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter)
self.table.horizontalHeaderItem(1).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(1).setTextAlignment(Qt.AlignHCenter)
self.table.horizontalHeaderItem(2).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(2).setTextAlignment(Qt.AlignHCenter)
# Do the resize of the columns by content grid_layout.addWidget(self.table, 0, 0)
self.table.resizeColumnsToContents()
grid_layout.addWidget(self.table, 0, 0) # Adding the table to the grid
def load_data(self, users_data): def load_data(self, users_data):
for i, user in enumerate(users_data): self.table.setRowCount(len(users_data))
row_number = self.table.rowCount()-1 for row_number, user in enumerate(users_data):
if len(users_data) != self.table.rowCount():
self.table.insertRow(row_number)
self.table.setItem(row_number, 0, QTableWidgetItem(str(user[0]))) 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, 1, QTableWidgetItem(str(user[1])))
self.table.setItem(row_number, 2, QTableWidgetItem(str(user[2]))) self.table.setItem(row_number, 2, QTableWidgetItem(str(user[2])))
self.table.resizeColumnsToContents()
class NeoWindow(QWidget): class GraphWindow(QWidget):
def __init__(self, parent_window): def __init__(self, title, parent_window):
super().__init__() super().__init__()
self.setWindowTitle("neo4j") self.setWindowTitle(title)
self.resize(300, 250)
grid_layout = QGridLayout(self) # Create QGridLayout grid_layout = QGridLayout(self)
self.setLayout(grid_layout) # Set this layout in central widget self.setLayout(grid_layout)
self.table = QTableWidget(self) self.table = QTableWidget(self)
self.table.setColumnCount(1) self.table.setColumnCount(1)
self.table.setRowCount(1) self.table.setRowCount(0)
self.table.setHorizontalHeaderLabels(["Info"]) self.table.setHorizontalHeaderLabels(["Info"])
# Set the tooltips to headings
self.table.horizontalHeaderItem(0).setToolTip("Info") self.table.horizontalHeaderItem(0).setToolTip("Info")
# Set the alignment to the headers
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter) self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter)
grid_layout.addWidget(self.table, 0, 0)
grid_layout.addWidget(self.table, 0, 0) # Adding the table to the grid
# Position the window to the right of the parent window
self.position_relative_to_parent(parent_window) self.position_relative_to_parent(parent_window)
def position_relative_to_parent(self, parent_window): def position_relative_to_parent(self, parent_window):
# Get the geometry of the parent window
parent_frame = parent_window.frameGeometry() 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) def load_data(self, rows):
new_x = parent_frame.x() + parent_frame.width() + 5 self.table.setRowCount(len(rows))
new_y = parent_frame.y() for row_number, item in enumerate(rows):
self.table.setItem(row_number, 0, QTableWidgetItem(str(item)))
# 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"])))
self.table.resizeColumnsToContents() 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__": if __name__ == "__main__":
import sys users = load_users()
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)
app = QApplication(sys.argv) app = QApplication(sys.argv)
mw = MainWindow() main_window = MainWindow()
mw.load_data(users) main_window.load_data(users)
mw.show() main_window.show()
if os.getenv('NEO_ACTIVE') == 'true': graph_windows = []
URI = NEO_HOST
AUTH = (NEO_USER, NEO_PASS)
with GraphDatabase.driver(URI, auth=AUTH) as driver: if NEO_ACTIVE:
records, summary, keys = driver.execute_query( try:
"MATCH (nineties:Movie) WHERE nineties.released >= 1990 AND nineties.released < 2000 RETURN nineties" 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) if AGE_ACTIVE:
neo_window.load_data(records) try:
neo_window.show() 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()) sys.exit(app.exec())